@akinon/pz-theme 2.0.90-beta.0 → 2.0.90

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,12 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.90-beta.0
3
+ ## 2.0.90
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.90-beta.0
9
- - @akinon/theme-contract@2.0.90-beta.0
7
+ - dfe76c5c: ZERO-4984: Stop an unnamed slot from adopting a custom section's regions
8
+ - @akinon/next@2.0.90
9
+ - @akinon/theme-contract@2.0.90
10
10
 
11
11
  ## 2.0.89
12
12
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
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.90-beta.0",
4
+ "version": "2.0.90",
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.90-beta.0",
11
+ "@akinon/next": "2.0.90",
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.90-beta.0",
16
+ "@akinon/theme-contract": "2.0.90",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.90-beta.0",
23
+ "@akinon/next": "2.0.90",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -162,6 +162,47 @@ const registerHeader = () =>
162
162
  })
163
163
  });
164
164
 
165
+ /**
166
+ * The same header, with its one slot spelled as a bare `<ThemeSlot />`.
167
+ *
168
+ * That spelling is not exotic — it is what every block written against the
169
+ * original single-slot API uses, and a developer moving a header into a section
170
+ * carries it over unchanged. On a section it resolves against the SECTION node,
171
+ * whose direct children are the seeded regions.
172
+ */
173
+ function ZsaHeaderBareSlot() {
174
+ return (
175
+ <header>
176
+ <div data-testid="logo-area">
177
+ <ThemeRegion name="logo" />
178
+ </div>
179
+ <div data-testid="search-area">
180
+ <ThemeRegion name="search" label="Store search" />
181
+ </div>
182
+ <div data-testid="basket-area">
183
+ <ThemeRegion name="basket" />
184
+ </div>
185
+ <div data-testid="extras-area">
186
+ <ThemeSlot label="Extra header actions" />
187
+ </div>
188
+ </header>
189
+ );
190
+ }
191
+
192
+ const registerHeaderWithBareSlot = () =>
193
+ registerCustomSections('zsazsazsu', {
194
+ header: defineCustomSection(ZsaHeaderBareSlot, {
195
+ label: 'ZSA ZSA ZSU Header',
196
+ regions: [
197
+ { name: 'logo', label: 'Logo', block: 'image' },
198
+ { name: 'search', label: 'Store search', block: 'search-box' },
199
+ { name: 'basket', label: 'Basket', block: 'mini-basket' }
200
+ ],
201
+ slots: [{ label: 'Extra header actions' }],
202
+ lockRootTransforms: true
203
+ })
204
+ });
205
+
165
206
  const renderComposition = (section: Section, isDesigner = false) =>
