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

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.
package/CHANGELOG.md CHANGED
@@ -1,40 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.115-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c2]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b1]
34
- - Updated dependencies [1595d45]
35
- - Updated dependencies [dca1281]
36
- - @akinon/next@2.0.115-rc.0
37
- - @akinon/theme-contract@2.0.115-rc.0
3
+ ## 2.0.115
4
+
5
+ ### Patch Changes
6
+
7
+ - 9ce1787: ZERO-5092: Render the header on first paint as it will settle: seed nav-menu entries on the server so the category row is in the served HTML, and add useChromeMode / chromeModeAttrs so a docked header is docked before hydration
8
+ - @akinon/next@2.0.115
9
+ - @akinon/theme-contract@2.0.115
38
10
 
39
11
  ## 2.0.114
40
12
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.115-rc.0",
4
+ "version": "2.0.115",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.115-rc.0",
11
+ "@akinon/next": "2.0.115",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.115-rc.0",
16
+ "@akinon/theme-contract": "2.0.115",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.115-rc.0",
24
+ "@akinon/next": "2.0.115",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,239 @@
1
+ /**
2
+ * The server seeds a nav-menu's entries under a key; the block looks them up
3
+ * under a key. If the two ever computed different keys the seed would be
4
+ * silently ignored — the block would fall back to its client fetch and the
5
+ * server HTML would carry an empty row again, with nothing saying why.
6
+ *
7
+ * So the args live in one function both sides call, and these pin it: what
8
+ * it resolves for the shapes compositions actually store, which blocks the
9
+ * collector visits, and — against the rendered block itself — that every
10
+ * request the block makes is one the collector would have seeded.
11
+ */
12
+ import React from 'react';
13
+ import { render } from '@testing-library/react';
14
+
15
+ jest.mock('next/navigation', () => ({
16
+ useRouter: () => ({ push: jest.fn() }),
17
+ usePathname: () => '/',
18
+ useSearchParams: () => new URLSearchParams('')
19
+ }));
20
+
21
+ const mockUseGetMenuQuery = jest.fn((_args: unknown, _options?: unknown) => ({
22
+ data: [],
23
+ isLoading: false
24
+ }));
25
+
26
+ jest.mock('@akinon/next/data/client/misc', () => ({
27
+ useGetMenuQuery: (...args: unknown[]) =>
28
+ (mockUseGetMenuQuery as (...a: unknown[]) => unknown)(...args)
29
+ }));
30
+
31
+ import NavMenuBlock from '../nav-menu-block';
32
+ import {
33
+ collectMenuSeedArgs,
34
+ menuSeedKey,
35
+ navMenuQueryArgs
36
+ } from '../menu-query-args';
37
+ import { BREAKPOINTS } from '../../utils/breakpoints';
38
+
39
+ const block = (
40
+ type: string,
41
+ properties: Record<string, unknown> = {},
42
+ over: Record<string, unknown> = {}
43
+ ): any => ({
44
+ id: `${type}-${Math.random().toString(36).slice(2, 7)}`,
45
+ type,
46
+ label: type,
47
+ order: 0,
48
+ hidden: false,
49
+ value: '',
50
+ properties,
51
+ styles: {},
52
+ blocks: [],
53
+ ...over
54
+ });
55
+
56
+ const section = (blocks: any[], over: Record<string, unknown> = {}): any => ({
57
+ id: 'header-main',
58
+ type: 'default',
59
+ name: 'header',
60
+ label: 'header',
61
+ properties: {},
62
+ styles: {},
63
+ blocks,
64
+ order: 0,
65
+ hidden: false,
66
+ ...over
67
+ });
68
+
69
+ const keys = (sections: any[]) =>
70
+ collectMenuSeedArgs(sections).map(menuSeedKey).sort();
71
+
72
+ describe('navMenuQueryArgs', () => {
73
+ it.each([
74
+ ['nothing authored', {}, 'desktop', { depth: 1 }],
75
+ ['a numeric string', { depth: '2' }, 'desktop', { depth: 2 }],
76
+ ['zero', { depth: 0 }, 'desktop', { depth: 1 }],
77
+ ['a negative depth', { depth: -3 }, 'desktop', { depth: 1 }],
78
+ ['a non-number', { depth: 'deep' }, 'desktop', { depth: 1 }],
79
+ [
80
+ 'a per-breakpoint depth on mobile',
81
+ { depth: { desktop: 1, mobile: 3 } },
82
+ 'mobile',
83
+ { depth: 3 }
84
+ ],
85
+ [
86
+ 'a tablet with nothing of its own (falls through to mobile)',
87
+ { depth: { desktop: 1, mobile: 3 } },
88
+ 'tablet',
89
+ { depth: 3 }
90
+ ],
91
+ ['an empty parent', { parent: '' }, 'desktop', { depth: 1 }],
92
+ [
93
+ 'a parent',
94
+ { depth: 2, parent: 'women' },
95
+ 'desktop',
96
+ { depth: 2, parent: 'women' }
97
+ ],
98
+ [
99
+ 'a parent cleared on mobile',
100
+ { parent: { desktop: 'women', mobile: '' } },
101
+ 'mobile',
102
+ { depth: 1 }
103
+ ],
104
+ ['a numeric parent', { parent: 42 }, 'desktop', { depth: 1, parent: '42' }]
105
+ ])('resolves %s', (_name, properties, breakpoint, expected) => {
106
+ expect(navMenuQueryArgs(properties, breakpoint)).toEqual(expected);
107
+ });
108
+
109
+ it('omits the parent key rather than sending it empty', () => {
110
+ // RTK derives the cache entry from these args; the shape is the one the
111
+ // block always sent.
112
+ expect('parent' in navMenuQueryArgs({ parent: '' }, 'desktop')).toBe(false);
113
+ });
114
+
115
+ it('tolerates missing properties', () => {
116
+ expect(navMenuQueryArgs(undefined)).toEqual({ depth: 1 });
117
+ expect(navMenuQueryArgs(null, 'mobile')).toEqual({ depth: 1 });
118
+ });
119
+ });
120
+
121
+ describe('menuSeedKey', () => {
122
+ it('is depth and parent, with an absent parent written empty', () => {
123
+ expect(menuSeedKey({ depth: 1 })).toBe('1|');
124
+ expect(menuSeedKey({ depth: 2, parent: 'women' })).toBe('2|women');
125
+ });
126
+ });
127
+
128
+ describe('collectMenuSeedArgs', () => {
129
+ it('seeds every breakpoint a nav-menu asks for, once per distinct menu', () => {
130
+ expect(
131
+ keys([section([block('nav-menu', { depth: { desktop: 1, mobile: 2 } })])])
132
+ ).toEqual(['1|', '2|']);
133
+ });
134
+
135
+ it('collapses a block that asks for one menu everywhere to one key', () => {
136
+ expect(keys([section([block('nav-menu')])])).toEqual(['1|']);
137
+ });
138
+
139
+ it('dedupes the same menu across blocks and sections', () => {
140
+ expect(
141
+ keys([
142
+ section([block('nav-menu'), block('nav-menu', { depth: 1 })]),
143
+ section([block('nav-menu')], { id: 'footer' })
144
+ ])
145
+ ).toEqual(['1|']);
146
+ });
147
+
148
+ it('finds a nav-menu nested inside groups', () => {
149
+ const nested = block(
150
+ 'group',
151
+ {},
152
+ {
153
+ blocks: [
154
+ block(
155
+ 'container',
156
+ {},
157
+ {
158
+ blocks: [block('nav-menu', { depth: 2, parent: 'women' })]
159
+ }
160
+ )
161
+ ]
162
+ }
163
+ );
164
+
165
+ expect(keys([section([nested])])).toEqual(['2|women']);
166
+ });
167
+
168
+ it('skips hidden sections', () => {
169
+ expect(keys([section([block('nav-menu')], { hidden: true })])).toEqual([]);
170
+ });
171
+
172
+ it('skips hidden blocks, and whatever they contain', () => {
173
+ const hiddenGroup = block(
174
+ 'group',
175
+ {},
176
+ { hidden: true, blocks: [block('nav-menu', { depth: 3 })] }
177
+ );
178
+
179
+ expect(
180
+ keys([section([block('nav-menu', {}, { hidden: true }), hiddenGroup])])
181
+ ).toEqual([]);
182
+ });
183
+
184
+ it('only seeds nav-menu — not the mega or hamburger menus', () => {
185
+ expect(
186
+ keys([
187
+ section([
188
+ block('mega-menu'),
189
+ block('hamburger-menu'),
190
+ block('text'),
191
+ block('link')
192
+ ])
193
+ ])
194
+ ).toEqual([]);
195
+ });
196
+
197
+ it('answers nothing for no sections', () => {
198
+ expect(collectMenuSeedArgs([])).toEqual([]);
199
+ expect(collectMenuSeedArgs(null)).toEqual([]);
200
+ expect(collectMenuSeedArgs(undefined)).toEqual([]);
201
+ });
202
+ });
203
+
204
+ describe('parity with the rendered block', () => {
205
+ beforeEach(() => mockUseGetMenuQuery.mockClear());
206
+
207
+ it.each([
208
+ ['nothing authored', {}],
209
+ ['a per-breakpoint depth', { depth: { desktop: 1, mobile: 2 } }],
210
+ ['a three-way depth', { depth: { desktop: 3, tablet: 2, mobile: 1 } }],
211
+ ['a parent', { depth: '2', parent: 'women' }],
212
+ ['a per-breakpoint parent', { parent: { desktop: 'women', mobile: '' } }],
213
+ ['an invalid depth', { depth: 'x' }]
214
+ ])(
215
+ 'every request the block makes for %s is one the server seeded',
216
+ (_name, properties) => {
217
+ const navMenu = block('nav-menu', properties);
218
+ const seeded = keys([section([navMenu])]);
219
+
220
+ BREAKPOINTS.forEach((breakpoint) => {
221
+ mockUseGetMenuQuery.mockClear();
222
+ const { unmount } = render(
223
+ <NavMenuBlock
224
+ block={navMenu}
225
+ placeholderId="header"
226
+ sectionId="header-main"
227
+ currentBreakpoint={breakpoint}
228
+ isDesigner={false}
229
+ />
230
+ );
231
+
232
+ const [args] = mockUseGetMenuQuery.mock.calls[0];
233
+ expect(args).toEqual(navMenuQueryArgs(properties, breakpoint));
234
+ expect(seeded).toContain(menuSeedKey(args as never));
235
+ unmount();
236
+ });
237
+ }
238
+ );
239
+ });
@@ -0,0 +1,260 @@
1
+ /**
2
+ * The category row is in the SERVER HTML — it used to arrive after hydration.
3
+ *
4
+ * The nav-menu block loads its entries with a client-only RTK hook, so the
5
+ * server rendered an empty `<nav>` and the row popped in once the client
6
+ * fetch landed. The server now fetches the menu ahead of the render and hands
7
+ * it down through `MenuSeedProvider`; RTK's cache cannot be filled
8
+ * synchronously on the v1 core (RTK 1.9.7), so the seed is render input.
9
+ *
10
+ * What these pin: the seeded entries are in `renderToString`'s output, the
11
+ * client request is skipped for a seeded key and made for any other, the
12
+ * client's first render agrees with the server's (no hydration error), and a
13
+ * tree with no provider behaves exactly as before.
14
+ */
15
+ import React from 'react';
16
+ import { act, render, screen } from '@testing-library/react';
17
+ import { renderToString } from 'react-dom/server';
18
+ import { hydrateRoot } from 'react-dom/client';
19
+
20
+ jest.mock('next/navigation', () => ({
21
+ useRouter: () => ({ push: jest.fn() }),
22
+ usePathname: () => '/women',
23
+ useSearchParams: () => new URLSearchParams('')
24
+ }));
25
+
26
+ const seededRows = [
27
+ { pk: 1, label: 'Women', url: '/women', children: [] },
28
+ { pk: 2, label: 'Men', url: '/men', children: [] },
29
+ { pk: 3, label: 'Home', url: '/home', children: [] }
30
+ ];
31
+
32
+ const fetchedRows = [
33
+ { pk: 9, label: 'Fetched', url: '/fetched', children: [] }
34
+ ];
35
+
36
+ /** What the client fetch answers; a parent menu names its parent. */
37
+ const fetchedFor = (args: unknown) => {
38
+ const parent = (args as { parent?: string } | undefined)?.parent;
39
+
40
+ return parent
41
+ ? [{ pk: 8, label: `Fetched ${parent}`, url: `/${parent}`, children: [] }]
42
+ : fetchedRows;
43
+ };
44
+
45
+ // Behaves like the real hook where it matters: a skipped query is not
46
+ // loading, and it does NOT clear its data. RTK (1.9.7 and 2.x alike) keeps
47
+ // returning the last result this hook instance got for the args it ran with
48
+ // before; only a hook that was skipped from its first render has nothing.
49
+ const mockUseGetMenuQuery = jest.fn(
50
+ (args: unknown, options?: { skip?: boolean }) => {
51
+ const last = React.useRef<unknown>(undefined);
52
+ if (!options?.skip) last.current = fetchedFor(args);
53
+
54
+ return { data: last.current, isLoading: false };
55
+ }
56
+ );
57
+
58
+ jest.mock('@akinon/next/data/client/misc', () => ({
59
+ useGetMenuQuery: (...args: unknown[]) =>
60
+ (mockUseGetMenuQuery as (...a: unknown[]) => unknown)(...args)
61
+ }));
62
+
63
+ import NavMenuBlock from '../nav-menu-block';
64
+ import { MenuSeedProvider } from '../../menu-seed-context';
65
+ import type { MenuSeeds } from '../menu-query-args';
66
+
67
+ const navBlock = (properties: Record<string, unknown> = {}) =>
68
+ ({
69
+ id: 'main-nav',
70
+ type: 'nav-menu',
71
+ label: 'nav-menu',
72
+ order: 0,
73
+ hidden: false,
74
+ value: '',
75
+ properties,
76
+ styles: {},
77
+ blocks: []
78
+ } as never);
79
+
80
+ const tree = (
81
+ seeds: MenuSeeds | null,
82
+ properties: Record<string, unknown> = {}
83
+ ) => (
84
+ <MenuSeedProvider value={seeds}>
85
+ <NavMenuBlock
86
+ block={navBlock(properties)}
87
+ placeholderId="header"
88
+ sectionId="header-main"
89
+ currentBreakpoint="desktop"
90
+ isDesigner={false}
91
+ />
92
+ </MenuSeedProvider>
93
+ );
94
+
95
+ /** The server's markup, parsed so it can be queried the way a browser would. */
96
+ const serverDom = (element: React.ReactElement): HTMLElement => {
97
+ const host = document.createElement('div');
98
+ host.innerHTML = renderToString(element);
99
+ return host;
100
+ };
101
+
102
+ const labels = (root: ParentNode) =>
103
+ Array.from(root.querySelectorAll('nav[data-nav-menu] a')).map(
104
+ (anchor) => anchor.textContent
105
+ );
106
+
107
+ beforeEach(() => {
108
+ mockUseGetMenuQuery.mockClear();
109
+ });
110
+
111
+ describe('a seeded nav-menu on the server', () => {
112
+ it('renders its entries into the server HTML', () => {
113
+ const dom = serverDom(tree({ '1|': seededRows }));
114
+
115
+ expect(labels(dom)).toEqual(['Women', 'Men', 'Home']);
116
+ expect(dom.querySelector('a[href="/men"]')).not.toBeNull();
117
+ });
118
+
119
+ it('skips the client request for the seeded key', () => {
120
+ renderToString(tree({ '1|': seededRows }));
121
+
122
+ expect(mockUseGetMenuQuery).toHaveBeenCalledWith(
123
+ { depth: 1 },
124
+ { skip: true }
125
+ );
126
+ });
127
+
128
+ it('still requests a menu the server did not seed', () => {
129
+ const dom = serverDom(tree({ '1|': seededRows }, { depth: 2 }));
130
+
131
+ expect(mockUseGetMenuQuery).toHaveBeenCalledWith(
132
+ { depth: 2 },
133
+ { skip: false }
134
+ );
135
+ expect(labels(dom)).toEqual(['Fetched']);
136
+ });
137
+
138
+ it('keys by parent too, so a seeded parent menu is found', () => {
139
+ const dom = serverDom(
140
+ tree({ '2|women': seededRows }, { depth: 2, parent: 'women' })
141
+ );
142
+
143
+ expect(mockUseGetMenuQuery).toHaveBeenCalledWith(
144
+ { depth: 2, parent: 'women' },
145
+ { skip: true }
146
+ );
147
+ expect(labels(dom)).toEqual(['Women', 'Men', 'Home']);
148
+ });
149
+ });
150
+
151
+ describe('the client agrees with the server', () => {
152
+ const hydrate = (serverTree: React.ReactElement, clientTree = serverTree) => {
153
+ const host = document.createElement('div');
154
+ host.innerHTML = renderToString(serverTree);
155
+ document.body.appendChild(host);
156
+
157
+ const recoverable: unknown[] = [];
158
+ const logged: string[] = [];
159
+ const spy = jest
160
+ .spyOn(console, 'error')
161
+ .mockImplementation((...args: unknown[]) => {
162
+ logged.push(args.map(String).join(' '));
163
+ });
164
+
165
+ let root: ReturnType<typeof hydrateRoot>;
166
+ act(() => {
167
+ root = hydrateRoot(host, clientTree, {
168
+ onRecoverableError: (error) => {
169
+ recoverable.push(error);
170
+ }
171
+ });
172
+ });
173
+
174
+ spy.mockRestore();
175
+ return {
176
+ host,
177
+ recoverable,
178
+ mismatches: logged.filter((message) =>
179
+ /hydrat|did not match/i.test(message)
180
+ ),
181
+ unmount: () => {
182
+ act(() => root.unmount());
183
+ host.remove();
184
+ }
185
+ };
186
+ };
187
+
188
+ it('hydrates the seeded row with no recoverable error', () => {
189
+ const seeded = tree({ '1|': seededRows });
190
+ const { host, recoverable, mismatches, unmount } = hydrate(seeded);
191
+
192
+ expect(recoverable).toEqual([]);
193
+ expect(mismatches).toEqual([]);
194
+ expect(labels(host)).toEqual(['Women', 'Men', 'Home']);
195
+ unmount();
196
+ });
197
+
198
+ it('would report one if the client did not see the seed (the detector works)', () => {
199
+ // Control: the same server HTML hydrated by a client that fetches instead.
200
+ // Proves the check above can fail, rather than trivially passing.
201
+ const { recoverable, unmount } = hydrate(
202
+ tree({ '1|': seededRows }),
203
+ tree(null)
204
+ );
205
+
206
+ expect(recoverable.length).toBeGreaterThan(0);
207
+ unmount();
208
+ });
209
+ });
210
+
211
+ describe('a block that leaves a seeded key and comes back', () => {
212
+ // The designer points the row at an unseeded parent, which the client
213
+ // fetches, then undoes it. The query is skipped again but still holds the
214
+ // menu it fetched for the other parent; the seed has to win over it.
215
+ it('draws the seed again, not the menu fetched for the other key', () => {
216
+ const seeds = { '2|women': seededRows };
217
+ const { container, rerender } = render(
218
+ tree(seeds, { depth: 2, parent: 'women' })
219
+ );
220
+ expect(labels(container)).toEqual(['Women', 'Men', 'Home']);
221
+
222
+ rerender(tree(seeds, { depth: 2, parent: 'men' }));
223
+ expect(labels(container)).toEqual(['Fetched men']);
224
+
225
+ rerender(tree(seeds, { depth: 2, parent: 'women' }));
226
+
227
+ const lastCall = mockUseGetMenuQuery.mock.calls.length - 1;
228
+ expect(mockUseGetMenuQuery.mock.calls[lastCall]).toEqual([
229
+ { depth: 2, parent: 'women' },
230
+ { skip: true }
231
+ ]);
232
+ // The precondition that makes this a real check: the skipped hook is
233
+ // still answering with the men menu.
234
+ expect(
235
+ (mockUseGetMenuQuery.mock.results[lastCall].value as { data: unknown })
236
+ .data
237
+ ).toEqual(fetchedFor({ parent: 'men' }));
238
+ expect(labels(container)).toEqual(['Women', 'Men', 'Home']);
239
+ });
240
+ });
241
+
242
+ describe('without a provider', () => {
243
+ it('fetches on the client exactly as before', () => {
244
+ render(
245
+ <NavMenuBlock
246
+ block={navBlock()}
247
+ placeholderId="header"
248
+ sectionId="header-main"
249
+ currentBreakpoint="desktop"
250
+ isDesigner={false}
251
+ />
252
+ );
253
+
254
+ expect(mockUseGetMenuQuery).toHaveBeenCalledWith(
255
+ { depth: 1 },
256
+ { skip: false }
257
+ );
258
+ expect(screen.getByText('Fetched')).toBeInTheDocument();
259
+ });
260
+ });
@@ -0,0 +1,105 @@
1
+ import type { Block } from '../theme-block';
2
+ import type { Section } from '../theme-section';
3
+ import { getResponsiveValue } from '../utils';
4
+ import { BREAKPOINTS } from '../utils/breakpoints';
5
+
6
+ /**
7
+ * The nav-menu block's menu request, readable by the server.
8
+ *
9
+ * The block loads its entries with the client-only RTK hook, so the server
10
+ * used to emit an empty category row that filled in after hydration. The
11
+ * server now fetches the same menus ahead of the render (chrome/
12
+ * menu-seed-source.ts) and hands them to the block through context
13
+ * (menu-seed-context.tsx); the block reads its seed by KEY and skips the
14
+ * client request when one is there.
15
+ *
16
+ * That only works if both sides compute the same key from the same
17
+ * properties. So the args logic lives here, once, and both the block and the
18
+ * server collector call it: a seed the block cannot find would silently fall
19
+ * back to the client fetch, and the row would be empty in the server HTML
20
+ * again with nothing in the output saying why.
21
+ *
22
+ * Directive-free on purpose: the server imports it, and a 'use client' module
23
+ * would hand the server a client reference instead of these functions.
24
+ */
25
+
26
+ export interface MenuQueryArgs {
27
+ depth: number;
28
+ parent?: string;
29
+ }
30
+
31
+ /** Menu entries per `menuSeedKey`, as the server fetched them. */
32
+ export type MenuSeeds = Record<string, unknown[]>;
33
+
34
+ const NAV_MENU_BLOCK_TYPE = 'nav-menu';
35
+
36
+ /**
37
+ * The args the nav-menu block hands `useGetMenuQuery` at `breakpoint`.
38
+ *
39
+ * The shape is the one the block has always sent, `parent` included only
40
+ * when set: RTK derives the cache entry from the args, so keeping it
41
+ * unchanged keeps the entry shared with every other caller asking for the
42
+ * same menu.
43
+ */
44
+ export const navMenuQueryArgs = (
45
+ properties: Record<string, unknown> | null | undefined,
46
+ breakpoint = 'desktop'
47
+ ): MenuQueryArgs => {
48
+ const depth = Number(getResponsiveValue(properties?.depth, breakpoint, 1));
49
+ const parent = properties?.parent
50
+ ? String(getResponsiveValue(properties.parent, breakpoint, ''))
51
+ : undefined;
52
+
53
+ return {
54
+ depth: Number.isFinite(depth) && depth > 0 ? depth : 1,
55
+ ...(parent ? { parent } : {})
56
+ };
57
+ };
58
+
59
+ /** One seed per distinct request; an absent parent and an empty one agree. */
60
+ export const menuSeedKey = ({ depth, parent }: MenuQueryArgs): string =>
61
+ `${depth}|${parent ?? ''}`;
62
+
63
+ /**
64
+ * Every distinct menu request the nav-menu blocks of these sections can make.
65
+ *
66
+ * Per breakpoint, not just desktop: the wrapper renders at its initial
67
+ * breakpoint and then follows the viewport, and a block whose depth differs on
68
+ * a phone asks for a different menu there. Seeding every breakpoint's request
69
+ * means the switch finds its seed instead of starting a client fetch. They
70
+ * usually collapse to one key, so this costs a request only when the
71
+ * composition really asks for different menus.
72
+ *
73
+ * Hidden sections and hidden blocks are skipped, children included: the
74
+ * storefront does not render them. Anything they would have asked for is
75
+ * still answered by the client query if the designer shows them.
76
+ */
77
+ export const collectMenuSeedArgs = (
78
+ sections: readonly Section[] | null | undefined
79
+ ): MenuQueryArgs[] => {
80
+ const found = new Map<string, MenuQueryArgs>();
81
+
82
+ const visit = (blocks: readonly Block[] | null | undefined) => {
83
+ if (!Array.isArray(blocks)) return;
84
+
85
+ for (const block of blocks) {
86
+ if (!block || block.hidden) continue;
87
+
88
+ if (block.type === NAV_MENU_BLOCK_TYPE) {
89
+ for (const breakpoint of BREAKPOINTS) {
90
+ const args = navMenuQueryArgs(block.properties, breakpoint);
91
+ const key = menuSeedKey(args);
92
+ if (!found.has(key)) found.set(key, args);
93
+ }
94
+ }
95
+
96
+ visit(block.blocks);
97
+ }
98
+ };
99
+
100
+ (Array.isArray(sections) ? sections : []).forEach((section) => {
101
+ if (section && !section.hidden) visit(section.blocks);
102
+ });
103
+
104
+ return Array.from(found.values());
105
+ };