@dfosco/storyboard 0.6.14 → 0.6.16

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "0.6.14",
3
+ "version": "0.6.16",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -461,12 +461,17 @@ export async function mountStoryboardCore(config = {}, options = {}) {
461
461
  * Compute the route src to broadcast to the parent canvas from a prototype embed iframe.
462
462
  *
463
463
  * In **dev mode**, the embed iframe is loaded via the isolation entry
464
- * (`/prototypes.html#/<route>`), so `pathname` is `/prototypes.html` and the
465
- * actual route lives in the hash. We strip the loader path and report the
466
- * hash content as the src. Otherwise, the parent canvas would persist
467
- * `/prototypes.html#/<route>` as the widget src and then re-build the
468
- * iframe URL as `/prototypes.html?proto=prototypes.html#/...`, which loads
469
- * a blank page. Duplicating that widget (cmd+D) inherits the broken src.
464
+ * (`/prototypes.html/<route>`), so `pathname` includes the `/prototypes.html`
465
+ * loader prefix. We strip that prefix and report the remaining route + hash
466
+ * as the src. The hash is preserved verbatim because storyboard URL state
467
+ * (`#key=value` overrides) writes to the hash and the canvas needs the full
468
+ * navigated URL state to round-trip cleanly.
469
+ *
470
+ * Legacy dev shape (0.6.13 and older) put the route in the hash
471
+ * (`/prototypes.html#/<route>`). We still recognize that shape and unwrap
472
+ * the inner hash so canvases never persist `/prototypes.html#/...` as a src
473
+ * (which the canvas-side URL builder would mis-interpret, producing a blank
474
+ * iframe — and cmd+D would duplicate the broken src to every clone).
470
475
  *
471
476
  * In **prod mode**, the iframe loads the prototype route directly through
472
477
  * the canvas SPA, so `pathname` already contains the route — we just strip
@@ -479,6 +484,13 @@ export function computeEmbedNavigationSrc(pathname, hash, basePath = '') {
479
484
  const stripped = cleanBase && pathname.startsWith(cleanBase)
480
485
  ? pathname.slice(cleanBase.length) || '/'
481
486
  : pathname.replace(/^\/branch--[^/]+/, '') || '/'
487
+ // New dev shape: `/prototypes.html/<route>` — strip loader prefix and
488
+ // append hash (storyboard URL state).
489
+ if (stripped.startsWith('/prototypes.html/')) {
490
+ const route = stripped.slice('/prototypes.html'.length) || '/'
491
+ return route + (hash || '')
492
+ }
493
+ // Legacy dev shape (0.6.13 and older): `/prototypes.html` + `#/<route>`.
482
494
  if (stripped === '/prototypes.html' || stripped.endsWith('/prototypes.html')) {
483
495
  const inner = (hash || '').startsWith('#') ? hash.slice(1) : (hash || '')
484
496
  if (!inner) return '/'
@@ -29,7 +29,61 @@ describe('computeEmbedNavigationSrc', () => {
29
29
  })
30
30
  })
31
31
 
32
- describe('dev mode (prototypes.html isolation entry)', () => {
32
+ describe('dev mode (new shape: /prototypes.html/<route>)', () => {
33
+ it('strips /prototypes.html prefix from pathname and keeps hash', () => {
34
+ expect(
35
+ computeEmbedNavigationSrc('/prototypes.html/cq-org-enablement', '#cqConfirmOpen=true', '/'),
36
+ ).toBe('/cq-org-enablement#cqConfirmOpen=true')
37
+ })
38
+
39
+ it('preserves multi-key storyboard hash state', () => {
40
+ expect(
41
+ computeEmbedNavigationSrc(
42
+ '/prototypes.html/cq-org-enablement',
43
+ '#cqEnabled=null&cqFlashDismissed=null&cqConfirmOpen=true&cqSettingUp=null',
44
+ '/',
45
+ ),
46
+ ).toBe('/cq-org-enablement#cqEnabled=null&cqFlashDismissed=null&cqConfirmOpen=true&cqSettingUp=null')
47
+ })
48
+
49
+ it('preserves search params (e.g. ?flow=name)', () => {
50
+ expect(
51
+ computeEmbedNavigationSrc(
52
+ '/prototypes.html/cq-org-enablement',
53
+ '',
54
+ '/',
55
+ ),
56
+ ).toBe('/cq-org-enablement')
57
+ })
58
+
59
+ it('handles nested route segments', () => {
60
+ expect(
61
+ computeEmbedNavigationSrc('/prototypes.html/MyProto/SignupForm', '', '/'),
62
+ ).toBe('/MyProto/SignupForm')
63
+ })
64
+
65
+ it('handles non-root basePath', () => {
66
+ expect(
67
+ computeEmbedNavigationSrc(
68
+ '/storyboard/prototypes.html/MyProto',
69
+ '#x=1',
70
+ '/storyboard/',
71
+ ),
72
+ ).toBe('/MyProto#x=1')
73
+ })
74
+
75
+ it('handles branch deploy basePath', () => {
76
+ expect(
77
+ computeEmbedNavigationSrc(
78
+ '/branch--feature-x/prototypes.html/MyProto',
79
+ '#x=1',
80
+ '/branch--feature-x/',
81
+ ),
82
+ ).toBe('/MyProto#x=1')
83
+ })
84
+ })
85
+
86
+ describe('dev mode legacy shape (0.6.13 and older: /prototypes.html + #/<route>)', () => {
33
87
  it('uses hash content as src when pathname is /prototypes.html', () => {
34
88
  expect(
35
89
  computeEmbedNavigationSrc('/prototypes.html', '#/cq-org-enablement', '/'),
@@ -77,22 +131,41 @@ describe('computeEmbedNavigationSrc', () => {
77
131
  })
78
132
  })
79
133
 
80
- describe('cmd+D regression — must never produce /prototypes.html#... as src', () => {
81
- // The original bug: dev embed broadcast pathname+hash, so the parent
82
- // canvas persisted `/prototypes.html#/route` as the widget src. The
83
- // URL builder then rebuilt the iframe URL as
134
+ describe('regression — must never produce /prototypes.html prefix as src', () => {
135
+ // 0.6.13 bug: dev embed broadcast pathname+hash, so the parent canvas
136
+ // persisted `/prototypes.html#/route` as the widget src. The URL builder
137
+ // then rebuilt the iframe URL as
84
138
  // `/prototypes.html?proto=prototypes.html#/prototypes.html#/route`,
85
139
  // which loaded a blank page. Duplicating that widget (cmd+D) carried
86
140
  // the broken src to every clone.
87
- it('never returns a string starting with /prototypes.html#', () => {
141
+ //
142
+ // 0.6.14 bug: hash-based routing in createHashRouter collided with
143
+ // storyboard URL state (which also writes to the hash). Navigating
144
+ // inside the iframe blanked the page because the route part of the
145
+ // hash got URL-encoded into a single nonsense fragment.
146
+ //
147
+ // 0.6.15 (this fix): pathname-based routing — both shapes must
148
+ // round-trip to a clean canvas-app route.
149
+ it('never returns a string starting with /prototypes.html', () => {
88
150
  const cases = [
151
+ // legacy hash-form (0.6.13 bug)
89
152
  ['/prototypes.html', '#/foo'],
90
153
  ['/prototypes.html', '#/foo?bar=1'],
91
154
  ['/storyboard/prototypes.html', '#/bar'],
92
155
  ['/branch--x/prototypes.html', '#/baz'],
156
+ // new path-form (0.6.15)
157
+ ['/prototypes.html/foo', ''],
158
+ ['/prototypes.html/foo', '#key=value'],
159
+ ['/prototypes.html/foo/bar', '#a=1&b=2'],
160
+ ['/storyboard/prototypes.html/foo', '#x=y'],
161
+ ['/branch--x/prototypes.html/foo', '#x=y'],
93
162
  ]
94
163
  for (const [pathname, hash] of cases) {
95
- const basePath = pathname.replace(/\/prototypes\.html$/, '/') || '/'
164
+ const basePath = pathname.startsWith('/branch--x')
165
+ ? '/branch--x/'
166
+ : pathname.startsWith('/storyboard')
167
+ ? '/storyboard/'
168
+ : '/'
96
169
  const result = computeEmbedNavigationSrc(pathname, hash, basePath)
97
170
  expect(result.startsWith('/prototypes.html')).toBe(false)
98
171
  }
@@ -5,10 +5,15 @@
5
5
  * the consumer-side `src/prototypes-entry.jsx` so consumers don't need to
6
6
  * maintain the iframe entry themselves.
7
7
  *
8
- * Uses `createHashRouter` so prototype routes live in the URL hash
9
- * (`prototypes.html#/MyProto/SignupForm`). Filters routes by `?proto=` so a
10
- * broken sibling prototype never appears in the matched route's lazy()
11
- * chain (module-graph isolation see .agents/plans/vite-isolation.md).
8
+ * Uses `createBrowserRouter` with `/prototypes.html` as the basename so
9
+ * prototype routes live in the URL path (`prototypes.html/MyProto/SignupForm`),
10
+ * leaving the hash and search free for storyboard URL state (which writes
11
+ * `#key=value` for overrides and `?flow=name` for the active flow).
12
+ *
13
+ * The first path segment after `prototypes.html` is the prototype name —
14
+ * used by `getRoutesForProto()` to filter routes so a broken sibling
15
+ * prototype never appears in the matched route's lazy() chain
16
+ * (module-graph isolation — see .agents/plans/vite-isolation.md).
12
17
  *
13
18
  * CSS chain: relies on `mountStoryboardCore` to inject the compiled
14
19
  * ui-runtime stylesheet (matches index.jsx's mount) — this restores stock
@@ -19,7 +24,7 @@
19
24
  */
20
25
  import { StrictMode } from 'react'
21
26
  import { createRoot } from 'react-dom/client'
22
- import { RouterProvider, createHashRouter } from 'react-router-dom'
27
+ import { RouterProvider, createBrowserRouter } from 'react-router-dom'
23
28
  import { ThemeProvider, BaseStyles } from '@primer/react'
24
29
  import { routes, getRoutesForProto } from './prototypeRoutes.jsx'
25
30
  import ThemeSync from '../../primer/ThemeSync.jsx'
@@ -46,10 +51,20 @@ const storyboardConfig = Object.values(configModules)[0]?.default || {}
46
51
  // - sets up the iframe→parent postMessage navigation/wheel bridge
47
52
  mountStoryboardCore(storyboardConfig, { basePath: import.meta.env.BASE_URL })
48
53
 
49
- const protoParam = new URLSearchParams(window.location.search).get('proto')
54
+ // Derive the prototype name for narrowing. Prefer the first pathname segment
55
+ // after `prototypes.html` (the new browser-router layout); fall back to the
56
+ // legacy `?proto=` query param so old canvases or older builds keep working.
57
+ const basePathNoTrail = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
58
+ const prototypesBasename = `${basePathNoTrail}/prototypes.html`
59
+ const afterBasename = window.location.pathname.startsWith(prototypesBasename)
60
+ ? window.location.pathname.slice(prototypesBasename.length)
61
+ : ''
62
+ const pathProto = (afterBasename.match(/^\/([^/?#]+)/) || [])[1] || ''
63
+ const queryProto = new URLSearchParams(window.location.search).get('proto') || ''
64
+ const protoParam = pathProto || queryProto
50
65
  const activeRoutes = protoParam ? getRoutesForProto(protoParam) : routes
51
66
 
52
- const router = createHashRouter(activeRoutes)
67
+ const router = createBrowserRouter(activeRoutes, { basename: prototypesBasename })
53
68
 
54
69
  const rootElement = document.getElementById('root')
55
70
  const root = createRoot(rootElement)
@@ -7,6 +7,7 @@ import { readProp, prototypeEmbedSchema } from './widgetProps.js'
7
7
  import { getEmbedChromeVars } from './embedTheme.js'
8
8
  import { useIframeDevLogs } from './iframeDevLogs.js'
9
9
  import { findAllConnectedSplitTargets, getSplitPaneLabel, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
10
+ import { normalizeLegacyEmbedSrc } from './normalizeLegacyEmbedSrc.js'
10
11
  import ExpandedPane from './ExpandedPane.jsx'
11
12
  import styles from './PrototypeEmbed.module.css'
12
13
  import overlayStyles from './embedOverlay.module.css'
@@ -27,21 +28,6 @@ function formatName(name) {
27
28
  .replace(/\b\w/g, (c) => c.toUpperCase())
28
29
  }
29
30
 
30
- // Self-heal canvases that persisted a dev-isolation src like
31
- // `/prototypes.html#/MyProto?...` (caused by an older broadcastNavigation
32
- // that reported pathname+hash, treating the loader path as the route).
33
- // Without this normalization, the URL builder treats "prototypes.html" as
34
- // the prototype name and produces a blank iframe — and cmd+D duplicates
35
- // inherit the broken src. Pure, deterministic, safe to call on every render.
36
- function normalizeLegacyEmbedSrc(src) {
37
- if (typeof src !== 'string' || !src) return src
38
- const m = src.match(/^\/?(?:[^#]*\/)?prototypes\.html#(.*)$/)
39
- if (!m) return src
40
- const inner = m[1] || ''
41
- if (!inner) return '/'
42
- return inner.startsWith('/') ? inner : `/${inner}`
43
- }
44
-
45
31
  function resolveCanvasThemeFromStorage() {
46
32
  if (typeof localStorage === 'undefined') return 'light'
47
33
  let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
@@ -61,25 +47,38 @@ function resolveCanvasThemeFromStorage() {
61
47
  const HEADER_HEIGHT = 37
62
48
 
63
49
  export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
64
- const src = normalizeLegacyEmbedSrc(readProp(props, 'src', prototypeEmbedSchema))
50
+ const rawStoredSrc = readProp(props, 'src', prototypeEmbedSchema)
51
+ const src = normalizeLegacyEmbedSrc(rawStoredSrc)
65
52
  const width = readProp(props, 'width', prototypeEmbedSchema) || 800
66
53
  const height = readProp(props, 'height', prototypeEmbedSchema) || 600
67
54
  const zoom = readProp(props, 'zoom', prototypeEmbedSchema) || 100
68
55
  const label = readProp(props, 'label', prototypeEmbedSchema) || src
69
56
 
57
+ // Self-healing write-back: if normalization changed the src, persist the
58
+ // clean form so the canvas JSONL stops carrying the broken URL. Without
59
+ // this, every render keeps recovering at runtime but the file stays
60
+ // poisoned and any consumer on an older library still sees the bug.
61
+ useEffect(() => {
62
+ if (!onUpdate) return
63
+ if (typeof rawStoredSrc !== 'string' || rawStoredSrc === src) return
64
+ onUpdate({ src })
65
+ }, [rawStoredSrc, src, onUpdate])
66
+
70
67
  const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
71
68
  const baseSegment = basePath.replace(/^\//, '')
72
69
  // Two URLs are derived from `src`:
73
70
  // - rawSrc — the iframe URL. In DEV this routes through the isolated
74
71
  // prototypes.html entry so a broken prototype's transform/HMR errors
75
72
  // stay inside the iframe (see .agents/plans/vite-isolation.md):
76
- // /MyProto/SignupForm becomes prototypes.html?proto=MyProto#/MyProto/SignupForm.
77
- // In PROD it loads the prototype path directly through the canvas SPA
78
- // (prototypes.html is a build-time isolation artifact that must not
79
- // leak into deployed URLs).
73
+ // /MyProto/SignupForm becomes prototypes.html/MyProto/SignupForm.
74
+ // Routing happens in the *pathname* (createBrowserRouter), not in the
75
+ // hash, so the hash and search stay free for storyboard URL state
76
+ // (`?flow=...` and `#key=value` overrides). In PROD it loads the
77
+ // prototype path directly through the canvas SPA (prototypes.html is
78
+ // a build-time isolation artifact that must not leak into deployed URLs).
80
79
  // - externalSrc — the URL used by "Open in new tab". Always direct
81
80
  // (`${basePath}/<protoPath>`), never prototypes.html, even in dev —
82
- // opening prototypes.html#/... in a fresh tab is a leaky surprise
81
+ // opening prototypes.html/... in a fresh tab is a leaky surprise
83
82
  // for users navigating from the canvas.
84
83
  // External http(s) URLs are left alone in both cases. basePath already
85
84
  // carries the /branch--xxx/ prefix on branch deploys, so both work for
@@ -109,13 +108,14 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
109
108
  if (import.meta.env.PROD) {
110
109
  return { rawSrc: directUrl, externalSrc: directUrl }
111
110
  }
112
- // Dev iframe: prototypes.html with ?proto= narrowing. The consumer's
113
- // prototypes-entry.jsx reads ?proto= and calls getRoutesForProto();
114
- // older scaffolds harmlessly ignore the param and load the full tree.
115
- const pathOnly = pathAndQuery.split('?')[0]
116
- const protoName = pathOnly.split('/').filter(Boolean)[0] || ''
117
- const queryStr = protoName ? `?proto=${encodeURIComponent(protoName)}` : ''
118
- const iframeUrl = `${basePath}/prototypes.html${queryStr}#${routePath}${suffix}`
111
+ // Dev iframe: prototypes.html with the route as a sub-path. The library
112
+ // middleware (`data-plugin.js`) serves the same HTML shell for
113
+ // `/prototypes.html/*` so a hard reload / open-in-new-tab works. The
114
+ // prototype name (first path segment) narrows getRoutesForProto so a
115
+ // broken sibling prototype can't poison the lazy() chain. Hash and
116
+ // search stay attached so storyboard URL state (`#key=value` overrides,
117
+ // `?flow=name`) survives the redirect through the isolation entry.
118
+ const iframeUrl = `${basePath}/prototypes.html${routePath}${suffix}`
119
119
  return { rawSrc: iframeUrl, externalSrc: directUrl }
120
120
  }, [src, basePath, baseSegment])
121
121
 
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { getEmbedChromeVars } from './embedTheme.js'
3
+ import { normalizeLegacyEmbedSrc } from './normalizeLegacyEmbedSrc.js'
3
4
 
4
5
  describe('getEmbedChromeVars', () => {
5
6
  it('follows toolbar theme variants for embed edit chrome', () => {
@@ -8,3 +9,116 @@ describe('getEmbedChromeVars', () => {
8
9
  expect(getEmbedChromeVars('dark_dimmed')['--bgColor-default']).toBe('#212830')
9
10
  })
10
11
  })
12
+
13
+ describe('normalizeLegacyEmbedSrc', () => {
14
+ it('passes through clean canvas-app routes unchanged', () => {
15
+ expect(normalizeLegacyEmbedSrc('/cq-org-enablement')).toBe('/cq-org-enablement')
16
+ expect(normalizeLegacyEmbedSrc('/MyProto/SignupForm')).toBe('/MyProto/SignupForm')
17
+ expect(normalizeLegacyEmbedSrc('/cq-org-enablement#x=1')).toBe('/cq-org-enablement#x=1')
18
+ })
19
+
20
+ it('passes through external http(s) URLs unchanged', () => {
21
+ expect(normalizeLegacyEmbedSrc('https://example.com/foo')).toBe('https://example.com/foo')
22
+ })
23
+
24
+ it('passes through empty/falsy values', () => {
25
+ expect(normalizeLegacyEmbedSrc('')).toBe('')
26
+ expect(normalizeLegacyEmbedSrc(null)).toBe(null)
27
+ expect(normalizeLegacyEmbedSrc(undefined)).toBe(undefined)
28
+ })
29
+
30
+ describe('legacy hash-form: /prototypes.html#/<route>', () => {
31
+ it('strips loader prefix and keeps inner route', () => {
32
+ expect(normalizeLegacyEmbedSrc('/prototypes.html#/cq-org-enablement')).toBe('/cq-org-enablement')
33
+ })
34
+
35
+ it('keeps query strings inside inner', () => {
36
+ expect(normalizeLegacyEmbedSrc('/prototypes.html#/cq-org-enablement?cqConfirmOpen=true'))
37
+ .toBe('/cq-org-enablement?cqConfirmOpen=true')
38
+ })
39
+
40
+ it('handles non-root basePath', () => {
41
+ expect(normalizeLegacyEmbedSrc('/storyboard/prototypes.html#/MyProto')).toBe('/MyProto')
42
+ })
43
+
44
+ it('returns "/" for empty inner', () => {
45
+ expect(normalizeLegacyEmbedSrc('/prototypes.html#')).toBe('/')
46
+ })
47
+
48
+ it('prefixes "/" if inner missing leading slash', () => {
49
+ expect(normalizeLegacyEmbedSrc('/prototypes.html#MyProto')).toBe('/MyProto')
50
+ })
51
+ })
52
+
53
+ describe('legacy URLSearchParams-clobbered form (real-world 0.6.13/0.6.14 bug)', () => {
54
+ // Real bug: storyboard's session.writeHash did URLSearchParams.toString()
55
+ // on the entire hash, which URL-encoded the route slash to %2F and turned
56
+ // /MyProto into a key with an empty value. Real persisted srcs from the
57
+ // wild look like /prototypes.html#%2Fcq-org-enablement=&cqConfirmOpen=true
58
+ it('recovers route from URLSearchParams-mangled hash', () => {
59
+ expect(
60
+ normalizeLegacyEmbedSrc('/prototypes.html#%2Fcq-org-enablement=&cqConfirmOpen=true'),
61
+ ).toBe('/cq-org-enablement#cqConfirmOpen=true')
62
+ })
63
+
64
+ it('recovers route with no surviving storyboard state', () => {
65
+ expect(normalizeLegacyEmbedSrc('/prototypes.html#%2Fcq-org-enablement=')).toBe('/cq-org-enablement')
66
+ })
67
+
68
+ it('recovers route with multiple storyboard params', () => {
69
+ expect(
70
+ normalizeLegacyEmbedSrc(
71
+ '/prototypes.html#%2Fcq-org-enablement=&cqEnabled=null&cqFlashDismissed=null&cqConfirmOpen=true',
72
+ ),
73
+ ).toBe('/cq-org-enablement#cqEnabled=null&cqFlashDismissed=null&cqConfirmOpen=true')
74
+ })
75
+
76
+ it('recovers nested route', () => {
77
+ expect(
78
+ normalizeLegacyEmbedSrc('/prototypes.html#%2FMyProto%2FSignupForm=&x=1'),
79
+ ).toBe('/MyProto/SignupForm#x=1')
80
+ })
81
+
82
+ it('handles non-root basePath', () => {
83
+ expect(
84
+ normalizeLegacyEmbedSrc('/storyboard/prototypes.html#%2FMyProto=&x=1'),
85
+ ).toBe('/MyProto#x=1')
86
+ })
87
+ })
88
+
89
+ describe('defensive path-form: /prototypes.html/<route>', () => {
90
+ it('strips loader prefix', () => {
91
+ expect(normalizeLegacyEmbedSrc('/prototypes.html/MyProto')).toBe('/MyProto')
92
+ })
93
+
94
+ it('handles nested routes', () => {
95
+ expect(normalizeLegacyEmbedSrc('/prototypes.html/MyProto/SignupForm')).toBe('/MyProto/SignupForm')
96
+ })
97
+
98
+ it('handles non-root basePath', () => {
99
+ expect(normalizeLegacyEmbedSrc('/storyboard/prototypes.html/MyProto')).toBe('/MyProto')
100
+ })
101
+ })
102
+
103
+ describe('all-encompassing regression — no legacy shape can produce a /prototypes.html src', () => {
104
+ const cases = [
105
+ '/prototypes.html#/foo',
106
+ '/prototypes.html#/foo?bar=1',
107
+ '/prototypes.html#',
108
+ '/prototypes.html#%2Ffoo=&bar=1',
109
+ '/prototypes.html#%2Ffoo%2Fbar=&x=1',
110
+ '/prototypes.html/foo',
111
+ '/prototypes.html/foo/bar',
112
+ '/storyboard/prototypes.html#/foo',
113
+ '/storyboard/prototypes.html#%2Ffoo=&x=1',
114
+ '/storyboard/prototypes.html/foo',
115
+ ]
116
+ for (const input of cases) {
117
+ it(`recovers ${input}`, () => {
118
+ const result = normalizeLegacyEmbedSrc(input)
119
+ expect(result).not.toMatch(/prototypes\.html/)
120
+ expect(result.startsWith('/')).toBe(true)
121
+ })
122
+ }
123
+ })
124
+ })
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Self-heal canvases that persisted a dev-isolation loader URL as the prototype
3
+ * widget src. Three legacy shapes exist (all caused by older bugs in the embed
4
+ * iframe broadcaster — see commit history of PrototypeEmbed.jsx + mountStoryboardCore.js):
5
+ *
6
+ * 1. `/prototypes.html#/MyProto?...`
7
+ * — 0.6.13 and older, when the iframe broadcast pathname+hash and
8
+ * routing lived in the hash. Inner is a clean path.
9
+ *
10
+ * 2. `/prototypes.html#%2FMyProto=&cqOpen=true&...`
11
+ * — 0.6.13/0.6.14 with storyboard URL state. The iframe's hash was
12
+ * `#/MyProto` (router route), then storyboard's session.writeHash
13
+ * did `URLSearchParams.toString()` on the entire hash, which
14
+ * URL-encoded `/` to `%2F` and turned `/MyProto` into a key with
15
+ * an empty value. This is the most common shape seen in the wild.
16
+ *
17
+ * 3. `/prototypes.html/MyProto...`
18
+ * — defensive: if a future broadcaster ever forwards the loader
19
+ * path verbatim with the path-based router layout (0.6.15+).
20
+ *
21
+ * Without normalization the URL builder in PrototypeEmbed treats
22
+ * "prototypes.html" as the prototype name and produces a blank iframe — and
23
+ * cmd+D duplicates inherit the broken src.
24
+ *
25
+ * Pure, deterministic, safe to call on every render. The PrototypeEmbed
26
+ * component also writes the normalized form back to the canvas via onUpdate
27
+ * so the persisted JSONL stops carrying the broken URL forever.
28
+ *
29
+ * @param {unknown} src
30
+ * @returns {unknown} normalized src, or the original value when not a string
31
+ */
32
+ export function normalizeLegacyEmbedSrc(src) {
33
+ if (typeof src !== 'string' || !src) return src
34
+ // Hash-form (legacy 0.6.13 router-in-hash). Match both clean and
35
+ // URLSearchParams-mangled inner contents.
36
+ const hashMatch = src.match(/^\/?(?:[^#]*\/)?prototypes\.html#(.*)$/)
37
+ if (hashMatch) {
38
+ const inner = hashMatch[1] || ''
39
+ if (!inner) return '/'
40
+ // Clean form: `#/MyProto?...` — inner already starts with `/`.
41
+ if (inner.startsWith('/')) return inner
42
+ // URLSearchParams-mangled form: parse and find the key whose decoded
43
+ // name starts with `/` (that's the route). Surviving params become
44
+ // the storyboard URL state on the recovered hash.
45
+ try {
46
+ const params = new URLSearchParams(inner)
47
+ let route = ''
48
+ const restPairs = []
49
+ for (const [key, value] of params) {
50
+ if (!route && key.startsWith('/')) {
51
+ // Storyboard never persists state with `/`-prefixed keys, and the
52
+ // mangled form always puts the route first — so first match wins.
53
+ // The route had no `=` in the original URL, so value is empty.
54
+ // If a `?query=...` survived alongside, append it back.
55
+ route = value ? `${key}?${key}=${value}` : key
56
+ } else {
57
+ restPairs.push(`${key}=${value}`)
58
+ }
59
+ }
60
+ if (route) {
61
+ return restPairs.length > 0 ? `${route}#${restPairs.join('&')}` : route
62
+ }
63
+ } catch { /* fall through */ }
64
+ // Last-resort: prefix `/` so we at least produce a routable path.
65
+ return `/${inner}`
66
+ }
67
+ // Path-form: `/prototypes.html/inner...` — defensive.
68
+ const pathMatch = src.match(/^\/?(?:[^#?]*\/)?prototypes\.html(\/[^]*)$/)
69
+ if (pathMatch) {
70
+ const inner = pathMatch[1] || ''
71
+ return inner || '/'
72
+ }
73
+ return src
74
+ }
@@ -1248,7 +1248,7 @@ export default function storyboardDataPlugin() {
1248
1248
  url = url.slice(baseNoTrail.length) || '/'
1249
1249
  }
1250
1250
  const cleanUrl = url.split('?')[0].split('#')[0]
1251
- if (cleanUrl !== '/prototypes.html') return next()
1251
+ if (cleanUrl !== '/prototypes.html' && !cleanUrl.startsWith('/prototypes.html/')) return next()
1252
1252
 
1253
1253
  // Theme bootstrap mirrors index.html so the iframe paints with the
1254
1254
  // correct color mode on first frame (before mountStoryboardCore