@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 +7 -35
- package/package.json +4 -4
- package/src/blocks/__tests__/menu-query-args.test.tsx +239 -0
- package/src/blocks/__tests__/nav-menu-ssr.test.tsx +260 -0
- package/src/blocks/menu-query-args.ts +105 -0
- package/src/blocks/nav-menu-block.tsx +21 -10
- package/src/chrome/__tests__/chrome-mode.test.ts +35 -2
- package/src/chrome/__tests__/menu-seed-source.test.ts +186 -0
- package/src/chrome/__tests__/use-chrome-mode.test.tsx +162 -0
- package/src/chrome/chrome-mode.ts +33 -2
- package/src/chrome/menu-seed-source.ts +89 -0
- package/src/chrome/use-chrome-mode.ts +44 -0
- package/src/custom/README-sections.md +32 -15
- package/src/custom/__tests__/custom-entry-exports.test.tsx +50 -0
- package/src/custom/index.ts +22 -6
- package/src/index.ts +2 -0
- package/src/menu-seed-context.tsx +38 -0
- package/src/theme-placeholder-wrapper.tsx +103 -91
- package/src/theme-placeholder.tsx +5 -0
- package/src/utils/__tests__/docked-state-styles.test.ts +88 -19
- package/src/utils/part-styles.ts +36 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,40 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.115
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
};
|