@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
|
@@ -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 = (
|
|
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
|
+
});
|