@molecule/app-live-preview-iframe
Provider bond · live-preview · App (browser) · v1.0.2 · Apache-2.0
Iframe-based live preview provider
npm install @molecule/app-live-preview-iframenpm · Source on GitHub · Implements @molecule/app-live-preview
How it works
@molecule/app-live-preview-iframe is a provider bond on the app (browser) side: it implements the live-preview core interface (@molecule/app-live-preview) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
import { setProvider } from '@molecule/app-live-preview'
import { provider } from '@molecule/app-live-preview-iframe'
setProvider(provider) // once, at app startup (bonds.ts)Reference
Iframe-based live preview provider for molecule.dev — manages preview STATE
(load-target url, URL-bar currentUrl, Back/Forward history, device
frame, loading/error, loadNonce). A companion renderer (e.g.
@molecule/app-ide-react's preview panel) renders the actual <iframe>
and feeds notifyLoaded / notifyError / recordNavigation back in.
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { setProvider } from '@molecule/app-live-preview'
import { provider } from '@molecule/app-live-preview-iframe'
setProvider(provider) // once, at app startup (bonds.ts)
Type
provider
Installation
npm install @molecule/app-live-preview-iframe @molecule/app-live-preview
API
Interfaces
IframePreviewConfig
Configuration for iframe preview.
interface IframePreviewConfig {
/** Initial preview URL. */
defaultUrl?: string
/** Default device frame. */
defaultDevice?: DeviceFrame
}
Classes
IframePreviewProvider
Iframe-based implementation of PreviewProvider. Manages preview URL, device frame selection,
loading state, and connection status. The actual iframe rendering is handled by a companion React component.
class IframePreviewProvider implements PreviewProvider {
readonly name = 'iframe'
private state: PreviewState
private refreshKey = 0
private subscribers: Set<(state: PreviewState) => void> = new Set()
/**
* Navigation history (browser-style). Entries are pushed by `setUrl` and by
* `recordNavigation` (the preview's reported locations); `back`/`forward` move
* `historyIndex` within it. Empty until the first navigation is recorded.
*/
private history: string[] = []
private historyIndex = -1
/**
* The url from the most recent EXPLICIT `setUrl` call, distinct from
* `state.url` (which the constructor also pre-populates from `defaultUrl`).
* `null` until the first `setUrl` call, so seeding the provider with
* `createProvider({ defaultUrl })` followed by an immediate
* `setUrl(defaultUrl)` — the standard "first load" pattern — is never
* mistaken for a redundant repeat just because it happens to match the
* constructor's default.
*/
private lastSetUrl: string | null = null
constructor(config: IframePreviewConfig = {}) {
this.state = {
url: config.defaultUrl ?? '',
currentUrl: config.defaultUrl ?? '',
isLoading: false,
device: config.defaultDevice ?? 'none',
error: null,
isConnected: false,
canGoBack: false,
canGoForward: false,
loadNonce: 0,
}
}
/**
* Records a URL in the navigation history. A PUSH (the default — `pushState`,
* `setUrl`, a popstate/hashchange move) truncates any forward entries (a new
* branch replaces the forward stack) and collapses a consecutive duplicate (a
* reload or a redirect to the same URL adds no entry). A REPLACE
* (`replaceState` — a redirect/canonicalization that swaps the current entry)
* overwrites the current entry IN PLACE and PRESERVES the forward stack, so a
* replaceState-redirect-on-load no longer disables Forward.
* @param url - The URL that was navigated to.
* @param isReplace - Whether to replace the current entry (preserving forward)
* instead of pushing a new one. Defaults to `false`.
*/
private pushHistory(url: string, isReplace = false): void {
// A replace swaps the current entry in place and leaves the forward stack
// intact. (With no current entry yet, fall through to seed the first one.)
if (isReplace && this.historyIndex >= 0) {
this.history[this.historyIndex] = url
return
}
// Drop any forward history beyond the current position.
this.history = this.history.slice(0, this.historyIndex + 1)
if (this.history[this.historyIndex] === url) return
this.history.push(url)
this.historyIndex = this.history.length - 1
}
/**
* Derives the navigation flags from the current history position.
* @returns The current `canGoBack`/`canGoForward` flags derived from the history index.
*/
private navFlags(): { canGoBack: boolean; canGoForward: boolean } {
return {
canGoBack: this.historyIndex > 0,
canGoForward: this.historyIndex < this.history.length - 1,
}
}
/**
* Moves the URL bar + nav flags to the history entry at the current index
* WITHOUT reloading the document (used by `back`/`forward`). It updates
* `currentUrl` (the displayed location) and the nav flags but deliberately
* leaves `url` (the iframe's load target) and `loadNonce` untouched — a
* browser's Back/Forward is a client-side history move, not a cold reload.
* The actual in-iframe navigation is driven by the renderer posting a
* `molecule:nav-command` (see the `PreviewProvider` `@remarks`); the
* preview then reports the resulting location via `molecule:navigate`, which
* `IframePreviewProvider.recordNavigation` dedupes against this
* optimistically-set `currentUrl` — so the forward stack is preserved.
*/
private stepHistory(): void {
const url = this.history[this.historyIndex]
this.state = {
...this.state,
currentUrl: url,
...this.navFlags(),
}
this.notify()
}
/**
* Sets the preview URL (load target) and marks the iframe as loading. Also
* optimistically updates `PreviewState.currentUrl` to the new URL; the
* preview will confirm (or correct, e.g. on a redirect) the actual location
* via `IframePreviewProvider.recordNavigation`.
*
* No-ops when `url` repeats the PREVIOUS explicit `setUrl` call: callers
* (e.g. a sandbox-boot host reporting its preview URL from several code
* paths — an SSE event, a status-poll fallback, a mount-time check) may
* legitimately call this more than once with the same value. Without this
* guard every redundant call still bumped `loadNonce` and notified, which
* could re-trigger the consuming component's load effect while the FIRST
* load is still in flight — restarting/aborting that load and risking the
* host re-creating the iframe before the prior attempt ever resolved. To
* force a reload of the SAME url, use `IframePreviewProvider.refresh`
* instead. Compared against `lastSetUrl`, NOT `state.url`, so the very
* first call is never skipped just because it happens to match a
* `defaultUrl` the constructor already pre-populated `state.url` with.
* @param url - The new URL to load in the preview iframe.
*/
setUrl(url: string): void {
if (url === this.lastSetUrl) return
this.lastSetUrl = url
this.pushHistory(url)
this.state = {
...this.state,
url,
currentUrl: url,
isLoading: true,
error: null,
loadNonce: this.state.loadNonce + 1,
...this.navFlags(),
}
this.notify()
}
/**
* Records a navigation reported by the running preview via the
* `molecule:navigate` iframe message. Updates `PreviewState.currentUrl`
* (the URL bar) WITHOUT reloading the document — the preview already navigated
* client-side. Non-`http(s)` or unparseable URLs are ignored so a hostile
* `javascript:`/`data:` URL can never reach the URL bar.
* @param url - The preview's new location (absolute `http`/`https` URL).
* @param isReplace - Whether the preview REPLACED its current history entry (a
* `replaceState` redirect/canonicalization) rather than ADDING one (a
* `pushState`). A replace swaps the current entry in place and PRESERVES the
* forward stack; a push (the default) truncates forward. Defaults to `false`.
*/
recordNavigation(url: string, isReplace = false): void {
const validated = validateNavigationUrl(url)
if (!validated) return
// Strip the host's own `_r` cache-buster: the renderer appends it to the
// iframe src to FORCE recovery reloads, and the preview faithfully echoes
// that full `location.href` back here — so without this the internal param
// would leak into the URL bar AND the Back/Forward history.
const cleaned = stripCacheBuster(validated)
if (cleaned === this.state.currentUrl) return
// Record the navigation in history (so Back/Forward work) but do NOT bump
// loadNonce or change the load target — the preview already navigated. A
// replaceState (redirect/canonicalization) swaps the current entry and keeps
// the forward stack; a pushState opens a new branch and truncates forward.
this.pushHistory(cleaned, isReplace)
this.state = { ...this.state, currentUrl: cleaned, ...this.navFlags() }
this.notify()
}
/**
* Returns the current preview URL.
* @returns The current preview URL string.
*/
getUrl(): string {
return this.state.url
}
/**
* Forces a reload of the preview's CURRENT location — like a browser's reload
* button, which reloads the route you are ON, not the page you first opened.
* After an in-app navigation `currentUrl` (the live route) has moved ahead of
* `url` (the last document the iframe loaded), so reload retargets `url` to
* `currentUrl` before bumping `loadNonce` (which makes the renderer reload even
// … truncated — the full declaration is in node_modules/@molecule/app-live-preview-iframe/dist/provider.d.ts
Functions
createProvider(config)
Creates an IframePreviewProvider with optional default URL and device frame.
function createProvider(config?: IframePreviewConfig): IframePreviewProvider
config— Iframe preview configuration (default URL, default device frame).
Returns: An IframePreviewProvider that manages live preview state.
Constants
provider
Pre-instantiated provider singleton.
const provider: IframePreviewProvider
Core Interface
Implements @molecule/app-live-preview interface.
Bond Wiring
Setup function to register this provider with the core interface:
import { setProvider } from '@molecule/app-live-preview'
import { provider } from '@molecule/app-live-preview-iframe'
export function setupLivePreviewIframe(): void {
setProvider(provider)
}
Injection Notes
Requirements
Peer dependencies:
@molecule/app-live-preview^1.0.1
Runtime Dependencies
-
@molecule/app-live-preview -
setUrl(sameUrl)is a deliberate no-op — renderers key reloads offloadNonce. See@molecule/app-live-preview's remarks for the full renderer contract (molecule:navigatereporting, nav-command Back/Forward,?_rcache-buster stripping) — this bond implements all of it.
E2E Tests
Integration checklist — drive the real preview panel this app renders (use navigate_preview, read_preview_ui, and interact_preview; no mocks), adapt each item to this app's actual preview UI + routes, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- The preview panel renders the target app at its current location: the URL
bar shows the app's route (
currentUrl, not the raw load target) and read_preview_ui returns the previewed screen's visible elements + pageText, not a blank frame. - Selecting a device frame/preset resizes the preview to that device: a
mobileframe narrows the viewport,tabletis mid-width,desktopis wide, andnoneshows it unframed/full-width. Switch between two presets and confirm the framed width ACTUALLY changes — not merely that a button looks selected. - Auto-refresh works: change a source file and the preview reloads on its own
to show the latest, with no manual reload — read_preview_ui reflects the new
content. The reload is a
loadNoncebump (a repeatedsetUrlwith the same url is a no-op), so verify the CONTENT updated, not just that a reload fired. - Navigating inside the preview updates the shown route: click an in-app link
(or navigate_preview to a new path) and the URL bar's
currentUrlfollows the client-side route change the app reports via themolecule:navigatemessage — the frame does not stay stuck on the old route. - Back/Forward move through the preview's own history: after two in-app
navigations, Back returns to the previous route and Forward re-advances, and
each button is disabled exactly when
canGoBack/canGoForwardsay there is nowhere to go (Back disabled at the first entry). - A failed or blank load surfaces state, never a silently dead frame: point
the preview at an unreachable/erroring route and the panel shows a visible
error or empty state (
errorset; an alert/status message in read_preview_ui), not a frozen blank iframe.