@dfosco/storyboard 0.6.13 → 0.6.14

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.13",
3
+ "version": "0.6.14",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -399,12 +399,7 @@ export async function mountStoryboardCore(config = {}, options = {}) {
399
399
  if (currentHref !== lastHref) {
400
400
  lastHref = currentHref
401
401
  const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
402
- const pathname = window.location.pathname
403
- const hash = window.location.hash
404
- const stripped = basePath && pathname.startsWith(basePath)
405
- ? pathname.slice(basePath.length) || '/'
406
- : pathname.replace(/^\/branch--[^/]+/, '') || '/'
407
- const src = stripped + hash
402
+ const src = computeEmbedNavigationSrc(window.location.pathname, window.location.hash, basePath)
408
403
  window.parent.postMessage({ type: 'storyboard:embed:navigate', src }, '*')
409
404
  }
410
405
  }
@@ -462,6 +457,36 @@ export async function mountStoryboardCore(config = {}, options = {}) {
462
457
  handlePendingNavigation()
463
458
  }
464
459
 
460
+ /**
461
+ * Compute the route src to broadcast to the parent canvas from a prototype embed iframe.
462
+ *
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.
470
+ *
471
+ * In **prod mode**, the iframe loads the prototype route directly through
472
+ * the canvas SPA, so `pathname` already contains the route — we just strip
473
+ * the base path (and any `/branch--xxx/` deploy prefix) and append the hash.
474
+ *
475
+ * Exported for tests.
476
+ */
477
+ export function computeEmbedNavigationSrc(pathname, hash, basePath = '') {
478
+ const cleanBase = (basePath || '').replace(/\/$/, '')
479
+ const stripped = cleanBase && pathname.startsWith(cleanBase)
480
+ ? pathname.slice(cleanBase.length) || '/'
481
+ : pathname.replace(/^\/branch--[^/]+/, '') || '/'
482
+ if (stripped === '/prototypes.html' || stripped.endsWith('/prototypes.html')) {
483
+ const inner = (hash || '').startsWith('#') ? hash.slice(1) : (hash || '')
484
+ if (!inner) return '/'
485
+ return inner.startsWith('/') ? inner : `/${inner}`
486
+ }
487
+ return stripped + (hash || '')
488
+ }
489
+
465
490
  /**
466
491
  * Check sessionStorage for a pending navigation target.
467
492
  * Used by PageSelector when creating a new canvas page — Vite does a full-reload
@@ -0,0 +1,101 @@
1
+ import { computeEmbedNavigationSrc } from './mountStoryboardCore.js'
2
+
3
+ describe('computeEmbedNavigationSrc', () => {
4
+ describe('prod mode (direct route in pathname)', () => {
5
+ it('strips basePath and appends hash', () => {
6
+ expect(computeEmbedNavigationSrc('/cq-org-enablement', '#sectionA', '/')).toBe(
7
+ '/cq-org-enablement#sectionA',
8
+ )
9
+ })
10
+
11
+ it('handles non-root basePath', () => {
12
+ expect(
13
+ computeEmbedNavigationSrc('/storyboard/cq-org-enablement', '#x', '/storyboard/'),
14
+ ).toBe('/cq-org-enablement#x')
15
+ })
16
+
17
+ it('strips /branch--xxx/ deploy prefix', () => {
18
+ expect(
19
+ computeEmbedNavigationSrc('/branch--my-feature/cq-org-enablement', '#x', '/'),
20
+ ).toBe('/cq-org-enablement#x')
21
+ })
22
+
23
+ it('returns "/" when pathname equals basePath', () => {
24
+ expect(computeEmbedNavigationSrc('/storyboard', '', '/storyboard/')).toBe('/')
25
+ })
26
+
27
+ it('handles missing hash', () => {
28
+ expect(computeEmbedNavigationSrc('/MyProto', '', '/')).toBe('/MyProto')
29
+ })
30
+ })
31
+
32
+ describe('dev mode (prototypes.html isolation entry)', () => {
33
+ it('uses hash content as src when pathname is /prototypes.html', () => {
34
+ expect(
35
+ computeEmbedNavigationSrc('/prototypes.html', '#/cq-org-enablement', '/'),
36
+ ).toBe('/cq-org-enablement')
37
+ })
38
+
39
+ it('preserves query string inside hash', () => {
40
+ expect(
41
+ computeEmbedNavigationSrc(
42
+ '/prototypes.html',
43
+ '#/cq-org-enablement?flow=cq-org-enablement/default&cqConfirmOpen=true',
44
+ '/',
45
+ ),
46
+ ).toBe('/cq-org-enablement?flow=cq-org-enablement/default&cqConfirmOpen=true')
47
+ })
48
+
49
+ it('handles non-root basePath', () => {
50
+ expect(
51
+ computeEmbedNavigationSrc('/storyboard/prototypes.html', '#/MyProto', '/storyboard/'),
52
+ ).toBe('/MyProto')
53
+ })
54
+
55
+ it('handles branch deploy basePath', () => {
56
+ expect(
57
+ computeEmbedNavigationSrc(
58
+ '/branch--feature-x/prototypes.html',
59
+ '#/MyProto',
60
+ '/branch--feature-x/',
61
+ ),
62
+ ).toBe('/MyProto')
63
+ })
64
+
65
+ it('prefixes inner hash with "/" if missing', () => {
66
+ expect(
67
+ computeEmbedNavigationSrc('/prototypes.html', '#MyProto', '/'),
68
+ ).toBe('/MyProto')
69
+ })
70
+
71
+ it('returns "/" for empty hash', () => {
72
+ expect(computeEmbedNavigationSrc('/prototypes.html', '', '/')).toBe('/')
73
+ })
74
+
75
+ it('returns "/" for hash that is just "#"', () => {
76
+ expect(computeEmbedNavigationSrc('/prototypes.html', '#', '/')).toBe('/')
77
+ })
78
+ })
79
+
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
84
+ // `/prototypes.html?proto=prototypes.html#/prototypes.html#/route`,
85
+ // which loaded a blank page. Duplicating that widget (cmd+D) carried
86
+ // the broken src to every clone.
87
+ it('never returns a string starting with /prototypes.html#', () => {
88
+ const cases = [
89
+ ['/prototypes.html', '#/foo'],
90
+ ['/prototypes.html', '#/foo?bar=1'],
91
+ ['/storyboard/prototypes.html', '#/bar'],
92
+ ['/branch--x/prototypes.html', '#/baz'],
93
+ ]
94
+ for (const [pathname, hash] of cases) {
95
+ const basePath = pathname.replace(/\/prototypes\.html$/, '/') || '/'
96
+ const result = computeEmbedNavigationSrc(pathname, hash, basePath)
97
+ expect(result.startsWith('/prototypes.html')).toBe(false)
98
+ }
99
+ })
100
+ })
101
+ })
@@ -27,6 +27,21 @@ function formatName(name) {
27
27
  .replace(/\b\w/g, (c) => c.toUpperCase())
28
28
  }
29
29
 
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
+
30
45
  function resolveCanvasThemeFromStorage() {
31
46
  if (typeof localStorage === 'undefined') return 'light'
32
47
  let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
@@ -46,7 +61,7 @@ function resolveCanvasThemeFromStorage() {
46
61
  const HEADER_HEIGHT = 37
47
62
 
48
63
  export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
49
- const src = readProp(props, 'src', prototypeEmbedSchema)
64
+ const src = normalizeLegacyEmbedSrc(readProp(props, 'src', prototypeEmbedSchema))
50
65
  const width = readProp(props, 'width', prototypeEmbedSchema) || 800
51
66
  const height = readProp(props, 'height', prototypeEmbedSchema) || 600
52
67
  const zoom = readProp(props, 'zoom', prototypeEmbedSchema) || 100
@@ -309,7 +324,10 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
309
324
  function handleMessage(e) {
310
325
  if (e.source !== iframeRef.current?.contentWindow) return
311
326
  if (e.data?.type !== 'storyboard:embed:navigate') return
312
- const newSrc = e.data.src
327
+ // Defensive: older consumer embeds reported `/prototypes.html#/route`
328
+ // here (loader path + hash). Normalize to the inner route so we never
329
+ // persist the broken form.
330
+ const newSrc = normalizeLegacyEmbedSrc(e.data.src)
313
331
  if (newSrc && newSrc !== src) {
314
332
  const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
315
333
  onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })