@dfosco/storyboard-react 4.0.0-beta.25 → 4.0.0-beta.26

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/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.0.0-beta.25",
3
+ "version": "4.0.0-beta.26",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.25",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.25",
6
+ "@dfosco/storyboard-core": "4.0.0-beta.26",
7
+ "@dfosco/tiny-canvas": "4.0.0-beta.26",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1",
@@ -1,5 +1,4 @@
1
- import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
- import { flushSync } from 'react-dom'
1
+ import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
2
  import { Canvas } from '@dfosco/tiny-canvas'
4
3
  import '@dfosco/tiny-canvas/style.css'
5
4
  import { useCanvas } from './useCanvas.js'
@@ -267,8 +266,10 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
267
266
  /**
268
267
  * Wrapper for each JSON widget that holds its own ref for imperative actions.
269
268
  * This allows WidgetChrome to dispatch actions to the widget via ref.
269
+ *
270
+ * Memoized to prevent re-renders during zoom and unrelated state changes.
270
271
  */
271
- function ChromeWrappedWidget({
272
+ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
272
273
  widget,
273
274
  selected,
274
275
  multiSelected,
@@ -293,6 +294,10 @@ function ChromeWrappedWidget({
293
294
  }
294
295
  }, [widget, onRemove, onCopy])
295
296
 
297
+ const handleWidgetFieldUpdate = useCallback((updates) => {
298
+ onUpdate?.(widget.id, updates)
299
+ }, [onUpdate, widget.id])
300
+
296
301
  return (
297
302
  <WidgetChrome
298
303
  widgetId={widget.id}
@@ -305,17 +310,29 @@ function ChromeWrappedWidget({
305
310
  onSelect={onSelect}
306
311
  onDeselect={onDeselect}
307
312
  onAction={handleAction}
308
- onUpdate={onUpdate ? (updates) => onUpdate(widget.id, updates) : undefined}
313
+ onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
309
314
  readOnly={readOnly}
310
315
  >
311
316
  <WidgetRenderer
312
317
  widget={widget}
313
- onUpdate={onUpdate ? (updates) => onUpdate(widget.id, updates) : undefined}
318
+ onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
314
319
  widgetRef={widgetRef}
315
320
  />
316
321
  </WidgetChrome>
317
322
  )
318
- }
323
+ }, function chromeWidgetAreEqual(prev, next) {
324
+ return (
325
+ prev.widget === next.widget &&
326
+ prev.selected === next.selected &&
327
+ prev.multiSelected === next.multiSelected &&
328
+ prev.readOnly === next.readOnly &&
329
+ prev.onSelect === next.onSelect &&
330
+ prev.onDeselect === next.onDeselect &&
331
+ prev.onUpdate === next.onUpdate &&
332
+ prev.onRemove === next.onRemove &&
333
+ prev.onCopy === next.onCopy
334
+ )
335
+ })
319
336
 
320
337
  /**
321
338
  * Generic canvas page component.
@@ -335,6 +352,9 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
335
352
  const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
336
353
  const zoomRef = useRef(initialViewport?.zoom ?? 100)
337
354
  const scrollRef = useRef(null)
355
+ const zoomElRef = useRef(null)
356
+ const zoomCommitTimer = useRef(null)
357
+ const zoomEventTimer = useRef(null)
338
358
  const pendingScrollRestore = useRef(initialViewport)
339
359
  // Gate viewport persistence until initial positioning is complete.
340
360
  // Tracks which canvasId was last initialized — save effects only
@@ -704,10 +724,17 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
704
724
  })
705
725
  }, [canvasId, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
706
726
 
727
+ // Keep zoomRef in sync when React state is set (e.g. by toolbar or zoom-to-fit)
707
728
  useEffect(() => {
708
729
  zoomRef.current = zoom
709
730
  }, [zoom])
710
731
 
732
+ // Cleanup zoom timers on unmount
733
+ useEffect(() => () => {
734
+ clearTimeout(zoomCommitTimer.current)
735
+ clearTimeout(zoomEventTimer.current)
736
+ }, [])
737
+
711
738
  // Restore scroll position from localStorage after first render.
712
739
  // When saved state is fresh (< 15 min), restore it. Otherwise zoom-to-fit
713
740
  // all objects so the user sees a useful overview instead of stale coordinates.
@@ -730,7 +757,14 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
730
757
  const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
731
758
  const newScale = fitZoom / 100
732
759
  zoomRef.current = fitZoom
733
- flushSync(() => setZoom(fitZoom))
760
+ // Imperative DOM update for initial zoom-to-fit — same path as applyZoom
761
+ const zoomEl = zoomElRef.current
762
+ if (zoomEl) {
763
+ zoomEl.style.transform = `scale(${newScale})`
764
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
765
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
766
+ }
767
+ setZoom(fitZoom)
734
768
  el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
735
769
  el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
736
770
  } else {
@@ -846,12 +880,19 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
846
880
  * When a cursor position is provided (e.g. from a wheel event), the
847
881
  * canvas point under the cursor stays fixed. Otherwise falls back to
848
882
  * the viewport center.
883
+ *
884
+ * Performs an imperative DOM mutation instead of a React state update
885
+ * to avoid triggering a full re-render of the widget tree on every
886
+ * zoom tick. React state is committed after a debounce for toolbar
887
+ * display updates.
849
888
  */
