Skip to content

Commit b60a73d

Browse files
Replace userId-only iframe context with combined guardian-puzzle-context
Per confirmed PR review feedback: replace the userId-only query param/postMessage mechanism with a single, richer PuzzleContext carrying both the user's ID and whether dark mode is currently active. - src/components/PuzzleIframe.island.tsx: - New PuzzleContext type: { userId: string | null; darkMode: boolean }. userId is the same source as before (legacy_identity_id via getAuthStatus()) but now string | null (null for signed-out) rather than string | undefined, since it's now always a serialized object field rather than an omitted query param. - New PuzzleContextMessage type ({ type: 'guardian-puzzle-context', context }), replacing the old PuzzleUserMessage ({ type: 'guardian-puzzle-user', userId }). - New usePuzzleDarkMode(darkModeAvailable) hook: returns false immediately (without touching matchMedia at all) when darkModeAvailable is false; otherwise reuses the EXISTING generic src/lib/useMatchMedia.ts hook (already used elsewhere in DCR, e.g. ArticleMeta.web.tsx) to check - and stay reactively subscribed to - '(prefers-color-scheme: dark)', so it updates live if the reader switches their OS theme while the page is open. No new matchMedia wiring was invented; this is the same reactive mechanism DCR already has, just applied here. - buildPuzzleIframeSrc -> buildPuzzleIframeSrcWithContext: now encodes the whole PuzzleContext as JSON into a single ?guardian-puzzle-context=<encoded> query param, always included (the old mechanism omitted ?userId entirely when signed out; the new shape always carries both fields, so there's no "nothing to add" case). Still preserves existing query params (e.g. AmuseLabs' ?set=...&embed=1&idx=1) and still returns src unchanged if it can't be parsed as an absolute URL. - postUserMessage -> postContextMessage: posts the new PuzzleContextMessage shape on iframe load. Unchanged behaviour otherwise - src is derived reactively from both usePuzzleUserId() and usePuzzleDarkMode(), so a change in either sign-in state or OS colour scheme causes a fresh src and a natural iframe reload, with postMessage firing again after. - PuzzleIframe now takes a new required darkModeAvailable: boolean prop. - src/layouts/PuzzlePageLayout.tsx / src/components/PuzzlePage.tsx: threaded darkModeAvailable down from PuzzlePage.tsx's existing useConfig() (the same flag already passed to rootStyles() for the page chrome's own dark mode support) through PuzzlePageLayout to PuzzlePageContent to PuzzleIframe - no new source of truth introduced, reusing the config value that already existed for this exact purpose. - src/components/PuzzleIframe.island.test.tsx: rewritten (not just renamed) to cover the new context shape and, specifically, dark-mode reactivity: userId: null + darkMode: false while signed out with dark mode unavailable; userId populated once signed in; darkMode staying false when darkModeAvailable is false regardless of the OS preference (confirming prefers-color-scheme is never even consulted in that case); darkMode true only when both darkModeAvailable AND the (mocked) useMatchMedia result are true; a live-reactivity test simulating an OS-level colour-scheme change via a re-render with a new mocked useMatchMedia value, matching this codebase's existing convention of mocking useMatchMedia at the module boundary (see ArticleMeta.web.test.tsx, PuzzlePageLayout.test.tsx) rather than mocking window.matchMedia directly; postMessage now asserted with the new PuzzleContextMessage shape; auth-state-change subscription and unmount cleanup tests carried over, updated for the new context shape. - src/layouts/PuzzlePageLayout.test.tsx: added the new required darkModeAvailable prop (false, matching its existing ConfigProvider/darkModeAvailable: false setup). Verified manually against a live dev server: POSTing a fixture renders 200, and the server-rendered iframe src correctly contains guardian-puzzle-context=%7B%22userId%22%3Anull%2C%22darkMode%22%3Afalse%7D (decodes to {"userId":null,"darkMode":false}), the expected default before client-side hydration resolves the real auth/media-query state. tsc --noEmit clean, full-repo eslint clean, full test suite passing (175 suites / 1268 tests, no regressions). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 3a800b6 commit b60a73d

5 files changed

Lines changed: 286 additions & 86 deletions

File tree

dotcom-rendering/src/components/PuzzleIframe.island.test.tsx

Lines changed: 169 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
import { act, render, screen, waitFor } from '@testing-library/react';
22
import { getAuthStatus, subscribeToAuthStateChange } from '../lib/identity';
3-
import { buildPuzzleIframeSrc, PuzzleIframe } from './PuzzleIframe.island';
3+
import { useMatchMedia } from '../lib/useMatchMedia';
4+
import {
5+
buildPuzzleIframeSrcWithContext,
6+
type PuzzleContext,
7+
PuzzleIframe,
8+
} from './PuzzleIframe.island';
49

510
jest.mock('../lib/identity', () => ({
611
getAuthStatus: jest.fn(),
712
subscribeToAuthStateChange: jest.fn(),
813
}));
914

15+
jest.mock('../lib/useMatchMedia', () => ({
16+
useMatchMedia: jest.fn(() => false),
17+
}));
18+
1019
const mockedGetAuthStatus = jest.mocked(getAuthStatus);
1120
const mockedSubscribeToAuthStateChange = jest.mocked(
1221
subscribeToAuthStateChange,
1322
);
23+
const mockedUseMatchMedia = jest.mocked(useMatchMedia);
1424

1525
const signedIn = (legacyIdentityId: string) => ({
1626
kind: 'SignedIn' as const,
@@ -22,32 +32,49 @@ const signedIn = (legacyIdentityId: string) => ({
2232

2333
const signedOut = () => ({ kind: 'SignedOut' as const });
2434

25-
describe('buildPuzzleIframeSrc', () => {
26-
it('returns the src unchanged when there is no signed-in user', () => {
27-
expect(
28-
buildPuzzleIframeSrc('https://example.com/puzzle', undefined),
29-
).toBe('https://example.com/puzzle');
30-
});
35+
const contextParam = (context: PuzzleContext) =>
36+
`guardian-puzzle-context=${encodeURIComponent(JSON.stringify(context))}`;
3137

32-
it('appends userId as a query param when the src has none', () => {
38+
describe('buildPuzzleIframeSrcWithContext', () => {
39+
it('appends the context as a JSON query param when the src has none', () => {
40+
const context: PuzzleContext = { userId: null, darkMode: false };
3341
expect(
34-
buildPuzzleIframeSrc('https://example.com/puzzle', 'abc123'),
35-
).toBe('https://example.com/puzzle?userId=abc123');
42+
buildPuzzleIframeSrcWithContext(
43+
'https://example.com/puzzle',
44+
context,
45+
),
46+
).toBe(`https://example.com/puzzle?${contextParam(context)}`);
3647
});
3748

38-
it('preserves existing query params when appending userId', () => {
49+
it('preserves existing query params when appending the context', () => {
50+
const context: PuzzleContext = { userId: 'abc123', darkMode: true };
3951
expect(
40-
buildPuzzleIframeSrc(
52+
buildPuzzleIframeSrcWithContext(
4153
'https://example.com/puzzle?set=guardian-sudoku-easy&embed=1',
42-
'abc123',
54+
context,
4355
),
4456
).toBe(
45-
'https://example.com/puzzle?set=guardian-sudoku-easy&embed=1&userId=abc123',
57+
`https://example.com/puzzle?set=guardian-sudoku-easy&embed=1&${contextParam(context)}`,
4658
);
4759
});
4860

61+
it('always includes the context, even when signed out and dark mode is off', () => {
62+
const context: PuzzleContext = { userId: null, darkMode: false };
63+
expect(
64+
buildPuzzleIframeSrcWithContext(
65+
'https://example.com/puzzle',
66+
context,
67+
),
68+
).toContain('guardian-puzzle-context=');
69+
});
70+
4971
it('returns the src unchanged if it cannot be parsed as an absolute URL', () => {
50-
expect(buildPuzzleIframeSrc('not-a-url', 'abc123')).toBe('not-a-url');
72+
expect(
73+
buildPuzzleIframeSrcWithContext('not-a-url', {
74+
userId: null,
75+
darkMode: false,
76+
}),
77+
).toBe('not-a-url');
5178
});
5279
});
5380

@@ -58,42 +85,138 @@ describe('PuzzleIframe', () => {
5885
jest.resetAllMocks();
5986
unsubscribe = jest.fn();
6087
mockedSubscribeToAuthStateChange.mockReturnValue(unsubscribe);
88+
mockedUseMatchMedia.mockReturnValue(false);
6189
});
6290

63-
it('renders the iframe with the plain src while signed out', async () => {
91+
const getContextFromSrc = (src: string): PuzzleContext => {
92+
const url = new URL(src);
93+
return JSON.parse(
94+
url.searchParams.get('guardian-puzzle-context') ?? '{}',
95+
) as PuzzleContext;
96+
};
97+
98+
it('renders userId: null and darkMode: false while signed out with dark mode unavailable', async () => {
6499
mockedGetAuthStatus.mockResolvedValue(signedOut());
65100

66101
render(
67-
<PuzzleIframe src="https://example.com/puzzle" title="Puzzle" />,
102+
<PuzzleIframe
103+
src="https://example.com/puzzle"
104+
title="Puzzle"
105+
darkModeAvailable={false}
106+
/>,
68107
);
69108

70-
const iframe = await screen.findByTitle('Puzzle');
109+
const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle');
71110
await waitFor(() =>
72-
expect(iframe).toHaveAttribute('src', 'https://example.com/puzzle'),
111+
expect(getContextFromSrc(iframe.src)).toEqual({
112+
userId: null,
113+
darkMode: false,
114+
}),
73115
);
74116
});
75117

76-
it('adds the userId query param once signed in', async () => {
118+
it('includes the userId in the context once signed in', async () => {
77119
mockedGetAuthStatus.mockResolvedValue(signedIn('user-123'));
78120

79121
render(
80-
<PuzzleIframe src="https://example.com/puzzle" title="Puzzle" />,
122+
<PuzzleIframe
123+
src="https://example.com/puzzle"
124+
title="Puzzle"
125+
darkModeAvailable={false}
126+
/>,
81127
);
82128

83-
const iframe = await screen.findByTitle('Puzzle');
129+
const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle');
84130
await waitFor(() =>
85-
expect(iframe).toHaveAttribute(
86-
'src',
87-
'https://example.com/puzzle?userId=user-123',
88-
),
131+
expect(getContextFromSrc(iframe.src)).toEqual({
132+
userId: 'user-123',
133+
darkMode: false,
134+
}),
135+
);
136+
});
137+
138+
it('does not check prefers-color-scheme at all when darkModeAvailable is false', async () => {
139+
mockedGetAuthStatus.mockResolvedValue(signedOut());
140+
mockedUseMatchMedia.mockReturnValue(true);
141+
142+
render(
143+
<PuzzleIframe
144+
src="https://example.com/puzzle"
145+
title="Puzzle"
146+
darkModeAvailable={false}
147+
/>,
148+
);
149+
150+
const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle');
151+
await waitFor(() =>
152+
expect(getContextFromSrc(iframe.src).darkMode).toBe(false),
153+
);
154+
});
155+
156+
it('reports darkMode: true only when darkModeAvailable AND the OS/browser prefers dark', async () => {
157+
mockedGetAuthStatus.mockResolvedValue(signedOut());
158+
mockedUseMatchMedia.mockReturnValue(true);
159+
160+
render(
161+
<PuzzleIframe
162+
src="https://example.com/puzzle"
163+
title="Puzzle"
164+
darkModeAvailable={true}
165+
/>,
166+
);
167+
168+
const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle');
169+
await waitFor(() =>
170+
expect(getContextFromSrc(iframe.src).darkMode).toBe(true),
89171
);
90172
});
91173

92-
it('posts the user context to the iframe once loaded', async () => {
174+
it('reacts to the OS/browser colour-scheme preference changing while mounted', async () => {
175+
mockedGetAuthStatus.mockResolvedValue(signedOut());
176+
mockedUseMatchMedia.mockReturnValue(false);
177+
178+
const { rerender } = render(
179+
<PuzzleIframe
180+
src="https://example.com/puzzle"
181+
title="Puzzle"
182+
darkModeAvailable={true}
183+
/>,
184+
);
185+
186+
const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle');
187+
await waitFor(() =>
188+
expect(getContextFromSrc(iframe.src).darkMode).toBe(false),
189+
);
190+
191+
// Simulate useMatchMedia reacting to a live prefers-color-scheme
192+
// change (it is itself reactive via useSyncExternalStore) by
193+
// updating its mocked return value and re-rendering, mirroring how
194+
// a real OS theme switch would cause useMatchMedia to return a new
195+
// value and this component to re-render.
196+
mockedUseMatchMedia.mockReturnValue(true);
197+
rerender(
198+
<PuzzleIframe
199+
src="https://example.com/puzzle"
200+
title="Puzzle"
201+
darkModeAvailable={true}
202+
/>,
203+
);
204+
205+
await waitFor(() =>
206+
expect(getContextFromSrc(iframe.src).darkMode).toBe(true),
207+
);
208+
});
209+
210+
it('posts the puzzle context to the iframe once loaded', async () => {
93211
mockedGetAuthStatus.mockResolvedValue(signedIn('user-123'));
212+
mockedUseMatchMedia.mockReturnValue(true);
94213

95214
render(
96-
<PuzzleIframe src="https://example.com/puzzle" title="Puzzle" />,
215+
<PuzzleIframe
216+
src="https://example.com/puzzle"
217+
title="Puzzle"
218+
darkModeAvailable={true}
219+
/>,
97220
);
98221

99222
const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle');
@@ -108,24 +231,28 @@ describe('PuzzleIframe', () => {
108231
});
109232

110233
expect(postMessage).toHaveBeenCalledWith(
111-
{ type: 'guardian-puzzle-user', userId: 'user-123' },
234+
{
235+
type: 'guardian-puzzle-context',
236+
context: { userId: 'user-123', darkMode: true },
237+
},
112238
'*',
113239
);
114240
});
115241

116-
it('subscribes to auth state changes and updates the src if the user signs out', async () => {
242+
it('subscribes to auth state changes and updates the context if the user signs out', async () => {
117243
mockedGetAuthStatus.mockResolvedValueOnce(signedIn('user-123'));
118244

119245
render(
120-
<PuzzleIframe src="https://example.com/puzzle" title="Puzzle" />,
246+
<PuzzleIframe
247+
src="https://example.com/puzzle"
248+
title="Puzzle"
249+
darkModeAvailable={false}
250+
/>,
121251
);
122252

123-
const iframe = await screen.findByTitle('Puzzle');
253+
const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle');
124254
await waitFor(() =>
125-
expect(iframe).toHaveAttribute(
126-
'src',
127-
'https://example.com/puzzle?userId=user-123',
128-
),
255+
expect(getContextFromSrc(iframe.src).userId).toBe('user-123'),
129256
);
130257

131258
expect(mockedSubscribeToAuthStateChange).toHaveBeenCalledTimes(1);
@@ -138,15 +265,19 @@ describe('PuzzleIframe', () => {
138265
});
139266

140267
await waitFor(() =>
141-
expect(iframe).toHaveAttribute('src', 'https://example.com/puzzle'),
268+
expect(getContextFromSrc(iframe.src).userId).toBe(null),
142269
);
143270
});
144271

145272
it('unsubscribes from auth state changes on unmount', async () => {
146273
mockedGetAuthStatus.mockResolvedValue(signedOut());
147274

148275
const { unmount } = render(
149-
<PuzzleIframe src="https://example.com/puzzle" title="Puzzle" />,
276+
<PuzzleIframe
277+
src="https://example.com/puzzle"
278+
title="Puzzle"
279+
darkModeAvailable={false}
280+
/>,
150281
);
151282

152283
await screen.findByTitle('Puzzle');

0 commit comments

Comments
 (0)