@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
|
@@ -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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
//
|
|
49
|
-
//
|
|
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.
|
|
19
|
-
*
|
|
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
|
+
};
|