@dfosco/storyboard-react 4.0.0-beta.21 → 4.0.0-beta.23

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.21",
3
+ "version": "4.0.0-beta.23",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.21",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.21",
6
+ "@dfosco/storyboard-core": "4.0.0-beta.23",
7
+ "@dfosco/tiny-canvas": "4.0.0-beta.23",
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)
@@ -1,13 +1,18 @@
1
1
  import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { buildPrototypeIndex } from '@dfosco/storyboard-core'
3
+ import { buildPrototypeIndex, getFlag } from '@dfosco/storyboard-core'
4
4
  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
 
12
+ function devLog(...args) {
13
+ try { if (getFlag('dev-logs')) console.log('[canvas:prototype-embed]', ...args) } catch { /* */ }
14
+ }
15
+
11
16
  function formatName(name) {
12
17
  return name
13
18
  .replace(/[-_]/g, ' ')
@@ -68,12 +73,43 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
68
73
  const validSnapshotDark = snapshotMatchesWidget(snapshotDark) ? snapshotDark : null
69
74
  const currentSnapshot = canvasTheme?.startsWith('dark') ? validSnapshotDark : validSnapshotLight
70
75
  const hasSnapshot = !!currentSnapshot
71
- const [preloadIframe, setPreloadIframe] = useState(!hasSnapshot || isExternal)
76
+
77
+ // Sequential iframe queue — prevents stampede when many embeds lack snapshots.
78
+ // Widgets with snapshots skip the queue entirely; others load one at a time.
79
+ const { ready: queueReady, releaseSlot } = useIframeQueue(hasSnapshot || isExternal, widgetId)
80
+ const [preloadIframe, setPreloadIframe] = useState(hasSnapshot || isExternal)
72
81
  const [iframeLoaded, setIframeLoaded] = useState(false)
73
- const [showIframe, setShowIframe] = useState(!hasSnapshot || isExternal)
82
+ const [showIframe, setShowIframe] = useState(hasSnapshot || isExternal)
74
83
  const [showSpinner, setShowSpinner] = useState(false)
75
84
  const capturingRef = useRef(false)
76
85
 
86
+ devLog(widgetId, { hasSnapshot, isExternal, queueReady, preloadIframe, showIframe, iframeLoaded, src })
87
+
88
+ // Start loading when the queue grants this widget a slot
89
+ useEffect(() => {
90
+ if (queueReady && !preloadIframe) {
91
+ devLog(widgetId, 'queue ready → loading iframe')
92
+ setPreloadIframe(true)
93
+ setShowIframe(true)
94
+ }
95
+ }, [queueReady, preloadIframe])
96
+
97
+ // Release the queue slot once the iframe has loaded or user clicked to interact
98
+ useEffect(() => {
99
+ if (iframeLoaded) {
100
+ devLog(widgetId, 'iframe loaded')
101
+ releaseSlot()
102
+ }
103
+ }, [iframeLoaded, releaseSlot])
104
+
105
+ // Click-to-interact: immediately start iframe and release queue slot for others
106
+ const activateIframe = useCallback(() => {
107
+ devLog(widgetId, 'user activated → jumping queue')
108
+ setShowIframe(true)
109
+ setPreloadIframe(true)
110
+ releaseSlot()
111
+ }, [releaseSlot])
112
+
77
113
  // Show spinner only after 500ms of loading
78
114
  useEffect(() => {
79
115
  if (showIframe && !iframeLoaded && hasSnapshot) {
@@ -535,8 +571,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
535
571
  }}
536
572
  onClick={(e) => {
537
573
  if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
538
- setShowIframe(true)
539
- setPreloadIframe(true)
574
+ activateIframe()
540
575
  enterInteractive()
541
576
  }}
542
577
  role="button"
@@ -545,8 +580,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
545
580
  if (e.key === 'Enter' || e.key === ' ') {
546
581
  e.preventDefault()
547
582
  e.stopPropagation()
548
- setShowIframe(true)
549
- setPreloadIframe(true)
583
+ activateIframe()
550
584
  enterInteractive()
551
585
  }
552
586
  }}
@@ -12,14 +12,19 @@
12
12
  * Props: { storyId, exportName, width, height }
13
13
  */
14
14
  import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
15
- import { getStoryData } from '@dfosco/storyboard-core'
15
+ import { getStoryData, getFlag } from '@dfosco/storyboard-core'
16
16
  import { createInspectorHighlighter } from '@dfosco/storyboard-core/inspector/highlighter'
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
 
