Skip to content
Closed
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
14 changes: 14 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------------------------------

Expand Down
27 changes: 27 additions & 0 deletions docs/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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.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()
70 changes: 70 additions & 0 deletions src/trame_dataclass/widgets/dataclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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),)
)
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"])
3 changes: 2 additions & 1 deletion vue-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
67 changes: 67 additions & 0 deletions vue-components/src/components/TrameDataclassPrefetch.js
Original file line number Diff line number Diff line change
@@ -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: "<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