diff --git a/docs/graph.md b/docs/graph.md new file mode 100644 index 00000000..29c7d24b --- /dev/null +++ b/docs/graph.md @@ -0,0 +1,129 @@ +# Box Graph Internals + +## Transaction Model + +`BoxEditing.modify()` wraps all box graph mutations in a transaction: + +``` +beginTransaction() + modifier() ← user code runs here (box creation, deletion, pointer changes) +endTransaction() ← deferred pointer notifications fire here +validateRequirements() +mark() +notifier.notify() ← BoxEditing subscribers notified (undo/redo state) +``` + +### Nested `modify()` calls + +When `modify()` is called while `#modifying` is true or the graph is in a transaction, +it takes a shortcut path: it calls `this.#notifier.notify()` and then `modifier()` directly, +without starting a new transaction. The box operations run inside the existing outer transaction. + +### Pointer Update Deferral + +During a transaction, pointer changes (e.g., `pointer.refer(target)`, `pointer.defer()`) +are recorded in `#pointerTransactionState` but NOT applied immediately. + +At `endTransaction()`, the deferred pointer changes are processed: + +```typescript +this.#pointerTransactionState.values() + .toSorted((a, b) => a.index - b.index) + .forEach(({pointer, initial, final}) => { + if (!initial.equals(final)) { + initial.ifSome(address => findVertex(address)?.pointerHub.onRemoved(pointer)) + final.ifSome(address => findVertex(address)?.pointerHub.onAdded(pointer)) + } + }) +``` + +This means `pointerHub.onRemoved` / `onAdded` callbacks fire AFTER all mutations complete, +during `endTransaction()`. Code subscribed via `pointerHub.catchupAndSubscribe()` (e.g., +`VertexSelection.#watch`) sees the changes only at this point. + +After pointer processing, `#inTransaction` is set to false. Then `#finalizeTransactionObservers` +are executed (these can add more observers in a loop). Finally `onEndTransaction` fires. + +## Box Deletion and Cascade + +`box.delete()` computes dependencies via `graph.dependenciesOf(box)`: + +1. Follows **outgoing** pointers to downstream targets +2. Follows **incoming** pointers that are `mandatory` to upstream boxes +3. Collects all dependent boxes and pointers recursively + +Then: +- All collected pointers are deferred (`pointer.defer()`) +- All collected dependent boxes are unstaged (`box.unstage()`) +- The root box is unstaged + +### Cascade Deletion via `Field.disconnect()` + +When a box is unstaged, its fields call `disconnect()`. For target fields with incoming pointers: + +```typescript +disconnect(): void { + const incoming = this.pointerHub.incoming() + incoming.forEach(pointer => { + pointer.defer() + if (pointer.mandatory || (this.pointerRules.mandatory && incoming.length === 1)) { + pointer.box.delete() // CASCADE: deletes the box that owns the mandatory pointer + } + }) +} +``` + +**Key implication**: If Box A has a `mandatory` pointer to Box B, deleting Box B +will cascade-delete Box A within the same transaction. + +### SelectionBox Cascade + +`SelectionBox` has two mandatory pointers: +- `selection` → the user's selection field +- `selectable` → the selected vertex (e.g., a region box) + +When a region box is deleted, `disconnect()` on the region's field finds the SelectionBox's +`selectable` pointer (which is mandatory) and cascade-deletes the SelectionBox. + +At `endTransaction()`, the SelectionBox's `selection` pointer fires `onRemoved` on the +user's selection field, which triggers `VertexSelection.#watch.onRemoved`. This removes +the entry from `#entityMap` and `#selectableMap`, and notifies `onDeselected` listeners. + +## VertexSelection and the `#watch` Mechanism + +`VertexSelection.#watch(target)` subscribes to the user's selection field's `pointerHub`: + +- **`onAdded`**: A new SelectionBox was created → adds entry to `#entityMap` and `#selectableMap`, + notifies `onSelected` listeners +- **`onRemoved`**: A SelectionBox was deleted → removes entry from both maps, + notifies `onDeselected` listeners (which propagates to `FilteredSelection`) + +These callbacks fire during `endTransaction()`, NOT during `modifier()` execution. + +## Timing of Side Effects + +Within `BoxEditing.modify()`: + +| Phase | `#modifying` | `inTransaction()` | Pointer notifications | `#selectableMap` updates | +|-------|-------------|-------------------|----------------------|------------------------| +| Before `beginTransaction()` | true | false | No | No | +| During `modifier()` | true | true | **Deferred** | No | +| During `endTransaction()` | true | transitions to false | **Firing** | **Yes** | +| After `endTransaction()` | true→false | false | Done | Done | +| `notifier.notify()` | false | false | Done | Done | + +This means code running inside `modifier()` can safely iterate `#selectableMap` +because it won't change until `endTransaction()`. But code triggered BY `endTransaction()` +(via `onRemoved`/`onAdded` cascades, `finalizeTransactionObservers`, or `onEndTransaction`) +runs AFTER the map has been modified. + +## Known Issue: Stale Deselection After Region Deletion + +When a region is deleted by the ClipResolver (e.g., during content-start trimming with overlap +resolution), the cascade deletes the SelectionBox and cleans up `#selectableMap` at +`endTransaction()`. If a reactive observer later tries to `deselect` the same region +(e.g., from an animation frame callback), `#selectableMap.get()` throws "Unknown key" +because the entry was already removed. + +Introduced by commit `608f0b48` ("prevent overlapping", Jan 26 2026) which added the +overlap resolver to `RegionContentStartModifier.approve()`. diff --git a/packages/studio/adapters/src/selection/VertexSelection.ts b/packages/studio/adapters/src/selection/VertexSelection.ts index ba683acb..c4efe408 100644 --- a/packages/studio/adapters/src/selection/VertexSelection.ts +++ b/packages/studio/adapters/src/selection/VertexSelection.ts @@ -88,7 +88,8 @@ export class VertexSelection implements Selection { } if (selectables.length === 0) {return} this.editing.modify(() => selectables - .forEach(selectable => this.#selectableMap.get(selectable.address).box.delete()), false) + .forEach(selectable => this.#selectableMap.opt(selectable.address) + .ifSome(entry => entry.box.delete())), false) } deselectAll(): void {