@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.
@@ -0,0 +1,144 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef } from 'react';
4
+
5
+ import { useCustomBlockContext } from '../custom/block-context';
6
+ import {
7
+ emitThemeSignal,
8
+ isThemeSignalMuted,
9
+ type ThemeSignalFacts,
10
+ type ThemeSignalIdentity,
11
+ type ThemeSignalName,
12
+ type ThemeSignalOptions
13
+ } from '../utils/theme-signal';
14
+
15
+ /**
16
+ * The React half of the theme signal API (ZERO-5156).
17
+ *
18
+ * `emitThemeSignal` is the primitive and is callable from anywhere, including
19
+ * the plain modules a third of these moments fire from — the overlay bus, the
20
+ * modal frequency rules, a delegated document listener. This hook exists for
21
+ * the one thing a plain function cannot do: resolve WHERE the moment happened.
22
+ * Section and block identity is carried by React context, so a brand's custom
23
+ * section gets it for free instead of threading four props to reach the line
24
+ * that reports.
25
+ */
26
+
27
+ export type ThemeSignalEmit = (
28
+ name: ThemeSignalName,
29
+ facts?: ThemeSignalFacts,
30
+ options?: ThemeSignalOptions
31
+ ) => boolean;
32
+
33
+ /**
34
+ * Resolve identity field by field: an explicit value wins, then the custom
35
+ * unit's context, then the field is omitted.
36
+ *
37
+ * Omitted, never fabricated — no `'unknown'`, no empty string. A bridge
38
+ * grouping by section must be able to tell absent from wrong, and an invented
39
+ * value is indistinguishable from a real one once it is in a report.
40
+ */
41
+ const resolveIdentity = (
42
+ explicit: ThemeSignalIdentity | undefined,
43
+ context: ReturnType<typeof useCustomBlockContext>
44
+ ): ThemeSignalIdentity => {
45
+ const identity: ThemeSignalIdentity = {};
46
+
47
+ const placeholderId = explicit?.placeholderId ?? context?.placeholderId;
48
+ const sectionId = explicit?.sectionId ?? context?.sectionId;
49
+ const blockId =
50
+ explicit?.blockId ??
51
+ (context?.scope === 'block' ? context?.node?.id : undefined);
52
+ const blockType =
53
+ explicit?.blockType ??
54
+ (context?.scope === 'block' ? context?.node?.type : undefined);
55
+ const sectionType =
56
+ explicit?.sectionType ??
57
+ (context?.scope === 'section' ? context?.node?.type : undefined);
58
+
59
+ if (placeholderId) identity.placeholderId = placeholderId;
60
+ if (sectionId) identity.sectionId = sectionId;
61
+ if (sectionType) identity.sectionType = sectionType;
62
+ if (blockId) identity.blockId = blockId;
63
+ if (blockType) identity.blockType = blockType;
64
+
65
+ return identity;
66
+ };
67
+
68
+ /**
69
+ * An emit bound to this unit's identity.
70
+ *
71
+ * A built-in renderer passes what its props already carry; a brand's custom
72
+ * block or section passes nothing and is identified by the context it renders
73
+ * inside. The returned function is stable across renders, so it can sit in a
74
+ * dependency array without rebuilding the effect that uses it.
75
+ */
76
+ export const useThemeSignal = (
77
+ identity?: ThemeSignalIdentity
78
+ ): ThemeSignalEmit => {
79
+ const context = useCustomBlockContext();
80
+
81
+ // Kept in a ref so the returned callback can stay stable while still
82
+ // reading the latest identity: a block moved between sections in the editor
83
+ // keeps the same component instance.
84
+ const identityRef = useRef<ThemeSignalIdentity>({});
85
+ identityRef.current = resolveIdentity(identity, context);
86
+
87
+ return useCallback(
88
+ (name, facts = {}, options = {}) =>
89
+ emitThemeSignal(name, { ...identityRef.current, ...facts }, options),
90
+ []
91
+ );
92
+ };
93
+
94
+ export interface UseThemeSignalOnceOptions {
95
+ name: ThemeSignalName;
96
+ /**
97
+ * What makes this occurrence distinct. A null or undefined key means "not
98
+ * ready yet" and reports nothing — the shape that stops a section from
99
+ * reporting before the data it describes has resolved.
100
+ */
101
+ key: string | number | null | undefined;
102
+ /** Report only once this turns true. Defaults to true. */
103
+ when?: boolean;
104
+ facts?: ThemeSignalFacts;
105
+ identity?: ThemeSignalIdentity;
106
+ }
107
+
108
+ /**
109
+ * The "report it the first time it becomes true" case: a section seen, a promo
110
+ * shown, a countdown finished.
111
+ *
112
+ * Latched on a ref, deliberately PER MOUNT rather than per route, exactly as
113
+ * the page-scope commerce emitter latches its own events: a client-side visit
114
+ * mounts a fresh unit and must report again, while re-renders of the same
115
+ * mount — a breakpoint change, context identity churn — must not. The module's
116
+ * route tally still applies on top, so neither guard can be forgotten.
117
+ */
118
+ export const useThemeSignalOnce = ({
119
+ name,
120
+ key,
121
+ when = true,
122
+ facts,
123
+ identity
124
+ }: UseThemeSignalOnceOptions): void => {
125
+ const signal = useThemeSignal(identity);
126
+ const reported = useRef<string | null>(null);
127
+
128
+ // Read through a ref so a fresh object literal on every render does not
129
+ // re-run the effect; the facts that matter are fixed by the key.
130
+ const factsRef = useRef<ThemeSignalFacts | undefined>(facts);
131
+ factsRef.current = facts;
132
+
133
+ useEffect(() => {
134
+ if (!when) return;
135
+ if (key === null || key === undefined) return;
136
+ if (isThemeSignalMuted()) return;
137
+
138
+ const latchKey = String(key);
139
+ if (reported.current === latchKey) return;
140
+ reported.current = latchKey;
141
+
142
+ signal(name, factsRef.current ?? {}, { key: latchKey, once: true });
143
+ }, [name, key, when, signal]);
144
+ };
@@ -23,7 +23,11 @@ const SRC_ROOT = path.resolve(__dirname, '../..');
23
23
  /** Entry points that are read during a server render. */
