Skip to content

feat(vue-model-api): let apps opt in to updating references to removed nodes - #3014

Open
SaSteffen wants to merge 2 commits into
modelix:mainfrom
SaSteffen:fix/vue-model-api-deleted-reference-targets
Open

SaSteffen wants to merge 2 commits into
modelix:mainfrom
SaSteffen:fix/vue-model-api-deleted-reference-targets

Conversation

@SaSteffen

@SaSteffen SaSteffen commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Problem

  • MPS removes a node without clearing the references to it. It does this on purpose: it keeps dangling references and offers to list them for repair.
  • After such a removal is synced, a Vue view that shows one of these references keeps showing the removed node. It does not update.
  • Repro: open a view that shows the target of a reference. Remove that target in MPS and sync to the model server.
  • Cause: JS change handlers get only ChildrenChanged for the parent of a removed node. The references to it do not change, so nothing tells vue-model-api that its cached target is gone.

Impact

  • Nothing changes unless an app opts in. Apps must change their code to get the new behavior.
  • addListener handlers get the same events in the same order as before.
  • No server-side changes. Only the JS source sets of model-client and vue-model-api change. The model server, the JVM client and the MPS plugin are not affected.
  • Both commits are feat. Two breaks remain for code that implements or matches on the exported types. See the open question below.

Changes

  • model-client: new MutableModelTreeJs.addListenerIncludingAddedAndRemovedNodes. A handler added with it gets all events that addListener handlers get. It also gets NodeAdded and NodeRemoved, for every node of an added or removed subtree. These two come from a separate tree listener. It is only registered for such a handler and is removed with it. Their order relative to the other events is not specified.
  • vue-model-api: useReplicatedModel, useReplicatedModels and useModelsFromJson get an optional last parameter of the new type ReactiveModelOptions. With updateReferencesToRemovedNodes: true, the hook builds its nodes from a subclass of the reactive node and uses the new listener. getReferenceTargetNode then reads a reference to a removed node as unset, the same as after a reload. getReferenceTargetRef still returns the reference. Without the option, the hook uses the same classes and listener as before.
  • The hooks read the option once when they are called. Changing the object later has no effect.
  • useReadonlyVersion gets no option. Its tree never sends change events.

Cost

Without the opt-in:

  • model-client: the change listener and the per-change path are the same code as on main. One more field per branch, null until a handler opts in. One null check in removeListener.
  • vue-model-api: the reactive node, the cache and handleChange run the same code as on main. Each hook makes one type check when it registers its handler, not per event. No new fields, objects or checks per node, per read or per event.

With the opt-in, per added or removed node:

  • model-client: the tree changes are diffed once more per opt-in handler, by its own listener. One event per added or removed node, with the same short-lived node wrappers as any other change event.
  • vue-model-api: the node's reference is turned into a string and looked up in the cache, as for any other change event. If the node was read as a reference target, one flag is written. Only the views that read a reference to that node update.
  • Memory: one shallowRef for each cached node that was read as a reference target. It is freed with the cached node.
  • Every Vue effect that reads a reference also tracks the flag of the target: one more Vue dependency per read.

How it scales with the opt-in:

  • The work grows linearly with the number of nodes added or removed in one transaction. Removing a subtree of 10,000 nodes gives 10,000 events. Each costs about as much as one property change does today.
  • Bulk changes pay the most. A large merge from the server that adds many nodes sends every one of them through the lookup, even though only re-added nodes matter.
  • It does not grow with the size of the model. The extra memory grows with the number of cached nodes that were read as reference targets, not with the number of references.

Known limits

  • When a removed node is added again with the same reference, for example by an undo, a reference to it resolves again. This only works if the reference was read while the node existed. A reference that is first read, or changed, while the node is removed stays unset until the reference changes again. An expected-failure test (test.failing) describes the wanted behavior.
  • Undo is only tested with fake nodes.
  • Not checked: the cache matches nodes by their serialized reference. A reference stored in another format than the node's own reference would not be flagged.

Open question: two breaks for typed consumers

  • NodeAdded and NodeRemoved are new subtypes of the sealed ChangeJS. An exhaustive when over ChangeJS in Kotlin code that uses the JS klib no longer compiles.
  • addListenerIncludingAddedAndRemovedNodes is a new abstract member of the exported MutableModelTreeJs. A TypeScript or Kotlin class that implements it no longer compiles. Code that only calls it is not affected.
  • Alternative for the first break: keep the new events out of ChangeJS. They would get their own handler type, for example (node: INodeJS, removed: Boolean) -> Unit, and their own method to register it. The cost is more API, and vue-model-api would register two handlers instead of one. The second break stays, because that method is also a new interface member.
  • Are these breaks acceptable for a feat, or do you prefer the alternative?

