Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ARGG-876] Add support for withScrimmedPortal with SSR #3085

Merged
merged 5 commits into from
Nov 22, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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` runs by default only on client due to the use of the portal. However, it accepts the `runOnSSR` prop which, when passed, renders a scrim on the server 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.

68 changes: 59 additions & 9 deletions packages/bpk-scrim-utils/src/withScrimmedPortal-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,35 +17,36 @@
*/

import '@testing-library/jest-dom';
import { render, within, screen } from '@testing-library/react';
import { render, within, screen, isInaccessible } from '@testing-library/react';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is isInaccessible used somewhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh no, I'd added it, but then removed it
I'll remove this, I thought the linting would do that 😅

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();
expect(document.body).toContainElement(screen.getByTestId('dialog-content'));
});

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>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the difference between this test and the next one?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, yeah, you're right. Nothing really, I'll remove it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the difference between this test and the next one?

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,11 +55,13 @@ describe('withScrimmedPortal', () => {
<div id="modal-container" />
</div>
);
expect(document.body).toMatchSnapshot();
expect(
document.getElementById('modal-container')
).toContainElement(screen.getByTestId('dialog-content'));
});

it('renders the wrapped component outside the applicationElement', () => {
const WrappedComponent = () => <div>Wrapped Component</div>;
const WrappedComponent = () => <div data-testid="dialog-content">Wrapped Component</div>;
const ScrimmedComponent = withScrimmedPortal(WrappedComponent);
render(
<div>
Expand Down Expand Up @@ -105,4 +108,51 @@ describe('withScrimmedPortal', () => {

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

it('renders the wrapped component inside a portal correctly when runOnServer is false (default)', () => {
const DialogContent = () => <div data-testid="dialog-content">Dialog content</div>;
anambl marked this conversation as resolved.
Show resolved Hide resolved
const ScrimmedComponent = withScrimmedPortal(DialogContent);

render(
<div id="pagewrap">
<div> Content hidden from AT</div>
<ScrimmedComponent
getApplicationElement={() => document.getElementById('pagewrap')}
runOnServer={false}
/>
</div>
);
expect(document.body).toContainElement(screen.getByTestId('dialog-content'));
});

it('renders the wrapped component inside a portal correctly when runOnServer is true', () => {
const DialogContent = () => <div data-testid="dialog-content">Dialog content</div>;
const ScrimmedComponent = withScrimmedPortal(DialogContent);

render(
<div id="pagewrap">
<div> Content hidden from AT</div>
<ScrimmedComponent
anambl marked this conversation as resolved.
Show resolved Hide resolved
getApplicationElement={() => document.getElementById('pagewrap')}
runOnServer
/>
</div>
);
expect(document.body).toContainElement(screen.getByTestId('dialog-content'));
});

it('throws an error when runOnServer is false and component is rendered on the server', () => {
anambl marked this conversation as resolved.
Show resolved Hide resolved
const DialogContent = () => <div>Dialog content</div>;
const ScrimmedComponent = withScrimmedPortal(DialogContent);

expect(() => renderToString(
<div id="pagewrap">
<div> Content hidden from AT</div>
<ScrimmedComponent
getApplicationElement={() => document.getElementById('pagewrap')}
runOnServer={false}
/>
</div>
)).toThrow();
});
});
27 changes: 22 additions & 5 deletions packages/bpk-scrim-utils/src/withScrimmedPortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@ 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 & {
renderTarget?: (() => HTMLElement | null) | null;
/**
* If true, the component will run on both server and client.
*/
runOnServer?: boolean;
};

const getPortalElement = (target: (() => HTMLElement | null) | null | undefined) => {
Expand All @@ -37,22 +42,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. The ScrimmedComponent uses a portal making it unsuitable for SSR. If you are trying to render on SSR, please pass the `runOnServer` prop.');
}

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

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

const ScrimmedComponent = ({ renderTarget, runOnServer = false, ...rest}: Props) => {
const [isPortalReady, setIsPortalReady] = useState(false);

useEffect(() => {
setIsPortalReady(true);
}, []);
Comment on lines 50 to 52
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given all the discussion regarding double render and the general strategy being taken here, I recommend linking to the react docs: https://react.dev/reference/react-dom/hydrate#handling-different-client-and-server-content and popping some of the context from the comments directly into code.


return createPortal(<Scrimmed {...rest} isPortalReady={isPortalReady} />, portalElement);
/**
* The following code runs only on the client.
* If consumer passes runOnServer as false (meaning they want to render the component on client only), we render the component as normal.
* If consumer passes runOnServer as true (meaning they want to render the component on server and client), we render the component as normal once the component has been mounted.
* This is to ensure the snapshotted markup (initial render before the component has been mounted) is the same on both server and client.
*/
if (!runOnServer || isPortalReady) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would we expect a consumer to be aware of where the component is being rendered (server or browser) and act accordingly to pass a prop in? If so, this is a code smell to me.

Modern web frameworks like Remix have no real concept of "running on server" or "running on browser". The expectation is all components are executed in both environments and it's common practice for components to be required to be self aware of the where they are being rendered, and thus act accordingly.

What's the reason we don't want this to be self aware?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modern web frameworks like Remix have no real concept of "running on server" or "running on browser". The expectation is all components are executed in both environments

I didn't know that, but I think that's still ok as then you just pass the runOnServer prop which means it runs on both (not just server)? we can make that the default instead (this component is not used anywhere, so we have the freedom of making any change to it).

and it's common practice for components to be required to be self aware of the where they are being rendered, and thus act accordingly.

How can we tell if a component is running on server?
I guess my thoughts here were that consumers can choose whether they want to render on both and use the BpkScrim on server, OR just render it on client.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't know that, but I think that's still ok as then you just pass the runOnServer prop which means it runs on both (not just server)?

Then the naming here should be improved, runOnServer suggests only the server.

But it still raises the question, do we need flexibility here? Why can't this component decide what should happen depending on the environment?

How can we tell if a component is running on server?

common practice is to check for the presence of window, but it depends what the requirements are. If we need the document to be available, we should check for that.

if (typeof window === 'undefined')

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

totally agree with Chris and probably that's the source of my confusion in the other comment

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then the naming here should be improved, runOnServer suggests only the server.

I thought we always run on either just client, or both client and server so it's clear that this will run on both (although also added a comment above to explain it)

But it still raises the question, do we need flexibility here? Why can't this component decide what should happen depending on the environment?

The component does decide what should happen depending on the env, I just didn't want to force the BpkScrim on the initial render on the client unless required i.e. to match the server initial render also thinking whether maybe in the future we want to render something else on the server, not the BpkScrim

Maybe this comment helps here https://github.com/Skyscanner/backpack/pull/3085/files#r1395443998

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do all our microsites run on both client and server completely?

There's a big mix. Banana is CSR only. Falcon and hotels do SSR. OCs SSR the HTML and deliver it when requested.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with previous comments here, replacing runOnServer prop and it's associated behaviour with if (typeof window === 'undefined') should do the trick 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yes, that'll work too, we don't need the prop

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed the prop, although just to clarify - the point of the runOnServer prop was to set the expectation whether the component will run only on client only or if it'll run on both. I think using the window object helps us determine whether at run time it's running on server or on client, but I think achieving the same as the prop (i.e. if the intent is for the component to run on both client and server, or just on client so that for client-only projects we don't need to first render the scrim) makes it confusing as when it's running on client we'd need to check if it's already ran on server 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we'd need to check if it's already ran on server

Can you explain what you mean by this? I don't quite follow

anambl marked this conversation as resolved.
Show resolved Hide resolved
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 when consumers opts for both SSR and CSR i.e. by passing the `runOnServer` prop.
*/
return <BpkScrim />;
}

return ScrimmedComponent;
Expand Down
Loading