Repository navigation
fix(client): load dataclass graphs in order and in parallel, for Vue and React - #15
Merged
Merged
Conversation
jourdain
reviewed
Oct 15, 2026
jourdain
reviewed
Oct 15, 2026
Collaborator
|
Looks good as far as I can tell... Try it with tomviz-web, I know that the current version trigger some transient errors in the console. Maybe you've fixed them here? |
A publication could be lost or applied out of order while an instance was still hydrating: - a delta arriving before the class definition threw on `dataclass_containers` and was dropped; - a delta arriving while nested refs were still being fetched wrote to a ref that did not exist yet; - a delta arriving during the first `state.get` was matched to the pending fetch and never applied; - an older async handler awaiting a child fetch could finish after a newer one and overwrite its value. Client: every publication now waits for the instance's whole `fetchState` (nested children included) and runs through a per-instance promise queue; a failed update settles only its own entry. Server: snapshots and deltas carry a per-instance revision, so a replayed delta that a newer snapshot already contains is dropped instead of rolling the value back. Revisions are keyed by the model's `_id`, so models need not be hashable and equal-but-distinct models keep separate counters. The v1 protocol, which also uses this client but sends no revisions, keeps the previous behaviour. Adds tests/test_protocol_revisions.py and vue-components/tests/ publication-order.cjs (`npm test`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`typeDefinitions[id]` is only filled when the reply lands, so every instance of a class that is hydrating at the same time finds it missing and asks for it again: N instances of one class cost N definition requests. Nothing noticed while instances hydrated one after another, because the first reply was cached before the second instance started. The request is now deduplicated the way `fetchState` deduplicates state requests, by sharing the pending promise. A rejection removes its own entry - conditionally, so a failure cannot evict a request that replaced it - which leaves the class fetchable again. Measured in Chromium on a 20-provider page whose instances share one class, started together, with 40 ms added to every RPC on a server that answers one at a time: 41 requests and 1.73 s to the last provider rendered, against 21 requests and 0.94 s with this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 35a0db0)
`fetchState` meant "this instance, plus its children unless someone else was already loading them": it skipped any child whose `dataStates` entry existed, which is what kept cycles from deadlocking, and that entry exists before the child's definition and refs. Every caller then built its own wait on top of it, and `link`, `getGUITemplate`, publications and nested hydration each handed out half-built instances in some interleaving. It is split in two: - `fetchInstance(id)` fetches one instance: its state, its class definition and its own refs. The instances it points at are started, never awaited, so it never waits on another instance and no cycle can deadlock it. `dataStates[id]` is now only set once the refs are built. - `fetchInstanceRecursive(id)` waits for everything reachable from `id`, one level of the graph at a time, from outside any fetch. Instances it has completed are remembered and not walked again. `link` waits for the whole graph (superseding f4e4323 from #14) and no longer links a component that moved on to another instance meanwhile. Publications wait for the graph of every instance they reference. `getGUITemplate` waits for the instance's definition. `dataToVue` only existed to refresh components linked to a half-built instance, so it is gone. A failed fetch used to stay cached in `pendingFetch`, so every later publication for that id re-awaited the same rejection and was dropped until the page reloaded. The failed attempt is now forgotten and the next request starts over. Children are no longer fetched one after another, so every state and definition request now goes through `callServer`, which keeps at most `maxConcurrentCalls` (16) in flight. Class definitions skip the queue, since one unblocks every instance of its class. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Vue and React clients each carried their own copy of everything that talks to the server, and the copies had drifted: the publication-order and hydration fixes made to the Vue client never reached the React one. `client-core/` now holds that half once: requests and the concurrency cap, class definitions, fetching an instance and walking its graph, the publication queue with revisions, and client edits. A client subclasses `DataclassCore` and only decides how an instance is represented, through `buildInstance(id, state)` and `applyState(id, state)`. Each client bundles the core into its own UMD, so nothing is shared at runtime. The Vue client is the first one moved onto it. Behavior changes: - Each instance's references are tracked by the core, from the server's ids and from client edits, instead of being read back off Vue refs. - Edits queued behind an in-flight request are coalesced, last value per field, before being compared to what the server holds. Comparing them one at a time sent Y and dropped the return to X in an X -> Y -> X sequence, leaving the server on Y. - An instance the server does not know (`state: null`) is no longer a failed fetch: it resolves as missing, is not cached, and its provider stays unavailable. The core's tests run against a minimal subclass, covering the scenarios of the previous Vue suite; Vue keeps tests for its own layer. Both share one fake-server harness. vue-components becomes `"type": "module"` so the tests import the real sources, with the UMD file name pinned so it stays `.umd.js`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The React client had its own fetch and publication logic, and with it:
- a provider was "available" before any data arrived, so a template read
such as `tree.children[1]` threw and blanked the page;
- a cycle in the instance graph never finished loading, each fetch waiting
on the other's completion;
- children were fetched one at a time;
- a delta arriving during the first fetch was overwritten by the older
snapshot, and one arriving before the class definition stored a reference
as a plain id that the snapshot then dropped;
- publications were not queued per instance, and ignored revisions;
- a failed fetch was cached for good.
It now subclasses `DataclassCore`, keeping only its representation: one
target object and version counter per instance, read through proxies.
`getSnapshot` returns null until the instance's whole graph is complete, so
`{name}_available` means what the Provider documents, and the Provider asks
for the whole graph.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The JS tests were never run in CI. The npm job now runs the client-core, Vue and React suites alongside the builds. The README's development section covers the React build too - a server started from source needs both bundles - and how to run the client tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
examples/validation/graph_loading.py: one provider over 1 library -> 10
shelves -> 20 books (211 instances); the page reports how long the whole
graph took to reach the client. Headless Chrome, fresh page per run, latency
added by a TCP proxy delaying 20 ms each way:
previous client level-by-level fetch
localhost (n=5) 82 ms 29 ms
40 ms RTT (n=3) 9725 ms 756 ms
examples/react/nested_graph_react.py: one provider reads two levels down a
tree, another reads through two nodes that point at each other, and a third
(always=True) reports `tree_available`. With 40 ms of round-trip latency:
previous React client: the page goes blank,
"TypeError: Cannot read properties of undefined (reading '1')"
shared client core: status: loading -> cycle: ping
-> status: loaded | deep: b0 | cycle: ping
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alesgenova
force-pushed
the
client-graph-loading
branch
from
October 16, 2026 14:08
ba767ad to
d0959ef
Compare
The Vue and React bundles serve both the v1 and the v2 protocol. Only v2 numbered its snapshots and deltas, so the client had to tell the two apart by whether a message carried a revision, and kept a second rule for v1: skip the publication that announced an instance, since its snapshot already holds it. The revision bookkeeping moves out of the v2 protocol into a mixin that both protocols use: `state.get` returns a frozen snapshot and its revision, and `push_delta` numbers every delta per instance. Each protocol points the mixin at its own model registry, since v1 and v2 keep separate ones. The client now has a single rule for both: drop a delta that is not newer than what it already holds. The revision tests run against both protocols. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator
|
LGTM |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #13, and includes the definition-sharing commit from #14.
client-core/): fetching, graph loading, publication ordering and client edits now exist once. The Vue and React clients only decide how an instance is represented.Loading 211 instances at 40 ms round-trip latency: 9.7 s → 0.76 s (
examples/validation/graph_loading.py).Testing: the client-core, Vue and React suites (CI now runs them) and the Python suite; also checked in a browser with both clients.