logo
Firsttx

Prepaint

Prepaint는 FirstTx의 렌더 레이어입니다.

  • 사용자가 재방문하면 마지막 화면을 DOM 스냅샷으로 복원하고
  • React 번들과 데이터가 준비되는 동안 보이는 빈 화면 시간을 줄이며
  • ViewTransition을 쓸 수 있는 환경에선 복원 → 실제 렌더 전환을 부드러운 애니메이션으로 감쌉니다.
한 줄 요약

Prepaint는 CSR 재방문용 부트 구간 visual cache입니다. 새로고침 / 뒤로 가기 / 외부 → 내부 도구 재진입 시 메인 앱 번들보다 먼저 마지막으로 캡처한 화면을 표시할 수 있습니다.


1. 설치 & 기본 통합

1‑1. 패키지 설치

대부분의 경우 세 레이어를 함께 사용하는 것을 권장합니다.

bash
pnpm add @firsttx/prepaint @firsttx/local-first @firsttx/tx

Prepaint만 먼저 붙여 보고 싶다면 이렇게 최소 설치도 가능합니다,

Prepaint만 설치
pnpm add @firsttx/prepaint

모듈 형식: ESM-only. CommonJS 환경에서는 import()(dynamic import)를 사용하세요.

Local‑First / Tx와의 조합
  • Prepaint만 써도 “재방문 시 빈 화면” 문제는 해결됩니다.
  • 여기에 Local‑First를 더하면, 오프라인/재방문 시 데이터 상태까지 유지되고,

  • Tx까지 더하면 낙관적 UI 단계에 명시적인 보상 작업을 연결할 수 있습니다.

1‑2. Vite 플러그인 설정

Prepaint는 Vite 플러그인을 통해 부트 스크립트를 HTML에 주입합니다. 현재 Prepaint 플러그인은 Vite만 지원합니다.

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(),
  ],
});
  • 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

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

Parameters
requiredoptional
NameTypeDefaultDescription
container
HTMLElement-React 앱을 마운트할 DOM 엘리먼트입니다 (보통 #root).
element
ReactElement-렌더링할 React 엘리먼트입니다. 보통 <App />을 전달합니다.
options.transition
booleantrue document.startViewTransition을 사용할지 여부입니다. 지원 브라우저에서는 visual snapshot → React 앱 핸드오프를 전환 애니메이션으로 감쌉니다. 미지원 브라우저에서는 해당 효과 없이 핸드오프합니다.
options.onCapture
(info: { route: string; size: number }) => voidundefinedPrepaint가 스냅샷을 캡처할 때 호출되는 콜백입니다. DevTools 로그와 별도로 커스텀 로깅을 붙일 때 사용할 수 있습니다.
options.onHandoff
(strategy: 'has-prepaint' | 'cold-start') => voidundefined스냅샷 복원 여부에 따라 선택된 핸드오프 전략을 전달합니다.

2. 부트 스크립트 & 스냅샷 라이프사이클

Prepaint의 핵심은 HTML에 주입된 부트 스크립트입니다.

부트 스크립트는 대략 다음 순서로 동작합니다.

  1. 현재 위치에서 **라우트 키(route key)**를 계산합니다.

    • 기본값: location.pathname
    • 필요 시 window.__FIRSTTX_ROUTE_KEY__를 통해 커스터마이즈 가능
  2. IndexedDB의 firsttx-prepaint DB, snapshots 스토어에서 해당 route 키에 대한 스냅샷을 읽습니다.

  3. 스냅샷의 만료 여부를 검사합니다.

    • MAX_SNAPSHOT_AGE 기본값은 7일입니다.
  4. 스냅샷이 유효하면,

    • #root는 건드리지 않고 Shadow DOM overlay에 스냅샷 HTML과 스타일을 렌더합니다.
    • overlay는 pointer-events: none인 비상호작용 visual cache입니다.
    • document.documentElementdata-prepaint, data-prepaint-overlay, data-prepaint-timestamp 속성을 달아 복원 상태를 표시합니다.
  5. 스냅샷이 손상되었거나 필수 필드가 없으면,

    • 해당 스냅샷을 삭제하고 cold start로 폴백합니다.
    • DevTools에는 storage.error / restore(strategy: 'cold-start') 이벤트가 기록됩니다.
Visual overlay

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 (숨김 상태로 전환될 때)
  • pagehide
  • beforeunload

이 이벤트가 발생하면 queueMicrotask로 캡처 작업을 한 번만 예약하여, 연속 이벤트에도 중복 캡처를 막습니다.

3‑2. 직렬화 & 민감 데이터 처리

캡처 과정은 대략 다음과 같습니다.

  1. 루트 직렬화

    • 대상: #root의 첫 번째 자식만 복사하여 HTML 문자열로 직렬화합니다.
  2. 민감/변동 데이터 스크럽

    • data-firsttx-volatile이 달린 노드의 텍스트/내용은 제거합니다.

      • 예: 실시간 타임스탬프, 랜덤 ID, 애니메이션 카운터 등
    • 기본 민감 셀렉터,

      • input[type="password"]
      • [data-firsttx-sensitive]
    • 전역 셀렉터,

      • window.__FIRSTTX_SENSITIVE_SELECTORS__에 배열로 추가하면, 해당 셀렉터들에 대해 값/텍스트가 제거됩니다.
  3. 인라인 이벤트 핸들러 제거

    • 모든 on* 속성(onClick, onChange, onMouseOver 등)을 제거해 XSS 위험을 최소화합니다.
  4. 스타일 수집

    • <style> 태그: CSS 텍스트를 그대로 저장합니다.
    • <link rel="stylesheet">,
      • 동일 오리진 + fetch 가능 시 CSS 내용을 함께 저장해 오프라인 복원 품질을 높입니다.
      • 그 외에는 href만 저장합니다(보안/용량 트레이드오프).
  5. 스냅샷 저장

    • { route, body, timestamp, styles } 형태로 IndexedDB에 put합니다.
    • 성공 시 DevTools에 capture 이벤트(HTML 길이, 스타일 개수, volatile 사용 여부, 소요 시간 등)를 보냅니다.
민감 데이터 스냅샷 보존 주의
  • 민감 데이터 스크럽은 셀렉터 기반이기 때문에, 직접 지정하지 않은 입력 필드는 스냅샷에 그대로 남을 수 있습니다.

  • 스냅샷은 기본적으로 최대 7일 동안 IndexedDB에 저장됩니다.

  • 로그인/결제/개인정보 폼에는 data-firsttx-sensitive나 전역 셀렉터로 스크럽을 추가하거나, 해당 화면을 Prepaint 대상에서 제외하는 것을 권장합니다.


4. Visual handoff

  1. handoff()data-prepaint 마커를 확인해 snapshot 복원 여부를 판별합니다.
  2. createFirstTxRoot()는 container를 비우고 항상 createRoot()로 React를 마운트합니다.
  3. React의 첫 commit이 확인되기 전까지 visual overlay와 data-prepaint* 마커를 유지합니다.
  4. 첫 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?)

    • 이미 오버레이가 있으면 재생성하지 않습니다.
    • bodyposition: 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: UUID
  • category: "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만 비복구로 간주하고 나머지는 재시도 가능

앱에서 직접 이 에러들을 잡을 일은 많지 않지만, 전역 에러 경로에서 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 시간을 측정하고, 실제 앱의 재방문 빈 화면 시간이 줄었는지 확인하세요.