166
207
  render(
167
208
  <ThemeSettingsProvider value={null}>
@@ -280,6 +321,35 @@ describe('the region blocks the editor actually seeds', () => {
280
321
  expect(childIdsIn('extras-area')).toEqual(['extras-child']);
281
322
  });
282
323
 
324
+ it('are never adopted by an unnamed slot the merchant has not opened', () => {
325
+ registerHeaderWithBareSlot();
326
+
327
+ // THE OTHER HALF OF THE ANTI-SLOT-FALLBACK INVARIANT, from the slot's side.
328
+ // The test above pins that a region never falls back to the section's
329
+ // direct children; this one pins that the slot's legacy fallback never
330
+ // claims them either. A merchant who has never opened the extras slot has
331
+ // no `custom-slot` node in the composition, so `resolveSlotChildren` takes
332
+ // that fallback — and every region is an ordinary block, invisible to
333
+ // `isSlotNode`. Without the region filter the whole header renders a second
334
+ // time inside the slot, with two `data-block-id` wrappers per region.
335
+ const neverOpened = composition();
336
+ neverOpened.blocks = neverOpened.blocks.filter(
337
+ (block) => block.id !== 'block-t6qz0'
338
+ );
339
+
340
+ renderComposition(neverOpened);
341
+
342
+ expect(childIdsIn('extras-area')).toEqual([]);
343
+ expect(childIdsIn('logo-area')).toEqual(['block-h3n1a']);
344
+ expect(childIdsIn('search-area')).toEqual(['block-k92rd']);
345
+ expect(childIdsIn('basket-area')).toEqual(['block-p47xv']);
346
+
347
+ // Counted across the whole render, not per area: the doubling this guards
348
+ // against puts the second copy somewhere the per-area assertions above
349
+ // cannot reach.
350
+ expect(screen.queryAllByTestId('region-child')).toHaveLength(3);
351
+ });
352
+
283
353
  it('are never spilled onto the page by a build that cannot draw the section', () => {
284
354
  const warn = jest
285
355
  .spyOn(console, 'warn')
@@ -6,6 +6,7 @@ import {
6
6
  slotNameError,
7
7
  slotNodeName
8
8
  } from '../slot-nodes';
9
+ import { REGION_NAME_PROPERTY } from '../section-types';
9
10
  import { CUSTOM_SLOT_BLOCK_TYPE, SLOT_NAME_PROPERTY } from '../types';
10
11
 
11
12
  const block = (over: Partial<Block> = {}): Block => ({
@@ -27,6 +28,19 @@ const slotNode = (properties: Record<string, unknown>, blocks: Block[] = []) =>
27
28
  blocks
28
29
  });
29
30
 
31
+ /**
32
+ * A seeded region as the editor writes one: an ORDINARY block of a real type
33
+ * carrying the reserved name property, NOT a reserved-type wrapper. That is
34
+ * precisely why `isSlotNode` cannot recognise it — see the region case below.
35
+ */
36
+ const regionBlock = (name: string): Block =>
37
+ block({
38
+ id: `region-${name}`,
39
+ type: 'image',
40
+ label: name,
41
+ properties: { [REGION_NAME_PROPERTY]: name }
42
+ });
43
+
30
44
  describe('slotNodeName', () => {
31
45
  it('reads the bare string the editor stamps', () => {
32
46
  expect(slotNodeName(slotNode({ [SLOT_NAME_PROPERTY]: 'promo' }))).toBe(
@@ -90,6 +104,20 @@ describe('resolveSlotChildren', () => {
90
104
  expect(resolveSlotChildren(halfMigrated, '')).toEqual([inAside]);
91
105
  });
92
106
 
107
+ it('keeps the regions a section seeds out of the unnamed slot', () => {
108
+ // The legacy branch reads a host's direct children as the merchant's own
109
+ // content, which is true of a custom block and false of a custom section:
110
+ // there the host is the section and its direct children are its regions.
111
+ // Regions are ordinary blocks, so `isSlotNode` waves every one of them
112
+ // through and a bare `<ThemeSlot />` on a section would render the whole
113
+ // header a second time for shoppers.
114
+ const section = block({
115
+ blocks: [regionBlock('logo'), regionBlock('search'), inPromo]
116
+ });
117
+
118
+ expect(resolveSlotChildren(section, '')).toEqual([inPromo]);
119
+ });
120
+
93
121
  it('never falls back for a named slot', () => {
94
122
  // A named slot cannot have legacy content to find: naming one was refused
95
123
  // at define time before slots were nodes.
@@ -1,4 +1,5 @@
1
1
  import type { Block } from '../theme-block';
2
+ import { isRegionNode } from './region-nodes';
2
3
  import {
3
4
  CUSTOM_SLOT_BLOCK_TYPE,
4
5
  SLOT_NAME_PATTERN,
@@ -58,6 +59,20 @@ export const slotNodeName = (block: Block): string => {
58
59
  * unnamed one — a named slot was refused at define time, so it can have no
59
60
  * legacy content to find. Slot nodes are filtered out of that branch so a block
60
61
  * re-saved with nodes does not render another slot's content here as well.
62
+ *
63
+ * REGION BLOCKS ARE FILTERED OUT OF IT TOO, and the slot-node filter does not
64
+ * cover them. The legacy branch reads a host's direct children as the
65
+ * merchant's own content, which is true of a custom BLOCK and false of a custom
66
+ * SECTION: `ThemeSlot` resolves against whatever node the context carries, and
67
+ * for a section that is the section itself, whose direct children are its
68
+ * seeded regions. A region is an ORDINARY block carrying
69
+ * `properties.regionName` rather than a reserved type, so `isSlotNode` waves
70
+ * every one of them through — a section that renders a bare `<ThemeSlot />`
71
+ * would draw its whole region set a SECOND time inside the slot for shoppers,
72
+ * with two `data-block-id` wrappers answering to each region's id. Reaching
73
+ * across to `isRegionNode` costs one import and both modules are
74
+ * directive-free, so nothing about the client boundary changes;
75
+ * `region-nodes.ts` states the same asymmetry from its own side.
61
76
  */
62
77
  export const resolveSlotChildren = (
63
78
  block: Block,
@@ -71,7 +86,10 @@ export const resolveSlotChildren = (
71
86
 
72
87
  if (node) return node.blocks ?? [];
73
88
 
74
- if (name === '') return children.filter((child) => !isSlotNode(child));
89
+ if (name === '')
90
+ return children.filter(
91
+ (child) => !isSlotNode(child) && !isRegionNode(child)
92
+ );
75
93
 
76
94
  return null;
77
95
  };