24
+ function devLog(...args) {
25
+ try { if (getFlag('dev-logs')) console.log('[canvas:story-widget]', ...args) } catch { /* */ }
26
+ }
27
+
23
28
  function resolveStoryUrl(storyId, exportName) {
24
29
  const story = getStoryData(storyId)
25
30
  if (!story?._route) return null
@@ -111,12 +116,42 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
111
116
  const validSnapshotDark = snapshotMatchesWidget(snapshotDark) ? snapshotDark : null
112
117
  const currentSnapshot = isDark ? validSnapshotDark : validSnapshotLight
113
118
  const hasSnapshot = !!currentSnapshot
114
- const [preloadIframe, setPreloadIframe] = useState(!hasSnapshot)
119
+
120
+ // Sequential iframe queue — prevents stampede when many embeds lack snapshots.
121
+ const { ready: queueReady, releaseSlot } = useIframeQueue(hasSnapshot, widgetId)
122
+ const [preloadIframe, setPreloadIframe] = useState(hasSnapshot)
115
123
  const [iframeLoaded, setIframeLoaded] = useState(false)
116
- const [showIframe, setShowIframe] = useState(!hasSnapshot)
124
+ const [showIframe, setShowIframe] = useState(hasSnapshot)
117
125
  const [showSpinner, setShowSpinner] = useState(false)
118
126
  const capturingRef = useRef(false)
119
127
 
128
+ devLog(widgetId, { hasSnapshot, queueReady, preloadIframe, showIframe, iframeLoaded, storyId })
129
+
130
+ // Start loading when the queue grants this widget a slot
131
+ useEffect(() => {
132
+ if (queueReady && !preloadIframe) {
133
+ devLog(widgetId, 'queue ready → loading iframe')
134
+ setPreloadIframe(true)
135
+ setShowIframe(true)
136
+ }
137
+ }, [queueReady, preloadIframe])
138
+
139
+ // Release the queue slot once the iframe has loaded or user clicked to interact
140
+ useEffect(() => {
141
+ if (iframeLoaded) {
142
+ devLog(widgetId, 'iframe loaded')
143
+ releaseSlot()
144
+ }
145
+ }, [iframeLoaded, releaseSlot])
146
+
147
+ // Click-to-interact: immediately start iframe and release queue slot for others
148
+ const activateIframe = useCallback(() => {
149
+ devLog(widgetId, 'user activated → jumping queue')
150
+ setShowIframe(true)
151
+ setPreloadIframe(true)
152
+ releaseSlot()
153
+ }, [releaseSlot, widgetId])
154
+
120
155
  // Show spinner only after 500ms of loading
121
156
  useEffect(() => {
122
157
  if (showIframe && !iframeLoaded && hasSnapshot) {
@@ -435,8 +470,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
435
470
  }}
436
471
  onClick={(e) => {
437
472
  if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
438
- setShowIframe(true)
439
- setPreloadIframe(true)
473
+ activateIframe()
440
474
  enterInteractive()
441
475
  }}
442
476
  role="button"
@@ -445,8 +479,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
445
479
  if (e.key === 'Enter' || e.key === ' ') {
446
480
  e.preventDefault()
447
481
  e.stopPropagation()
448
- setShowIframe(true)
449
- setPreloadIframe(true)
482
+ activateIframe()
450
483
  enterInteractive()
451
484
  }
452
485
  }}
@@ -0,0 +1,93 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react'
2
+ import { getFlag } from '@dfosco/storyboard-core'
3
+
4
+ function devLog(...args) {
5
+ try { if (getFlag('dev-logs')) console.log('[canvas:iframe-queue]', ...args) } catch { /* flag system not initialized */ }
6
+ }
7
+
8
+ /**
9
+ * Sequential iframe loading queue.
10
+ *
11
+ * Embed widgets (prototype, story) that lack snapshots call `requestSlot()`
12
+ * which returns a promise that resolves when it's their turn to load.
13
+ * Only one iframe loads at a time — the next starts when the previous
14
+ * calls the returned `release()` function (or on timeout).
15
+ *
16
+ * This prevents the "iframe stampede" when many embeds lack snapshots
17
+ * and would otherwise all try to load simultaneously.
18
+ */
19
+ const SLOT_TIMEOUT = 15_000
20
+
21
+ let _queue = []
22
+ let _active = false
23
+
24
+ function processQueue() {
25
+ if (_active || _queue.length === 0) return
26
+ _active = true
27
+ const { resolve, label } = _queue.shift()
28
+ devLog(`slot granted → ${label} (${_queue.length} queued)`)
29
+
30
+ let released = false
31
+ const release = () => {
32
+ if (released) return
33
+ released = true
34
+ _active = false
35
+ devLog(`slot released ← ${label}`)
36
+ processQueue()
37
+ }
38
+
39
+ // Safety timeout — if the iframe never signals load, release after 15s
40
+ setTimeout(release, SLOT_TIMEOUT)
41
+ resolve(release)
42
+ }
43
+
44
+ function requestSlot(label = '?') {
45
+ return new Promise((resolve) => {
46
+ _queue.push({ resolve, label })
47
+ devLog(`queued ${label} (position ${_queue.length})`)
48
+ processQueue()
49
+ })
50
+ }
51
+
52
+ /**
53
+ * Hook for embed widgets that need sequential iframe loading.
54
+ *
55
+ * When the widget has a usable snapshot, this is a no-op (returns ready immediately).
56
+ * When no snapshot exists, it queues the widget and returns `ready: true` when
57
+ * it's this widget's turn.
58
+ *
59
+ * @param {boolean} hasUsableSnapshot - whether the widget has a working snapshot
60
+ * @param {string} [label] - debug label for dev logs
61
+ * @returns {{ ready: boolean, releaseSlot: Function }}
62
+ */
63
+ export function useIframeQueue(hasUsableSnapshot, label = '?') {
64
+ const [ready, setReady] = useState(hasUsableSnapshot)
65
+ const releaseRef = useRef(null)
66
+
67
+ useEffect(() => {
68
+ if (hasUsableSnapshot || ready) return
69
+
70
+ let cancelled = false
71
+ requestSlot(label).then((release) => {
72
+ if (cancelled) {
73
+ release()
74
+ return
75
+ }
76
+ releaseRef.current = release
77
+ setReady(true)
78
+ })
79
+
80
+ return () => {
81
+ cancelled = true
82
+ releaseRef.current?.()
83
+ }
84
+ }, [hasUsableSnapshot]) // eslint-disable-line react-hooks/exhaustive-deps
85
+
86
+ // Release the slot when the component unmounts
87
+ const releaseSlot = useCallback(() => {
88
+ releaseRef.current?.()
89
+ releaseRef.current = null
90
+ }, [])
91
+
92
+ return { ready, releaseSlot }
93
+ }