Skip to content

Commit

Permalink
[ARGG-876] Add support for withScrimmedPortal with SSR (#3085)
Browse files Browse the repository at this point in the history
* Add support for withScrimmedPortal with SSR

* Update tests

* Remove runOnServer prop

* Update tests

* Update tests and comments
  • Loading branch information
anambl authored Nov 22, 2023
1 parent b7b9f27 commit 2fc97b4
Show file tree
Hide file tree
Showing 4 changed files with 53 additions and 95 deletions.
2 changes: 2 additions & 0 deletions packages/bpk-scrim-utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const BoxWithScrim = withScrim(Box);

The version using a [React portal](https://react.dev/reference/react-dom/createPortal) renders the wrapped component in a different part of the DOM. It also provides an `isPortalReady` prop to notify when the component inside the portal is ready to be used. This may be necessary to interact with the content of the component in a `useEffect` hook, for example to set the focus on mount.

The `withScrimmedPortal` works with SSR, as well as CSR. On the server, it renders a scrim to block users from interacting with the page and making it evident that the page is not interactive.

```js
import { withScrimmedPortal } from '@skyscanner/backpack-web/bpk-scrim-utils';

Expand Down

This file was deleted.

62 changes: 34 additions & 28 deletions packages/bpk-scrim-utils/src/withScrimmedPortal-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,33 +19,38 @@
import '@testing-library/jest-dom';
import { render, within, screen } from '@testing-library/react';
import { useEffect, useState } from 'react';
import { renderToString } from 'react-dom/server';

import withScrimmedPortal from './withScrimmedPortal';
import type { Props } from './withScrimmedPortal';

describe('withScrimmedPortal', () => {
it('renders the wrapped component inside a portal correctly with fallback to document.body', () => {
const DialogContent = () => <div>Dialog content</div>;
const DialogContent = () => <div data-testid="dialog-content">Dialog content</div>;
const ScrimmedComponent = withScrimmedPortal(DialogContent);

render(
<div id="pagewrap">
<div> Content hidden from AT</div>
<div data-testid="hidden"> Content hidden from AT</div>
<ScrimmedComponent
getApplicationElement={() => document.getElementById('pagewrap')}
/>
</div>
);
expect(document.body).toMatchSnapshot();

const hiddenElements = document.getElementById('pagewrap');

expect(document.body).toContainElement(screen.getByTestId('dialog-content'));
expect(within(hiddenElements as HTMLElement).queryByText('Wrapped Component')).toBeNull();
});

it('renders the wrapped component inside a portal with renderTarget provided', () => {
const WrappedComponent = () => <div>Wrapped Component</div>;
const ScrimmedComponent = withScrimmedPortal(WrappedComponent);
const DialogContent = () => <div data-testid="dialog-content">Dialog content</div>;
const ScrimmedComponent = withScrimmedPortal(DialogContent);
render(
<div>
<div id="pagewrap">
<div> Content hidden from AT</div>
<div data-testid="hidden"> Content hidden from AT</div>
<ScrimmedComponent
getApplicationElement={() => document.getElementById('pagewrap')}
renderTarget={() => document.getElementById('modal-container')}
Expand All @@ -54,27 +59,12 @@ describe('withScrimmedPortal', () => {
<div id="modal-container" />
</div>
);
expect(document.body).toMatchSnapshot();
});

it('renders the wrapped component outside the applicationElement', () => {
const WrappedComponent = () => <div>Wrapped Component</div>;
const ScrimmedComponent = withScrimmedPortal(WrappedComponent);
render(
<div>
<div id="pagewrap">
<div> Content hidden from AT</div>
<ScrimmedComponent
getApplicationElement={() => document.getElementById('pagewrap')}
renderTarget={() => document.getElementById('modal-container')}
/>
</div>
<div id="modal-container" />
</div>
);
const hiddenElements = document.getElementById('pagewrap');

const hiddenElements = document.getElementById('pagewrap');
expect(within(hiddenElements as HTMLElement).queryByText('Wrapped Component')).toBeNull();
expect(
document.getElementById('modal-container')
).toContainElement(screen.getByTestId('dialog-content'));
expect(within(hiddenElements as HTMLElement).queryByText('Wrapped Component')).toBeNull();
});

it('notifies the child component when the portal is ready', () => {
Expand All @@ -83,7 +73,7 @@ describe('withScrimmedPortal', () => {
useEffect(() => {
if (isPortalReady) {
setPortalStatus(`${portalStatus} portal is now ready`);
} else {
} else {
setPortalStatus(`${portalStatus} portal is not ready yet /`);
}
}, [isPortalReady]);
Expand All @@ -103,6 +93,22 @@ describe('withScrimmedPortal', () => {
</div>
);

expect(screen.getByText('Wrapped Component / portal is not ready yet / portal is now ready')).toBeInTheDocument();
expect(screen.getByText('Wrapped Component / portal is now ready')).toBeInTheDocument();
});
});

describe('Server Side Rendering', () => {
it('renders without crashing', () => {
const WrappedComponent = () => <div data-testid="dialog-content">Wrapped Component</div>;
const ScrimmedComponent = withScrimmedPortal(WrappedComponent);

expect(() => renderToString(
<div id="pagewrap">
<div> Content hidden from AT</div>
<ScrimmedComponent
getApplicationElement={() => document.getElementById('pagewrap')}
/>
</div>
)).not.toThrow();
});
});
21 changes: 17 additions & 4 deletions packages/bpk-scrim-utils/src/withScrimmedPortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';

import withScrim from './withScrim';
import BpkScrim from './BpkScrim';
import type { Props as ScrimProps } from './withScrim';

export type Props = ScrimProps & {
Expand All @@ -37,22 +38,34 @@ const getPortalElement = (target: (() => HTMLElement | null) | null | undefined)
if (document.body) {
return document.body;
}
throw new Error('Render target and fallback unavailable');
throw new Error('Render target and fallback unavailable.');
}

const withScrimmedPortal = (WrappedComponent: ComponentType<ScrimProps>) => {
const Scrimmed = withScrim(WrappedComponent);

const ScrimmedComponent = ({ renderTarget, ...rest}: Props) => {
const portalElement = getPortalElement(renderTarget);

const [isPortalReady, setIsPortalReady] = useState(false);

useEffect(() => {
setIsPortalReady(true);
}, []);

return createPortal(<Scrimmed {...rest} isPortalReady={isPortalReady} />, portalElement);
/**
* The following code runs only on the client - only once the component has been mounted.
*/
if (isPortalReady) {
const portalElement = getPortalElement(renderTarget);
return createPortal(<Scrimmed {...rest} isPortalReady={isPortalReady} />, portalElement);
}

/**
* The following code will run on both server and on the intial render on the client.
* This is to ensure the snapshotted markup (initial render before the component has been mounted) is the same on both server and client.
* This is the recommended approach from React for those cases that require rendering something different on the server and the client
* https://react.dev/reference/react-dom/hydrate#handling-different-client-and-server-content
*/
return <BpkScrim />;
}

return ScrimmedComponent;
Expand Down

0 comments on commit 2fc97b4

Please sign in to comment.