Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions client-core/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions client-core/tests/prefetch.test.js
Original file line number Diff line number Diff line change
@@ -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],
],
);
});
59 changes: 59 additions & 0 deletions examples/getting_started/prefetch.py
Original file line number Diff line number Diff line change
@@ -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()
50 changes: 50 additions & 0 deletions src/trame_dataclass/widgets/dataclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def __init__(self, _elem_name, version="v2", children=None, **kwargs):

__all__ = [
"Gui",
"Prefetch",
"Provider",
]

Expand Down Expand Up @@ -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),)
)
45 changes: 45 additions & 0 deletions tests/test_widgets_prefetch.py
Original file line number Diff line number Diff line change
@@ -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"])
56 changes: 56 additions & 0 deletions vue-components/src/components/TrameDataclassPrefetch.js
Original file line number Diff line number Diff line change
@@ -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: "<slot></slot>",
};
2 changes: 2 additions & 0 deletions vue-components/src/components/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import TrameDataclass from "./TrameDataclass";
import TrameDataclassGui from "./TrameDataclassGui";
import TrameDataclassPrefetch from "./TrameDataclassPrefetch";

export default {
TrameDataclass,
TrameDataclassGui,
TrameDataclassPrefetch,
};
Loading