|
| 1 | +# Box Graph Internals |
| 2 | + |
| 3 | +## Transaction Model |
| 4 | + |
| 5 | +`BoxEditing.modify()` wraps all box graph mutations in a transaction: |
| 6 | + |
| 7 | +``` |
| 8 | +beginTransaction() |
| 9 | + modifier() ← user code runs here (box creation, deletion, pointer changes) |
| 10 | +endTransaction() ← deferred pointer notifications fire here |
| 11 | +validateRequirements() |
| 12 | +mark() |
| 13 | +notifier.notify() ← BoxEditing subscribers notified (undo/redo state) |
| 14 | +``` |
| 15 | + |
| 16 | +### Nested `modify()` calls |
| 17 | + |
| 18 | +When `modify()` is called while `#modifying` is true or the graph is in a transaction, |
| 19 | +it takes a shortcut path: it calls `this.#notifier.notify()` and then `modifier()` directly, |
| 20 | +without starting a new transaction. The box operations run inside the existing outer transaction. |
| 21 | + |
| 22 | +### Pointer Update Deferral |
| 23 | + |
| 24 | +During a transaction, pointer changes (e.g., `pointer.refer(target)`, `pointer.defer()`) |
| 25 | +are recorded in `#pointerTransactionState` but NOT applied immediately. |
| 26 | + |
| 27 | +At `endTransaction()`, the deferred pointer changes are processed: |
| 28 | + |
| 29 | +```typescript |
| 30 | +this.#pointerTransactionState.values() |
| 31 | + .toSorted((a, b) => a.index - b.index) |
| 32 | + .forEach(({pointer, initial, final}) => { |
| 33 | + if (!initial.equals(final)) { |
| 34 | + initial.ifSome(address => findVertex(address)?.pointerHub.onRemoved(pointer)) |
| 35 | + final.ifSome(address => findVertex(address)?.pointerHub.onAdded(pointer)) |
| 36 | + } |
| 37 | + }) |
| 38 | +``` |
| 39 | + |
| 40 | +This means `pointerHub.onRemoved` / `onAdded` callbacks fire AFTER all mutations complete, |
| 41 | +during `endTransaction()`. Code subscribed via `pointerHub.catchupAndSubscribe()` (e.g., |
| 42 | +`VertexSelection.#watch`) sees the changes only at this point. |
| 43 | + |
| 44 | +After pointer processing, `#inTransaction` is set to false. Then `#finalizeTransactionObservers` |
| 45 | +are executed (these can add more observers in a loop). Finally `onEndTransaction` fires. |
| 46 | + |
| 47 | +## Box Deletion and Cascade |
| 48 | + |
| 49 | +`box.delete()` computes dependencies via `graph.dependenciesOf(box)`: |
| 50 | + |
| 51 | +1. Follows **outgoing** pointers to downstream targets |
| 52 | +2. Follows **incoming** pointers that are `mandatory` to upstream boxes |
| 53 | +3. Collects all dependent boxes and pointers recursively |
| 54 | + |
| 55 | +Then: |
| 56 | +- All collected pointers are deferred (`pointer.defer()`) |
| 57 | +- All collected dependent boxes are unstaged (`box.unstage()`) |
| 58 | +- The root box is unstaged |
| 59 | + |
| 60 | +### Cascade Deletion via `Field.disconnect()` |
| 61 | + |
| 62 | +When a box is unstaged, its fields call `disconnect()`. For target fields with incoming pointers: |
| 63 | + |
| 64 | +```typescript |
| 65 | +disconnect(): void { |
| 66 | + const incoming = this.pointerHub.incoming() |
| 67 | + incoming.forEach(pointer => { |
| 68 | + pointer.defer() |
| 69 | + if (pointer.mandatory || (this.pointerRules.mandatory && incoming.length === 1)) { |
| 70 | + pointer.box.delete() // CASCADE: deletes the box that owns the mandatory pointer |
| 71 | + } |
| 72 | + }) |
| 73 | +} |
| 74 | +``` |
| 75 | + |
| 76 | +**Key implication**: If Box A has a `mandatory` pointer to Box B, deleting Box B |
| 77 | +will cascade-delete Box A within the same transaction. |
| 78 | + |
| 79 | +### SelectionBox Cascade |
| 80 | + |
| 81 | +`SelectionBox` has two mandatory pointers: |
| 82 | +- `selection` → the user's selection field |
| 83 | +- `selectable` → the selected vertex (e.g., a region box) |
| 84 | + |
| 85 | +When a region box is deleted, `disconnect()` on the region's field finds the SelectionBox's |
| 86 | +`selectable` pointer (which is mandatory) and cascade-deletes the SelectionBox. |
| 87 | + |
| 88 | +At `endTransaction()`, the SelectionBox's `selection` pointer fires `onRemoved` on the |
| 89 | +user's selection field, which triggers `VertexSelection.#watch.onRemoved`. This removes |
| 90 | +the entry from `#entityMap` and `#selectableMap`, and notifies `onDeselected` listeners. |
| 91 | + |
| 92 | +## VertexSelection and the `#watch` Mechanism |
| 93 | + |
| 94 | +`VertexSelection.#watch(target)` subscribes to the user's selection field's `pointerHub`: |
| 95 | + |
| 96 | +- **`onAdded`**: A new SelectionBox was created → adds entry to `#entityMap` and `#selectableMap`, |
| 97 | + notifies `onSelected` listeners |
| 98 | +- **`onRemoved`**: A SelectionBox was deleted → removes entry from both maps, |
| 99 | + notifies `onDeselected` listeners (which propagates to `FilteredSelection`) |
| 100 | + |
| 101 | +These callbacks fire during `endTransaction()`, NOT during `modifier()` execution. |
| 102 | + |
| 103 | +## Timing of Side Effects |
| 104 | + |
| 105 | +Within `BoxEditing.modify()`: |
| 106 | + |
| 107 | +| Phase | `#modifying` | `inTransaction()` | Pointer notifications | `#selectableMap` updates | |
| 108 | +|-------|-------------|-------------------|----------------------|------------------------| |
| 109 | +| Before `beginTransaction()` | true | false | No | No | |
| 110 | +| During `modifier()` | true | true | **Deferred** | No | |
| 111 | +| During `endTransaction()` | true | transitions to false | **Firing** | **Yes** | |
| 112 | +| After `endTransaction()` | true→false | false | Done | Done | |
| 113 | +| `notifier.notify()` | false | false | Done | Done | |
| 114 | + |
| 115 | +This means code running inside `modifier()` can safely iterate `#selectableMap` |
| 116 | +because it won't change until `endTransaction()`. But code triggered BY `endTransaction()` |
| 117 | +(via `onRemoved`/`onAdded` cascades, `finalizeTransactionObservers`, or `onEndTransaction`) |
| 118 | +runs AFTER the map has been modified. |
| 119 | + |
| 120 | +## Known Issue: Stale Deselection After Region Deletion |
| 121 | + |
| 122 | +When a region is deleted by the ClipResolver (e.g., during content-start trimming with overlap |
| 123 | +resolution), the cascade deletes the SelectionBox and cleans up `#selectableMap` at |
| 124 | +`endTransaction()`. If a reactive observer later tries to `deselect` the same region |
| 125 | +(e.g., from an animation frame callback), `#selectableMap.get()` throws "Unknown key" |
| 126 | +because the entry was already removed. |
| 127 | + |
| 128 | +Introduced by commit `608f0b48` ("prevent overlapping", Jan 26 2026) which added the |
| 129 | +overlap resolver to `RegionContentStartModifier.approve()`. |
0 commit comments