Design decision

  • Making it the default was rejected. Every app would pay per added or removed node.
  • A separate tree listener for the opt-in was chosen over one shared listener with a check. The default path stays the same code as on main. Opted-in apps pay for a second diff.
  • A subclass of the reactive node was chosen over a flag check in the reference getter. Default nodes read references with the same code as on main.
  • Re-reading all cached references on every ChildrenChanged was rejected. It would cost time on every edit to children, even edits that neither add nor remove nodes.
  • Indexing the read references by their target was rejected. It costs memory for every read reference.
  • Sending NodeAdded and NodeRemoved to every handler was rejected. Existing handlers would get one call per node of a removed subtree.

Test plan

  • MutableModelTreeJsTest:
    • addListener still gets one event for a removal.
    • Handlers are called in the order they were added.
    • An opt-in handler gets every node of a removed subtree, an added node, and every node of an added subtree.
    • Without an opt-in handler, no added or removed events are reported and only one tree listener is registered. Opting in adds a second listener. removeListener removes it.
  • ReactiveINodeJS.test.ts:
    • Without the option: nodes are not the subclass and have no removal flag. A reference to a removed node behaves as before.
    • With the option: only targets of a read reference get the flag. Removed target, target in a removed subtree, holder and target removed together, former target removed, moved target, unrelated changes, a reference read outside a computed, and undo.
    • The undo limit is an expected-failure test.
  • Hook tests: useReplicatedModel and useReplicatedModels use only addListener by default, and only the new listener with the option. Changing the options object after the call does not change the listener used on reconnect. useModelsFromJson has no hook test.
  • Run locally: MutableModelTreeJsTest 15 of 15 pass. Jest ReactiveINodeJS.test.ts, useReplicatedModel.test.ts and useReplicatedModels.test.ts 36 of 36 pass. tsc --noEmit passes, also with useDefineForClassFields. pre-commit passes; detekt shows no new findings in the changed files.
  • Manual test in a Vue app with the option: remove a reference target in MPS. The view updates without a reload.

🤖 Generated with Claude Code

@SaSteffen
SaSteffen marked this pull request as draft October 9, 2026 09:06
@SaSteffen
SaSteffen force-pushed the fix/vue-model-api-deleted-reference-targets branch 2 times, most recently from 4111fbf to d2c85bc Compare October 9, 2026 10:45
@SaSteffen SaSteffen changed the title fix(vue-model-api): read references to deleted nodes as unset feat(vue-model-api): let apps opt in to updating references to removed nodes Oct 9, 2026
@SaSteffen
SaSteffen force-pushed the fix/vue-model-api-deleted-reference-targets branch from d2c85bc to 61c3460 Compare October 9, 2026 11:21
SaSteffen and others added 2 commits October 12, 2026 12:19
…d nodes

A removed node is reported to JS change handlers only as a change to the children of its parent.
A handler that keeps state per node, like a cache, cannot tell which nodes are gone.
Handlers can now opt in to also get every node of an added or removed subtree.

Design decision: this is opt-in, so existing handlers keep their event count.
The changes for added and removed nodes are only created when a handler opted in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d nodes

Removing a node is reported only as a change to the children of its parent,
so a reference to the removed node, or to one of its descendants, keeps its cached target.
A view that shows such a reference keeps showing the removed node and does not update.
With the new option `updateReferencesToRemovedNodes`,
`getReferenceTargetNode` reads such a reference as unset.
The option is off by default, because it costs work for every added or removed node.

When the node is added again with the same reference, for example by an undo,
the reference resolves again if it was read while the node existed.
Known limit: a reference that is first read, or changed, while the node is removed
stays unset after the node is added again, until the reference changes again.

Design decision: a removed node is flagged, instead of tracking the references to it.
Only nodes that were read as a reference target get the flag,
so other nodes cost nothing extra.
`useReadonlyVersion` takes no option, because a readonly version never changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@SaSteffen
SaSteffen force-pushed the fix/vue-model-api-deleted-reference-targets branch from 61c3460 to 5fa05e9 Compare October 12, 2026 10:20
@SaSteffen
SaSteffen marked this pull request as ready for review October 12, 2026 12:26

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant