@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-iframe

npm · 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.ts JSDoc, 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 off loadNonce. See @molecule/app-live-preview's remarks for the full renderer contract (molecule:navigate reporting, nav-command Back/Forward, ?_r cache-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 mobile frame narrows the viewport, tablet is mid-width, desktop is wide, and none shows 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 loadNonce bump (a repeated setUrl with 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 currentUrl follows the client-side route change the app reports via the molecule:navigate message — 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/canGoForward say 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 (error set; an alert/status message in read_preview_ui), not a frozen blank iframe.