# Sheetwrite — full documentation text > Sheetwrite is a framework-agnostic, high-performance spreadsheet grid: a canvas grid backed by a Rust/WASM columnar engine, with an imperative core and React, Vue, and Svelte adapters. Every authored guide in the Sheetwrite documentation, followed by every compiler-verified public API declaration. Generated reference tables (API pages, formula functions, compatibility results, entry-point inventory, performance evidence) are published as HTML and are linked from https://sheetwrite.vercel.app/llms.txt instead of being repeated here. Packages: 0.5.4. Canonical HTML: https://sheetwrite.vercel.app/docs/ ## Contents - [Calculation engine](#calculation-engine) - [Runtime ownership](#runtime-ownership) - [Adapter lifecycle](#adapter-lifecycle) - [React](#react) - [Svelte](#svelte) - [Vanilla JavaScript](#vanilla-javascript) - [Vue](#vue) - [Accessibility](#accessibility) - [Analysis formulas](#analysis-formulas) - [Collaboration and offline work](#collaboration-and-offline-work) - [Configuration](#configuration) - [Data operations and export](#data-operations-and-export) - [Formulas](#formulas) - [Keep app rows in your store](#keep-app-rows-in-your-store) - [Interaction and editing](#interaction-and-editing) - [Persistence and recovery](#persistence-and-recovery) - [Styling and theming](#styling-and-theming) - [Worker rendering](#worker-rendering) - [XLSX and export](#xlsx-and-export) - [Public API contract](#public-api-contract) - [Compatibility and limits](#compatibility-and-limits) - [Document operations](#document-operations) - [Error handling](#error-handling) - [Releases](#releases) - [Sheetwrite 0.5.0](#sheetwrite-0-5-0) - [Sheetwrite 0.5.1](#sheetwrite-0-5-1) - [Sheetwrite 0.5.2](#sheetwrite-0-5-2) - [Sheetwrite 0.5.3](#sheetwrite-0-5-3) - [Sheetwrite 0.5.4](#sheetwrite-0-5-4) - [Your first grid](#your-first-grid) - [Installation](#installation) - [Public API declarations](#public-api-declarations) ## Authored guides ### Calculation engine Source: https://sheetwrite.vercel.app/docs/concepts/calculation-engine/ Sheetwrite calculates formulas in a Rust engine compiled to WebAssembly. The TypeScript core sends edits to the engine as transactions, and the engine keeps every formula result current. This page explains the model. The [formulas guide](https://sheetwrite.vercel.app/docs/guides/formulas/) has the exact syntax and semantics. ## One engine, two builds | Build | Package | Use it when | | --- | --- | --- | | Default | `@sheetwrite/wasm` | The app needs the standard functions: math, lookup, text, logic, dates, dynamic arrays, and `LET`. It is the smallest download. | | Full | `@sheetwrite/formulas` | The app needs analysis functions: `GROUPBY`, `PIVOTBY`, `LAMBDA`, statistics, regression, finance, regex, and more. | Both builds compile from the same source and have the same API, storage, and limits. The full build only registers more functions. An app selects one build one time, before it creates a Grid or a store, and cannot change it later. See [Full formula engine](https://sheetwrite.vercel.app/docs/start/installation/#full-formula-engine) and the [analysis formulas guide](https://sheetwrite.vercel.app/docs/guides/analysis-formulas/). ## What happens on an edit 1. The Grid or the host submits a transaction, for example a `set` operation. 2. The engine stores the new values and marks the changed cells dirty. 3. A dependency index records which formulas read which cells and ranges. The engine uses it to find every formula that the dirty cells affect, also through other formulas. 4. Only those formulas calculate again. A formula that no change reaches keeps its result. 5. The transaction returns after the calculation. `store.getCell(address).resolved` gives the new result at once, and the `change` event and the next paint show it. The dependency index changes only when formula reads change. If you rewrite a formula and its cell and range reads stay the same, for example a new constant, the index stays valid. ## Spills A formula that returns an array, such as `=SORT(A2:A100)` or `={1,2;3,4}`, **spills**: the formula cell is the anchor, and the result fills the cells to its right and below. Spills are atomic. If any destination cell is not empty, the anchor shows `#SPILL!` and no partial result appears. `A1#` refers to the current spill of `A1` and follows it when it grows or shrinks. When one spill reads another, the engine calculates them in dependency order, so the reader always sees current values. Spills that read each other show `#CYCLE!`. See [Dynamic arrays and spills](https://sheetwrite.vercel.app/docs/guides/formulas/#dynamic-arrays-and-spills). ## Paged data and `#LOADING!` With a windowed datasource, only some rows are in memory. A formula that reads cells that are not loaded yet shows `#LOADING!` and calculates again when they arrive. For visible formulas, the Grid asks the engine which same-sheet cells they read and loads those cells with the window, within a cell budget. See [Cells that visible formulas read](https://sheetwrite.vercel.app/docs/guides/configuration/#cells-that-visible-formulas-read). ## Errors are values A formula error is a result, not an exception: `#DIV/0!`, `#N/A`, `#NAME?`, `#REF!`, `#VALUE!`, `#NUM!`, `#SPILL!`, `#CALC!`, `#CYCLE!`, and `#LOADING!`. Errors flow to dependent formulas, and `IFERROR` can replace them. An unknown function returns `#NAME?`, and its source stays in the document, so a later engine build can calculate it. See [Scalars, coercion, and errors](https://sheetwrite.vercel.app/docs/guides/formulas/#scalars-coercion-and-errors). ## Limits Every formula runs inside fixed resource limits. When a formula reaches a limit, it returns an error, usually `#NUM!`, and never a partial result: - one range can hold at most 1,000,000 cells; - one spill can hold at most 1,000,000 cells and 64 MiB; - one dynamic-array calculation can do at most 2,000,000 cell operations; - dependencies and nested expressions can be at most 256 levels deep. The [compatibility limits](https://sheetwrite.vercel.app/docs/reference/compatibility-limits/#formula-evaluation) list every limit, its unit, and its behavior. ## No network, no custom code Formula calculation makes no network request and runs no JavaScript from the document. Functions that need external data, such as `WEBSERVICE` or `GOOGLEFINANCE`, are not supported. `TODAY` and `NOW` change only when the host calls `store.recalculateVolatile(date)`. ### Runtime ownership Source: https://sheetwrite.vercel.app/docs/concepts/runtime-ownership/ ## 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. 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 and 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. - Bulk loads write packed blocks: one engine call per column, with all text cells in one UTF-8 buffer instead of one string per cell crossing into WASM. - 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](https://sheetwrite.vercel.app/docs/guides/worker-rendering/)). The same engine calculates formulas. The default build is `@sheetwrite/wasm`, and the optional full build `@sheetwrite/formulas` adds the analysis functions. See [Calculation engine](https://sheetwrite.vercel.app/docs/concepts/calculation-engine/) for recalculation, spills, and limits. ## 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: ```ts 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: ```ts // 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: | 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: ```ts 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. ```ts 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. ```ts 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](https://sheetwrite.vercel.app/docs/guides/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: ```ts 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](https://sheetwrite.vercel.app/vanilla/) reserves row 0 and writes a styled header band over it once the initial page loads: ```ts 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 - [Configuration](https://sheetwrite.vercel.app/docs/guides/configuration/) for the option and method reference. - [Data operations](https://sheetwrite.vercel.app/docs/guides/data-operations/) for sort/filter views built on the same window read. ### Adapter lifecycle Source: https://sheetwrite.vercel.app/docs/frameworks/lifecycle/ Every adapter follows one lifecycle: 1. mount the framework component; 2. initialize the shared WASM runtime; 3. create one `Grid` generation; 4. publish readiness; 5. reset only when structural ownership changes; 6. destroy the generation on unmount. Pick a framework in any tab group below; the selection is remembered and reused across documentation pages. ## Pick the ownership model Use **`Sheetwrite`** for a data-first, uncontrolled grid: pass columns plus initial rows and let the component own the live document. Use **`SheetwriteGrid`** for an advanced `Workbook` plus eager data or a datasource. It exposes the imperative `Grid` after readiness and keeps structural configuration under the host's control. **Vanilla** The imperative core has one ownership mode: the host supplies the workbook and data, awaits runtime initialization, and destroys the returned grid. ```ts import { createGrid, initSheetwrite } from "@sheetwrite/core"; await initSheetwrite(); const grid = createGrid(host, { workbook, data }); // Later, when the host DOM lifetime ends: grid.destroy(); ``` **React** ```tsx observe(grid, generation, reason)} /> ``` ```tsx connect(grid)} /> ``` `defaultRows` is seed data. Changing it after mount does not replace committed edits; a new `columns`, `defaultRows`, or `sheetName` identity derives a new document and creates a new generation. **Vue** ```vue ``` ```vue ``` The template ref exposes `{ grid }` after readiness. `defaultRows` seeds the document once; a new `columns`, `defaultRows`, or `sheetName` identity creates a new generation. **Svelte** ```svelte ``` ```svelte ``` `bind:grid` publishes the live handle and clears it on reset or unmount. `defaultRows` seeds the document once; a new `columns`, `defaultRows`, or `sheetName` identity creates a new generation. ## Readiness is generation-scoped `onReady` receives `{ grid, generation, reason }`. `generation` is one-based and increments whenever the adapter replaces its `Grid`. `reason` is exactly one of: - `"initial"` — the first generation after mount and WASM initialization; - `"input-reset"` — a reset-classified document input changed identity; - `"renderer-reset"` — `renderer`, `workerUrl`, or `renderers` changed identity. The exposed handle (React ref, Vue `expose`, Svelte `bind:grid`) is assigned before `onReady` runs and cleared when that generation is destroyed. Reattach subscriptions and collaboration coordinators inside `onReady`; never retain a stale `Grid` across a reset. ## Controlled values do not reset the document `theme`, `readOnly`, `config`, `overscan`, and `minColumns` are live: changing them updates the current generation in place without recreating the grid or replaying `defaultRows`. Callbacks and host sizing (`height`, `fill`, `className`/`class`, `style`) are also safe to change live. Structural changes are different. Every reset-classified input is compared by identity: | Change | Result | | --- | --- | | `workbook`, `data`, `datasource`, `datasourceStorage`, `presentation` | New generation, `reason: "input-reset"` | | `editors`, `protectionResolver`, `mutationPolicy`, `transactionResourceLimits` | New generation, `reason: "input-reset"` | | `renderer`, `workerUrl`, `renderers` | New generation, `reason: "renderer-reset"` | | `columns`, `defaultRows`, `sheetName` (simple component) | New derived document, `reason: "input-reset"` | | `theme`, `readOnly`, `config`, `overscan`, `minColumns` | In-place update, no reset | Keep reset-classified inputs referentially stable (module constants, memoized values, or state) unless you intend to create a new generation. ## Transactions and persistence `onGridChange` receives committed transactions. `transaction.patches` is the exhaustive document operation list; `changes` is the cell-level compatibility view. ```ts function persist(event: ChangeEvent): void { if (event.source === "local") operationQueue.push(...event.transaction.patches); } ``` Use `event.reason` for audit context, not to reconstruct the transaction. For offline durability and replay, persist operations with `SheetwriteStore` or your own `PersistenceAdapter`. ## Initialization failures Framework components surface initialization errors instead of rendering a dead grid. React uses `fallback` and `onInitializationError`; Vue and Svelte support fallback content or slots plus their error callback/event. WASM initialization is process-wide and monotonic: every adapter converges on the same runtime, and when several pass an explicit `wasmSource` concurrently, the first source wins. While initialization has not yet succeeded, a changed `wasmSource` or a remount retries it; after the runtime is ready, the source is fixed for the process. ## Server rendering Importing an adapter during SSR is safe, but the grid initializes only after a browser mount. Put it behind the host framework's client boundary: - React/Next.js: a client component; - Nuxt: ``; - SvelteKit: a browser-only branch or client-mounted component. No framework owns the global runtime. All adapters converge on the same monotonic initializer. ### React Source: https://sheetwrite.vercel.app/docs/frameworks/react/ `@sheetwrite/react` wraps a Grid in a React component. It initializes WASM after the component mounts on the client, so the page stays server-renderable. Package`@sheetwrite/react` Components`Sheetwrite`, `SheetwriteGrid` Grid accessForwarded ref and `onReady` Change events`onGridChange` prop ## Install ```sh npm install @sheetwrite/react ``` Import `@sheetwrite/react/styles.css` once in your app. ## Mount a grid Use `Sheetwrite` for the simple `columns` and `defaultRows` contract. Use `SheetwriteGrid` when the host supplies a `Workbook` plus eager data or a datasource. ```tsx import { Sheetwrite, type GridReadyEvent, type SimpleColumn } from "@sheetwrite/react"; import type { Transaction } from "@sheetwrite/core"; import "@sheetwrite/react/styles.css"; type Row = { name: string; amount: number }; interface SheetProps { columns: readonly SimpleColumn[]; rows: readonly Row[]; save: (transaction: Transaction) => void; onReady: (event: GridReadyEvent) => void; } export function Sheet({ columns, rows, save, onReady }: SheetProps) { return ( save(event.transaction)} onReady={onReady} /> ); } ``` ## Listen to changes `onGridChange` reports committed Grid changes. `onReady` receives `{ grid, generation, reason }` after the forwarded ref is assigned. ## Resets and cleanup Replacing a reset-sensitive input creates a new Grid generation. Destroy the persistence and sync coordinators that you attached to the previous Grid, then attach new ones in `onReady`. The [lifecycle page](https://sheetwrite.vercel.app/docs/frameworks/lifecycle/) lists which inputs update in place and which reset. ## Server rendering Rendering the component on the server does not initialize WASM. In frameworks that render on the server, mount it in a client component. Example Run the React example (https://sheetwrite.vercel.app/react/) Lifecycle Updates and resets (https://sheetwrite.vercel.app/docs/frameworks/lifecycle/) Reference @sheetwrite/react API (https://sheetwrite.vercel.app/docs/api/react/) ### Svelte Source: https://sheetwrite.vercel.app/docs/frameworks/svelte/ `@sheetwrite/svelte` wraps a Grid in a Svelte 5 component. It initializes WASM in the browser. Package`@sheetwrite/svelte` Component`Sheetwrite` Grid access`bind:grid` and `onReady` Change events`onGridChange` prop ## Install ```sh npm install @sheetwrite/svelte ``` Import `@sheetwrite/svelte/styles.css` once in your app. ## Mount a grid Bind `grid` when the host needs imperative access. Pass callbacks with the adapter's camel-case props. ```svelte ``` ## Listen to changes `onGridChange` reports committed Grid changes. The `grid` binding is set before `onReady` runs. ## Resets and cleanup The binding is cleared before a reset or unmount. A reset publishes a new generation and reason; destroy the coordinators attached to the previous Grid and create new ones. The [lifecycle page](https://sheetwrite.vercel.app/docs/frameworks/lifecycle/) lists which inputs update in place and which reset. ## Server rendering When the surrounding SvelteKit route renders on the server, use a client-only boundary. Example Run the Svelte example (https://sheetwrite.vercel.app/svelte/) Lifecycle Updates and resets (https://sheetwrite.vercel.app/docs/frameworks/lifecycle/) Reference @sheetwrite/svelte API (https://sheetwrite.vercel.app/docs/api/svelte/) ### Vanilla JavaScript Source: https://sheetwrite.vercel.app/docs/frameworks/vanilla/ Use `@sheetwrite/core` directly when the host owns the DOM lifetime. You initialize the engine, create the Grid, and destroy it yourself. Package`@sheetwrite/core` Entry points`initSheetwrite`, `createGrid` Grid accessThe returned `Grid` Change events`grid.on("change")` ## Install ```sh npm install @sheetwrite/core ``` ## Mount a grid Call `initSheetwrite()` on the client, pass an existing element to `createGrid`, and keep the returned `Grid` for events and commands. ```ts import { createGrid, initSheetwrite, type ChangeEvent, type ColumnarData, type Workbook, } from "@sheetwrite/core"; export async function mountSheetwrite( host: HTMLElement, workbook: Workbook, data: ColumnarData, persistChange: (event: ChangeEvent) => void, ) { await initSheetwrite(); const grid = createGrid(host, { workbook, data }); const off = grid.on("change", persistChange); return () => { off(); grid.destroy(); }; } ``` ## Listen to changes `grid.on("change", …)` reports committed changes and returns a function that removes the listener. The cleanup function in the example removes the listener and then destroys the Grid. ## Data and rendering A datasource-backed Grid can use dense storage or allocation-lazy paged storage. Full-sheet operations can throw `IncompleteDataError` until every required page is loaded. Worker rendering falls back to the main-thread canvas when the browser lacks a capability or the worker fails to start; listen to `renderer-fallback` if the host must report that change. Example Run the Vanilla example (https://sheetwrite.vercel.app/vanilla/) Setup Installation and WASM sources (https://sheetwrite.vercel.app/docs/start/installation/) Reference @sheetwrite/core API (https://sheetwrite.vercel.app/docs/api/core/) ### Vue Source: https://sheetwrite.vercel.app/docs/frameworks/vue/ `@sheetwrite/vue` wraps a Grid in a Vue component. It initializes WASM after the component mounts on the client. Package`@sheetwrite/vue` Component`Sheetwrite` Grid accessExposed `grid` and `@ready` Change events`@grid-change` ## Install ```sh npm install @sheetwrite/vue ``` Import `@sheetwrite/vue/styles.css` once in your app. ## Mount a grid The component accepts `columns` and `default-rows`. Templates use kebab-case event listeners. ```vue ``` ## Listen to changes `@grid-change` reports committed Grid changes. The component exposes `grid` before it emits `ready`. ## Resets and cleanup A reset clears the old exposed handle, creates a new generation, and then publishes the replacement. Attach again any coordinator that subscribed to the old Grid. The [lifecycle page](https://sheetwrite.vercel.app/docs/frameworks/lifecycle/) lists which inputs update in place and which reset. ## Server rendering Importing or rendering the component on the server does not start WASM. In Nuxt or another server-rendered host, use a client-only boundary. Example Run the Vue example (https://sheetwrite.vercel.app/vue/) Lifecycle Updates and resets (https://sheetwrite.vercel.app/docs/frameworks/lifecycle/) Reference @sheetwrite/vue API (https://sheetwrite.vercel.app/docs/api/vue/) ### Accessibility Source: https://sheetwrite.vercel.app/docs/guides/accessibility/ The grid paints to a ``, which assistive technology can not read. To make it navigable, Sheetwrite maintains a parallel **ARIA shadow tree**: a visually-hidden DOM mirror of the visible window that carries the grid semantics, while the canvas itself is hidden from the accessibility tree. ## The host grid `createGrid` annotates the host element as the grid: | Attribute | Value | | --- | --- | | `role` | `"grid"` | | `aria-multiselectable` | `"true"` | | `aria-rowcount` | total rows **+ 1** (the column-letter header counts as a row) | | `aria-colcount` | number of visible columns | | `aria-readonly` | `"true"` when the grid is `readOnly` (omitted otherwise) | | `aria-label` | `"Spreadsheet grid"` (default; not overwritten if you set your own) | | `aria-activedescendant` | the id of the focused cell in the mirror | The visual layers — the scroller, the overlay, and the `` — are all marked `aria-hidden="true"`, so screen readers ignore the pixels and read the mirror. ## The SR-only mirror Alongside the host the grid appends a visually-hidden `
` with a unique id (`sheetwrite-grid-`) and `role="rowgroup"`. It contains: - A **header row** — `role="row"`, `aria-rowindex="1"` — whose cells are `role="columnheader"` with a 1-based `aria-colindex`. Spreadsheet presentation uses the positional column letter (`A`, `B`, …); data-grid presentation uses the semantic `Column.header`. - One **data row** per visible row — `role="row"` with `aria-rowindex` set to the true row position (data row + 2, leaving index 1 for the header). Its cells are `role="gridcell"` with a 1-based `aria-colindex`, a stable id (`--`), and the cell's value as text. - Every visible cell covered by the current cell/range/multi selection carries `aria-selected="true"`. A row selection marks its visible row; a column selection marks both its header and visible cells. The focused cell's id is mirrored into the host's `aria-activedescendant`. Sketch of the emitted structure: ```html
A
B
Widget
12
``` As keyboard or pointer input changes the selection, `aria-selected` and the host's `aria-activedescendant` update. Scrolling rebuilds the visible mirror. Horizontal virtualization does not renumber the mirror: if the visible window starts at sheet column G, its header and cells use `aria-colindex="7"`. Frozen and scrolling columns likewise keep their absolute one-based sheet positions. A focused cell note is connected through `aria-describedby` to hidden plain text. Validation list and checkbox editors use real `listbox` / `option` and `checkbox` semantics, with the rule help text as their accessible label. Host-supplied editors receive a `context.label` composed from that same semantic/positional header and row number. Core applies it to the retained editor wrapper and to the first focusable control when the editor did not provide its own name. Commit, cancel, reset, and unmount return focus to the grid; teardown aborts the editor signal before removing its DOM. Custom DOM renderer output uses the same mirror for its default cell value. Non-semantic renderer elements are hidden from assistive technology to avoid a duplicate announcement. A renderer can expose an explicitly interactive, labelled control; its keyboard events remain with that control, its element is retained across scroll and geometry updates, and focus returns to the grid if virtualization or renderer replacement removes it. ## Limitation: the mirror reflects the visible window The mirror contains **only the rows and columns currently rendered** (the same virtualized window the canvas paints, plus overscan) — not the entire sheet. This is deliberate: materializing 100k+ rows of DOM would defeat the point of canvas rendering. The consequences to be aware of: - `aria-rowindex` / `aria-colindex` carry the **true** positions, so assistive technology can announce "row N of `aria-rowcount`" correctly even though only a window is present. - A screen reader's own virtual cursor can only walk the cells in the rendered window. Use the grid's keyboard navigation (arrows, Page Up/Down, Home/End — see [Interaction](https://sheetwrite.vercel.app/docs/guides/interaction/#keyboard-navigation)) to move focus; that scrolls new rows into view and into the mirror, and updates `aria-activedescendant`. ### Analysis formulas Source: https://sheetwrite.vercel.app/docs/guides/analysis-formulas/ The full formula engine, `@sheetwrite/formulas`, adds analysis functions to the default engine: grouping and pivot tables, `LAMBDA` helpers, statistics, regression, distributions, finance, dates, text and regex, reshaping, database functions, and matrices. This guide shows one task for each family. The [formula function contract](https://sheetwrite.vercel.app/docs/reference/formula-functions/) lists every function, its signature, and its build. The [formulas guide](https://sheetwrite.vercel.app/docs/guides/formulas/) explains syntax, references, errors, and spills. To see these functions recalculate live over 20,000 orders, open the [formula analysis showcase](https://sheetwrite.vercel.app/showcases/formulas/). ## Select the engine Select the full engine one time, before you create a Grid or a store. The rest of the API does not change. ```ts import { initSheetwrite } from "@sheetwrite/core"; import * as formulas from "@sheetwrite/formulas"; await initSheetwrite(undefined, formulas); ``` With the default engine, these functions return `#NAME?`, and formula assist does not suggest them. ## The example data All examples use this sheet of 12 orders in `A1:G12`. Column E holds `Units × Price`, and column F holds order dates. | Row | A Region | B Product | C Units | D Price | E Revenue | F Date | G Note | | --- | --- | --- | ---: | ---: | ---: | --- | --- | | 1 | North | Desk | 4 | 250 | 1,000 | 2026-01-05 | Ref INV-1042; ship by Friday | | 2 | South | Chair | 10 | 80 | 800 | 2026-01-09 | Ref INV-1043; gift wrap | | 3 | North | Chair | 6 | 80 | 480 | 2026-01-14 | Ref INV-1044 | | 4 | East | Desk | 2 | 250 | 500 | 2026-01-20 | Ref INV-1045; call 555-0142 | | 5 | South | Lamp | 15 | 30 | 450 | 2026-02-02 | Ref INV-1046 | | 6 | East | Lamp | 8 | 30 | 240 | 2026-02-06 | Ref INV-1047; ship by Monday | | 7 | North | Lamp | 12 | 30 | 360 | 2026-02-11 | Ref INV-1048 | | 8 | South | Desk | 3 | 250 | 750 | 2026-02-17 | Ref INV-1049 | | 9 | East | Chair | 7 | 80 | 560 | 2026-02-23 | Ref INV-1050; call 555-0187 | | 10 | North | Desk | 5 | 250 | 1,250 | 2026-03-03 | Ref INV-1051 | | 11 | South | Chair | 9 | 80 | 720 | 2026-03-10 | Ref INV-1052 | | 12 | East | Desk | 1 | 250 | 250 | 2026-03-16 | Ref INV-1053; gift wrap | Cash flows for the finance examples are in `H1:I4`: −10,000 on 2026-01-01, 2,500 on 2026-04-01, 4,200 on 2026-09-01, and 6,000 on 2026-12-31. Every result below comes from the full engine. Results that spill are shown as the cells they fill. ## Summarize: GROUPBY, PIVOTBY, PERCENTOF `GROUPBY` groups rows by one or more key columns and aggregates the values. It adds a total row by default. ```text =GROUPBY(A1:A12, E1:E12, SUM) ``` | Region | Revenue | | --- | ---: | | East | 1550 | | North | 3090 | | South | 2720 | | Total | 7360 | `PIVOTBY` adds column keys: ```text =PIVOTBY(A1:A12, B1:B12, E1:E12, SUM) ``` | | Chair | Desk | Lamp | Total | | --- | ---: | ---: | ---: | ---: | | East | 560 | 750 | 240 | 1550 | | North | 480 | 2250 | 360 | 3090 | | South | 1520 | 750 | 450 | 2720 | | Total | 2560 | 3750 | 1050 | 7360 | The optional arguments control headers, totals, and sort order. `PERCENTOF` as the function gives each group's share, and a `LAMBDA` can be the function: | Formula | Result | | --- | --- | | `=GROUPBY(A1:A12, E1:E12, SUM, 0, 0, -2)` | North 3090, South 2720, East 1550 (no total, largest first) | | `=GROUPBY(A1:A12, E1:E12, PERCENTOF, 0, 0)` | East 0.2106, North 0.4198, South 0.3696 | | `=GROUPBY(B1:B12, C1:C12, LAMBDA(units, MAX(units)), 0, 0)` | Chair 10, Desk 5, Lamp 15 | A vector of functions, such as `HSTACK(SUM, AVERAGE)`, returns `#VALUE!`. Use one function per `GROUPBY`. ## Your own functions: LAMBDA, MAP, REDUCE, SCAN, BYROW `LAMBDA` makes a function from parameters and a calculation. The helpers call it for each value, row, or column. | Formula | Result | | --- | --- | | `=MAP(C1:C3, D1:D3, LAMBDA(units, price, units * price))` | 1000; 800; 480 (spills down) | | `=REDUCE(0, E1:E12, LAMBDA(total, x, IF(x >= 1000, total + x, total)))` | 2250 | | `=SCAN(0, E1:E4, LAMBDA(running, x, running + x))` | 1000; 1800; 2280; 2780 (running total) | | `=BYROW(C1:D3, LAMBDA(row, PRODUCT(row)))` | 1000; 800; 480 | | `=LET(big, LAMBDA(x, x >= 1000), FILTER(B1:B12, MAP(E1:E12, big)))` | Desk; Desk | `LET` can name a `LAMBDA` and call it later. An uncalled `LAMBDA` returns `#CALC!`, and a call with the wrong number of arguments returns `#VALUE!`. Calls nest up to 64 deep. ## Describe: statistics and ranking | Formula | Result | Meaning | | --- | --- | --- | | `=TRIMMEAN(E1:E12, 0.2)` | 587 | Mean revenue without the top and bottom 10% | | `=PERCENTILE.EXC(E1:E12, 0.9)` | 1175 | 90th percentile order | | `=RANK.AVG(E2, E1:E12)` | 3 | The South chair order is the third largest | | `=SKEW(E1:E12)` | 0.784 | Revenue has a tail of large orders | | `=FREQUENCY(E1:E12, {500, 1000})` | 6; 5; 1 | Orders up to 500, up to 1,000, and above | | `=MODE.MULT({4, 10, 6, 4, 15, 10, 8})` | 4; 10 | Every most frequent value | ## Forecast: regression Monthly revenue for six months was 1200, 1350, 1500, 1580, 1700, and 1850. | Formula | Result | | --- | --- | | `=FORECAST.LINEAR(7, {1200,1350,1500,1580,1700,1850}, {1,2,3,4,5,6})` | 1968 | | `=TREND({1200;1350;1500;1580;1700;1850}, {1;2;3;4;5;6}, {7;8;9})` | 1968; 2093.14; 2218.29 | | `=LINEST({1200;1350;1500;1580;1700;1850}, {1;2;3;4;5;6})` | 125.14, 1092 (slope, intercept) | `LINEST` and `LOGEST` accept more than one predictor column, the `const` argument, and `stats = TRUE` for five rows of fit statistics. `TREND` and `GROWTH` project new values from the same fit. ## Estimate risk: distributions | Formula | Result | Meaning | | --- | --- | --- | | `=NORM.INV(0.95, 1000, 120)` | 1197.38 | Demand that covers 95% of days, with mean 1000 and deviation 120 | | `=BINOM.DIST(3, 10, 0.2, FALSE)` | 0.2013 | Chance of exactly 3 returns in 10 orders at a 20% return rate | | `=CONFIDENCE.T(0.05, STDEV.S(E1:E12), 12)` | 193.05 | Half-width of the 95% interval for mean revenue | The engine has 36 distribution functions: normal, Student t, chi-squared, F, binomial, Poisson, exponential, gamma, beta, lognormal, and Weibull, with their inverses. ## Plan money: finance | Formula | Result | Meaning | | --- | --- | --- | | `=XIRR(H1:H4, I1:I4)` | 0.3897 | Annual return of irregular cash flows (39%) | | `=XNPV(0.08, H1:H4, I1:I4)` | 1999.96 | Their value today at 8% a year | | `=PMT(0.06/12, 36, -25000)` | 760.55 | Monthly payment on a 25,000 loan over 3 years at 6% | | `=CUMIPMT(0.06/12, 36, 25000, 1, 12, 0)` | −1286.73 | Interest paid in the first year of that loan | The full engine also has `MIRR`, `NPER`, `CUMPRINC`, `EFFECT`, `NOMINAL`, and depreciation functions (`SLN`, `DB`, `DDB`, `SYD`). ## Schedule: working days and dates | Formula | Result | | --- | --- | | `=NETWORKDAYS.INTL(DATE(2026,1,1), DATE(2026,1,31), 1)` | 22 working days | | `=WORKDAY.INTL(DATE(2026,1,30), 5, "0000011")` | 46059, that is 2026-02-06 | | `=DATEDIF(DATE(2024,3,15), DATE(2026,1,5), "m")` | 21 full months | | `=ISOWEEKNUM(DATE(2026,1,5))` | 2 | The weekend argument is a number or a seven-character mask that starts on Monday: `"0000011"` means Saturday and Sunday. A holiday range is the optional last argument. ## Clean text: split, extract, and regex | Formula | Result | | --- | --- | | `=TEXTSPLIT("North,South,East", ",")` | North, South, East (spills across) | | `=TEXTBEFORE(G1, ";")` | Ref INV-1042 | | `=TEXTAFTER(G1, "; ")` | ship by Friday | | `=REGEXEXTRACT(G1, "INV-[0-9]+")` | INV-1042 | | `=REGEXREPLACE(G4, "[0-9]{3}-[0-9]{4}", "***-****")` | Ref INV-1045; call \*\*\*-\*\*\*\* | | `=REGEXTEST(G6, "ship by")` | TRUE | | `=ARRAYTOTEXT({"North", "South"})` | North, South | Regex uses the regex-lite syntax. Patterns with lookaround, backreferences, or Unicode classes return `#VALUE!`, never a wrong match. Case-insensitive matching folds ASCII letters only. ## Reshape arrays | Formula | Result | | --- | --- | | `=VSTACK({"Region","Units"}, HSTACK(A1:A3, C1:C3))` | A header row on top of North 4, South 10, North 6 | | `=TAKE(SORTBY(HSTACK(B1:B12, E1:E12), E1:E12, -1), 3)` | Desk 1250, Desk 1000, Chair 800 | | `=WRAPROWS(SEQUENCE(6), 3)` | 1, 2, 3 / 4, 5, 6 | | `=TOCOL({1,2;3,4})` | 1; 2; 3; 4 | | `=EXPAND({1,2;3,4}, 3, 3, 0)` | The 2 × 2 array padded to 3 × 3 with 0 | `SORTBY` keeps equal items in their order. `TOCOL` and `TOROW` can skip blanks and errors and read by row or by column. ## Query tables: database functions Database functions take a table with a header row, a field, and a criteria table with the same headers. Criteria columns use AND, and criteria rows use OR. | Formula | Result | | --- | --- | | `=DSUM({"Region","Units";"North",4;"South",10;"North",6}, "Units", {"Region";"North"})` | 10 | | `=DAVERAGE({"Region","Units";"North",4;"South",10;"North",6}, "Units", {"Region";"North"})` | 5 | In a workbook, the table and the criteria are usually ranges, such as `=DSUM(A1:E200, "Revenue", H1:H2)`. ## Matrices | Formula | Result | | --- | --- | | `=MMULT({1,2;3,4}, {5;6})` | 17; 39 | | `=MINVERSE({4,7;2,6})` | 0.6, −0.7 / −0.2, 0.4 | | `=MDETERM({4,7;2,6})` | 10 | | `=SUMSQ(C1:C3)` | 152 | ## Limits - All these functions use the normal spill, shape, memory, and work limits. A result that does not fit returns an error, not a partial result. - `AGGREGATE` options that skip hidden rows have no effect, because formulas do not see hidden-row state. - Each family's exact argument rules are in the [formula function contract](https://sheetwrite.vercel.app/docs/reference/formula-functions/). ### Collaboration and offline work Source: https://sheetwrite.vercel.app/docs/guides/collaboration/ Sheetwrite provides transport-, database-, and authentication-neutral collaboration primitives. The host still owns server sequencing, durable document storage, user identity, authorization, and network lifecycle. ## Load, mount, queue, and acknowledge Load a validated snapshot before mounting, then let one `SyncCoordinator` observe local grid transactions. Do not also save `event.changes`: that list is cell-oriented rollback detail and omits document metadata operations. ```ts import { createGridFromSnapshot, SyncCoordinator, type PersistenceAdapter, type RemoteOperationSource, } from "@sheetwrite/core"; const documentId = "workbook-42"; const snapshot = await persistenceAdapter.load(documentId); const grid = createGridFromSnapshot(document.querySelector("#grid")!, snapshot); const sync = new SyncCoordinator(grid, persistenceAdapter, { documentId, serverVersion: snapshot.version ?? 0, }); sync.on((event) => { if (event.type === "acknowledged") { console.log(`server version ${event.version} acknowledged ${event.clientMutationId}`); } else if (event.type === "conflict") { console.error("conflict retained for host recovery", event.mutation); } }); await sync.ready(); const unsubscribeRemote = sync.subscribe(remoteOperationSource); saveButton.addEventListener("click", () => { void sync.flush(); // sends in order; each response acknowledges only its mutation ID }); // Teardown: // unsubscribeRemote(); // sync.destroy(); // grid.destroy(); ``` `SyncCoordinator` subscribes to `Grid` changes itself. Local rendering remains optimistic; the host controls when to call `sendNext`, `flush`, or `retry`. `applied` and `duplicate` responses acknowledge the matching operations and remove that mutation. A transport error leaves the immutable record pending. An HTTP adapter can stay transport-neutral at the core boundary: ```ts import type { PersistenceAdapter, PersistenceBatchCommitRequest, PersistenceCommitRequest, PersistenceCommitResponse, WorkbookSnapshot, } from "@sheetwrite/core"; async function responseJson(response: Response): Promise { if (!response.ok) throw new Error(`Persistence request failed: ${response.status}`); return (await response.json()) as T; } const persistenceAdapter: PersistenceAdapter = { async load(documentId, signal) { const response = await fetch(`/api/documents/${encodeURIComponent(documentId)}`, { signal }); return responseJson(response); }, async commit(request: PersistenceCommitRequest) { const { signal, ...body } = request; const response = await fetch( `/api/documents/${encodeURIComponent(request.documentId)}/operations`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal, }, ); return responseJson(response); }, // Required for undo of very large edits; see "Large undo and atomic batches". async commitBatch(request: PersistenceBatchCommitRequest) { const { signal, ...body } = request; const response = await fetch( `/api/documents/${encodeURIComponent(request.documentId)}/operation-batches`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal, }, ); return responseJson(response); }, }; ``` The server must authenticate the request, authorize every operation, validate the document schema/policy, and assign the version. Client protection metadata is not authorization. ## Database-neutral server model A snapshot plus append-only operation log is the default persistence shape: ```sql documents( document_id primary key, current_version integer not null, snapshot_version integer not null, snapshot_json json not null ) document_operations( document_id not null, version integer not null, client_mutation_id text not null, operations_json json not null, created_at server_timestamp not null, primary key(document_id, version), unique(document_id, client_mutation_id) ) ``` Handle `commit()` in one database transaction: 1. Return `duplicate` with the existing version when `(document_id, client_mutation_id)` already exists. 2. Lock/read the document version. If it differs from `baseVersion`, return `conflict` with ordered operations since base or a current snapshot. 3. Validate and authorize the submitted `DocumentOp[]` on the server. 4. Append at `current_version + 1`, update the document version, and return `applied` with the same mutation ID. 5. Periodically fold the log into `snapshot_json`; never rewrite operation versions or accept client-authored server timestamps. Handle `commitBatch()` the same way, with one difference: the request has `versionOperationCounts`, and it fills several consecutive versions. In one database transaction, check the duplicate and the base version once, validate every version, then append all of them or none. Store each version with its `batch: { index, count }` position, and acknowledge with the last version. Normalized cell tables are an alternative for products that need SQL queries over cell values. They do not replace the protocol: the host must still reconstruct a complete schema-versioned `WorkbookSnapshot`, preserve formula source/style/metadata, and serialize structural operations under one document version. Mixing independent cell writes with the operation log breaks atomic row/column/formula semantics. ## Large undo and atomic batches Undo of a large clear restores the old cells. When the ordinary `setBlock` operation is too large for one version, the Grid sends one `restoreBlock` operation instead: the same cells as compressed JSON (`deflate-json-v1`). If even that does not fit one version, the Grid splits the restore into an atomic batch of up to 16 versions and 64 MiB. Undo stays one history step and one `change` event. Before you accept 0.5.0 clients, make sure that your server: 1. accepts `restoreBlock` wherever it switches over the `DocumentOp` union, and decodes it as untrusted input. `@sheetwrite/core` does not export a server decoder, so your decoder must do these checks, in this order, before it changes the document: - `encoding` is `"deflate-json-v1"`, and `decodedBytes` is a positive integer of at most 64 MiB; - `data` is canonical base64 (it decodes and re-encodes to the same text); - the raw DEFLATE stream inflates into a buffer of exactly `decodedBytes` bytes. Never inflate without that output limit: a small stream can expand to gigabytes. Reject a stream that ends early or has bytes left over; - the bytes are valid UTF-8 JSON of a `PackedCellBlock` whose row and column counts match `range`, with at most 4,000,000 cells; - every value, formula, reference, and style in the block passes the same validation as a `setBlock` operation; 2. implements `commitBatch` as above, or accepts that `SyncCoordinator` rejects a local step that needs more than one version; 3. publishes batch members with their `batch` field, so that peers apply the whole batch at once; and 4. folds `restoreBlock` into snapshots as ordinary cells. Snapshots never store restore streams. The [document operations reference](https://sheetwrite.vercel.app/docs/reference/document-operations/#atomic-multi-version-batches) has the exact field rules, limits, and error codes. When an undo is above the transaction limits even after this, the Grid changes nothing and reports it; see [Interaction](https://sheetwrite.vercel.app/docs/guides/interaction/#undo-and-redo). ## Durable pending commits `SyncCoordinator` can receive a `PendingCommitStorage` adapter. With one configured, it: 1. persists each immutable commit before it can be sent; 2. restores pending commits on startup and reapplies them locally as remote-source operations, so restoration does not echo into the outgoing queue; 3. retries with the original `clientMutationId` after reconnect or reload; 4. removes durable work only after an `applied` or `duplicate` acknowledgement; and 5. retains conflicts and storage failures for explicit host recovery. Await `coordinator.ready()` before declaring startup synchronized. Use `initialConnection: "offline"` for offline-first startup, `setOnline(false)` on disconnect, and `setOnline(true)` to reconnect and drain pending work in order. `coordinator.state` distinguishes connection state from hydration, persistence, sending, conflict, and error activity. The browser IndexedDB implementation is intentionally isolated from Node and SSR entrypoints: ```ts import { SyncCoordinator } from "@sheetwrite/core"; import { IndexedDbPendingCommitStorage } from "@sheetwrite/core/browser"; const pendingStorage = new IndexedDbPendingCommitStorage({ databaseName: "my-product-sheetwrite", }); const sync = new SyncCoordinator(grid, persistenceAdapter, { documentId: "workbook-42", serverVersion: snapshot.version ?? 0, pendingStorage, initialConnection: navigator.onLine ? "online" : "offline", }); await sync.ready(); window.addEventListener("online", () => sync.setOnline(true)); window.addEventListener("offline", () => sync.setOnline(false)); ``` IndexedDB record migration is versioned. Unsupported future schemas, blocked upgrades, quota exhaustion, transaction failures, and aborts surface as typed `IndexedDbPendingCommitStorageError` codes. Core continues to work without IndexedDB when no durable adapter is supplied. ## Remote operations and version gaps `SyncCoordinator.subscribe` accepts either `RemoteOperationSource` or `AsyncIterable`. Operations apply only at `serverVersion + 1`; own echoed mutation IDs and already acknowledged IDs are deduplicated. Remote application uses `Grid.applyRemoteOperations`, so it does not create outgoing dirty work or local undo entries. Remote row and column inserts and deletes move existing undo entries, so a later undo restores the cell the user edited. A gap emits `reload-required`. Prefer fetching missing ordered operations through `recoverVersionGap`; the coordinator applies them in sequence. A returned snapshot is a host remount signal, not an automatic store replacement. `resumeAfterReload(snapshot)` updates retained queue base versions only after the host has installed that snapshot and reapplied local work; it does not hydrate a grid or attach the coordinator to a newly created grid. ### Conflict and snapshot reload Never acknowledge or discard a conflicted mutation implicitly. First call `sync.recoverConflict()`. It recovers on the live Grid, without a reload, when the server returns `operationsSinceBase`, the pending work and the server versions it did not see change different cells and sheets, and neither side inserts, deletes, or moves rows or columns. It applies the missed versions, moves the queue onto the server head, and emits `recovered`; call `sync.flush()` to resend: ```ts import type { PersistenceCommitResponse, SyncConflictReloadReason } from "@sheetwrite/core"; /** Host fallback: the rebase-and-remount recipe below. */ declare function reloadSession( response: Extract, reason: SyncConflictReloadReason, ): Promise; sync.on((event) => { if (event.type !== "conflict") return; void sync.recoverConflict().then((result) => { if (result.status === "recovered") return sync.flush(); if (result.status === "reload-required") return reloadSession(event.response, result.reason); }); }); ``` Otherwise the result is `reload-required` with a reason and nothing changes. If the server returns both `operationsSinceBase` and a current snapshot, the host can then gate every pending transaction through the conservative rebaser, clear the old durable IDs, remount, and submit the safe results as new mutations: ```ts import { createGridFromSnapshot, rebaseDocumentOperations, SyncCoordinator, type Grid, type PersistenceCommitResponse, } from "@sheetwrite/core"; type ConflictResponse = Extract; interface SyncSession { grid: Grid; sync: SyncCoordinator; unsubscribeRemote: () => void; } async function reloadAfterConflict( session: SyncSession, response: ConflictResponse, ): Promise { if (!response.operationsSinceBase) { console.error("Manual conflict review required: server did not return an operation tail"); return session; } // Freeze local edits and let queued writes finish before reading the queue. session.grid.setReadOnly(true); await session.sync.ready(); const pending = session.sync.pendingCommits(); const remoteOperations = response.operationsSinceBase.flatMap((entry) => [ ...entry.operations, ]); const safeBatches: Array & { status: "rebased"; }> = []; for (const record of pending) { const result = rebaseDocumentOperations(record.operations, remoteOperations); if (result.status === "conflict") { console.error("Manual conflict review required", result.conflict); session.grid.setReadOnly(false); return session; // old queue and grid remain intact } safeBatches.push(result); } const latest = response.snapshot ?? (await persistenceAdapter.load(documentId)); for (const record of pending) { await pendingStorage.remove(documentId, record.clientMutationId); } session.unsubscribeRemote(); session.sync.destroy(); session.grid.destroy(); const grid = createGridFromSnapshot(document.querySelector("#grid")!, latest); const sync = new SyncCoordinator(grid, persistenceAdapter, { documentId, serverVersion: latest.version ?? 0, pendingStorage, }); await sync.ready(); // the stale IDs were removed before hydration for (const batch of safeBatches) { const outcome = grid.applyTransaction({ patches: [...batch.operations] }); if (outcome.status !== "applied") { throw new Error(`Rebased local transaction was not applied: ${outcome.status}`); } } const unsubscribeRemote = sync.subscribe(remoteOperationSource); await sync.flush(); return { grid, sync, unsubscribeRemote }; } ``` When the rebaser reports `conflict`—overlap, formulas across structural changes, concurrent moves, or sheet lifecycle ambiguity—keep the original queue and show product-specific conflict UI. If the server returns only a snapshot, the host cannot infer a safe transform; offer explicit discard/export/manual re-entry instead. Reloading a snapshot over pending edits without this decision loses intent. Two ordering rules keep recovery from losing work: - Freeze the Grid and await `sync.ready()` before reading `pendingCommits()`. An edit made while recovery awaits would be neither rebased nor removed, and a queue write still in flight could land after its removal. Either comes back on the remounted client with a stale base version. - Keep remote operations that arrive between loading `latest` and `sync.subscribe(...)`, and deliver them to the new coordinator. Drop versions at or below `latest.version`, which the snapshot already contains. A transport that only forwards to the current subscriber loses them, and the client then waits on a version gap that never closes. ## Presence `PresenceCoordinator` publishes actor metadata, active sheet, and bounded selection ranges through a host `PresenceTransport`. Presence: - is never written to document operations, snapshots, dirty state, or history; - expires according to local heartbeat receipt time; - is bounded by actor and range limits; - clamps ranges to valid visible grid geometry and hides other-sheet or filtered selections; - reuses the grid overlay element pool rather than mutating canvas cells; and - supports privacy controls for display name, selections, inbound presence, and actor filtering. Presence is advisory UI state. Do not use it for authorization, locking, or conflict prevention. ## Revisions `RevisionCoordinator` delegates revision listing, loading, and restore to a `RevisionAdapter`. Preview snapshots pass through the optional host schema migrator and mount with `readOnly: true`. Restore requests include `targetVersion`, current `baseVersion`, and a stable mutation ID. The adapter contract requires restore to create a newer auditable server version; it must never rewind database state in place. ## Comments Comments are document-adjacent server entities, not fields on every cell. `CommentCoordinator` stores stable thread/message IDs and range or cell anchors, while adapters supply authenticated author references and server timestamps. Create, reply, and resolve requests contain no client-authored identity or time fields. Authorization remains entirely host/server-owned. Comment streams are versioned independently. Out-of-order events emit a gap rather than being applied speculatively. ## Concurrent structural editing decision Sheetwrite uses deterministic server ordering plus conservative rebase, not a CRDT or general OT layer. `rebaseDocumentOperations` safely shifts non-overlapping literal edits and references across server-ordered row/column insertions and deletions. It reports explicit conflicts for: - edits or range pastes overlapping inserted/deleted boundaries; - targets or references deleted by a concurrent operation; - formula source crossing structural edits, because text-only rewriting cannot preserve sheet-aware reference intent; - concurrent row/column moves; - conflicting sheet lifecycle operations; and - metadata families that need domain-specific transforms. This choice preserves stable sheet IDs and authoritative formula source without lossy adapters. A CRDT/OT dependency is not justified by current scenarios: short-lived offline and online collaboration converge under server sequencing, while long-lived offline concurrent structural edits require product-level conflict UX and spreadsheet-specific formula/range semantics that generic sequence CRDTs do not supply. Revisit only if requirements demand automatic preservation of those ambiguous edits and scenario tests define the intended result for every structural/formula case. ### Configuration Source: https://sheetwrite.vercel.app/docs/guides/configuration/ Everything you pass to `createGrid(host, opts)` lives in `GridOptions`. This page lists every field with its type and default, the optional toolbar `GridConfig`, and the `Grid` instance API. ## GridOptions ```ts type CreateGrid = (host: HTMLElement, opts: GridOptions) => Grid; ``` | Field | Type | Default | Notes | | --- | --- | --- | --- | | `workbook` | `Workbook` | — (required) | Sheets, columns, row counts, and the `activeSheet` id. | | `presentation` | `"spreadsheet" \| "data-grid"` | `"spreadsheet"` | Positional A/B/C headers or semantic `Column.header` labels. Addressing and data rows are unchanged. | | `data` | `ColumnarData` | `undefined` | Eager, in-memory, column-major values. Pass this **or** `datasource`. | | `datasource` | `DataSource` | `undefined` | Lazy, paged async source; rows fetched per visible window. | | `datasourceStorage` | `DataSourceStorageOptions` | `{ mode: "dense" }` | Use `{ mode: "paged", chunkRows?, cacheBytes? }` for allocation-lazy datasource storage. | | `renderer` | `"canvas" \| "worker"` | `"canvas"` | `"worker"` paints off-thread via OffscreenCanvas, falling back to canvas. See [Worker rendering](https://sheetwrite.vercel.app/docs/guides/worker-rendering/). | | `workerUrl` | `string \| URL` | `undefined` | Browser-fetchable module URL, e.g. `"/sheetwrite/worker.js"` after copying the package `dist/`; Vite can import `@sheetwrite/core/worker?worker&url`. See [Worker rendering](https://sheetwrite.vercel.app/docs/guides/worker-rendering/). | | `theme` | `Partial` | `undefined` | Overrides merged over `DEFAULT_THEME` and any `--sheetwrite-*` CSS vars. See [Styling](https://sheetwrite.vercel.app/docs/guides/styling/). | | `readOnly` | `boolean` | `false` | When `true`, all mutating interactions (edit, clear, fill, paste, restyle) are disabled and the host gets `aria-readonly="true"`. | | `protectionResolver` | `ProtectionResolver` | `undefined` | Host callback for protected local mutations; no resolver means deny. Client-side UX policy only, never server authorization. | | `mutationPolicy` | `"atomic" \| "partial"` | `"atomic"` | Reject the whole local transaction on a denied protected operation, or apply allowed operations and report denied ones. | | `renderers` | `Record` | `{}` | Custom cell renderers registered up front; reference one by name via `Column.renderer`. Also see `defineCellRenderer`. | | `editors` | `Record` | `{}` | Host-owned cell editors registered up front; reference one by name via `Column.editor`. | | `overscan` | `number` | `6` | Row and visible-column positions painted on each viewport edge; `0` disables the buffer. | | `minColumns` | `number` | workbook width | Minimum rendered/store column count. A spreadsheet also adds empty columns to fill the host width; a `data-grid` shows only its own columns unless you set `minColumns`. | | `config` | `GridConfig` | `undefined` | Presence opts into the built-in toolbar (see below). Omit for no toolbar. | Framework adapters classify every `GridOptions` field centrally. `workbook`, `data`, `datasource`, `datasourceStorage`, `presentation`, `editors`, `protectionResolver`, `mutationPolicy`, and `transactionResourceLimits` create an `input-reset`; `renderer`, `workerUrl`, and `renderers` create a `renderer-reset`. `theme`, `readOnly`, `config`, `overscan`, and `minColumns` update the existing grid live. Readiness includes the resulting generation and reset reason. Framework adapters also accept `wasmSource`. Initialization is process-wide and first-source-wins: concurrent calls using the same source share one attempt, while a different source rejects until that attempt settles. If the winning attempt succeeds, every still-mounted adapter becomes ready even when its prop changed during the attempt. Changing `wasmSource` after readiness warns and keeps the live grid, selection, edits, generation, and ready-event count unchanged. A true initialization failure remains observable through `onInitializationError` and a later source can retry it. ## Spreadsheet and data-grid presentation `presentation: "spreadsheet"` is the backward-compatible default. It paints and announces positional column headers (`A`, `B`, `C`, …). Use `presentation: "data-grid"` when the columns describe row-object fields: ```ts const workbook: Workbook = { activeSheet: "people", sheets: [{ id: "people", name: "People", rowCount: people.length, columns: [ { key: "name", header: "Customer name", width: 220, type: "text" }, { key: "status", header: "Account status", width: 160, type: "text" }, ], }], }; const grid = createGrid(host, { workbook, data, presentation: "data-grid", }); ``` The semantic header occupies the same header band as `A/B/C`; it does **not** consume row 0. Cell addresses, formulas, selections, row numbers, clipboard payloads, CSV/XLSX schema labels, mutation events, and datasource ranges keep their existing zero-based data coordinates. Table exports use `Column.header` in both presentation modes. The data-first `Sheetwrite` components select `"data-grid"` automatically because every `SimpleColumn` requires a `title`. Presentation is construction-bound. Changing it through a framework adapter replaces the grid with `reason: "input-reset"` rather than renaming columns or moving data in place. ## Host-supplied editors Set `Column.editor` to a key in `GridOptions.editors`. A custom editor takes precedence over the built-in validation-list editor. If the key is absent, Sheetwrite falls back to validation editing or its stock text/date editor. ```ts const statusEditor: CellEditor = { mount(host, context) { const select = document.createElement("select"); select.setAttribute("aria-label", context.label); for (const value of ["Prospect", "Active", "Paused"]) { select.add(new Option(value, value)); } select.value = context.text; host.appendChild(select); select.focus(); return { update(next) { select.setAttribute("aria-label", next.label); select.value = next.text; }, reposition(rect) { select.style.width = `${rect.width}px`; select.style.height = `${rect.height}px`; }, commit() { return select.value; }, cancel() {}, destroy() { select.remove(); }, }; }, }; const grid = createGrid(host, { workbook, data, presentation: "data-grid", editors: { status: statusEditor }, }); ``` The returned value is parsed with the column's normal type and committed through the same document transaction as stock editing. That preserves protection, validation, mutation policy, undo/redo, change events, and edit-commit events. An asynchronous commit remains bound to the data row captured at `mount`, even when sorting or `clearView()` moves that row before the promise settles. If a filter removes the row from the view, Sheetwrite cancels and aborts the pending edit instead of retargeting another row. Returning a rejected promise, `undefined`, or a non-string value from untyped JavaScript cancels without a mutation. Enter commits and moves down, Tab/Shift+Tab commit and move horizontally, and Escape cancels. Focus returns to the grid after commit or cancel. For remote choices, use the editor-owned `AbortSignal`; never let a late request write into a destroyed editor: ```ts const assigneeAutocomplete: CellEditor = { mount(host, context) { const input = document.createElement("input"); const list = document.createElement("datalist"); list.id = `assignees-${context.address.row}-${context.address.col}`; input.setAttribute("list", list.id); input.setAttribute("role", "combobox"); input.setAttribute("aria-label", context.label); input.value = context.initialInput ?? context.text; host.append(input, list); input.focus(); void fetch(`/api/people?q=${encodeURIComponent(input.value)}`, { signal: context.signal, }) .then((response) => response.json() as Promise>) .then((people) => { if (context.signal.aborted) return; list.replaceChildren( ...people.map((person) => { const option = document.createElement("option"); option.value = person.name; option.dataset.id = person.id; return option; }), ); }) .catch((error: unknown) => { if (!(error instanceof DOMException && error.name === "AbortError")) throw error; }); return { update(next) { input.setAttribute("aria-label", next.label); }, reposition(rect) { input.style.width = `${rect.width}px`; }, async commit() { await Promise.resolve(); return input.value; }, cancel() {}, destroy() { input.remove(); list.remove(); }, }; }, }; ``` One retained wrapper and one `CellEditorInstance` exist per active edit: | Hook / value | Guarantee | | --- | --- | | `mount(host, context)` | Runs once. The host is positioned over the active cell. | | `context.address` / `viewAddress` | Canonical data-row and current displayed-row snapshots. They are readonly and mutating a JavaScript object received by the editor cannot retarget the edit. | | `context.value` / `text` / `initialInput` | Resolved scalar, formatted text, and optional typed character. | | `context.label` | Accessible name derived from semantic/positional header plus row number. | | `context.signal` | Aborted before the editor's `cancel()` or `destroy()` hook on cancel, commit, reset, or unmount. | | `context.commit()` / `cancel()` | Optional editor-driven completion using the canonical path. A non-string commit from untyped JavaScript cancels safely. | | `update(context)` | External value, theme, zoom, view permutation, or geometry-sensitive state changed without replacing ownership. | | `reposition(rect)` | The active cell moved or resized. | | `commit()` | May return input synchronously or asynchronously; duplicate completion is ignored. | | `cancel()` | Notification before a host-requested cancellation; the signal is already aborted. | | `destroy()` | Runs exactly once; late async work must observe the aborted signal. A thrown hook error is reported without interrupting Sheetwrite's DOM, listener, or store cleanup. | `editors` is construction-bound so React/Vue/Svelte resets safely abort and destroy an active editor before publishing the new grid generation. All adapters accept the same registry: ```tsx setCommandStates(states)} /> ``` ```vue ``` ```svelte commandStates = event.states} /> ``` ### Datasource pages The cancellable request API owns one visible-window generation. Pages can carry authoritative formula source and style rather than only resolved scalars: ```ts import type { DataSource } from "@sheetwrite/core"; interface ApiRow { label: string; quantity: number; price: number; } const datasource: DataSource = { async getRows({ sheet, start, end, signal, revision }) { const response = await fetch( `/sheets/${encodeURIComponent(sheet)}?start=${start}&end=${end}`, { signal }, ); if (!response.ok) throw new Error(`Datasource request failed: ${response.status}`); const records = (await response.json()) as ApiRow[]; return { start, revision: response.headers.get("etag") ?? revision, rows: records.map((record, index) => ({ label: record.label, quantity: record.quantity, price: record.price, total: { value: { kind: "formula", src: `=B${start + index + 1}*C${start + index + 1}`, }, style: { numberFormat: "$#,##0.00", bold: true }, }, })), }; }, }; ``` `rows` may contain scalars, `CellValue` objects, or `{ value: CellValue, style?: CellStyle }` wrappers. Hydrated formulas, references, and styles do not emit user change events. Short pages mark only the returned rows loaded; malformed ranges emit `datasource-error` and remain retryable. Resetting or destroying the grid aborts outstanding requests, and a late page never overwrites a cell edited after that request began. Dense storage is the default. `{ mode: "paged" }` allocates power-of-two row chunks only for loaded or locally edited areas; `cacheBytes` bounds clean cached chunks, while dirty chunks remain pinned until acknowledgement. Full-sheet queries and exports report incomplete data until all required pages are loaded. `Store.queryCapability(sheet)` and `getCellLoadState(addr)` expose that state. Default chunk/cache values and eviction behavior are listed in [Compatibility and limits](https://sheetwrite.vercel.app/docs/reference/compatibility-limits/#rendering-interaction-and-paged-data). #### Cells that visible formulas read A visible formula can read cells outside the visible window, for example a variance column that reads a plan column far to the right. The Grid asks the engine which same-sheet cells the painted formulas read, also through other formulas, and requests those cells with the window. This includes frozen bands, overscan, and merge anchors reached by the paint plan. The formula then resolves instead of staying `#LOADING!`. One shared 32,768-cell budget bounds dependency loading across these roots, so a whole-column read does not load the full column in one window. The painted cells and their bounded dependency closure remain protected from clean-cache eviction while visible. Bulk reads such as exports and search use separate, transient protection and do not replace the viewport's pins. Reads from other sheets are not loaded this way; return those values from the datasource or keep that sheet dense. ### Serializable documents Use `WorkbookSnapshot` plus `validateWorkbookSnapshot()` at persistence boundaries. `schemaVersion: 1` rejects unsupported future schemas with structured errors. `DocumentOp` is the exhaustive reducer operation union, including metadata and sheet lifecycle operations. Session-only grid options such as `renderer`, `readOnly`, local zoom, selection, scroll, search, and temporary highlights never belong in a snapshot. `createGridFromSnapshot(host, snapshot, options)` validates and hydrates every sheet before mounting. Hydration emits no change event or undo entry. `grid.exportSnapshot()` uses bulk sheet reads and returns deterministic sparse blocks. `grid.applyRemoteOperations(operations)` emits a change with `source: "remote"` while remaining outside local undo history and `SyncCoordinator`'s outgoing queue. `SyncCoordinator` queues each non-empty local transaction as an immutable mutation record. Its `serverVersion` option is required and should come from the loaded snapshot. `sendNext()` and `retry(id)` are host-controlled; retries retain the original ID. `subscribe(source)` accepts a transport-neutral callback source and validates strict version order. Use `MemoryPersistenceAdapter` as an executable, server-sequenced reference—not as durable storage. Adapter methods accept `AbortSignal`; transport failures use `PersistenceError`, while version conflicts are typed commit responses that retain local work. A `CellRenderer` paints or retains a DOM node for each cell in the rendered window. When `dom` is present, it owns the cell content (the canvas still paints the cell background, border, headers, and grid lines): ```ts interface CellRenderer { canvas?(ctx: CanvasRenderingContext2D, c: CellPaintContext): void; dom?(c: CellPaintContext): HTMLElement; update?(element: HTMLElement, c: CellPaintContext): void; destroy?(element: HTMLElement): void; } ``` `dom` creates an element when a cell enters the bounded rendered window. `update` receives that same element after values, styles, theme, zoom, size, or scroll geometry change. `destroy` runs immediately before the element leaves the window, is replaced by a newly registered renderer, or its grid is reset or destroyed. Implement `update` to preserve focus and element-local state. A legacy renderer with only `dom` is recreated when its value, style, theme, or size changes, but not for a pure scroll. DOM cells are clipped to the viewport and frozen pane that owns them. A merged range produces one node for its anchor, not one node per covered cell. Plain renderer output stays hidden from assistive technology because the compact ARIA mirror already exposes its cell value. To make a renderer explicitly interactive, return a native control (or add a non-negative `tabindex`), give it an accessible name, and set `element.style.pointerEvents = "auto"`. Keyboard events from that control stay with the control instead of moving the grid. With `renderer: "worker"`, `canvas` hooks cannot cross the Worker boundary. `dom`, `update`, and `destroy` still run on the main thread in the retained overlay. ## GridConfig (toolbar) Set `config` to show the built-in toolbar. Each flag toggles one control; all flags **default to `true`** when `config` is present. The one special case is `toolbar: false`, which suppresses the toolbar entirely. ```ts createGrid(host, { workbook, config: {} }); // toolbar with every control createGrid(host, { workbook, config: { sort: false } }); // toolbar, no sort control createGrid(host, { workbook, config: { toolbar: false } }); // no toolbar createGrid(host, { workbook }); // no toolbar (config omitted) ``` | Flag | Type | Default | Control | | --- | --- | --- | --- | | `toolbar` | `boolean` | `true`¹ | Master switch. `false` removes the toolbar. | | `bold` | `boolean` | `true` | Bold toggle. | | `italic` | `boolean` | `true` | Italic toggle. | | `align` | `boolean` | `true` | Left / center / right alignment. | | `textColor` | `boolean` | `true` | Text color picker. | | `fillColor` | `boolean` | `true` | Fill (background) color picker. | | `border` | `boolean` | `true` | Border control. | | `clearFormat` | `boolean` | `true` | Clear formatting. | | `merge` | `boolean` | `true` | Merge / unmerge selection. | | `sort` | `boolean` | `true` | Sort the selected column. | | `export` | `boolean` | `true` | CSV / XLSX export buttons. | | `undo` | `boolean` | `true` | Undo / redo buttons (also bound to Ctrl+Z / Ctrl+Shift+Z). | ¹ "Default `true`" means: when you supply a `config` object at all. With no `config` there is no toolbar. The export flag always enables CSV. Its XLSX button requires an explicit optional installation and `import "@sheetwrite/xlsx/register"` before use; the framework packages do not install an XLSX backend. Direct `grid.exportXlsx(...)` calls reject on failure. Built-in toolbar and context-menu actions show an alert and emit one `export-error` event for CSV or XLSX failures. `grid.actions.exportXlsx()` returns a promise that does not reject. Built-in buttons show a busy state and ignore repeated starts while their command is pending. Every control acts on the current selection — see [Interaction](https://sheetwrite.vercel.app/docs/guides/interaction/). ### Host-owned command state Host chrome can use `grid.actions` without duplicating selection/history logic. Query one command with `grid.getCommandState(name)` or subscribe to the complete snapshot through `command-state-change` (`onCommandStateChange` in React/Svelte, `@command-state-change` in Vue): ```ts const update = (bold: GridCommandState, undo: GridCommandState) => { undoButton.disabled = undo.disabled; boldButton.disabled = bold.disabled; boldButton.setAttribute( "aria-pressed", bold.activity === "mixed" ? "mixed" : String(bold.activity === "active"), ); }; update(grid.getCommandState("bold"), grid.getCommandState("undo")); const stop = grid.on("command-state-change", ({ states }) => { update(states.bold, states.undo); }); boldButton.addEventListener("click", () => grid.actions.toggleBold()); undoButton.addEventListener("click", () => grid.actions.undo()); // Run during host teardown. stop(); ``` `disabled` accounts for read-only mode, empty selections, and undo/redo history. Formatting commands report `activity: "inactive" | "active" | "mixed"` across the current selection. The built-in toolbar uses the same contract, including native `disabled` and `aria-pressed="mixed"`. Aggregation is bounded; very large selections conservatively report `mixed` rather than forcing an unbounded cell walk. ### Feature flags Three `GridConfig` fields enable behavior that lives outside the toolbar row: | Field | Type | Default | Effect | | --- | --- | --- | --- | | `find` | `boolean` | `true` | Built-in **Ctrl+F** search box, next/previous controls, and live match count. | | `contextMenu` | `boolean \| ContextMenuItems` | `true` | Built-in right-click menu, disabled menu, static readonly rows, or a context-aware row factory. | | `icons` | `Partial>` | `undefined` | Built-in toolbar icon overrides. Strings render as text; a DOM `Node` or `() => Node` supports SVG/HTML without `innerHTML`. | ### Context-menu items The default menu contains copy, cut, paste, clear contents, row insert/delete/hide/show/auto-fit, column insert/delete/hide/show/auto-fit, clear column filter, merge, and unmerge actions, with separators between groups. `exportCsv` and `exportXlsx` are also valid built-in actions in a custom list. `ContextMenuItems` is either a `readonly ContextMenuItem[]` or a factory evaluated for each `ContextMenuContext`. An item may set a stable `id`, built-in `action`, visible `label`, `shortcut` hint, and static or context-aware `visible`/`disabled` policy. The lean context carries only `cell`, `clientX`, and `clientY`. `onClick(grid, cell)` overrides `action`; hidden separators are normalized. ```ts const config = { contextMenu: (context) => [ { id: "copy", action: "copy", label: "Copy value", shortcut: "copy" }, { action: "separator" }, { id: "inspect", label: "Inspect cell", visible: context.cell !== null, disabled: ({ cell }) => cell === null, onClick(grid, cell) { if (cell !== null) console.log(grid.store.getCell(cell)); }, }, { action: "exportCsv", label: "Download CSV" }, ], } satisfies GridConfig; ``` For fully host-owned UI, set `contextMenu: false`, listen for the DOM `contextmenu` event on the host, call `grid.getCellAtPoint(event.clientX, event.clientY)`, update selection, and invoke `grid.actions` from the host menu. Sheetwrite does not prescribe the host's event lifecycle: ```ts host.addEventListener("contextmenu", (event) => { event.preventDefault(); const cell = grid.getCellAtPoint(event.clientX, event.clientY); if (cell !== null) grid.setSelection({ kind: "cell", addr: cell }); host.dispatchEvent( new CustomEvent("sheetwrite:context-menu", { detail: { cell, x: event.clientX, y: event.clientY, actions: grid.actions }, }), ); }); ``` See [Interaction → Context menu](https://sheetwrite.vercel.app/docs/guides/interaction/#context-menu), [Styling](https://sheetwrite.vercel.app/docs/guides/styling/#widget-and-context-menu-styling), and the generated [`ContextMenuContext`](https://sheetwrite.vercel.app/docs/api/core/context-menu-context/), [`ContextMenuItems`](https://sheetwrite.vercel.app/docs/api/core/context-menu-items/), [`ContextMenuItem`](https://sheetwrite.vercel.app/docs/api/core/context-menu-item/), and [`Grid.getCellAtPoint`](https://sheetwrite.vercel.app/docs/api/core/grid/#getcellatpoint) contracts. Undo/redo and find are keyboard-driven and work without the toolbar: **Ctrl+Z** undoes the last edit, **Ctrl+Shift+Z** redoes it, and **Ctrl+F** opens the find widget (unless `find: false`). ## Grid instance `createGrid` returns an imperative handle: ```ts interface Grid { readonly store: Store; readonly actions: GridActions; setActiveSheet(id: SheetId): void; scrollToCell(addr: CellAddress): void; getSelection(): Selection | null; setSelection(sel: Selection | null): void; setTheme(theme: Partial): void; defineCellRenderer(name: string, renderer: CellRenderer): void; aggregate(col: number, op: AggregateOp): number; sortBy(col: number, ascending?: boolean): void; sortByMulti(keys: readonly SortKey[]): void; filterBy(col: number, needle: string): void; setColumnFilter(col: number, filter: ColumnFilter | null): void; getColumnFilters(): ReadonlyMap; distinctValues(col: number, limit?: number): CellScalar[]; hideRows(rows: readonly number[]): void; showRows(rows?: readonly number[]): void; hiddenRows(): readonly number[]; hideColumns(cols?: readonly number[]): void; showColumns(cols?: readonly number[]): void; hiddenColumns(): readonly number[]; groupRows(start: number, end: number): void; ungroupRows(start: number, end: number): void; setGroupCollapsed(start: number, collapsed: boolean): void; rowGroups(): readonly RowGroup[]; clearView(): void; undo(): void; redo(): void; exportCsv(filename: string): void; exportXlsx(filename: string): Promise; search(query: string, opts?: SearchOptions): SearchResult; findNext(): SearchResult; findPrev(): SearchResult; clearSearch(): void; replaceCurrent(replacement: string): SearchResult; replaceAll(replacement: string): ReplaceResult; insertRows(at: number, count?: number): void; removeRows(at: number, count?: number): void; insertColumns(at: number, count?: number): void; removeColumns(at: number, count?: number): void; highlightCells(ranges: readonly HighlightRange[] | null, color?: string): void; styleRange(range: Range, style: Partial | null): void; setValidationRule(rule: DataValidationRule): ApplyTransactionResult; removeValidationRule(id: string): ApplyTransactionResult; setProtectedRange(range: ProtectedRange): ApplyTransactionResult; removeProtectedRange(id: string): ApplyTransactionResult; setProtectionResolver(resolver?: ProtectionResolver, mode?: MutationPolicyMode): void; setNote(addr: CellAddress, text: string | null): ApplyTransactionResult; getNote(addr: CellAddress): string | null; beginEdit(row: number, col: number, initial?: string, selectAll?: boolean): void; dataEdge(row: number, col: number, dRow: number, dCol: number): number | null; setRowHeight(row: number, height: number): void; setColumnWidth(col: number, width: number): void; autoFitRows(range?: Range): void; autoFitColumns(cols?: readonly number[]): void; setFrozen(rows: number, cols?: number): void; setZoom(zoom: number): void; getZoom(): number; on(evt: E, fn: (e: GridEvents[E]) => void): () => void; refresh(): void; destroy(): void; } ``` Each name links to its entry in the [`Grid` API reference](https://sheetwrite.vercel.app/docs/api/core/grid/). ### Document and sheets | Method | Purpose | | --- | --- | | [`store`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-store) | The underlying [`Store`](https://sheetwrite.vercel.app/docs/concepts/runtime-ownership/#the-store-contract) — apply transactions, read cells, subscribe. | | [`actions`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-actions) | Imperative action surface (`toggleBold()`, `merge()`, `undo()`, `exportCsv()`, …) the toolbar and context menu bind to — use it to wire custom controls. | | [`setActiveSheet(id)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-set-active-sheet) | Switch the visible sheet. | | [`undo()`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-undo) / [`redo()`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-redo) | Undo or redo the last recorded cell edit (also bound to Ctrl+Z / Ctrl+Shift+Z). | | [`insertRows`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-insert-rows) / `removeRows` / `insertColumns` / `removeColumns` | Structural edits through undoable patches. | | [`styleRange(range, style)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-style-range) | Merge or clear store-backed cell styles across a range. | | [`setValidationRule`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-set-validation-rule) / `removeValidationRule` | Add, replace, or remove a serializable range validation rule. List and checkbox rules get accessible editors. | | [`setProtectedRange`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-set-protected-range) / `removeProtectedRange` / `setProtectionResolver` | Define protected-range metadata and host-owned local permission policy. This is not server authorization. | | [`setNote`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-set-note) / `getNote` | Set, clear, or read a serializable plain-text cell note. | | [`exportCsv`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-export-csv) / `exportXlsx` | Download the active data; see [Data operations](https://sheetwrite.vercel.app/docs/guides/data-operations/#export). | ### Selection, editing and search | Method | Purpose | | --- | --- | | [`getSelection()`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-get-selection) / `setSelection(sel)` | Read or set the current [`Selection`](https://sheetwrite.vercel.app/docs/guides/interaction/#selection-model) (`null` clears it). | | [`scrollToCell(addr)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-scroll-to-cell) | Scroll a cell into view. | | [`beginEdit(row, col, initial?, selectAll?)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-begin-edit) | Open the inline editor at a view cell. | | [`dataEdge(row, col, dRow, dCol)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-data-edge) | Ctrl+Arrow-style data-run jump target; vertical movement is view-aware under sort/filter. | | [`search(query, opts?)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-search) | Find matching cells; highlights them, emits `search`, returns a [`SearchResult`](#search). | | [`findNext()`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-find-next) / `findPrev()` | Step the active match forward / backward and scroll it into view. | | [`clearSearch()`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-clear-search) | Drop the current search and clear its highlights. | | [`replaceCurrent()`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-replace-current) / `replaceAll()` | Replace literal text/number matches through undoable transactions. | | [`highlightCells(ranges, color?)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-highlight-cells) | Highlight arbitrary ranges (`null` clears); `color` overrides the theme highlight. | ### Views: sort, filter, hide and group | Method | Purpose | | --- | --- | | [`aggregate(col, op)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-aggregate) | Column aggregate; see [Data operations](https://sheetwrite.vercel.app/docs/guides/data-operations/#aggregate). | | [`sortBy`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-sort-by) / `sortByMulti` | Non-mutating display sorts; see [Data operations](https://sheetwrite.vercel.app/docs/guides/data-operations/#display-views). | | [`filterBy`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-filter-by) / `setColumnFilter` / `getColumnFilters` | Column filters that compose with sort, hidden rows, and row groups. | | [`distinctValues(col, limit?)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-distinct-values) | First-seen distinct values for building filter menus. | | [`hideRows`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-hide-rows) / `showRows` / `hiddenRows` | Explicit row visibility separate from sort/filter state. | | [`hideColumns`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-hide-columns) / `showColumns` / `hiddenColumns` | Bulk-safe persisted column visibility; omitted arguments target the focused column for hide and every column for show. | | [`groupRows`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-group-rows) / `ungroupRows` / `setGroupCollapsed` / `rowGroups` | Inclusive data-row groups with collapse state. | | [`clearView()`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-clear-view) | Clears sort/filter state; hidden rows and row groups remain. | ### Layout and appearance | Method | Purpose | | --- | --- | | [`setFrozen(rows, cols?)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-set-frozen) | Pin leading view rows/columns while the body scrolls. | | [`setZoom(z)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-set-zoom) / `getZoom()` | Scale grid content between `0.5` and `2` without mutating workbook base sizes. | | [`setRowHeight(row, h)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-set-row-height) / `setColumnWidth(col, w)` | Geometry APIs; row height is view-indexed and persists against the underlying data row. | | [`autoFitRows(range?)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-auto-fit-rows) / `autoFitColumns(cols?)` | Explicit, undoable geometry fitting from bulk reads; auto-fit never runs during paint. | | [`setTheme(partial)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-set-theme) | Merge a partial theme and repaint. | | [`defineCellRenderer(name, r)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-define-cell-renderer) | Register a custom renderer after construction. | ### Lifecycle | Method | Purpose | | --- | --- | | [`on(evt, fn)`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-on) | Subscribe to an event; returns an unsubscribe function. | | [`refresh()`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-refresh) | Force a re-render (e.g. after mutating the workbook directly). | | [`destroy()`](https://sheetwrite.vercel.app/docs/api/core/grid/#grid-destroy) | Tear down listeners, DOM, and ARIA attributes. | ## Search `grid.search(query, opts?)` scans cells, highlights every match, scrolls the first match into view, and emits a [`search`](#events) event. `findNext()` / `findPrev()` move the active match; `clearSearch()` clears the highlights. The built-in **Ctrl+F** find widget (gated by `config.find`, on by default) drives this same API. ```ts interface SearchOptions { matchCase?: boolean; // case-sensitive match (default false) wholeCell?: boolean; // match the whole cell, not a substring (default false) sheet?: SheetId; // restrict to one sheet (default: the active sheet) columns?: number[]; // restrict to these column indices (default: all) } interface SearchResult { query: string; matches: CellAddress[]; // matching cells, in row-major order active: number; // index of the active match, or -1 when there are none } ``` ```ts const result = grid.search("error"); console.log(`${result.matches.length} match(es)`); grid.findNext(); // advance the active match and scroll to it grid.clearSearch(); // remove the highlights when done ``` `highlightCells(ranges, color?)` highlights arbitrary ranges independently of search (pass `null` to clear); `color` overrides the theme highlight color. ## Events `grid.on(evt, fn)` returns an `off()` you should call to unsubscribe. | Event | Payload | | --- | --- | | `change` | `{ transaction; changes; dirty; commitReason; source: "local" \| "remote"; epoch? }` | | `selection` | `{ selection: Selection \| null }` | | `scroll` | `{ scrollTop: number; firstRow: number; lastRow: number }` | | `edit-begin` | `{ addr: CellAddress }` | | `edit-commit` | `{ addr: CellAddress; value: CellValue }` | | `search` | `SearchResult` — `{ query: string; matches: CellAddress[]; active: number }` | ```ts const sync = new SyncCoordinator(grid, adapter, { documentId: "products", serverVersion: loadedSnapshot.version ?? 0, }); const off = sync.on((event) => { if (event.type === "conflict") showConflict(event.response); if (event.type === "reload-required") requestFreshSnapshot(); }); saveButton.onclick = () => void sync.sendNext(); // later off(); sync.destroy(); ``` ### Data operations and export Source: https://sheetwrite.vercel.app/docs/guides/data-operations/ Sheetwrite can sort, filter, hide, group, aggregate, import, and export the active sheet without rewriting the stored row data. Sorts and filters are display views: the store keeps a row-order permutation/subset and the renderer reads through it. ## Display views The simple shorthands are still available: ```ts grid.sortBy(4); // sort by column 4, ascending grid.sortBy(4, false); // descending grid.filterBy(3, "Tokyo"); // keep rows whose column-3 text contains "Tokyo" grid.clearView(); // clear sort/filter state only ``` For custom UI, use the composable primitives. All active column filters are ANDed together, hidden rows and collapsed groups are subtracted, and the remaining rows are sorted by the active multi-key sort. ```ts grid.sortByMulti([ { col: 4, ascending: false }, // primary key { col: 0, ascending: true }, // tie-breaker ]); grid.setColumnFilter(3, { kind: "contains", text: "Tokyo" }); grid.setColumnFilter(2, { kind: "values", values: ["Retail", "Partner"] }); grid.setColumnFilter(5, { kind: "compare", op: "gte", value: 1000 }); const cities = grid.distinctValues(3, 100); // data source for a filter menu grid.hideRows([1, 7, 9]); grid.groupRows(10, 25); grid.setGroupCollapsed(10, true); ``` | Method | Signature | Effect | | --- | --- | --- | | `sortBy` | `sortBy(col: number, ascending = true): void` | Reorder displayed rows by one column. | | `sortByMulti` | `sortByMulti(keys: readonly SortKey[]): void` | Stable multi-key sort; first key is primary. | | `filterBy` | `filterBy(col: number, needle: string): void` | Shorthand for a case-insensitive `contains` column filter. | | `setColumnFilter` | `setColumnFilter(col: number, filter: ColumnFilter \| null): void` | Set or clear one column filter. | | `getColumnFilters` | `getColumnFilters(): ReadonlyMap` | Current active column filters. | | `distinctValues` | `distinctValues(col: number, limit = 1000): CellScalar[]` | First-seen distinct resolved values for a column. | | `hideRows` / `showRows` | `hideRows(rows)` / `showRows(rows?)` | Hide/show data rows independent of sort/filter state. | | `hiddenRows` | `hiddenRows(): readonly number[]` | Currently hidden data rows. | | `groupRows` / `ungroupRows` | `groupRows(start, end)` / `ungroupRows(start, end)` | Create/remove an inclusive data-row group. | | `setGroupCollapsed` | `setGroupCollapsed(start, collapsed): void` | Collapse or expand the group starting at `start`. | | `rowGroups` | `rowGroups(): readonly RowGroup[]` | Current row-group definitions. | | `clearView` | `clearView(): void` | Clears sort/filter state. Hidden rows and row groups are preserved. | `clearView()` deliberately does **not** unhide rows or expand row groups. This matches spreadsheet behavior: clearing a filter removes the query, but explicit hidden rows and collapsed groups remain a separate visibility state. Use `showRows()` and `setGroupCollapsed(start, false)` to reverse those states. Because a view reorders or hides rows, two features that depend on a stable row-to-cell mapping are disabled while a view is active: - **Merged cells** are not drawn (the layout reports no merges under a view). - **Drag-to-fill** is unavailable (the fill handle is hidden). Call `clearView()` to remove sort/filter state; also show hidden rows / expand row groups if you need the full natural data order. ## Aggregate `aggregate` computes a column reduction over the active sheet's data and returns a number: ```ts type Aggregate = Grid["aggregate"]; ``` ```ts const total = grid.aggregate(4, "sum"); const rows = grid.aggregate(0, "count"); ``` ## Frozen panes and zoom Frozen panes and zoom are view geometry, not data mutations: ```ts grid.setFrozen(1, 1); // pin first view row and first column grid.setFrozen(0, 0); // unfreeze grid.setZoom(1.25); console.log(grid.getZoom()); ``` `setFrozen(rows, cols?)` pins leading view rows and columns while the body scrolls. `setZoom(z)` clamps to `0.5`-`2` and scales painted row/column geometry and fonts; workbook widths/heights remain in base units. ## Export The grid can download the active sheet directly. CSV is synchronous; XLSX is async because the optional backend produces bytes asynchronously. Install and register `@sheetwrite/xlsx` before enabling a framework toolbar export action or calling `grid.exportXlsx`: ```sh npm install @sheetwrite/xlsx ``` ```ts import "@sheetwrite/xlsx/register"; grid.exportCsv("sales.csv"); await grid.exportXlsx("sales.xlsx"); ``` `grid.exportXlsx(...)` rejects when registration or encoding fails, preserving the backend error. Built-in toolbar and context-menu actions show an alert and emit one `export-error` event with `{ format: "csv" | "xlsx", error }`. `grid.actions.exportXlsx()` returns a promise that resolves after the command finishes, including when the export fails. Built-in buttons ignore a repeated start while the same command is pending and show a busy state. ```ts grid.on("export-error", ({ format, error }) => { console.error(`${format} export failed`, error); }); ``` CSV is written UTF-8 with a BOM and CRLF line endings, and string values are **injection-hardened** — a value starting with `=`, `+`, `-`, `@`, tab, or CR is prefixed with a single quote so it cannot become an executable formula when reopened in a spreadsheet app. Clipboard formulas and references retain their rich behavior only for a copy/cut pasted back through the same live Sheetwrite grid controller. Rich payloads from another controller or application, including spreadsheet HTML, paste as injection-neutralized text; `pasteValues` uses resolved literals. ### Standalone import/export functions Sheetwrite exposes two intentionally different XLSX contracts: - **Table interchange**: `toXlsxTable` / `fromXlsxTable`. This is the active-sheet, first-row-header API used by `grid.exportXlsx`. - **Workbook round-trip**: `toXlsxWorkbook` / `fromXlsxWorkbook`. This consumes and produces the same `WorkbookSnapshot` used by persistence, without adding a header row. Register the optional XLSX package once before using either contract: ```ts import { downloadBytes, fromCsv, fromXlsxTable, fromXlsxWorkbook, SheetwriteStore, toCsv, toTsv, toXlsxTable, toXlsxWorkbook, } from "@sheetwrite/core"; import "@sheetwrite/xlsx/register"; const dataFromCsv = fromCsv(csvText, columns); const tableBytes = await toXlsxTable(workbook, store); const dataFromTable = await fromXlsxTable(tableBytes); const workbookBytes = await toXlsxWorkbook(store.exportSnapshot(), { maxCells: 250_000, signal: abortController.signal, onWarning: (warning) => console.warn(warning.code, warning.message), }); const importedSnapshot = await fromXlsxWorkbook(workbookBytes); const importedStore = SheetwriteStore.fromSnapshot(importedSnapshot); const csv = toCsv(sheet, store); // string (BOM + CRLF, injection-hardened) const tsv = toTsv(range, store); // string (Excel/Sheets clipboard TSV) downloadBytes(csv, "sales.csv", "text/csv"); downloadBytes( workbookBytes, "workbook.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ); ``` | Function | Signature | | --- | --- | | `fromCsv` | `fromCsv(text: string, columns: readonly Column[]): ColumnarData` | | `fromXlsxTable` | `(data: ArrayBuffer \| Uint8Array, options?: XlsxWorkbookOptions) => Promise` | | `fromXlsxWorkbook` | `(data: ArrayBuffer \| Uint8Array, options?: XlsxWorkbookOptions) => Promise` | | `toCsv` | `toCsv(sheet: Sheet, store: Store): string` | | `toTsv` | `toTsv(range: Range, store: Store): string` | | `toXlsxTable` | `(workbook: Workbook, store: Store, options?: XlsxWorkbookOptions) => Promise` | | `toXlsxWorkbook` | `(snapshot: WorkbookSnapshot, options?: XlsxWorkbookOptions) => Promise` | | `downloadBytes` | `downloadBytes(bytes: Uint8Array \| string, filename: string, mime: string): void` | `XlsxWorkbookOptions` is the shared resource contract for every table and workbook path. `maxCells` bounds logical accepted cells and defaults to `1_000_000`; `resourceLimits` overrides the remaining input, archive, XML, dimension, and output budgets, which are checked before decompression and allocation and reject with a typed `XlsxResourceError`. `signal` is checked between bounded codec stages, and structured warnings surface through `onWarning`. Both directions materialize the complete file representation in memory; there is no streaming mode. ### XLSX compatibility | Feature | Table API | Workbook API | | --- | --- | --- | | Multiple worksheets and order | Active sheet only | Preserved | | First row | Synthesized column headers | Ordinary row; no header synthesis | | Formula source, including cross-sheet formulas | Exports resolved scalars | Preserved; workbook requests full recalculation on open | | Cell styles, borders, number/date formats | Preserved on active-sheet export | Preserved | | Merges, column widths, row heights | Preserved on active-sheet export | Preserved | | Frozen panes and active worksheet | Not round-tripped | Preserved | | Named ranges | Not represented | Preserved | | Sheetwrite conditional formats and row groups | Not represented | Preserved in Sheetwrite metadata; warning reports that no Excel rule/outline is emitted | | Boolean literals | Imported by the table reader | Preserved as native booleans | | Rich text and hyperlinks | Reader-dependent flattening | Display text preserved with a structured warning | | Sheetwrite validation rules | Not represented | Preserved in Sheetwrite metadata; supported list/numeric/date/text-length rules also emit native Excel validation | | Protected ranges | Not represented | Preserved in Sheetwrite metadata; no Excel sheet-protection claim is made | | Cell notes | Not represented | Preserved in Sheetwrite metadata and emitted as native Excel notes | | External Excel validation, protection, notes, images, tables, auto-filters, conditional formatting, and VBA | Not represented | Features without Sheetwrite metadata are dropped with a structured `unsupported-feature` warning when detected | Workbook formulas are imported as formula source, not as cached results. Hydrating the returned snapshot with `SheetwriteStore.fromSnapshot` recompiles them into the WASM formula engine. No raw formula handles enter the serialized snapshot. ### XLSX backends XLSX uses pluggable backends. Calling a table or workbook API without its backend throws a configuration error that names both remedies: install `@sheetwrite/xlsx`, then import `@sheetwrite/xlsx/register` before calling the function. The ordinary core entry never resolves the optional codec, so normal grid bundles carry no spreadsheet-file code. `@sheetwrite/xlsx` is the only concrete implementation package. Its root entry is side-effect free and exports `registerXlsxBackends()` plus the three named backend instances for explicit registration or wrapping. The `./register` entry performs idempotent registration: ```ts import { registerXlsxBackends, sheetwriteTableExportBackend, sheetwriteTableImportBackend, sheetwriteWorkbookBackend, } from "@sheetwrite/xlsx"; import { setXlsxTableExportBackend, setXlsxTableImportBackend, setXlsxWorkbookBackend, type XlsxTableExportBackend, type XlsxTableImportBackend, type XlsxWorkbookBackend, } from "@sheetwrite/core"; registerXlsxBackends(); // Or compose individual backends explicitly. setXlsxTableExportBackend(sheetwriteTableExportBackend); setXlsxTableImportBackend(sheetwriteTableImportBackend); setXlsxWorkbookBackend(sheetwriteWorkbookBackend); ``` The backends share Sheetwrite's internal bounded OOXML codec, which owns OPC part resolution, XML tokenization, styles, and worksheet mapping over one audited compression primitive (`fflate`). It preserves formula source, worksheets, styles, merges, dimensions, frozen views, and named ranges, and it enforces the shared resource limits before decompression. The host can replace any role by implementing `XlsxWorkbookBackend` or the table backend contracts. ## See also - [Interaction](https://sheetwrite.vercel.app/docs/guides/interaction/) for clipboard TSV and the toolbar sort control. - [Concepts](https://sheetwrite.vercel.app/docs/concepts/runtime-ownership/#document-protocol-and-storage-transactions) for row/column `DocumentOp` transactions. ### Formulas Source: https://sheetwrite.vercel.app/docs/guides/formulas/ Sheetwrite evaluates formulas in the Rust/WASM calculation engine. Formula sources are persisted exactly as document values; evaluated results are cached for rendering, queries, export, and dependent formulas. Sheetwrite intentionally implements a coherent spreadsheet subset. It does **not** claim full Google Sheets or Excel formula parity. See the generated [formula function contract](https://sheetwrite.vercel.app/docs/reference/formula-functions/) for the complete inventory-derived function table and the [detailed compatibility results](https://sheetwrite.vercel.app/docs/reference/compatibility-results/) for checked operator, spill, preservation, and unsupported boundaries. ## Authoring formulas A formula is a `CellValue` with `kind: "formula"`. The leading `=` is optional at the storage API, although UI input conventionally includes it. ```ts grid.store.applyTransaction({ patches: [ { op: "set", addr: { sheet: "sheet1", row: 5, col: 4 }, value: { kind: "formula", src: "=SUM(E1:E5)" }, }, ], }); ``` `store.getFormula(address)` returns the source. `store.getCell(address).resolved` returns the evaluated scalar or error sentinel. An unsupported function evaluates to `#NAME?`, but its source remains retrievable and persists through snapshots/XLSX round-trips so a later engine can evaluate it. ## Supported formulas The checked, versioned `sheetwrite.formula-capabilities` contract is the source of truth for function registration. Its generated reference lists every function in both engines, grouped by family, with aliases, exact signature and semantic profiles, implementation and evidence paths, source links, and behavior status. Do not infer support from an Excel, Google Sheets, or OpenFormula function with a similar name. [Browse the generated formula function contract →](https://sheetwrite.vercel.app/docs/reference/formula-functions/) ## Full formula engine Sheetwrite has two engine builds with the same API. `@sheetwrite/wasm` is the default engine: it has the standard function set and the smallest download. `@sheetwrite/formulas` is the full engine: the same engine with the analysis families below. An app selects one engine, one time, before it creates a Grid or a store: ```ts import { initSheetwrite } from "@sheetwrite/core"; import * as formulas from "@sheetwrite/formulas"; await initSheetwrite(undefined, formulas); ``` | Family | Examples | | --- | --- | | Distributions | `NORM.DIST`, `T.INV.2T`, `CHISQ.DIST.RT`, `BINOM.DIST`, `GAMMA.INV`, `CONFIDENCE.T` | | Descriptive statistics | `TRIMMEAN`, `PERCENTILE.EXC`, `RANK.AVG`, `SKEW`, `KURT`, `FORECAST.LINEAR` | | Regression | `LINEST`, `LOGEST`, `TREND`, `GROWTH`, `FREQUENCY`, `MODE.MULT` | | Finance | `XIRR`, `XNPV`, `MIRR`, `NPER`, `CUMIPMT`, `DDB` | | Dates | `NETWORKDAYS.INTL`, `WORKDAY.INTL`, `DATEDIF`, `ISOWEEKNUM` | | Text and regex | `TEXTSPLIT`, `TEXTBEFORE`, `TEXTAFTER`, `REGEXEXTRACT`, `REGEXREPLACE`, `ARRAYTOTEXT` | | Database | `DSUM`, `DAVERAGE`, `DCOUNT`, `DGET`, `DMAX`, `DSTDEV` | | Math and matrices | `AGGREGATE`, `MMULT`, `MINVERSE`, `MDETERM`, `SUMSQ`, `COMBIN` | | Reshaping | `SORTBY`, `HSTACK`, `VSTACK`, `TOCOL`, `WRAPROWS`, `EXPAND` | | LAMBDA | `LAMBDA`, `MAP`, `REDUCE`, `SCAN`, `BYROW`, `BYCOL`, `MAKEARRAY`, `ISOMITTED` | | Grouping | `GROUPBY`, `PIVOTBY`, `PERCENTOF` | The default engine returns `#NAME?` for these functions, and formula assist does not suggest them. The [formula analysis showcase](https://sheetwrite.vercel.app/showcases/formulas/) runs thirteen panels of them live over 20,000 orders, and the [analysis formulas guide](https://sheetwrite.vercel.app/docs/guides/analysis-formulas/) shows each family with examples. Limits of the full engine: - `LAMBDA` parameters bind lexically, and `LET` can bind a `LAMBDA`. An uncalled `LAMBDA` returns `#CALC!`. A call with the wrong number of arguments returns `#VALUE!`. Calls are limited to a depth of 64 and 2,000,000 work units per outer helper; more returns `#NUM!`. - `GROUPBY` and `PIVOTBY` accept one built-in function name, such as `SUM`, or a `LAMBDA` with one or two parameters. A vector of functions, such as `HSTACK(SUM,AVERAGE)`, returns `#VALUE!`. - Regex functions do not support lookaround, backreferences, or Unicode classes. Such patterns return `#VALUE!`, never a wrong match. - `AGGREGATE` options that skip hidden rows have no effect, because formulas do not see hidden-row state. Function names are case-insensitive. Commas are the only documented argument separator. Interior omitted optional arguments are preserved (`XLOOKUP(key, keys, results,, 0)`). Locale-specific separators are not accepted. ## Scalars, coercion, and errors Persisted literals and evaluated formula results use `string | number | boolean | null`. Boolean literals entered as `TRUE`/`FALSE`, formula comparisons, XLSX boolean cells, snapshots, visible windows, clipboard output, and CSV/TSV output remain booleans end to end. Display and text export use uppercase `TRUE` and `FALSE`. | Sentinel | Meaning | | --- | --- | | `#REF!` | Missing sheet/cell reference or invalid reference structure. | | `#VALUE!` | Wrong value type, malformed formula, invalid arity, or range-shape mismatch. | | `#DIV/0!` | Division/modulo by zero or an empty criteria average. | | `#NAME?` | Unknown function or unresolved named range. | | `#N/A` | Lookup did not find a compatible value. | | `#NUM!` | Non-finite numeric result, excessive recursion, or an oversized range. | | `#SPILL!` | A dynamic result intersects content, another spill, a merge, validation/protection metadata, or a sheet boundary. | | `#CALC!` | A supported array calculation has no result, such as `FILTER` without matches or an empty fallback. | | `#CYCLE!` | Direct or transitive formula/reference cycle. | | `#LOADING!` | A formula depends on datasource cells that have not loaded yet. | Errors are values for display and dependency propagation, not `NaN` or blank rendering. `IFERROR` can replace them. Unsupported or malformed source remains stored even when the result is an error. Coercion follows these documented rules: - Arithmetic converts numeric text and booleans (`TRUE = 1`, `FALSE = 0`); nonnumeric text returns `#VALUE!`. - Direct scalar boolean/text arguments may be coerced by numeric functions. Text/booleans reached through a range are ignored by numeric aggregates, matching common spreadsheet behavior. - Empty scalar arithmetic behaves as zero. Empty range cells are skipped. - Comparisons are case-insensitive for text and use spreadsheet type ordering. - `AND`, `OR`, `NOT`, and `IF` accept booleans, numbers, and the text `TRUE`/`FALSE`. ## LET bindings `LET(name1, value1, [name2, value2, …], calculation)` supports lexical, case-insensitive local names. A name starts with an ASCII letter or `_`; subsequent characters may also contain ASCII digits or `.`. Inner `LET` bindings shadow outer bindings, and each value expression can see only earlier bindings. Bindings are lazy. Only names reachable from `calculation` are expanded and evaluated, so an unused cell read, error, or `NOW()` does not become a dependency, error, or volatile marker. A used binding preserves ordinary formula dependency, spill, copy/fill, and structural-reference behavior. `LET(x,1/0,7)` therefore returns `7`, while `LET(x,A1,x+1)` tracks `A1`. One `LET` admits at most 126 bindings. Expansion admits at most 16,384 AST nodes across reachable bindings. Invalid name/arity/binding structure returns `#VALUE!`; expansion beyond the node ceiling returns `#NUM!`. In the full engine, a `LET` value can be a `LAMBDA`; see [Full formula engine](#full-formula-engine). ## References, ranges, and named ranges A1 cells (`A1`, `$B12`, `AA$3`), rectangular ranges (`A1:B3`), and cross-sheet references (`Sales!E2`, `'Sales 2026'!E2:E10`) are supported. Absolute markers affect fill/structural rewriting; they do not change evaluation. Named ranges are document operations and snapshot metadata: ```ts store.applyTransaction({ patches: [ { op: "setNamedRange", namedRange: { name: "Revenue", range: { sheet: "data", start: { row: 1, col: 4 }, end: { row: 100, col: 4 }, }, }, }, { op: "setNamedRange", namedRange: { name: "Revenue", scope: "summary", range: { sheet: "data", start: { row: 1, col: 5 }, end: { row: 100, col: 5 }, }, }, }, ], }); ``` A formula on `summary` resolves the sheet-scoped `Revenue`; formulas on other sheets resolve the workbook definition. Names are case-insensitive, must be formula-safe identifiers, and cannot look like A1 cells or boolean literals. Row/column insertions, removals, and moves rebase their rectangles. Deleting the complete target or its scope removes the definition; formulas then fall back to a workbook definition or evaluate to `#NAME?`. ## Array constants Both engines accept array constants in braces. Commas separate columns and semicolons separate rows: `={1,2;3,4}` spills into two rows and two columns. `={1,2,3}` is one row; `={1;2;3}` is one column. Normal spill limits and collision rules apply. Elements can be numbers with an optional sign, quoted text, `TRUE`, `FALSE`, or error constants such as `#N/A`. Every row must have the same length. Empty arrays, references, and expressions inside braces are invalid and return `#VALUE!`. Functions that accept arrays can use these values directly, for example `=SUM({1,2,3})` returns `6` and `=INDEX({1,2;3,4},2,1)` returns `3`. Copy and structural edits preserve constants and their text elements. XLSX import and export preserve the formula source. ## Spill references Put `#` after a cell reference to refer to the whole spill of the formula in that cell. If `A1` contains `=SEQUENCE(D1)`, then `=SUM(A1#)` adds all the values in the spill, and `=SORT(A1#,1,-1)` spills them in reverse order. When the spill changes size, formulas that use `A1#` use the new range. A sheet name is permitted: `='Report'!B2#`. `A1#` returns `#REF!` when the cell does not hold a spill: for example, the cell contains a value, or a collision blocks the spill (`#SPILL!`). If you delete the row or column of the anchor cell, the reference becomes `#REF!`. Both engines support spill references. XLSX files store `A1#` as `_xlfn.ANCHORARRAY(A1)`; import and export convert between the two forms. Arithmetic on a whole spill applies to each element. For example, `=A1#*10` multiplies each value in the spill by 10 and spills the result. ## Array arithmetic Operators apply to each element of a range, array constant, spill reference, or dynamic-array result. This includes arithmetic (`+`, `-`, `*`, `/`, `^`), text concatenation (`&`), comparisons, unary `+` and `-`, and `%`. For example, `=A1:A3*10` spills three results. `=FILTER(A1:A5,B1:B5>2)` uses a comparison array to select rows. `=SUMPRODUCT((A1:A5>2)*B1:B5)` multiplies the comparison results by the values in column B. A scalar applies to every element. Rows and columns expand separately. A size of one expands to match the other operand. For example, `={1,2,3}+{10;20}` returns `{11,12,13;21,22,23}`. If two sizes differ and neither is one, the result uses the larger size. Positions outside the smaller operand return `#N/A`. Each element uses the scalar rules for values and errors. An error affects its element, not the whole array. Functions that accept arrays use the results directly. For example, `=SUM(A1:A3*B1:B3)` returns one sum. A top-level array result spills into adjacent cells. A blocked spill returns `#SPILL!`. Array size and memory limits still apply. ## Operators | Precedence, high to low | Operators | Associativity | | --- | --- | --- | | Reference | `:`, postfix `#` (spill) | — | | Unary sign | unary `+`, unary `-` | right | | Percentage | postfix `%` | left | | Exponentiation | `^` | left | | Multiplication | `*`, `/` | left | | Addition | `+`, `-` | left | | Concatenation | `&` | left | | Comparison | `=`, `<>`, `<`, `>`, `<=`, `>=` | one comparison | This deliberately follows Excel's spreadsheet precedence rather than programming-language conventions: `=-2^2` is `4`, `=2^3^2` is `64`, and `=2^-2` is `0.25`. Parenthesize formulas when portability to a non-spreadsheet evaluator matters. Percentage divides its operand by 100 and may repeat (`=50%%` is `0.005`). Concatenation formats numbers, booleans, and blanks as spreadsheet text; arithmetic and comparison bind before `&`. Comparisons return native booleans. For example, `=IF(A1 >= 100, A1 * 0.9, A1)`. ## Date/time semantics Dates are numbers: whole days since the spreadsheet epoch, with a fractional day for time. Sheetwrite uses only the Excel 1900 date system on this path: - serial `0` maps to `1899-12-31` - `DATE(1900,1,1) = 1` - serial `60` is retained as the compatibility-only, non-Gregorian date `1900-02-29` - `DATE(1900,3,1) = 61` - `DATE` treats 1900 as a leap year for this serial boundary and normalizes month/day overflow; `DATE(2024,13,1)` is 2025-01-01 - `DATEVALUE` is locale-neutral: it accepts the documented ISO `yyyy-mm-dd` form and fixed slash-delimited numeric forms; it never consults the host locale `TODAY` and `NOW` are volatile, but never consult the clock during paint or ordinary dependency reads. The host captures one absolute instant and triggers a barrier: ```ts store.recalculateVolatile(new Date("2026-07-13T18:00:00.000Z")); ``` The default captures `new Date()` once. The instant is converted to a UTC serial, so a fixed `Date` produces identical results in every host timezone. `TODAY` returns its whole-day component; `NOW` retains the fraction. Basic `TEXT` date patterns are `yyyy-mm-dd`, `yyyy/mm/dd`, `mm/dd/yyyy`, `dd/mm/yyyy`, `m/d/yyyy`, `yyyy-mm-dd hh:mm`, `yyyy-mm-dd hh:mm:ss`, `hh:mm`, and `hh:mm:ss`. Text evaluation is host-locale neutral. Function argument separators stay commas; `VALUE` uses `.` decimal and `,` grouping, and `NUMBERVALUE(text, [decimal_separator], [group_separator])` uses those same defaults unless the separators are supplied explicitly. Case conversion uses Unicode mappings, not browser locale. `LEN`, `LEFT`, `RIGHT`, and `MID` count Unicode scalar values rather than UTF-16 code units. Generated text is capped at 16 MiB and bounded searches at 4,000,000 steps; excess work returns `#NUM!`. ## Criteria semantics and shapes Criteria strings may begin with `=`, `<>`, `<`, `<=`, `>`, or `>=`. Numeric and boolean operands are parsed before text comparison. Plain text comparisons are case-insensitive. `*` matches zero or more characters, `?` matches one character, and `~` escapes the next wildcard. Wildcards apply to equality/inequality text criteria. Criteria are parsed once per formula evaluation, not once per cell. Shapes are exact row-by-column dimensions; equal cell counts with different dimensions do not match: - `COUNTIF(criteria_range, criterion)` accepts one matrix and one scalar criterion. - `COUNTIFS(criteria_range1, criterion1, …)` requires one or more range/criterion pairs. Every criteria range must have exactly the shape of the first. - `SUMIF(criteria_range, criterion, [value_range])` and `AVERAGEIF(...)` use `criteria_range` as the value range when the third argument is omitted. When supplied, `value_range` must have exactly the same shape; Sheetwrite does not implement Excel's top-left range extension. - `SUMIFS(value_range, criteria_range1, criterion1, …)`, `AVERAGEIFS`, `MAXIFS`, and `MINIFS` require one or more pairs after the value range. Every criteria range must exactly match the value range. A mismatch or malformed pair list returns `#VALUE!`. Matching result-range errors propagate. Text, booleans, and blanks in a numeric result range are ignored. A criteria sum with no numeric matches is `0`; an average is `#DIV/0!`; `MAXIFS`/`MINIFS` return `0`. ## SUMPRODUCT and SUBTOTAL `SUMPRODUCT(array1, [array2, …])` requires at least one argument. Every argument must have the same exact row-by-column shape; a scalar is a 1×1 shape, and a dimensional mismatch returns `#VALUE!`. It multiplies corresponding cells and sums the products. Text, booleans, and blanks reached through ranges contribute zero; directly supplied numeric text and booleans use ordinary scalar numeric coercion. Errors propagate, and a non-finite product or total returns `#NUM!`. `SUBTOTAL(function_number, reference1, [reference2, …])` accepts codes `1`–`11`: `AVERAGE`, `COUNT`, `COUNTA`, `MAX`, `MIN`, `PRODUCT`, `STDEV.S`, `STDEV.P`, `SUM`, `VAR.S`, and `VAR.P`. It accepts up to 253 reference arguments and uses the corresponding aggregate's range coercion/error rules. Codes `101`–`111` are deliberately unsupported and return `#VALUE!` pending hidden-row provenance in formula range values. Nested-subtotal and filtered/hidden-row exclusion must not be inferred. ## Financial functions `PV`, `FV`, `PMT`, `NPV`, `IRR`, `RATE`, `IPMT`, and `PPMT` use binary64 arithmetic, fixed defaults from the generated signatures, and explicit domain errors. `NPV` discounts its first cash flow at period 1 and uses compensated summation. Range cash flows ignore text and blanks; errors propagate. `IRR` requires at least one positive and one negative cash flow. `IRR` and `RATE` use the caller's guess (default `0.1`), 14 deterministic bracket steps, at most 100 deterministic solve steps, fixed absolute/relative tolerances of `1e-12`, and a finite transformed search domain. Invalid domains or failure to converge return `#NUM!`; no random seed, host locale, wall clock, or platform-specific iteration budget is used. ## Lookup semantics - `INDEX(range, row, [column])` uses 1-based indices and returns one scalar. Zero/negative indices return `#VALUE!`; array-return row/column projections are not implemented. - `MATCH(key, range, 0)` is exact. Match type `1` returns the largest value less than or equal to the key from ascending data. `-1` returns the smallest value greater than or equal to the key from descending data. Invalid ordering returns `#N/A` rather than a plausible wrong row. - `VLOOKUP`/`HLOOKUP` use exact matching when the final argument is false. The omitted/true mode uses correctly ordered approximate data and returns the largest value less than or equal to the key. - `XLOOKUP` supports exact, next-smaller (`-1`), next-larger (`1`), and wildcard (`2`) match modes; forward/reverse and ordered binary-search modes are accepted. `if_not_found` is optional. If it is omitted—including via an interior empty argument—the result is `#N/A`. Lookup errors in the scanned range propagate. Approximate modes validate ordering and do not silently return a result from unsorted input. ## Dynamic arrays and spills `FILTER`, `SORT`, `UNIQUE`, `TRANSPOSE`, `SEQUENCE`, `TAKE`, `DROP`, `CHOOSECOLS`, and `CHOOSEROWS` return rectangular values. A direct range formula such as `=A1:B4` also spills: - `FILTER(array, include, [if_empty])` accepts a one-column include range matching the array's rows or a one-row include range matching its columns. Shape mismatches are `#VALUE!`. No selected values returns `if_empty`, or `#CALC!` when omitted. - `SORT(array, [sort_index], [sort_order], [by_col])` defaults to the first column, ascending. `sort_index` selects the row/column key; `sort_order` is `1` or `-1`; `by_col=TRUE` sorts columns instead of rows. For several keys, use `SORTBY` in the full engine. - `UNIQUE(array, [by_col], [exactly_once])` preserves first-seen order. `by_col=TRUE` compares columns; `exactly_once=TRUE` keeps only items occurring once. An empty result is `#CALC!`. - `TRANSPOSE(array)` swaps rows and columns. - `SEQUENCE(rows, [columns], [start], [step])` requires positive integer dimensions and defaults to one column, start `1`, step `1`; values fill row-major. - `TAKE`/`DROP(array, rows, [columns])` use positive counts from the start and negative counts from the end. A zero or empty result is `#CALC!`; counts beyond an axis clamp to that axis. - `CHOOSECOLS`/`CHOOSEROWS(array, index1, …)` use 1-based positive indices and end-relative negative indices. Repeated indices repeat output axes; zero/out-of-range indices return `#VALUE!`. Errors inside a returned array stay at their corresponding spill children; a mixed result such as `{1;#N/A;2}` does not collapse to an anchor-only error. The formula cell is the **anchor** and owns the complete runtime rectangle. Spill children have no formula source or persisted document identity. `store.getSpillAnchor(address)` returns the anchor for either the anchor or a child, and returns `null` for an ordinary cell. Spills publish atomically. Any nonempty destination, another spill, merge, validation or protected range, unloaded paged cell, or sheet boundary makes the anchor `#SPILL!`; no partial children remain. Styles, conditional formatting, and hidden rows/columns do not obstruct. Editing a projected child through canonical core mutations is rejected. Edit or clear the anchor instead. Structural and metadata changes invalidate ownership and recompute it. Snapshots and XLSX export serialize only the anchor formula. Children recompute after hydration. Internal rich copy preserves the anchor formula while treating children as derived blanks on paste; external TSV receives the displayed spill values. History snapshots likewise restore the anchor and recompute children, so undo never persists stale projections. Unsupported OOXML array/data-table formula records remain inert source text with an explicit warning rather than being silently treated as Sheetwrite spills. Each array dimension is capped at Excel's row/column limits, one spill is capped at 1,000,000 cells and 64 MiB of bounded value/intermediate storage, and one dynamic recompute pass is capped at 2,000,000 cell operations. Materialized spill ownership across the complete store is also capped at 1,000,000 cells and 64 MiB, including per-child error metadata. Installation checks that cumulative budget atomically; clearing, shrinking, or removing a spill releases its ownership. Oversized work returns stable `#NUM!` without partial children, while unstable or colliding shapes return an explicit error rather than truncating. The admission check happens before materialization when the shape is statically knowable and is repeated before spill ownership is installed. It accounts source/result copies and per-item auxiliary storage; a shape that could exceed a ceiling is rejected rather than optimistically allocated. A recompute either atomically replaces the complete previous spill or leaves no projected children. ## Unsupported categories Formula evaluation performs no network request and runs no custom JavaScript. Automatic volatile functions beyond the explicit `TODAY`/`NOW` host-clock barrier, network functions, external/live-data providers, arbitrary external workbook references, and cube/OLAP functions are unsupported. Examples include `RAND`, `RANDBETWEEN`, `INDIRECT`, `OFFSET`, `WEBSERVICE`, `GOOGLEFINANCE`, `IMPORT*`, `RTD`, and `CUBEVALUE`. Database functions such as `DSUM` and `LAMBDA` helpers such as `MAP` are in the full engine only. Unsupported names evaluate to `#NAME?` while preserving source for snapshots and interchange. There is no compatibility shim or side-effecting fallback. The generated [unsupported-category ledger](https://sheetwrite.vercel.app/docs/reference/formula-functions/#unsupported-categories) is derived from the versioned inventory. ## Performance evidence The [engine guide](https://sheetwrite.vercel.app/docs/guides/performance-resources/#choose-a-formula-engine) shows function coverage, download size, and analysis workloads. Shared functions use the same implementation in both builds. Small timing differences do not show that one package is faster. Follow the [performance evidence guide](https://sheetwrite.vercel.app/docs/guides/performance-resources/) to capture and check measurements; resource ceilings are not benchmark results. ## Point mode and reference rewriting While editing a formula, clicking a cell inserts its A1 reference and dragging inserts a range. Relative references shift during fill; `$`-absolute axes remain fixed. Structural row/column edits rewrite direct and named references while preserving stable sheet identity. The same `$`-aware utility is public: ```ts import { shiftA1Refs } from "@sheetwrite/core"; shiftA1Refs("A1 + $B$1", 0, 1); // "B1 + $B$1" ``` A1 address helpers are exported as `cellA1`, `colToA1`, `labelToCol`, and `rangeA1`. ### Keep app rows in your store Source: https://sheetwrite.vercel.app/docs/guides/host-owned-rows/ Use the row bridge when your app owns the row objects and Sheetwrite owns editing, history, sorting, and filtering. The bridge is optional. Without `getRowId`, `defaultRows` keeps its existing one-time, uncontrolled behavior. ```ts import { createRowBridge, type RowBridgeProjection } from "@sheetwrite/core"; interface InvoiceRow { id: string; customer: string; total: number; } const entities = new Map([ ["invoice-1", { id: "invoice-1", customer: "Ada", total: 120 }], ["invoice-2", { id: "invoice-2", customer: "Lin", total: 75 }], ]); const rows = [...entities.values()]; let nextId = 3; const bridge = createRowBridge({ columns: [{ key: "customer" }, { key: "total" }], defaultRows: rows, getRowId: (row) => row.id, createRowId: () => `invoice-${nextId++}`, }); function applyProjection(projection: RowBridgeProjection) { if (projection.status === "rejected" || projection.status === "duplicate") return; for (const delta of projection.deltas) { if (delta.kind === "cell") { const { rowId, columnKey, next } = delta.cell; if (rowId === null || columnKey === null || next?.kind !== "literal") continue; const row = entities.get(rowId); if (row) entities.set(rowId, { ...row, [columnKey]: next.value }); } if (delta.kind === "row-structure" && delta.action === "delete") { for (const rowId of delta.removed) if (rowId !== null) entities.delete(rowId); } if (delta.kind === "row-structure" && delta.action === "insert") { for (const rowId of delta.inserted) { if (rowId !== null) entities.set(rowId, { id: rowId, customer: "", total: 0 }); } } } } ``` Attach the same bridge and callback in any adapter: ```tsx import { SheetwriteGrid, type RowBridge, type RowBridgeProjection, type SimpleColumn, } from "@sheetwrite/react"; interface InvoiceRow { id: string; customer: string; total: number; } declare const columns: readonly SimpleColumn[]; declare const rows: readonly InvoiceRow[]; declare const bridge: RowBridge; declare const applyProjection: (projection: RowBridgeProjection) => void; ``` Vue emits `row-delta`; Svelte and the imperative controller use `onRowDelta`. All adapters call the same core bridge. Sorts, filters, and hidden rows only change the visible order, so emitted row IDs still refer to data-space rows. Remote changes should enter through `grid.applyRemoteOperations(...)`. Their projections have `status: "remote"` and `source: "remote"`. An echoed local transaction is returned as `duplicate` with no deltas. For a server rejection or transformed acceptance, call `bridge.reconcile(...)` with the server result and the canonical applied operations. The bridge does not update your store automatically; your callback remains the only place that changes host rows. By default, the bridge retains at most 8,192 recent transaction IDs and 8,192 recent local operation fingerprints. If your host applies its own echoes directly through `applyRemoteOperations`, deliver each echo before its identity leaves this window. An echo outside this window is no longer recognized and projects as a remote change. `SyncCoordinator` removes acknowledged local echoes before they become Grid change events, so its separate 4,096 acknowledgement window does not set the bridge limit. Set `maxRecentTransactions` in `createRowBridge` to change the limit for each identity window. The value must be a positive safe integer; invalid values throw a `RangeError`. Raise the limit if your host can project more than 8,192 transactions before an echo arrives. A larger limit retains more identity strings and uses more memory. ```ts import { createRowBridge } from "@sheetwrite/core"; const bridge = createRowBridge({ columns: [{ key: "total" }], defaultRows: [{ id: "invoice-1", total: 120 }], getRowId: (row) => row.id, maxRecentTransactions: 16384, }); ``` ### Interaction and editing Source: https://sheetwrite.vercel.app/docs/guides/interaction/ Sheetwrite handles selection, keyboard navigation, inline editing, the clipboard, the toolbar, merges, and drag-to-fill out of the box. The host element is made focusable (`tabindex="0"`) so it receives keyboard events. Setting `readOnly: true` disables every mutating interaction below (editing, clearing, fill, paste, and restyling) while leaving navigation and selection intact. ## Selection model A `Selection` is one of five shapes: ```ts type Selection = | { kind: "cell"; addr: CellAddress } | { kind: "range"; range: Range } | { kind: "row"; sheet: SheetId; row: number } | { kind: "column"; sheet: SheetId; col: number } | { kind: "multi"; ranges: Range[] }; ``` Read or set it imperatively, and subscribe to changes: ```ts const sel = grid.getSelection(); grid.setSelection({ kind: "cell", addr: { sheet: "sheet1", row: 0, col: 0 } }); grid.setSelection(null); // clear grid.on("selection", (e) => console.log(e.selection)); ``` How selections are made with the mouse (primary button): | Gesture | Result | | --- | --- | | Click a cell | Select that cell. | | Drag across cells | Extend to a **range**. | | Shift-click | Extend the range from the current anchor. | | Ctrl/Cmd-click | Add a region — produces a **multi** selection. | | Click a column letter (top header) | Select that **column** (Shift extends; Ctrl/Cmd adds). | Row and full-column selections are part of the model. Column selection is wired to the top header; **row selection is programmatic only** — there is no row-header click gesture, so set it via `setSelection({ kind: "row", sheet, row })`. Only the pointer that began a drag can change it. Switching sheets, changing sheet structure or the sorted/filtered view, or losing pointer capture ends the gesture; a later pointer release cannot apply it to a different sheet or view. ## Resizing Drag a column-header boundary or a row-header boundary to preview its size. The preview changes geometry only: the document and undo history remain intact until release. Release submits one undoable size change. Cancellation, capture loss, or a rejected change restores the document's size. Turning read-only on ends an active resize without committing. ## Keyboard navigation When the grid is focused and not editing: | Key | Action | | --- | --- | | Arrow keys | Move the focus one cell. | | Ctrl/Cmd + Arrow | Jump to the first/last row or column. | | Page Up / Page Down | Move by a viewport of rows. | | Home / End | Move to the first / last column of the row. | | Ctrl/Cmd + Home / End | Move to the sheet's first / last cell. | | Shift + any of the above | Extend the selection instead of moving. | | Enter or F2 | Edit the focused cell (F2 selects the existing text). | | Delete / Backspace | Clear the selected cells. | | A printable character | Start editing, replacing the cell's content. | | Ctrl/Cmd + C / X / V | Copy / cut / paste. | | Ctrl/Cmd + Z | Undo the last edit. | | Ctrl/Cmd + Shift + Z (or Ctrl/Cmd + Y) | Redo. | | Ctrl/Cmd + F | Open the find bar (when `config.find` is not `false`). | ### Rebind an action Use `config.keyboard.bindings` to replace the keys for one action. Omitted actions keep their defaults. Use `null` to remove an action's keys, or an array for multiple bindings. These bindings apply when the grid has focus and is not editing. Write a key combination as a string or as an object: - A string lists modifiers and then one key: `"Mod+Shift+K"`, `"Ctrl+Alt+Delete"`. The modifiers are `Mod`, `Ctrl`, `Meta`, `Alt` and `Shift`, in any order. - An object names the key and its modifiers: `{ key: "k", mod: true, shift: true }`. Use the object form for a key without modifiers, for example `{ key: "F2" }`. `Mod` shows as Command on macOS and as Control on other systems. It accepts either key, so the same shortcut works with a Mac keyboard and a PC keyboard. `Ctrl` and `Meta` are always exactly the Control and Command (or Windows) keys, so `"Ctrl+J"` is Control+J on a Mac too. Shift and Alt must match exactly. Name the key as printed without Shift: `"Mod+Shift+1"` and `"Mod+Shift+/"` run when the user presses Shift with the 1 or / key, although the browser reports `!` and `?` for those presses. Letters and digits keep their meaning on other keyboard layouts. ```ts grid.setConfig({ keyboard: { bindings: { copy: "Mod+Shift+C", find: { key: "f", mod: true }, replace: null, }, }, contextMenu: [ { action: "copy", label: "Copy value", shortcut: "copy" }, ], }); ``` The Copy label is `⌘⇧C` on macOS and `Ctrl+Shift+C` elsewhere. Its original Mod+C binding no longer runs Copy. Menu actions use their bound shortcut by default; `shortcut: "copy"` lets a custom item use the same label. Built-in toolbar titles and `grid.getShortcutLabel("copy")` use the same keymap. ### Add a shortcut for your own command Give a context-menu item a `shortcut` and an `onClick`. The grid then runs `onClick` when the grid has focus and the user presses that combination, with the focused cell as the cell argument. A combination with `Mod`, `Ctrl` or `Meta` also works while the inline editor is open. A hidden or disabled item does not run. ```ts grid.setConfig({ contextMenu: [ { id: "open-record", label: "Open record", shortcut: "Mod+Shift+O", onClick: (_grid, cell) => { if (cell) console.log("open record for row", cell.row); }, }, ], }); ``` If two commands use the same combination, `createGrid` and `setConfig` throw a `SheetwriteError` with code `shortcut-conflict`, and `setConfig` keeps the previous configuration. A malformed combination throws code `invalid-shortcut`. Use `keyboard: false` to handle all built-in keys in the host. The existing intercept function remains available: return `true` to consume a key or `false` to let the built-in handler continue. See [KeyboardOptions](https://sheetwrite.vercel.app/docs/api/core/keyboard-options/). ## Undo and redo `grid.undo()` and `grid.redo()` (and Ctrl/Cmd + Z, Ctrl/Cmd + Shift + Z) step through the last 200 recorded transactions. Remote operations from a `SyncCoordinator` do not create undo entries. Undo of a large clear restores every old cell. When the restore is larger than one transaction allows, the Grid sends one compressed `restoreBlock` operation. If that still needs more than one server version, it uses an atomic batch, which needs `PersistenceAdapter.commitBatch`. See [Large undo and atomic batches](https://sheetwrite.vercel.app/docs/guides/collaboration/#large-undo-and-atomic-batches). If the restore is above the batch ceilings, cannot be split, or needs a `commitBatch` that the adapter does not have, `grid.undo()` changes nothing. The Grid emits `mutation-rejected` with a `resource-limit` issue and removes that entry from the history, so older edits can still be undone. Show the reason to the user: ```ts grid.on("mutation-rejected", ({ issues }) => { for (const issue of issues) { if (issue.kind === "resource-limit") { console.warn(`Not undone: ${issue.resource} ${issue.actual} is above ${issue.max}.`); } } }); ``` ## Inline editing Editing happens in a single real `