@dfosco/storyboard-react 4.0.0-beta.20 → 4.0.0-beta.22

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.20",
3
+ "version": "4.0.0-beta.22",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.20",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.20",
6
+ "@dfosco/storyboard-core": "4.0.0-beta.22",
7
+ "@dfosco/tiny-canvas": "4.0.0-beta.22",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1",
@@ -344,6 +344,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
344
344
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
345
345
  const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
346
346
  const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
347
+
347
348
  // Refs for snap settings (used by drop handler inside effect closure)
348
349
  const snapEnabledRef = useRef(snapEnabled)
349
350
  const snapGridSizeRef = useRef(snapGridSize)
@@ -5,6 +5,7 @@ import WidgetWrapper from './WidgetWrapper.jsx'
5
5
  import { readProp, prototypeEmbedSchema } from './widgetProps.js'
6
6
  import { getEmbedChromeVars } from './embedTheme.js'
7
7
  import { uploadImage } from '../canvasApi.js'
8
+ import { useIframeQueue } from './useViewportEntry.js'
8
9
  import styles from './PrototypeEmbed.module.css'
9
10
  import overlayStyles from './embedOverlay.module.css'
10
11
 
@@ -68,12 +69,29 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
68
69
  const validSnapshotDark = snapshotMatchesWidget(snapshotDark) ? snapshotDark : null
69
70
  const currentSnapshot = canvasTheme?.startsWith('dark') ? validSnapshotDark : validSnapshotLight
70
71
  const hasSnapshot = !!currentSnapshot
71
- const [preloadIframe, setPreloadIframe] = useState(!hasSnapshot || isExternal)
72
+
73
+ // Sequential iframe queue — prevents stampede when many embeds lack snapshots.
74
+ // Widgets with snapshots skip the queue entirely; others load one at a time.
75
+ const { ready: queueReady, releaseSlot } = useIframeQueue(hasSnapshot || isExternal)
76
+ const [preloadIframe, setPreloadIframe] = useState(hasSnapshot || isExternal)
72
77
  const [iframeLoaded, setIframeLoaded] = useState(false)
73
- const [showIframe, setShowIframe] = useState(!hasSnapshot || isExternal)
78
+ const [showIframe, setShowIframe] = useState(hasSnapshot || isExternal)
74
79
  const [showSpinner, setShowSpinner] = useState(false)
75
80
  const capturingRef = useRef(false)
76
81
 
82
+ // Start loading when the queue grants this widget a slot
83
+ useEffect(() => {
84
+ if (queueReady && !preloadIframe) {
85
+ setPreloadIframe(true)
86
+ setShowIframe(true)
87
+ }
88
+ }, [queueReady, preloadIframe])
89
+
90
+ // Release the queue slot once the iframe has loaded
91
+ useEffect(() => {
92
+ if (iframeLoaded) releaseSlot()
93
+ }, [iframeLoaded, releaseSlot])
94
+
77
95
  // Show spinner only after 500ms of loading
