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 3472b22..824a308 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) { @@ -292,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]; @@ -355,6 +380,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() @@ -391,10 +434,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, 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, []); +});