From f4e43232cb292de82ca69b5d418bb412892d203a Mon Sep 17 00:00:00 2001 From: Ahinoam Pollack Date: Wed, 7 Oct 2026 01:02:39 +0200 Subject: [PATCH 1/3] fix(client): link a component to a complete instance, not an allocated one `link` tested `dataStates[id]` to decide whether to wait. That entry is created as soon as the state reply lands - before the class definition is cached and before the nested refs are built - so a component that links during that window is handed an instance whose fields are still undefined and renders them. Serial hydration made the window hard to reach: each provider's slot waits for its own instance, so the next one was not created until the previous one was complete. Anything that starts several instances at once makes it ordinary. `fetchState` already deduplicates by id, so awaiting it unconditionally is a wait on work that is already under way rather than a second request. Co-Authored-By: Claude Opus 5 (1M context) --- vue-components/src/core.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/vue-components/src/core.js b/vue-components/src/core.js index 3472b22..12c93a2 100644 --- a/vue-components/src/core.js +++ b/vue-components/src/core.js @@ -391,10 +391,13 @@ export class DataclassManager { this.dataToVue[dataId].push(componentId); } - // Put initial state - if (!this.dataStates[dataId]) { - await this.fetchState(dataId); - } + // Put initial state. Await COMPLETION rather than testing the allocation: + // `dataStates[id]` is created as soon as the state reply lands, before the class + // definition and the nested refs are filled in, so a component that links during + // that window used to be handed an instance whose fields are still undefined. + // Serial hydration made the window hard to reach; starting a wave together makes + // it ordinary. `fetchState` deduplicates, so this is a wait, not a request. + await this.fetchState(dataId); updateWidget( dataId, From 35a0db049de858f3f457dd30a3cb84d1bdab5a5d Mon Sep 17 00:00:00 2001 From: Ahinoam Pollack Date: Wed, 7 Oct 2026 01:02:47 +0200 Subject: [PATCH 2/3] perf(client): share the in-flight class definition request `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) --- vue-components/src/core.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/vue-components/src/core.js b/vue-components/src/core.js index 12c93a2..94b9138 100644 --- a/vue-components/src/core.js +++ b/vue-components/src/core.js @@ -38,6 +38,7 @@ export class DataclassManager { this.deepReactiveWatchers = {}; this.pendingDeepReactives = {}; this.pendingFetch = {}; + this.pendingDefinition = {}; } connect(client) { @@ -355,6 +356,24 @@ export class DataclassManager { } async fetchDefinition(id) { + // `typeDefinitions[id]` is only set once the reply lands, so every instance of a + // class hydrating at the same time saw it missing and asked again: a wave of N + // instances of one class cost N definition requests. Share the in-flight promise. + if (this.pendingDefinition[id]) { + return await this.pendingDefinition[id]; + } + const pending = this.callDefinition(id).catch((error) => { + // Conditional, so a failure cannot evict a request that replaced it. + if (this.pendingDefinition[id] === pending) { + delete this.pendingDefinition[id]; + } + throw error; + }); + this.pendingDefinition[id] = pending; + return await pending; + } + + async callDefinition(id) { const data = await this.client .getConnection() .getSession() From 5b6e2d544825dbdf7ada09cb02afb68bac87c0f9 Mon Sep 17 00:00:00 2001 From: Ahinoam Pollack Date: Wed, 7 Oct 2026 01:06:20 +0200 Subject: [PATCH 3/3] feat(client): prefetch dataclass instances in one wave A `Provider` only renders its slot once its own instance is available, so providers that nest hydrate strictly one after another: the second is not created until the first one's state has landed. A page with many of them pays that chain end to end rather than making its requests in parallel. `DataclassManager.prefetch(ids)` starts all of them at once and reports which ones are ready. It is a thin reuse of `fetchState`, so each id is still completed over its whole graph and every request underneath is still deduplicated; nothing is batched, because one blocked instance holding unrelated instances back is worse than the requests it would save. `dataclass.Prefetch(instances=[...])` is the widget around it. It renders its slot immediately and always: each provider already withholds its own subtree until its own data is there, so a page-wide gate would buy nothing more at the price of a new way for the page to stay blank. Removing the wrapper changes when the data arrives and nothing else. Measured in Chromium on a 20-provider page behind a proxy adding 40 ms of round-trip latency (examples/getting_started/prefetch.py, with and without `--serial`), median of five loads: 1.31 s to the last provider rendered without the wrapper, 0.49 s with it. Co-Authored-By: Claude Opus 5 (1M context) --- README.rst | 14 + docs/getting_started.md | 27 ++ examples/getting_started/prefetch.py | 59 ++++ src/trame_dataclass/widgets/dataclass.py | 70 +++++ tests/test_widgets_prefetch.py | 45 +++ vue-components/package.json | 3 +- .../src/components/TrameDataclassPrefetch.js | 67 ++++ vue-components/src/components/index.js | 2 + vue-components/src/core.js | 24 ++ vue-components/tests/prefetch.cjs | 289 ++++++++++++++++++ 10 files changed, 599 insertions(+), 1 deletion(-) create mode 100755 examples/getting_started/prefetch.py create mode 100644 tests/test_widgets_prefetch.py create mode 100644 vue-components/src/components/TrameDataclassPrefetch.js create mode 100644 vue-components/tests/prefetch.cjs diff --git a/README.rst b/README.rst index b1e7a12..1dabb3e 100644 --- a/README.rst +++ b/README.rst @@ -115,6 +115,20 @@ a state variable — use ``dataclass.Provider`` directly: ``instance`` accepts a trame expression tuple; the second element is the default when the state variable is unset. +Providers that nest hydrate one after another, because each one only shows +its slot once its own instance has arrived. ``dataclass.Prefetch`` asks the +client for a whole set of ids at once, so each provider inside finds its own +hydration already in flight: + +.. code-block:: python + + with dataclass.Prefetch(instances=[model._id for model in models]): + with models[0].provide_as("settings"), models[1].provide_as("scene"): + html.Div("{{ settings.title }} / {{ scene.name }}") + +It is a warm-up, not a barrier: the slot renders immediately and every +provider inside keeps gating its own subtree. + Composed dataclasses ---------------------------------------- diff --git a/docs/getting_started.md b/docs/getting_started.md index af25e58..706827d 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -544,6 +544,33 @@ with dcw.Provider(name="active_node", instance=("tree.actives?.[0]",)): ... ``` +### `Prefetch` Widget — warming a whole page of instances + +A `Provider` only shows its slot once its own instance has arrived, so providers +that nest hydrate strictly one after another: the second one is not created +until the first one's state has landed, the third waits for the second, and a +page with many of them pays that chain end to end rather than making its +requests in parallel. + +Wrap them in a `Prefetch` to ask the client for every id at once: + +```python +from trame.widgets import dataclass as dcw + +with dcw.Prefetch(instances=[model._id for model in models]): + with models[0].provide_as("settings"), models[1].provide_as("scene"): + html.Div("{{ settings.title }} / {{ scene.name }}") +``` + +Each provider inside then finds its own hydration already in flight instead of +starting it. `instances` takes a list of ids, or a tuple holding a reactive +expression (`instances=("ids",)`) when the set changes at runtime. + +`Prefetch` is **not** a barrier: it renders its slot immediately and always, and +every provider inside keeps gating its own subtree as before. Removing the +wrapper changes when the data arrives and nothing else, so it is safe to add +around an existing layout. + --- ## Nested Structures and the Object Graph diff --git a/examples/getting_started/prefetch.py b/examples/getting_started/prefetch.py new file mode 100755 index 0000000..1201599 --- /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.app.dataclass 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..5dc3d48 100644 --- a/src/trame_dataclass/widgets/dataclass.py +++ b/src/trame_dataclass/widgets/dataclass.py @@ -22,10 +22,28 @@ def __init__(self, _elem_name, version="v2", children=None, **kwargs): __all__ = [ "Gui", + "Prefetch", "Provider", ] +def _js_string_array(values): + """``["ab", "cd"]`` -> ``['ab', 'cd']``, refusing anything a quote could break out of. + + The ids this is given are ``StateDataModel._id``, which is base-62, so the refusal + is not a limitation anyone meets - it is there because the alternative to refusing + is emitting a template that silently does not compile. + """ + 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) + "]" + + def _to_react_bind(react, instance): """Normalize the Vue-flavored `instance=` shorthand into a `react.Bind`. @@ -173,3 +191,55 @@ def __init__(self, **kwargs): ) raise NotImplementedError(msg) self._attr_names += ["instance"] + + +class Prefetch(HtmlElement): + """Start a wave of instances hydrating, and render its children immediately. + + :class:`Provider` nests and gates its slot on its own instance being available, so + N providers one inside the next cost N round trips END TO END rather than N + requests in flight. Wrapping them in a ``Prefetch`` asks the client for every id at + once, so each provider finds its own hydration already under way:: + + with dataclass.Prefetch(instances=[model._id for model in models]): + for name, model in scopes.items(): + stack.enter_context(model.provide_as(name)) + + It is deliberately **not** a barrier: the slot renders at once, always. Each + provider already withholds its own subtree until its own data is there, and a + page-wide gate would buy nothing more at the price of a new way for the page to + stay blank. Dropping this wrapper changes timing and nothing else. + + Not available under ``client_type="react"``: the React client has its own manager, + with no prefetch component. + """ + + def __init__(self, instances=None, **kwargs): + """Args: + instances: the instance IDs to hydrate, as a list of strings or a Vue binding. + **kwargs: additional element attributes. + """ + super().__init__( + "trame-dataclass-prefetch", + **kwargs, + ) + if self.server.client_type == "react": + msg = ( + "dataclass.Prefetch is not supported under client_type='react': " + "the React client has its own manager and no prefetch component." + ) + raise NotImplementedError(msg) + self._attr_names += ["instances"] + if instances is not None: + # A plain list has to become a BOUND array literal, and neither half of + # that conversion is a convenience: handed `["ab", "cd"]`, trame reads a + # sequence as its `(expression, default)` binding shorthand and renders + # `:instances="ab"` - a legal template, a mounted component and a prop that + # is `undefined`; and the literal must be SINGLE-quoted, because + # `:instances="["ab"]"` closes the attribute at its first inner quote. A + # tuple still means "bind this expression", for a reactive list. + 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/package.json b/vue-components/package.json index 1a23399..706beb3 100644 --- a/vue-components/package.json +++ b/vue-components/package.json @@ -15,7 +15,8 @@ "dev": "vite", "build": "vite build --emptyOutDir", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore --ignore-pattern public", - "semantic-release": "semantic-release" + "semantic-release": "semantic-release", + "test": "node --test tests/*.cjs" }, "dependencies": {}, "peerDependencies": { diff --git a/vue-components/src/components/TrameDataclassPrefetch.js b/vue-components/src/components/TrameDataclassPrefetch.js new file mode 100644 index 0000000..82131b4 --- /dev/null +++ b/vue-components/src/components/TrameDataclassPrefetch.js @@ -0,0 +1,67 @@ +import { inject, watch, onBeforeUnmount } from "vue"; + +import { GLOBAL_DATA_MANAGER } from "./TrameDataclass"; + +/** + * Start a whole wave of instances hydrating, without gating anything on the result. + * + * Providers nest, and a provider's slot waits for its own instance, so provider N+1 + * is not created until provider N's state has landed: N of them pay N round trips END + * TO END. This component asks the manager for all of them at once, so each provider + * finds its own hydration already in flight. + * + * **It renders its slot immediately and always.** Opening the slot only once every id + * is ready would also suppress partial renders -- but each provider already does that + * for itself, and a page-wide gate would buy that for the price of a new way for the + * page to stay blank. Warming is strictly additive: if this component did nothing at + * all, every provider would fetch exactly as it does without it. + */ +export default { + props: { + instances: { + type: Array, + default: () => [], + }, + }, + setup(props) { + const trame = inject("trame"); + GLOBAL_DATA_MANAGER.connect(trame.client); + // A generation token, not a flag: an unmount or a replaced list must not let a + // retired wave's failures be reported against the live page. + let generation = 0; + watch( + () => props.instances, + (instances) => { + const mine = ++generation; + if (!Array.isArray(instances)) { + // The way this prop goes wrong is that it arrives `undefined` from a binding + // that rendered as a bare identifier, and a silent no-op is indistinguishable + // from a working prefetch. + 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 hydrate", + 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, }; diff --git a/vue-components/src/core.js b/vue-components/src/core.js index 94b9138..824a308 100644 --- a/vue-components/src/core.js +++ b/vue-components/src/core.js @@ -293,6 +293,30 @@ export class DataclassManager { return definition?.template || ""; } + // Start every one of these hydrating now, and resolve once all of them are done. + // + // Providers nest, and a provider's slot waits for its own instance, so provider N+1 + // is not created until provider N's reply has landed: N of them cost N round trips + // END TO END rather than N requests in flight. Asking for them all up front leaves + // each provider to find its own hydration already under way. + // + // This reuses `fetchState`, so each id is still completed over its whole graph, the + // requests underneath are still deduplicated, and nothing is batched. + // + // `allSettled`, not `all`: one instance that is gone or refused must not take the + // rest of the wave with it. The caller is told which ids are ready. + async prefetch(ids) { + const settled = await Promise.allSettled( + ids.map((id) => this.fetchState(id)), + ); + return ids.map((id, index) => ({ + id, + ready: settled[index].status === "fulfilled", + error: + settled[index].status === "rejected" ? settled[index].reason : null, + })); + } + async fetchState(id) { if (this.pendingFetch[id]) { return await this.pendingFetch[id]; diff --git a/vue-components/tests/prefetch.cjs b/vue-components/tests/prefetch.cjs new file mode 100644 index 0000000..9f9cfb5 --- /dev/null +++ b/vue-components/tests/prefetch.cjs @@ -0,0 +1,289 @@ +/* eslint-env node */ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); +const Vue = require("vue"); +const source = + fs + .readFileSync( + process.env.DATACLASS_CORE || path.join(__dirname, "../src/core.js"), + "utf8", + ) + .replace( + 'import { ref, reactive, watch } from "vue";', + "const { ref, reactive, watch } = Vue;", + ) + .replace("export class DataclassManager", "class DataclassManager") + + "\nmodule.exports = { DataclassManager };"; +function deferred() { + let resolve, reject; + const promise = new Promise((a, b) => { + resolve = a; + reject = b; + }); + return { promise, resolve, reject }; +} +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const settle = async (times = 6) => { + for (let i = 0; i < times; i++) await tick(); +}; +const definition = (containers = []) => ({ + dataclass_containers: containers, + client_only: [], + deep_reactive: [], +}); +const state = (values, type = "Layer", hold = null) => ({ + definition: type, + state: values, + hold, +}); +// The component, loaded from source against the manager the fixture built. Its `vue` +// and `./TrameDataclass` imports are the only things it needs from a bundler. +function loadPrefetchComponent(manager, logger) { + const code = fs + .readFileSync( + path.join(__dirname, "../src/components/TrameDataclassPrefetch.js"), + "utf8", + ) + .replace(/import \{([^}]+)\} from "vue";/, "const {$1} = Vue;") + .replace(/import[^;]+;/g, "") + .replace("export default", "module.exports ="); + const context = { Vue, console: logger, GLOBAL_DATA_MANAGER: manager }; + context.module = { exports: {} }; + vm.runInNewContext(code, context); + const component = context.module.exports; + return { ...component, render: Vue.compile(component.template) }; +} + +// A host-less Vue renderer: these tests are about what the component asks for, so +// they need a component tree, not a DOM. +function mount(render, client, logger) { + const node = (type, text = "") => ({ + type, + text, + children: [], + parent: null, + }); + const host = { + createElement: (type) => node(type), + createText: (text) => node("text", text), + createComment: (text) => node("comment", text), + setText: (n, t) => (n.text = t), + setElementText: (n, t) => { + n.text = t; + n.children = []; + }, + parentNode: (n) => n.parent, + nextSibling: (n) => + n.parent?.children[n.parent.children.indexOf(n) + 1] || null, + patchProp: () => {}, + insert(n, parent) { + n.parent = parent; + parent.children.push(n); + }, + remove(n) { + const index = n.parent?.children.indexOf(n) ?? -1; + if (index >= 0) n.parent.children.splice(index, 1); + n.parent = null; + }, + }; + const root = node("root"); + const app = Vue.createRenderer(host).createApp({ render }); + app.provide("trame", { client }); + app.config.warnHandler = (message) => logger.error(["Vue", message]); + app.mount(root); + return { app, root }; +} + +function fixture(states, definitions = { Layer: definition() }) { + const errors = [], + warnings = [], + calls = []; + const context = { + Vue, + module: { exports: {} }, + console: { + error: (...args) => errors.push(args), + warn: (...args) => warnings.push(args), + }, + }; + vm.runInNewContext(source, context); + const manager = new context.module.exports.DataclassManager(); + const session = { + async call(name, args) { + calls.push([name, args]); + if (name === "trame.dataclass.state.get") { + const original = states[args[0]]; + const snapshot = structuredClone({ + definition: original.definition, + state: original.state, + }); + if (original.hold) await original.hold.promise; + return snapshot; + } + if (name === "trame.dataclass.definition.get") { + const result = definitions[args[0]]; + return result.promise || result; + } + return null; + }, + subscribe() { + return {}; + }, + }; + const client = { getConnection: () => ({ getSession: () => session }) }; + manager.connect(client); + return { + manager, + client, + errors, + warnings, + logger: context.console, + requested: (method) => + calls.filter(([name]) => name === method).map(([, args]) => args[0]), + value: (id, key) => manager.dataStates[id].refs[key].value, + }; +} + +test("every id is requested in the same wave, not one after another", async () => { + const holds = { a: deferred(), b: deferred(), c: deferred() }; + const f = fixture({ + a: state({ v: "a" }, "Layer", holds.a), + b: state({ v: "b" }, "Layer", holds.b), + c: state({ v: "c" }, "Layer", holds.c), + }); + const wave = f.manager.prefetch(["a", "b", "c"]); + await tick(); + assert.deepEqual(f.requested("trame.dataclass.state.get"), ["a", "b", "c"]); + Object.values(holds).forEach((hold) => hold.resolve()); + const results = await wave; + assert.deepEqual( + results.map(({ id, ready }) => ({ id, ready })), + [ + { id: "a", ready: true }, + { id: "b", ready: true }, + { id: "c", ready: true }, + ], + ); + assert.equal(f.value("a", "v"), "a"); + assert.deepEqual(f.errors, []); +}); + +test("a repeated or already-started id is not fetched twice", async () => { + const hold = deferred(); + const f = fixture({ a: state({ v: 1 }, "Layer", hold) }); + const first = f.manager.fetchState("a"); + await tick(); + const wave = f.manager.prefetch(["a", "a"]); + hold.resolve(); + const results = await wave; + await first; + assert.deepEqual(f.requested("trame.dataclass.state.get"), ["a"]); + assert.ok(results.every((result) => result.ready)); +}); + +test("prefetching a container hydrates its children too", async () => { + const f = fixture( + { + parent: state({ child: "child", title: "parent" }, "Parent"), + child: state({ required: "child-ready" }, "Child"), + }, + { Parent: definition(["child"]), Child: definition() }, + ); + const results = await f.manager.prefetch(["parent"]); + assert.ok(results[0].ready); + assert.equal(f.value("parent", "child").required, "child-ready"); + assert.deepEqual(f.errors, []); +}); + +test("a component linked mid-wave waits for complete refs", async () => { + const gate = deferred(); + const f = fixture( + { + parent: state({ child: "child", title: "parent" }, "Parent"), + child: state({ required: "child-ready" }, "Child"), + }, + { Parent: definition(["child"]), Child: gate }, + ); + const wave = f.manager.prefetch(["parent"]); + await settle(); + // The instance is allocated -- the state reply landed -- but its class definition + // and nested refs are not there yet. This is the window a wave makes ordinary. + assert.ok(f.manager.dataStates.parent); + const data = Vue.reactive({}); + const linked = f.manager.connectVueComponent("component", { + id: "parent", + data, + guards: { serverPush: false }, + }); + await settle(); + assert.equal(data._id, undefined, "component saw an incomplete instance"); + gate.resolve(definition()); + await Promise.all([wave, linked]); + assert.equal(data._id, "parent"); + assert.equal(data.title, "parent"); + assert.equal(data.child.required, "child-ready"); + assert.deepEqual(f.errors, []); +}); + +test("the component renders its slot while every id is still in flight", async () => { + const holds = { a: deferred(), b: deferred() }; + const f = fixture({ + a: state({ v: "a" }, "Layer", holds.a), + b: state({ v: "b" }, "Layer", holds.b), + }); + const Prefetch = loadPrefetchComponent(f.manager, f.logger); + const mounted = mount( + () => + Vue.h(Prefetch, { instances: ["a", "b"] }, () => Vue.h("span", "now")), + f.client, + f.logger, + ); + await tick(); + // Nothing has hydrated, and the slot content is already there: this is a warm-up, + // not a barrier. (A fragment also inserts empty text anchors around it.) + assert.deepEqual( + mounted.root.children.filter((node) => node.type === "span"), + [{ type: "span", text: "now", children: [], parent: mounted.root }], + ); + assert.deepEqual(f.requested("trame.dataclass.state.get"), ["a", "b"]); + Object.values(holds).forEach((hold) => hold.resolve()); + await settle(); + mounted.app.unmount(); + assert.deepEqual(f.warnings, []); + assert.deepEqual(f.errors, []); +}); + +test("a replaced list warms the new ids only", async () => { + const f = fixture({ a: state({ v: "a" }), b: state({ v: "b" }) }); + const Prefetch = loadPrefetchComponent(f.manager, f.logger); + const ids = Vue.ref(["a"]); + const mounted = mount( + () => Vue.h(Prefetch, { instances: ids.value }, () => Vue.h("span", "now")), + f.client, + f.logger, + ); + await settle(); + ids.value = ["a", "b"]; + await settle(); + assert.deepEqual(f.requested("trame.dataclass.state.get"), ["a", "b"]); + mounted.app.unmount(); + assert.deepEqual(f.warnings, []); + assert.deepEqual(f.errors, []); +}); + +test("one class definition is requested once for a whole wave", async () => { + const f = fixture({ + a: state({ v: "a" }), + b: state({ v: "b" }), + c: state({ v: "c" }), + }); + const results = await f.manager.prefetch(["a", "b", "c"]); + assert.ok(results.every((result) => result.ready)); + // Without this, warming N instances of one class costs N state requests AND N + // definition requests: the wave doubles the traffic it was meant to overlap. + assert.deepEqual(f.requested("trame.dataclass.definition.get"), ["Layer"]); + assert.deepEqual(f.errors, []); +});