Skip to content

fix(client): keep publication order while an instance hydrates - #13

Closed
ahinoamp wants to merge 1 commit into
Kitware:mainfrom
ahinoamp:fix/publication-ordering
Closed

ahinoamp wants to merge 1 commit into
Kitware:mainfrom
ahinoamp:fix/publication-ordering

Conversation

@ahinoamp

@ahinoamp ahinoamp commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Problem

In 2.2.1 the Vue DataclassManager can lose a publication, or apply it out of order, while an instance is still hydrating. There are four windows:

  1. The state reply arrives before the definition. fetchState fills dataStates[id] before definition.get resolves. A publication that arrives in between treats the entry as ready. isDataClass then dereferences the missing definition and throws on dataclass_containers, and the delta is lost.
  2. Nested refs are not built yet. Even with the definition cached, fetchState awaits each child container before it builds the next field's ref. A publication during that wait writes to a ref that doesn't exist yet.
  3. The first state.get is still out. The subscription takes the if (!dataStates[id]) { await fetchState(id); return; } path. fetchState returns the already-pending promise, and the handler exits without applying the publication. If the snapshot was taken before the delta, the newer value is gone.
  4. Async handlers overtake each other. A handler that awaits a newly referenced child fetch can resume after a later publication for the same instance, and overwrite the newer value with the older one.

Fix

Client (vue-components/src/core.js):

  • Each publication waits for the instance's complete fetchState, including its nested child handlers.
  • Publications for an instance run through a per-instance promise queue, so a later one can't overtake an earlier one that is still awaiting a child.
  • A failed update is reported and settles only its own queue entry; it doesn't poison later ones.
  • fetchState rejects on RPC failure instead of leaving a promise pending forever.

Server (protocol_v2.py):

  • Snapshots and deltas carry a per-instance revision. The client uses it to drop a replayed delta that a newer snapshot already contains, rather than rolling the value back.
  • Payloads are copied, so a later mutation can't change data that already has a revision.
  • Revisions are keyed by the model's _id, with weakref.finalize cleanup, not by the instance. So models don't need to be hashable (a subclass with __eq__ and no __hash__ keeps working), and two equal-but-distinct models keep separate counters.
  • With a server that sends no revisions, the client keeps the 2.2.1 discovery behaviour, and only replays deltas that arrive during an already-started fetch.

Tests

  • tests/test_protocol_revisions.py: revision numbering, snapshot/payload immutability, weak lifetime, unhashable models, and equal-but-distinct models.
  • vue-components/tests/publication-order.cjs (new npm test script): runs the real core.js against a small fake wslink session with real Vue refs, so every race above is reproduced deterministically by holding RPC replies.
  • Against the unpatched 2.2.1 core.js (DATACLASS_CORE=/path/to/old/core.js npm test), 10 of the 12 client tests fail. The two that pass cover behaviour the patch deliberately keeps.
python -m pip install -e '.[dev]' pytest-asyncio
python -m pytest -q tests        # 24 passed, 1 xfailed (pre-existing)
cd vue-components && npm ci && npm test && npm run build

pre-commit run passes on the changed files.

Small reproduction

A container that swaps in a new child and then changes a scalar, in a burst that starts as soon as the client connects:

import asyncio

from trame.app import TrameApp
from trame.ui.html import DivLayout
from trame.widgets import html
from trame_dataclass.v2 import StateDataModel, Sync


class Child(StateDataModel):
    label = Sync(str, "")


class Parent(StateDataModel):
    step = Sync(int, -1)
    children = Sync(list[Child], list, has_dataclass=True)


class App(TrameApp):
    def __init__(self, server=None):
        super().__init__(server)
        self.parent = Parent(self.server)
        with DivLayout(self.server):
            with self.parent.provide_as("p"):
                html.Div("{{ p.step }}", id="step")
        self.ctrl.on_client_connected.add(self.burst)

    def burst(self, **_):
        asyncio.get_event_loop().create_task(self._burst())

    async def _burst(self):
        # Publish while the client is still hydrating `parent`: each step adds a
        # new child (forcing a nested fetch) and then changes a scalar.
        for i in range(30):
            self.parent.children = [Child(self.server, label=f"c{i}")]
            self.parent.step = i
            await asyncio.sleep(0.005)


if __name__ == "__main__":
    App().server.start()

python demo.py --server --port 8080, then load the page a few times. Measured in Chromium, 12 loads each (6 with no added latency, 6 with 40 ms):

client loads with Cannot read properties of undefined (reading 'dataclass_containers')
2.2.1 from PyPI 11 / 12 (one load also threw on deep_reactive)
this branch 0 / 12

In this demo the final rendered value is correct in both cases. The test file covers the ordering cases, where 2.2.1 ends on the wrong value.

Not in scope

  • Retry or reconnect after a failed hydration: a failure is terminal for that manager instance.
  • Instances collected before they are fetched ({id, state: null}).
  • The React client.

Happy to adjust the approach, or split the client and server parts.

🤖 Generated with Claude Code

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.
Servers without revisions keep 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>
@alesgenova

Copy link
Copy Markdown
Member

Superseeded by #15, which includes these changes

@alesgenova alesgenova closed this Oct 15, 2026
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.

2 participants