From 670190fd72fd7270fd369719f3b684a7c4eff543 Mon Sep 17 00:00:00 2001 From: Ahinoam Pollack Date: Wed, 28 Oct 2026 15:49:23 +0100 Subject: [PATCH] feat(client): prefetch a list of instances in one wave A provider renders its slot only once its own instance is available, so providers nested inside each other load one after another: the inner one is not created until the outer one has loaded. DataclassCore.prefetch(ids) starts every id loading at once, each over its whole graph, and resolves to [{ id, ready, error }] once all have settled. It reuses fetchInstanceRecursive, so every request is shared with the providers and nothing is fetched twice. The Vue component trame-dataclass-prefetch and the dataclass.Prefetch widget wrap it. The slot always renders; each provider still waits for its own data. Co-Authored-By: Claude Opus 5.5 --- client-core/src/index.js | 17 ++++ client-core/tests/prefetch.test.js | 78 +++++++++++++++++++ examples/getting_started/prefetch.py | 59 ++++++++++++++ src/trame_dataclass/widgets/dataclass.py | 50 ++++++++++++ tests/test_widgets_prefetch.py | 45 +++++++++++ .../src/components/TrameDataclassPrefetch.js | 56 +++++++++++++ vue-components/src/components/index.js | 2 + 7 files changed, 307 insertions(+) create mode 100644 client-core/tests/prefetch.test.js create mode 100644 examples/getting_started/prefetch.py create mode 100644 tests/test_widgets_prefetch.py create mode 100644 vue-components/src/components/TrameDataclassPrefetch.js diff --git a/client-core/src/index.js b/client-core/src/index.js index 1d0a74b..d64d475 100644 --- a/client-core/src/index.js +++ b/client-core/src/index.js @@ -302,6 +302,23 @@ export class DataclassCore { return await pending; } + // Start every id in `ids` loading at once, each over its whole graph, and resolve + // to `[{ id, ready, error }]` once all of them have settled. Nested providers each + // wait for their parent before they mount, so their fetches would otherwise run one + // after another. Every request underneath is shared with the providers, so a + // provider that mounts later finds its instance already loading or loaded. + async prefetch(ids) { + const settled = await Promise.allSettled( + ids.map((id) => this.fetchInstanceRecursive(id)), + ); + return ids.map((id, index) => ({ + id, + ready: settled[index].status === "fulfilled" && settled[index].value, + error: + settled[index].status === "rejected" ? settled[index].reason : null, + })); + } + // `id` and every instance it reaches, one level of the graph at a time: each level // is fetched in parallel, and a cycle is only an instance already seen. A complete // instance stays complete, because a publication never commits a reference before diff --git a/client-core/tests/prefetch.test.js b/client-core/tests/prefetch.test.js new file mode 100644 index 0000000..8d00760 --- /dev/null +++ b/client-core/tests/prefetch.test.js @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DataclassCore } from "../src/index.js"; +import { connect, definition, state, tick } from "./harness.js"; + +class PlainManager extends DataclassCore { + constructor() { + super(); + this.values = {}; + } + + buildInstance(id, values) { + this.values[id] = { ...values }; + } + + applyState(id, values) { + Object.assign(this.values[id], values); + } +} + +const stateRequests = (calls) => + calls + .filter(([name]) => name === "trame.dataclass.state.get") + .map(([, args]) => args[0]); + +test("prefetch requests every id in one wave", async () => { + const states = {}; + for (let i = 0; i < 6; i++) states[`m${i}`] = state({ value: i }); + const f = connect(new PlainManager(), states); + const results = await f.manager.prefetch(Object.keys(states)); + assert.deepEqual( + results.map(({ id, ready }) => [id, ready]), + Object.keys(states).map((id) => [id, true]), + ); + assert.equal(f.maxInFlight() > 1, true); + assert.deepEqual(stateRequests(f.calls).sort(), Object.keys(states).sort()); +}); + +test("prefetch loads the children of a container", async () => { + const f = connect( + new PlainManager(), + { + parent: state({ items: ["c1", "c2"] }, "Parent"), + c1: state({ value: 1 }), + c2: state({ value: 2 }), + }, + { Parent: definition(["items"]), Layer: definition() }, + ); + await f.manager.prefetch(["parent"]); + assert.deepEqual(stateRequests(f.calls).sort(), ["c1", "c2", "parent"]); + assert.equal(f.manager.values.c2.value, 2); +}); + +test("an id already loading or loaded is not fetched again", async () => { + const f = connect(new PlainManager(), { + a: state({ value: 1 }), + b: state({ value: 2 }), + }); + const first = f.manager.prefetch(["a"]); + await f.manager.prefetch(["a", "b"]); + await first; + await f.manager.prefetch(["a", "b"]); + await tick(); + assert.deepEqual(stateRequests(f.calls).sort(), ["a", "b"]); +}); + +test("an unknown id is reported without failing the rest of the wave", async () => { + const f = connect(new PlainManager(), { a: state({ value: 1 }) }); + const results = await f.manager.prefetch(["a", "missing"]); + assert.deepEqual( + results.map(({ id, ready }) => [id, ready]), + [ + ["a", true], + ["missing", false], + ], + ); +}); diff --git a/examples/getting_started/prefetch.py b/examples/getting_started/prefetch.py new file mode 100644 index 0000000..6064967 --- /dev/null +++ b/examples/getting_started/prefetch.py @@ -0,0 +1,59 @@ +#!/usr/bin/env -S uv run --script +# +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "trame", +# "trame-dataclass>=2", +# ] +# /// +# +# Nested providers hydrate one after another: each one only shows its slot once its +# own instance has arrived, so the one inside it is not even created until then. +# +# python prefetch.py --server # wrapped in a Prefetch (default) +# python prefetch.py --server --serial # the same page without it +# +# Both pages end up identical. Only the shape of the loading differs: one wave of +# requests, or a chain of replies as long as the nesting. + +import sys +from contextlib import ExitStack + +from trame.ui.html import DivLayout + +from trame.app import TrameApp +from trame_dataclass.v2 import StateDataModel, Sync +from trame.widgets import dataclass, html + +DEPTH = 20 + + +class Section(StateDataModel): + title = Sync(str, "") + + +class Nested(TrameApp): + def __init__(self, server=None, prefetch=True): + super().__init__(server) + self.sections = [ + Section(self.server, title=f"section {i}") for i in range(DEPTH) + ] + self._build_ui(prefetch) + + def _build_ui(self, prefetch): + with DivLayout(self.server), ExitStack() as stack: + if prefetch: + stack.enter_context( + dataclass.Prefetch( + instances=[section._id for section in self.sections] + ) + ) + for i, section in enumerate(self.sections): + stack.enter_context(section.provide_as(f"s{i}")) + html.Div(f"{{{{ s{i}.title }}}}", id=f"s{i}") + + +if __name__ == "__main__": + app = Nested(prefetch="--serial" not in sys.argv) + app.server.start() diff --git a/src/trame_dataclass/widgets/dataclass.py b/src/trame_dataclass/widgets/dataclass.py index dc68a14..ea9fa9a 100644 --- a/src/trame_dataclass/widgets/dataclass.py +++ b/src/trame_dataclass/widgets/dataclass.py @@ -22,6 +22,7 @@ def __init__(self, _elem_name, version="v2", children=None, **kwargs): __all__ = [ "Gui", + "Prefetch", "Provider", ] @@ -173,3 +174,52 @@ def __init__(self, **kwargs): ) raise NotImplementedError(msg) self._attr_names += ["instance"] + + +def _js_string_array(values): + """``["ab", "cd"]`` -> ``"['ab', 'cd']"``, refusing ids a quote could escape.""" + out = [] + for value in values: + text = str(value) + if "'" in text or '"' in text or "\\" in text: + msg = f"instance id is not safe to inline in a template: {text!r}" + raise ValueError(msg) + out.append(f"'{text}'") + return "[" + ", ".join(out) + "]" + + +class Prefetch(HtmlElement): + """Start loading a list of instances at once, and render its children immediately. + + A :class:`Provider` only renders its children once its own instance is + available, so providers nested inside each other load one after another. + Wrapping them in a ``Prefetch`` asks for every instance up front:: + + with dataclass.Prefetch(instances=[m._id for m in models]): + with models[0].provide_as("settings"), models[1].provide_as("scene"): + ... + + It is not a barrier: the children always render, and each provider still waits + for its own data. Vue only. + """ + + def __init__(self, instances=None, **kwargs): + """Args: + instances: the instance ids to load, as a list of strings, or a tuple + holding a Vue binding expression. + **kwargs: additional element attributes. + """ + super().__init__("trame-dataclass-prefetch", **kwargs) + if self.server.client_type == "react": + msg = "dataclass.Prefetch is not supported with client_type='react'" + raise NotImplementedError(msg) + self._attr_names += ["instances"] + if instances is not None: + # A list must become a bound array literal: trame reads a sequence as + # its (expression, default) shorthand, and the literal must be + # single-quoted to stay inside the HTML attribute. + self["instances"] = ( + instances + if isinstance(instances, tuple) + else (_js_string_array(instances),) + ) diff --git a/tests/test_widgets_prefetch.py b/tests/test_widgets_prefetch.py new file mode 100644 index 0000000..466037f --- /dev/null +++ b/tests/test_widgets_prefetch.py @@ -0,0 +1,45 @@ +import itertools + +import pytest +from trame.ui.html import DivLayout + +from trame.app import get_server +from trame_dataclass.widgets import dataclass + +COUNTER = itertools.count() + + +def render(client_type, **kwargs): + server = get_server(f"prefetch-{next(COUNTER)}", client_type=client_type) + with DivLayout(server) as layout, dataclass.Prefetch(**kwargs): + pass + return layout.html + + +def test_list_of_ids_becomes_a_bound_array_literal(): + # Not `instances="['ab', 'cd']"`: an unbound attribute is a string, and the + # component expects an array. Single quotes, because the double ones a JSON dump + # would use close the HTML attribute at the first inner quote. + assert ":instances=\"['ab', 'cd']\"" in render("vue3", instances=["ab", "cd"]) + + +def test_an_expression_stays_a_binding(): + # A tuple is trame's "bind this expression" shorthand, for a list that changes. + assert ':instances="ids"' in render("vue3", instances=("ids",)) + + +def test_no_instances_renders_the_component_without_the_prop(): + html = render("vue3") + assert "trame-dataclass-prefetch" in html + assert "instances" not in html + + +def test_an_id_a_quote_could_escape_is_refused(): + # The alternative to refusing is emitting a template that does not compile. + with pytest.raises(ValueError, match="not safe to inline"): + render("vue3", instances=["ab', x='"]) + + +def test_react_client_is_refused(): + with pytest.raises(NotImplementedError, match="client_type='react'"): + render("react", instances=["ab"]) diff --git a/vue-components/src/components/TrameDataclassPrefetch.js b/vue-components/src/components/TrameDataclassPrefetch.js new file mode 100644 index 0000000..74e2a16 --- /dev/null +++ b/vue-components/src/components/TrameDataclassPrefetch.js @@ -0,0 +1,56 @@ +import { inject, watch, onBeforeUnmount } from "vue"; + +import { GLOBAL_DATA_MANAGER } from "./TrameDataclass"; + +// Start loading a list of instances at once, and render the slot immediately. +// +// A provider only mounts its slot once its own instance is available, so providers +// nested inside each other load one after another. Wrapping them in this component +// asks the manager for every id up front, so each provider finds its instance +// already loading. It is not a barrier: the slot always renders, and each provider +// still waits for its own data. Removing it changes timing only. +export default { + props: { + instances: { + type: Array, + default: () => [], + }, + }, + setup(props) { + const trame = inject("trame"); + GLOBAL_DATA_MANAGER.connect(trame.client); + // A later list, or an unmount, must not report an older wave's failures. + let generation = 0; + watch( + () => props.instances, + (instances) => { + const mine = ++generation; + if (!Array.isArray(instances)) { + console.warn( + "TrameDataclassPrefetch: `instances` must be an array, got", + instances, + ); + return; + } + const ids = instances.map(String).filter(Boolean); + if (!ids.length) return; + GLOBAL_DATA_MANAGER.prefetch(ids).then((results) => { + if (mine !== generation) return; + const failed = results.filter((result) => !result.ready); + if (failed.length) { + console.warn( + "Dataclass prefetch could not load", + failed.map((result) => result.id), + ); + } + }); + }, + { immediate: true }, + ); + onBeforeUnmount(() => { + generation++; + }); + return {}; + }, + template: "", +}; diff --git a/vue-components/src/components/index.js b/vue-components/src/components/index.js index df8fe5f..7097bd5 100644 --- a/vue-components/src/components/index.js +++ b/vue-components/src/components/index.js @@ -1,7 +1,9 @@ import TrameDataclass from "./TrameDataclass"; import TrameDataclassGui from "./TrameDataclassGui"; +import TrameDataclassPrefetch from "./TrameDataclassPrefetch"; export default { TrameDataclass, TrameDataclassGui, + TrameDataclassPrefetch, };