Skip to content
Sheetwrite

Sheetwrite / Documentation

Runtime ownership

Understand Sheetwrite runtime, renderer, store, virtualization, and document ownership boundaries.

Installation

Architecture at a glance

Sheetwrite splits cleanly into a data engine (Rust → WASM) and a renderer (canvas). The TypeScript core (@sheetwrite/core) owns the host DOM, the scroll/virtualization math, interactions, and the bridge between the two.

01Host

Lifecycle, persistence, collaboration, and product UI

02TypeScript core

Grid API, transactions, virtualization, interaction, and renderer coordination

03Rust / WASM

Columnar cells, formulas, query scans, snapshots, and packed render windows

Ownership moves downward through narrow contracts; events and resolved windows move upward.

The renderer never owns document cells. Each frame requests one bulk resolved window per painted pane; an unfrozen sheet uses one window, while frozen panes use up to four clipped windows.

GridImpl is the composition root rather than the owner of every subsystem. DocumentController owns document commits and history, DatasourceController owns paged request generations and cancellation, GeometryLayoutController owns row/column indexes and frozen-window mapping, and RenderCoordinator is the sole animation-frame and paint-cache owner. Interaction-specific controllers remain separate. These are internal ownership boundaries; the public API stays the Grid and Store contracts.

Canvas rendering + the WASM columnar store

Cells live in the WASM module as typed columns, not as objects. Each cell has a kind—EMPTY=0, NUMBER=1, STRING=2, BOOLEAN=3, or FORMULA=4—and numeric/string data is held in parallel typed arrays. This keeps memory flat and lets the store hand the renderer a transferable, typed-array-backed window.

Because the engine is columnar and WASM-resident:

  • A sheet of 100k+ rows costs no DOM nodes and no per-cell JS objects.
  • The render hot path is a single bulk read, not N cell lookups.
  • The same snapshot can be transferred to a worker for off-thread painting (see Worker rendering).

Virtualization & scaled scroll

Only the rows intersecting the viewport are ever painted. computeWindow turns the current scroll offset and viewport height into a half-open row range [start, end), padded by overscan rows on each side so a fast scroll reveals already-painted rows:

Partial example
declare function computeWindow(index: unknown, contentTop: number, viewportHeight: number, overscan: number): { start: number; end: number };

Row positions come from an OffsetIndex — a Fenwick (binary-indexed) tree over row heights giving O(log n) scrollTop ↔ row mapping and O(log n) single-row height edits, so variable row heights stay cheap.

Browsers clamp an element's height at an engine-specific ceiling — Chromium sits near 33.5 million CSS pixels at 100% zoom, and the ceiling shrinks with browser zoom and devicePixelRatio. At the default 28px row height that can be barely ~1M rows, so Sheetwrite never trusts a constant: it measures the real clamp with a probe element (re-measuring on zoom changes), pins the DOM sizer to that cap, and ScaledScroll maps the capped scrollbar position linearly onto the real virtual range:

Partial example
// DOM scrollTop -> content-space offset (identity below the cap)
declare function toContent(scrollTop: number): number;
// content-space offset -> DOM scrollTop
declare function toScroll(contentOffset: number): number;

Below the cap the mapping is the identity; above it, scrolling stays smooth while the few-pixel-per-row resolution loss is invisible at that scale.

The Store contract

A grid talks to its data through the Store interface. The two reads are deliberately different:

MethodUse
getVisibleWindow(sheet, rows, cols)The render hot path. Returns one VisibleWindowView for a whole rectangle. The renderer paints from this and must never read cell-by-cell.
getCell(addr)A single-cell read for interactions, API reads, and tests — never per frame. Returns { resolved, style }.

VisibleWindowView is backed by typed arrays (values, styleIds: Uint32Array, a shared styles dictionary) so it is worker-transferable, and it is valid only until the next store mutation or window refresh.

The full interface:

Partial example
interface StoreContract {
getWorkbook(): Workbook;
getCell(addr: CellAddress): ResolvedCell;
getVisibleWindow(
sheet: SheetId,
rows: { start: number; end: number },
cols: readonly number[],
): VisibleWindowView;
applyTransaction: Store["applyTransaction"];
on: Store["on"];
acknowledgeOperations?(operations: readonly DocumentOp[]): void;
}

Document protocol and storage transactions

WorkbookSnapshot is the versioned, JSON-safe authoritative document. Schema 1 contains sheet order and identity, stable column keys, literal/formula/reference cell inputs, sparse styles and row metadata, merges, frozen panes, conditional formats, validation rules, protected ranges, notes, row groups, hidden document metadata, filters, sort keys, and named ranges. Formula source is authoritative; resolved values are derived caches and are not serialized.

DocumentOp is the exhaustive plain-data mutation vocabulary for that document. Cells, ranges, rows/columns, merges, row metadata, frozen panes, conditional formats, validation, protection, notes, row groups, named ranges, and sheet lifecycle operations use the same store reducer and change-event shape. Local operations submitted through Grid enter grid undo/redo history; SyncCoordinator observes each non-empty local transaction and owns its durable mutation-ID record. Direct Store.applyTransaction calls deliberately bypass grid read-only/history policy. Remote operations remain observable with source: "remote" but enter neither local history nor outgoing synchronization.

Partial example
const checked = validateWorkbookSnapshot(JSON.parse(payload));
if (!checked.ok) throw new Error(checked.errors[0]?.message);
const outcome = grid.store.applyTransaction({
patches: [
{
op: "set",
addr,
value: { kind: "literal", value: "authoritative input" },
style: { backgroundColor: "#fde68a" },
},
],
});

Snapshot hydration is a separate, validated path: it creates every sheet handle, bulk-loads literal runs, installs formula/reference/style exceptions, then recomputes after the complete graph exists. It emits no user event, dirty patch, or undo entry. Export uses one bulk data-order read per sheet, ignores local sort/filter views and conditional render styles, and emits deterministic sparse blocks.

Partial example
const saved = await adapter.load(documentId, signal);
const grid = createGridFromSnapshot(host, saved);
const sync = new SyncCoordinator(grid, adapter, {
documentId,
serverVersion: saved.version ?? 0,
});
await sync.ready();
const unsubscribeRemote = sync.subscribe(remoteOperationSource);
await sync.sendNext(); // host chooses send/retry timing

Each local grid transaction becomes one immutable PendingCommit with a stable clientMutationId and explicit baseVersion. Records move from pending to sending; an applied or duplicate response acknowledges store dirty state and removes only the matching ID. Transport failure returns that record to pending, so retry sends the same ID. A conflict remains conflicted with its local operations intact and exposes server operations or a snapshot; Sheetwrite does not silently merge ambiguous structural edits. See Offline and collaboration for durable storage, recovery, presence, comments, revisions, and the conservative rebase gate.

Remote operations must arrive at exactly serverVersion + 1. Older versions and known mutation echoes are idempotently ignored; a gap emits reload-required instead of guessing. Canonical server operations apply with source remote, so they repaint/recompute without entering the outgoing queue.

Document state does not include selection, scroll position, editor/caret state, search results, temporary highlights, renderer choice, read-only policy, or local zoom. Those are session state. Workbook.activeSheet remains document metadata in schema 1.

Validation and protection run at the shared local transaction boundary, not only inside the inline editor. Validation policies can reject atomically, apply with a warning, or allow input. Protected ranges deny local writes unless the host's ProtectionResolver allows them; the default is deny. atomic mode rejects a transaction containing any denied operation, while partial mode filters denied operations and reports them in rejections. Protection is client UX policy, not server authorization. Remote operations intentionally bypass local protection.

Applied storage transactions emit change with the filtered transaction, per-cell rollback data, accumulated dirty patches, epoch, commit reason, and an explicit source (local or remote). Remote operations still update formulas and rendering, but do not re-enter outgoing persistence. Conflicts and no-ops return explicit outcomes rather than throwing.

Sheet IDs remain stable across rename and reorder. Renaming rewrites canonical cross-sheet formula source while preserving the stable formula-engine handle. Removing a referenced sheet rewrites dependents to #REF!; undo restores the sheet snapshot, formulas, styles, metadata, and dependencies. Removing the active sheet selects the nearest surviving sheet. The final sheet cannot be removed. All public document actions, including custom calls through grid.actions, are no-ops when the grid is read-only.

Cell values

A cell value is one of three shapes:

Partial example
type CellValue =
| { kind: "literal"; value: string | number | boolean | null }
| { kind: "ref"; target: CellAddress } // a plain cross-reference
| { kind: "formula"; src: string }; // authoritative source; "=" is optional at storage

Headers: letters vs. field names

The grid chrome is a real spreadsheet: the top band shows column letters (A, B, C, … AA) centered, and the left gutter shows row numbers (its width is theme.rowHeaderWidth; set it to 0 to hide the gutter). These letters are the column headers the renderer draws — the header you set on a Column is metadata (it is what CSV/XLSX export writes), not the on-canvas header.

Named field titles are therefore not chrome. The convention, exactly as in a real sheet, is to put them in row 0 (the first data row) and style them yourself. The live Vanilla example reserves row 0 and writes a styled header band over it once the initial page loads:

Partial example
const FIELD_HEADERS = ["ID", "Date", "Customer", "City", "Amount"];
function applyFieldHeader(): void {
grid.store.applyTransaction({
patches: FIELD_HEADERS.map((label, col) => ({
op: "set",
addr: { sheet: "sales", row: 0, col },
value: { kind: "literal", value: label },
style: { bold: true, align: "center", backgroundColor: "#eef1f5" },
})),
});
}

Datasource pages can carry the same authoritative CellValue shapes as eager data, plus { value, style } wrappers. Formula sources, references, and styles hydrate without entering dirty history. If row 0 is styled locally while a page is outstanding, the local edit wins over that stale response; reserving an empty row 0 in the source remains useful when the source itself should never provide a title row.

See also