Pretext Architecture: Why Two Phases Beat DOM Measurement
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:
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.
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 calllayout()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.
The PreparedText Opaque Handle Pattern
Pretext uses TypeScript branded types to keep the public API stable while allowing the internal representation to change freely.
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
}
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 path — prepare() + 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 path — prepareWithSegments() + layoutWithLines() / walkLineRanges() / layoutNextLine(). Exposes segment text, line boundaries, per-line widths, and bidi levels for custom rendering.
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:
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.