Prepaint
Prepaint is the render layer of FirstTx.
- On user revisit, it restores the last screen from a DOM snapshot
- Reduces the visible blank interval while the React bundle and data are loading
- And when ViewTransition is available, it wraps the restore → real render handoff in a smooth animation.
Prepaint is a boot-time visual cache for CSR revisits. On refresh, back navigation, or re-entering an internal tool from outside, it can show the last captured screen before the main app bundle starts.
1. Installation & basic integration
1-1. Install packages
In most cases, you’ll want to use all three layers together,
If you want to try Prepaint first, you can install it alone,
Module format: ESM-only. CommonJS users should use
import()(dynamic import).
- Prepaint alone can reduce the visible blank interval on revisit.
Adding Local-First keeps data state around across offline / revisits.
- Adding Tx lets you run optimistic UI steps with explicit compensating actions.
1-2. Vite plugin setup
Prepaint ships a Vite plugin that injects a boot script into your HTML. Currently, the Prepaint plugin is provided for Vite only.
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import { firstTx } from "@firsttx/prepaint/plugin/vite";
export default defineConfig({
plugins: [
react(),
firstTx(),
],
});
-
firstTx()bundles the boot script (using esbuild internally) and injects it as an IIFE into your HTML. -
Default behavior,
inline: true- bundle is inlined into the HTML (no extra network request)minifyis enabled only in production
-
If you prefer a separate file, you can set
inline: falseand load it via<script src="/firsttx-boot.js">.
Snapshot restore always uses a non-interactive Shadow DOM overlay outside the React root.
overlayandoverlayRoutesremain accepted as deprecated no-ops for one release.nonce: string- nonce embedded in generated output; static Vite output cannot create a per-response nonce
setupCapture with its routes option can restrict new captures; restore-side route filtering is not available in this release.
For static hosting, prefer an external boot asset or a CSP hash. A nonce passed to the Vite plugin is generated at build time, not once per HTTP response.
1-3. Entry point: createFirstTxRoot
// main.tsx
import { createFirstTxRoot } from "@firsttx/prepaint";
import { App } from "./App";
createFirstTxRoot(
document.getElementById("root")!,
<App />,
{ transition: true },
);
Instead of calling createRoot yourself, use createFirstTxRoot.
It mounts React into an empty container and removes the visual overlay after the first commit.
createFirstTxRoot(container, element, options?)
| Name | Type | Default | Description |
|---|---|---|---|
container | HTMLElement | - | The DOM element where your React app is mounted (usually #root). |
element | ReactElement | - | The React element to render, typically <App />. |
options.transition | boolean | true | Whether to use document.startViewTransition. In supported browsers, the visual snapshot → React app handoff is wrapped in a transition. In unsupported browsers the handoff runs without that effect. |
2. Boot script & snapshot lifecycle
The core of Prepaint is the boot script injected into your HTML.
2-1. Snapshot storage spec
- IndexedDB DB name:
firsttx-prepaint - Object store:
snapshots - Key:
route(route key) - Snapshot format,
type Snapshot = {
route: string;
body: string; // innerHTML of the first child of #root
timestamp: number; // when the snapshot was captured
styles?: Array<
| { type: "inline"; content: string }
| { type: "external"; href: string; content?: string }
>;
};
- Default max age:
MAX_SNAPSHOT_AGE = 7 days
2-2. Boot flow
Roughly, the boot script does,
-
Compute route key
- If
window.FIRSTTX_ROUTE_KEYis set, use that. - Otherwise, fall back to
location.pathname.
- If
-
Read the snapshot for that key from the
snapshotsstore in IndexedDB. -
Validate snapshot shape and required fields.
- If validation fails, delete the snapshot and fall back to a cold start.
-
Check TTL.
- If the snapshot age exceeds 7 days, treat it as expired and do a cold start.
-
Restore visual cache
- Render HTML and styles into a non-interactive Shadow DOM overlay.
- Leave
#rootuntouched for a clean React mount. - Set
data-prepaint,data-prepaint-overlay, anddata-prepaint-timestampon thehtmlroot.
-
Emit a
prepaint.restoreevent to DevTools, including thestrategy("has-prepaint"/"cold-start"), snapshot age, restore duration, etc.
// Conceptual example (the real implementation is more defensive)
async function boot() {
const snapshot = await getSnapshotForRoute();
if (!snapshot || isExpired(snapshot)) {
emitRestore({ strategy: "cold-start" });
return;
}
mountOverlay(snapshot.body, snapshot.styles);
markAsPrepainted(snapshot.timestamp);
emitRestore({
strategy: "has-prepaint",
age: Date.now() - snapshot.timestamp,
});
}
In overlay mode, Prepaint uses a Shadow DOM-based overlay
It does not touch
#root, but instead creates a host under<body>and renders HTML/CSS inside a Shadow DOM.- The router/layout can run its initial render normally while the overlay shows the previous screen.
- Once React is ready, the overlay is replaced by the real app, optionally wrapped in a ViewTransition or a simple fade.
3. Capture pipeline: what DOM is stored?
Right before the user leaves the page, Prepaint stores a snapshot of the first child of #root.
3-1. Capture triggers
setupCapture schedules a capture when the following events fire,
visibilitychange(whendocument.visibilityState === "hidden")pagehidebeforeunload
When these events occur, it queues the capture using queueMicrotask.
To avoid capturing too frequently, it ignores redundant calls in a short time window in the same tab.
3-2. DOM serialization & scrubbing
During capture, Prepaint performs several cleanup steps,
-
Target: only the first child node under
#rootis serialized. -
Security,
- Remove all inline event handlers (
onClick,onChange, etc.) to minimize XSS risk.
- Remove all inline event handlers (
-
Volatile values,
- For elements with
data-firsttx-volatile, clear their text/value. - This prevents always-changing values (timestamps, random IDs, counters) from appearing as stale visual cache.
- For elements with
-
Sensitive data scrubbing,
- Default selectors include
input[type="password"],[data-firsttx-sensitive], etc. - You can provide additional selectors via
window.FIRSTTX_SENSITIVE_SELECTORS. - For matching elements, values/text are removed from the snapshot.
- Default selectors include
3-3. Style collection
Styles are collected as,
-
<style>tags- Their CSS text is stored inline.
-
<link rel="stylesheet">- Same-origin +
fetchable: CSS text is fetched and stored along with the href, for better offline restores. - Otherwise: only the href is stored (for a security/size trade-off).
- Same-origin +
This information becomes Snapshot.styles. On restore, style-utils turns them back into <style> / <link> elements.
- Scrubbing is selector-based, so any field you don't explicitly mark may remain in the DOM snapshot.
By default, snapshots are stored in IndexedDB for up to 7 days.
For screens with passwords or personal data, use
data-firsttx-sensitiveor global selectors, or consider disabling Prepaint on those pages altogether.
4. Visual handoff
handoff()checks thedata-prepaintmarker to determine whether a snapshot was restored.createFirstTxRoot()clears the container and always mounts React withcreateRoot().- The visual overlay and
data-prepaint*markers remain until React's first commit. - After the first commit, Prepaint removes the overlay and markers. When ViewTransition is supported and
transition: true, the removal is wrapped in a transition.
Cached client DOM is never reused as React input. There is no single-child root guard, so Fragments and multiple top-level React nodes are valid output.
Prepaint assumes there is a single React root and only snapshots the
first child of #root
. In multi-root architectures, restoration may not behave as expected. If possible,
- Consolidate to a single root, or
- Disable Prepaint on pages that intentionally use multiple roots.
5. DevTools & error model
5-1. DevTools events
Prepaint emits the following events to the DevTools bridge,
capture- snapshot capture completedrestore- snapshot restore completed (or cold start decided)handoff- control handed from Prepaint to Reacthydration.error- legacy event type retained for compatibility; not emitted by the current default pathstorage.error- IndexedDB read/write error
Each event has,
category: "prepaint"id: a UUIDtimestamp: when it occurredpriority: 0-2 (smooth flow / important events / errors)
Events are only emitted if window.__FIRSTTX_DEVTOOLS__?.emit is present.
5-2. Error hierarchy (overview)
Internally, Prepaint uses a small error hierarchy,
-
PrepaintError(abstract)getUserMessage()getDebugInfo()isRecoverable()
-
BootError- For DB open, snapshot read, DOM restore, style injection, etc.
- Usually recoverable (a retry can succeed).
-
CaptureError- For DOM serialization, style collection, DB writes during capture.
- Effect: “capture failed → Prepaint won’t be applied on next visit”, typically recoverable.
-
HydrationError- Deprecated error type retained for legacy consumers.
-
PrepaintStorageError- For IndexedDB issues like quota/permission/corruption.
PERMISSION_DENIEDis treated as non-recoverable, others as recoverable.
You rarely need to catch these in app code directly, but they are useful when analyzing issues in DevTools or logs.
6. Synergy with Local-First / Tx
Prepaint works fine on its own, but the experience is best when combined with the other two layers.
Example revisit flow,
- The user opens the same dashboard they were looking at yesterday.
- Prepaint replays yesterday’s visual snapshot during boot.
- React mounts and Local-First reads persisted state from IndexedDB.
- If the TTL has expired,
useSyncedModelruns a background server sync. - User actions (filter changes, form submissions, etc.) are handled by Tx as optimistic transactions, and rolled back cleanly—with ViewTransition—if needed.
Prepaint: revisit experience for the visible screen
Local-First: durability for the data state
Tx: safety for the change path
Together, they preserve visual and data snapshots while the current app starts and revalidates.
7. Recommended settings & caveats
Based on the internal design and reference docs, here are some practical tips,
-
Sensitive data
- For passwords, tokens, personal information fields, mark them with
data-firsttx-sensitiveor register selectors inwindow.FIRSTTX_SENSITIVE_SELECTORSso their values are scrubbed.
- For passwords, tokens, personal information fields, mark them with
-
Frequently changing areas
- Wrap clocks, counters, random badges, and A/B test placements with
data-firsttx-volatileso stale text is not exposed in the visual cache.
- Wrap clocks, counters, random badges, and A/B test placements with
-
Style size
- Storing same-origin CSS content improves offline restores, but in large apps it can consume noticeable IndexedDB space. If needed, structure your CSS so “critical styles” are same-origin (and stored), and heavy styles are externalized.
-
Root structure
- React Fragments and multiple top-level nodes are supported. Prepaint does not monitor or rewrite the committed React root.
With this setup, Prepaint can reduce the visible boot interval on revisit. Measure replay and handoff timing with the snapshot sizes, devices, and network profiles your app actually supports.