@akinon/pz-theme 2.0.128-beta.0 → 2.0.128
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 +4 -4
- package/package.json +4 -4
- package/src/__tests__/commerce-channel-frozen.test.ts +118 -0
- package/src/custom/README-sections.md +13 -0
- package/src/custom/README.md +79 -0
- package/src/custom/__tests__/custom-entry-exports.test.tsx +36 -2
- package/src/custom/index.ts +38 -0
- package/src/hooks/__tests__/use-theme-signal.test.tsx +241 -0
- package/src/hooks/use-theme-signal.ts +144 -0
- package/src/overlays/__tests__/server-read-path-client-free.test.ts +8 -2
- package/src/utils/__tests__/theme-signal.test.ts +284 -0
- package/src/utils/theme-signal.ts +419 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.128
|
|
3
|
+
## 2.0.128
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- @akinon/next@2.0.128
|
|
9
|
-
- @akinon/theme-contract@2.0.128
|
|
7
|
+
- 2bf278e: ZERO-5156: Add a shared event API for theme-composed sections and blocks
|
|
8
|
+
- @akinon/next@2.0.128
|
|
9
|
+
- @akinon/theme-contract@2.0.128
|
|
10
10
|
|
|
11
11
|
## 2.0.127
|
|
12
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.128
|
|
4
|
+
"version": "2.0.128",
|
|
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.128
|
|
11
|
+
"@akinon/next": "2.0.128",
|
|
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.128
|
|
16
|
+
"@akinon/theme-contract": "2.0.128",
|
|
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.128
|
|
24
|
+
"@akinon/next": "2.0.128",
|
|
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,118 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The commerce channel is closed. New moments go through `emitThemeSignal`.
|
|
6
|
+
*
|
|
7
|
+
* Two emit paths now live side by side on purpose (ZERO-5156): the commerce
|
|
8
|
+
* events keep dispatching `pz:commerce` exactly as they always have, and every
|
|
9
|
+
* new interaction moment dispatches `pz:theme`. Nothing enforces that split at
|
|
10
|
+
* runtime — the older emitter is the one everyone has already seen, its name
|
|
11
|
+
* reads generic, and adding a seventeenth call to it is one copy-paste away.
|
|
12
|
+
*
|
|
13
|
+
* The consequence of getting it wrong is not local. The one bridge that exists
|
|
14
|
+
* in the fleet (gant_next) ends in an unconditional flat dataLayer push, so a
|
|
15
|
+
* UI moment named on the commerce channel starts writing bare events into a
|
|
16
|
+
* live GA4 property the day that brand bumps this package — with nothing
|
|
17
|
+
* failing anywhere in this repo.
|
|
18
|
+
*
|
|
19
|
+
* So the call sites are frozen here. Adding a genuinely new COMMERCE event is
|
|
20
|
+
* a deliberate act: update this map in the same change, and check the
|
|
21
|
+
* consuming brand bridge maps the action rather than flat-pushing it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const SRC_ROOT = path.resolve(__dirname, '..');
|
|
25
|
+
|
|
26
|
+
/** file path relative to src -> number of `emitCommerceEvent(` calls. */
|
|
27
|
+
const FROZEN_COMMERCE_CALL_SITES: Record<string, number> = {
|
|
28
|
+
'blocks/account-menu-block.tsx': 1,
|
|
29
|
+
'blocks/add-to-cart-block.tsx': 1,
|
|
30
|
+
'blocks/auth-form-block.tsx': 3,
|
|
31
|
+
'blocks/basket-items-block.tsx': 1,
|
|
32
|
+
'blocks/favorite-button-block.tsx': 1,
|
|
33
|
+
'blocks/favourites-grid-block.tsx': 3,
|
|
34
|
+
'blocks/group-block.tsx': 1,
|
|
35
|
+
'blocks/product-grid-block.tsx': 2,
|
|
36
|
+
'blocks/product-recommendations-block.tsx': 1,
|
|
37
|
+
'blocks/search-box-block.tsx': 1,
|
|
38
|
+
// One call, four page-scope actions behind it (view_item, view_cart,
|
|
39
|
+
// begin_checkout, purchase) — the action is a variable there.
|
|
40
|
+
'theme-placeholder-wrapper.tsx': 1
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx']);
|
|
44
|
+
|
|
45
|
+
/** The emitter's own module: the definition and its GA4 bridge live there. */
|
|
46
|
+
const EXCLUDED_FILES = new Set(['utils/analytics.ts']);
|
|
47
|
+
|
|
48
|
+
const collectSourceFiles = (directory: string, collected: string[] = []) => {
|
|
49
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
50
|
+
const full = path.join(directory, entry.name);
|
|
51
|
+
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
if (entry.name === '__tests__' || entry.name === 'node_modules') continue;
|
|
54
|
+
collectSourceFiles(full, collected);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!SOURCE_EXTENSIONS.has(path.extname(entry.name))) continue;
|
|
59
|
+
if (entry.name.endsWith('.test.ts') || entry.name.endsWith('.test.tsx')) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
collected.push(full);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return collected;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const countCommerceCalls = (): Record<string, number> => {
|
|
70
|
+
const counts: Record<string, number> = {};
|
|
71
|
+
|
|
72
|
+
for (const file of collectSourceFiles(SRC_ROOT)) {
|
|
73
|
+
const relative = path.relative(SRC_ROOT, file).split(path.sep).join('/');
|
|
74
|
+
if (EXCLUDED_FILES.has(relative)) continue;
|
|
75
|
+
|
|
76
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
77
|
+
const matches = source.match(/emitCommerceEvent\s*\(/g);
|
|
78
|
+
if (matches) counts[relative] = matches.length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return counts;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
describe('commerce channel call sites', () => {
|
|
85
|
+
it('has not grown — new moments belong on emitThemeSignal', () => {
|
|
86
|
+
expect(countCommerceCalls()).toEqual(FROZEN_COMMERCE_CALL_SITES);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('keeps the two channels distinct', () => {
|
|
90
|
+
const analytics = fs.readFileSync(
|
|
91
|
+
path.join(SRC_ROOT, 'utils/analytics.ts'),
|
|
92
|
+
'utf8'
|
|
93
|
+
);
|
|
94
|
+
const signal = fs.readFileSync(
|
|
95
|
+
path.join(SRC_ROOT, 'utils/theme-signal.ts'),
|
|
96
|
+
'utf8'
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// The commerce bridge must never listen to the new channel: it flat-pushes
|
|
100
|
+
// anything it does not recognise as an ecommerce verb.
|
|
101
|
+
expect(analytics).not.toContain('pz:theme');
|
|
102
|
+
expect(analytics).not.toContain('theme-signal');
|
|
103
|
+
expect(signal).not.toContain("'pz:commerce'");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('keeps the new emitter callable from a server graph', () => {
|
|
107
|
+
// Guarded properly by the import-walking boundary test; this is the cheap
|
|
108
|
+
// first line, because a directive added here is the single change that
|
|
109
|
+
// would break every brand build while the whole suite stays green.
|
|
110
|
+
const signal = fs.readFileSync(
|
|
111
|
+
path.join(SRC_ROOT, 'utils/theme-signal.ts'),
|
|
112
|
+
'utf8'
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(signal).not.toMatch(/^\s*(['"])use client\1/);
|
|
116
|
+
expect(signal).not.toContain('server-only');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -296,6 +296,19 @@ the English storefront too. Run each one through `useLocalizeHref()` where
|
|
|
296
296
|
you read it, or render it with `ThemeLink`. See "Links and the page's
|
|
297
297
|
language" in [README.md](./README.md).
|
|
298
298
|
|
|
299
|
+
## Reporting what happened
|
|
300
|
+
|
|
301
|
+
Identical to a custom block, and documented in `README.md` under **Reporting
|
|
302
|
+
what happened**: `useThemeSignal()` picks the section up from the context it
|
|
303
|
+
already renders in, so `sectionId` and `sectionType` ride the payload with
|
|
304
|
+
nothing to thread. `useThemeSignalOnce({ name: 'section.seen', key })` is the
|
|
305
|
+
form a section usually wants — it reports the first time a key resolves and
|
|
306
|
+
stays quiet on every re-render after it.
|
|
307
|
+
|
|
308
|
+
Do not guard a report with `isDesigner`. The emitter is already silent while the
|
|
309
|
+
editor is attached, browse mode included, and that prop goes false under **Use
|
|
310
|
+
preview** while the editor is still open.
|
|
311
|
+
|
|
299
312
|
## Parts must live on YOUR markup
|
|
300
313
|
|
|
301
314
|
Part rules are generated with an ownership guard that excludes anything inside a
|
package/src/custom/README.md
CHANGED
|
@@ -303,6 +303,85 @@ attached". The editor's **Use preview** toggle turns it off while the editor
|
|
|
303
303
|
stays open, so the canvas behaves like the live site: whatever you held open
|
|
304
304
|
closes and links navigate again.
|
|
305
305
|
|
|
306
|
+
## Reporting what happened
|
|
307
|
+
|
|
308
|
+
A custom unit can report a moment — a strip was seen, a lookbook was opened, a
|
|
309
|
+
tab was changed — and the brand decides in its own repo what that moment means
|
|
310
|
+
to GA4, Insider or its own service. The theme never names an analytics event and
|
|
311
|
+
never pushes to a tag manager, which is why one renderer can serve brands whose
|
|
312
|
+
schemas disagree.
|
|
313
|
+
|
|
314
|
+
```tsx
|
|
315
|
+
import { useThemeSignal } from '@akinon/pz-theme/src/custom';
|
|
316
|
+
|
|
317
|
+
const LookbookStrip = () => {
|
|
318
|
+
// Identity comes from the context this unit already renders inside, so the
|
|
319
|
+
// placeholder, section and block ride the payload with no props to thread.
|
|
320
|
+
const signal = useThemeSignal();
|
|
321
|
+
|
|
322
|
+
return (
|
|
323
|
+
<button onClick={() => signal('custom.lookbook.opened', { look: 3 })}>
|
|
324
|
+
…
|
|
325
|
+
</button>
|
|
326
|
+
);
|
|
327
|
+
};
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
For the "first time it becomes true" case — a section seen, a promo shown — use
|
|
331
|
+
the latching form. A `null` key means "not ready yet" and reports nothing, which
|
|
332
|
+
is what stops a unit reporting before the data it describes has resolved:
|
|
333
|
+
|
|
334
|
+
```tsx
|
|
335
|
+
useThemeSignalOnce({
|
|
336
|
+
name: 'section.seen',
|
|
337
|
+
key: inView ? lookbookId : null,
|
|
338
|
+
facts: { lookSlot: 'home-hero' }
|
|
339
|
+
});
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Outside React — a module-level bus, a delegated document listener — call the
|
|
343
|
+
primitive directly:
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
import { emitThemeSignal } from '@akinon/pz-theme/src/custom';
|
|
347
|
+
|
|
348
|
+
emitThemeSignal('modal.opened', { modalId: id }, { key: id, once: true });
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
The brand listens once, anywhere in its client tree, and maps:
|
|
352
|
+
|
|
353
|
+
```tsx
|
|
354
|
+
import { subscribeToThemeSignals } from '@akinon/pz-theme/src/custom';
|
|
355
|
+
|
|
356
|
+
useEffect(
|
|
357
|
+
() =>
|
|
358
|
+
subscribeToThemeSignals((detail) => {
|
|
359
|
+
if (detail.name !== 'custom.lookbook.opened') return;
|
|
360
|
+
window.dataLayer.push({ event: 'lookbook_open', look: detail.look });
|
|
361
|
+
}),
|
|
362
|
+
[]
|
|
363
|
+
);
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Four things the emitter does so a call site does not have to:
|
|
367
|
+
|
|
368
|
+
- **It is silent while the editor is attached, browse mode included.** It reads
|
|
369
|
+
the editor handshake flag itself, so do NOT wrap a report in an `isDesigner`
|
|
370
|
+
check — that prop means "the editing affordances are on", goes false under
|
|
371
|
+
**Use preview**, and a report guarded on it reports a designer's clicks as a
|
|
372
|
+
shopper's.
|
|
373
|
+
- **`{ key, once: true }` is the latch**, reset on client-side navigation. A
|
|
374
|
+
repeat inside the same page view is a no-op.
|
|
375
|
+
- **A ceiling** — 24 per key, 200 per page view. An autoplaying carousel would
|
|
376
|
+
otherwise emit continuously, billed to whatever the brand forwards to.
|
|
377
|
+
- **The payload is closed to flat scalars** and sanitized at emit. A product
|
|
378
|
+
object, a DOM node or shopper-typed text is dropped with a dev warning, which
|
|
379
|
+
is how the no-PII rule is enforced rather than reviewed.
|
|
380
|
+
|
|
381
|
+
A brand's own moment must be namespaced `custom.<something>`, so it can never
|
|
382
|
+
collide with a name this package adds later and a bridge can tell the two apart.
|
|
383
|
+
The package's own names are listed in `THEME_SIGNAL_NAMES`.
|
|
384
|
+
|
|
306
385
|
## Styling parts
|
|
307
386
|
|
|
308
387
|
The `field-<key>` parts come free from `ThemeEditable`. Everything else a block
|
|
@@ -9,6 +9,8 @@ import * as blockContext from '../block-context';
|
|
|
9
9
|
import * as overlay from '../../hooks/use-overlay';
|
|
10
10
|
import * as themeLink from '../../components/theme-link';
|
|
11
11
|
import * as localizeHrefHook from '../../hooks/use-localize-href';
|
|
12
|
+
import * as themeSignal from '../../utils/theme-signal';
|
|
13
|
+
import * as themeSignalHook from '../../hooks/use-theme-signal';
|
|
12
14
|
import { resetCustomCollection } from '../collect';
|
|
13
15
|
import { CustomSectionHost } from '../section-host';
|
|
14
16
|
import type { Section } from '../../theme-section';
|
|
@@ -22,10 +24,15 @@ import {
|
|
|
22
24
|
defineCustomSection,
|
|
23
25
|
setChromeMode,
|
|
24
26
|
useChromeMode,
|
|
27
|
+
emitThemeSignal,
|
|
28
|
+
subscribeToThemeSignals,
|
|
25
29
|
ThemeLink,
|
|
30
|
+
THEME_SIGNAL_NAMES,
|
|
26
31
|
useCustomBlockContext,
|
|
27
32
|
useLocalizeHref,
|
|
28
|
-
useOverlay
|
|
33
|
+
useOverlay,
|
|
34
|
+
useThemeSignal,
|
|
35
|
+
useThemeSignalOnce
|
|
29
36
|
} from '../index';
|
|
30
37
|
import type {
|
|
31
38
|
ChromeMode,
|
|
@@ -46,7 +53,7 @@ const section = (): Section =>
|
|
|
46
53
|
order: 0,
|
|
47
54
|
hidden: false,
|
|
48
55
|
blocks: []
|
|
49
|
-
} as unknown as Section
|
|
56
|
+
}) as unknown as Section;
|
|
50
57
|
|
|
51
58
|
/** Reports what a developer's own code would read from the hook. */
|
|
52
59
|
function EditorProbe() {
|
|
@@ -261,3 +268,30 @@ describe('the custom entry exports ZERO-5100 added', () => {
|
|
|
261
268
|
expect(useLocalizeHref).toBe(localizeHrefHook.useLocalizeHref);
|
|
262
269
|
});
|
|
263
270
|
});
|
|
271
|
+
|
|
272
|
+
describe('the custom entry exports ZERO-5156 added', () => {
|
|
273
|
+
// A custom unit reports moments through the same barrel it imports
|
|
274
|
+
// everything else from; the package root is unreachable from a client
|
|
275
|
+
// module, so the reporting API has to be here too.
|
|
276
|
+
it('re-exports the emitter and its hooks rather than second copies', () => {
|
|
277
|
+
expect(emitThemeSignal).toBe(themeSignal.emitThemeSignal);
|
|
278
|
+
expect(subscribeToThemeSignals).toBe(themeSignal.subscribeToThemeSignals);
|
|
279
|
+
expect(THEME_SIGNAL_NAMES).toBe(themeSignal.THEME_SIGNAL_NAMES);
|
|
280
|
+
expect(useThemeSignal).toBe(themeSignalHook.useThemeSignal);
|
|
281
|
+
expect(useThemeSignalOnce).toBe(themeSignalHook.useThemeSignalOnce);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('documents the reporting API where a brand developer will look', () => {
|
|
285
|
+
// The README is the brand-facing doc for this entry, and the one trap
|
|
286
|
+
// that costs a brand real data — do not guard a report with `isDesigner` —
|
|
287
|
+
// only exists in prose.
|
|
288
|
+
const readme = fs.readFileSync(
|
|
289
|
+
path.join(__dirname, '..', 'README.md'),
|
|
290
|
+
'utf8'
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
expect(readme).toContain('useThemeSignal');
|
|
294
|
+
expect(readme).toContain('subscribeToThemeSignals');
|
|
295
|
+
expect(readme).toContain('custom.');
|
|
296
|
+
});
|
|
297
|
+
});
|
package/src/custom/index.ts
CHANGED
|
@@ -183,6 +183,44 @@ export type { CustomBlockContextValue } from './block-context';
|
|
|
183
183
|
export { useOverlay } from '../hooks/use-overlay';
|
|
184
184
|
export type { OverlayHandle, UseOverlayOptions } from '../hooks/use-overlay';
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Reporting a moment (ZERO-5156).
|
|
188
|
+
*
|
|
189
|
+
* A custom section or block reports what happened — a strip was seen, a
|
|
190
|
+
* lookbook was opened — and the brand decides in its own repo what that means
|
|
191
|
+
* to GA4, Insider or its own service. The theme never names an analytics
|
|
192
|
+
* event and never pushes to a tag manager.
|
|
193
|
+
*
|
|
194
|
+
* A brand's own moment is namespaced: `custom.lookbook.opened`. The hook
|
|
195
|
+
* resolves placeholder, section and block identity from the context a custom
|
|
196
|
+
* unit already renders inside, so nothing has to be threaded through.
|
|
197
|
+
*
|
|
198
|
+
* Both are inert while the theme editor is attached, browse mode included —
|
|
199
|
+
* the emitter reads the editor handshake flag itself, so a custom unit must
|
|
200
|
+
* NOT add its own `isDesigner` check around a report.
|
|
201
|
+
*/
|
|
202
|
+
export {
|
|
203
|
+
emitThemeSignal,
|
|
204
|
+
isThemeSignalMuted,
|
|
205
|
+
subscribeToThemeSignals,
|
|
206
|
+
CUSTOM_SIGNAL_PREFIX,
|
|
207
|
+
THEME_SIGNAL_EVENT_NAME,
|
|
208
|
+
THEME_SIGNAL_NAMES
|
|
209
|
+
} from '../utils/theme-signal';
|
|
210
|
+
export type {
|
|
211
|
+
ThemeSignalDetail,
|
|
212
|
+
ThemeSignalFacts,
|
|
213
|
+
ThemeSignalIdentity,
|
|
214
|
+
ThemeSignalName,
|
|
215
|
+
ThemeSignalOptions,
|
|
216
|
+
ThemeSignalValue
|
|
217
|
+
} from '../utils/theme-signal';
|
|
218
|
+
export { useThemeSignal, useThemeSignalOnce } from '../hooks/use-theme-signal';
|
|
219
|
+
export type {
|
|
220
|
+
ThemeSignalEmit,
|
|
221
|
+
UseThemeSignalOnceOptions
|
|
222
|
+
} from '../hooks/use-theme-signal';
|
|
223
|
+
|
|
186
224
|
// What the editor bridge announces. Exported here rather than kept internal
|
|
187
225
|
// because a brand that mounts its own announce path needs the same payload the
|
|
188
226
|
// package would have sent, not a second construction of it.
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { renderHook } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CustomBlockContext,
|
|
6
|
+
type CustomBlockContextValue
|
|
7
|
+
} from '../../custom/block-context';
|
|
8
|
+
import {
|
|
9
|
+
__resetThemeSignalsForTests,
|
|
10
|
+
subscribeToThemeSignals,
|
|
11
|
+
type ThemeSignalDetail
|
|
12
|
+
} from '../../utils/theme-signal';
|
|
13
|
+
import { useThemeSignal, useThemeSignalOnce } from '../use-theme-signal';
|
|
14
|
+
|
|
15
|
+
declare global {
|
|
16
|
+
interface Window {
|
|
17
|
+
__themeEditorDesigner?: boolean;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const customBlockContext = (
|
|
22
|
+
overrides: Partial<CustomBlockContextValue> = {}
|
|
23
|
+
): CustomBlockContextValue =>
|
|
24
|
+
({
|
|
25
|
+
node: { id: 'block-d4k1z', type: 'promo-strip' },
|
|
26
|
+
scope: 'block',
|
|
27
|
+
block: { id: 'block-d4k1z', type: 'promo-strip' },
|
|
28
|
+
placeholderId: 'theme-homepage-body',
|
|
29
|
+
sectionId: 'section-h3k9q',
|
|
30
|
+
isDesigner: false,
|
|
31
|
+
currentBreakpoint: 'desktop',
|
|
32
|
+
...overrides
|
|
33
|
+
}) as CustomBlockContextValue;
|
|
34
|
+
|
|
35
|
+
const wrapperFor = (value: CustomBlockContextValue) => {
|
|
36
|
+
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
37
|
+
<CustomBlockContext.Provider value={value}>
|
|
38
|
+
{children}
|
|
39
|
+
</CustomBlockContext.Provider>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return Wrapper;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
describe('useThemeSignal (ZERO-5156)', () => {
|
|
46
|
+
let received: ThemeSignalDetail[];
|
|
47
|
+
let detach: () => void;
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
received = [];
|
|
51
|
+
detach = subscribeToThemeSignals((detail) => received.push(detail));
|
|
52
|
+
__resetThemeSignalsForTests();
|
|
53
|
+
delete window.__themeEditorDesigner;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
detach();
|
|
58
|
+
delete window.__themeEditorDesigner;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('carries the identity a renderer passes explicitly', () => {
|
|
62
|
+
const { result } = renderHook(() =>
|
|
63
|
+
useThemeSignal({
|
|
64
|
+
placeholderId: 'theme-homepage-body',
|
|
65
|
+
sectionId: 'section-h3k9q',
|
|
66
|
+
blockId: 'block-w2m4t',
|
|
67
|
+
blockType: 'slider'
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
result.current('slide.changed', { index: 1, total: 3 });
|
|
72
|
+
|
|
73
|
+
expect(received).toEqual([
|
|
74
|
+
{
|
|
75
|
+
name: 'slide.changed',
|
|
76
|
+
placeholderId: 'theme-homepage-body',
|
|
77
|
+
sectionId: 'section-h3k9q',
|
|
78
|
+
blockId: 'block-w2m4t',
|
|
79
|
+
blockType: 'slider',
|
|
80
|
+
index: 1,
|
|
81
|
+
total: 3
|
|
82
|
+
}
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('reads identity from the custom unit it renders inside', () => {
|
|
87
|
+
const { result } = renderHook(() => useThemeSignal(), {
|
|
88
|
+
wrapper: wrapperFor(customBlockContext())
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
result.current('custom.promo.shown');
|
|
92
|
+
|
|
93
|
+
expect(received[0]).toMatchObject({
|
|
94
|
+
placeholderId: 'theme-homepage-body',
|
|
95
|
+
sectionId: 'section-h3k9q',
|
|
96
|
+
blockId: 'block-d4k1z',
|
|
97
|
+
blockType: 'promo-strip'
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('reports a custom section under sectionType, not blockType', () => {
|
|
102
|
+
const { result } = renderHook(() => useThemeSignal(), {
|
|
103
|
+
wrapper: wrapperFor(
|
|
104
|
+
customBlockContext({
|
|
105
|
+
scope: 'section',
|
|
106
|
+
node: { id: 'section-h3k9q', type: 'custom.store-header' } as never
|
|
107
|
+
})
|
|
108
|
+
)
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
result.current('custom.header.pinned');
|
|
112
|
+
|
|
113
|
+
expect(received[0].sectionType).toBe('custom.store-header');
|
|
114
|
+
expect(received[0]).not.toHaveProperty('blockId');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('omits identity it does not have rather than inventing it', () => {
|
|
118
|
+
const { result } = renderHook(() => useThemeSignal());
|
|
119
|
+
|
|
120
|
+
result.current('modal.opened', { modalId: 'size-guide' });
|
|
121
|
+
|
|
122
|
+
expect(received).toEqual([{ name: 'modal.opened', modalId: 'size-guide' }]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('lets a caller override one inherited field without losing the rest', () => {
|
|
126
|
+
const { result } = renderHook(() => useThemeSignal(), {
|
|
127
|
+
wrapper: wrapperFor(customBlockContext())
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
result.current('nav.clicked', { blockId: 'block-inner', itemKey: 'women' });
|
|
131
|
+
|
|
132
|
+
expect(received[0]).toMatchObject({
|
|
133
|
+
sectionId: 'section-h3k9q',
|
|
134
|
+
blockId: 'block-inner',
|
|
135
|
+
itemKey: 'women'
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns a stable callback across re-renders', () => {
|
|
140
|
+
const { result, rerender } = renderHook(
|
|
141
|
+
({ id }: { id: string }) => useThemeSignal({ sectionId: id }),
|
|
142
|
+
{ initialProps: { id: 'section-a' } }
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const first = result.current;
|
|
146
|
+
rerender({ id: 'section-b' });
|
|
147
|
+
|
|
148
|
+
expect(result.current).toBe(first);
|
|
149
|
+
// Stable, but not stale: the identity it sends is the current one.
|
|
150
|
+
result.current('section.seen');
|
|
151
|
+
expect(received[0].sectionId).toBe('section-b');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('useThemeSignalOnce (ZERO-5156)', () => {
|
|
156
|
+
let received: ThemeSignalDetail[];
|
|
157
|
+
let detach: () => void;
|
|
158
|
+
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
received = [];
|
|
161
|
+
detach = subscribeToThemeSignals((detail) => received.push(detail));
|
|
162
|
+
__resetThemeSignalsForTests();
|
|
163
|
+
delete window.__themeEditorDesigner;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
detach();
|
|
168
|
+
delete window.__themeEditorDesigner;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('reports the first time the condition becomes true', () => {
|
|
172
|
+
const { rerender } = renderHook(
|
|
173
|
+
({ when }: { when: boolean }) =>
|
|
174
|
+
useThemeSignalOnce({
|
|
175
|
+
name: 'section.seen',
|
|
176
|
+
key: 'hero',
|
|
177
|
+
when,
|
|
178
|
+
identity: { sectionId: 'section-h3k9q' }
|
|
179
|
+
}),
|
|
180
|
+
{ initialProps: { when: false } }
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
expect(received).toEqual([]);
|
|
184
|
+
|
|
185
|
+
rerender({ when: true });
|
|
186
|
+
rerender({ when: true });
|
|
187
|
+
|
|
188
|
+
expect(received).toEqual([
|
|
189
|
+
{ name: 'section.seen', sectionId: 'section-h3k9q' }
|
|
190
|
+
]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('waits for a key rather than reporting an unresolved moment', () => {
|
|
194
|
+
const { rerender } = renderHook(
|
|
195
|
+
({ key }: { key: string | null }) =>
|
|
196
|
+
useThemeSignalOnce({ name: 'section.seen', key }),
|
|
197
|
+
{ initialProps: { key: null as string | null } }
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(received).toEqual([]);
|
|
201
|
+
|
|
202
|
+
rerender({ key: 'hero' });
|
|
203
|
+
expect(received).toHaveLength(1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('reports again when the key changes', () => {
|
|
207
|
+
const { rerender } = renderHook(
|
|
208
|
+
({ key }: { key: string }) =>
|
|
209
|
+
useThemeSignalOnce({ name: 'slide.changed', key }),
|
|
210
|
+
{ initialProps: { key: 'slide-1' } }
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
rerender({ key: 'slide-2' });
|
|
214
|
+
|
|
215
|
+
expect(received).toHaveLength(2);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('reports again on a fresh mount, the way a client-side visit does', () => {
|
|
219
|
+
const render = () =>
|
|
220
|
+
renderHook(() =>
|
|
221
|
+
useThemeSignalOnce({ name: 'section.seen', key: 'hero' })
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const first = render();
|
|
225
|
+
first.unmount();
|
|
226
|
+
// The module tally is per route; a new visit to the same page mounts a
|
|
227
|
+
// fresh unit, which is exactly the case the page-scope emitter refires on.
|
|
228
|
+
__resetThemeSignalsForTests();
|
|
229
|
+
render();
|
|
230
|
+
|
|
231
|
+
expect(received).toHaveLength(2);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('stays silent while the editor is attached', () => {
|
|
235
|
+
window.__themeEditorDesigner = true;
|
|
236
|
+
|
|
237
|
+
renderHook(() => useThemeSignalOnce({ name: 'section.seen', key: 'hero' }));
|
|
238
|
+
|
|
239
|
+
expect(received).toEqual([]);
|
|
240
|
+
});
|
|
241
|
+
});
|