New to Sandbox?
Start with the Overview for installation and a quick example, then come back here for in-depth usage patterns.
Basic Usage
Create a sandbox by passing a container element. The returned SandboxHandle is your entire interface to the iframe.
import { createSandbox } from '@vielzeug/sandbox';
const container = document.getElementById('preview')!;
const sandbox = createSandbox(container);
await sandbox.render('<p>Hello from the sandbox</p>');render() returns a Promise<void> that resolves when the sandbox document signals it is ready. No DOM is created until render() is called — createSandbox() is a cheap factory.
For reactive frameworks, subscribe via onMessage to receive error, custom, and resize events.
Rendering HTML
render(html) replaces the entire sandboxed document with a new one containing your HTML in the body.
await sandbox.render(`
<style>body { font-family: sans-serif; }</style>
<h1>Component Preview</h1>
<ore-button variant="primary">Click me</ore-button>
`);Each call to render() is a full page reset — scripts reinitialise, CSS is re-applied, and any DOM state is lost. For incremental updates, push state via setState() or patch styles via updateStyle() rather than re-rendering.
Incremental Updates with replaceBody()
replaceBody(html) replaces document.body.innerHTML in the live document without navigating the iframe. Head scripts, document/window listeners, named styles, and global state survive. Body descendants, their listeners, references, form state, and scripts inside replacement HTML do not survive.
Use it for streaming AI-generated output or live previews when the host owns accumulated markup.
// Initial render — sets up the document, scripts, and styles
await sandbox.render(`
<script>
document.addEventListener('sandbox:state-update', (e) => {
document.body.dataset.theme = e.detail.value;
});
</script>
<p>Loading…</p>
`);
// Subsequent updates replace body descendants
sandbox.replaceBody('<p>First chunk arrived</p>');
sandbox.replaceBody('<p>First chunk arrived</p><p>Second chunk…</p>');
sandbox.replaceBody('<p>Complete response</p>');replaceBody() vs render():
render() | replaceBody() | |
|---|---|---|
| Full page reset | Yes | No |
| Returns a Promise | Yes | No |
| Head scripts re-run | Yes | No |
namedStyles preserved | Re-injected | Yes |
| Body descendants/listeners | Recreated | Replaced |
| When to use | Initial load, structural reset | Streaming markup, live preview |
replaceBody() must be called after render() resolves. The bridge must be initialized before it can receive the replacement.
Passing State
setState(key, value) pushes data into the sandbox without re-rendering.
Always call setState() after render() resolves — calling it before the bridge finishes initializing will silently drop the update in a real browser, and a dev warning will fire.
// Correct: await render() before pushing state
await sandbox.render('<div id="root"></div>');
sandbox.setState('theme', 'dark');
sandbox.setState('user', { name: 'Alice' });Inside the sandbox document, listen for the sandbox:state-update custom event on document:
<script>
document.addEventListener('sandbox:state-update', (e) => {
const { key, value } = e.detail;
if (key === 'theme') document.body.dataset.theme = value;
if (key === 'user') document.querySelector('#name').textContent = value.name;
});
</script>Batch State Updates
setStateAll(record) pushes multiple state values in a single postMessage — one call instead of one setState() per key. Use it for initial state setup where several values become available at the same time.
await sandbox.render('<div id="root"></div>');
// One postMessage instead of two setState() calls
sandbox.setStateAll({
theme: 'dark',
user: { name: 'Alice' },
});The sandbox side listens the same way as for setState() — each key in the record fires its own sandbox:state-update event.
Handling Errors
Subscribe to onMessage before calling render() to catch runtime errors in sandbox content.
sandbox.onMessage((msg) => {
if (msg.type === 'error') {
console.error('[sandbox error]', msg.message);
if (msg.stack) console.debug(msg.stack);
}
});Both synchronous errors (window.onerror) and unhandled promise rejections (unhandledrejection) are forwarded as { type: 'error' } messages.
render() rejection
render() rejects with a SandboxTimeoutError if the document never signals 'ready' within 5 seconds — this happens in every build, not just dev. It usually means the document is missing the bridge script (custom srcdoc HTML built by hand instead of via buildDocument()). Always handle it:
import { SandboxError } from '@vielzeug/sandbox';
try {
await sandbox.render(html);
} catch (err) {
if (err instanceof SandboxError) {
console.error('Sandbox failed to load:', err.message);
}
}A second render() call superseding the first does not trigger this — the superseded Promise resolves, not rejects.
Injecting Scripts and Styles
Use SandboxOptions to inject external scripts and styles into every rendered document.
const sandbox = createSandbox(container, {
scripts: [
'https://cdn.example.com/ore.js',
'https://cdn.example.com/refine.js',
],
namedStyles: {
base: `
:root { --color-primary: #0066cc; }
body { margin: 0; font-family: var(--font-sans); }
`,
},
});Script URLs are injected before user content. Their origins are automatically added to script-src in the CSP — you do not need to configure buildCsp separately.
Setting Document Language and Title
Use lang and title to set the generated document's <html lang="…"> attribute and <title>. Both improve screen-reader behaviour for sandboxed content.
const sandbox = createSandbox(container, {
lang: 'de',
title: 'Component Preview',
});lang defaults to 'en'; use a 2–3 letter primary language with optional 2–8 character subtags, such as de or zh-Hant. title defaults to '' and is HTML-escaped before document generation. Invalid language tags throw SandboxConfigurationError.
Hot-patching Named Styles
namedStyles injects named <style id="key"> blocks into the document <head>. Named blocks can be updated live without a full re-render using updateStyle(id, css).
const sandbox = createSandbox(container, {
namedStyles: {
theme: ':root { --color-primary: #0066cc; --bg: #fff; }',
},
});
await sandbox.render('<ore-button variant="primary">Click me</ore-button>');
// Switch theme live — no re-render
sandbox.updateStyle('theme', ':root { --color-primary: #bb33ff; --bg: #111; }');updateStyle() sends a postMessage to the iframe, patching <style id="theme"> in place. It also updates the baseline so the next render() starts with the patched CSS. Safe to call before the first render (baseline only — no postMessage sent to an uninitialized iframe).
Resize Notifications
The bridge script automatically emits resize messages via a ResizeObserver on document.body. No manual wiring is needed in your sandbox content.
sandbox.onMessage((msg) => {
if (msg.type === 'resize') {
container.style.height = `${msg.height}px`;
}
});The resize message fires whenever the document.body height changes — on initial load, after content updates via setState(), and after style patches via updateStyle().
Tying Async Work to Sandbox Lifetime
disposalSignal is an AbortSignal that is aborted when the sandbox is disposed. Pass it to any async operation that should stop when the sandbox is torn down.
const sandbox = createSandbox(container);
// Polling loop tied to sandbox lifetime
async function poll() {
while (!sandbox.disposalSignal.aborted) {
const data = await fetch('/api/data', { signal: sandbox.disposalSignal }).then(r => r.json()).catch(() => null);
if (data) sandbox.setState('data', data);
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
poll();When sandbox.dispose() is called, disposalSignal aborts, cancelling in-flight fetches and stopping the loop.
Configuring CSP
Use allowedStyleOrigins, allowedFontOrigins, and allowedImageOrigins to allow CDN resources.
const sandbox = createSandbox(container, {
allowedStyleOrigins: ['https://fonts.googleapis.com'],
allowedFontOrigins: ['https://fonts.gstatic.com'],
allowedImageOrigins: ['https://images.example.com'],
});Then render HTML that uses those resources:
await sandbox.render(`
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
<p style="font-family: Inter, sans-serif">Hello</p>
`);Origins must be absolute http: or https: origins without paths, query strings, fragments, or credentials. Script URLs must be absolute http: or https: URLs. Nonces must be non-empty base64/base64url-style tokens. Invalid configuration throws SandboxConfigurationError; generated CSP always includes base-uri 'none' to block <base>-tag injection.
Disposal
Dispose the sandbox when it is no longer needed. This removes the iframe from the DOM and clears all message listeners.
// Explicit
sandbox.dispose();
// Using explicit resource management (TypeScript 5.2+)
{
using sandbox = createSandbox(container);
await sandbox.render('<p>Temporary preview</p>');
} // sandbox.dispose() called automaticallyMultiple Listeners
onMessage supports multiple independent subscriptions. Each call returns its own unsubscribe function.
const unsubErrors = sandbox.onMessage((msg) => {
if (msg.type === 'error') logError(msg);
});
const unsubEvents = sandbox.onMessage((msg) => {
if (msg.type === 'custom') handleCustomEvent(msg);
});
// Remove a single subscription
unsubErrors();
// Remove all — dispose() clears all listeners at once
sandbox.dispose();Receiving Events from the Sandbox
Sandbox code calls window.__sandbox__.emit(event, detail) to send events to the host. Receive them via onMessage with msg.type === 'custom'.
<!-- Inside sandbox content -->
<button onclick="window.__sandbox__.emit('button:click', { label: 'Save' })">Save</button>// Host
sandbox.onMessage((msg) => {
if (msg.type === 'custom' && msg.event === 'button:click') {
console.log('Sandbox button clicked:', msg.detail);
}
});TypeScript support for sandbox-side code — add an ambient declaration referencing SandboxBridge:
// sandbox-env.d.ts
declare interface Window {
__sandbox__: import('@vielzeug/sandbox').SandboxBridge;
}Awaiting Subsequent Renders
render() returns a Promise<void> that resolves when the new document signals ready. Await it directly for each render:
await sandbox.render(firstHtml); // first render complete
await sandbox.render(secondHtml); // second render completeIf a second render() starts before the first resolves, the first Promise resolves immediately (superseded). Multiple concurrent callers can each await their own returned Promise.
Cancelling Renders with AbortSignal
Pass an AbortSignal to render() to skip the render if it has already been cancelled. Useful in streaming or queued workflows:
let controller = new AbortController();
async function streamRender(html: string) {
controller.abort(); // cancel previous pending render
controller = new AbortController();
await sandbox.render(html, { signal: controller.signal });
}If the signal is already aborted when render() is called, the render is skipped with no warning and no DOM change.
Building Sandbox Documents Directly
Use buildDocument when you need static isolated markup outside createSandbox, such as server-generated HTML or a Codex template. Use createSandbox instead when the host needs state updates, body replacement, style updates, readiness, or disposal.
import { buildDocument } from '@vielzeug/sandbox';
const html = buildDocument('<p>Hello</p>', {
allowedStyleOrigins: ['https://fonts.googleapis.com'],
allowedFontOrigins: ['https://fonts.gstatic.com'],
namedStyles: {
theme: ':root { --bg: #fff; }',
},
});
// html is a complete <!doctype html> document — assign directly to srcdoc
iframe.srcdoc = html;Use buildCsp if you only need the CSP string for an existing document template:
import { buildCsp } from '@vielzeug/sandbox';
const csp = buildCsp({ allowedFontOrigins: ['https://fonts.gstatic.com'] });
// → "default-src 'none'; ... font-src https://fonts.gstatic.com; ..."Testing
Use createSandboxTestHelpers from the /testing subpath to simulate sandbox→host messages without a real srcdoc script execution (jsdom does not execute iframe srcdoc scripts).
import { createSandbox } from '@vielzeug/sandbox';
import { createSandboxTestHelpers } from '@vielzeug/sandbox/testing';
import { describe, expect, it } from 'vitest';
describe('preview panel', () => {
it('forwards a custom event from the sandbox', async () => {
const container = document.createElement('div');
const sandbox = createSandbox(container);
const helpers = createSandboxTestHelpers(container);
const received: unknown[] = [];
sandbox.onMessage((msg) => received.push(msg));
const renderPromise = sandbox.render('<button>Save</button>');
helpers.fireReady(); // simulate the bridge script's initial postMessage
await renderPromise;
helpers.fireCustom('button:click', { label: 'Save' });
expect(received).toEqual([{ type: 'custom', event: 'button:click', detail: { label: 'Save' } }]);
sandbox.dispose();
});
});SandboxTestHelpers also exposes fireResize(height) and fireError(message, stack?) for testing resize and error handling without a live browser.
Framework Integration
Create the sandbox once per mount and dispose it on unmount — the container element is stable for the component's lifetime.
import { useEffect, useRef } from 'react';
import { createSandbox } from '@vielzeug/sandbox';
function SandboxPreview({ html }: { html: string }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const sandbox = createSandbox(containerRef.current);
sandbox.render(html);
return () => sandbox.dispose();
}, [html]);
return <div ref={containerRef} />;
}<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { createSandbox, type SandboxHandle } from '@vielzeug/sandbox';
const props = defineProps<{ html: string }>();
const containerRef = ref<HTMLDivElement>();
let sandbox: SandboxHandle | undefined;
onMounted(() => {
if (!containerRef.value) return;
sandbox = createSandbox(containerRef.value);
sandbox.render(props.html);
});
onUnmounted(() => sandbox?.dispose());
</script>
<template>
<div ref="containerRef" />
</template><script lang="ts">
import { onMount } from 'svelte';
import { createSandbox } from '@vielzeug/sandbox';
export let html: string;
let container: HTMLDivElement;
onMount(() => {
const sandbox = createSandbox(container);
sandbox.render(html);
return () => sandbox.dispose();
});
</script>
<div bind:this={container}></div>Working with Other Vielzeug Libraries
With Codex: The generate-sandbox-document and get-state-bridge-spec MCP tools in @vielzeug/codex are designed to work with Sandbox. They generate complete sandbox-ready document templates and document the bridge protocol.
// After codex generates an HTML document:
await sandbox.render(generatedDocument);With Refine: Inject the Refine/Ore runtime into the sandbox via scripts:
const sandbox = createSandbox(container, {
scripts: ['https://cdn.example.com/refine.iife.js'],
namedStyles: {
theme: '/* refine theme tokens */',
},
});
await sandbox.render('<ore-card><ore-button>Save</ore-button></ore-card>');Best Practices
- Await
render()before callingsetState()/setStateAll()— both warn in dev if called before the bridge is ready. UsesetStateAll()to bootstrap several values in one postMessage instead of callingsetState()repeatedly. - Use
await sandbox.render(html)for each render —render()returns aPromise<void>that resolves when the document is ready. No separate readiness API is needed. - Use
updateStyle()for theme switching — updating a named style avoids a fullrender()and preserves current document state. - Check
disposedbefore deferred calls — across async operations, checksandbox.disposedbefore calling any method to avoid spurious dev warnings. - Tie async work to
disposalSignal— passdisposalSignaltofetchand other async operations so they cancel automatically on dispose. - Treat all messages as untrusted — sandbox code controls
SandboxMessagepayloads. Do noteval()or execute any message field. - One sandbox per preview —
createSandbox()is a cheap factory; create a new sandbox per user session or component rather than reusing across unrelated renders. - Use
usingin functions — in TypeScript 5.2+ contexts,usingguarantees cleanup even on exceptions. - Use
replaceBody()orsetState()for incremental updates —render()resets document state.replaceBody()replaces body descendants;setState()updates live code without replacing DOM.