Prepaint
Prepaint is the render layer of FirstTx.
- On user revisit, it restores the last screen from a DOM snapshot
- Hides the “blank screen” 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 = the layer that gives you a SSR-like revisit experience in CSR apps.” On refresh, back navigation, or re-entering an internal tool from outside, it immediately shows the last screen the user saw.
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,
- Prepaint alone fixes most “blank screen on revisit” problems.
Adding Local-First keeps data state around across offline / revisits.
- Adding Tx lets you wrap optimistic UI updates in safe transactions, with rollback.
1-2. Vite plugin setup
Prepaint ships a Vite plugin that injects a boot script into your HTML.
// 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(), // automatically injects the Prepaint boot script
],
});
-
firstTx()bundles the boot script with esbuild 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">.
The plugin supports several overlay-related options,
overlay: boolean- force overlay mode globallyoverlayRoutes: string[]- enable overlay mode only for specific route prefixesnonce: string- CSP nonce for the injected scripts
The defaults are usually enough, but for complex router/layout setups, overlay mode can make things safer.
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 / hydrateRoot yourself, you use createFirstTxRoot.
This function wires Prepaint’s restore logic and the React mount together.
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 “restore DOM → hydrate / re-render” handoff is wrapped in a smooth, cross-fade-style transition. In unsupported browsers it gracefully degrades. |
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.
-
Choose restore strategy
-
Decide whether to use overlay mode,
- Priority:
window.FIRSTTX_OVERLAY→localStorage("firsttx:overlay")→localStorage("firsttx:overlayRoutes")(prefix matching).
- Priority:
-
If overlay mode,
- Render HTML/styles into a Shadow DOM overlay.
- Set
data-prepaint,data-prepaint-overlay,data-prepaint-timestampon thehtmlroot.
-
If not overlay,
- Inject HTML into the first child of
#root. - Re-inject styles as
or<style data-firsttx-prepaint/>.<link/> - Set
data-prepaintanddata-prepaint-timestampon thehtmlroot.
- Inject HTML into the first child of
-
-
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;
}
const root = document.getElementById("root");
if (!root) return;
root.innerHTML = snapshot.body;
injectStyles(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 being baked into the snapshot and causing hydration mismatches.
- 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. React hydration & root guard
After Prepaint restores a snapshot, React mounts and attempts hydration. Because of CSS-in-JS, random IDs, timestamps, non-deterministic rendering, hydration mismatches can occur.
4-1. Hydration → clean render fallback
createFirstTxRoot wraps this process safely,
-
It checks the
data-prepaintmarker to see whether Prepaint restoration happened, and whether hydration is allowed. -
If a restored DOM is present and
container.children.length === 1,- It tries to hydrate using
hydrateRoot.
- It tries to hydrate using
-
If React’s
onRecoverableErrorfires,-
Wrap the error in a
HydrationErrorand emit aprepaint.hydration.errorevent to DevTools. -
If
transition: trueand ViewTransition is supported,- Use
document.startViewTransitionto switch to a clean client render.
- Use
-
Otherwise, silently fall back to a clean client render.
-
-
After hydration or client render,
- Clean up Prepaint markers (
data-prepaint*), Prepaint styles, and overlays. - Install a root guard via
installRootGuardto watch for structural changes under#root.
- Clean up Prepaint markers (
4-2. Root guard behavior
installRootGuard ensures that,
-
If the number of children under
#rootbecomes anything other than 1 (e.g., some code adds sibling nodes directly under#root),- It unmounts the current React tree.
- Clears
container.innerHTML. - Re-renders the app cleanly.
-
It may also react to
popstate/pageshownavigation events to restore a clean root in unexpected situations.
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- hydration error detectedstorage.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- Wraps React hydration mismatches and is surfaced as
hydration.errorevents.
- Wraps React hydration mismatches and is surfaced as
-
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 restores yesterday’s DOM snapshot in ~0ms.
- React + Local-First hydrate state from IndexedDB and fill in the data.
- 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 let a CSR app feel like “the screen is always ready when you come back.”
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
- Clocks, counters, random badges, A/B test placements, etc. should be wrapped with
data-firsttx-volatileso their text is removed on capture. This greatly reduces hydration mismatch risk.
- Clocks, counters, random badges, A/B test placements, etc. should be wrapped 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
- Avoid attaching non-React DOM nodes directly under
#root. If you must, use a dedicated container or disable Prepaint for that page.
- Avoid attaching non-React DOM nodes directly under
-
Overlay routes
overlayRoutesmatches by prefix. For more complex patterns (e.g. parameterized routes or regex), handle logic in your app (e.g. setwindow.FIRSTTX_OVERLAY) and let Prepaint read that flag.
With this setup, Prepaint becomes a straightforward way to deliver a “0ms revisit screen” on top of your existing CSR React app.