850
889
  function applyZoom(newZoom, clientX, clientY) {
851
890
  const el = scrollRef.current
891
+ const zoomEl = zoomElRef.current
852
892
  const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
853
893
 
854
- if (!el) {
894
+ if (!el || !zoomEl) {
895
+ zoomRef.current = clampedZoom
855
896
  setZoom(clampedZoom)
856
897
  return
857
898
  }
@@ -869,23 +910,36 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
869
910
  const canvasX = (el.scrollLeft + anchorX) / oldScale
870
911
  const canvasY = (el.scrollTop + anchorY) / oldScale
871
912
 
872
- // Synchronous render so the DOM has the new transform before we adjust scroll
913
+ // Imperative DOM update no React re-render
873
914
  zoomRef.current = clampedZoom
874
- flushSync(() => setZoom(clampedZoom))
915
+ zoomEl.style.transform = `scale(${newScale})`
916
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
917
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
918
+
919
+ // Hint GPU compositing during active zoom
920
+ zoomEl.dataset.zooming = ''
875
921
 
876
922
  // Scroll so the same canvas point stays under the anchor
877
923
  el.scrollLeft = canvasX * newScale - anchorX
878
924
  el.scrollTop = canvasY * newScale - anchorY
879
925
 
880
- // Persist after both zoom and scroll are settled (the zoom effect
881
- // fires inside flushSync before the scroll adjustment above, so it
882
- // would capture stale scroll values).
883
- if (viewportInitName.current === canvasId) {
884
- saveViewportState(canvasId, {
885
- zoom: clampedZoom,
886
- scrollLeft: el.scrollLeft,
887
- scrollTop: el.scrollTop,
888
- })
926
+ // Debounced commit: update React state for toolbar display + persistence
927
+ clearTimeout(zoomCommitTimer.current)
928
+ zoomCommitTimer.current = setTimeout(() => {
929
+ // Remove GPU compositing hint
930
+ delete zoomEl.dataset.zooming
931
+ setZoom(clampedZoom)
932
+ }, 150)
933
+
934
+ // Throttled zoom-changed event for external consumers (toolbar)
935
+ if (!zoomEventTimer.current) {
936
+ zoomEventTimer.current = setTimeout(() => {
937
+ zoomEventTimer.current = null
938
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
939
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
940
+ detail: { zoom: zoomRef.current }
941
+ }))
942
+ }, 100)
889
943
  }
890
944
  }
891
945
 
@@ -1067,9 +1121,15 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
1067
1121
  const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
1068
1122
  const newScale = fitZoom / 100
1069
1123
 
1070
- // Apply zoom synchronously so DOM updates before we scroll
1124
+ // Imperative DOM update same path as applyZoom
1071
1125
  zoomRef.current = fitZoom
1072
- flushSync(() => setZoom(fitZoom))
1126
+ const zoomEl = zoomElRef.current
1127
+ if (zoomEl) {
1128
+ zoomEl.style.transform = `scale(${newScale})`
1129
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
1130
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
1131
+ }
1132
+ setZoom(fitZoom)
1073
1133
 
1074
1134
  // Scroll so the bounding box top-left (with padding) is at viewport top-left
1075
1135
  el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
@@ -1623,6 +1683,15 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
1623
1683
  const canvasThemeVars = getCanvasThemeVars(canvasTheme)
1624
1684
  const canvasPrimerAttrs = getCanvasPrimerAttrs(canvasTheme)
1625
1685
 
