@zubra/runtime 5.7.0 → 5.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ // Generated by scripts/build-production.js from src/outlet.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
+ // Outlets (experimental): a place in a Zubra component where the app's framework renders its
3
+ // own components, with the app's context, state and events, through a portal.
4
+ //
5
+ // <!-- row-list.zb -->
6
+ // <zb-each items={items} as="item" key={item.id}>
7
+ // <li><zb-outlet name="row" item={item}></zb-outlet></li>
8
+ // </zb-each>
9
+ //
10
+ // // React
11
+ // <RowList items={rows} renderRow={(item) => <Row item={item} />} />
12
+ //
13
+ // Slots let the consumer put content *in* a component; an outlet lets the component ask the
14
+ // consumer for content *per item*, which is what render props and scoped slots do inside one
15
+ // framework. The component stays the owner of the structure (the list, its keys, its order);
16
+ // the framework renders what goes in each outlet, as a portal, so React context, hooks and
17
+ // event handlers work there as anywhere else in the app.
18
+ //
19
+ // Protocol: a connected `<zb-outlet>` fires a bubbling, composed `outletconnect` event. A
20
+ // listener that serves outlets of that name calls `event.claim({ change, disconnect })` and
21
+ // stops it: `change(outlet)` is called when the outlet's `item` changes, `disconnect(outlet)`
22
+ // when it leaves the document. Outlets connected before anyone listened are found with
23
+ // `outletsOf(host)`. A keyed move disconnects and reconnects the outlet node itself, so a
24
+ // renderer that keys its portal by the node (as the React adapter does) keeps what's in it.
25
+ const Base = globalThis.HTMLElement ?? class {};
26
+
27
+ export class ZubraOutletElement extends Base {
28
+ #item = undefined;
29
+ /** Who renders into this outlet: `{ change, disconnect }`, or null. */
30
+ owner = null;
31
+
32
+ /** Which of the consumer's renderers fills it. */
33
+ get name() {
34
+ return this.getAttribute('name') ?? '';
35
+ }
36
+
37
+ /** The data the component gives the renderer for this outlet. */
38
+ get item() {
39
+ return this.#item;
40
+ }
41
+
42
+ set item(value) {
43
+ if (Object.is(value, this.#item)) return;
44
+ this.#item = value;
45
+ this.owner?.change(this);
46
+ }
47
+
48
+ connectedCallback() {
49
+ if (!this.style.display) this.style.display = 'contents';
50
+ if (this.owner) return;
51
+ const event = new Event('outletconnect', { bubbles: true, composed: true });
52
+ event.outlet = this;
53
+ event.claim = (owner) => {
54
+ this.owner = owner;
55
+ event.stopPropagation();
56
+ };
57
+ this.dispatchEvent(event);
58
+ }
59
+
60
+ disconnectedCallback() {
61
+ const owner = this.owner;
62
+ this.owner = null;
63
+ owner?.disconnect(this);
64
+ }
65
+ }
66
+
67
+ /** The unclaimed outlets inside `host` (its light DOM and its shadow root), in document order. */
68
+ export function outletsOf(host) {
69
+ const found = [...host.querySelectorAll('zb-outlet')];
70
+ if (host.shadowRoot) found.push(...host.shadowRoot.querySelectorAll('zb-outlet'));
71
+ return found.filter((outlet) => !outlet.owner);
72
+ }
73
+
74
+ /** Define `<zb-outlet>` (once; a no-op without `customElements`). */
75
+ export function defineOutletElement() {
76
+ const registry = globalThis.customElements;
77
+ if (registry && !registry.get('zb-outlet')) registry.define('zb-outlet', ZubraOutletElement);
78
+ }
79
+
80
+ defineOutletElement();
@@ -16,9 +16,29 @@
16
16
  // works the same way.
17
17
  import * as React from 'react';
18
18
  import { serverContent } from './server.js';
19
+ import { outletsOf } from './outlet.js';
20
+ import { routeLinks } from './navigation.js';
21
+ import './context.js';
22
+ import { subscribe, read } from './bridge.js';
19
23
 
20
24
  const useIsomorphicLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect;
21
25
 
26
+ // `renderRow` serves `<zb-outlet name="row">`, `renderDetailPanel` serves `detail-panel`.
27
+ const outletName = (key) => key.slice(6).replace(/[A-Z]/g, (letter, at) => (at ? '-' : '') + letter.toLowerCase());
28
+ const isRenderer = (key, value) => key.length > 6 && key.startsWith('render') && /[A-Z]/.test(key[6]) && typeof value === 'function';
29
+ // react-dom's createPortal, loaded when an element first has an outlet to fill: a page that
30
+ // uses no outlets (or maps only `react` in an import map) never imports react-dom from here.
31
+ let createPortal = null;
32
+ const portals = () => (createPortal ? Promise.resolve() : import('react-dom').then((dom) => { createPortal = dom.createPortal; }));
33
+
34
+ // A stable key per outlet, for its portal.
35
+ const outletKeys = new WeakMap();
36
+ let nextOutlet = 0;
37
+ const outletKey = (outlet) => {
38
+ if (!outletKeys.has(outlet)) outletKeys.set(outlet, `zb-outlet-${++nextOutlet}`);
39
+ return outletKeys.get(outlet);
40
+ };
41
+
22
42
  const clone = (value) => (value && typeof value === 'object' ? structuredClone(value) : value);
23
43
 
24
44
  /** `zb-star-rating` → `ZbStarRating`. */
@@ -56,14 +76,69 @@ export function wrap(Element) {
56
76
  const values = {};
57
77
  const listeners = {};
58
78
  const rest = {};
79
+ const renderers = {};
59
80
  for (const key of Object.keys(all)) {
60
81
  if (key === 'children') continue;
61
82
  const event = key.length > 2 && key.startsWith('on') ? key.slice(2).toLowerCase() : null;
62
83
  if (Object.hasOwn(props, key)) values[key] = all[key];
63
84
  else if (event && events.includes(event)) listeners[event] = all[key];
85
+ else if (isRenderer(key, all[key])) renderers[outletName(key)] = all[key];
64
86
  else rest[key] = all[key];
65
87
  }
66
88
 
89
+ // Outlets (experimental, outlet.js): `render<Name>` fills each `<zb-outlet name>` the element
90
+ // renders, through a portal, so what's rendered there is part of this React tree (context,
91
+ // state, events). `outlets` maps each claimed outlet to its current item.
92
+ const served = React.useRef(renderers);
93
+ served.current = renderers;
94
+ const [outlets, setOutlets] = React.useState(() => new Map());
95
+ useIsomorphicLayoutEffect(() => {
96
+ const node = element.current;
97
+ const claimed = new Set();
98
+ const owner = {
99
+ change: (outlet) => setOutlets((map) => new Map(map).set(outlet, outlet.item)),
100
+ disconnect: (outlet) => {
101
+ claimed.delete(outlet);
102
+ setOutlets((map) => {
103
+ const next = new Map(map);
104
+ next.delete(outlet);
105
+ return next;
106
+ });
107
+ },
108
+ };
109
+ const take = (outlets) => {
110
+ if (!outlets.length) return;
111
+ for (const outlet of outlets) {
112
+ outlet.owner = owner;
113
+ claimed.add(outlet);
114
+ }
115
+ const add = () => setOutlets((map) => {
116
+ const next = new Map(map);
117
+ for (const outlet of outlets) if (claimed.has(outlet)) next.set(outlet, outlet.item);
118
+ return next;
119
+ });
120
+ if (createPortal) add();
121
+ else portals().then(add);
122
+ };
123
+ const connected = (event) => {
124
+ if (!Object.hasOwn(served.current, event.outlet.name)) return;
125
+ event.claim(owner);
126
+ take([event.outlet]);
127
+ };
128
+ node.addEventListener('outletconnect', connected);
129
+ // Outlets the element rendered before this listener existed.
130
+ take(outletsOf(node).filter((outlet) => Object.hasOwn(served.current, outlet.name)));
131
+ return () => {
132
+ node.removeEventListener('outletconnect', connected);
133
+ for (const outlet of claimed) outlet.owner = null;
134
+ };
135
+ }, []);
136
+ const filled = [];
137
+ for (const [outlet, item] of outlets) {
138
+ const render = renderers[outlet.name];
139
+ if (render && createPortal) filled.push(createPortal(render(item), outlet, outletKey(outlet)));
140
+ }
141
+
67
142
  // Subscribe once per event; always call the latest handler.
68
143
  const handlers = React.useRef(listeners);
69
144
  handlers.current = listeners;
@@ -115,11 +190,70 @@ export function wrap(Element) {
115
190
  // @zubra/server's injectShadowRoots; this works without one.)
116
191
  if (React.Children.count(all.children) === 0) {
117
192
  const html = typeof window === 'undefined' ? serverContent(Element, { props: json ? JSON.parse(json) : {}, name: rest.name }) : '';
118
- return React.createElement(tag, { ...host, dangerouslySetInnerHTML: { __html: html }, suppressHydrationWarning: true });
193
+ return React.createElement(React.Fragment, null,
194
+ React.createElement(tag, { ...host, dangerouslySetInnerHTML: { __html: html }, suppressHydrationWarning: true }), filled);
119
195
  }
120
- return React.createElement(tag, host, all.children);
196
+ return React.createElement(React.Fragment, null, React.createElement(tag, host, all.children), filled);
121
197
  });
122
198
  Component.displayName = pascal(tag);
123
199
  Component.element = Element;
124
200
  return Component;
125
201
  }
202
+
203
+ /**
204
+ * Route links inside Zubra components through the app's router while this component is mounted:
205
+ * `useRouteLinks(useNavigate())` (React Router), `useRouteLinks(useRouter().push)` (Next.js).
206
+ * The function can change between renders. Options as for `routeLinks` (`@zubra/runtime/navigation`).
207
+ */
208
+ export function useRouteLinks(navigate, options) {
209
+ const latest = React.useRef(navigate);
210
+ latest.current = navigate;
211
+ React.useEffect(() => routeLinks((to, info) => latest.current(to, info), options), []);
212
+ }
213
+
214
+ /**
215
+ * Provide a Context Protocol value to every element inside: an app's theme, locale or current user,
216
+ * read by components with `inject(context)` (or Lit's `@consume`).
217
+ *
218
+ * <ZubraContext context={theme} value={dark ? 'dark' : 'light'}>…</ZubraContext>
219
+ *
220
+ * `value` can also be a signal. It renders a `<zb-context>` element, which is `display: contents`.
221
+ */
222
+ export function ZubraContext({ context, value, children }) {
223
+ const ref = React.useRef(null);
224
+ useIsomorphicLayoutEffect(() => {
225
+ ref.current.context = context;
226
+ ref.current.value = value;
227
+ });
228
+ // React 19 sets these as properties when it creates the element, before the children are in
229
+ // the document, so they're answered on their first request; React 18 sets them in the effect,
230
+ // and the element's announcement makes the children ask again.
231
+ const early = legacy || typeof window === 'undefined' ? {} : { context, value };
232
+ return React.createElement('zb-context', { ...early, ref, style: { display: 'contents' } }, children);
233
+ }
234
+
235
+ /**
236
+ * Read a Zubra signal (or a function of signals) in a React component, re-rendering when it
237
+ * changes: a store shared with elements, a `computed`, a `@zubra/sync` field.
238
+ *
239
+ * const items = useSignal(cart, { deep: true }) // write with cart.set(...) or cart.get().push(...)
240
+ *
241
+ * Built on `useSyncExternalStore`, so it's safe under concurrent rendering and on the server
242
+ * (where it reads the value once). With `deep: true` a mutation inside an array or object
243
+ * re-renders too; the value is the same object, so memoize on the returned value with care.
244
+ * Pass a stable source (module-level, or from `useMemo`): a new function each render
245
+ * re-subscribes each render.
246
+ */
247
+ export function useSignal(source, { deep = false } = {}) {
248
+ const store = React.useMemo(() => {
249
+ let version = 0;
250
+ return {
251
+ subscribe: (notify) => subscribe(source, () => { version++; notify(); }, { deep }),
252
+ // Without `deep`, the value itself is the snapshot (React compares it with Object.is);
253
+ // with it, a counter that each notification moves, so a mutation counts as a change.
254
+ snapshot: deep ? () => version : () => read(source),
255
+ };
256
+ }, [source, deep]);
257
+ React.useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
258
+ return read(source);
259
+ }
@@ -9,8 +9,10 @@
9
9
  // `AbortSignal` it was given, and so does the element's removal; a result that arrives after a
10
10
  // newer run started is dropped, so the value is always the latest request's. While a new run is
11
11
  // pending the previous value stays (`loading` is true), so the UI doesn't flash empty.
12
+ // `dispose()` (or `using`, through `Symbol.dispose`) stops it early: the run in flight is
13
+ // aborted and nothing re-runs it. Otherwise it lives as long as the scope that created it.
12
14
  import { Signal } from './signal.js';
13
- import { effect } from './effect.js';
15
+ import { DISPOSE, effect } from './effect.js';
14
16
 
15
17
  const { untrack } = Signal.subtle;
16
18
 
@@ -30,7 +32,7 @@ export function resource(fetcher, { initial } = {}) {
30
32
  const version = new Signal.State(0);
31
33
  let current = null; // the latest run's promise
32
34
 
33
- effect(() => {
35
+ const stop = effect(() => {
34
36
  version.get();
35
37
  const controller = new AbortController();
36
38
  let result;
@@ -72,5 +74,7 @@ export function resource(fetcher, { initial } = {}) {
72
74
  get loading() { return status.get() === 'pending'; },
73
75
  refresh() { version.set(untrack(() => version.get()) + 1); },
74
76
  then(resolve, reject) { return current.then(resolve, reject); },
77
+ dispose: stop,
78
+ [DISPOSE]: stop,
75
79
  };
76
80
  }
@@ -0,0 +1,154 @@
1
+ // Generated by scripts/build-production.js from src/testing.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
+ // Testing helpers: mount a Zubra component the way each framework's consumers do, so a library
3
+ // author can run one test through plain HTML, React and Vue, in any browser test runner (Vitest
4
+ // browser mode, Web Test Runner, Playwright's own).
5
+ //
6
+ // import { frameworks, mount } from '@zubra/runtime/testing';
7
+ // import StarRating from './star-rating.zb';
8
+ //
9
+ // for (const framework of await frameworks()) {
10
+ // test(`${framework}: a click sets the value and fires valuechange`, async () => {
11
+ // const view = await mount(StarRating, { framework, props: { value: 2 } });
12
+ // view.element.querySelector('button:nth-of-type(4)').click();
13
+ // expect(view.events('valuechange')).toEqual([4]);
14
+ // await view.set({ value: 1 }); // through the framework, as an app would
15
+ // expect(view.element.value).toBe(1);
16
+ // view.unmount();
17
+ // });
18
+ // }
19
+ //
20
+ // A `<prop>change` event updates the prop the framework passes, as an app binding it two-way
21
+ // would (`v-model`, React's controlled pattern). React and Vue are imported only when asked for, through the same adapters apps use
22
+ // (`@zubra/runtime/react`, `@zubra/runtime/vue`), so events reach the framework's own handlers,
23
+ // props are set the framework's way, and slots are the framework's children.
24
+
25
+ const ALL = ['html', 'react', 'vue'];
26
+
27
+ /** The frameworks that can be mounted here: plain HTML always, React and Vue when installed. */
28
+ export async function frameworks() {
29
+ const found = ['html'];
30
+ for (const name of ['react', 'vue']) {
31
+ try {
32
+ await load(name);
33
+ found.push(name);
34
+ } catch {
35
+ // not installed: skipped
36
+ }
37
+ }
38
+ return found;
39
+ }
40
+
41
+ const loaded = {};
42
+ function load(framework) {
43
+ if (framework === 'react') {
44
+ loaded.react ??= Promise.all([import('react'), import('react-dom'), import('react-dom/client'), import('./react.js')])
45
+ .then(([React, ReactDOM, client, adapter]) => ({ React, flushSync: ReactDOM.flushSync, createRoot: client.createRoot, wrap: adapter.wrap }));
46
+ return loaded.react;
47
+ }
48
+ if (framework === 'vue') {
49
+ loaded.vue ??= Promise.all([import('vue'), import('./vue.js')]).then(([Vue, adapter]) => ({ Vue, wrap: adapter.wrap }));
50
+ return loaded.vue;
51
+ }
52
+ return Promise.resolve(null);
53
+ }
54
+
55
+ // What the element is doing once a change has landed: its own render, and the framework's.
56
+ const settle = async (element) => {
57
+ await new Promise((resolve) => setTimeout(resolve));
58
+ await element?.updateComplete;
59
+ };
60
+
61
+ /**
62
+ * Mount `Element` (a compiled component's default export) through `framework` (`html`, `react` or
63
+ * `vue`), with `props` and `slots` (`{ default: 'text', title: 'Heading' }`: text for each slot).
64
+ * Resolves once it has rendered, to:
65
+ * - `element`: the component's host element;
66
+ * - `events(name)`: the `detail` of each `name` event so far, as the framework's handler got it
67
+ * (`events()`: every event as `[name, detail]`);
68
+ * - `set(props)`: change props through the framework (merged with the current ones), and settle;
69
+ * - `unmount()`: remove it the framework's way.
70
+ */
71
+ export async function mount(Element, { framework = 'html', props = {}, slots = {}, container } = {}) {
72
+ const meta = Element?.zubra;
73
+ if (!meta) throw new TypeError('zubra: mount() needs a compiled zubra element (the default export of a .zb module)');
74
+ if (!ALL.includes(framework)) throw new TypeError(`zubra: mount() knows ${ALL.join(', ')}, not "${framework}"`);
75
+ if (!customElements.get(meta.tag)) customElements.define(meta.tag, Element);
76
+ const host = container ?? document.body.appendChild(document.createElement('div'));
77
+ const log = [];
78
+ // Like an app that binds the component two-way (`v-model`, React's controlled pattern): a
79
+ // `<prop>change` event updates the prop the framework passes back, so its next render doesn't
80
+ // put the old value back.
81
+ const models = Object.fromEntries(Object.keys(meta.props).filter((name) => meta.events.includes(`${name.toLowerCase()}change`)).map((name) => [`${name.toLowerCase()}change`, name]));
82
+ let model = () => {};
83
+ const record = (name) => (event) => {
84
+ log.push([name, event.detail]);
85
+ if (models[name]) model(models[name], event.detail);
86
+ };
87
+ let current = { ...props };
88
+ let view;
89
+
90
+ if (framework === 'html') {
91
+ const element = document.createElement(meta.tag);
92
+ for (const [name, value] of Object.entries(current)) element[name] = value;
93
+ for (const name of meta.events) element.addEventListener(name, record(name));
94
+ for (const [name, text] of Object.entries(slots)) {
95
+ if (name === 'default') element.append(text);
96
+ else element.append(Object.assign(document.createElement('span'), { slot: name, textContent: text }));
97
+ }
98
+ host.append(element);
99
+ view = {
100
+ element,
101
+ set: async (next) => { Object.assign(current, next); for (const [name, value] of Object.entries(next)) element[name] = value; },
102
+ unmount: () => element.remove(),
103
+ };
104
+ } else if (framework === 'react') {
105
+ const { React, flushSync, createRoot, wrap } = await load('react');
106
+ const Component = wrap(Element);
107
+ const handlers = Object.fromEntries(meta.events.map((name) => [`on${name}`, record(name)]));
108
+ const children = Object.entries(slots).map(([name, text]) =>
109
+ name === 'default' ? text : React.createElement('span', { key: name, slot: name }, text));
110
+ let element = null;
111
+ const ref = (node) => { if (node) element = node; };
112
+ const root = createRoot(host);
113
+ const render = () => flushSync(() => root.render(React.createElement(Component, { ...current, ...handlers, ref }, ...children)));
114
+ model = (name, value) => { current[name] = value; queueMicrotask(render); };
115
+ render();
116
+ view = {
117
+ get element() { return element; },
118
+ set: async (next) => { Object.assign(current, next); render(); },
119
+ unmount: () => root.unmount(),
120
+ };
121
+ } else {
122
+ const { Vue, wrap } = await load('vue');
123
+ const Component = wrap(Element);
124
+ const state = Vue.reactive({ ...current });
125
+ model = (name, value) => { current[name] = value; state[name] = value; };
126
+ const handlers = Object.fromEntries(meta.events.map((name) => [`on${name[0].toUpperCase()}${name.slice(1)}`, record(name)]));
127
+ const slotFunctions = Object.fromEntries(Object.entries(slots).map(([name, text]) => [name, () => (name === 'default' ? text : Vue.h('span', text))]));
128
+ const component = Vue.ref(null);
129
+ const app = Vue.createApp({ render: () => Vue.h(Component, { ...state, ...handlers, ref: component }, slotFunctions) });
130
+ app.mount(host);
131
+ view = {
132
+ get element() { return component.value?.element ?? null; },
133
+ set: async (next) => { Object.assign(current, next); Object.assign(state, next); await Vue.nextTick(); },
134
+ unmount: () => app.unmount(),
135
+ };
136
+ }
137
+
138
+ await settle(view.element);
139
+ const element = () => view.element;
140
+ return {
141
+ framework,
142
+ host,
143
+ get element() { return element(); },
144
+ events: (name) => (name === undefined ? log.slice() : log.filter(([event]) => event === name).map(([, detail]) => detail)),
145
+ async set(next) {
146
+ await view.set(next);
147
+ await settle(element());
148
+ },
149
+ unmount() {
150
+ view.unmount();
151
+ if (!container) host.remove();
152
+ },
153
+ };
154
+ }
@@ -0,0 +1,78 @@
1
+ // Generated by scripts/build-production.js from src/track.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
+ // Reads after an `await` in a resource's fetcher: `resource(tracked(fetcher))`.
3
+ //
4
+ // const list = resource(tracked(async ({ signal, track }) => {
5
+ // const token = await auth(signal)
6
+ // return load(token, track(() => page.get()), signal)
7
+ // }))
8
+ //
9
+ // The Signals API tracks a read only while a Computed is running. A resource's fetcher runs
10
+ // inside its effect until its first `await`; the code after that runs later, outside it, so its
11
+ // reads are not followed. `tracked()` gives each run `track(read)`: it reads, and when what
12
+ // `read` returns changes, the resource runs the fetcher again, as for a read before the
13
+ // `await`. The compiler writes `tracked()` and the `track()` calls for an async fetcher in a
14
+ // `.zb` script, so this module is loaded only by components that have one.
15
+ //
16
+ // A run that was replaced or has settled, or a resource that was disposed, still reads through
17
+ // `track()`, but no longer subscribes. The reads are watched by an effect owned where `tracked()` was called (a
18
+ // component's setup), so a detached element holds none of them and catches up when it returns.
19
+ import { Signal } from './signal.js';
20
+ import { effect } from './effect.js';
21
+
22
+ const { untrack, currentComputed } = Signal.subtle;
23
+ const THREW = Symbol('threw');
24
+
25
+ function readOnce(computed) {
26
+ try { return computed.get(); } catch { return THREW; }
27
+ }
28
+
29
+ /** Wraps a resource's fetcher so it is also given `track(read)`, for reads after an `await`. */
30
+ export function tracked(fetcher) {
31
+ if (typeof fetcher !== 'function') throw new TypeError('zubra: tracked() takes a function');
32
+ // Read by each run before it can wait, so the resource's effect runs again when it changes.
33
+ const again = new Signal.State(0);
34
+ // The run whose later reads are watched: set when it makes one, and when it settles.
35
+ const tail = new Signal.State(null);
36
+ let latest = null;
37
+
38
+ // When a read changes, run again. A new read is appended (and seen) without re-running.
39
+ const stop = effect(() => {
40
+ const watched = tail.get();
41
+ if (!watched) return;
42
+ let changed = false;
43
+ for (const read of watched.run.reads) {
44
+ if (!Object.is(readOnce(read.computed), read.seen)) changed = true; // read each, to keep watching it
45
+ }
46
+ if (changed && watched.run === latest) untrack(() => again.set(again.get() + 1));
47
+ });
48
+
49
+ return (context) => {
50
+ again.get();
51
+ const { signal } = context;
52
+ const run = { reads: [] };
53
+ latest = run;
54
+ // The resource aborts a run when the next one starts, or when it is disposed: then no run
55
+ // follows, and nothing is watched any more.
56
+ signal?.addEventListener('abort', () => queueMicrotask(() => { if (latest === run) stop(); }), { once: true });
57
+ const track = (read) => {
58
+ if (typeof read !== 'function') throw new TypeError('zubra: track() takes a function that reads signals');
59
+ // Before the first `await` the resource's effect is running, and tracks the read itself.
60
+ if (currentComputed()) return read();
61
+ // A replaced or finished run (a timer the fetcher left), or a disposed resource: its value
62
+ // can't depend on the read any more, so read without subscribing.
63
+ if (run !== latest || run.done || signal?.aborted) return untrack(read);
64
+ const computed = new Signal.Computed(read);
65
+ run.reads.push({ computed, seen: readOnce(computed) });
66
+ tail.set({ run });
67
+ return computed.get(); // rethrows what `read` threw
68
+ };
69
+ const settled = () => {
70
+ run.done = true;
71
+ // The newest run has finished: stop watching an older run's later reads.
72
+ if (run === latest && untrack(() => tail.get())?.run !== run) tail.set(run.reads.length ? { run } : null);
73
+ };
74
+ const result = fetcher({ ...context, track });
75
+ Promise.resolve(result).then(settled, settled);
76
+ return result;
77
+ };
78
+ }
@@ -13,8 +13,11 @@
13
13
  // `v-model:<prop>`, and `v-model` binds the `value` pair (or the only pair, if there's one).
14
14
  // - Other attributes and listeners (class, style, @click, …) go to the element; slots become its
15
15
  // children, named slots with `slot="name"`.
16
- import { defineComponent, h, withDirectives, Text, Comment, Fragment } from 'vue';
16
+ import { defineComponent, h, withDirectives, Text, Comment, Fragment, customRef, getCurrentScope, onScopeDispose } from 'vue';
17
17
  import { serverContent } from './server.js';
18
+ import { routeLinks } from './navigation.js';
19
+ import './context.js';
20
+ import { subscribe, read, writable } from './bridge.js';
18
21
 
19
22
  const clone = (value) => (value && typeof value === 'object' ? structuredClone(value) : value);
20
23
  const pascal = (tag) => tag.split(/[-.]/).filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join('');
@@ -124,3 +127,66 @@ export function wrap(Element) {
124
127
  },
125
128
  });
126
129
  }
130
+
131
+ /**
132
+ * Route links inside Zubra components through Vue Router (or any `push`) until the current effect
133
+ * scope ends: `useRouteLinks((to) => router.push(to))` in a component's `setup`. Returns the
134
+ * function that stops it.
135
+ */
136
+ export function useRouteLinks(navigate, options) {
137
+ const stop = routeLinks(navigate, options);
138
+ if (getCurrentScope()) onScopeDispose(stop);
139
+ return stop;
140
+ }
141
+
142
+ /**
143
+ * Provide a Context Protocol value to every element inside: an app's theme, locale or current user,
144
+ * read by components with `inject(context)` (or Lit's `@consume`).
145
+ *
146
+ * <ZubraContext :context="theme" :value="dark ? 'dark' : 'light'">…</ZubraContext>
147
+ *
148
+ * `value` can also be a signal. It renders a `<zb-context>` element, which is `display: contents`.
149
+ */
150
+ export const ZubraContext = defineComponent({
151
+ name: 'ZubraContext',
152
+ props: { context: { default: undefined }, value: { default: undefined } },
153
+ setup(given, { slots }) {
154
+ // Set as DOM properties when the element is created, before its children are inserted.
155
+ return () => h('zb-context', { '.context': given.context, '.value': given.value, style: 'display: contents' }, slots.default?.());
156
+ },
157
+ });
158
+
159
+ /**
160
+ * A Zubra signal (or a function of signals) as a Vue ref: reading `.value` tracks it in
161
+ * templates, computeds and watchers, and assigning `.value` writes the signal when it's writable
162
+ * (a `Signal.State`, `state()`, `from()`), so `v-model="cart"` works on one.
163
+ *
164
+ * const items = useSignal(cart, { deep: true })
165
+ *
166
+ * The subscription ends with the effect scope it was created in (the component's `setup`); outside
167
+ * one, call `stop()` on the ref when done. With `deep: true` a mutation inside an array or object
168
+ * triggers too.
169
+ */
170
+ export function useSignal(source, { deep = false } = {}) {
171
+ let stop = () => {};
172
+ const ref = customRef((track, trigger) => {
173
+ let first = true;
174
+ stop = subscribe(source, () => {
175
+ if (first) first = false;
176
+ else trigger();
177
+ }, { deep });
178
+ return {
179
+ get() {
180
+ track();
181
+ return read(source);
182
+ },
183
+ set(value) {
184
+ if (!writable(source)) throw new TypeError('zubra: useSignal(): this signal is read-only (a computed); write the state it reads');
185
+ source.set(value);
186
+ },
187
+ };
188
+ });
189
+ Object.defineProperty(ref, 'stop', { value: () => stop() });
190
+ if (getCurrentScope()) onScopeDispose(() => stop());
191
+ return ref;
192
+ }