@firsttx/prepaint
v0.10.0
Published
Instant boot script for CSR apps that replays IndexedDB snapshots before React loads, delivering ~0 ms revisit blanks with automatic hydration or clean rerender fallback.
Downloads
169
Maintainers
Readme
@firsttx/prepaint
Instant replay for CSR apps — ~0ms blank screen on revisit
Restores your app's last visual state from IndexedDB before JavaScript loads. No blank screens. Automatic React hydration with graceful fallback.
npm install @firsttx/prepaintModule format: ESM-only. CommonJS users should use
import()(dynamic import).
Why Prepaint?
The only solution that restores UI before JavaScript loads.
- No SSR/SSG infrastructure needed
- Works with any existing CSR React app
- Automatic React hydration with graceful fallback
- Native ViewTransition support
The Problem
Traditional CSR on revisit:
User clicks → Blank screen (2000ms) → Content appears
With Prepaint:
User clicks → Last snapshot (~0ms) → React hydrates → Fresh dataPrepaint captures DOM snapshots per route and replays them instantly on the next visit.
Quick Start
1. Vite Plugin
Prepaint currently provides a Vite plugin only.
// vite.config.ts
import { firstTx } from '@firsttx/prepaint/plugin/vite';
export default defineConfig({
plugins: [firstTx()],
});2. React Entry
// main.tsx
import { createFirstTxRoot } from '@firsttx/prepaint';
createFirstTxRoot(document.getElementById('root')!, <App />);Done. Prepaint now:
- Captures snapshots on page hide/unload
- Restores them in ~0ms on revisit
- Hydrates with React (or falls back gracefully)
How It Works
Three Phases
┌─────────────────────────────────┐
│ 1) Capture (on page leave) │
│ - beforeunload/pagehide/ │
│ visibilitychange │
│ - Saves DOM + styles to │
│ IndexedDB │
└─────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ 2) Boot (~0ms on revisit) │
│ - Inline script runs │
│ - Reads snapshot from IndexedDB│
│ - Injects into #root │
└─────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ 3) Handoff (~500ms) │
│ - Main bundle loads │
│ - Hydrates or client-renders │
│ - Cleans up prepaint artifacts │
└─────────────────────────────────┘Storage
- DB:
firsttx-prepaint - Store:
snapshots - Key: route pathname
- TTL: 7 days
API
createFirstTxRoot(container, element, options?)
createFirstTxRoot(
container: HTMLElement,
element: ReactElement,
options?: {
transition?: boolean; // ViewTransition (default: true)
onCapture?: (snapshot: Snapshot) => void;
onHandoff?: (strategy: 'has-prepaint' | 'cold-start') => void;
onHydrationError?: (error: Error) => void;
}
);Behavior
- If snapshot exists +
#roothas 1 child →hydrateRoot() - Otherwise →
createRoot()fresh render - On hydration error → fallback to clean render (with ViewTransition)
firstTx(options?) (Vite Plugin)
firstTx({
inline?: boolean, // Inline boot script (default: true)
minify?: boolean, // Minify boot script (default: !isDev)
injectTo?: 'head-prepend' | 'head' | 'body-prepend' | 'body',
nonce?: string | (() => string),
overlay?: boolean, // Enable overlay mode globally
overlayRoutes?: string[], // Overlay for specific routes
})Overlay Mode
Problem Direct injection into #root can race with routers, causing duplicate DOM.
Solution Overlay mode paints the snapshot above your app in Shadow DOM, then fades out after hydration.
Enable Overlay
// Option 1: Global flag
window.__FIRSTTX_OVERLAY__ = true;
// Option 2: localStorage (persists)
localStorage.setItem('firsttx:overlay', '1');
// Option 3: Specific routes
localStorage.setItem('firsttx:overlayRoutes', '/prepaint,/dashboard');
// Option 4: Vite plugin
firstTx({ overlay: true });Disable
delete window.__FIRSTTX_OVERLAY__;
localStorage.removeItem('firsttx:overlay');
localStorage.removeItem('firsttx:overlayRoutes');Real-World Patterns
With Local-First
import { useModel } from '@firsttx/local-first';
function ProductsPage() {
const [products] = useModel(ProductsModel);
// Prepaint shows last snapshot
// useModel provides instant data from IndexedDB
if (!products) return <Skeleton />;
return <ProductList products={products} />;
}Mark Volatile Content
// These change on every render → exclude from snapshot
<span data-firsttx-volatile>{Date.now()}</span>
<div data-firsttx-volatile>{Math.random()}</div>
<Timer data-firsttx-volatile />Debug Lifecycle
createFirstTxRoot(root, <App />, {
onCapture: (snapshot) => console.log('Captured:', snapshot.route),
onHandoff: (strategy) => console.log('Strategy:', strategy),
onHydrationError: (err) => console.error('Hydration failed:', err),
});Hydration & Fallback
Single-Child Rule
Prepaint only attempts hydration if #root has exactly 1 child. Otherwise → fresh render.
Root Guard
After mount, a MutationObserver watches #root:
- Detects extra children (e.g., router appending siblings)
- Unmounts → clears → re-renders cleanly
- Prevents "double UI" issues
Cleanup
Post-mount:
- Removes
<html data-prepaint="true"> - Removes
style[data-firsttx-prepaint] - Removes overlay host
#__firsttx_prepaint__
Best Practices
DO
✅ Use ViewTransition (default)
createFirstTxRoot(root, <App />, { transition: true });✅ Mark volatile content
<span data-firsttx-volatile>{timestamp}</span>✅ Combine with Local-First
const [data] = useModel(Model);
// Instant data from IndexedDB while network refreshesDON'T
❌ Don't expect instant availability on first visit
// First visit: snapshot doesn't exist yet
// Only kicks in on second+ visits❌ Don't capture sensitive data
// Snapshots are plain HTML in IndexedDB
// Avoid capturing auth tokens, PII, etc.Security
HTML Sanitization
Prepaint sanitizes all restored HTML to prevent XSS attacks:
- Removes dangerous tags:
<script>,<iframe>,<form>,<object>, etc. - Removes event handlers:
onclick,onerror,onload, etc. - Blocks
javascript:anddata:text/htmlURLs
Note on DOMPurify
The boot-time restore path uses a synchronous built-in sanitizer for speed. For async helpers (safeSetInnerHTML), DOMPurify is used if available:
npm install dompurifyThe built-in fallback covers common attack vectors but may not catch all edge cases. For maximum security in custom restore flows, use safeSetInnerHTML with DOMPurify installed.
Sensitive Data
Prepaint automatically protects sensitive fields:
// Password inputs are automatically cleared
<input type="password" />
// Mark custom sensitive fields
<input data-firsttx-sensitive name="ssn" />
<div data-firsttx-sensitive>{authToken}</div>Best Practices:
✅ Mark auth tokens and PII with data-firsttx-sensitive
✅ Keep session data in memory (not DOM)
❌ Don't store encryption keys in visible elements
Storage Security
- Snapshots are stored in IndexedDB (browser's same-origin policy applies)
- Data is not encrypted — treat IndexedDB like localStorage
- TTL: 7 days (auto-expires)
Debugging
Development Logs
[FirstTx] Snapshot restored (age: 63ms)
[FirstTx] Prepaint detected (age: 63ms)
[FirstTx] Snapshot captured for /productsInspect IndexedDB
indexedDB.open('firsttx-prepaint').onsuccess = (e) => {
const db = e.target.result;
const tx = db.transaction('snapshots', 'readonly');
tx.objectStore('snapshots').getAll().onsuccess = (e2) => {
console.log(e2.target.result);
};
};Check Injection
On revisit, look for:
<html data-prepaint="true" data-prepaint-timestamp="...">- Boot script near
<head>top - Temporary
style[data-firsttx-prepaint]
Performance
| Metric | Target | Actual | | ----------------- | ------ | ------- | | Boot script size | <2KB | ~1.7KB | | Boot execution | <20ms | ~15ms | | Hydration success | >80% | ~80-85% |
Hydration mismatches (timestamps, random IDs, client-only branches) automatically fallback to clean render with ViewTransition.
Browser Support
| Browser | Min Version | ViewTransition | Status | | ----------- | ---------------------------- | -------------- | ------------- | | Chrome/Edge | 111+ | ✅ Full | ✅ Tested | | Firefox | Latest | ❌ No | ✅ Fallback | | Safari | 16+ | ❌ No | ✅ Fallback | | Mobile | iOS 16+, Android Chrome 111+ | Varies | ✅ Core works |
Limitations
| Issue | Workaround |
| ---------------------- | ------------------------------------ |
| Vite-only plugin | Manual <script> for other bundlers |
| Fixed 7-day TTL | Override in source (config planned) |
| Full-page capture only | Sub-tree snapshots not supported yet |
FAQ
Q: Does this work with SSR/Next.js?
A: No. Prepaint targets pure CSR apps. For SSR, use framework's native features.
Q: Will this increase memory usage?
A: Snapshots live in IndexedDB (disk). Memory overhead is minimal during boot.
Q: How do I prevent duplicate UI?
A: Use overlay mode or rely on the root guard:
firstTx({ overlay: true });Q: Can I restrict capture to certain routes?
A: Use setupCapture() manually or filter at app layer. Default captures all routes.
Q: What if hydration fails?
A: Automatic fallback to clean client render (with ViewTransition if enabled). No manual intervention needed.
Changelog
0.3.0 - 2025.10.12
Add overlay mode and hard hydration bailout to prepaint
This release introduces significant improvements to snapshot capture and hydration reliability:
Breaking Changes
captureSnapshot()now uses root element serialization instead ofbody.innerHTML- Requires
#rootelement to be present for capture to work
New Features
- Overlay mode support Adds global
__FIRSTTX_DEV__flag for development logging - Volatile data handling Elements with
data-firsttx-volatileattribute are automatically cleared during capture (useful for timestamps, random values) - Style filtering Prepaint-injected styles are now excluded from capture via
data-firsttx-prepaintattribute - Enhanced capture timing
- Captures on
visibilitychange(when page becomes hidden) - Captures on
pagehide(mobile-friendly) - Maintains
beforeunloadcapture - Debounced with microtask queue to prevent duplicate saves
- Captures on
Improvements
- Cleaner serialization: Only captures first child of root element
- More reliable hydration: Filters out dynamic content that causes mismatches
- Better mobile support:
pagehideevent works more reliably on iOS/Android - Development experience:
__FIRSTTX_DEV__replacesprocess.env.NODE_ENVchecks
Migration Guide
// If you have dynamic content that changes on every render:
<span data-firsttx-volatile>{Date.now()}</span>
<div data-firsttx-volatile>{Math.random()}</div>
// Vite plugin automatically injects __FIRSTTX_DEV__ flag
// No changes needed to your vite.config.tsRelated Packages
@firsttx/local-first- IndexedDB + React integration@firsttx/tx- Atomic transactions with rollback
License
MIT © joseph0926