1686
+ // Stable callback for deselecting all widgets
1687
+ const handleDeselectAll = useCallback(() => setSelectedWidgetIds(new Set()), [])
1688
+
1689
+ // Stable callback for widget removal + deselect
1690
+ const handleWidgetRemoveAndDeselect = useCallback((id) => {
1691
+ handleWidgetRemove(id)
1692
+ setSelectedWidgetIds(new Set())
1693
+ }, [handleWidgetRemove])
1694
+
1626
1695
  // Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
1627
1696
  const allChildren = []
1628
1697
 
@@ -1653,7 +1722,7 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
1653
1722
  selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1654
1723
  multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1655
1724
  onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1656
- onDeselect={() => setSelectedWidgetIds(new Set())}
1725
+ onDeselect={handleDeselectAll}
1657
1726
  readOnly={!isLocalDev}
1658
1727
  >
1659
1728
  <ComponentWidget
@@ -1695,13 +1764,10 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
1695
1764
  selected={selectedWidgetIds.has(widget.id)}
1696
1765
  multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
1697
1766
  onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
1698
- onDeselect={() => setSelectedWidgetIds(new Set())}
1767
+ onDeselect={handleDeselectAll}
1699
1768
  onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
1700
1769
  onCopy={isLocalDev ? handleWidgetCopy : undefined}
1701
- onRemove={isLocalDev ? (id) => {
1702
- handleWidgetRemove(id)
1703
- setSelectedWidgetIds(new Set())
1704
- } : undefined}
1770
+ onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
1705
1771
  readOnly={!isLocalDev}
1706
1772
  />
1707
1773
  </div>
@@ -1729,10 +1795,11 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
1729
1795
  ...canvasThemeVars,
1730
1796
  ...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
1731
1797
  }}
1732
- onClick={() => setSelectedWidgetIds(new Set())}
1798
+ onClick={handleDeselectAll}
1733
1799
  onMouseDown={handlePanStart}
1734
1800
  >
1735
1801
  <div
1802
+ ref={zoomElRef}
1736
1803
  data-storyboard-canvas-zoom
1737
1804
  data-sb-canvas-theme={canvasTheme}
1738
1805
  className={styles.canvasZoom}
@@ -32,6 +32,13 @@
32
32
  min-height: 100%;
33
33
  }
34
34
 
35
+ /* GPU compositing hint during active zoom gestures — applied imperatively
36
+ via data-zooming attribute and removed on zoom-end to avoid permanent
37
+ memory pressure on the large canvas surface. */
38
+ .canvasZoom[data-zooming] {
39
+ will-change: transform;
40
+ }
41
+
35
42
  /* Selection outline is now handled by WidgetChrome.module.css (.widgetSlotSelected) */
36
43
 
37
44
  .canvasTitle {
@@ -249,7 +249,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
249
249
  }, [showIframe])
250
250
 
251
251
  // Exit interactive mode when clicking outside the embed.
