@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
|
@@ -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
|
|
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
|
+
}
|