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

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.
@@ -1,29 +1,181 @@
1
- import { postToEditor } from './editor-bridge';
1
+ import { getCustomCatalog } from './custom/catalog';
2
+ import type { CustomCatalog } from './custom/types';
3
+ import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
4
+
5
+ /**
6
+ * The single announce the storefront makes to the editor: which placeholder
7
+ * slugs this document mounted, and — only for a proven designer — which
8
+ * developer-authored blocks this build can render.
9
+ *
10
+ * One debounced flush carries both. Placeholders register as their components
11
+ * mount, while custom blocks register at module evaluation (before any
12
+ * placeholder exists), so the catalog re-arms the SAME timer instead of owning
13
+ * one: two timers would mean two frames, and the editor treats every frame as
14
+ * a complete answer.
15
+ */
2
16
  const placeholderRegistry = new Set<string>();
3
17
  let registryTimeout: NodeJS.Timeout | null = null;
18
+ let listeningForDesigner = false;
19
+ /**
20
+ * The designer re-announce is a ONE-SHOT. See `handleEditorMessage` — the
21
+ * editor answers every announce with the same cookie it answered the last one
22
+ * with, so an unlatched re-arm is an endless round trip.
23
+ */
24
+ let designerReannounceSpent = false;
4
25
 
5
- export const registerPlaceholder = (slug: string) => {
6
- placeholderRegistry.add(slug);
26
+ interface PlaceholdersAvailablePayload {
27
+ placeholders: string[];
28
+ /**
29
+ * ALWAYS THE WHOLE CATALOG, never a delta. An in-iframe route change
30
+ * remounts every placeholder and produces a second announce; the receiver
31
+ * must replace what it holds rather than merge, or a block the build no
32
+ * longer registers would live on in the picker and be offered for a type
33
+ * nothing can render.
34
+ */
35
+ customBlocks?: CustomCatalog;
36
+ }
37
+
38
+ const isFramed = (): boolean =>
39
+ typeof window !== 'undefined' &&
40
+ Boolean(window.parent) &&
41
+ window.self !== window.top;
42
+
43
+ /**
44
+ * The editor-proven designer flag — the same signal `chrome/checkout-pieces.ts`
45
+ * exports as `isThemeEditorDesigner`, set only after a trusted
46
+ * SET_THEME_EDITOR_COOKIE arrives through the origin-guarded bridge.
47
+ *
48
+ * Read inline rather than imported so the module every placeholder pulls in
49
+ * does not drag the checkout outlet store into its graph.
50
+ */
51
+ const isDesignerAttached = (): boolean =>
52
+ typeof window !== 'undefined' &&
53
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner ===
54
+ true;
55
+
56
+ const flush = () => {
57
+ registryTimeout = null;
58
+
59
+ // An empty announce is not a harmless no-op. The editor's only
60
+ // PLACEHOLDERS_AVAILABLE subscriber (loadThemeForPlaceholders) runs
61
+ // setIsLoadingTheme, clears its cache and swaps the active placeholder set
62
+ // before its first await — so a frame carrying no slugs makes it reload the
63
+ // theme against nothing. The custom-block re-arm below fires before any
64
+ // placeholder has mounted, which is exactly when that would happen.
65
+ if (placeholderRegistry.size === 0) return;
66
+ if (!isFramed()) return;
7
67
 
68
+ const data: PlaceholdersAvailablePayload = {
69
+ placeholders: Array.from(placeholderRegistry)
70
+ };
71
+
72
+ // Being framed is NOT enough to hand the catalog over. A storefront is
73
+ // legitimately framed in production — payment providers, marketplace
74
+ // previews — and the catalog is brand IP: every block name, every setting
75
+ // key and every option list this build ships. The placeholder slugs are
76
+ // route names and keep their existing behaviour; the catalog waits for the
77
+ // editor to have proven itself.
78
+ if (isDesignerAttached()) {
79
+ const customBlocks = getCustomCatalog();
80
+ // Omitted entirely rather than sent empty: a build with no custom blocks
81
+ // must be indistinguishable from one whose catalog simply is not this
82
+ // frame's business.
83
+ if (customBlocks) data.customBlocks = customBlocks;
84
+ }
85
+
86
+ postToEditor({
87
+ type: 'PLACEHOLDERS_AVAILABLE',
88
+ data
89
+ });
90
+ };
91
+
92
+ const scheduleAnnounce = () => {
8
93
  if (registryTimeout) {
9
94
  clearTimeout(registryTimeout);
10
95
  }
11
96
 
12
- registryTimeout = setTimeout(() => {
13
- if (
14
- typeof window !== 'undefined' &&
15
- window.parent &&
16
- window.self !== window.top
17
- ) {
18
- const availablePlaceholders = Array.from(placeholderRegistry);
19
- postToEditor({
20
- type: 'PLACEHOLDERS_AVAILABLE',
21
- data: { placeholders: availablePlaceholders }
22
- });
23
- }
24
- }, 100);
97
+ registryTimeout = setTimeout(flush, 100);
98
+ };
99
+
100
+ /**
101
+ * Re-announce once the editor proves itself.
102
+ *
103
+ * The designer flag arrives in the editor's reply to IFRAME_READY, which is a
104
+ * postMessage round trip — nothing guarantees it lands inside the 100ms
105
+ * debounce. Without this the first flush would go out gated shut and the
106
+ * catalog would never be offered again for the life of the document, leaving
107
+ * the picker with no custom blocks on exactly the loads where the handshake
108
+ * was slow.
109
+ *
110
+ * The listener is attached on first placeholder registration (never at module
111
+ * scope, which would give the barrel an import side effect) and then kept for
112
+ * the life of the document, like the one in browse-mode.ts.
113
+ *
114
+ * AT MOST ONCE, and only when there is something a frame could not already
115
+ * carry. The editor does not send this cookie once — it answers EVERY
116
+ * PLACEHOLDERS_AVAILABLE with it (`theme-iframe-service.ts`, the same handler
117
+ * that ingests the catalog), so an unlatched re-arm is a ping-pong:
118
+ * announce -> cookie -> announce, every 100ms for the life of the document.
119
+ * Each lap costs the editor a full theme reload, because that announce is what
120
+ * `loadThemeForPlaceholders` subscribes to — it clears the widget cache,
121
+ * rewrites the active placeholder set and refetches every widget.
122
+ */
123
+ const handleEditorMessage = (event: MessageEvent) => {
124
+ if (!isTrustedEditorEvent(event)) return;
125
+ if (event.data?.type !== 'SET_THEME_EDITOR_COOKIE') return;
126
+ if (designerReannounceSpent) return;
127
+ designerReannounceSpent = true;
128
+
129
+ // A build that registers no custom block has nothing to add to a frame it
130
+ // has already sent, so it stays at exactly one announce — the behaviour
131
+ // every brand shipping no custom block had before any of this existed.
132
+ if (!getCustomCatalog()) return;
133
+
134
+ // Re-arm rather than flush: the flag itself is set by the placeholder
135
+ // client's own listener, and listener order across modules is not ours to
136
+ // depend on. By the time the timer fires both have run.
137
+ scheduleAnnounce();
138
+ };
139
+
140
+ const listenForDesigner = () => {
141
+ if (listeningForDesigner || typeof window === 'undefined') return;
142
+ listeningForDesigner = true;
143
+ window.addEventListener('message', handleEditorMessage);
144
+ };
145
+
146
+ export const registerPlaceholder = (slug: string) => {
147
+ placeholderRegistry.add(slug);
148
+ listenForDesigner();
149
+ scheduleAnnounce();
150
+ };
151
+
152
+ /**
153
+ * Re-arms the placeholder announce so a registered barrel rides the next
154
+ * flush. Called by `registerCustomBlocks` at module evaluation; posts nothing
155
+ * on its own, because a document with no placeholders has nothing to say.
156
+ */
157
+ export const announceCustomBlocks = () => {
158
+ scheduleAnnounce();
25
159
  };
26
160
 
27
161
  export const unregisterPlaceholder = (slug: string) => {
28
162
  placeholderRegistry.delete(slug);
29
163
  };
164
+
165
+ /**
166
+ * Test-only. Module state outlives a test file, and the window listener
167
+ * outlives even a re-imported copy of this module — one jsdom window collects
168
+ * every copy's listener, so without this each test would announce for all of
169
+ * its predecessors as well.
170
+ */
171
+ export const __resetPlaceholderRegistryForTests = () => {
172
+ if (registryTimeout) clearTimeout(registryTimeout);
173
+ registryTimeout = null;
174
+ placeholderRegistry.clear();
175
+ designerReannounceSpent = false;
176
+
177
+ if (typeof window !== 'undefined') {
178
+ window.removeEventListener('message', handleEditorMessage);
179
+ }
180
+ listeningForDesigner = false;
181
+ };