@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.
@@ -310,7 +310,7 @@ way a composition learns something it cannot see from where it sits.
310
310
  |---|---|---|---|
311
311
  | `scrolled` | the page has moved under the chrome | the chrome slot, `data-scroll-state` | pz-theme (`chrome/scroll-state.tsx`) |
312
312
  | `overlay-open` | some overlay is open | `body`, `data-overlay-state` | pz-theme (`hooks/use-overlay.ts`) |
313
- | `docked` | the chrome sits **above** the page, not over it | `body`, `data-chrome-mode` | **you**, via `setChromeMode` |
313
+ | `docked` | the chrome sits **above** the page, not over it | your header's root element and `body`, `data-chrome-mode` | **you**, via `useChromeMode` |
314
314
 
315
315
  All three work as a root state on the section itself, `:<state>/<property>`, and
316
316
  as a part state, `<part>/<state>/<property>`.
@@ -318,7 +318,7 @@ as a part state, `<part>/<state>/<property>`.
318
318
  **The one rule: pz-theme owns the vocabulary, the brand owns the signal.** The
319
319
  attribute names, the tokens and the selectors that read them live here. Nothing
320
320
  in pz-theme can know which of your routes carry a hero, so nothing in pz-theme
321
- stamps `docked`. You do — and through `setChromeMode`, never by hand-writing the
321
+ stamps `docked`. You do — and through `useChromeMode`, never by hand-writing the
322
322
  attribute, because a typo there raises no error and simply produces a header
323
323
  that never changes appearance.
324
324
 
@@ -335,18 +335,13 @@ and lose the second appearance.
335
335
  'use client';
336
336
 
337
337
  import { usePathname } from 'next/navigation';
338
- import { useEffect } from 'react';
339
- import {
340
- CHROME_MODE_DOCKED,
341
- setChromeMode
342
- } from '@akinon/pz-theme/src/custom';
338
+ import { CHROME_MODE_DOCKED, useChromeMode } from '@akinon/pz-theme/src/custom';
343
339
 
344
340
  function StoreHeader() {
345
341
  const isHome = usePathname() === '/';
342
+ const attrs = useChromeMode(isHome ? null : CHROME_MODE_DOCKED);
346
343
 
347
- useEffect(() => setChromeMode(isHome ? null : CHROME_MODE_DOCKED), [isHome]);
348
-
349
- // ...the header markup
344
+ return <header {...attrs}>{/* ...the header markup */}</header>;
350
345
  }