252
- // Keeps iframe mounted until snapshots are captured and preloaded.
252
+ // Hides iframe immediately for a responsive feel, then captures
253
+ // snapshots in the background with the iframe hidden but still mounted.
253
254
  useEffect(() => {
254
255
  if (!interactive || expanded) return
255
256
  function handlePointerDown(e) {
@@ -259,18 +260,10 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
259
260
 
260
261
  setInteractive(false)
261
262
  if (onUpdate && !isExternal && iframeLoaded && iframeRef.current?.contentWindow) {
263
+ // Keep iframe mounted but hidden for background capture
264
+ if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
262
265
  const session = ++exitSessionRef.current
263
- requestCapture({ force: true }).then(async (updates) => {
264
- if (exitSessionRef.current !== session) return
265
- const urls = [updates?.snapshotLight, updates?.snapshotDark].filter(Boolean)
266
- if (urls.length > 0) {
267
- await Promise.all(urls.map(url => new Promise(resolve => {
268
- const img = new Image()
269
- img.onload = resolve
270
- img.onerror = resolve
271
- img.src = url
272
- })))
273
- }
266
+ requestCapture({ force: true }).then(() => {
274
267
  if (exitSessionRef.current !== session) return
275
268
  setShowIframe(false)
276
269
  })
@@ -156,7 +156,8 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
156
156
  }, [showIframe])
157
157
 
158
158
  // Exit interactive mode when clicking outside.
159
- // Keeps iframe mounted until snapshots are captured and preloaded.
159
+ // Hides iframe immediately for a responsive feel, then captures
160
+ // snapshots in the background with the iframe hidden but still mounted.
160
161
  useEffect(() => {
161
162
  if (!interactive) return
162
163
  function handlePointerDown(e) {
@@ -166,18 +167,10 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
166
167
 
167
168
  setInteractive(false)
168
169
  if (onUpdate && iframeLoaded && iframeRef.current?.contentWindow) {
170
+ // Keep iframe mounted but hidden for background capture
171
+ if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
169
172
  const session = ++exitSessionRef.current
170
- requestCapture({ force: true }).then(async (updates) => {
171
- if (exitSessionRef.current !== session) return
172
- const urls = [updates?.snapshotLight, updates?.snapshotDark].filter(Boolean)
173
- if (urls.length > 0) {
174
- await Promise.all(urls.map(url => new Promise(resolve => {
175
- const img = new Image()
176
- img.onload = resolve
177
- img.onerror = resolve
178
- img.src = url
179
- })))
180
- }
173
+ requestCapture({ force: true }).then(() => {
181
174
  if (exitSessionRef.current !== session) return
182
175
  setShowIframe(false)
183
176
  })
@@ -9,6 +9,10 @@
9
9
  * captures the alternate theme, and switches back. The user never
10
10
  * sees the theme flash because the iframe is hidden during the switch.
11
11
  *
12
+ * Optimized pipeline: the first theme's upload runs in parallel with
13
+ * the alternate theme's capture, and capture generation tokens prevent
14
+ * stale results from overwriting newer snapshots.
15
+ *
12
16
  * Only active in dev mode (when onUpdate is provided).
13
17
  */
14
18
  import { useState, useEffect, useCallback, useRef } from 'react'
@@ -84,6 +88,19 @@ function switchTheme(iframeContentWindow, theme, requestId, listeners) {
84
88
  })
85
89
  }
86
90
 
91
+ /**
92
+ * Upload a captured dataUrl and return the resolved image URL.
93
+ */
94
+ async function uploadAndResolve(dataUrl, widgetId, themeLabel, base) {
95
+ const filename = `snapshot-${widgetId}--${themeLabel}.webp`
96
+ const result = await uploadImage(dataUrl, `snapshot-${widgetId}`, filename)
97
+ if (result?.filename) {
98
+ const cacheBust = `?v=${Date.now()}`
99
+ return `${base}/_storyboard/canvas/images/${result.filename}${cacheBust}`
100
+ }
101
+ return null
102
+ }
103
+
87
104
  export function useSnapshotCapture({
88
105
  iframeRef,
89
106
  widgetId,
@@ -94,6 +111,8 @@ export function useSnapshotCapture({
94
111
  const iframeReadyRef = useRef(false)
95
112
  const capturingRef = useRef(false)
96
113
  const requestIdCounter = useRef(0)
114
+ // Generation token — incremented on each capture request to detect stale results
115
+ const captureGeneration = useRef(0)
97
116
  // Handlers for both snapshot and theme-applied responses
98
117
  const responseHandlers = useRef([])
99
118
 
@@ -126,14 +145,18 @@ export function useSnapshotCapture({
126
145
 
127
146
  window.addEventListener('message', handler)
128
147
  return () => window.removeEventListener('message', handler)
129
- }, [iframeRef, onUpdate]) // eslint-disable-line react-hooks/exhaustive-deps
148
+ }, [iframeRef, onUpdate])
130
149
 
131
150
  /**
132
- * Dual-theme capture. Captures current theme, then hides iframe,
133
- * switches to alternate theme, captures again, switches back.
134
- * Returns { snapshotLight, snapshotDark } URLs.
151
+ * Dual-theme capture with pipelined uploads.
152
+ *
153
+ * Pipeline: capture theme-1 start upload-1 in parallel with
154
+ * (hide iframe → switch theme → capture theme-2) → upload-2.
155
+ *
156
+ * Generation tokens prevent stale captures from overwriting newer ones.
157
+ *
135
158
  * @param {Object} opts
136
- * @param {boolean} opts.force - Skip the iframeReady guard (use when iframe is known to be loaded)
159
+ * @param {boolean} opts.force - Skip the iframeReady guard
137
160
  */
138
161
  const requestCapture = useCallback(async ({ force = false } = {}) => {
139
162
  if (!onUpdate) return {}
@@ -142,6 +165,7 @@ export function useSnapshotCapture({
142
165
  if (!force && !iframeReadyRef.current) return {}
143
166
 
144
167
  capturingRef.current = true
168
+ const gen = ++captureGeneration.current
145
169
  const cw = iframeRef.current.contentWindow
146
170
  const iframe = iframeRef.current
147
171
  const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
@@ -153,57 +177,70 @@ export function useSnapshotCapture({
153
177
  try {
154
178
  // 1. Capture current theme (iframe is visible, user sees current state)
155
179
  const currentKey = isCurrentDark ? 'snapshotDark' : 'snapshotLight'
180
+ const currentLabel = isCurrentDark ? 'dark' : 'light'
156
181
  const currentReqId = ++requestIdCounter.current
157
182
  const currentDataUrl = await captureOnce(cw, currentReqId, responseHandlers.current)
158
183
 
184
+ // Bail if a newer capture started while we were waiting
185
+ if (gen !== captureGeneration.current) return {}
186
+
187
+ // 2. Start upload of theme-1 in parallel with alternate-theme capture
188
+ const uploadPromise1 = currentDataUrl
189
+ ? uploadAndResolve(currentDataUrl, widgetId, currentLabel, base)
190
+ : Promise.resolve(null)
191
+
192
+ // Publish theme-1 immediately so snapshot img is ready before iframe hides
159
193
  if (currentDataUrl) {
160
- const filename = `snapshot-${widgetId}--${isCurrentDark ? 'dark' : 'light'}.webp`
161
- const result = await uploadImage(currentDataUrl, `snapshot-${widgetId}`, filename)
162
- if (result?.filename) {
163
- const cacheBust = `?v=${Date.now()}`
164
- updates[currentKey] = `${base}/_storyboard/canvas/images/${result.filename}${cacheBust}`
165
- // Publish immediately so the snapshot img is ready before iframe hides
166
- onUpdate?.({ [currentKey]: updates[currentKey] })
167
- await new Promise(resolve => {
168
- const img = new Image()
169
- img.onload = resolve
170
- img.onerror = resolve
171
- img.src = updates[currentKey]
172
- setTimeout(resolve, 2000)
173
- })
174
- }
194
+ uploadPromise1.then(url => {
195
+ if (url && gen === captureGeneration.current) {
196
+ updates[currentKey] = url
197
+ onUpdate?.({ [currentKey]: url })
198
+ }
199
+ }).catch(() => {})
175
200
  }
176
201
 
177
- // 2. Hide iframe, switch theme, capture alternate (snapshot now visible behind)
202
+ // 3. Hide iframe, switch theme, capture alternate overlaps with upload-1
178
203
  const savedVisibility = iframe.style.visibility
179
204
  iframe.style.visibility = 'hidden'
180
205
 
181
206
  const switchReqId = ++requestIdCounter.current
182
207
  const switched = await switchTheme(cw, alternateTheme, switchReqId, responseHandlers.current)
183
208
 
209
+ if (gen !== captureGeneration.current) {
210
+ iframe.style.visibility = savedVisibility || ''
211
+ return {}
212
+ }
213
+
184
214
  if (switched) {
185
215
  const altKey = isCurrentDark ? 'snapshotLight' : 'snapshotDark'
216
+ const altLabel = isCurrentDark ? 'light' : 'dark'
186
217
  const altReqId = ++requestIdCounter.current
187
218
  const altDataUrl = await captureOnce(cw, altReqId, responseHandlers.current)
188
219
 
220
+ if (gen !== captureGeneration.current) {
221
+ iframe.style.visibility = savedVisibility || ''
222
+ return {}
223
+ }
224
+
189
225
  if (altDataUrl) {
190
- const filename = `snapshot-${widgetId}--${isCurrentDark ? 'light' : 'dark'}.webp`
191
- const result = await uploadImage(altDataUrl, `snapshot-${widgetId}`, filename)
192
- if (result?.filename) {
193
- const cacheBust = `?v=${Date.now()}`
194
- updates[altKey] = `${base}/_storyboard/canvas/images/${result.filename}${cacheBust}`
226
+ const altUrl = await uploadAndResolve(altDataUrl, widgetId, altLabel, base)
227
+ if (altUrl && gen === captureGeneration.current) {
228
+ updates[altKey] = altUrl
195
229
  }
196
230
  }
197
231
 
198
- // 3. Switch back to original theme
232
+ // 4. Switch back to original theme
199
233
  const switchBackReqId = ++requestIdCounter.current
200
234
  await switchTheme(cw, currentTheme, switchBackReqId, responseHandlers.current)
201
235
  }
202
236
 
203
- // 4. Restore iframe visibility
237
+ // Ensure upload-1 is complete before final update
238
+ await uploadPromise1
239
+
240
+ // 5. Restore iframe visibility
204
241
  iframe.style.visibility = savedVisibility || ''
205
242
 
206
- if (Object.keys(updates).length > 0) {
243
+ if (gen === captureGeneration.current && Object.keys(updates).length > 0) {
207
244
  onUpdate?.(updates)
208
245
  }
209
246
  return updates
@@ -215,7 +252,7 @@ export function useSnapshotCapture({
215
252
  } finally {
216
253
  capturingRef.current = false
217
254
  }
218
- }, [onUpdate, iframeRef, widgetId, canvasTheme]) // eslint-disable-line react-hooks/exhaustive-deps
255
+ }, [onUpdate, iframeRef, widgetId, canvasTheme])
219
256
 
220
257
  return { iframeReady, requestCapture }
221
258
  }
@@ -146,4 +146,80 @@ describe('useSnapshotCapture', () => {
146
146
  })
147
147
  )
148
148
  })
149
+
150
+ it('discards stale capture results via generation token', async () => {
151
+ const cw = createMockContentWindow()
152
+ const style = { visibility: "" }
153
+ const iframeRef = { current: { contentWindow: cw, style } }
154
+ const onUpdate = vi.fn()
155
+ const { result } = renderHook(() =>
156
+ useSnapshotCapture({ iframeRef, widgetId: 'gen-widget', onUpdate, canvasTheme: 'light' })
157
+ )
158
+
159
+ act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
160
+
161
+ uploadImage.mockResolvedValue({ filename: 'snapshot-gen-widget--light.webp' })
162
+
163
+ // Start first capture — will be waiting for response
164
+ let capture1Done = false
165
+ let capture2Done = false
166
+ await act(async () => {
167
+ const promise1 = result.current.requestCapture().then(() => { capture1Done = true })
168
+
169
+ // Respond to first capture
170
+ await new Promise(r => setTimeout(r, 10))
171
+ dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,FIRST' })
172
+
173
+ // Start second capture before first completes its alternate-theme work
174
+ // This won't start because capturingRef is still true, so it will return {}
175
+ const promise2 = result.current.requestCapture().then(() => { capture2Done = true })
176
+
177
+ // Complete first capture's theme switch and alternate capture
178
+ await new Promise(r => setTimeout(r, 10))
179
+ dispatchMessage(cw, { type: 'storyboard:embed:theme-applied', requestId: 2 })
180
+ await new Promise(r => setTimeout(r, 10))
181
+ dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 3, dataUrl: 'data:image/webp;base64,DARK' })
182
+ await new Promise(r => setTimeout(r, 10))
183
+ dispatchMessage(cw, { type: 'storyboard:embed:theme-applied', requestId: 4 })
184
+
185
+ await promise1
186
+ await promise2
187
+ })
188
+
189
+ // The second capture should have no-opped (capturingRef guard)
190
+ expect(capture1Done).toBe(true)
191
+ expect(capture2Done).toBe(true)
192
+ })
193
+
194
+ it('restores iframe visibility on error', async () => {
195
+ const cw = createMockContentWindow()
196
+ const style = { visibility: "visible" }
197
+ const iframeRef = { current: { contentWindow: cw, style } }
198
+ const onUpdate = vi.fn()
199
+ const { result } = renderHook(() =>
200
+ useSnapshotCapture({ iframeRef, widgetId: 'err-widget', onUpdate, canvasTheme: 'light' })
201
+ )
202
+
203
+ act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
204
+
205
+ // Make uploadImage throw to trigger error path
206
+ uploadImage.mockRejectedValueOnce(new Error('upload failed'))
207
+
208
+ await act(async () => {
209
+ const promise = result.current.requestCapture()
210
+
211
+ // Respond to capture
212
+ await new Promise(r => setTimeout(r, 10))
213
+ dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,FAIL' })
214
+
215
+ await promise
216
+ })
217
+
218
+ // Visibility should be restored after error
219
+ expect(style.visibility).toBe('')
220
+ // onUpdate should not be called with failed data
221
+ expect(onUpdate).not.toHaveBeenCalledWith(
222
+ expect.objectContaining({ snapshotLight: expect.any(String) })
223
+ )
224
+ })
149
225
  })