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:
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)
declarefunctiontoContentfunction toContent(scrollTop: number): number (+1 overload)functiontoContent(scrollTop:number):number (+1overload)functiontoContent(scrollTop:number):number (+1overload)(scrollTop:number):number;
// content-space offset -> DOM scrollTop
declarefunctiontoScrollfunction toScroll(contentOffset: number): number (+1 overload)functiontoScroll(contentOffset:number):number (+1overload)functiontoScroll(contentOffset:number):number (+1overload)(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:
Method
Use
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
interfaceStoreContract {
getWorkbookStoreContract.getWorkbook(): WorkbookStoreContract.getWorkbook(): WorkbookStoreContract.getWorkbook(): Workbook():Workbookinterface WorkbookinterfaceWorkbookinterfaceWorkbookLive workbook schema containing ordered sheets and the active sheet ID.API reference →;
getCellStoreContract.getCell(addr: CellAddress): ResolvedCellStoreContract.getCell(addr: CellAddress): ResolvedCellStoreContract.getCell(addr: CellAddress): ResolvedCellSingle-cell read for interactions, API reads, and tests. NOT for the render hot path — renderers use getVisibleWindow.API reference →(addr:CellAddressinterface CellAddressinterfaceCellAddressinterfaceCellAddressZero-based address of one cell on a stable sheet ID.API reference →):ResolvedCellinterface ResolvedCellinterfaceResolvedCellinterfaceResolvedCellAuthoritative source value, evaluated value, style, and load state for a cell.API reference →;
getVisibleWindowStoreContract.getVisibleWindow(sheet: SheetId, rows: {
start: number;
end: number;
}, cols: readonly number[]): VisibleWindowViewStoreContract.getVisibleWindow(sheet: SheetId, rows: {start: number;end: number;}, cols: readonly number[]): VisibleWindowViewStoreContract.getVisibleWindow(sheet: SheetId, rows: {start: number;end: number;}, cols: readonly number[]): VisibleWindowViewBulk read of a visible window; the only read a renderer should use per frame.API reference →(
sheetsheet: stringlet sheet:stringlet sheet:stringRestrict to a sheet (defaults to the active sheet).API reference →:SheetIdtype SheetId = stringtypeSheetId=string;typeSheetId=string;Stable identifier used to address a workbook sheet.API reference →,
):VisibleWindowViewinterface VisibleWindowViewinterfaceVisibleWindowViewinterfaceVisibleWindowViewOne rectangular window of resolved cells, returned by Store.getVisibleWindow in a single call. The renderer paints from this view and MUST NOT call Store.getCell per cell. styleIds are view-local indices into this view's compact styles dictionary; on the worker renderer path, styleIds.buffer is transferred during paint, so main-thread code must not read it after paint. Lifetime: valid until the next store mutation or window refresh.API reference →;
applyTransactionStoreContract.applyTransaction: (tx: Transaction, options?: TransactionApplicationOptions) => ApplyTransactionResultinterfaceStoreContract {applyTransaction: (tx:Transaction,options?:TransactionApplicationOptions,) =>ApplyTransactionResult;}interfaceStoreContract {applyTransaction: (tx:Transaction,options?:TransactionApplicationOptions,) =>ApplyTransactionResult;}Apply arbitrary patches as one undoable Grid commit. No-op when read-only. Use Store.applyTransaction only for low-level writes that intentionally bypass Grid history and policy.API reference →:Storeinterface StoreinterfaceStoreinterfaceStoreColumnar workbook storage, query, transaction, and subscription contract.API reference →["applyTransaction"];
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
constcheckedconst checked: DocumentValidationResultconstchecked:DocumentValidationResultconstchecked:DocumentValidationResult=validateWorkbookSnapshotfunction validateWorkbookSnapshot(input: unknown, options?: SnapshotValidationOptions): DocumentValidationResultfunctionvalidateWorkbookSnapshot(input:unknown,options?:SnapshotValidationOptions,):DocumentValidationResultfunctionvalidateWorkbookSnapshot(input:unknown,options?:SnapshotValidationOptions,):DocumentValidationResultValidate and canonically order a schema-1 snapshot without hydrating runtime state.API reference →(JSONvar JSON: JSONvarJSON:JSONvarJSON:JSONAn intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format..parseJSON.parse(text: string, reviver?: (this: any, key: string, value: any) => any): anyJSON.parse(text: string, reviver?: (this:any, key:string, value:any) => any): anyJSON.parse(text: string, reviver?: (this:any, key:string, value:any) => any): anyConverts a JavaScript Object Notation (JSON) string into an object.@param text A valid JSON string.@param reviver A function that transforms the results. This function is called for each member of the object. If a member contains nested objects, the nested objects are transformed before the parent object is.@throws {SyntaxError} If text is not valid JSON.(payloadconst payload: stringconstpayload:stringconstpayload:string));
constoutcomeconst outcome: ApplyTransactionResultconstoutcome:ApplyTransactionResultconstoutcome:ApplyTransactionResult=gridconst grid: Gridconstgrid:Gridconstgrid:GridThe imperative core grid this controller owns.API reference →.storeGrid.store: StoreinterfaceGrid {store:Store;}interfaceGrid {store:Store;}.applyTransactionStore.applyTransaction(tx: Transaction, options?: TransactionApplicationOptions): ApplyTransactionResultStore.applyTransaction(tx: Transaction, options?: TransactionApplicationOptions): ApplyTransactionResultStore.applyTransaction(tx: Transaction, options?: TransactionApplicationOptions): ApplyTransactionResultApply a low-level storage transaction. This bypasses Grid read-only checks and Grid undo/redo history. Use Grid.applyTransaction for normal host-driven edits. Queued and flushed at a barrier — never reentrant.API reference →({
patchesTransaction.patches: DocumentOp[]interfaceTransaction {patches:DocumentOp[];}interfaceTransaction {patches:DocumentOp[];}Ordered document operations submitted as one store commit.API reference →: [
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
constsavedconst saved: WorkbookSnapshotconstsaved:WorkbookSnapshotconstsaved:WorkbookSnapshot=awaitadapterconst adapter: PersistenceAdapterconstadapter:PersistenceAdapterconstadapter:PersistenceAdapter.loadPersistenceAdapter.load(documentId: string, signal?: AbortSignal): Promise<WorkbookSnapshot>PersistenceAdapter.load(documentId: string, signal?: AbortSignal): Promise<WorkbookSnapshot>PersistenceAdapter.load(documentId: string, signal?: AbortSignal): Promise<WorkbookSnapshot>(documentIdconst documentId: stringconstdocumentId:stringconstdocumentId:string, signalconst signal: AbortSignalconstsignal:AbortSignalconstsignal:AbortSignalAbort before or between bounded codec operations.API reference →);
constgridconst grid: Gridconstgrid:Gridconstgrid:GridThe imperative core grid this controller owns.API reference →=createGridFromSnapshotfunction createGridFromSnapshot(host: HTMLElement, snapshot: unknown, options?: SnapshotGridOptions): GridfunctioncreateGridFromSnapshot(host:HTMLElement,snapshot:unknown,options?:SnapshotGridOptions,):GridfunctioncreateGridFromSnapshot(host:HTMLElement,snapshot:unknown,options?:SnapshotGridOptions,):GridMount a grid over a validated, non-dirty snapshot. The grid owns and disposes the hydrated store just like one created through createGrid.API reference →(hostconst host: HTMLElementconsthost:HTMLElementconsthost:HTMLElement, savedconst saved: WorkbookSnapshotconstsaved:WorkbookSnapshotconstsaved:WorkbookSnapshot);
constsyncconst sync: SyncCoordinatorconstsync:SyncCoordinatorconstsync:SyncCoordinator=newSyncCoordinatornew SyncCoordinator(grid: Grid, adapter: PersistenceAdapter, options: SyncCoordinatorOptions): SyncCoordinatornewSyncCoordinator(grid: Grid, adapter: PersistenceAdapter, options: SyncCoordinatorOptions): SyncCoordinatornewSyncCoordinator(grid: Grid, adapter: PersistenceAdapter, options: SyncCoordinatorOptions): SyncCoordinatorDeterministic, transport-neutral optimistic sync. Local rendering is never blocked: changes queue immediately, while hosts explicitly call sendNext or retry to perform network work.API reference →(gridconst grid: Gridconstgrid:Gridconstgrid:GridThe imperative core grid this controller owns.API reference →, adapterconst adapter: PersistenceAdapterconstadapter:PersistenceAdapterconstadapter:PersistenceAdapter, {
serverVersionSyncCoordinatorOptions.serverVersion: numberinterfaceSyncCoordinatorOptions {serverVersion:number;}interfaceSyncCoordinatorOptions {serverVersion:number;}Last accepted contiguous remote server version.API reference →: savedconst saved: WorkbookSnapshotconstsaved:WorkbookSnapshotconstsaved:WorkbookSnapshot.versionWorkbookSnapshot.version?: number | undefinedinterfaceWorkbookSnapshot {version?:number|undefined;}interfaceWorkbookSnapshot {version?:number|undefined;}??0,
});
awaitsyncconst sync: SyncCoordinatorconstsync:SyncCoordinatorconstsync:SyncCoordinator.readySyncCoordinator.ready(): Promise<void>SyncCoordinator.ready(): Promise<void>SyncCoordinator.ready(): Promise<void>Resolves after durable work is restored and every startup commit is visible locally.API reference →();
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.
| { kind:"ref"; target:CellAddressinterface CellAddressinterfaceCellAddressinterfaceCellAddressZero-based address of one cell on a stable sheet ID.API reference → } // 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:
gridconst grid: Gridconstgrid:Gridconstgrid:GridThe imperative core grid this controller owns.API reference →.storeGrid.store: StoreinterfaceGrid {store:Store;}interfaceGrid {store:Store;}.applyTransactionStore.applyTransaction(tx: Transaction, options?: TransactionApplicationOptions): ApplyTransactionResultStore.applyTransaction(tx: Transaction, options?: TransactionApplicationOptions): ApplyTransactionResultStore.applyTransaction(tx: Transaction, options?: TransactionApplicationOptions): ApplyTransactionResultApply a low-level storage transaction. This bypasses Grid read-only checks and Grid undo/redo history. Use Grid.applyTransaction for normal host-driven edits. Queued and flushed at a barrier — never reentrant.API reference →({
patchesTransaction.patches: DocumentOp[]interfaceTransaction {patches:DocumentOp[];}interfaceTransaction {patches:DocumentOp[];}Ordered document operations submitted as one store commit.API reference →: FIELD_HEADERSconst FIELD_HEADERS: string[]constFIELD_HEADERS:string[]constFIELD_HEADERS:string[].mapArray<string>.map<{
op: "set";
addr: {
sheet: string;
row: number;
col: number;
};
value: {
kind: "literal";
value: string;
};
style: {
bold: true;
align: "center";
backgroundColor: string;
};
}>(callbackfn: (value: string, index: number, array: string[]) => {
op: "set";
addr: {
sheet: string;
row: number;
col: number;
};
value: {
kind: "literal";
value: string;
};
style: {
bold: true;
align: "center";
backgroundColor: string;
};
}, thisArg?: any): {
op: "set";
addr: {
sheet: string;
row: number;
col: number;
};
value: {
kind: "literal";
value: string;
};
style: {
bold: true;
align: "center";
backgroundColor: string;
};
}[]Array<string>.map<{op:"set";addr: {sheet:string;row:number;col:number;};value: {kind:"literal";value:string;};style: {bold:true;align:"center";backgroundColor:string;};}>(callbackfn: (value:string, index:number, array:string[]) => {op:"set";addr: {sheet:string;row:number;col:number;};value: {kind:"literal";value:string;};style: {bold:true;align:"center";backgroundColor:string;};}, thisArg?:any): {op:"set";addr: {sheet:string;row:number;col:number;};value: {kind:"literal";value:string;};style: {bold:true;align:"center";backgroundColor:string;};}[]Array<string>.map<{op:"set";addr: {sheet:string;row:number;col:number;};value: {kind:"literal";value:string;};style: {bold:true;align:"center";backgroundColor:string;};}>(callbackfn: (value:string, index:number, array:string[]) => {op:"set";addr: {sheet:string;row:number;col:number;};value: {kind:"literal";value:string;};style: {bold:true;align:"center";backgroundColor:string;};}, thisArg?:any): {op:"set";addr: {sheet:string;row:number;col:number;};value: {kind:"literal";value:string;};style: {bold:true;align:"center";backgroundColor:string;};}[]Calls a defined callback function on each element of an array, and returns an array that contains the results.@param callbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.@param thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.((labellabel: stringlet label:stringlet label:stringMenu row text. Defaults per action.API reference →, colcol: numberlet col:numberlet col:number) => ({
op: "set",
addraddr: {
sheet: string;
row: number;
col: number;
}let addr: {sheet:string;row:number;col:number;}let addr: {sheet:string;row:number;col:number;}: { sheetsheet: stringlet sheet:stringlet sheet:stringRestrict to a sheet (defaults to the active sheet).API reference →: "sales", rowrow: numberlet row:numberlet row:number: 0, colcol: numberlet col:numberlet col:number },
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.