@akinon/pz-theme 2.0.115-rc.0 → 2.0.116-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,8 +5,10 @@ import NextLink from 'next/link';
5
5
  import { usePathname } from 'next/navigation';
6
6
  import { useGetMenuQuery } from '@akinon/next/data/client/misc';
7
7
  import { getResponsiveValue, partAttrs, resolveForceOpen } from '../utils';
8
+ import { useMenuSeed } from '../menu-seed-context';
8
9
  import { BlockRendererProps } from './block-renderer-registry';
9
10
  import { hasItemTemplate, ItemTemplateList } from './item-template';
11
+ import { navMenuQueryArgs } from './menu-query-args';
10
12
  import {
11
13
  MenuSeparator,
12
14
  MenuTrailingFill,
@@ -53,12 +55,9 @@ const NavMenuBlock = ({
53
55
  const pathname = usePathname();
54
56
  const [openKey, setOpenKey] = useState<string | null>(null);
55
57
 
56
- const depth = Number(
57
- getResponsiveValue(block.properties?.depth, currentBreakpoint, 1)
58
- );
59
- const parent = block.properties?.parent
60
- ? String(getResponsiveValue(block.properties.parent, currentBreakpoint, ''))
61
- : undefined;
58
+ // Shared with the server's menu collector, so the key it seeded under is the
59
+ // key this block looks up (see menu-query-args.ts).
60
+ const menuArgs = navMenuQueryArgs(block.properties, currentBreakpoint);
62
61
  const orientation = String(
63
62
  getResponsiveValue(
64
63
  block.properties?.orientation,
@@ -143,10 +142,22 @@ const NavMenuBlock = ({
143
142
  return current === target;
144
143
  };
145
144
 
146
- const { data: menu, isLoading } = useGetMenuQuery({
147
- depth: Number.isFinite(depth) && depth > 0 ? depth : 1,
148
- ...(parent ? { parent } : {})
149
- } as Parameters<typeof useGetMenuQuery>[0]);
145
+ // A server-fetched menu is render input, not cache: the server HTML and the
146
+ // first client render both draw it, and the request is skipped. Without one
147
+ // (no server render, a failed fetch, a depth the server did not see) the
148
+ // block fetches on the client exactly as it always has.
149
+ //
150
+ // The seed wins whenever there is one. A skipped RTK query does not clear
151
+ // `data` (1.9.7 and 2.x alike): it keeps the last result of whatever args it
152
+ // ran with before. So a block moved from an unseeded key back to a seeded
153
+ // one (a designer undo, a breakpoint switch) would otherwise keep drawing
154
+ // the menu it fetched for the OTHER key.
155
+ const seeded = useMenuSeed(menuArgs);
156
+ const { data: fetched, isLoading } = useGetMenuQuery(
157
+ menuArgs as Parameters<typeof useGetMenuQuery>[0],
158
+ { skip: seeded !== undefined }
159
+ );
160
+ const menu = seeded !== undefined ? seeded : fetched;
150
161
 
151
162
  const allItems: MenuItemLike[] = Array.isArray(menu) ? menu : [];
152
163
  const items =
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  CHROME_MODE_ATTRIBUTE,
3
3
  CHROME_MODE_DOCKED,
4
+ chromeModeAttrs,
4
5
  setChromeMode
5
6
  } from '../chrome-mode';
6
7
 
@@ -45,8 +46,11 @@ describe('stamping', () => {
45
46
  });
46
47
 
47
48
  it('writes to body rather than to the chrome slot', () => {
48
- // The selector is `body[data-chrome-mode~="docked"] &`. Stamping the slot
49
- // instead would match nothing at all, silently.
49
+ // This is the selector's `body[data-chrome-mode~="docked"] &` branch — the
50
+ // one the designer pin, a browser without `:has` and hand-written CSS all
51
+ // match. The element attribute the `:has` branch reads is rendered by the
52
+ // header (`chromeModeAttrs`), never written here; stamping the slot from
53
+ // this function would leave the body branch matching nothing, silently.
50
54
  document.body.innerHTML = '<div data-chrome-slot="header"></div>';
51
55
 
52
56
  setChromeMode(CHROME_MODE_DOCKED);
@@ -172,3 +176,32 @@ describe('on the server', () => {
172
176
  expect(typeof document).toBe('object');
173
177
  });
174
178
  });
179
+
180
+ describe('chromeModeAttrs', () => {
181
+ it('renders the docked token under the attribute the selector reads', () => {
182
+ expect(chromeModeAttrs(CHROME_MODE_DOCKED)).toEqual({
183
+ [CHROME_MODE_ATTRIBUTE]: 'docked'
184
+ });
185
+ });
186
+
187
+ it('renders nothing at all for overlay, not an empty attribute', () => {
188
+ // Absent is what overlay has always meant; an empty value would also fail
189
+ // the `~="docked"` token match, but it reads as a bug in devtools.
190
+ expect(chromeModeAttrs(null)).toEqual({});
191
+ });
192
+
193
+ it('never touches the document, so a server-rendered header may call it', () => {
194
+ chromeModeAttrs(CHROME_MODE_DOCKED);
195
+
196
+ expect(mode()).toBeNull();
197
+ });
198
+
199
+ it('hands back a fresh object per call', () => {
200
+ const first = chromeModeAttrs(CHROME_MODE_DOCKED);
201
+ first['data-extra'] = 'mine';
202
+
203
+ expect(chromeModeAttrs(CHROME_MODE_DOCKED)).toEqual({
204
+ [CHROME_MODE_ATTRIBUTE]: 'docked'
205
+ });
206
+ });
207
+ });
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The server's menu fetch for nav-menu blocks: what it asks for, and that no
3
+ * failure of it can do worse than today's client fetch.
4
+ *
5
+ * A key the loader leaves out is not an error — the block finds no seed and
6
+ * requests the menu on the client, as it always did. So every failure mode
7
+ * here (a throw, a non-array answer, a core without `getMenu`) has to end as
8
+ * an ABSENT key, never as an empty row that would suppress that request.
9
+ */
10
+ jest.mock('server-only', () => ({}), { virtual: true });
11
+
12
+ const getMenu = jest.fn();
13
+ // Flipped off to model an old core whose server data layer has no reader.
14
+ let getMenuExported = true;
15
+
16
+ jest.mock('@akinon/next/data/server', () => ({
17
+ get getMenu() {
18
+ return getMenuExported
19
+ ? (...args: unknown[]) =>
20
+ (getMenu as (...a: unknown[]) => unknown)(...args)
21
+ : undefined;
22
+ }
23
+ }));
24
+
25
+ import { loadMenuSeeds, MENU_SEED_TIMEOUT_MS } from '../menu-seed-source';
26
+
27
+ const block = (
28
+ type: string,
29
+ properties: Record<string, unknown> = {},
30
+ over: Record<string, unknown> = {}
31
+ ): any => ({
32
+ id: `${type}-1`,
33
+ type,
34
+ label: type,
35
+ order: 0,
36
+ hidden: false,
37
+ value: '',
38
+ properties,
39
+ styles: {},
40
+ blocks: [],
41
+ ...over
42
+ });
43
+
44
+ const section = (blocks: any[], over: Record<string, unknown> = {}): any => ({
45
+ id: 'header-main',
46
+ type: 'default',
47
+ name: 'header',
48
+ label: 'header',
49
+ properties: {},
50
+ styles: {},
51
+ blocks,
52
+ order: 0,
53
+ hidden: false,
54
+ ...over
55
+ });
56
+
57
+ const rows = [{ pk: 1, label: 'Women', url: '/women', children: [] }];
58
+
59
+ beforeEach(() => {
60
+ getMenu.mockReset();
61
+ getMenuExported = true;
62
+ });
63
+
64
+ describe('loadMenuSeeds', () => {
65
+ it('makes no request for a composition without a nav-menu', async () => {
66
+ const seeds = await loadMenuSeeds([
67
+ section([block('text'), block('mega-menu'), block('hamburger-menu')])
68
+ ]);
69
+
70
+ expect(seeds).toEqual({});
71
+ expect(getMenu).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it('makes no request for no sections', async () => {
75
+ expect(await loadMenuSeeds([])).toEqual({});
76
+ expect(await loadMenuSeeds(null)).toEqual({});
77
+ expect(getMenu).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('seeds each distinct menu under its key, asking with the block args', async () => {
81
+ getMenu.mockImplementation(async ({ depth }: { depth: number }) =>
82
+ depth === 1 ? rows : [{ pk: 2, label: 'Deep', url: '/deep' }]
83
+ );
84
+
85
+ const seeds = await loadMenuSeeds([
86
+ section([block('nav-menu', { depth: { desktop: 1, mobile: 2 } })])
87
+ ]);
88
+
89
+ expect(getMenu).toHaveBeenCalledTimes(2);
90
+ expect(getMenu).toHaveBeenCalledWith({ depth: 1 });
91
+ expect(getMenu).toHaveBeenCalledWith({ depth: 2 });
92
+ expect(seeds).toEqual({
93
+ '1|': rows,
94
+ '2|': [{ pk: 2, label: 'Deep', url: '/deep' }]
95
+ });
96
+ });
97
+
98
+ it('passes the parent through, and keys by it', async () => {
99
+ getMenu.mockResolvedValue(rows);
100
+
101
+ const seeds = await loadMenuSeeds([
102
+ section([block('nav-menu', { depth: 2, parent: 'women' })])
103
+ ]);
104
+
105
+ expect(getMenu).toHaveBeenCalledWith({ depth: 2, parent: 'women' });
106
+ expect(seeds).toEqual({ '2|women': rows });
107
+ });
108
+
109
+ it('keeps an empty menu — it is the answer the client would get too', async () => {
110
+ getMenu.mockResolvedValue([]);
111
+
112
+ expect(await loadMenuSeeds([section([block('nav-menu')])])).toEqual({
113
+ '1|': []
114
+ });
115
+ });
116
+
117
+ it('leaves a rejected request out, without failing the others', async () => {
118
+ getMenu.mockImplementation(async ({ depth }: { depth: number }) => {
119
+ if (depth === 2) throw new Error('menus down');
120
+ return rows;
121
+ });
122
+
123
+ const seeds = await loadMenuSeeds([
124
+ section([block('nav-menu', { depth: { desktop: 1, mobile: 2 } })])
125
+ ]);
126
+
127
+ expect(seeds).toEqual({ '1|': rows });
128
+ expect('2|' in seeds).toBe(false);
129
+ });
130
+
131
+ it.each([
132
+ ['undefined', undefined],
133
+ ['null', null],
134
+ ['an unwrapped response object', { menu: rows }],
135
+ ['a string', 'error']
136
+ ])('leaves the key out when the answer is %s', async (_name, answer) => {
137
+ getMenu.mockResolvedValue(answer);
138
+
139
+ const seeds = await loadMenuSeeds([section([block('nav-menu')])]);
140
+
141
+ expect(seeds).toEqual({});
142
+ });
143
+
144
+ it('gives up on a menu that outlasts the timeout, keeping the ones that answered', async () => {
145
+ jest.useFakeTimers();
146
+ try {
147
+ getMenu.mockImplementation(({ depth }: { depth: number }) =>
148
+ depth === 2 ? new Promise(() => undefined) : Promise.resolve(rows)
149
+ );
150
+
151
+ const pending = loadMenuSeeds([
152
+ section([block('nav-menu', { depth: { desktop: 1, mobile: 2 } })])
153
+ ]);
154
+ await jest.advanceTimersByTimeAsync(MENU_SEED_TIMEOUT_MS);
155
+
156
+ expect(await pending).toEqual({ '1|': rows });
157
+ } finally {
158
+ jest.useRealTimers();
159
+ }
160
+ });
161
+
162
+ it('keeps a menu that answers just inside the timeout', async () => {
163
+ jest.useFakeTimers();
164
+ try {
165
+ getMenu.mockImplementation(
166
+ () =>
167
+ new Promise((resolve) =>
168
+ setTimeout(() => resolve(rows), MENU_SEED_TIMEOUT_MS - 1)
169
+ )
170
+ );
171
+
172
+ const pending = loadMenuSeeds([section([block('nav-menu')])]);
173
+ await jest.advanceTimersByTimeAsync(MENU_SEED_TIMEOUT_MS);
174
+
175
+ expect(await pending).toEqual({ '1|': rows });
176
+ } finally {
177
+ jest.useRealTimers();
178
+ }
179
+ });
180
+
181
+ it('seeds nothing on a core without getMenu', async () => {
182
+ getMenuExported = false;
183
+
184
+ expect(await loadMenuSeeds([section([block('nav-menu')])])).toEqual({});
185
+ });
186
+ });
@@ -0,0 +1,162 @@
1
+ import React, { useLayoutEffect } from 'react';
2
+ import { act, cleanup, render } from '@testing-library/react';
3
+ import { renderToString } from 'react-dom/server';
4
+ import { hydrateRoot } from 'react-dom/client';
5
+
6
+ import { CHROME_MODE_ATTRIBUTE, CHROME_MODE_DOCKED } from '../chrome-mode';
7
+ import type { ChromeMode } from '../chrome-mode';
8
+ import { useChromeMode } from '../use-chrome-mode';
9
+
10
+ /**
11
+ * The chrome mode on first paint.
12
+ *
13
+ * Before this hook the only writer was `setChromeMode`, called from a brand
14
+ * effect, so the mode reached the page after hydration and every inner page
15
+ * first painted the transparent overlay header. The hook keeps that `body`
16
+ * stamp and adds the half that can be in the server HTML: the attribute it
17
+ * returns, spread on the header's own root, which the `docked` selector's
18
+ * `body:has(…)` branch reads.
19
+ *
20
+ * So both halves are pinned: the markup the SERVER sends (no effect has run
21
+ * there), and the `body` stamp the client adds and takes away again.
22
+ */
23
+
24
+ const mode = () => document.body.getAttribute(CHROME_MODE_ATTRIBUTE);
25
+
26
+ /** A brand header written the way the README teaches. */
27
+ function Header({ chromeMode }: { chromeMode: ChromeMode }) {
28
+ const attrs = useChromeMode(chromeMode);
29
+
30
+ return (
31
+ <header data-testid="header" {...attrs}>
32
+ Store
33
+ </header>
34
+ );
35
+ }
36
+
37
+ afterEach(() => {
38
+ // Unmount first, so each hook's cleanup restores before the reset below.
39
+ cleanup();
40
+ document.body.removeAttribute(CHROME_MODE_ATTRIBUTE);
41
+ });
42
+
43
+ describe('the server HTML', () => {
44
+ it('carries data-chrome-mode="docked" on the header root', () => {
45
+ const html = renderToString(<Header chromeMode={CHROME_MODE_DOCKED} />);
46
+
47
+ expect(html).toContain(
48
+ '<header data-testid="header" data-chrome-mode="docked">'
49
+ );
50
+ });
51
+
52
+ it('carries no attribute at all for overlay', () => {
53
+ // Absent is what overlay has always meant, so every page that never
54
+ // declared a mode renders byte for byte as it did.
55
+ const html = renderToString(<Header chromeMode={null} />);
56
+
57
+ expect(html).not.toContain(CHROME_MODE_ATTRIBUTE);
58
+ });
59
+
60
+ it('does not stamp body while rendering, because no effect runs there', () => {
61
+ // Which is exactly why the element attribute has to exist: on the server
62
+ // the body stamp never happens, and in the browser it waits for hydration.
63
+ renderToString(<Header chromeMode={CHROME_MODE_DOCKED} />);
64
+
65
+ expect(mode()).toBeNull();
66
+ });
67
+ });
68
+
69
+ describe('the body stamp', () => {
70
+ it('stamps body on mount and restores it on unmount', () => {
71
+ const { unmount } = render(<Header chromeMode={CHROME_MODE_DOCKED} />);
72
+
73
+ expect(mode()).toBe('docked');
74
+
75
+ unmount();
76
+
77
+ expect(mode()).toBeNull();
78
+ });
79
+
80
+ it('clears body when the header re-renders from docked to overlay', () => {
81
+ // A client-side navigation onto the home page: the header stays mounted
82
+ // and only its mode changes.
83
+ const { getByTestId, rerender } = render(
84
+ <Header chromeMode={CHROME_MODE_DOCKED} />
85
+ );
86
+
87
+ expect(mode()).toBe('docked');
88
+
89
+ rerender(<Header chromeMode={null} />);
90
+
91
+ expect(mode()).toBeNull();
92
+ expect(getByTestId('header').hasAttribute(CHROME_MODE_ATTRIBUTE)).toBe(
93
+ false
94
+ );
95
+ });
96
+
97
+ it('stamps in the layout phase, before the browser paints', () => {
98
+ // A later sibling's layout effect runs after the header's in the same
99
+ // commit. If the stamp were a passive effect it would still be missing
100
+ // here, and a client-side navigation would paint one frame with the
101
+ // previous route's body stamp.
102
+ let seenByLaterLayoutEffect: string | null = 'unset';
103
+
104
+ function LaterSibling() {
105
+ useLayoutEffect(() => {
106
+ seenByLaterLayoutEffect = mode();
107
+ }, []);
108
+
109
+ return null;
110
+ }
111
+
112
+ render(
113
+ <>
114
+ <Header chromeMode={CHROME_MODE_DOCKED} />
115
+ <LaterSibling />
116
+ </>
117
+ );
118
+
119
+ expect(seenByLaterLayoutEffect).toBe('docked');
120
+ });
121
+ });
122
+
123
+ describe('hydrating the server HTML', () => {
124
+ it('matches the server markup and then stamps body', () => {
125
+ // The render-time-inputs rule, end to end: the same mode on both sides
126
+ // hydrates without a mismatch, and the body half arrives afterwards.
127
+ const container = document.createElement('div');
128
+ container.innerHTML = renderToString(
129
+ <Header chromeMode={CHROME_MODE_DOCKED} />
130
+ );
131
+ document.body.appendChild(container);
132
+
133
+ const consoleError = jest
134
+ .spyOn(console, 'error')
135
+ .mockImplementation(() => undefined);
136
+
137
+ expect(mode()).toBeNull();
138
+
139
+ let root: ReturnType<typeof hydrateRoot> | undefined;
140
+
141
+ try {
142
+ act(() => {
143
+ root = hydrateRoot(
144
+ container,
145
+ <Header chromeMode={CHROME_MODE_DOCKED} />
146
+ );
147
+ });
148
+
149
+ expect(consoleError).not.toHaveBeenCalled();
150
+ expect(
151
+ container.querySelector('header')?.getAttribute(CHROME_MODE_ATTRIBUTE)
152
+ ).toBe('docked');
153
+ expect(mode()).toBe('docked');
154
+ } finally {
155
+ act(() => root?.unmount());
156
+ consoleError.mockRestore();
157
+ container.remove();
158
+ }
159
+
160
+ expect(mode()).toBeNull();
161
+ });
162
+ });
@@ -15,8 +15,22 @@
15
15
  *
16
16
  * WHO STAMPS IT. The brand, from its own header, exactly as the mega menu
17
17
  * stamps `data-overlay-state`. pz-theme supplies the vocabulary and the
18
- * selector; only the brand knows which of its routes carry a hero. `setChromeMode`
19
- * below is the one writer, so no brand hand-types the attribute.
18
+ * selector; only the brand knows which of its routes carry a hero. The
19
+ * brand's header calls `useChromeMode` (chrome/use-chrome-mode.ts), which does
20
+ * both writes below, so no brand hand-types the attribute.
21
+ *
22
+ * Two writes, because only one of them can be in the server HTML:
23
+ *
24
+ * - `chromeModeAttrs` renders the attribute on the brand's OWN chrome
25
+ * element. That is the first-paint signal: it is in the markup the server
26
+ * sends, and the selector's `body:has(…)` branch reads it from there, so an
27
+ * inner page paints docked before any script has run.
28
+ * - `setChromeMode` stamps `body`; the hook calls it from a layout effect,
29
+ * so this half only exists once the page has hydrated. It stays because the
30
+ * designer pin writes there, because a browser without `:has` only ever
31
+ * matches the `body` branch, and because hand-written brand CSS written
32
+ * against `body[data-chrome-mode]` has only ever had that element to key
33
+ * on.
20
34
  *
21
35
  * DEFAULT IS OVERLAY, and that is deliberate: the attribute is absent until
22
36
  * something declares otherwise, so every composition that existed before this
@@ -31,6 +45,23 @@ export const CHROME_MODE_DOCKED = 'docked';
31
45
 
32
46
  export type ChromeMode = 'docked' | null;
33
47
 
48
+ /**
49
+ * The attribute to spread on the brand's own chrome element for `mode`.
50
+ *
51
+ * Pure, and callable on the server as well as in a client component, which is
52
+ * the whole point: whatever renders the header puts `data-chrome-mode` in the
53
+ * server HTML, where the `docked` selector's `body:has(…)` branch finds it on
54
+ * first paint. Overlay renders NO attribute rather than an empty one — absent
55
+ * is what overlay has always meant (see DEFAULT IS OVERLAY above).
56
+ *
57
+ * A fresh object per call, so a caller may add to it without reaching into
58
+ * anyone else's.
59
+ */
60
+ export const chromeModeAttrs = (mode: ChromeMode): Record<string, string> =>
61
+ mode === CHROME_MODE_DOCKED
62
+ ? { [CHROME_MODE_ATTRIBUTE]: CHROME_MODE_DOCKED }
63
+ : {};
64
+
34
65
  /**
35
66
  * Stamps or clears the mode on `<body>`.
36
67
  *
@@ -0,0 +1,89 @@
1
+ import 'server-only';
2
+
3
+ import * as serverData from '@akinon/next/data/server';
4
+
5
+ import type { Section } from '../theme-section';
6
+ import { collectMenuSeedArgs, menuSeedKey } from '../blocks/menu-query-args';
7
+ import type { MenuQueryArgs, MenuSeeds } from '../blocks/menu-query-args';
8
+
9
+ export type { MenuSeeds } from '../blocks/menu-query-args';
10
+
11
+ type GetMenu = (args: MenuQueryArgs) => Promise<unknown>;
12
+
13
+ /**
14
+ * How long the page waits for one menu before it gives up and lets the block
15
+ * fetch it on the client.
16
+ *
17
+ * The seed is awaited by ThemePlaceholder, and a brand typically mounts the
18
+ * header slot in its root layout with no Suspense boundary around it, so every
19
+ * millisecond spent here is a millisecond before the first byte of EVERY page.
20
+ * A warm cache answers in a few milliseconds; a cold one is one round trip to
21
+ * the menu service. When that round trip is slow, a late category row is the
22
+ * better failure than a late page, and it is exactly today's behaviour. The
23
+ * request is not cancelled: it still lands in the core cache for the next page.
24
+ */
25
+ export const MENU_SEED_TIMEOUT_MS = 1000;
26
+
27
+ const TIMED_OUT = Symbol('menu-seed-timeout');
28
+
29
+ const withinTimeout = async <T>(
30
+ pending: Promise<T>
31
+ ): Promise<T | undefined> => {
32
+ let timer: ReturnType<typeof setTimeout> | undefined;
33
+ const timeout = new Promise<typeof TIMED_OUT>((resolve) => {
34
+ timer = setTimeout(() => resolve(TIMED_OUT), MENU_SEED_TIMEOUT_MS);
35
+ });
36
+
37
+ try {
38
+ const answer = await Promise.race([pending, timeout]);
39
+ return answer === TIMED_OUT ? undefined : (answer as T);
40
+ } finally {
41
+ clearTimeout(timer);
42
+ }
43
+ };
44
+
45
+ /**
46
+ * The menus a placeholder's nav-menu blocks will ask for, fetched on the
47
+ * server so the category row is in the served HTML.
48
+ *
49
+ * Why not seed the RTK cache instead: brands on the v1 core run RTK 1.9.7,
50
+ * where `upsertQueryData` is async and `upsertQueryEntries` does not exist.
51
+ * Nothing can fill the cache before the first render, so the entries travel
52
+ * as render input — a prop on the wrapper, then context (menu-seed-context.tsx).
53
+ *
54
+ * `getMenu` hits the same `/menus/generate/?depth_height=` endpoint as the
55
+ * client query and returns the same `.menu` array, on v1 and v2 alike, so a
56
+ * seed is exactly what the block would have fetched.
57
+ *
58
+ * Every failure degrades to today's behaviour, never to a broken row: a
59
+ * request that throws, outlasts MENU_SEED_TIMEOUT_MS or answers with anything
60
+ * but an array leaves its key out, and the block falls back to the client query for that key. A core
61
+ * without `getMenu` seeds nothing. A composition with no nav-menu block makes
62
+ * no request at all.
63
+ */
64
+ export async function loadMenuSeeds(
65
+ sections: readonly Section[] | null | undefined
66
+ ): Promise<MenuSeeds> {
67
+ const wanted = collectMenuSeedArgs(sections);
68
+ if (wanted.length === 0) return {};
69
+
70
+ const getMenu = (serverData as unknown as { getMenu?: GetMenu }).getMenu;
71
+ if (typeof getMenu !== 'function') return {};
72
+
73
+ const answers = await Promise.all(
74
+ wanted.map(async (args): Promise<[string, unknown]> => {
75
+ try {
76
+ return [menuSeedKey(args), await withinTimeout(getMenu(args))];
77
+ } catch {
78
+ return [menuSeedKey(args), undefined];
79
+ }
80
+ })
81
+ );
82
+
83
+ const seeds: MenuSeeds = {};
84
+ answers.forEach(([key, menu]) => {
85
+ if (Array.isArray(menu)) seeds[key] = menu;
86
+ });
87
+
88
+ return seeds;
89
+ }
@@ -0,0 +1,44 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useLayoutEffect } from 'react';
4
+
5
+ import { chromeModeAttrs, setChromeMode } from './chrome-mode';
6
+ import type { ChromeMode } from './chrome-mode';
7
+
8
+ // `useLayoutEffect` warns during SSR under React 18, and every brand header
9
+ // that calls this is server-rendered. On the server neither effect runs, so
10
+ // the swap changes nothing but the warning.
11
+ const useIsomorphicLayoutEffect =
12
+ typeof window === 'undefined' ? useEffect : useLayoutEffect;
13
+
14
+ /**
15
+ * Declares the chrome mode from the brand's header, both halves at once.
16
+ *
17
+ * Returns the attribute for the header's own root element — spread it there —
18
+ * and stamps `body` with `setChromeMode` from a layout effect, handing the
19
+ * effect `setChromeMode`'s own cleanup. The returned attribute is what makes
20
+ * the first paint right: it is in the server HTML, and the `docked` selector's
21
+ * `body:has(…)` branch reads it before any script runs. The `body` stamp is the
22
+ * half the designer pin, a browser without `:has` and hand-written CSS rely on
23
+ * (see chrome/chrome-mode.ts).
24
+ *
25
+ * A LAYOUT effect rather than a plain one, so a client-side navigation cannot
26
+ * paint a frame with the previous route's `body` stamp still in place — the
27
+ * element attribute changes in the commit, and the stamp follows before paint.
28
+ *
29
+ * COMPUTE `mode` FROM RENDER-TIME INPUTS ONLY — `usePathname()`, settings,
30
+ * props — never from `window`, `localStorage` or state an effect sets. The
31
+ * server renders the attribute from what it knows, and hydration expects the
32
+ * client's first render to produce the same markup. React does not promise to
33
+ * patch a mismatched attribute while hydrating, so a mode the server could not
34
+ * compute can leave the server's answer stuck on the header while the effect
35
+ * writes the other one onto `body`.
36
+ *
37
+ * One owner, as with `setChromeMode`: the cleanup restores a snapshot rather
38
+ * than counting callers, so call this from the one header that owns the mode.
39
+ */
40
+ export const useChromeMode = (mode: ChromeMode): Record<string, string> => {
41
+ useIsomorphicLayoutEffect(() => setChromeMode(mode), [mode]);
42
+
43
+ return chromeModeAttrs(mode);
44
+ };