fixes "Unknown key" exception
This commit is contained in:
+129
@@ -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()`.
|
||||||
@@ -88,7 +88,8 @@ export class VertexSelection implements Selection<SelectableVertex> {
|
|||||||
}
|
}
|
||||||
if (selectables.length === 0) {return}
|
if (selectables.length === 0) {return}
|
||||||
this.editing.modify(() => selectables
|
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 {
|
deselectAll(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user