351
346
  ```
352
347
 
@@ -354,11 +349,32 @@ Import both from `@akinon/pz-theme/src/custom`, like the rest of the section
354
349
  API. The package root exports them too, but it pulls in `server-only`, so a
355
350
  client header cannot import them from there.
356
351
 
357
- `setChromeMode` returns a cleanup, which is why it is returned straight from the
358
- effect. **Give the signal one owner.** The cleanup restores the value that call
352
+ **Spread what the hook returns on the header's root element.** That attribute is
353
+ what the first paint uses: it is in the HTML the server sends, and the `docked`
354
+ selector reads it through `body:has(…)`, so an inner page arrives docked instead
355
+ of showing the transparent overlay header until hydration. The hook also stamps
356
+ `body` from a layout effect — the editor's docked preview, browsers without
357
+ `:has` and any CSS of your own written against `body[data-chrome-mode]` all rely
358
+ on that half. Leave the spread out and the header still docks, but only after
359
+ hydration, which is exactly the flash this exists to remove.
360
+
361
+ **Compute the mode from render-time inputs only** — `usePathname()`, settings,
362
+ props. Never from `window`, `localStorage` or state an effect sets. The server
363
+ renders the attribute from what it knows, and the client's first render has to
364
+ produce the same markup: React does not promise to patch a mismatched attribute
365
+ while hydrating, so a mode only the browser could compute can leave the server's
366
+ answer on the header while `body` says the opposite.
367
+
368
+ **Give the signal one owner.** The hook's cleanup restores the value its stamp
359
369
  displaced — a snapshot, not a counted stack — so it unwinds correctly as one
360
- component mounts, re-stamps and unmounts, and does *not* compose if two
361
- components both stamp during a route transition.
370
+ component mounts, changes mode and unmounts, and does *not* compose if two
371
+ components both declare a mode during a route transition.
372
+
373
+ The two halves are exported too, for a header that cannot call a hook:
374
+ `chromeModeAttrs(mode)` returns the attribute and is pure, so a server-rendered
375
+ header can call it, and `setChromeMode(mode)` is the `body` stamp, called from an
376
+ effect with the cleanup it returns handed straight back. Prefer the hook — it is
377
+ both, in the right order.
362
378
 
363
379
  The section then styles all three appearances, and no descriptor changes:
364
380
 
@@ -386,7 +402,8 @@ did.
386
402
  **`docked` outranks `scrolled` on purpose.** On an inner page both are true at
387
403
  once — a docked header is scrolled the moment the shopper moves — and both set
388
404
  the same property on the same band. The `docked` selector is qualified with
389
- `body`, which makes it (0,2,1) against `scrolled` at (0,2,0), so the mode wins
405
+ `body` — both of its branches, the `body` stamp and `body:has(…)`, weigh the
406
+ same — which makes it (0,2,1) against `scrolled` at (0,2,0), so the mode wins
390
407
  on specificity rather than on whichever rule happened to be emitted last. That
391
408
  is the right answer and not an arbitrary one: `scrolled` describes how far the
392
409
  page has slid under a bar that floats over it, and once the bar is docked the
@@ -4,6 +4,7 @@ import React from 'react';
4
4
  import { render, screen } from '@testing-library/react';
5
5
 
6
6
  import * as chromeMode from '../../chrome/chrome-mode';
7
+ import * as chromeModeHook from '../../chrome/use-chrome-mode';
7
8
  import * as blockContext from '../block-context';
8
9
  import * as overlay from '../../hooks/use-overlay';
9
10
  import { resetCustomCollection } from '../collect';
@@ -15,8 +16,10 @@ import type { Section } from '../../theme-section';
15
16
  // `server-only`, so a client module cannot reach them there.
16
17
  import {
17
18
  CHROME_MODE_DOCKED,
19
+ chromeModeAttrs,
18
20
  defineCustomSection,
19
21
  setChromeMode,
22
+ useChromeMode,
20
23
  useCustomBlockContext,
21
24
  useOverlay
22
25
  } from '../index';
@@ -147,6 +150,53 @@ describe('useCustomBlockContext', () => {
147
150
  });
148
151
  });
149
152
 
153
+ describe('the chrome mode exports for first paint', () => {
154
+ it('re-exports the same bindings rather than second copies', () => {
155
+ expect(chromeModeAttrs).toBe(chromeMode.chromeModeAttrs);
156
+ expect(useChromeMode).toBe(chromeModeHook.useChromeMode);
157
+ });
158
+
159
+ it('keeps use-chrome-mode.ts client-safe: a client directive, and react and chrome-mode as its only imports', () => {
160
+ const source = fs.readFileSync(
161
+ path.resolve(__dirname, '../../chrome/use-chrome-mode.ts'),
162
+ 'utf8'
163
+ );
164
+ const sources = Array.from(
165
+ source.matchAll(/^import[\s\S]*?from\s+['"]([^'"]+)['"]/gm)
166
+ ).map((match) => match[1]);
167
+
168
+ expect(source.trimStart()).toMatch(/^['"]use client['"]/);
169
+ expect(source).not.toMatch(/server-only/);
170
+ expect(sources.length).toBeGreaterThan(0);
171
+ sources.forEach((from) => {
172
+ expect(['react', './chrome-mode']).toContain(from);
173
+ });
174
+ });
175
+ });
176
+
177
+ describe('useChromeMode from the custom entry', () => {
178
+ function StoreHeader({ mode }: { mode: ChromeMode }) {
179
+ return <header data-testid="store-header" {...useChromeMode(mode)} />;
180
+ }
181
+
182
+ afterEach(() => {
183
+ document.body.removeAttribute('data-chrome-mode');
184
+ });
185
+
186
+ it('puts the mode on the header root and on body, and takes body back on unmount', () => {
187
+ const { unmount } = render(<StoreHeader mode={CHROME_MODE_DOCKED} />);
188
+
189
+ expect(
190
+ screen.getByTestId('store-header').getAttribute('data-chrome-mode')
191
+ ).toBe('docked');
192
+ expect(document.body.getAttribute('data-chrome-mode')).toBe('docked');
193
+
194
+ unmount();
195
+
196
+ expect(document.body.hasAttribute('data-chrome-mode')).toBe(false);
197
+ });
198
+ });
199
+
150
200
  describe('setChromeMode from the custom entry', () => {
151
201
  afterEach(() => {
152
202
  document.body.removeAttribute('data-chrome-mode');
@@ -239,12 +239,28 @@ export { findRegionChild, isRegionNode, regionNodeName } from './region-nodes';
239
239
  export { partAttrs } from '../utils/part-styles';
240
240
 
241
241
  /**
242
- * The chrome mode writer, re-exported so a custom header section can declare
243
- * its docked appearance from this module. The package root exports the same
242
+ * The chrome mode, re-exported so a custom header section can declare its
243
+ * docked appearance from this module. The package root exports the same
244
244
  * symbols but pulls in `server-only`, which a client module cannot import.
245
- * `chrome/chrome-mode.ts` has no imports and no directive, and `setChromeMode`
246
- * is a no-op without a `document`, so it is safe on either graph. Call it from
247
- * an effect and return the cleanup it hands back.
245
+ *
246
+ * `useChromeMode` is the one to reach for: call it in the header with a mode
247
+ * computed from render-time inputs (`usePathname()`, settings, props) and
248
+ * spread what it returns on the header's root element. That attribute is the
249
+ * first-paint signal; the hook also stamps `body` from a layout effect.
250
+ * `chrome/use-chrome-mode.ts` is 'use client' and imports only react and
251
+ * `chrome-mode.ts`, so it is safe to re-export here.
252
+ *
253
+ * `chromeModeAttrs` and `setChromeMode` are its two halves, for a header that
254
+ * cannot use the hook — `chromeModeAttrs` is pure, so a server-rendered header
255
+ * can call it. `chrome/chrome-mode.ts` has no imports and no directive, and
256
+ * `setChromeMode` is a no-op without a `document`, so both are safe on either
257
+ * graph. Call `setChromeMode` from an effect and return the cleanup it hands
258
+ * back.
248
259
  */
249
- export { CHROME_MODE_DOCKED, setChromeMode } from '../chrome/chrome-mode';
260
+ export {
261
+ CHROME_MODE_DOCKED,
262
+ chromeModeAttrs,
263
+ setChromeMode
264
+ } from '../chrome/chrome-mode';
250
265
  export type { ChromeMode } from '../chrome/chrome-mode';
266
+ export { useChromeMode } from '../chrome/use-chrome-mode';
package/src/index.ts CHANGED
@@ -66,9 +66,11 @@ export {
66
66
  export {
67
67
  CHROME_MODE_ATTRIBUTE,
68
68
  CHROME_MODE_DOCKED,
69
+ chromeModeAttrs,
69
70
  setChromeMode
70
71
  } from './chrome/chrome-mode';
71
72
  export type { ChromeMode } from './chrome/chrome-mode';
73
+ export { useChromeMode } from './chrome/use-chrome-mode';
72
74
  export { PreviewAuthStateReporter } from './chrome/preview-auth-state';
73
75
  export { LiveBasketPageContext } from './chrome/live-basket-page-context';
74
76
  export { LiveWishlistPageContext } from './chrome/live-wishlist-page-context';
@@ -0,0 +1,38 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, ReactNode, useContext } from 'react';
4
+ import { menuSeedKey } from './blocks/menu-query-args';
5
+ import type { MenuQueryArgs, MenuSeeds } from './blocks/menu-query-args';
6
+
7
+ /**
8
+ * Server-fetched menus for the nav-menu blocks of one placeholder
9
+ * (chrome/menu-seed-source.ts). `null` wherever no server answered: the
10
+ * designer's modal preview host, a brand that mounts the wrapper itself.
11
+ * Blocks then fetch on the client exactly as they always have.
12
+ *
13
+ * Read straight from the prop, no state: the server and the first client
14
+ * render must see the same value or hydration would not match.
15
+ */
16
+ export const MenuSeedContext = createContext<MenuSeeds | null>(null);
17
+
18
+ export function MenuSeedProvider({
19
+ value,
20
+ children
21
+ }: {
22
+ value: MenuSeeds | null;
23
+ children?: ReactNode;
24
+ }) {
25
+ return (
26
+ <MenuSeedContext.Provider value={value}>
27
+ {children}
28
+ </MenuSeedContext.Provider>
29
+ );
30
+ }
31
+
32
+ /** The seeded entries for these args, or `undefined` when none were seeded. */
33
+ export function useMenuSeed(args: MenuQueryArgs): unknown[] | undefined {
34
+ const seeds = useContext(MenuSeedContext);
35
+ const seeded = seeds?.[menuSeedKey(args)];
36
+
37
+ return Array.isArray(seeded) ? seeded : undefined;
38
+ }
@@ -16,6 +16,8 @@ import {
16
16
  import { useThemePageContext } from './theme-page-context';
17
17
  import { ThemePageContextProvider } from './theme-page-context';
18
18
  import { ThemeSettingsProvider } from './theme-settings-context';
19
+ import { MenuSeedProvider } from './menu-seed-context';
20
+ import type { MenuSeeds } from './blocks/menu-query-args';
19
21
  import { generateThemeCSS, readThemeTypographyFont } from './utils';
20
22
  import { breakpointForWidth } from './utils/breakpoints';
21
23
  import { PartStatePreview } from './components/part-state-preview';
@@ -221,6 +223,13 @@ interface ThemePlaceholderWrapperProps {
221
223
  dataSources?: any[];
222
224
  initialThemeSettings?: Record<string, unknown> | null;
223
225
  initialPageContext?: Record<string, unknown> | null;
226
+ /**
227
+ * Menus the server fetched for this placeholder's nav-menu blocks
228
+ * (chrome/menu-seed-source.ts), so the category row is in the served HTML.
229
+ * Absent for callers without a server render; the blocks then fetch on the
230
+ * client as before.
231
+ */
232
+ initialMenuSeeds?: MenuSeeds | null;
224
233
  }
225
234
 
226
235
  declare global {
@@ -357,7 +366,8 @@ export default function ThemePlaceholderWrapper({
357
366
  isDesignMode,
358
367
  dataSources: initialDataSources = [],
359
368
  initialThemeSettings = null,
360
- initialPageContext = null
369
+ initialPageContext = null,
370
+ initialMenuSeeds = null
361
371
  }: ThemePlaceholderWrapperProps) {
362
372
  const inheritedPageContext = useThemePageContext();
363
373
  const [sections, setSections] = useState<Section[]>(initialSections);
@@ -1058,96 +1068,98 @@ export default function ThemePlaceholderWrapper({
1058
1068
  // falls back to its empty state.
1059
1069
  <ThemePageContextProvider value={resolvedPageContext}>
1060
1070
  <ThemeSettingsProvider value={themeSettings}>
1061
- {/*
1062
- Pins are released while browsing — a button frozen in its hover state
1063
- is not what the live site looks like. The editor re-sends the pinned
1064
- state when the designer comes back, so the panel and the canvas agree.
1065
- */}
1066
- <PartStatePreview isDesigner={isEditing} />
1067
- {/*
1068
- Tells the editor which languages this storefront has and follows its
1069
- language switch. Every placeholder renders one; they share a single
1070
- listener and a single announcement (see editor-locale-bridge.tsx).
1071
- */}
1072
- <EditorLocaleBridge />
1073
- {themeBackgroundCSS && (
1074
- <style dangerouslySetInnerHTML={{ __html: themeBackgroundCSS }} />
1075
- )}
1076
- {dynamicCSS && (
1077
- <style dangerouslySetInnerHTML={{ __html: dynamicCSS }} />
1078
- )}
1079
- {/*
1080
- Deliberately unpainted. This wrapper sits behind every section of the
1081
- slot, so any colour on it showed through a section that asked for
1082
- `background-color: transparent` — the shipped default. That is why a
1083
- composed header could not sit over a hero: the section was transparent
1084
- and the wrapper behind it was not. The page colour belongs to `body`
1085
- (see buildThemeBackgroundCss), which paints the same pixels everywhere
1086
- the slot does not overlap something else.
1087
- */}
1088
- <div data-placeholder={slug} className="theme-placeholder">
1089
- {renderedSections
1090
- .sort((a, b) => a.order - b.order)
1091
- .filter(
1092
- (section) =>
1093
- !section.hidden &&
1094
- (isEditing ||
1095
- isPublishWindowVisible(
1096
- section.properties?.['publish-window']
1097
- ))
1098
- )
1099
- .map((section) => (
1100
- <SectionErrorBoundary key={section.id} sectionId={section.id}>
1101
- <ThemeSection
1102
- section={section}
1103
- placeholderId={placeholderSlug}
1104
- pageContext={resolvedPageContext}
1105
- isDesigner={isEditing}
1106
- isSelected={selectedSectionId === section.id}
1107
- selectedBlockId={selectedBlockId}
1108
- currentBreakpoint={currentBreakpoint}
1109
- onSelect={setSelectedSectionId}
1110
- onMoveUp={() =>
1111
- sendAction('MOVE_SECTION_UP', {
1112
- placeholderId: placeholderSlug,
1113
- sectionId: section.id
1114
- })
1115
- }
1116
- onMoveDown={() =>
1117
- sendAction('MOVE_SECTION_DOWN', {
1118
- placeholderId: placeholderSlug,
1119
- sectionId: section.id
1120
- })
1121
- }
1122
- onDuplicate={() =>
1123
- sendAction('DUPLICATE_SECTION', {
1124
- placeholderId: placeholderSlug,
1125
- sectionId: section.id
1126
- })
1127
- }
1128
- onToggleVisibility={() =>
1129
- sendAction('TOGGLE_SECTION_VISIBILITY', {
1130
- placeholderId: placeholderSlug,
1131
- sectionId: section.id
1132
- })
1133
- }
1134
- onDelete={() =>
1135
- sendAction('DELETE_SECTION', {
1136
- placeholderId: placeholderSlug,
1137
- sectionId: section.id
1138
- })
1139
- }
1140
- onRename={(newLabel) =>
1141
- sendAction('RENAME_SECTION', {
1142
- placeholderId: placeholderSlug,
1143
- sectionId: section.id,
1144
- label: newLabel
1145
- })
1146
- }
1147
- />
1148
- </SectionErrorBoundary>
1149
- ))}
1150
- </div>
1071
+ <MenuSeedProvider value={initialMenuSeeds}>
1072
+ {/*
1073
+ Pins are released while browsing — a button frozen in its hover state
1074
+ is not what the live site looks like. The editor re-sends the pinned
1075
+ state when the designer comes back, so the panel and the canvas agree.
1076
+ */}
1077
+ <PartStatePreview isDesigner={isEditing} />
1078
+ {/*
1079
+ Tells the editor which languages this storefront has and follows its
1080
+ language switch. Every placeholder renders one; they share a single
1081
+ listener and a single announcement (see editor-locale-bridge.tsx).
1082
+ */}
1083
+ <EditorLocaleBridge />
1084
+ {themeBackgroundCSS && (
1085
+ <style dangerouslySetInnerHTML={{ __html: themeBackgroundCSS }} />
1086
+ )}
1087
+ {dynamicCSS && (
1088
+ <style dangerouslySetInnerHTML={{ __html: dynamicCSS }} />
1089
+ )}
1090
+ {/*
1091
+ Deliberately unpainted. This wrapper sits behind every section of the
1092
+ slot, so any colour on it showed through a section that asked for
1093
+ `background-color: transparent` — the shipped default. That is why a
1094
+ composed header could not sit over a hero: the section was transparent
1095
+ and the wrapper behind it was not. The page colour belongs to `body`
1096
+ (see buildThemeBackgroundCss), which paints the same pixels everywhere
1097
+ the slot does not overlap something else.
1098
+ */}
1099
+ <div data-placeholder={slug} className="theme-placeholder">
1100
+ {renderedSections
1101
+ .sort((a, b) => a.order - b.order)
1102
+ .filter(
1103
+ (section) =>
1104
+ !section.hidden &&
1105
+ (isEditing ||
1106
+ isPublishWindowVisible(
1107
+ section.properties?.['publish-window']
1108
+ ))
1109
+ )
1110
+ .map((section) => (
1111
+ <SectionErrorBoundary key={section.id} sectionId={section.id}>
1112
+ <ThemeSection
1113
+ section={section}
1114
+ placeholderId={placeholderSlug}
1115
+ pageContext={resolvedPageContext}
1116
+ isDesigner={isEditing}
1117
+ isSelected={selectedSectionId === section.id}
1118
+ selectedBlockId={selectedBlockId}
1119
+ currentBreakpoint={currentBreakpoint}
1120
+ onSelect={setSelectedSectionId}
1121
+ onMoveUp={() =>
1122
+ sendAction('MOVE_SECTION_UP', {
1123
+ placeholderId: placeholderSlug,
1124
+ sectionId: section.id
1125
+ })
1126
+ }
1127
+ onMoveDown={() =>
1128
+ sendAction('MOVE_SECTION_DOWN', {
1129
+ placeholderId: placeholderSlug,
1130
+ sectionId: section.id
1131
+ })
1132
+ }
1133
+ onDuplicate={() =>
1134
+ sendAction('DUPLICATE_SECTION', {
1135
+ placeholderId: placeholderSlug,
1136
+ sectionId: section.id
1137
+ })
1138
+ }
1139
+ onToggleVisibility={() =>
1140
+ sendAction('TOGGLE_SECTION_VISIBILITY', {
1141
+ placeholderId: placeholderSlug,
1142
+ sectionId: section.id
1143
+ })
1144
+ }
1145
+ onDelete={() =>
1146
+ sendAction('DELETE_SECTION', {
1147
+ placeholderId: placeholderSlug,
1148
+ sectionId: section.id
1149
+ })
1150
+ }
1151
+ onRename={(newLabel) =>
1152
+ sendAction('RENAME_SECTION', {
1153
+ placeholderId: placeholderSlug,
1154
+ sectionId: section.id,
1155
+ label: newLabel
1156
+ })
1157
+ }
1158
+ />
1159
+ </SectionErrorBoundary>
1160
+ ))}
1161
+ </div>
1162
+ </MenuSeedProvider>
1151
1163
  </ThemeSettingsProvider>
1152
1164
  </ThemePageContextProvider>
1153
1165
  );
@@ -4,6 +4,7 @@ import ThemePlaceholderWrapper from './theme-placeholder-wrapper';
4
4
  import { ThemeFontsLink } from './chrome/theme-fonts-link';
5
5
  import { ThemeGoogleFontsLinks } from './chrome/theme-google-fonts-links';
6
6
  import { loadPlaceholderSections } from './load-placeholder-sections';
7
+ import { loadMenuSeeds } from './chrome/menu-seed-source';
7
8
  import { generateThemeCSS } from './utils';
8
9
 
9
10
  interface ThemePlaceholderProps {
@@ -23,6 +24,9 @@ export default async function ThemePlaceholder({
23
24
 
24
25
  const { sections, placeholderId, dataSources, themeSettings } = loaded;
25
26
  const themeCSS = generateThemeCSS(sections);
27
+ // The live and the preview-snapshot loads both arrive here, so both get the
28
+ // menus their nav-menu blocks would otherwise fetch after hydration.
29
+ const menuSeeds = await loadMenuSeeds(sections);
26
30
 
27
31
  return (
28
32
  <>
@@ -37,6 +41,7 @@ export default async function ThemePlaceholder({
37
41
  dataSources={dataSources}
38
42
  initialThemeSettings={themeSettings}
39
43
  initialPageContext={pageContext}
44
+ initialMenuSeeds={menuSeeds}
40
45
  />
41
46
  </>
42
47
  );