logo
Firsttx

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.
One-line summary

“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,

Install the full FirstTx stack
pnpm add @firsttx/prepaint @firsttx/local-first @firsttx/tx

If you want to try Prepaint first, you can install it alone,

Install only Prepaint
pnpm add @firsttx/prepaint
Combining with Local-First / Tx
  • 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.

ts
// 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)
    • minify is enabled only in production
  • If you prefer a separate file, you can set inline: false and load it via <script src="/firsttx-boot.js">.

Overlay-related plugin options

The plugin supports several overlay-related options,

  • overlay: boolean - force overlay mode globally

  • overlayRoutes: string[] - enable overlay mode only for specific route prefixes

  • nonce: 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

tsx
// 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?)

Parameters
requiredoptional
NameTypeDefaultDescription
container
HTMLElement-The DOM element where your React app is mounted (usually #root).
element
ReactElement-The React element to render, typically <App />.
options.transition
booleantrueWhether 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,
ts
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,

  1. Compute route key

    • If window.FIRSTTX_ROUTE_KEY is set, use that.
    • Otherwise, fall back to location.pathname.
  2. Read the snapshot for that key from the snapshots store in IndexedDB.

  3. Validate snapshot shape and required fields.

    • If validation fails, delete the snapshot and fall back to a cold start.
  4. Check TTL.

    • If the snapshot age exceeds 7 days, treat it as expired and do a cold start.
  5. Choose restore strategy

    • Decide whether to use overlay mode,

      • Priority: window.FIRSTTX_OVERLAYlocalStorage("firsttx:overlay")localStorage("firsttx:overlayRoutes") (prefix matching).
    • If overlay mode,

      • Render HTML/styles into a Shadow DOM overlay.
      • Set data-prepaint, data-prepaint-overlay, data-prepaint-timestamp on the html root.
    • If not overlay,

      • Inject HTML into the first child of #root.
      • Re-inject styles as <style data-firsttx-prepaint/> or <link/>.
      • Set data-prepaint and data-prepaint-timestamp on the html root.
  6. Emit a prepaint.restore event to DevTools, including the strategy ("has-prepaint" / "cold-start"), snapshot age, restore duration, etc.

ts
// 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,
  });
}
Overlay mode

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 (when document.visibilityState === "hidden")
  • pagehide
  • beforeunload

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 #root is serialized.

  • Security,

    • Remove all inline event handlers (onClick, onChange, etc.) to minimize XSS risk.
  • 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.
  • 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.

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).

This information becomes Snapshot.styles. On restore, style-utils turns them back into <style> / <link> elements.

Be careful with sensitive data in snapshots
  • 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-sensitive or 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,

  1. It checks the data-prepaint marker to see whether Prepaint restoration happened, and whether hydration is allowed.

  2. If a restored DOM is present and container.children.length === 1,

    • It tries to hydrate using hydrateRoot.
  3. If React’s onRecoverableError fires,

    • Wrap the error in a HydrationError and emit a prepaint.hydration.error event to DevTools.

    • If transition: true and ViewTransition is supported,

      • Use document.startViewTransition to switch to a clean client render.
    • Otherwise, silently fall back to a clean client render.

  4. After hydration or client render,

    • Clean up Prepaint markers (data-prepaint*), Prepaint styles, and overlays.
    • Install a root guard via installRootGuard to watch for structural changes under #root.

4-2. Root guard behavior

installRootGuard ensures that,

  • If the number of children under #root becomes 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 / pageshow navigation events to restore a clean root in unexpected situations.

Multi-root apps warning

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 completed
  • restore - snapshot restore completed (or cold start decided)
  • handoff - control handed from Prepaint to React
  • hydration.error - hydration error detected
  • storage.error - IndexedDB read/write error

Each event has,

  • category: "prepaint"
  • id: a UUID
  • timestamp: when it occurred
  • priority: 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.error events.
  • PrepaintStorageError

    • For IndexedDB issues like quota/permission/corruption.
    • PERMISSION_DENIED is 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,

  1. The user opens the same dashboard they were looking at yesterday.
  2. Prepaint restores yesterday’s DOM snapshot in ~0ms.
  3. React + Local-First hydrate state from IndexedDB and fill in the data.
  4. If the TTL has expired, useSyncedModel runs a background server sync.
  5. User actions (filter changes, form submissions, etc.) are handled by Tx as optimistic transactions, and rolled back cleanly—with ViewTransition—if needed.
One sentence for all three layers
  • 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-sensitive or register selectors in window.FIRSTTX_SENSITIVE_SELECTORS so their values are scrubbed.
  • Frequently changing areas

    • Clocks, counters, random badges, A/B test placements, etc. should be wrapped with data-firsttx-volatile so their text is removed on capture. This greatly reduces hydration mismatch risk.
  • 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.
  • Overlay routes

    • overlayRoutes matches by prefix. For more complex patterns (e.g. parameterized routes or regex), handle logic in your app (e.g. set window.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.