24
24
  const SERVER_ENTRY_POINTS = [
25
25
  'chrome/theme-config-source.ts',
26
- 'overlays/modal-registry.ts'
26
+ 'overlays/modal-registry.ts',
27
+ // The theme signal emitter is called from plain modules as well as from
28
+ // components, so it must stay reachable without dragging a client reference
29
+ // behind it — `browse-mode.ts` and any hook are one convenient import away.
30
+ 'utils/theme-signal.ts'
27
31
  ];
28
32
 
29
33
  const CANDIDATE_EXTENSIONS = ['.ts', '.tsx', '/index.ts', '/index.tsx'];
@@ -61,7 +65,9 @@ const isTypeOnlyImport = (source: string, index: number): boolean => {
61
65
  const isClientModule = (source: string): boolean =>
62
66
  /^\s*(['"])use client\1/.test(source);
63
67
 
64
- const collectReachableModules = (entryPoints: string[]): Map<string, string> => {
68
+ const collectReachableModules = (
69
+ entryPoints: string[]
70
+ ): Map<string, string> => {
65
71
  const visited = new Map<string, string>();
66
72
  const queue = entryPoints.map((entry) => path.join(SRC_ROOT, entry));
67
73
 
@@ -0,0 +1,284 @@
1
+ import {
2
+ __resetThemeSignalsForTests,
3
+ CUSTOM_SIGNAL_PREFIX,
4
+ DEFAULT_SIGNAL_CEILING,
5
+ emitThemeSignal,
6
+ isThemeSignalMuted,
7
+ SIGNAL_BUDGET_PER_ROUTE,
8
+ subscribeToThemeSignals,
9
+ THEME_SIGNAL_EVENT_NAME,
10
+ THEME_SIGNAL_NAMES,
11
+ type ThemeSignalDetail,
12
+ type ThemeSignalName
13
+ } from '../theme-signal';
14
+ import {
15
+ attachGa4DataLayerBridge,
16
+ COMMERCE_EVENT_NAME,
17
+ emitCommerceEvent
18
+ } from '../analytics';
19
+ import { resetDevWarnCacheForTests } from '../dev-warn';
20
+
21
+ declare global {
22
+ interface Window {
23
+ __themeEditorDesigner?: boolean;
24
+ // Must match the augmentation in ../analytics, and the core package's.
25
+ // eslint-disable-next-line @typescript-eslint/ban-types
26
+ dataLayer?: Object[];
27
+ }
28
+ }
29
+
30
+ const setPathname = (pathname: string) => {
31
+ window.history.replaceState({}, '', pathname);
32
+ };
33
+
34
+ describe('theme signals (ZERO-5156)', () => {
35
+ let received: ThemeSignalDetail[];
36
+ let detach: () => void;
37
+
38
+ beforeEach(() => {
39
+ received = [];
40
+ detach = subscribeToThemeSignals((detail) => received.push(detail));
41
+ __resetThemeSignalsForTests();
42
+ resetDevWarnCacheForTests();
43
+ delete window.__themeEditorDesigner;
44
+ setPathname('/');
45
+ });
46
+
47
+ afterEach(() => {
48
+ detach();
49
+ delete window.__themeEditorDesigner;
50
+ });
51
+
52
+ it('reports the moment under `name`, never `action`', () => {
53
+ emitThemeSignal('modal.opened', { modalId: 'size-guide' });
54
+
55
+ expect(received).toEqual([{ name: 'modal.opened', modalId: 'size-guide' }]);
56
+ // A bridge pointed at the wrong channel reads `action` and drops the
57
+ // frame rather than pushing a nameless event.
58
+ expect(received[0]).not.toHaveProperty('action');
59
+ });
60
+
61
+ it('is silent while the theme editor is attached, browse mode included', () => {
62
+ // The raw handshake flag, which stays true in browse mode — the exact
63
+ // state in which the block-level commerce emits currently report a
64
+ // designer's clicks as a shopper's.
65
+ window.__themeEditorDesigner = true;
66
+
67
+ expect(isThemeSignalMuted()).toBe(true);
68
+ expect(emitThemeSignal('slide.changed', { index: 2 })).toBe(false);
69
+ expect(received).toEqual([]);
70
+ });
71
+
72
+ it('reports on a storefront that is framed but has no editor attached', () => {
73
+ // Being inside an iframe is not evidence of the editor: a production
74
+ // storefront is legitimately framed by payment providers and marketplace
75
+ // previews, so framing is deliberately not part of the gate.
76
+ window.__themeEditorDesigner = false;
77
+
78
+ expect(
79
+ emitThemeSignal('section.seen', { sectionId: 'section-h3k9q' })
80
+ ).toBe(true);
81
+ expect(received).toHaveLength(1);
82
+ });
83
+
84
+ it('drops facts that are not flat scalars', () => {
85
+ emitThemeSignal('filter.applied', {
86
+ facetKey: 'color',
87
+ // Nothing may carry a product, a node or a form state onto the wire.
88
+ product: { pk: 42 } as never,
89
+ items: [1, 2] as never,
90
+ ratio: Number.NaN,
91
+ on: true
92
+ });
93
+
94
+ expect(received).toEqual([
95
+ { name: 'filter.applied', facetKey: 'color', on: true }
96
+ ]);
97
+ });
98
+
99
+ it('drops null and undefined facts instead of sending them', () => {
100
+ emitThemeSignal('nav.clicked', {
101
+ itemKey: 'women',
102
+ level: 1,
103
+ label: undefined,
104
+ url: null
105
+ });
106
+
107
+ expect(received).toEqual([
108
+ { name: 'nav.clicked', itemKey: 'women', level: 1 }
109
+ ]);
110
+ });
111
+
112
+ it('truncates a long string rather than refusing the moment', () => {
113
+ emitThemeSignal('share.clicked', { method: 'x'.repeat(200) });
114
+
115
+ expect(String(received[0].method)).toHaveLength(120);
116
+ });
117
+
118
+ it('cannot be renamed from inside its own payload', () => {
119
+ emitThemeSignal('promo.dismissed', {
120
+ name: 'purchase'
121
+ } as never);
122
+
123
+ expect(received[0].name).toBe('promo.dismissed');
124
+ });
125
+
126
+ it('refuses a name that is neither known nor brand-prefixed', () => {
127
+ expect(emitThemeSignal('slide_changed' as ThemeSignalName)).toBe(false);
128
+ expect(received).toEqual([]);
129
+ });
130
+
131
+ it('accepts a brand-authored name behind the reserved prefix', () => {
132
+ expect(
133
+ emitThemeSignal(`${CUSTOM_SIGNAL_PREFIX}lookbook.opened`, { look: 3 })
134
+ ).toBe(true);
135
+ expect(received[0].name).toBe('custom.lookbook.opened');
136
+ });
137
+
138
+ it('exports one frozen name list, so the vocabulary is pinnable', () => {
139
+ expect(new Set(THEME_SIGNAL_NAMES).size).toBe(THEME_SIGNAL_NAMES.length);
140
+ // Dot-namespaced, so no name can collide with a GA4 reserved or
141
+ // recommended event such as `share` or `video_start`.
142
+ for (const name of THEME_SIGNAL_NAMES) {
143
+ expect(name).toMatch(/^[a-z]+\.[a-z]+$/);
144
+ }
145
+ });
146
+
147
+ describe('latch and ceiling', () => {
148
+ it('reports a once-keyed moment a single time', () => {
149
+ const facts = { sectionId: 'section-h3k9q' };
150
+
151
+ expect(
152
+ emitThemeSignal('section.seen', facts, { key: 'hero', once: true })
153
+ ).toBe(true);
154
+ expect(
155
+ emitThemeSignal('section.seen', facts, { key: 'hero', once: true })
156
+ ).toBe(false);
157
+
158
+ expect(received).toHaveLength(1);
159
+ });
160
+
161
+ it('latches each key separately', () => {
162
+ emitThemeSignal('section.seen', {}, { key: 'hero', once: true });
163
+ emitThemeSignal('section.seen', {}, { key: 'footer-strip', once: true });
164
+
165
+ expect(received).toHaveLength(2);
166
+ });
167
+
168
+ it('stops a repeating moment at the default ceiling', () => {
169
+ for (let index = 0; index < DEFAULT_SIGNAL_CEILING + 5; index += 1) {
170
+ emitThemeSignal('slide.changed', { index }, { key: 'hero' });
171
+ }
172
+
173
+ expect(received).toHaveLength(DEFAULT_SIGNAL_CEILING);
174
+ });
175
+
176
+ it('lets a caller opt out of the ceiling explicitly', () => {
177
+ for (let index = 0; index < DEFAULT_SIGNAL_CEILING + 5; index += 1) {
178
+ emitThemeSignal(
179
+ 'slide.changed',
180
+ { index },
181
+ { key: 'hero', ceiling: Infinity }
182
+ );
183
+ }
184
+
185
+ expect(received).toHaveLength(DEFAULT_SIGNAL_CEILING + 5);
186
+ });
187
+
188
+ it('starts counting again after a client-side navigation', () => {
189
+ emitThemeSignal('section.seen', {}, { key: 'hero', once: true });
190
+ expect(received).toHaveLength(1);
191
+
192
+ setPathname('/kadin');
193
+ emitThemeSignal('section.seen', {}, { key: 'hero', once: true });
194
+
195
+ // A fresh page must report again; a re-render of the same page must not.
196
+ expect(received).toHaveLength(2);
197
+ });
198
+
199
+ it('bounds the whole page, not just each key', () => {
200
+ for (let index = 0; index < SIGNAL_BUDGET_PER_ROUTE + 10; index += 1) {
201
+ emitThemeSignal(
202
+ 'section.seen',
203
+ {},
204
+ { key: `section-${index}`, once: true }
205
+ );
206
+ }
207
+
208
+ expect(received).toHaveLength(SIGNAL_BUDGET_PER_ROUTE);
209
+ });
210
+ });
211
+
212
+ describe('channel separation', () => {
213
+ it('does not reach a listener on the commerce channel', () => {
214
+ const commerce: unknown[] = [];
215
+ const onCommerce = (event: Event) =>
216
+ commerce.push((event as CustomEvent).detail);
217
+ window.addEventListener(COMMERCE_EVENT_NAME, onCommerce);
218
+
219
+ emitThemeSignal('modal.opened', { modalId: 'newsletter' });
220
+
221
+ window.removeEventListener(COMMERCE_EVENT_NAME, onCommerce);
222
+ expect(commerce).toEqual([]);
223
+ });
224
+
225
+ it('leaves the GA4 commerce bridge untouched', () => {
226
+ // The bridge ends in an unconditional flat push, so a UI moment reaching
227
+ // it would write junk events into a live property.
228
+ window.dataLayer = [];
229
+ const detachBridge = attachGa4DataLayerBridge();
230
+
231
+ emitThemeSignal('promo.dismissed', { messageIndex: 1 });
232
+
233
+ expect(window.dataLayer).toEqual([]);
234
+
235
+ // The commerce channel still works, so the bridge itself is alive.
236
+ emitCommerceEvent('search', { searchTerm: 'elbise' });
237
+ expect(window.dataLayer).toHaveLength(1);
238
+
239
+ detachBridge();
240
+ delete window.dataLayer;
241
+ });
242
+ });
243
+
244
+ describe('subscription', () => {
245
+ it('filters to the moments a listener asked for', () => {
246
+ const wanted: ThemeSignalDetail[] = [];
247
+ const stop = subscribeToThemeSignals((detail) => wanted.push(detail), {
248
+ names: ['modal.opened']
249
+ });
250
+
251
+ emitThemeSignal('modal.opened', { modalId: 'a' });
252
+ emitThemeSignal('tab.changed', { index: 1 });
253
+
254
+ stop();
255
+ expect(wanted.map((detail) => detail.name)).toEqual(['modal.opened']);
256
+ });
257
+
258
+ it('survives a listener that throws', () => {
259
+ const stop = subscribeToThemeSignals(() => {
260
+ throw new Error('brand bridge is broken');
261
+ });
262
+
263
+ expect(() => emitThemeSignal('tab.changed', { index: 2 })).not.toThrow();
264
+ // The healthy listener registered in beforeEach still received it.
265
+ expect(received).toHaveLength(1);
266
+
267
+ stop();
268
+ });
269
+
270
+ it('stops delivering once detached', () => {
271
+ const seen: ThemeSignalDetail[] = [];
272
+ const stop = subscribeToThemeSignals((detail) => seen.push(detail));
273
+
274
+ stop();
275
+ emitThemeSignal('drawer.opened', {});
276
+
277
+ expect(seen).toEqual([]);
278
+ });
279
+ });
280
+
281
+ it('uses the documented event name', () => {
282
+ expect(THEME_SIGNAL_EVENT_NAME).toBe('pz:theme');
283
+ });
284
+ });