Skip to content

Commit 3900348

Browse files
authored
Support nested islands 🏝️ (#14205)
* Use context to track depth of nested islands * Change context to an object * Wrap hydrated components in `IslandProvider` to set context * Remove debug output * Rename config key * Add tests for Island context * Update test description
1 parent 91d2d06 commit 3900348

File tree

4 files changed

+117
-11
lines changed

4 files changed

+117
-11
lines changed

dotcom-rendering/src/client/islands/doHydration.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { isUndefined, log, startPerformanceMeasure } from '@guardian/libs';
55
import { createElement } from 'react';
66
import { hydrateRoot } from 'react-dom/client';
77
import { ConfigProvider } from '../../components/ConfigContext';
8+
import { IslandProvider } from '../../components/IslandContext';
89
import type { Config } from '../../types/configContext';
910

1011
declare global {
@@ -61,7 +62,11 @@ export const doHydration = async (
6162
element,
6263
<ConfigProvider value={config}>
6364
<CacheProvider value={emotionCache}>
64-
{createElement(module[name], data)}
65+
{/* Child islands should not be hydrated separately */}
66+
<IslandProvider value={{ isChild: true }}>
67+
{/* The component to hydrate must be a single JSX Element */}
68+
{createElement(module[name], data)}
69+
</IslandProvider>
6570
</CacheProvider>
6671
</ConfigProvider>,
6772
);

dotcom-rendering/src/components/Island.tsx

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { useContext } from 'react';
12
import type { ScheduleOptions, SchedulePriority } from '../lib/scheduler';
3+
import { IslandContext, IslandProvider } from './IslandContext';
24

35
type DeferredProps = {
46
visible: {
@@ -55,17 +57,29 @@ export const Island = ({ priority, defer, children, role }: IslandProps) => {
5557
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Type definitions on children are limited
5658
const name = String(children.type.name);
5759

60+
/**
61+
* Context tracks whether current island is a child of another
62+
*/
63+
const island = useContext(IslandContext);
64+
5865
return (
59-
<gu-island
60-
name={name}
61-
priority={priority}
62-
deferUntil={defer?.until}
63-
props={JSON.stringify(children.props)}
64-
rootMargin={rootMargin}
65-
data-spacefinder-role={role}
66-
>
67-
{children}
68-
</gu-island>
66+
<IslandProvider value={{ isChild: true }}>
67+
{/* Child islands defer to nearest parent island for hydration */}
68+
{island.isChild ? (
69+
children
70+
) : (
71+
<gu-island
72+
name={name}
73+
priority={priority}
74+
deferUntil={defer?.until}
75+
props={JSON.stringify(children.props)}
76+
rootMargin={rootMargin}
77+
data-spacefinder-role={role}
78+
>
79+
{children}
80+
</gu-island>
81+
)}
82+
</IslandProvider>
6983
);
7084
};
7185

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { render } from '@testing-library/react';
2+
import { Island } from './Island';
3+
4+
describe('IslandContext tracks nesting of islands', () => {
5+
test('Single island', () => {
6+
const { container } = render(
7+
<Island priority="feature" defer={{ until: 'visible' }}>
8+
<span>🏝️</span>
9+
</Island>,
10+
);
11+
const islands = container.querySelectorAll('gu-island');
12+
expect(islands.length).toBe(1);
13+
});
14+
15+
test('Nested island', () => {
16+
const { container } = render(
17+
<Island priority="feature" defer={{ until: 'visible' }}>
18+
<div>
19+
<Island priority="feature" defer={{ until: 'visible' }}>
20+
<span>🏝️</span>
21+
</Island>
22+
</div>
23+
</Island>,
24+
);
25+
const islands = container.querySelectorAll('gu-island');
26+
expect(islands.length).toBe(1);
27+
});
28+
29+
test('Multiple nested islands', () => {
30+
const { container } = render(
31+
<Island priority="critical">
32+
<div>
33+
<Island priority="critical">
34+
<div>
35+
<Island priority="critical">
36+
<span>🏝️</span>
37+
</Island>
38+
<Island priority="critical">
39+
<span>🏝️</span>
40+
</Island>
41+
</div>
42+
</Island>
43+
</div>
44+
</Island>,
45+
);
46+
const islands = container.querySelectorAll('gu-island');
47+
expect(islands.length).toBe(1);
48+
});
49+
50+
test('Parent island includes props for child islands', () => {
51+
const { container } = render(
52+
<Island priority="feature" defer={{ until: 'visible' }}>
53+
<div>
54+
<Island priority="feature" defer={{ until: 'visible' }}>
55+
<span className="archipelago">🏝️</span>
56+
</Island>
57+
</div>
58+
</Island>,
59+
);
60+
const island = container.querySelector('gu-island');
61+
expect(island).toHaveAttribute(
62+
'props',
63+
expect.stringContaining(
64+
'"props":{"className":"archipelago","children":"🏝️"}',
65+
),
66+
);
67+
});
68+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { createContext } from 'react';
2+
3+
type Config = {
4+
isChild: boolean;
5+
};
6+
7+
/**
8+
* Context to track whether the current island is a child of another island.
9+
* Child islands defer to the nearest parent island for hydration.
10+
*/
11+
export const IslandContext = createContext<Config>({ isChild: false });
12+
13+
export const IslandProvider = ({
14+
value,
15+
children,
16+
}: {
17+
value: Config;
18+
children: React.ReactNode;
19+
}) => <IslandContext.Provider value={value}>{children}</IslandContext.Provider>;

0 commit comments

Comments
 (0)