@dfosco/storyboard 0.6.15 → 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.15",
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",
@@ -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,33 +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 loader URL as the widget src.
31
- // Two legacy shapes exist:
32
- // - `/prototypes.html#/MyProto?...` — 0.6.13 and older, when the iframe
33
- // broadcast pathname+hash and routing lived in the hash
34
- // - `/prototypes.html/MyProto?...` — could appear if a future broadcaster
35
- // ever forwards the loader path verbatim with the new browser-router layout
36
- // Without normalization the URL builder treats "prototypes.html" as the
37
- // prototype name and produces a blank iframe — and cmd+D duplicates inherit
38
- // the broken src. Pure, deterministic, safe to call on every render.
39
- function normalizeLegacyEmbedSrc(src) {
40
- if (typeof src !== 'string' || !src) return src
41
- // Hash-form: `/prototypes.html#/inner` or `/storyboard/prototypes.html#/inner`
42
- const hashMatch = src.match(/^\/?(?:[^#]*\/)?prototypes\.html#(.*)$/)
43
- if (hashMatch) {
44
- const inner = hashMatch[1] || ''
45
- if (!inner) return '/'
46
- return inner.startsWith('/') ? inner : `/${inner}`
47
- }
48
- // Path-form: `/prototypes.html/inner...` or `/storyboard/prototypes.html/inner...`
49
- const pathMatch = src.match(/^\/?(?:[^#?]*\/)?prototypes\.html(\/[^]*)$/)
50
- if (pathMatch) {
51
- const inner = pathMatch[1] || ''
52
- return inner || '/'
53
- }
54
- return src
55
- }
56
-
57
31
  function resolveCanvasThemeFromStorage() {
58
32
  if (typeof localStorage === 'undefined') return 'light'
59
33
  let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
@@ -73,12 +47,23 @@ function resolveCanvasThemeFromStorage() {
73
47
  const HEADER_HEIGHT = 37
74
48
 
75
49
  export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
76
- const src = normalizeLegacyEmbedSrc(readProp(props, 'src', prototypeEmbedSchema))
50
+ const rawStoredSrc = readProp(props, 'src', prototypeEmbedSchema)
51
+ const src = normalizeLegacyEmbedSrc(rawStoredSrc)
77
52
  const width = readProp(props, 'width', prototypeEmbedSchema) || 800
78
53
  const height = readProp(props, 'height', prototypeEmbedSchema) || 600
79
54
  const zoom = readProp(props, 'zoom', prototypeEmbedSchema) || 100
80
55
  const label = readProp(props, 'label', prototypeEmbedSchema) || src
81
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
+
82
67
  const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
83
68
  const baseSegment = basePath.replace(/^\//, '')
84
69
  // Two URLs are derived from `src`:
@@ -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
+ }