@zubra/runtime 5.6.0 → 5.8.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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zubra/runtime",
3
- "version": "5.6.0",
3
+ "version": "5.8.0",
4
4
  "description": "Browser runtime for Zubra compiled components, built on the TC39 Signals API",
5
5
  "license": "ISC",
6
6
  "repository": {
@@ -45,6 +45,36 @@
45
45
  "production": "./dist/production/motion.js",
46
46
  "default": "./src/motion.js"
47
47
  },
48
+ "./bridge": {
49
+ "types": "./types/bridge.d.ts",
50
+ "production": "./dist/production/bridge.js",
51
+ "default": "./src/bridge.js"
52
+ },
53
+ "./context": {
54
+ "types": "./types/context.d.ts",
55
+ "production": "./dist/production/context.js",
56
+ "default": "./src/context.js"
57
+ },
58
+ "./forms": {
59
+ "types": "./types/forms.d.ts",
60
+ "production": "./dist/production/forms.js",
61
+ "default": "./src/forms.js"
62
+ },
63
+ "./navigation": {
64
+ "types": "./types/navigation.d.ts",
65
+ "production": "./dist/production/navigation.js",
66
+ "default": "./src/navigation.js"
67
+ },
68
+ "./outlet": {
69
+ "types": "./types/outlet.d.ts",
70
+ "production": "./dist/production/outlet.js",
71
+ "default": "./src/outlet.js"
72
+ },
73
+ "./testing": {
74
+ "types": "./types/testing.d.ts",
75
+ "production": "./dist/production/testing.js",
76
+ "default": "./src/testing.js"
77
+ },
48
78
  "./react": {
49
79
  "types": "./types/react.d.ts",
50
80
  "production": "./dist/production/react.js",
@@ -79,7 +109,8 @@
79
109
  "@angular/forms": ">=16",
80
110
  "@zag-js/vanilla": ">=1.47",
81
111
  "react": ">=18",
82
- "vue": "^3.3"
112
+ "vue": "^3.3",
113
+ "react-dom": ">=18"
83
114
  },
84
115
  "peerDependenciesMeta": {
85
116
  "@angular/core": {
@@ -96,6 +127,9 @@
96
127
  },
97
128
  "vue": {
98
129
  "optional": true
130
+ },
131
+ "react-dom": {
132
+ "optional": true
99
133
  }
100
134
  },
101
135
  "devDependencies": {
@@ -106,6 +140,8 @@
106
140
  "@angular/forms": "22.2.5",
107
141
  "@angular/platform-browser": "22.2.5",
108
142
  "@babel/core": "8.1.0",
143
+ "@felte/vanilla": "0.3.14",
144
+ "@tanstack/react-form": "1.33.5",
109
145
  "@types/react": "19.3.0",
110
146
  "@zag-js/combobox": "1.47.0",
111
147
  "@zag-js/vanilla": "1.47.0",
@@ -116,13 +152,17 @@
116
152
  "react": "19.3.1",
117
153
  "react-dom": "19.3.1",
118
154
  "react-dom18": "npm:react-dom@18.3.1",
155
+ "react-hook-form": "7.92.0",
156
+ "react-router": "8.5.1",
119
157
  "react18": "npm:react@18.3.1",
120
158
  "rxjs": "7.8.2",
121
159
  "signal-polyfill": "0.2.2",
122
160
  "typescript": "^7.0.2",
161
+ "vee-validate": "4.15.1",
123
162
  "vscode-html-languageservice": "5.6.2",
124
163
  "vscode-languageserver-textdocument": "1.0.16",
125
- "vue": "3.5.45"
164
+ "vue": "3.5.45",
165
+ "vue-router": "5.5.0"
126
166
  },
127
167
  "files": [
128
168
  "src",
package/src/bridge.js ADDED
@@ -0,0 +1,96 @@
1
+ // Signal bridges: read (and write) a Zubra signal from a framework's own code.
2
+ //
3
+ // import { shared } from './cart-state.js' // export const shared = state([])
4
+ // import { toStore } from '@zubra/runtime/bridge'
5
+ // const cart = toStore(shared, { deep: true })
6
+ //
7
+ // Svelte: {$cart.length} (the store contract)
8
+ // Solid: const items = from(cart) (solid-js `from`)
9
+ // Angular: items = toSignal(cart, { requireSync: true }) (a Subscribable)
10
+ // RxJS: from(cart) (Symbol.observable)
11
+ // React: useSignal(shared) from '@zubra/runtime/react'
12
+ // Vue: useSignal(shared) from '@zubra/runtime/vue'
13
+ //
14
+ // The adapters carry props, events and two-way bindings across the element boundary; this
15
+ // carries state: a signal that elements and the app's code both read, without copying it into
16
+ // the framework's state and back. Any `Signal.State` or `Signal.Computed` works (`state()`,
17
+ // `computed()`, `from()`, a `@zubra/sync` field, a native `Signal` once browsers ship one), as
18
+ // does a function that reads signals (it's tracked like a computed).
19
+ //
20
+ // `deep: true` also tracks what's inside an array or plain object, so `items.push(x)` notifies
21
+ // as `items = [...items, x]` does. Without it a mutation inside the value isn't a change.
22
+ import { Signal } from './signal.js';
23
+ import { effect, root } from './effect.js';
24
+
25
+ const isSignal = (value) => value instanceof Signal.State || value instanceof Signal.Computed;
26
+
27
+ function check(source, name) {
28
+ if (typeof source !== 'function' && !isSignal(source)) {
29
+ throw new TypeError(`zubra: ${name}() needs a signal (state(), computed(), a Signal.State or Signal.Computed) or a function that reads signals`);
30
+ }
31
+ }
32
+
33
+ /** The source's current value: a signal's `get()`, or the function's result. */
34
+ export const read = (source) => (typeof source === 'function' ? source() : source.get());
35
+
36
+ /** Whether the source can be written: a `Signal.State`, or a signal with its own `set` (`from()`). */
37
+ export const writable = (source) => typeof source?.set === 'function';
38
+
39
+ // Read every property of an array or plain object (through state's proxy, each read is tracked),
40
+ // so a change anywhere inside notifies.
41
+ function touch(value, seen = new Set()) {
42
+ if (value === null || typeof value !== 'object' || seen.has(value)) return;
43
+ if (!Array.isArray(value)) {
44
+ const proto = Object.getPrototypeOf(value);
45
+ if (proto !== Object.prototype && proto !== null) return;
46
+ }
47
+ seen.add(value);
48
+ if (Array.isArray(value)) for (let index = 0; index < value.length; index++) touch(value[index], seen);
49
+ else for (const key of Object.keys(value)) touch(value[key], seen);
50
+ }
51
+
52
+ /**
53
+ * Call `callback(value)` now and whenever `source` changes, until the returned function is
54
+ * called. Changes are batched like the runtime's own effects: one call per microtask, with the
55
+ * latest value. Not owned by any component, so it lives until it's unsubscribed.
56
+ */
57
+ export function subscribe(source, callback, { deep = false } = {}) {
58
+ check(source, 'subscribe');
59
+ const owner = root(() => {
60
+ effect(() => {
61
+ const value = read(source);
62
+ if (deep) touch(value);
63
+ Signal.subtle.untrack(() => callback(value));
64
+ });
65
+ });
66
+ return () => owner.dispose();
67
+ }
68
+
69
+ /**
70
+ * The source as a store: the Svelte store contract (`subscribe(run)` calls `run` now and on each
71
+ * change, returning the unsubscribe function), which is also an RxJS-style Subscribable
72
+ * (`subscribe({ next })` returns something with `unsubscribe()`), so Angular's `toSignal`, Solid's
73
+ * `from` and RxJS's `from` take it as it is. `set` and `update` are there when the source is
74
+ * writable (a Svelte writable store, so `bind:value={$store}` works).
75
+ */
76
+ export function toStore(source, options = {}) {
77
+ check(source, 'toStore');
78
+ const store = {
79
+ get: () => read(source),
80
+ subscribe(run) {
81
+ const next = typeof run === 'function' ? run : (value) => run?.next?.(value);
82
+ const stop = subscribe(source, next, options);
83
+ const unsubscribe = () => stop();
84
+ unsubscribe.unsubscribe = unsubscribe;
85
+ return unsubscribe;
86
+ },
87
+ [Symbol.observable ?? '@@observable']() {
88
+ return this;
89
+ },
90
+ };
91
+ if (writable(source)) {
92
+ store.set = (value) => source.set(value);
93
+ store.update = (fn) => source.set(fn(Signal.subtle.untrack(() => read(source))));
94
+ }
95
+ return store;
96
+ }