Skip to content

Add synchronized new-tab live preview - #2025

Open
toransahu wants to merge 1 commit into
mermaid-js:developfrom
toransahu:feat/live-preview-new-tab
Open

Add synchronized new-tab live preview#2025
toransahu wants to merge 1 commit into
mermaid-js:developfrom
toransahu:feat/live-preview-new-tab

Conversation

@toransahu

Copy link
Copy Markdown

📑 Summary

Why

The current full-screen action opens a static URL snapshot. Once it is open, continuing to edit the diagram does not update that tab, which makes a second-window or dual-monitor preview workflow impractical.

What

  • Rename the action to Open Live Preview.
  • Keep the new /view tab synchronized with its originating editor.
  • Preserve the URL snapshot as a standalone fallback when the editor closes or cross-tab messaging is unavailable.
  • Remove the private live-preview session identifier from analytics URLs.

📏 Design Decisions

How

Each editor tab creates a UUID-scoped BroadcastChannel and publishes the existing serialized application state. A newly opened view tab uses a request/state handshake to get the latest state immediately, then applies subsequent messages through the existing deserialization and state replacement pipeline.

The live session is added as a query parameter before the snapshot hash, so the URL remains a valid shareable snapshot. If BroadcastChannel is unsupported or the source editor is unavailable, the view still renders that snapshot rather than failing.

🧪 Testing

  • pnpm test:unit --run src/lib/util/livePreview.test.ts src/lib/util/stats.test.ts
  • pnpm test:e2e tests/livePreview.spec.ts
  • pnpm check
  • pnpm build
  • pnpm lint
  • Pre-commit Prettier and ESLint checks

📋 Tasks

  • 📖 Read the contribution guidelines
  • 💻 Added unit and end-to-end tests
  • 🔖 Targeted the develop branch

@netlify

netlify Bot commented Aug 28, 2026

Copy link
Copy Markdown

Deploy Preview for mermaidjs ready!

Name Link
🔨 Latest commit ccf33b8
🔍 Latest deploy log https://app.netlify.com/projects/mermaidjs/deploys/6a91c525e1114d000875d27a
😎 Deploy Preview https://deploy-preview-2025--mermaidjs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Why:
The existing full-screen link captures a static URL snapshot, so the
diagram does not update while its author continues editing. This prevents
a practical second-window or dual-monitor preview workflow.

What:
Rename the action to Open Live Preview and keep each opened view tab
synchronized with its editor. Preserve the snapshot as a standalone
fallback and exclude the private preview session from analytics URLs.

How:
Create a per-editor UUID and exchange serialized state through a
BroadcastChannel request/state protocol. The view tab requests current
state when it connects and applies updates through the existing state
pipeline. Cover URL handling, messaging, analytics privacy, and real
two-tab synchronization with unit and Playwright tests.
@toransahu
toransahu force-pushed the feat/live-preview-new-tab branch from 2bfb691 to ccf33b8 Compare August 28, 2026 17:28
@toransahu

Copy link
Copy Markdown
Author

Hi @sidharthv96, I've made this improvement the the live editor. Could you please review it?

@sidharthv96

Copy link
Copy Markdown
Member

Thanks for this — a synchronized preview is a real upgrade over the static snapshot, and the per-editor-tab channel scoping is the right call. A few behavior problems worth fixing before merge, then smaller cleanups.

Behavior issues

1. Pan/zoom sync is the right behavior for presenting — but the coordinates need mapping

Syncing the presenter's pan/zoom into the preview is what we want: zoom into a region in the editor and the audience view follows. One caveat with the current mechanism: the raw svg-pan-zoom pan/zoom values are relative to the editor pane's dimensions, and the preview applies them verbatim (View.svelte:86–88 → panZoom.ts:106–109) in a full-window container with a different size and aspect — so the preview won't actually frame the same region the presenter is looking at. Worth normalizing (e.g. center point + zoom relative to fit) so the sync delivers what it promises. A synced mouse pointer that tracks the presenter's position in the preview would round out the presenting story nicely — fine as a follow-up.

2. Unthrottled broadcasts

The $effect (edit/+page.svelte:53) fires on every store write — per-pointermove pan/zoom included (panZoom.ts:81–95) — so dragging the canvas floods the channel with full pako payloads, and the preview runs inflate + JSON.parse + mermaid.parse per message. Early-return in publish() when nextSerialized is unchanged, and throttle broadcasts to animation frames.

3. The view tab should skip persistence entirely

Every broadcast the preview applies goes through persistAndProcess, which unconditionally writes the shared codeStore in localStorage (state.svelte.ts:124) — so the preview races the editor tab for whatever the next /edit session restores (a one-keystroke-old diagram, or the preview's full-window pan/zoom instead of the editor's). The view tab should never write codeStore; skip the writeJSON in the view context rather than trying to referee the race. Related: the fire-and-forget processState().then() (state.svelte.ts:125) has no ordering guard, and with hash-load + broadcasts racing, a staler parse can resolve last and clobber the newer state (the cold hash parse loads grammars lazily, so it genuinely can outlast the warm broadcast parse). A monotonic token/abort guard on processState fixes that.

4. getAnalyticsSafeUrl re-spells every tracked URL

stats.ts:36 now round-trips the query through URLSearchParams, which re-encodes unrelated params even when no live param is present — ?utm_campaign=hello%20world&flag becomes ?utm_campaign=hello+world&flag= (verified in Node). The old code returned window.location.search verbatim, so this splits Plausible URL/UTM buckets for the same logical page across the deploy. Return the original search untouched when it lacks the live param.

5. Editor reloads orphan open previews

The sessionId is a fresh uuid per edit-page mount (edit/+page.svelte:48), so reloading the editor tab (or a zenuml edit, which forces window.location.reload via processState, state.svelte.ts:66–68) closes the channel and mints a new uuid — the preview keeps listening on the dead channel and silently freezes on the last state, still presenting itself as live. Not a blocker, but persisting the sessionId (e.g. sessionStorage) or a "disconnected" affordance in the preview would cover it.

Smaller things

  • The subscriber re-implements state loading. try { replaceInputState(deserializeState(serialized)) } catch (view/+page.svelte:16–22) duplicates what loadState (state.svelte.ts:228) does, minus its error recovery — future loading changes (new State fields, migrations) now live in two places. Worth extracting a shared helper. Deliberately not suggesting sanitizeConfig here: the channel is same-origin-only (anyone who can post has already won), and sanitizing per-message would confirm()-spam previews whenever the user has legitimately kept a loose config.
  • initialSerialized is dead weight. The mount-time $effect publishes the identical validatedState.current.serialized on its first run, in the same task as mount — and BroadcastChannel delivery needs a new macrotask, so no request-state can ever be answered from the constructor value. Drop the parameter (init serialized to '', or take a getSerialized getter, which also removes the second copy of state).
  • addLivePreviewSession hand-rolls URL surgery that this same PR does with the URL API in stats.ts. Its only caller passes urls.current.view, whose shape is fixed as /view#<serialized> (state.svelte.ts:188) — so the includes('?') ? '&' : '?' branch is unreachable (and untested; the unit test only exercises '?'), and encodeURIComponent on a uuid is a no-op. new URL(href, location.origin) + searchParams.set(...) does the whole job.
  • The /^[\w-]{1,100}$/ check guards nothing. The parsed sessionId flows only into new BroadcastChannel(name) — an opaque, same-origin-only string with no injection surface; a malformed id just subscribes to a channel nobody publishes on. If validation stays, validate it's a uuid, since that's all the publisher mints.
  • fullScreenHref is now a stale name — the title and doc comment in PanZoomToolbar.svelte moved to "live preview" but the prop didn't (lines 14, 18–19).

Style alignment

  • channelFactory and the sessionId default are test-only seams — no production caller passes either (edit passes one arg, view passes two); they exist for livePreview.test.ts:44/66. The existing pattern for browser globals is vi.stubGlobal (embed.test.ts:15 stubs confirm). Stubbing BroadcastChannel with a mock class removes a parameter from both public functions and the exported LivePreviewChannelFactory type (currently imported only by the test; LivePreviewSubscriber is exported but imported nowhere).
  • LIVE_PREVIEW_QUERY_PARAMETER fits better as C.livePreviewParamC in constants.ts groups exactly this kind of magic string (editorChooserDismissedKey is the same species), sorting between editorChooserDismissedKey and utmSource. Prefer lowerCamelCase for module constants, matching renderDelay, codeFileName, and mermaidAiDomain — so CHANNEL_PREFIXchannelPrefix, and same for the message constants (REQUEST_STATE/STATE), which are otherwise good to keep — the typeof derivation in LivePreviewMessage keeps the union in sync with them.
  • The e2e spec bypasses the selector convention — livePreview.spec.ts:9 uses getByTitle('Open Live Preview') while every other spec goes through getByTestId(TID.*); rewording the tooltip silently breaks the test. Since this PR touches that button anyway, add TID.livePreviewButton + a data-testid, and consider an openLivePreview() fixture method. Also worth a test: two editor tabs with independent previews — the per-session channel scoping exists precisely to protect that behaviour, and nothing exercises it.
  • (Lowest priority) the closure-factory idiom is a third state pattern next to classes (PanZoomState) and module singletons (autoSync.ts). It's defensible here — easier to test, and the subscriber is per-tab — just noting it for consistency awareness.

@sidharthv96

Copy link
Copy Markdown
Member

@toransahu non AI review, really useful feature, need some tweaks mentioned above, and we could add some polish as follow up.

@toransahu

Copy link
Copy Markdown
Author

Thanks a lot for the review and feedback @sidharthv96. I'll work on them and let you know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants