Course progress Course outline 18 of 18 lessons available
Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates
Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools
- 05 Chapter 5: Give the Pixel World a Registry Current lesson
- 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
- 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
- 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
- 09 Chapter 9: Tools Are Traffic Lights, Not a Bag of Booleans available now
Part III: From “It Drags” to “It Is Trustworthy”—Interaction, Text, Assets, and Recovery
- 10 Chapter 10: Make the Editor Feel Right available now
- 11 Chapter 11: Drawn Text Is Not Editable Text available now
- 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
- 13 Chapter 13: Time Machines and Old Boxes available now
- 14 Chapter 14: Looking Correct Is Not Being Correct available now
Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI
Start with a game a five-year-old can understand
Take a family photograph, then place the photo and a stack of family-registry cards on a table. The photograph can tell you, “There are three people here.” The cards instead record each person’s name, an unchanging number, who belongs to which family, who stands in front of whom, and who is holding whose hand. Now cut one person out of the photograph and ask: do their name and family relationships automatically disappear from the cards? Replace the photograph with a watercolor painting. Do their numbers need to change?
First predict: if two children are both called “Blue,” can a name alone reliably find the same person? If one card says “My parent is B,” and B’s card says “My parent is A,” what happens when you keep following parents upward?
- Issue unique numbersIdentity does not change with appearance
- Record relationshipsParent-child, order, connections
- Inspect the registryReject orphans and cycles
- Choose a projectionCanvas or DOM
The single truth of this chapter is: the Document Model is the source of truth; the Renderer is only a projection. Pixels contain no Stable ID, parent-child relationship, permission, connection, or business identity.
Translate the toys into Canvas engineering
| Family-registry game | Canvas Lab | Responsibility |
|---|---|---|
| Entire registry book | DocumentRecord | Document version and page entry points |
| Each family page | PageRecord | One scene and its root Shape order |
| Person card | ShapeRecord | Stable ID, type, parent, local transform, and properties |
| Family photo/attachment | AssetRecord | Metadata for resources such as images |
| Holding hands | BindingRecord | Semantic connection between a Connector and target |
| Parents and children | Parent/Child Scene Graph | Local Transform becomes World along ancestors |
| Front and back rows | Z-Order | Determines drawing and hit priority |
| List of photographers | Renderer Registry | type → how to project it |
| Height-measuring tool | Geometry Registry | type → geometry queries |
| Who is being pointed at today | Session Selection | Does not belong to the durable Document |
The analogy has limits. A Shape may have no real-world “parent,” and a Group is not a person. Several Shapes may refer to the same Asset, while a Binding is not Parent/Child. Real registry cards change infrequently; editor Documents are updated frequently by commands. The metaphor emphasizes identity and relationships, not a requirement to store every subtree as nested JSON. Runtime code uses Normalized Records so it can validate and update by ID.
Kill the misleading intuitions first
- “If Canvas drew it, it is saved.” A Bitmap stores only colors. It cannot recover objects after reload or Resize, or when exporting semantic data.
- “Putting
CanvasRenderingContext2Din a Shape is convenient.” A Context cannot be serialized and locks the model to the Renderer. A Shape stores domain data only. - “Save Selection too so returning is convenient.” Selection, Hover, and camera usually belong to Session; multiplayer Presence is yet another layer. Mixing them into the Document contaminates Undo, collaboration, and persistence.
- “Use an array index as the object ID.” Reordering, insertion, and collaboration change indexes. Identity must be stable and immutable.
- “A Group can just store one absolute coordinate.” Children need Local Transforms. A World Transform is derived from the Scene Graph; storing both lets them drift apart.
- “Drop unknown Shapes immediately.” If an old client opens data from a new client, silent deletion destroys the document. Isolate and preserve the original record while displaying a compatibility placeholder.
Production backpack
Prerequisite contract
The prerequisite is Chapter 4’s Matrix2D and its space naming. Canvas Lab specifies that the Document contains Durable State only. SessionState={selection,camera,activeTool} is separate, and Presence certainly does not enter the Document in this chapter. Every write passes through a Command Boundary. The Renderer receives a read-only snapshot. A Business Domain Object’s ID is retained after adaptation into a Canvas Record.
Formal knowledge
Stable IDs are immutable identities, not display names. A Document aggregates Pages, Shapes, Assets, and Bindings. A Page selects a visible scene. A Shape is a visual record with a type and properties. Groups form a Scene Graph through Parent/Child. Assets manage binary resources independently. Bindings express cross-Shape relationships such as Connectors and ports. Parent IDs and each Page’s root list together form a tree/forest, so cycles and dangling cross-Document references must be forbidden.
Z-Order must not rely on accidental object-iteration order. It can use the childIds order of each parent or an explicit comparable key, but ties require a deterministic tie-break. Local Transform is stored in the record; World Transform is Derived State from the ancestor chain. Selection Bounds, World Bounds, visibility, and cached Paths are also Derived State and are not written back into the source of truth.
A Domain Model describes business concepts such as orders, warehouses, and approvals. A Visual Model describes rectangles, text, and connector ports. An Adapter sits between them: Business Domain Object → Canvas Document Record → Geometry/Renderer → Pixels or DOM. One business object may produce several Shapes, while a Shape may be a note with no business meaning. The two cannot be equated directly.
Normalized Records can be represented on the wire as a record array or ID dictionary; runtime indexes use Map. Deserialization performs Schema Validation before building indexes and checking Duplicate IDs, unknown records, orphans, cycles, and ordering conflicts. Immutable Identity means “change ID” is not an ordinary Update. To copy, create a new ID and repair Bindings explicitly.
The Renderer Registry maps shape.type → ShapeRenderer; the Geometry Registry maps shape.type → ShapeGeometryProvider. When a Renderer is absent, Geometry and Document can still work, and the Renderer can return an Unknown Placeholder. A Command Boundary turns “I want to move this” into a MoveShapeCommand, validates permissions and preconditions, and commits atomically. This prevents UI or Renderer code from mutating a Map arbitrarily.
Evidence and compatibility
The core types here depend only on JavaScript data, not Canvas or DOM, so they run in Node tests. The Wire Format uses only JSON-representable values. Map, Set, DOMMatrix, and functions cannot be stored directly as a JSON Document. Even if you use structuredClone for an in-process snapshot, do not mistake it for a long-term persistence format.
Sources above were checked on 2026-08-29. Long-term compatibility is the responsibility of an explicit schema version and later Migrations. Never depend on a JS engine’s incidental object-property order or prototype.
This chapter’s engineering increment
Starting point: Chapter 3’s temporary Shape[] mixes in selectedIds. Finish line: Durable Document and Session are separate; five Record types, two Registries, validation, and a command interface run in a Node environment without Canvas.
canvas-lab/src/lab/ch05/
records.ts
document-store.ts
registries.ts
document-store.test.ts
Complete core types and validator:
export type Transform = Readonly<{
x: number;
y: number;
rotation: number;
scaleX: number;
scaleY: number;
}>;
export type DocumentRecord = Readonly<{
id: string;
type: 'document';
schemaVersion: 1;
pageIds: readonly string[];
}>;
export type PageRecord = Readonly<{
id: string;
type: 'page';
name: string;
rootShapeIds: readonly string[];
}>;
export type ShapeRecord = Readonly<{
id: string;
type: 'shape';
shapeType: string;
pageId: string;
parentId: string | null;
childIds: readonly string[];
transform: Transform;
props: Readonly<Record<string, unknown>>;
businessObjectId: string | null;
}>;
export type AssetRecord = Readonly<{
id: string;
type: 'asset';
kind: 'image';
source: string;
width: number;
height: number;
}>;
export type BindingRecord = Readonly<{
id: string;
type: 'binding';
bindingType: string;
fromShapeId: string;
toShapeId: string;
props: Readonly<Record<string, unknown>>;
}>;
export type AnyRecord = DocumentRecord | PageRecord | ShapeRecord | AssetRecord | BindingRecord;
export type WireDocument = Readonly<{ records: readonly AnyRecord[] }>;
export type RuntimeDocument = Readonly<{
document: DocumentRecord;
pages: ReadonlyMap<string, PageRecord>;
shapes: ReadonlyMap<string, ShapeRecord>;
assets: ReadonlyMap<string, AssetRecord>;
bindings: ReadonlyMap<string, BindingRecord>;
unknown: readonly AnyRecord[];
}>;
export type SessionState = {
selectedShapeIds: ReadonlySet<string>;
camera: { x: number; y: number; zoom: number };
activeTool: string;
};
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isStringList(value: unknown): value is readonly string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
function assertUnique(label: string, values: readonly string[]): void {
if (new Set(values).size !== values.length) throw new Error(`${label} contains duplicate IDs`);
}
function parseRecords(input: unknown): AnyRecord[] {
if (!isObject(input) || !Array.isArray(input.records))
throw new Error('Document payload must contain a records array');
const records: AnyRecord[] = [];
for (const value of input.records) {
if (!isObject(value) || typeof value.id !== 'string' || value.id.length === 0)
throw new Error('Every record needs a non-empty string id');
if (value.type === 'document') {
if (value.schemaVersion !== 1 || !isStringList(value.pageIds))
throw new Error(`Invalid document record: ${value.id}`);
} else if (value.type === 'page') {
if (typeof value.name !== 'string' || !isStringList(value.rootShapeIds))
throw new Error(`Invalid page record: ${value.id}`);
} else if (value.type === 'shape') {
const parentIsValid = value.parentId === null || typeof value.parentId === 'string';
const businessIdIsValid =
value.businessObjectId === null || typeof value.businessObjectId === 'string';
const transform = value.transform;
const transformIsValid =
isObject(transform) &&
['x', 'y', 'rotation', 'scaleX', 'scaleY'].every((key) => Number.isFinite(transform[key]));
if (
typeof value.shapeType !== 'string' ||
typeof value.pageId !== 'string' ||
!parentIsValid ||
!isStringList(value.childIds) ||
!transformIsValid ||
!isObject(value.props) ||
!businessIdIsValid
)
throw new Error(`Invalid shape record: ${value.id}`);
} else if (value.type === 'asset') {
if (
value.kind !== 'image' ||
typeof value.source !== 'string' ||
!Number.isFinite(value.width) ||
!Number.isFinite(value.height)
)
throw new Error(`Invalid asset record: ${value.id}`);
} else if (value.type === 'binding') {
if (
typeof value.bindingType !== 'string' ||
typeof value.fromShapeId !== 'string' ||
typeof value.toShapeId !== 'string' ||
!isObject(value.props)
)
throw new Error(`Invalid binding record: ${value.id}`);
} else {
throw new Error(`Unknown record type on ${value.id}`);
}
records.push(value as AnyRecord);
}
return records;
}
export function loadDocument(wire: unknown, knownShapeTypes: ReadonlySet<string>): RuntimeDocument {
const records = parseRecords(wire);
const ids = new Set<string>();
for (const record of records) {
if (ids.has(record.id)) throw new Error(`Duplicate id: ${record.id}`);
ids.add(record.id);
}
const documents = records.filter((r): r is DocumentRecord => r.type === 'document');
if (documents.length !== 1) throw new Error('Exactly one document record is required');
const pages = new Map(
records.filter((r): r is PageRecord => r.type === 'page').map((r) => [r.id, r]),
);
const allShapes = records.filter((r): r is ShapeRecord => r.type === 'shape');
const shapes = new Map(allShapes.map((r) => [r.id, r]));
const assets = new Map(
records.filter((r): r is AssetRecord => r.type === 'asset').map((r) => [r.id, r]),
);
const bindings = new Map(
records.filter((r): r is BindingRecord => r.type === 'binding').map((r) => [r.id, r]),
);
assertUnique('document.pageIds', documents[0].pageIds);
const documentPageIds = new Set(documents[0].pageIds);
for (const pageId of documentPageIds)
if (!pages.has(pageId)) throw new Error(`Missing page: ${pageId}`);
for (const page of pages.values()) {
if (!documentPageIds.has(page.id)) throw new Error(`Page ${page.id} is outside the document`);
assertUnique(`Page ${page.id} rootShapeIds`, page.rootShapeIds);
for (const rootId of page.rootShapeIds) {
const root = shapes.get(rootId);
if (!root || root.pageId !== page.id || root.parentId !== null)
throw new Error(`Broken root order ${page.id} -> ${rootId}`);
}
}
for (const shape of allShapes) {
const page = pages.get(shape.pageId);
if (!page || !documentPageIds.has(shape.pageId))
throw new Error(`Shape ${shape.id} has missing page ${shape.pageId}`);
assertUnique(`Shape ${shape.id} childIds`, shape.childIds);
if (shape.parentId) {
const parent = shapes.get(shape.parentId);
if (!parent) throw new Error(`Shape ${shape.id} has missing parent ${shape.parentId}`);
if (parent.pageId !== shape.pageId || !parent.childIds.includes(shape.id))
throw new Error(`Broken parent link ${shape.parentId} -> ${shape.id}`);
if (page.rootShapeIds.includes(shape.id))
throw new Error(`Child shape ${shape.id} also appears in root order`);
} else if (!page.rootShapeIds.includes(shape.id)) {
throw new Error(`Root shape ${shape.id} is missing from page order`);
}
for (const childId of shape.childIds) {
const child = shapes.get(childId);
if (!child || child.parentId !== shape.id || child.pageId !== shape.pageId)
throw new Error(`Broken child link ${shape.id} -> ${childId}`);
}
}
const visiting = new Set<string>(),
visited = new Set<string>();
const visit = (id: string): void => {
if (visiting.has(id)) throw new Error(`Group cycle at ${id}`);
if (visited.has(id)) return;
visiting.add(id);
for (const child of shapes.get(id)?.childIds ?? []) visit(child);
visiting.delete(id);
visited.add(id);
};
for (const id of shapes.keys()) visit(id);
for (const binding of bindings.values()) {
if (!shapes.has(binding.fromShapeId) || !shapes.has(binding.toShapeId))
throw new Error(`Binding ${binding.id} has a missing endpoint`);
}
const unknown = allShapes.filter((shape) => !knownShapeTypes.has(shape.shapeType));
return { document: documents[0], pages, shapes, assets, bindings, unknown };
}
export type DocumentCommand =
| { type: 'move-shape'; shapeId: string; dx: number; dy: number }
| { type: 'delete-shape'; shapeId: string };
export function executeCommand(runtime: RuntimeDocument, command: DocumentCommand): WireDocument {
if (command.type !== 'move-shape')
throw new Error('Delete requires a cascade policy and is not implemented');
const target = runtime.shapes.get(command.shapeId);
if (!target) throw new Error(`Unknown shape: ${command.shapeId}`);
const moved: ShapeRecord = {
...target,
transform: {
...target.transform,
x: target.transform.x + command.dx,
y: target.transform.y + command.dy,
},
};
const records: AnyRecord[] = [runtime.document, ...runtime.pages.values()];
for (const shape of runtime.shapes.values()) records.push(shape.id === moved.id ? moved : shape);
records.push(...runtime.assets.values(), ...runtime.bindings.values());
return { records };
}
parseRecords is this chapter’s minimal runnable Schema Gate. Unknown record types, missing fields, and non-finite Transforms fail before Map creation; an unknown shapeType, in contrast, is preserved as a compatibility record. A production project can replace this code with Zod, Valibot, or JSON Schema, but it cannot remove the “validate first, normalize second” boundary. rootShapeIds/childIds also carry sibling Z-Order. Both uniqueness and bidirectional parent-child consistency must be checked, or “stable ordering” remains wishful thinking.
The delete command is deliberately rejected for now. Parent, Child, Binding, and Asset references require a cascade, reparent, or reject policy chosen by an ADR; they must not silently leave orphans. This is an “explicit boundary,” not an empty implementation pretending to be complete.
The Registry interface keeps Geometry independent of Canvas:
export type Bounds = { x: number; y: number; width: number; height: number };
export interface ShapeGeometryProvider {
localBounds(shape: ShapeRecord): Bounds;
containsLocalPoint(shape: ShapeRecord, point: { x: number; y: number }): boolean;
}
export interface ShapeRenderer<TTarget> {
render(shape: ShapeRecord, target: TTarget): void;
}
export class Registry<T> {
private entries = new Map<string, T>();
constructor(private readonly fallback?: T) {}
register(type: string, implementation: T): void {
if (this.entries.has(type)) throw new Error(`Duplicate registry entry: ${type}`);
this.entries.set(type, implementation);
}
resolve(type: string): T {
const value = this.entries.get(type);
if (value) return value;
if (this.fallback) return this.fallback;
throw new Error(`Missing registry entry: ${type}`);
}
}
The Renderer Registry uses a compatibility renderer as its fallback. A Geometry Registry generally should not fabricate geometry, so it can omit a fallback and make the missing capability fail explicitly. This prevents “drawing an unknown placeholder” from being misread as “this unknown Shape can already be hit precisely.”
Tests cover duplicate IDs, cycles, identity, and Renderer replacement:
import { describe, expect, it } from 'vitest';
import { loadDocument, type AnyRecord, type SessionState, type WireDocument } from './records';
import { Registry } from './registries';
const base = (): AnyRecord[] => [
{ id: 'doc', type: 'document', schemaVersion: 1, pageIds: ['page'] },
{ id: 'page', type: 'page', name: 'Main', rootShapeIds: ['order'] },
{
id: 'order',
type: 'shape',
shapeType: 'business-card',
pageId: 'page',
parentId: null,
childIds: [],
transform: { x: 10, y: 20, rotation: 0, scaleX: 1, scaleY: 1 },
props: { label: 'Order' },
businessObjectId: 'order-42',
},
];
describe('document invariants', () => {
it('rejects duplicate immutable identities', () => {
const records = base();
records.push({ ...records[2] });
expect(() => loadDocument({ records } as WireDocument, new Set(['business-card']))).toThrow(
'Duplicate',
);
});
it('rejects a group cycle', () => {
const records = base();
records[1] = { ...records[1], rootShapeIds: [] } as AnyRecord;
records[2] = { ...records[2], parentId: 'group', childIds: ['group'] } as AnyRecord;
records.push({
id: 'group',
type: 'shape',
shapeType: 'group',
pageId: 'page',
parentId: 'order',
childIds: ['order'],
transform: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
props: {},
businessObjectId: null,
});
expect(() => loadDocument({ records }, new Set(['business-card', 'group']))).toThrow('cycle');
});
it('rejects duplicate root z-order entries before rendering', () => {
const records = base();
records[1] = { ...records[1], rootShapeIds: ['order', 'order'] } as AnyRecord;
expect(() => loadDocument({ records }, new Set(['business-card']))).toThrow('duplicate');
});
it('preserves identity and Session when a renderer is replaced', () => {
const runtime = loadDocument({ records: base() }, new Set(['business-card']));
const session: SessionState = {
selectedShapeIds: new Set(['order']),
camera: { x: 0, y: 0, zoom: 1 },
activeTool: 'select',
};
const before = JSON.stringify([...runtime.shapes.values()]);
const canvas = new Registry<(id: string) => string>(() => 'unknown placeholder');
const dom = new Registry<(id: string) => string>(() => '<div>unknown</div>');
canvas.register('business-card', (id) => `canvas:${id}`);
dom.register('business-card', (id) => `<article data-id="${id}" />`);
expect(canvas.resolve('business-card')('order')).not.toBe(
dom.resolve('business-card')('order'),
);
expect(JSON.stringify([...runtime.shapes.values()])).toBe(before);
expect(runtime.shapes.get('order')?.businessObjectId).toBe('order-42');
expect([...session.selectedShapeIds]).toEqual(['order']);
});
it('round-trips an unknown shape and resolves a compatibility renderer', () => {
const records = base();
records[2] = { ...records[2], shapeType: 'future-card', props: { future: 7 } } as AnyRecord;
const runtime = loadDocument({ records }, new Set(['business-card']));
const renderers = new Registry<(id: string) => string>((id) => `unknown:${id}`);
expect(runtime.unknown.map((record) => record.id)).toEqual(['order']);
expect(runtime.shapes.get('order')?.props).toEqual({ future: 7 });
expect(renderers.resolve('future-card')('order')).toBe('unknown:order');
});
});
Run npx vitest run src/lab/ch05/document-store.test.ts; expect 5 passed. Then rerun Chapter 3’s visual test: the Renderer now reads records from the new RuntimeDocument, and the pixels should remain unchanged. Return to the registry book: changing the photographer or art style does not change card numbers, and whoever you point at today is not written into a permanent archive.
Break it on purpose
| Injection | Symptom | Evidence | Fix | Regression test | Recovery |
|---|---|---|---|---|---|
| Delete Parent without handling children | Child Shape has no route to World | Validator reports missing parent | Define reject/reparent/cascade policy | Test every delete policy | Restore from transaction snapshot |
| A→B→A Group cycle | Recursion overflows or freezes | DFS visiting set detects it | Reject cycles before commit | Two-node and self-cycle tests | Reject bad command |
| Duplicate ID in Wire array | Later value silently overwrites earlier value | Count differs before normalization | Check IDs before building Map | duplicate test | Isolate bad payload |
| Duplicate/conflicting order under one Parent | Drawing order is unstable | Two render traces differ | Use one childIds order and validate uniqueness | permutation test | Rebuild stable order |
| Unknown Shape type | Blank output or crash | unknown list is nonempty | Preserve record, show placeholder, keep read-only | round-trip unknown props | Do not delete original record |
| Missing Renderer | Only that type cannot be projected | Registry resolve reaches fallback | compatibility renderer | Document/Geometry tests still run | Load fallback |
| Selection mixed into Document | Save/collaboration produces ghost selection | schema contains selected | Move to SessionStore | Persistence snapshot contains no selection | Remove field and migrate |
For every case, follow “inject → observe symptom → validate error code/record ID → fix validation or transaction → run Node regression → restore from the last valid Document.” Do not let a Renderer try/catch swallow model corruption; that turns a factual error into apparent visual emptiness.
Pass with evidence
| Gate | Automated/manual | Evidence |
|---|---|---|
| Document does not depend on Renderer | Automated | Records module has no DOM/Canvas import and Node tests pass |
| Replacing Renderer does not change Document | Automated | JSON is identical before and after Canvas/DOM implementations |
| Selection survives without contaminating persistence | Automated | Session survives separately; Document snapshot has no selection |
| Persistence Format needs no rewrite | Automated | Different Registries read the same WireDocument |
| Business ID remains stable | Automated/manual | Still order-42 after adapter/renderer swap |
- All five Record types, plus Group, Parent/Child, Z-Order, and Local Transform, have explicit contracts.
- Domain Model, Visual Model, Geometry, and Renderer do not impersonate one another.
- Normalized Records check duplicate IDs before building indexes.
- Derived State and Session State are not written into the Durable Document.
- Renderer/Geometry Registries are independently replaceable, with explicit compatibility behavior for missing implementations.
- Deleting a Parent, cycles, unknown Shapes, conflicting order, and a missing Renderer have all been injected and recovered.
Explain it to a five-year-old
Without using the words “Document,” “Renderer,” “Scene Graph,” “Registry,” or “Derived State,” answer: Why should replacing a family photo with a cartoon not change a child’s identity-card number? Why should “who I am pointing at today” not be written into the permanent family registry?
Expand a good jargon-free answer
A photograph and a cartoon are two appearances of the same family. The child remains the same child; their name, number, and family relationships do not change with the drawing tool. Pointing at someone describes only what you are doing at this moment, and another person opening the book tomorrow should not inherit your gesture. We write identities and relationships that do not change with the drawing style on permanent cards, and put today's pointing on a separate temporary note. Then changing artists, closing the book, and reopening it never makes us confuse one person with another.