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

🏝 React Islands #3629

Merged
merged 19 commits into from
Dec 16, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Improved Partial Hydration

## Context

We already had a solution for partial hydration in DCR but the developer experience was poor and it was not scaling well.

### What is Partial Hydration?

Partial hydration (aka React Islands) is the name for the technique where, instead of hydrating the entire page, you target specific parts using ids or markers in an effort to reduce the amount of work needed on the client.

See: https://addyosmani.com/blog/rehydration/

### Before

We used App.tsx which was a global react app that let us have shared state between isands. For lazy loading we employed React Loadable Components. From there, we used a component called `HydrateOnce` which was designed to only execute once even thought the global react was triggering re-renders as state changed.

This was problematic because sometimes you had dependencies on global state so to get around this we added the `waitFor` prop.

To create a new island you had to:

1. Wrap your component on the server with a div with an manually gererated id, like 'my-thing-root'
2. Update the `LoadableComponents` type in index.d.ts
3. Update `document.tsx` to create the chunk
4. Insert the `loadable` statement at the top of `App.tsx`
5. Extract the props needed for hydration from the global CAPI object
6. Create an entry in the return of `App.tsx` to call `HydrateOnce` using the extracted props

### After

[This PR](https://github.com/guardian/dotcom-rendering/pull/3629) simplifies the process of partial hydration by moving the logic out of a React app and into a simple script tag. This removes the need to manage re-renders, can use standard dynamic imports and reduces the effort needed to use.

To create a new island you now:

1. Wrap you component on the server with `Hydrate`.
2. Add `.importable` to the component filname. Eg: `[MyThing].importable.tsx`

This is simpler to use, harder to make mistakes with and is certain to only ever send the data to the client that is required.
11 changes: 11 additions & 0 deletions dotcom-rendering/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1062,3 +1062,14 @@ interface PerformanceEntry {
loadTime: number;
renderTime: number;
}

declare namespace JSX {
interface IntrinsicElements {
'gu-hydrate': {
name: string;
when?: 'immediate' | 'idle' | 'visible';
props: any;
children: React.ReactNode;
};
}
}
1 change: 1 addition & 0 deletions dotcom-rendering/scripts/test/build-check.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const fileExists = async (glob) => {
'bootCmp',
'ga',
'ophan',
'hydration',
'react',
'dynamicImport',
'atomIframe',
Expand Down
1 change: 1 addition & 0 deletions dotcom-rendering/scripts/webpack/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ module.exports = ({ isLegacyJS }) => ({
bootCmp: scriptPath('bootCmp'),
ga: scriptPath('ga'),
ophan: scriptPath('ophan'),
hydration: scriptPath('hydration'),
react: scriptPath('react'),
dynamicImport: scriptPath('dynamicImport'),
atomIframe: scriptPath('atomIframe'),
Expand Down
33 changes: 33 additions & 0 deletions dotcom-rendering/src/web/browser/hydration/doHydration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { hydrate, h } from 'preact';
import { initPerf } from '../initPerf';

/**
* This function dynamically imports and then hydrates a specific component in
* a specific part of the page
*
* @param name The name of the component we want to hydrate
* @param data The deserialised props we want to use for hydration
* @param marker The location on the DOM where the component to hydrate exists
*/
export const doHydration = (name: string, data: any, marker: HTMLElement) => {
const { start, end } = initPerf(`hydrate-${name}`);
start();
import(
/* webpackInclude: /\.importable\.(tsx|jsx)$/ */
`../../components/${name}.importable`
)
.then((module) => {
hydrate(h(module[name], data), marker);
oliverlloyd marked this conversation as resolved.
Show resolved Hide resolved
marker.setAttribute('data-gu-hydrated', 'true');
end();
})
.catch((error) => {
if (name && error.message.includes(name)) {
console.error(
`🚨 Error importing ${name}. Did you forget to use the [MyComponent].importable.tsx naming convention? 🚨`,
);
}
throw error;
});
};
17 changes: 17 additions & 0 deletions dotcom-rendering/src/web/browser/hydration/getName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* getName takes the given html element and returns its name attibute
*
* We expect the element to always be a `gu-*` custom element
Copy link
Contributor

@SiAdcock SiAdcock Nov 15, 2021

Choose a reason for hiding this comment

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

For future reference (and for reviewers in a hurry!) what is the role and significance of gu-* custom elements?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

They're cooler 😉

Maybe they also provide a nice separation from standard elements as a way to highlight these markers as not being part of the semantic dom structure?

Copy link
Contributor

Choose a reason for hiding this comment

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

Okay thanks for clarifying that, I agree it flags up the hydratable markup better than a class. I'm wondering if the naming scales well with some possible future in which many of our components are custom elements. Do you think we should choose a name that implies these elements need to be hydrated on the client?

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'm wondering if the naming scales well with some possible future in which many of our components are custom elements

Good point. It's worth pointing out I had in mind using gu-hydrate + gu-portal at some point soon, hence the asterisk

*
* @param marker : The html element that we want to read the name attribute from;
* @returns
*/
export const getName = (marker: HTMLElement): string | null => {
const name = marker.getAttribute('name');
if (!name) {
console.error(
`🚨 Error - no name attribute supplied. We need name to know what component to import 🚨`,
);
}
return name;
};
21 changes: 21 additions & 0 deletions dotcom-rendering/src/web/browser/hydration/getProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* getProps takes the given html element and returns its props attibute
*
* We expect the element to always be a `gu-*` custom element
*
* @param marker : The html element that we want to read the props attribute from;
* @returns
*/
export const getProps = (marker: HTMLElement): Record<string, unknown> => {
const serialised = marker.getAttribute('props');
let props: Record<string, unknown>;
try {
props = serialised && JSON.parse(serialised);
} catch (error: unknown) {
console.error(
`🚨 Error parsing props. Is this data serialisable? ${serialised} 🚨`,
);
throw error;
}
return props;
};
57 changes: 57 additions & 0 deletions dotcom-rendering/src/web/browser/hydration/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import '../webpackPublicPath';

import { startup } from '@root/src/web/browser/startup';
import { log } from '@guardian/libs';
import { whenVisible } from './whenVisible';
import { whenIdle } from './whenIdle';
import { doHydration } from './doHydration';
import { getName } from './getName';
import { getProps } from './getProps';

const init = () => {
/**
* Partial Hydration
*
* The code here looks for parts of the dom that have been marked using the `gu-hydrate`
* marker, hydrating each one using the following properties:
*
* when - Used to optionally defer hydration
* name - The name of the component. Used to dynamically import the code
* props - The data for the component that has been serialised in the dom
* marker - The `gu-hydrate` custom element which is wrapping the content
*/
const hydrationMarkers = document.querySelectorAll('gu-hydrate');
hydrationMarkers.forEach((marker) => {
if (marker instanceof HTMLElement) {
const name = getName(marker);
const props = getProps(marker);

if (!name) return;
log('dotcom', `Hydrating ${name}`);

const when = marker.getAttribute('when');
switch (when) {
case 'idle': {
whenIdle(() => {
doHydration(name, props, marker);
});
break;
}
case 'visible': {
whenVisible(marker, () => {
doHydration(name, props, marker);
});
break;
}
case 'immediate':
default: {
doHydration(name, props, marker);
}
}
}
});

return Promise.resolve();
};

startup('hydration', null, init);
12 changes: 12 additions & 0 deletions dotcom-rendering/src/web/browser/hydration/whenIdle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* whenIdle exectures the given callback when the browser is 'idle'
*
* @param callback Fired when requestIdleCallback runs. If requestIdleCallback is not available after 300ms
*/
export const whenIdle = (callback: () => void) => {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(callback, { timeout: 500 });
} else {
setTimeout(callback, 300);
}
};
22 changes: 22 additions & 0 deletions dotcom-rendering/src/web/browser/hydration/whenVisible.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Use this function to delay execution of something until an element is visible
* in the viewport
*
* @param element : The html element that we want to observe;
* @param callback : This is fired when the element is visible in the viewport
*/
export const whenVisible = (element: HTMLElement, callback: () => void) => {
if ('IntersectionObserver' in window) {
const io = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
// Disconnect this IntersectionObserver once seen
io.disconnect();
callback();
});

io.observe(element);
} else {
// IntersectionObserver is not supported so failover to calling back at the end of the call stack
setTimeout(() => callback(), 0);
}
};
28 changes: 28 additions & 0 deletions dotcom-rendering/src/web/components/Hydrate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
interface HydrateProps {
when?: 'immediate' | 'idle' | 'visible';
children: JSX.Element;
}

/**
* Hydrates a component in the client by async loading the exported component.
*
* Note. The component being hydrated must follow the [MyComponent].importable.tsx
* namimg convention
*
* @param when - When hydration should take place.
* - immediate - Hydrate without delay
* - idle - Hydrate when browser idle
* - visible - Hydrate when component appears in viewport
* @param children - What you want hydrated.
*
*/
export const Hydrate = ({ when = 'immediate', children }: HydrateProps) => (
<gu-hydrate
name={children.type.name}
when={when}
props={JSON.stringify(children.props)}
>
{children}
</gu-hydrate>
);
1 change: 1 addition & 0 deletions dotcom-rendering/src/web/server/document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ export const document = ({ data }: Props): string => {
pageHasNonBootInteractiveElements && {
src: `${ASSET_ORIGIN}static/frontend/js/curl-with-js-and-domReady.js`,
},
...getScriptArrayFromChunkName('hydration'),
...arrayOfLoadableScriptObjects, // This includes the 'react' entry point
].filter(isDefined), // We use the TypeGuard to keep TS happy
);
Expand Down