78
96
  useEffect(() => {
79
97
  if (showIframe && !iframeLoaded && hasSnapshot) {
@@ -17,6 +17,7 @@ import { createInspectorHighlighter } from '@dfosco/storyboard-core/inspector/hi
17
17
  import WidgetWrapper from './WidgetWrapper.jsx'
18
18
  import ResizeHandle from './ResizeHandle.jsx'
19
19
  import { uploadImage } from '../canvasApi.js'
20
+ import { useIframeQueue } from './useViewportEntry.js'
20
21
  import styles from './StoryWidget.module.css'
21
22
  import overlayStyles from './embedOverlay.module.css'
22
23
 
@@ -111,12 +112,28 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
111
112
  const validSnapshotDark = snapshotMatchesWidget(snapshotDark) ? snapshotDark : null
112
113
  const currentSnapshot = isDark ? validSnapshotDark : validSnapshotLight
113
114
  const hasSnapshot = !!currentSnapshot
114
- const [preloadIframe, setPreloadIframe] = useState(!hasSnapshot)
115
+
116
+ // Sequential iframe queue — prevents stampede when many embeds lack snapshots.
117
+ const { ready: queueReady, releaseSlot } = useIframeQueue(hasSnapshot)
118
+ const [preloadIframe, setPreloadIframe] = useState(hasSnapshot)
115
119
  const [iframeLoaded, setIframeLoaded] = useState(false)
116
- const [showIframe, setShowIframe] = useState(!hasSnapshot)
120
+ const [showIframe, setShowIframe] = useState(hasSnapshot)
117
121
  const [showSpinner, setShowSpinner] = useState(false)
118
122
  const capturingRef = useRef(false)
119
123
 
124
+ // Start loading when the queue grants this widget a slot
125
+ useEffect(() => {
126
+ if (queueReady && !preloadIframe) {
127
+ setPreloadIframe(true)
128
+ setShowIframe(true)
129
+ }
130
+ }, [queueReady, preloadIframe])
131
+
132
+ // Release the queue slot once the iframe has loaded
133
+ useEffect(() => {
134
+ if (iframeLoaded) releaseSlot()
135
+ }, [iframeLoaded, releaseSlot])
136
+
120
137
  // Show spinner only after 500ms of loading
121
138
  useEffect(() => {
122
139
  if (showIframe && !iframeLoaded && hasSnapshot) {
@@ -0,0 +1,84 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react'
2
+
3
+ /**
4
+ * Sequential iframe loading queue.
5
+ *
6
+ * Embed widgets (prototype, story) that lack snapshots call `requestSlot()`
7
+ * which returns a promise that resolves when it's their turn to load.
8
+ * Only one iframe loads at a time — the next starts when the previous
9
+ * calls the returned `release()` function (or on timeout).
10
+ *
11
+ * This prevents the "iframe stampede" when many embeds lack snapshots
12
+ * and would otherwise all try to load simultaneously.
13
+ */
14
+ const SLOT_TIMEOUT = 15_000
15
+
16
+ let _queue = []
17
+ let _active = false
18
+
19
+ function processQueue() {
20
+ if (_active || _queue.length === 0) return
21
+ _active = true
22
+ const { resolve } = _queue.shift()
23
+
24
+ let released = false
25
+ const release = () => {
26
+ if (released) return
27
+ released = true
28
+ _active = false
29
+ processQueue()
30
+ }
31
+
32
+ // Safety timeout — if the iframe never signals load, release after 15s
33
+ setTimeout(release, SLOT_TIMEOUT)
34
+ resolve(release)
35
+ }
36
+
37
+ function requestSlot() {
38
+ return new Promise((resolve) => {
39
+ _queue.push({ resolve })
40
+ processQueue()
41
+ })
42
+ }
43
+
44
+ /**
45
+ * Hook for embed widgets that need sequential iframe loading.
46
+ *
47
+ * When the widget has a usable snapshot, this is a no-op (returns ready immediately).
48
+ * When no snapshot exists, it queues the widget and returns `ready: true` when
49
+ * it's this widget's turn.
50
+ *
51
+ * @param {boolean} hasUsableSnapshot - whether the widget has a working snapshot
52
+ * @returns {{ ready: boolean }}
53
+ */
54
+ export function useIframeQueue(hasUsableSnapshot) {
55
+ const [ready, setReady] = useState(hasUsableSnapshot)
56
+ const releaseRef = useRef(null)
57
+
58
+ useEffect(() => {
59
+ if (hasUsableSnapshot || ready) return
60
+
61
+ let cancelled = false
62
+ requestSlot().then((release) => {
63
+ if (cancelled) {
64
+ release()
65
+ return
66
+ }
67
+ releaseRef.current = release
68
+ setReady(true)
69
+ })
70
+
71
+ return () => {
72
+ cancelled = true
73
+ releaseRef.current?.()
74
+ }
75
+ }, [hasUsableSnapshot]) // eslint-disable-line react-hooks/exhaustive-deps
76
+
77
+ // Release the slot when the component unmounts
78
+ const releaseSlot = useCallback(() => {
79
+ releaseRef.current?.()
80
+ releaseRef.current = null
81
+ }, [])
82
+
83
+ return { ready, releaseSlot }
84
+ }