@akinon/pz-theme 2.0.128-rc.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 CHANGED
@@ -1,40 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.128-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c2]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
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 [1595d458]
35
- - Updated dependencies [dca12811]
36
- - @akinon/next@2.0.128-rc.0
37
- - @akinon/theme-contract@2.0.128-rc.0
3
+ ## 2.0.128
4
+
5
+ ### Patch Changes
6
+
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
38
10
 
39
11
  ## 2.0.127
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.128-rc.0",
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-rc.0",
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-rc.0",
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-rc.0",
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
@@ -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
+ });
@@ -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
+ });