@akinon/pz-theme 2.0.58-rc.0 → 2.0.59-beta.0

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,179 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useRef } from 'react';
4
+
5
+ import { useOverlay } from '../hooks/use-overlay';
6
+ import { useVisibilityContext } from '../hooks/use-visibility-context';
7
+ import { useBrowseMode } from '../browse-mode';
8
+ import { evaluateVisibilityRules } from '../utils/visibility-rules';
9
+ import { closeModal, useModalOpen } from './overlay-bus';
10
+ import { ThemeModal } from './modal-registry';
11
+ import {
12
+ CentredModalSurface,
13
+ LayeredOverlaySurface,
14
+ OverlayCloseButton
15
+ } from './overlay-surface';
16
+ import { useEditorKnowsModal } from './use-editor-modals';
17
+ import { useModalPreview } from './use-modal-preview';
18
+ import { useOverlayBreakpoint } from './use-overlay-breakpoint';
19
+ import { useModalAutoTrigger } from './use-modal-auto-trigger';
20
+
21
+ interface ThemeModalMountProps {
22
+ modal: ThemeModal;
23
+ /** The composition, already rendered on the server. */
24
+ children: React.ReactNode;
25
+ /**
26
+ * Where this mount's registry entry came from.
27
+ *
28
+ * A `saved` mount stands down while the editor is attached and knows this
29
+ * modal, because the editor's copy is the authority then — it carries chrome
30
+ * the designer is changing right now, and may describe a modal the backend
31
+ * has never heard of. `ModalPreviewHost` renders an `editor` mount in its
32
+ * place. Without the distinction the replacement would read "the editor owns
33
+ * this" about itself and both would stand down, leaving nothing.
34
+ */
35
+ origin?: 'saved' | 'editor';
36
+ }
37
+
38
+ const isDesignerWindow = () =>
39
+ typeof window !== 'undefined' &&
40
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner === true;
41
+
42
+ /**
43
+ * One composed modal: the bus decides whether it is open, `useOverlay` decides
44
+ * what being open costs the page, and `overlay-surface` draws it.
45
+ *
46
+ * The children arrive already rendered by the server and are mounted only
47
+ * while the modal is open. That is the whole reason the host can afford to
48
+ * declare every modal a theme has: a closed one costs the page nothing but the
49
+ * bytes of its markup, and never appears in the accessibility tree, in the tab
50
+ * order, or as duplicate content behind the page.
51
+ */
52
+ const ThemeModalMount = ({
53
+ modal,
54
+ children,
55
+ origin = 'saved'
56
+ }: ThemeModalMountProps) => {
57
+ const isOpen = useModalOpen(modal.id);
58
+ const breakpoint = useOverlayBreakpoint();
59
+ const rootRef = useRef<HTMLDivElement | null>(null);
60
+
61
+ /**
62
+ * Held open by the editor. This mount then stands down entirely and
63
+ * `ModalPreviewHost` draws the surface instead, from the live entry rather
64
+ * than from the saved one.
65
+ *
66
+ * Standing down is not an optimisation: both would render a
67
+ * `ThemePlaceholderWrapper` for the same slug, and two wrappers claiming one
68
+ * slug means the region registers twice and the editor sees the modal's
69
+ * contents doubled.
70
+ */
71
+ const isPreviewing = useModalPreview(modal.id);
72
+ const editorOwnsModal = useEditorKnowsModal(modal.id);
73
+ const isDesigner = isDesignerWindow();
74
+ const isBrowseMode = useBrowseMode();
75
+ const isEditing = isDesigner && !isBrowseMode;
76
+
77
+ const visibilityContext = useVisibilityContext(breakpoint, {
78
+ isDesigner: isEditing
79
+ });
80
+ const existsHere = evaluateVisibilityRules(
81
+ modal.visibilityRules,
82
+ visibilityContext
83
+ );
84
+
85
+ /**
86
+ * A modal that is not on this page must not be left open by a route change
87
+ * that carried it out of scope — the bus is module state and survives
88
+ * client-side navigation, so nothing else would close it and the shopper
89
+ * would be looking at an overlay belonging to a page they have left.
90
+ */
91
+ useEffect(() => {
92
+ if (!existsHere && isOpen) closeModal(modal.id);
93
+ }, [existsHere, isOpen, modal.id]);
94
+
95
+ useModalAutoTrigger({
96
+ modal,
97
+ enabled: existsHere && !isEditing,
98
+ visibilityContext
99
+ });
100
+
101
+ // Superseded by the editor's own copy of this modal. See `origin`.
102
+ const supersededByEditor = origin === 'saved' && editorOwnsModal;
103
+
104
+ const opened = !isPreviewing && !supersededByEditor && isOpen && existsHere;
105
+ const { chrome, presentation } = modal;
106
+ const isCentred = presentation === 'modal';
107
+
108
+ /**
109
+ * In the canvas the overlay is fully inert: it never steals focus into the
110
+ * page the designer is styling, never freezes it, never claims `aria-modal`
111
+ * and never answers Escape. The primitive does all of that off one flag.
112
+ */
113
+ const overlay = useOverlay<HTMLDivElement>({
114
+ open: opened,
115
+ onClose: () => closeModal(modal.id),
116
+ isDesigner: isEditing,
117
+ // A centred modal really does cover the page. The others make the claim
118
+ // only behind a backdrop, because with the page still clickable it would
119
+ // be a lie.
120
+ modal: isCentred ? true : chrome.showBackdrop,
121
+ lockScroll: chrome.lockScroll && (isCentred || chrome.showBackdrop),
122
+ closeOnEscape: chrome.closeOnEscape,
123
+ fallbackFocusRef: rootRef
124
+ });
125
+
126
+ if (!opened) return null;
127
+
128
+ const dismiss = () => closeModal(modal.id);
129
+
130
+ const header = chrome.showClose ? (
131
+ <OverlayCloseButton label={chrome.closeLabel} onClick={dismiss} />
132
+ ) : null;
133
+
134
+ const ariaLabel = chrome.ariaLabel || modal.name;
135
+
136
+ const surface = isCentred ? (
137
+ <CentredModalSurface
138
+ overlayProps={overlay.overlayProps}
139
+ ariaLabel={ariaLabel}
140
+ closeOnOverlay={chrome.closeOnBackdrop}
141
+ onDismiss={dismiss}
142
+ width={chrome.width}
143
+ header={header}
144
+ >
145
+ {children}
146
+ </CentredModalSurface>
147
+ ) : (
148
+ <LayeredOverlaySurface
149
+ presentation={presentation}
150
+ overlayProps={overlay.overlayProps}
151
+ ariaLabel={ariaLabel}
152
+ showBackdrop={chrome.showBackdrop}
153
+ closeOnOverlay={chrome.closeOnBackdrop}
154
+ onDismiss={dismiss}
155
+ width={chrome.width}
156
+ panelSide={chrome.panelSide}
157
+ sheetHeight={chrome.sheetHeight}
158
+ header={header}
159
+ >
160
+ {children}
161
+ </LayeredOverlaySurface>
162
+ );
163
+
164
+ return (
165
+ <div
166
+ ref={rootRef}
167
+ // The id a trigger's `aria-controls` points at. It exists only while the
168
+ // modal is mounted, which is why the trigger only claims the reference
169
+ // while the modal is open.
170
+ id={`theme-modal-${modal.id}`}
171
+ data-theme-modal={modal.id}
172
+ data-theme-modal-state="open"
173
+ >
174
+ {surface}
175
+ </div>
176
+ );
177
+ };
178
+
179
+ export default ThemeModalMount;
@@ -0,0 +1,82 @@
1
+ import React from 'react';
2
+
3
+ import ThemePlaceholder from '../theme-placeholder';
4
+ import { getThemeConfig } from '../chrome/theme-config-source';
5
+ import ThemeModalMount from './theme-modal-mount';
6
+ import { ModalHostBeacon } from './modal-host-beacon';
7
+ import ModalPreviewHost from './modal-preview-host';
8
+
9
+ interface ThemeModalsProps {
10
+ /**
11
+ * Commerce data the composed blocks read, built with the same
12
+ * `chrome/page-context` helpers a ChromeSlot uses. A modal mounted in the
13
+ * root layout has no page of its own, so a brand that wants product-aware
14
+ * modals passes the context down from the page that has one.
15
+ */
16
+ pageContext?: Record<string, unknown> | null;
17
+ }
18
+
19
+ /**
20
+ * Every composed modal this theme has, mounted once for the page.
21
+ *
22
+ * Mount it in the brand's ROOT LAYOUT, next to the footer ChromeSlot:
23
+ *
24
+ * <ThemeModals />
25
+ *
26
+ * It is an async server component, so — exactly like `ChromeSlot` — it cannot
27
+ * be used inside a client page. The layout is also the right home for a
28
+ * different reason: a modal is opened from the header as often as from the
29
+ * body, so it must outlive any one page's tree.
30
+ *
31
+ * Cost when a theme has no modals is a single object read from the
32
+ * `theme-config` widget that every page already fetches and memoizes, and this
33
+ * renders nothing at all.
34
+ */
35
+ export default async function ThemeModals({
36
+ pageContext = null
37
+ }: ThemeModalsProps) {
38
+ const { modals, dataSources, settings } = await getThemeConfig();
39
+
40
+ /**
41
+ * Every modal is fetched with the page, closed, and its content is mounted
42
+ * only when it opens.
43
+ *
44
+ * Route scoping is deliberately NOT applied here. `visibilityRules` read the
45
+ * pathname, the auth state and the breakpoint, and the layout this renders
46
+ * in has none of those reliably — deciding server-side would bake one page's
47
+ * answer into every page. The mount evaluates them client-side, exactly the
48
+ * way sections already do.
49
+ *
50
+ * The cost is therefore two widget reads per modal per page. That is fine
51
+ * for the handful of modals a theme actually has and would not be for
52
+ * twenty; when a theme gets there, the fix is server-side route scoping on
53
+ * the registry entry, not a client-side fetch that would have to reimplement
54
+ * the v1 schema fallback in `theme-placeholder.tsx`.
55
+ */
56
+ return (
57
+ <>
58
+ {/* Tells a trigger that the host exists, so an unmounted host is a
59
+ named warning instead of a button that does nothing. */}
60
+ <ModalHostBeacon />
61
+ {modals.map((modal) => (
62
+ <ThemeModalMount key={modal.id} modal={modal}>
63
+ <ThemePlaceholder slug={modal.slug} pageContext={pageContext} />
64
+ </ThemeModalMount>
65
+ ))}
66
+ {/*
67
+ Rendered even when the theme has no saved modals, which is precisely
68
+ when it matters most: the first modal a designer creates exists only in
69
+ the editor, and returning early on an empty registry would leave them
70
+ composing against a canvas that can never show it.
71
+
72
+ Costs a shopper nothing — it renders null unless the editor is holding
73
+ a modal open, and only the editor can ask.
74
+ */}
75
+ <ModalPreviewHost
76
+ dataSources={dataSources}
77
+ themeSettings={settings}
78
+ pageContext={pageContext}
79
+ />
80
+ </>
81
+ );
82
+ }
@@ -0,0 +1,109 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ import { parseEditorMessage } from '../editor-bridge';
6
+ import { readThemeModals, ThemeModal } from './modal-registry';
7
+
8
+ /**
9
+ * The modal registry as the EDITOR currently has it.
10
+ *
11
+ * The editor already sends its whole theme on `LOAD_THEME` and every
12
+ * `UPDATE_THEME`, modals included; this reads the field the preview used to
13
+ * throw away. Without it the registry could only ever come from
14
+ * `theme-config`, which made composed modals behave unlike everything else on
15
+ * the page: a section appears as you add it, but a modal did not exist until
16
+ * it was saved AND the preview was reloaded by hand — and a trigger pointed at
17
+ * one simply did nothing, with no way to tell a missing modal from a broken
18
+ * trigger.
19
+ *
20
+ * Module state, like the overlay bus and for the same reason: several mounts
21
+ * across different React roots must agree on one answer.
22
+ */
23
+ let editorModals: ThemeModal[] = [];
24
+
25
+ /**
26
+ * Serialized form of the registry. `UPDATE_THEME` arrives on every keystroke
27
+ * anywhere in the theme, and almost none of those touch a modal — comparing
28
+ * here keeps the whole modal layer from re-rendering at typing speed.
29
+ */
30
+ let signature = '';
31
+
32
+ const listeners = new Set<() => void>();
33
+
34
+ const setEditorModals = (modals: ThemeModal[]) => {
35
+ const next = JSON.stringify(modals);
36
+ if (next === signature) return;
37
+
38
+ signature = next;
39
+ editorModals = modals;
40
+ listeners.forEach((listener) => listener());
41
+ };
42
+
43
+ let listening = false;
44
+
45
+ const startListening = () => {
46
+ if (listening || typeof window === 'undefined') return;
47
+ listening = true;
48
+
49
+ window.addEventListener('message', (event: MessageEvent) => {
50
+ const message = parseEditorMessage(event);
51
+ if (!message) return;
52
+ if (message.type !== 'LOAD_THEME' && message.type !== 'UPDATE_THEME') {
53
+ return;
54
+ }
55
+
56
+ const theme = (message.data as { theme?: { modals?: unknown } } | undefined)
57
+ ?.theme;
58
+ if (!theme) return;
59
+
60
+ /**
61
+ * Read through the same normaliser the saved registry uses, so a modal
62
+ * cannot behave one way while being composed and another once published.
63
+ * A theme with no modals correctly yields an empty registry, which hands
64
+ * every modal back to the saved mounts.
65
+ */
66
+ setEditorModals(readThemeModals(theme.modals));
67
+ });
68
+ };
69
+
70
+ /** Stable empty registry: a fresh `[]` each render would loop the subscriber. */
71
+ const NO_MODALS: ThemeModal[] = [];
72
+
73
+ export const useEditorModals = (): ThemeModal[] => {
74
+ /**
75
+ * Empty on the server and on the first client render, so hydration matches;
76
+ * nothing can have arrived before the listener below is installed.
77
+ */
78
+ const [modals, setModals] = useState<ThemeModal[]>(NO_MODALS);
79
+
80
+ useEffect(() => {
81
+ startListening();
82
+
83
+ const listener = () => setModals(editorModals);
84
+ listeners.add(listener);
85
+ // A frame may have landed between render and this effect.
86
+ listener();
87
+
88
+ return () => {
89
+ listeners.delete(listener);
90
+ };
91
+ }, []);
92
+
93
+ return modals;
94
+ };
95
+
96
+ /**
97
+ * Whether the editor is attached AND knows this modal — in which case its copy
98
+ * supersedes whatever `theme-config` last saved.
99
+ */
100
+ export const useEditorKnowsModal = (id: string): boolean =>
101
+ useEditorModals().some((modal) => modal.id === id);
102
+
103
+ /** Test-only: drop the registry and the listener. */
104
+ export const __resetEditorModalsForTests = (): void => {
105
+ editorModals = [];
106
+ signature = '';
107
+ listeners.clear();
108
+ listening = false;
109
+ };
@@ -0,0 +1,187 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+
5
+ import { VisibilityRuleContext } from '../utils/visibility-rules';
6
+ import { getOpenModalIds, openModal } from './overlay-bus';
7
+ import { canAutoOpen, markAutoOpened } from './modal-frequency';
8
+ import { ThemeModal } from './modal-registry';
9
+
10
+ interface UseModalAutoTriggerOptions {
11
+ modal: ThemeModal;
12
+ /** False in the designer and wherever the modal does not belong. */
13
+ enabled: boolean;
14
+ visibilityContext: VisibilityRuleContext;
15
+ }
16
+
17
+ const DEFAULT_DELAY_MS = 3000;
18
+ const DEFAULT_SCROLL_PERCENT = 50;
19
+ const DEFAULT_IDLE_MS = 15000;
20
+
21
+ /**
22
+ * Opens a modal without anyone clicking anything.
23
+ *
24
+ * Every branch below fires from an EFFECT, never during render. A modal opened
25
+ * while rendering would put a focus trap and an `aria-modal` claim into the
26
+ * server markup, and the client — which starts from an empty page-load state —
27
+ * would hydrate into a different tree. `overlay-bus.ts` backs this up by
28
+ * always reporting closed on the server, so a mistake here fails as a visible
29
+ * hydration warning rather than a subtly broken page.
30
+ */
31
+ export const useModalAutoTrigger = ({
32
+ modal,
33
+ enabled,
34
+ visibilityContext
35
+ }: UseModalAutoTriggerOptions): void => {
36
+ const trigger = modal.autoTrigger;
37
+
38
+ /**
39
+ * Auth state is 'loading' on the first client render and settles a beat
40
+ * later. A rule saying "guests only" would be unsatisfied at exactly the
41
+ * moment a page-load trigger fires, so the effect waits rather than deciding
42
+ * against information it does not have yet.
43
+ */
44
+ const authSettled = visibilityContext.authState !== 'loading';
45
+
46
+ // Read inside the handlers rather than captured, so a re-render does not
47
+ // rearm a timer that is already counting.
48
+ const stateRef = useRef({ modal, enabled, authSettled });
49
+ useEffect(() => {
50
+ stateRef.current = { modal, enabled, authSettled };
51
+ });
52
+
53
+ const armed = Boolean(trigger) && enabled && authSettled;
54
+ const event = trigger?.event;
55
+ const frequency = trigger?.frequency || 'once-per-session';
56
+
57
+ useEffect(() => {
58
+ if (!armed || !event || event === 'none') return undefined;
59
+ if (typeof window === 'undefined') return undefined;
60
+
61
+ let done = false;
62
+
63
+ const fire = () => {
64
+ if (done) return;
65
+
66
+ const current = stateRef.current;
67
+ if (!current.enabled) return;
68
+
69
+ /**
70
+ * Never interrupt. A shopper who opened the size guide, or is inside
71
+ * the mini basket, did not ask for a newsletter over the top of it —
72
+ * and stacking two dialogs also puts two focus traps on the page, which
73
+ * is the exact failure `use-overlay.ts` exists to prevent.
74
+ */
75
+ if (getOpenModalIds().length > 0) return;
76
+
77
+ if (!canAutoOpen(current.modal.id, frequency)) return;
78
+
79
+ done = true;
80
+ markAutoOpened(current.modal.id, frequency);
81
+ openModal(current.modal.id);
82
+ };
83
+
84
+ if (event === 'page-load') {
85
+ // Still a task rather than an inline call: firing inside the effect body
86
+ // on mount would open before the first paint has settled.
87
+ const id = window.setTimeout(fire, 0);
88
+
89
+ return () => window.clearTimeout(id);
90
+ }
91
+
92
+ if (event === 'delay') {
93
+ const id = window.setTimeout(
94
+ fire,
95
+ trigger?.delayMs ?? DEFAULT_DELAY_MS
96
+ );
97
+
98
+ return () => window.clearTimeout(id);
99
+ }
100
+
101
+ if (event === 'idle') {
102
+ const wait = trigger?.delayMs ?? DEFAULT_IDLE_MS;
103
+ let id = window.setTimeout(fire, wait);
104
+
105
+ const restart = () => {
106
+ window.clearTimeout(id);
107
+ id = window.setTimeout(fire, wait);
108
+ };
109
+
110
+ const events = ['pointerdown', 'keydown', 'scroll', 'pointermove'];
111
+ events.forEach((name) =>
112
+ window.addEventListener(name, restart, { passive: true })
113
+ );
114
+
115
+ return () => {
116
+ window.clearTimeout(id);
117
+ events.forEach((name) => window.removeEventListener(name, restart));
118
+ };
119
+ }
120
+
121
+ if (event === 'scroll-depth') {
122
+ const target = trigger?.scrollPercent ?? DEFAULT_SCROLL_PERCENT;
123
+
124
+ const check = () => {
125
+ const scrollable =
126
+ document.documentElement.scrollHeight - window.innerHeight;
127
+ // A page shorter than the viewport can never reach a depth, and
128
+ // dividing by zero would report Infinity and fire immediately.
129
+ if (scrollable <= 0) return;
130
+
131
+ const percent = (window.scrollY / scrollable) * 100;
132
+ if (percent >= target) fire();
133
+ };
134
+
135
+ window.addEventListener('scroll', check, { passive: true });
136
+ check();
137
+
138
+ return () => window.removeEventListener('scroll', check);
139
+ }
140
+
141
+ if (event === 'exit-intent') {
142
+ // Pointer leaving through the TOP of the viewport, which is the only
143
+ // direction that means "heading for the tab bar". A touch device has no
144
+ // such gesture and simply never fires this.
145
+ const handle = (nativeEvent: MouseEvent) => {
146
+ if (nativeEvent.clientY > 0) return;
147
+ if (nativeEvent.relatedTarget) return;
148
+
149
+ fire();
150
+ };
151
+
152
+ document.addEventListener('mouseout', handle);
153
+
154
+ return () => document.removeEventListener('mouseout', handle);
155
+ }
156
+
157
+ if (event === 'element-visible') {
158
+ const anchorId = trigger?.anchorId;
159
+ if (!anchorId || typeof IntersectionObserver === 'undefined') {
160
+ return undefined;
161
+ }
162
+
163
+ const element = document.getElementById(anchorId);
164
+ if (!element) return undefined;
165
+
166
+ const observer = new IntersectionObserver(
167
+ (entries) => {
168
+ if (entries.some((entry) => entry.isIntersecting)) fire();
169
+ },
170
+ { threshold: 0.1 }
171
+ );
172
+
173
+ observer.observe(element);
174
+
175
+ return () => observer.disconnect();
176
+ }
177
+
178
+ return undefined;
179
+ }, [
180
+ armed,
181
+ event,
182
+ frequency,
183
+ trigger?.delayMs,
184
+ trigger?.scrollPercent,
185
+ trigger?.anchorId
186
+ ]);
187
+ };
@@ -0,0 +1,127 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ import { parseEditorMessage } from '../editor-bridge';
6
+ import { readThemeModal, ThemeModal } from './modal-registry';
7
+
8
+ /**
9
+ * The modal the editor is holding open in the canvas, as the editor currently
10
+ * has it — not as the server last saved it.
11
+ *
12
+ * The whole entry travels, not just an id, and that is the point. A modal being
13
+ * composed may not exist on the server at all (it was created a moment ago and
14
+ * nothing has been saved), and one that does exist is being restyled: its
15
+ * presentation, width and dismissal switches change under the designer's hands.
16
+ * Holding open a server-rendered surface would mean every one of those edits
17
+ * needed a save and a reload before it could be seen, which is not an editing
18
+ * loop anyone would use.
19
+ *
20
+ * Module state rather than component state for the same reason the overlay bus
21
+ * is: several consumers must agree on ONE answer, or two surfaces cover the
22
+ * canvas at once.
23
+ *
24
+ * Deliberately NOT routed through the overlay bus. The bus is the shopper's
25
+ * page state; this is an editing mode. Keeping them apart is what lets the
26
+ * canvas hold a modal open with every dismissal inert while a real `openModal`
27
+ * on the same page still behaves like a real modal.
28
+ */
29
+ let heldModal: ThemeModal | null = null;
30
+
31
+ /**
32
+ * Serialized form of what is held, so a repeated frame does not re-render and
33
+ * a changed one always does. Comparing ids would miss chrome edits — the exact
34
+ * updates this channel exists to deliver.
35
+ */
36
+ let heldSignature = '';
37
+
38
+ const listeners = new Set<() => void>();
39
+
40
+ const setHeldModal = (modal: ThemeModal | null) => {
41
+ const signature = modal ? JSON.stringify(modal) : '';
42
+ if (signature === heldSignature) return;
43
+
44
+ heldSignature = signature;
45
+ heldModal = modal;
46
+ listeners.forEach((listener) => listener());
47
+ };
48
+
49
+ let listening = false;
50
+
51
+ /**
52
+ * One document listener for the whole page, installed on first use.
53
+ *
54
+ * Per-mount listeners would mean N handlers parsing every frame the editor
55
+ * sends, on a channel that already carries every style keystroke.
56
+ */
57
+ const startListening = () => {
58
+ if (listening || typeof window === 'undefined') return;
59
+ listening = true;
60
+
61
+ window.addEventListener('message', (event: MessageEvent) => {
62
+ const message = parseEditorMessage(event);
63
+ if (!message) return;
64
+
65
+ if (message.type === 'PREVIEW_OPEN_MODAL') {
66
+ const data = message.data as
67
+ | { modalId?: string; modal?: unknown }
68
+ | undefined;
69
+
70
+ /**
71
+ * `modal` is what the editor sends. The `modalId`-only fallback keeps a
72
+ * mismatched pair of builds usable rather than silently dead: the canvas
73
+ * shows the surface with default chrome instead of nothing at all.
74
+ */
75
+ const modal =
76
+ readThemeModal(data?.modal) ??
77
+ (data?.modalId
78
+ ? readThemeModal({ id: data.modalId, name: data.modalId })
79
+ : null);
80
+
81
+ setHeldModal(modal);
82
+ return;
83
+ }
84
+
85
+ if (message.type === 'PREVIEW_CLOSE_MODAL') {
86
+ setHeldModal(null);
87
+ }
88
+ });
89
+ };
90
+
91
+ /** The modal the editor is holding open, or null. */
92
+ export const useHeldModal = (): ThemeModal | null => {
93
+ /**
94
+ * Starts null on both sides. Nothing can have arrived before the first
95
+ * render — the listener is installed in the effect below — so the server and
96
+ * the first client render agree, and the effect picks up anything that lands
97
+ * between the two.
98
+ */
99
+ const [modal, setModal] = useState<ThemeModal | null>(null);
100
+
101
+ useEffect(() => {
102
+ startListening();
103
+
104
+ const listener = () => setModal(heldModal);
105
+ listeners.add(listener);
106
+ // The frame may have arrived between render and this effect.
107
+ listener();
108
+
109
+ return () => {
110
+ listeners.delete(listener);
111
+ };
112
+ }, []);
113
+
114
+ return modal;
115
+ };
116
+
117
+ /** True while the editor is holding THIS modal open in the canvas. */
118
+ export const useModalPreview = (modalId: string): boolean =>
119
+ useHeldModal()?.id === modalId;
120
+
121
+ /** Test-only: drop the listener registry and the held modal. */
122
+ export const __resetModalPreviewForTests = (): void => {
123
+ heldModal = null;
124
+ heldSignature = '';
125
+ listeners.clear();
126
+ listening = false;
127
+ };