Skip to content

fix(client): load dataclass graphs in order and in parallel, for Vue and React - #15

Merged
alesgenova merged 8 commits into
mainfrom
client-graph-loading
Oct 16, 2026
Merged

alesgenova merged 8 commits into
mainfrom
client-graph-loading

Conversation

@alesgenova

Copy link
Copy Markdown
Member

Supersedes #13, and includes the definition-sharing commit from #14.

  • Shared client core (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.
  • Ordered publications (fix(client): keep publication order while an instance hydrates #13): a queue per instance plus server revisions, so deltas aren't lost or rolled back while an instance is loading.
  • Graph loading: graphs load one level at a time, with at most 16 requests in flight, instead of one instance at a time. A provider renders only once its whole graph has arrived, cycles load, and failed fetches are retried.
  • React gets all of the above. It used to render before its data arrived (crashing on nested reads) and deadlock on cycles.

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.

Comment thread client-core/src/index.js Outdated
Comment thread client-core/src/index.js Outdated
@jourdain

Copy link
Copy Markdown
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?

ahinoamp and others added 7 commits October 16, 2026 09:48
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
alesgenova force-pushed the client-graph-loading branch from ba767ad to d0959ef Compare October 16, 2026 14:08
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>
@jourdain

Copy link
Copy Markdown
Collaborator

LGTM

@alesgenova
alesgenova merged commit e472c86 into main Oct 16, 2026
12 checks passed
@alesgenova
alesgenova deleted the client-graph-loading branch October 16, 2026 17:43
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.

3 participants