Prepaint
Prepaint는 FirstTx의 렌더 레이어입니다.
- 사용자가 재방문하면 마지막 화면을 DOM 스냅샷으로 복원하고
- React 번들과 데이터가 준비되는 동안 보이는 빈 화면 시간을 줄이며
- ViewTransition을 쓸 수 있는 환경에선 복원 → 실제 렌더 전환을 부드러운 애니메이션으로 감쌉니다.
Prepaint는 CSR 재방문용 부트 구간 visual cache입니다. 새로고침 / 뒤로 가기 / 외부 → 내부 도구 재진입 시 메인 앱 번들보다 먼저 마지막으로 캡처한 화면을 표시할 수 있습니다.
1. 설치 & 기본 통합
1‑1. 패키지 설치
대부분의 경우 세 레이어를 함께 사용하는 것을 권장합니다.
pnpm add @firsttx/prepaint @firsttx/local-first @firsttx/tx
Prepaint만 먼저 붙여 보고 싶다면 이렇게 최소 설치도 가능합니다,
모듈 형식: ESM-only. CommonJS 환경에서는
import()(dynamic import)를 사용하세요.
- Prepaint만 써도 “재방문 시 빈 화면” 문제는 해결됩니다.
여기에 Local‑First를 더하면, 오프라인/재방문 시 데이터 상태까지 유지되고,
- Tx까지 더하면 낙관적 UI 단계에 명시적인 보상 작업을 연결할 수 있습니다.
1‑2. Vite 플러그인 설정
Prepaint는 Vite 플러그인을 통해 부트 스크립트를 HTML에 주입합니다. 현재 Prepaint 플러그인은 Vite만 지원합니다.
// 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()는 HTML에 작은 IIFE 부트 스니펫(~1.7KB)을 인라인으로 주입하거나(기본값), 별도의/firsttx-boot.js로 서빙할 수 있습니다.- 이 스니펫이 React가 로드되기 전에 IndexedDB에서 스냅샷을 읽어와 DOM을 복원합니다.
주요 플러그인 옵션(요약),
inline(boolean, 기본true): 부트 스크립트를 HTML에 인라인 삽입할지 여부injectTo("head" | "head-prepend" | "body" | "body-prepend", 기본"head"): 스크립트 삽입 위치nonce(string): 생성 결과물에 포함할 nonce. 정적 Vite 출력에서는 응답별 nonce를 만들 수 없음
정적 호스팅에서는 external boot asset이나 CSP hash를 우선하세요. Vite 플러그인에 전달한 nonce는 HTTP 응답마다가 아니라 빌드 시점에 생성됩니다.
기존 overlay, overlayRoutes 옵션은 한 릴리스 동안 deprecated no-op으로 허용됩니다. 복원 방식은 항상 오버레이입니다. setupCapture({ routes })로 새 캡처를 제한할 수 있지만, restore-side route filtering은 이 릴리스에서 제공하지 않습니다.
1‑3. 엔트리 포인트: createFirstTxRoot
// main.tsx
import { createFirstTxRoot } from "@firsttx/prepaint";
import { App } from "./App";
createFirstTxRoot(
document.getElementById("root")!,
<App />,
{
transition: true,
// onCapture(info) { ... },
// onHandoff(info) { ... },
},
);
기존 createRoot를 직접 사용하는 대신,
항상 createFirstTxRoot를 통해 React 엔트리를 만들도록 하는 것이 좋습니다.
이 함수는 빈 container에 React를 마운트하고 첫 commit 뒤 visual overlay를 제거합니다.
createFirstTxRoot(container, element, options?)
| Name | Type | Default | Description |
|---|---|---|---|
container | HTMLElement | - | React 앱을 마운트할 DOM 엘리먼트입니다 (보통 #root). |
element | ReactElement | - | 렌더링할 React 엘리먼트입니다. 보통 <App />을 전달합니다. |
options.transition | boolean | true | document.startViewTransition을 사용할지 여부입니다. 지원 브라우저에서는 visual snapshot → React 앱 핸드오프를 전환 애니메이션으로 감쌉니다. 미지원 브라우저에서는 해당 효과 없이 핸드오프합니다. |
options.onCapture | (info: { route: string; size: number }) => void | undefined | Prepaint가 스냅샷을 캡처할 때 호출되는 콜백입니다. DevTools 로그와 별도로 커스텀 로깅을 붙일 때 사용할 수 있습니다. |
options.onHandoff | (strategy: 'has-prepaint' | 'cold-start') => void | undefined | 스냅샷 복원 여부에 따라 선택된 핸드오프 전략을 전달합니다. |
2. 부트 스크립트 & 스냅샷 라이프사이클
Prepaint의 핵심은 HTML에 주입된 부트 스크립트입니다.
부트 스크립트는 대략 다음 순서로 동작합니다.
-
현재 위치에서 **라우트 키(route key)**를 계산합니다.
- 기본값:
location.pathname - 필요 시
window.__FIRSTTX_ROUTE_KEY__를 통해 커스터마이즈 가능
- 기본값:
-
IndexedDB의
firsttx-prepaintDB,snapshots스토어에서 해당 route 키에 대한 스냅샷을 읽습니다. -
스냅샷의 만료 여부를 검사합니다.
MAX_SNAPSHOT_AGE기본값은 7일입니다.
-
스냅샷이 유효하면,
#root는 건드리지 않고 Shadow DOM overlay에 스냅샷 HTML과 스타일을 렌더합니다.- overlay는
pointer-events: none인 비상호작용 visual cache입니다. document.documentElement에data-prepaint,data-prepaint-overlay,data-prepaint-timestamp속성을 달아 복원 상태를 표시합니다.
-
스냅샷이 손상되었거나 필수 필드가 없으면,
- 해당 스냅샷을 삭제하고 cold start로 폴백합니다.
- DevTools에는
storage.error/restore(strategy: 'cold-start')이벤트가 기록됩니다.
Prepaint의 복원 경로는 항상 overlay를 사용합니다.
오버레이 모드에서는
#root를 건드리지 않고,body에 Shadow DOM 호스트를 만들어 그 안에 HTML/CSS를 렌더합니다.- 라우터 초기 렌더와 섞이지 않아, 구조가 복잡한 앱에서 더 안전합니다.
pointer-events를 조절해, React가 준비되는 동안에만 클릭을 막고 이후에는 즉시 제거합니다.
React는 빈 root에서 createRoot()로 시작하며, 첫 commit이 확인된 뒤에만 overlay가 제거됩니다.
3. 캡처 파이프라인: 어떤 DOM이 저장되나요?
사용자가 페이지를 떠나기 직전에 Prepaint는 #root의 첫 번째 자식을 직렬화해서 IndexedDB에 저장합니다.
3‑1. 캡처 트리거
setupCapture는 다음 이벤트에서 캡처를 예약합니다.
visibilitychange(숨김 상태로 전환될 때)pagehidebeforeunload
이 이벤트가 발생하면 queueMicrotask로 캡처 작업을 한 번만 예약하여, 연속 이벤트에도 중복 캡처를 막습니다.
3‑2. 직렬화 & 민감 데이터 처리
캡처 과정은 대략 다음과 같습니다.
-
루트 직렬화
- 대상:
#root의 첫 번째 자식만 복사하여 HTML 문자열로 직렬화합니다.
- 대상:
-
민감/변동 데이터 스크럽
-
data-firsttx-volatile이 달린 노드의 텍스트/내용은 제거합니다.- 예: 실시간 타임스탬프, 랜덤 ID, 애니메이션 카운터 등
-
기본 민감 셀렉터,
input[type="password"][data-firsttx-sensitive]
-
전역 셀렉터,
window.__FIRSTTX_SENSITIVE_SELECTORS__에 배열로 추가하면, 해당 셀렉터들에 대해 값/텍스트가 제거됩니다.
-
-
인라인 이벤트 핸들러 제거
- 모든
on*속성(onClick, onChange, onMouseOver 등)을 제거해 XSS 위험을 최소화합니다.
- 모든
-
스타일 수집
<style>태그: CSS 텍스트를 그대로 저장합니다.<link rel="stylesheet">,- 동일 오리진 + fetch 가능 시 CSS 내용을 함께 저장해 오프라인 복원 품질을 높입니다.
- 그 외에는 href만 저장합니다(보안/용량 트레이드오프).
-
스냅샷 저장
{ route, body, timestamp, styles }형태로 IndexedDB에put합니다.- 성공 시 DevTools에
capture이벤트(HTML 길이, 스타일 개수, volatile 사용 여부, 소요 시간 등)를 보냅니다.
민감 데이터 스크럽은 셀렉터 기반이기 때문에, 직접 지정하지 않은 입력 필드는 스냅샷에 그대로 남을 수 있습니다.
스냅샷은 기본적으로 최대 7일 동안 IndexedDB에 저장됩니다.
로그인/결제/개인정보 폼에는
data-firsttx-sensitive나 전역 셀렉터로 스크럽을 추가하거나, 해당 화면을 Prepaint 대상에서 제외하는 것을 권장합니다.
4. Visual handoff
handoff()가data-prepaint마커를 확인해 snapshot 복원 여부를 판별합니다.createFirstTxRoot()는 container를 비우고 항상createRoot()로 React를 마운트합니다.- React의 첫 commit이 확인되기 전까지 visual overlay와
data-prepaint*마커를 유지합니다. - 첫 commit 뒤 overlay와 marker를 제거합니다. ViewTransition을 지원하고
transition: true이면 이 제거를 전환으로 감쌉니다.
캐시된 client DOM은 React 입력으로 재사용되지 않습니다. root 자식 수를 1개로 강제하는 guard도 없으므로 Fragment와 복수 최상위 노드는 정상적인 React 출력으로 처리됩니다.
Prepaint는 기본적으로
#root의 첫 번째 자식만
스냅샷에 저장합니다. 여러 React 루트를 동시에 사용하는 멀티 루트 구조에서는 복원이 깔끔하게 이루어지지 않을 수 있으니, 가능하면 루트를 하나로 통합하거나, 해당 페이지에서는 Prepaint를 비활성화하는 전략을 고려하는 것이 좋습니다.
5. 오버레이 렌더링
오버레이 모드에서는 스냅샷이 #root가 아니라 Shadow DOM 오버레이에 렌더링됩니다.
-
mountOverlay(html, styles?)- 이미 오버레이가 있으면 재생성하지 않습니다.
body에position: fixed호스트를 만들고, 그 안에 Shadow DOM을 생성합니다.- 스타일은
style-utils로 정규화되어<style>/<link>로 삽입됩니다. - HTML은 래퍼 div에
innerHTML로 주입됩니다.
-
removeOverlay()- 오버레이 호스트 DOM을 제거합니다.
오버레이는 다음 상황에서도 React root와 분리된 visual cache를 유지합니다.
- 라우터가 초기 렌더에서 DOM 구조를 크게 바꾸는 경우
- 화면 전체를 덮는 초기 로딩/스켈레톤을 많이 사용하는 경우
- 기존 DOM 위에 “복원된 화면”을 잠깐 얹어두고 싶을 때
6. DevTools & 에러 모델
6‑1. DevTools 이벤트
Prepaint는 내부 상태를 DevTools에 category: "prepaint" 이벤트로 전송합니다.
대표 타입,
capture– 스냅샷 캡처 완료 (body 길이, 스타일 개수, 소요 시간 등)restore– 스냅샷 복원 (전략:has-prepaint/cold-start, snapshot 나이 등)handoff– Prepaint → React 제어권 전달 (canHydrate는 호환성을 위해 남아 있으며 항상false)hydration.error– legacy 소비자 호환을 위해 남아 있는 이벤트 타입으로 현재 기본 경로에서는 발생하지 않음storage.error– IndexedDB 읽기/쓰기 실패
공통 필드,
id: UUIDcategory:"prepaint"timestamp: 발생 시각priority: 0~2 (capture=0, restore/handoff=1, error류=2)
Chrome 확장 프로그램 FirstTx DevTools를 설치합니다.
DevTools → “FirstTx” 패널을 열고, Category에서
prepaint만 필터링합니다.- 그러면 캡처/복원/핸드오프/스토리지 에러가 타임라인으로 표시되어, 재방문 replay 경로를 추적할 수 있습니다.
6‑2. 에러 계층
Prepaint는 다음과 같은 에러 계층을 사용합니다.
-
PrepaintError(추상)getUserMessage()getDebugInfo()isRecoverable()
-
BootError- DB 오픈 실패, 스냅샷 읽기 실패, DOM 복원 실패, 스타일 주입 실패 등
- 대부분 재시도 가능하다고 보고
isRecoverable() === true입니다.
-
CaptureError- DOM 직렬화 실패, 스타일 수집 실패, DB 쓰기 실패 등
-
HydrationError- legacy 소비자 호환을 위해 export를 유지하는 deprecated 에러 타입
-
PrepaintStorageError- IndexedDB 관련 DOMException을
quota/permission/corrupted/unknown코드로 변환해 표현 PERMISSION_DENIED만 비복구로 간주하고 나머지는 재시도 가능
- IndexedDB 관련 DOMException을
앱에서 직접 이 에러들을 잡을 일은 많지 않지만, 전역 에러 경로에서 instanceof PrepaintError로 분기할 수 있습니다.
7. 권장 패턴 & 실전 팁
마지막으로, Prepaint를 실제 앱에 붙일 때 도움이 되는 패턴들입니다.
-
엔트리는 항상
createFirstTxRoot- 기존
createRoot대신 엔트리 포인트를createFirstTxRoot로 통일하면 캡처와 visual handoff를 일관되게 적용할 수 있습니다.
- 기존
-
민감 요소는 반드시 스크럽
- 비밀번호/토큰/개인정보 필드는
data-firsttx-sensitive속성- 또는 전역
window.__FIRSTTX_SENSITIVE_SELECTORS__ = ['.secret', '#card-number']로 지정해 스냅샷에서 값을 지우는 것이 안전합니다.
- 비밀번호/토큰/개인정보 필드는
-
자주 바뀌는 텍스트는
data-firsttx-volatile- 시계, 카운터, 랜덤 문구 등이 오래된 visual cache에 노출되지 않도록 사용합니다.
- 해당 엘리먼트에
data-firsttx-volatile을 달면 캡처 시 텍스트가 제거됩니다.
-
ViewTransition은 기본 ON, 필요하면 끄기
- 대부분의 경우
transition: true가 자연스럽지만, 특정 페이지에서 ViewTransition 애니메이션이 UX를 해친다면 해당 엔트리에서만transition: false로 끄는 것도 가능합니다.
- 대부분의 경우
-
오버레이는 항상 사용
- 별도
overlay옵션 없이 캐시된 client DOM과 React root가 분리됩니다.
- 별도
-
스타일 용량 고려
- 동일 오리진 CSS를 내용까지 저장하는 옵션은 재방문 품질을 매우 높여주지만, 앱 규모가 크면 IndexedDB 용량을 꽤 사용할 수 있습니다.
- 스타일이 지나치게 많다면, 일부 페이지는 오버레이/스타일 저장을 제한하거나, 스타일 구조를 분리해서 “스냅샷에 꼭 필요한 부분”만 포함하는 것이 좋습니다.
이 설정을 바탕으로 목표 기기와 snapshot 크기에서 replay·handoff 시간을 측정하고, 실제 앱의 재방문 빈 화면 시간이 줄었는지 확인하세요.