Read OSS

Pretext Architecture: Why Two Phases Beat DOM Measurement

Intermediate

Prerequisites

  • Basic web development and DOM concepts
  • CSS text layout model (white-space, overflow-wrap)
  • Familiarity with TypeScript type system

Pretext Architecture: Why Two Phases Beat DOM Measurement

If you've ever watched Chrome DevTools turn purple with "Forced reflow" warnings while rendering a chat interface, you already know the problem Pretext solves. Chenglou's @chenglou/pretext is a pure-JavaScript library that measures and lays out multiline text without touching the DOM during resize — achieving ~0.0002ms per layout call. The trick isn't a clever algorithm applied to the same problem; it's a structural decomposition that makes the expensive problem disappear from the hot path entirely.

This is Part 1 of a six-part deep-dive. We'll start with the architectural skeleton — the two-phase model, the module dependency graph, the opaque handle pattern, and the parallel array data model that makes the line-breaking engine so fast.

The Problem: DOM Measurement Interleaving and Layout Thrashing

The opening comment block in layout.ts states the problem directly:

src/layout.ts#L1-L33

When UI components independently measure text heights using getBoundingClientRect() or offsetHeight, each read can force a synchronous layout reflow. If those reads interleave with DOM writes (e.g., setting style.width on a container, then reading back the resulting text height), the browser relayouts the entire document for every read-write cycle.

sequenceDiagram
    participant App as Application
    participant DOM as Browser DOM
    Note over App,DOM: Layout Thrashing Pattern
    App->>DOM: Set container width (WRITE)
    App->>DOM: Read text height (READ) — forces reflow
    App->>DOM: Set another container width (WRITE)
    App->>DOM: Read text height (READ) — forces reflow again
    Note over DOM: Each read invalidates the layout,<br/>costing 30ms+ for 500 text blocks

For a chat application with 500 visible messages, this pattern can cost 30ms+ per frame — well over a single frame budget. The conventional advice is to batch reads and writes, but that requires coordinating across independent components, which breaks encapsulation.

The Two-Phase Solution: prepare() vs layout()

Pretext's answer is to split text measurement into two phases with fundamentally different performance characteristics:

Phase 1: prepare(text, font) — Called once when text first appears. Segments the text using Intl.Segmenter, measures each segment via Canvas measureText(), and caches the widths. This is expensive (font engine calls, segmentation, Unicode analysis) but only happens once per text block.

Phase 2: layout(prepared, maxWidth, lineHeight) — Called on every resize. Walks the cached widths with pure arithmetic to count lines and compute height. No canvas calls, no DOM reads, no string operations, no allocations.

src/layout.ts#L458-L500

flowchart LR
    subgraph "Phase 1 — prepare() [once]"
        A[Raw text] --> B[Whitespace normalize]
        B --> C[Intl.Segmenter]
        C --> D[Merge cascade]
        D --> E[Canvas measureText]
        E --> F[PreparedText handle]
    end
    subgraph "Phase 2 — layout() [every resize]"
        F --> G[Walk cached widths]
        G --> H[Pure arithmetic]
        H --> I["{lineCount, height}"]
    end

The prepare() result is width-independent — the same PreparedText handle works at any maxWidth and lineHeight. This is the key insight: the expensive work (segmentation, measurement, Unicode analysis) doesn't depend on the container width. Only the cheap work (line breaking) does.

The comment on layout() captures the performance target:

// ~0.0002ms per text block. Call on every resize.

That's 200 nanoseconds — fast enough to lay out 5,000 text blocks per millisecond.

Tip: If your application shows text blocks that change content infrequently but resize often (chat messages, comments, social feeds), the two-phase model gives you essentially free resize relayout. Call prepare() once when the message arrives, then call layout() on every frame during an animation.

Module Dependency Architecture

Pretext is organized into five source modules with a clean dependency DAG:

Module Responsibility Lines
layout.ts Public API orchestrator, measurement bridge, line materialization ~718
analysis.ts Whitespace normalization, segmentation, merge cascade ~1020
measurement.ts Canvas measurement, emoji correction, engine profiles, caches ~232
line-break.ts Line-walking engines (simple + full paths) ~1059
bidi.ts Simplified UAX #9 bidi levels for rich rendering ~174
graph TD
    layout["layout.ts<br/>(Public API)"]
    analysis["analysis.ts<br/>(Text Analysis)"]
    measurement["measurement.ts<br/>(Canvas Measurement)"]
    linebreak["line-break.ts<br/>(Line Walker)"]
    bidi["bidi.ts<br/>(Bidi Metadata)"]

    layout --> analysis
    layout --> measurement
    layout --> linebreak
    layout --> bidi
    linebreak --> analysis
    linebreak --> measurement
    measurement --> analysis

The dependency flow is strictly downward — layout.ts imports from all four modules, line-break.ts imports types from analysis.ts and the engine profile from measurement.ts, and measurement.ts imports the isCJK helper from analysis.ts. There are no cycles.

This matters for two reasons. First, tree-shaking: if you only need the opaque path, bundlers can determine exactly what's required. Second, cognitive load: you can understand any module by reading it plus its imports, never worrying about cross-module mutation.

src/layout.ts#L35-L65

The PreparedText Opaque Handle Pattern

Pretext uses TypeScript branded types to keep the public API stable while allowing the internal representation to change freely.

src/layout.ts#L79-L109

The public type is an empty branded interface:

declare const preparedTextBrand: unique symbol

export type PreparedText = {
  readonly [preparedTextBrand]: true
}

Internally, the actual data lives in InternalPreparedText, which extends both the brand and the PreparedCore type containing the parallel arrays. External code cannot access any of the internal fields — the brand is a unique symbol declared with declare (never assigned), so it's impossible to construct or destructure.

classDiagram
    class PreparedText {
        <<branded>>
        +[preparedTextBrand]: true
    }
    class PreparedCore {
        +widths: number[]
        +kinds: SegmentBreakKind[]
        +lineEndFitAdvances: number[]
        +lineEndPaintAdvances: number[]
        +breakableWidths: (number[] | null)[]
        +breakablePrefixWidths: (number[] | null)[]
        +simpleLineWalkFastPath: boolean
        +chunks: PreparedLineChunk[]
        +discretionaryHyphenWidth: number
        +tabStopAdvance: number
        +segLevels: Int8Array | null
    }
    class InternalPreparedText {
        <<internal>>
    }
    class PreparedTextWithSegments {
        +segments: string[]
    }
    PreparedText <|-- InternalPreparedText
    PreparedCore <|-- InternalPreparedText
    InternalPreparedText <|-- PreparedTextWithSegments

The cast between public and internal is a single function:

function getInternalPrepared(prepared: PreparedText): InternalPreparedText {
  return prepared as InternalPreparedText
}

src/layout.ts#L482-L484

This pattern is worth stealing for any library where internal data structures might evolve. The branded type prevents consumers from depending on field names that could change, while the internal cast is zero-cost at runtime.

Two-Tier Public API: Fast Path vs Rich Path

Pretext exposes two tiers of functionality:

Tier 1: Opaque fast pathprepare() + layout(). Returns line counts and heights only. No segment data, no bidi metadata, no string materialization. This is the resize hot path.

Tier 2: Rich segment-aware pathprepareWithSegments() + layoutWithLines() / walkLineRanges() / layoutNextLine(). Exposes segment text, line boundaries, per-line widths, and bidi levels for custom rendering.

src/layout.ts#L472-L480

flowchart TD
    subgraph "Tier 1: Fast Path"
        P1[prepare] --> L1[layout]
        L1 --> R1["{lineCount, height}"]
    end
    subgraph "Tier 2: Rich Path"
        P2[prepareWithSegments] --> L2[layoutWithLines]
        P2 --> L3[walkLineRanges]
        P2 --> L4[layoutNextLine]
        L2 --> R2["{lines: LayoutLine[]}"]
        L3 --> R3["onLine callback"]
        L4 --> R4["LayoutLine | null"]
    end

The key difference at prepare time: prepareWithSegments() includes segment text strings and bidi levels that the opaque prepare() skips. At layout time, layoutWithLines() materializes line text (including discretionary hyphen insertion), walkLineRanges() provides line geometry without string materialization, and layoutNextLine() enables iterator-style variable-width layout.

Tip: Use the opaque path (prepare + layout) for sizing containers. Switch to the rich path only when you need to render the text yourself — for example, drawing onto Canvas or building custom selection rectangles.

The Parallel Array Data Model

The PreparedCore type uses a struct-of-arrays layout rather than an array-of-structs:

src/layout.ts#L83-L95

type PreparedCore = {
  widths: number[]               // Segment widths
  lineEndFitAdvances: number[]   // Width contribution for line-fit decisions
  lineEndPaintAdvances: number[] // Width contribution for visible painting
  kinds: SegmentBreakKind[]      // Break behavior per segment
  breakableWidths: (number[] | null)[]      // Per-grapheme widths for overflow-wrap
  breakablePrefixWidths: (number[] | null)[] // Cumulative prefix widths
  simpleLineWalkFastPath: boolean
  // ...
}

Each array is indexed by segment position, so widths[i], kinds[i], and lineEndFitAdvances[i] all describe the same segment. This keeps the hot loop in the line walker tight — it accesses sequential array positions, which is cache-friendly and avoids pointer chasing through objects.

erDiagram
    SEGMENT_INDEX ||--|| WIDTHS : "widths[i]"
    SEGMENT_INDEX ||--|| KINDS : "kinds[i]"
    SEGMENT_INDEX ||--|| FIT_ADVANCES : "lineEndFitAdvances[i]"
    SEGMENT_INDEX ||--|| PAINT_ADVANCES : "lineEndPaintAdvances[i]"
    SEGMENT_INDEX ||--o| BREAKABLE_WIDTHS : "breakableWidths[i]"
    SEGMENT_INDEX ||--o| BREAKABLE_PREFIX : "breakablePrefixWidths[i]"

The breakableWidths array deserves special attention. For segments that are word-like and multi-grapheme, it stores an array of per-grapheme widths, enabling character-level word breaking (overflow-wrap: break-word). For single-grapheme or non-breakable segments, it's null — no allocation. The sister array breakablePrefixWidths stores cumulative prefix widths as a browser-specific shim for Safari, which we'll explore in Part 4.

The simpleLineWalkFastPath boolean is a fast-path gate computed at prepare time. When the text contains only text, space, and zero-width-break segments — no hard breaks, soft hyphens, tabs, or glue — the line walker can use a simpler, faster loop. This covers the vast majority of real-world text.

Looking Ahead

This article established the structural foundation: the two-phase split that makes resize cheap, the opaque handle that shields internals, and the parallel arrays that keep the line walker fast. But we've glossed over the most complex part of the system — the text analysis pipeline that transforms a raw string into those parallel arrays.

In Part 2, we'll descend into analysis.ts to see how whitespace normalization, Intl.Segmenter, and an elaborate multi-pass merge cascade handle CJK grapheme splitting, Arabic no-space clusters, kinsoku shori, URL merging, and a half-dozen other internationalization challenges — all before a single pixel is measured.