@zubra/runtime 5.2.0 → 5.4.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.
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-production.js from src/deep.js: the runtime without hot reloading. Do not edit.
1
+ // Generated by scripts/build-production.js from src/deep.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
2
  // Deep reactivity for state: `let items = []` followed by `items.push(x)` updates the DOM.
3
3
  //
4
4
  // A `state()` holding an array or a plain object holds a proxy of it instead. Each property the
@@ -29,11 +29,14 @@ const isPlain = (value) => {
29
29
  /** The object behind a reactive proxy, or the value itself. */
30
30
  export const raw = (value) => (value && typeof value === 'object' && value[RAW]) || value;
31
31
 
32
+
32
33
  function track(target, key) {
33
34
  let map = signals.get(target);
34
35
  if (!map) signals.set(target, (map = new Map()));
35
36
  let signal = map.get(key);
36
- if (!signal) map.set(key, (signal = new Signal.State(0)));
37
+ if (!signal) {
38
+ map.set(key, (signal = new Signal.State(0)));
39
+ }
37
40
  signal.get();
38
41
  }
39
42
 
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-production.js from src/effect.js: the runtime without hot reloading. Do not edit.
1
+ // Generated by scripts/build-production.js from src/effect.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
2
  // Effects and ownership, built only on the Signals API.
3
3
  //
4
4
  // The Signals proposal intentionally leaves effects and scheduling to libraries; this is ours.
@@ -29,6 +29,7 @@ let scheduled = null; // promise for the pending microtask flush
29
29
  let flushing = false;
30
30
  const effectOf = new WeakMap();
31
31
 
32
+
32
33
  const watcher = new Signal.subtle.Watcher(() => {
33
34
  scheduled ??= new Promise((resolve) => queueMicrotask(() => {
34
35
  try { flush(); } finally { scheduled = null; resolve(); }
@@ -214,7 +215,9 @@ export function flush() {
214
215
  for (let round = 0; ; round++) {
215
216
  const pending = watcher.getPending();
216
217
  watcher.watch(); // re-arm before running, so writes made by effects schedule another round
217
- if (pending.length === 0) return;
218
+ if (pending.length === 0) {
219
+ return;
220
+ }
218
221
  if (round >= MAX_ROUNDS) {
219
222
  report(new Error('zubra: effects keep re-triggering each other; stopped after 100 rounds'));
220
223
  return;
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-production.js from src/element.js: the runtime without hot reloading. Do not edit.
1
+ // Generated by scripts/build-production.js from src/element.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
2
  // define(): the custom element a compiled component becomes.
3
3
  //
4
4
  // The compiler hands over a template and a list of parts (see src/emit_js.rs). Each part has a
@@ -1673,7 +1673,10 @@ function defineElement(tag, definition) {
1673
1673
  const initial = (name, compute) => {
1674
1674
  if (!this.#given.has(name)) this.#signals[name].set(compute());
1675
1675
  };
1676
- try { parts = setup ? setup($p, (name, detail) => this.#emit(name, detail), initial) : []; } finally { current = previous; }
1676
+ try {
1677
+ parts = setup ? setup($p, (name, detail) => this.#emit(name, detail), initial
1678
+ ) : [];
1679
+ } finally { current = previous; }
1677
1680
  // A form control with one two-way prop: that's what it submits, and what a form reset
1678
1681
  // restores, unless the script says otherwise.
1679
1682
  const models = Object.keys(props).filter((name) => props[name].model);
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-production.js from src/index.js: the runtime without hot reloading. Do not edit.
1
+ // Generated by scripts/build-production.js from src/index.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
2
  // @zubra/runtime — everything compiled components and their scripts import.
3
3
  export { Signal, isNative } from './signal.js';
4
4
  export { effect, scope, flush, tick } from './effect.js';
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-production.js from src/primitives.js: the runtime without hot reloading. Do not edit.
1
+ // Generated by scripts/build-production.js from src/primitives.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
2
  // @zubra/runtime/primitives: the two keyboard behaviours the platform doesn't have.
3
3
  //
4
4
  // The platform now does most of what headless libraries used to: <dialog> traps focus and closes
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-production.js from src/react.js: the runtime without hot reloading. Do not edit.
1
+ // Generated by scripts/build-production.js from src/react.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
2
  // React adapter: a zubra element as a React component, for React 18 and 19.
3
3
  //
4
4
  // import Rating from './rating.zb' // the build plugin wraps it for React files
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-production.js from src/remark.js: the runtime without hot reloading. Do not edit.
1
+ // Generated by scripts/build-production.js from src/remark.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
2
  // Server markup whose markers were stripped: the element's runtime loads this module only when it
3
3
  // meets such markup.
4
4
  //
@@ -10,10 +10,12 @@
10
10
  // - an zb-if region holds the branch the current values choose, and an zb-each region one row
11
11
  // per item, each with the shape of its block (checked element by element);
12
12
  // - a light-DOM slot region is the rest of its range, when nothing follows it there; its
13
- // content is the fallback if it has the fallback's shape, and supplied content otherwise.
14
- // Anything else (an zb-await, content projected into a child component, a region the server
15
- // left to the browser, other than one left empty) can't be placed for sure, and the element
16
- // renders afresh as before.
13
+ // content is the fallback if it has the fallback's shape, and supplied content otherwise;
14
+ // - an zb-await region holds its zb-then block, for a value known at build time, or its pending
15
+ // block, which the element renders afresh anyway (so only where the region ends matters).
16
+ // Anything else (content projected into a child component, a region the server left to the
17
+ // browser, other than one left empty or a pending zb-await) can't be placed for sure, and the
18
+ // element renders afresh as before.
17
19
 
18
20
  const TEXT = 3;
19
21
  const COMMENT = 8;
@@ -56,7 +58,12 @@ export function remark(root, template, parts, { blueprint, isOpen, isClose, read
56
58
  let index = 0;
57
59
  for (; t; t = t.nextSibling) {
58
60
  if (t.nodeType === TEXT) {
59
- if (cursor?.nodeType === TEXT) cursor = cursor.nextSibling; // static text (if kept)
61
+ // Static text (if kept): the same words as the template's. Text that isn't (a page's own
62
+ // `<x-a>Save</x-a>` where the template has only whitespace) isn't server markup.
63
+ if (cursor?.nodeType === TEXT) {
64
+ if (cursor.data.trim() !== t.data.trim()) throw fail;
65
+ cursor = cursor.nextSibling;
66
+ }
60
67
  continue;
61
68
  }
62
69
  const path = [...prefix, index];
@@ -147,8 +154,40 @@ export function remark(root, template, parts, { blueprint, isOpen, isClose, read
147
154
  insert(parent, null, ']');
148
155
  }
149
156
  cursor = null;
157
+ } else if (part.await) {
158
+ // zb-await: the server rendered the zb-then block for a value it knew at build time (a
159
+ // plain value here too), or the pending block in a region it left to the element, which
160
+ // the element renders afresh anyway (`[?`): only its extent has to be found, by the
161
+ // pending block's shape. Failing both, an empty region left to the element, as above.
162
+ const saved = edits.length;
163
+ const start = cursor;
164
+ const value = untrack(() => $r(part.await()));
165
+ const thenable = (item) => item != null && typeof $r(item)?.then === 'function';
166
+ const settled = !(Array.isArray(value) ? value.some(thenable) : thenable(value));
167
+ const attempts = [];
168
+ if (settled && part.blocks[1]) attempts.push(['[', part.blocks[1], [new State(value), new State(() => {})]]);
169
+ if (part.blocks[0]) attempts.push(['[?', part.blocks[0], []]);
170
+ let placed = false;
171
+ for (const [open, chosen, args] of attempts) {
172
+ try {
173
+ insert(parent, cursor, open);
174
+ cursor = block(chosen, args);
175
+ insert(parent, cursor, ']');
176
+ placed = true;
177
+ break;
178
+ } catch (error) {
179
+ if (error !== fail) throw error;
180
+ edits.length = saved;
181
+ cursor = start;
182
+ }
183
+ }
184
+ if (!placed) {
185
+ if (!followsRegion(t, skipText(cursor))) throw fail;
186
+ insert(parent, cursor, '[?');
187
+ insert(parent, cursor, ']');
188
+ }
150
189
  } else {
151
- throw fail; // zb-await
190
+ throw fail;
152
191
  }
153
192
  // Past the template's matching close.
154
193
  let depth = 0;
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-production.js from src/resource.js: the runtime without hot reloading. Do not edit.
1
+ // Generated by scripts/build-production.js from src/resource.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
2
  // Async data as signals: `resource(fetcher)`.
3
3
  //
4
4
  // const user = resource(({ signal }) => fetch(`/api/users/${p.id}`, { signal }).then((r) => r.json()))
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-production.js from src/server.js: the runtime without hot reloading. Do not edit.
1
+ // Generated by scripts/build-production.js from src/server.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
2
  // Server rendering of zubra elements, for framework integrations (the Astro renderer, …).
3
3
  //
4
4
  // A `.zb` module loads on a server: `define()` returns a stand-in that carries the element's
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-production.js from src/signal-impl.js: the runtime without hot reloading. Do not edit.
1
+ // Generated by scripts/build-production.js from src/signal-impl.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
2
  // An implementation of the TC39 Signals proposal API (https://github.com/tc39/proposal-signals).
3
3
  //
4
4
  // This is zubra's polyfill boundary for reactivity: compiled components talk only to this API,
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-production.js from src/signal.js: the runtime without hot reloading. Do not edit.
1
+ // Generated by scripts/build-production.js from src/signal.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
2
  // The one place compiled zubra code gets reactivity from.
3
3
  //
4
4
  // If the platform provides the TC39 Signals API natively, use it. Otherwise fall back to our
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-production.js from src/spread.js: the runtime without hot reloading. Do not edit.
1
+ // Generated by scripts/build-production.js from src/spread.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
2
  // {...spread} in compiled components: an object's entries as an element's attributes,
3
3
  // properties and listeners. Its own entry (`@zubra/runtime/spread`), which a compiled module imports
4
4
  // only when its template spreads, so components that don't, don't load it.
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-production.js from src/vue.js: the runtime without hot reloading. Do not edit.
1
+ // Generated by scripts/build-production.js from src/vue.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
2
  // Vue 3 adapter: a zubra element as a Vue component.
3
3
  //
4
4
  // <script setup>
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-production.js from src/zag.js: the runtime without hot reloading. Do not edit.
1
+ // Generated by scripts/build-production.js from src/zag.js: the runtime without hot reloading or the DevTools hook. Do not edit.
2
2
  // @zubra/runtime/zag: Zag.js state machines driving zubra components.
3
3
  //
4
4
  // Zag (https://zagjs.com) is a framework-agnostic set of accessible widget behaviours (combobox,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zubra/runtime",
3
- "version": "5.2.0",
3
+ "version": "5.4.0",
4
4
  "description": "Browser runtime for Zubra compiled components, built on the TC39 Signals API",
5
5
  "license": "ISC",
6
6
  "repository": {
package/src/deep.js CHANGED
@@ -28,17 +28,33 @@ const isPlain = (value) => {
28
28
  /** The object behind a reactive proxy, or the value itself. */
29
29
  export const raw = (value) => (value && typeof value === 'object' && value[RAW]) || value;
30
30
 
31
+ // @dev
32
+ /**
33
+ * For the DevTools hook (devtools.js), in development only: each property signal's object and
34
+ * key, so it can be named, and `wrote` hears each write through a proxy.
35
+ */
36
+ export const dev = { propertyOf: new WeakMap(), wrote: null };
37
+ // @dev-end
38
+
31
39
  function track(target, key) {
32
40
  let map = signals.get(target);
33
41
  if (!map) signals.set(target, (map = new Map()));
34
42
  let signal = map.get(key);
35
- if (!signal) map.set(key, (signal = new Signal.State(0)));
43
+ if (!signal) {
44
+ map.set(key, (signal = new Signal.State(0)));
45
+ // @dev
46
+ dev.propertyOf.set(signal, { target, key });
47
+ // @dev-end
48
+ }
36
49
  signal.get();
37
50
  }
38
51
 
39
52
  function trigger(target, key) {
40
53
  const signal = signals.get(target)?.get(key);
41
54
  if (signal) signal.set(signal.get() + 1);
55
+ // @dev
56
+ if (signal) dev.wrote?.(signal);
57
+ // @dev-end
42
58
  }
43
59
 
44
60
  const handler = {
package/src/devhook.js ADDED
@@ -0,0 +1,41 @@
1
+ // What development records for the DevTools, from the page's first element on: which components
2
+ // are on the page, and which listeners each node has. Only element.js imports it, from a
3
+ // `// @dev` block, so the production build (scripts/build-production.js) leaves it out.
4
+ //
5
+ // This part is small and always loaded in development. The reading (devtools.js) is loaded
6
+ // when the browser extension connects, so a page that never opens the pane carries only this.
7
+
8
+ export const elements = new WeakMap(); // host → { tag, source, props, named, scope }
9
+ export const listeners = new WeakMap(); // node → the listener parts bound to it
10
+ export const hooks = { registered: null }; // devtools.js listens once it's loaded
11
+
12
+ // Elements from before the reading side loads: it watches their writes when it does. WeakRefs,
13
+ // so a removed element isn't kept alive, and pruned now and then on a page that never connects.
14
+ export const early = [];
15
+
16
+ /** An element finished setting up (see devtools.js for what the record holds). */
17
+ export function register(host, record) {
18
+ elements.set(host, record);
19
+ if (hooks.registered) return hooks.registered(record);
20
+ if (early.length >= 1000) early.splice(0, Infinity, ...early.filter((ref) => ref.deref()));
21
+ early.push(new WeakRef(record));
22
+ }
23
+
24
+ /** A listener part was bound to `node` (effects carry their part; listeners aren't effects). */
25
+ export function listened(part, node) {
26
+ let parts = listeners.get(node);
27
+ if (!parts) listeners.set(node, (parts = []));
28
+ if (!parts.includes(part)) parts.push(part);
29
+ }
30
+
31
+ let loading = null;
32
+ if (!globalThis.__ZUBRA_DEVTOOLS__) {
33
+ globalThis.__ZUBRA_DEVTOOLS__ = {
34
+ version: 1,
35
+ /** Load the reading side; the extension calls this, then `inspect()` once it's there. */
36
+ connect() {
37
+ loading ??= import('./devtools.js');
38
+ return true;
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,284 @@
1
+ // The DevTools reading side: what the browser extension (editors/devtools) shows for a node.
2
+ // devhook.js records from the page's start and loads this when the extension connects, so a
3
+ // page that never opens the pane doesn't carry it. Neither is in the production build
4
+ // (scripts/build-production.js).
5
+ //
6
+ // The extension reads the page through `globalThis.__ZUBRA_DEVTOOLS__` and nothing else: plain
7
+ // JSON out, so the runtime's internals stay free to change. `version` says which shape.
8
+ //
9
+ // What it knows comes from a `--devtools` build (which @zubra/unplugin makes in Vite's dev
10
+ // server): each part's line and column in the `.zb` file, the file itself, and the script's
11
+ // signals by name. Without that build, an element still shows its tag, props and bindings,
12
+ // unlocated and with fewer names.
13
+ //
14
+ // The dependencies are read through `Signal.subtle.introspectSources`, the Signals proposal's
15
+ // own introspection, so the same view works on the polyfill and on a native `Signal`.
16
+ import { Signal, isNative } from './signal.js';
17
+ import { dev as effects } from './effect.js';
18
+ import { dev as deep, raw } from './deep.js';
19
+ import { elements, listeners, hooks, early } from './devhook.js';
20
+
21
+ const { introspectSources, untrack } = Signal.subtle;
22
+ const isComputed = (value) => value instanceof Signal.Computed;
23
+ const isState = (value) => value instanceof Signal.State;
24
+
25
+ // The most recent flush since connecting: the effects it re-ran, and the signals written since
26
+ // the one before.
27
+ let writes = new Set();
28
+ let last = { ran: new Set(), written: new Set() };
29
+ let updates = 0;
30
+
31
+ effects.flushed = (ran) => {
32
+ last = { ran: new Set(ran), written: writes };
33
+ writes = new Set();
34
+ updates++;
35
+ };
36
+ const wrote = (signal) => {
37
+ // A write nothing reads never flushes: don't let those pile up.
38
+ if (writes.size > 1000) writes = new Set();
39
+ writes.add(signal);
40
+ };
41
+ deep.wrote = wrote;
42
+
43
+ /** Watch writes to a signal (a prop's, or a named state's). */
44
+ function watchWrites(signal) {
45
+ if (signal.__zubraWatched) return;
46
+ const set = signal.set;
47
+ signal.set = function (value) {
48
+ wrote(signal);
49
+ return set.call(this, value);
50
+ };
51
+ Object.defineProperty(signal, '__zubraWatched', { value: true });
52
+ }
53
+
54
+ /** Watch a component's props and named state (once; elements from before connecting too). */
55
+ function watch(record) {
56
+ if (record.watched) return;
57
+ record.watched = true;
58
+ for (const signal of Object.values(record.props)) watchWrites(signal);
59
+ for (const signal of Object.values(record.named ?? {})) {
60
+ if (isState(signal) || typeof signal?.set === 'function') watchWrites(signal);
61
+ }
62
+ }
63
+ hooks.registered = watch;
64
+ for (const ref of early.splice(0)) {
65
+ const record = ref.deref();
66
+ if (record) watch(record);
67
+ }
68
+
69
+ // ---- reading -------------------------------------------------------------------------------
70
+
71
+ /** The nearest registered component at or above `node`, crossing shadow roots. */
72
+ function hostOf(node, skipSelf = false) {
73
+ for (let at = skipSelf ? up(node) : node; at; at = up(at)) if (elements.has(at)) return at;
74
+ return null;
75
+ }
76
+ const up = (node) => node.parentNode ?? node.host ?? null;
77
+
78
+ /** Every effect `owner` owns, depth first (a component's own: child components have their own roots). */
79
+ function effectsOf(owner, out = []) {
80
+ for (const child of owner.children) {
81
+ if (child.disposed) continue;
82
+ if (child.computed) out.push(child);
83
+ effectsOf(child, out);
84
+ }
85
+ return out;
86
+ }
87
+
88
+ // Form controls' live state, which the runtime sets as a property (element.js, bindAttribute).
89
+ const LIVE = new Set(['input.value', 'textarea.value', 'select.value', 'input.checked', 'option.selected']);
90
+
91
+ /** The DOM operation a part performs on `node`, in the platform's terms. */
92
+ export function operation(part, node) {
93
+ if (part.text) return 'textContent = …';
94
+ if (part.anchor) return 'Text.data = …';
95
+ if (part.global) return `${part.global}.addEventListener('${part.on}', …)`;
96
+ if (part.on) return `addEventListener('${part.on}', …)`;
97
+ if (part.classes) return 'classList.add/remove(…)';
98
+ if (part.toggle) return `classList.toggle('${part.toggle}', …)`;
99
+ if (part.style) return `style.setProperty('${part.style}', …)`;
100
+ if (part.ref) return 'ref(element)';
101
+ if (part.property) return `.${part.property} = …`;
102
+ if (part.attribute) {
103
+ if (node && LIVE.has(`${node.localName}.${part.attribute}`)) return `.${part.attribute} = …`;
104
+ if (part.boolean) return `toggleAttribute('${part.attribute}', …)`;
105
+ if (part.prop) return `.${part.prop} = … (or setAttribute('${part.attribute}', …))`;
106
+ return `setAttribute('${part.attribute}', …)`;
107
+ }
108
+ if (part.when) return 'zb-if: insert or remove a block';
109
+ if (part.each) return 'zb-each: add, move or remove rows';
110
+ if (part.await) return 'zb-await: pending, then or catch';
111
+ if (part.slot) return `slot "${part.slot}"`;
112
+ if (part.spread) return 'spread: attributes and properties';
113
+ return 'binding';
114
+ }
115
+
116
+ /** A short name for a node: `<button.on>`, or its parent's for a text anchor or region marker. */
117
+ function describe(node) {
118
+ if (!node) return '?';
119
+ if (node.nodeType === 1) {
120
+ const id = node.id ? `#${node.id}` : '';
121
+ const classes = [...(node.classList ?? [])].slice(0, 2).map((name) => `.${name}`).join('');
122
+ return `<${node.localName}${id}${classes}>`;
123
+ }
124
+ return node.parentNode?.nodeType === 1 ? `${describe(node.parentNode)} content` : node.nodeName;
125
+ }
126
+
127
+ /** A value as a short string: the pane shows values, it doesn't hold them. */
128
+ export function preview(value, depth = 0) {
129
+ value = raw(value);
130
+ if (value === undefined) return 'undefined';
131
+ if (value === null || typeof value === 'number' || typeof value === 'boolean') return String(value);
132
+ if (typeof value === 'string') return JSON.stringify(value.length > 80 ? `${value.slice(0, 80)}…` : value);
133
+ if (typeof value === 'bigint') return `${value}n`;
134
+ if (typeof value === 'symbol') return value.toString();
135
+ if (typeof value === 'function') return `ƒ ${value.name || 'anonymous'}()`;
136
+ if (isComputed(value) || isState(value)) return preview(untrack(() => value.get()), depth);
137
+ if (typeof Node !== 'undefined' && value instanceof Node) return describe(value);
138
+ if (value instanceof Promise) return 'Promise';
139
+ if (Array.isArray(value)) {
140
+ if (depth > 1) return `Array(${value.length})`;
141
+ const shown = value.slice(0, 5).map((item) => preview(item, depth + 1));
142
+ return `[${shown.join(', ')}${value.length > 5 ? `, … ${value.length - 5} more` : ''}]`;
143
+ }
144
+ const name = value.constructor && value.constructor !== Object ? value.constructor.name : '';
145
+ if (depth > 1) return name || '{…}';
146
+ const keys = Object.keys(value);
147
+ const shown = keys.slice(0, 5).map((key) => `${key}: ${preview(value[key], depth + 1)}`);
148
+ return `${name ? `${name} ` : ''}{${shown.join(', ')}${keys.length > 5 ? ', …' : ''}}`;
149
+ }
150
+
151
+ /**
152
+ * Names for the signals a component can read: its props and named script signals, and the
153
+ * property signals of the arrays and objects they hold (`items[0].done`, `items.length`).
154
+ */
155
+ function namer(record) {
156
+ const names = new Map();
157
+ for (const [name, signal] of Object.entries(record.props)) names.set(signal, name);
158
+ for (const [name, signal] of Object.entries(record.named ?? {})) if (signal && typeof signal === 'object') names.set(signal, name);
159
+ const paths = new Map(); // raw object → path
160
+ const walk = (value, path, depth) => {
161
+ value = raw(value);
162
+ if (!value || typeof value !== 'object' || paths.has(value) || depth > 3) return;
163
+ paths.set(value, path);
164
+ if (Array.isArray(value)) value.slice(0, 200).forEach((item, i) => walk(item, `${path}[${i}]`, depth + 1));
165
+ else if (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) {
166
+ for (const key of Object.keys(value).slice(0, 50)) walk(value[key], `${path}.${key}`, depth + 1);
167
+ }
168
+ };
169
+ for (const [signal, name] of names) {
170
+ try { walk(untrack(() => signal.get()), name, 0); } catch {}
171
+ }
172
+ return (signal) => {
173
+ if (names.has(signal)) return names.get(signal);
174
+ const property = deep.propertyOf.get(signal);
175
+ if (!property) return null;
176
+ const path = paths.get(property.target);
177
+ if (!path) return null;
178
+ const { key } = property;
179
+ if (typeof key === 'symbol') return path; // its keys
180
+ return /^\d+$/.test(key) ? `${path}[${key}]` : `${path}.${key}`;
181
+ };
182
+ }
183
+
184
+ /** The named signals `sink` reads, looking through unnamed computeds to the names behind them. */
185
+ function dependencies(sink, name) {
186
+ const found = [];
187
+ const seen = new Set();
188
+ const visit = (node, depth) => {
189
+ let sources;
190
+ try { sources = introspectSources(node); } catch { return; }
191
+ for (const source of sources) {
192
+ if (seen.has(source)) continue;
193
+ seen.add(source);
194
+ const label = name(source);
195
+ if (label) {
196
+ if (!found.includes(label)) found.push(label);
197
+ } else if (isComputed(source) && depth < 6) visit(source, depth + 1);
198
+ }
199
+ };
200
+ visit(sink, 0);
201
+ return found;
202
+ }
203
+
204
+ const location = (part) => (Array.isArray(part?.at) ? { line: part.at[0], column: part.at[1] } : null);
205
+
206
+ /** One binding as the pane shows it. */
207
+ function binding(part, node, effect, name, selected) {
208
+ return {
209
+ operation: operation(part, node),
210
+ target: describe(node),
211
+ at: location(part),
212
+ dependsOn: effect ? dependencies(effect.computed, name) : [],
213
+ ranLast: !!effect && last.ran.has(effect),
214
+ here: node === selected || (node?.nodeType !== 1 && node?.parentNode === selected),
215
+ };
216
+ }
217
+
218
+ /** What the pane shows for `node`: its component, and that component's state and bindings. */
219
+ export function inspect(node) {
220
+ if (!node) return { version: 1, component: null };
221
+ const host = hostOf(node);
222
+ if (!host) return { version: 1, component: null, native: isNative };
223
+ const record = elements.get(host);
224
+ watch(record);
225
+ const name = namer(record);
226
+ const effects = record.scope ? effectsOf(record.scope) : [];
227
+ const bindings = effects
228
+ .filter((effect) => effect.label)
229
+ .map((effect) => binding(effect.label.part, effect.label.node, effect, name, node));
230
+ // Listeners on the selected node (on the host itself, they're its parent's: see `setBy`).
231
+ if (node !== host) for (const part of listeners.get(node) ?? []) bindings.unshift(binding(part, node, null, name, node));
232
+ // The selected node is itself a component: what its parent binds onto it (its props, its events).
233
+ let setBy = null;
234
+ const parent = node === host ? hostOf(host, true) : null;
235
+ if (parent) {
236
+ const parentRecord = elements.get(parent);
237
+ const parentName = namer(parentRecord);
238
+ const onto = (parentRecord.scope ? effectsOf(parentRecord.scope) : [])
239
+ .filter((effect) => effect.label?.node === host)
240
+ .map((effect) => binding(effect.label.part, host, effect, parentName, host));
241
+ for (const part of listeners.get(host) ?? []) onto.push(binding(part, host, null, parentName, host));
242
+ if (onto.length) setBy = { tag: parentRecord.tag, file: parentRecord.definition.source?.file ?? null, bindings: onto };
243
+ }
244
+ const values = (entries) => entries.map(([key, signal]) => ({ name: key, value: preview(signal) }));
245
+ const written = [...last.written].map(name).filter(Boolean);
246
+ return {
247
+ version: 1,
248
+ native: isNative,
249
+ update: updates,
250
+ component: {
251
+ tag: record.tag,
252
+ file: record.definition.source?.file ?? null,
253
+ at: location(record.definition.source),
254
+ located: !!record.definition.source,
255
+ },
256
+ props: values(Object.entries(record.props)),
257
+ state: values(Object.entries(record.named ?? {}).filter(([, signal]) => signal && typeof signal === 'object')),
258
+ bindings,
259
+ setBy,
260
+ lastUpdate: {
261
+ number: updates,
262
+ written: [...new Set(written)],
263
+ ran: bindings.filter((item) => item.ranLast).length,
264
+ },
265
+ };
266
+ }
267
+
268
+ /** Open `file` at `line` and `column` in the editor, through Vite's dev server (or the page). */
269
+ export function open(file, line = 1, column = 1) {
270
+ // A page can show the place itself (the playground selects the line in its editor): it
271
+ // cancels this event. Otherwise Vite's dev server opens the editor.
272
+ const event = new CustomEvent('zubra:open', { detail: { file, line, column }, cancelable: true });
273
+ if (!globalThis.dispatchEvent(event)) return Promise.resolve(true);
274
+ return fetch(`/__open-in-editor?file=${encodeURIComponent(`${file}:${line}:${column}`)}`).then((response) => response.ok, () => false);
275
+ }
276
+
277
+ // (defineProperties, not Object.assign, which would copy the getter's value once.)
278
+ Object.defineProperties(globalThis.__ZUBRA_DEVTOOLS__, {
279
+ ready: { value: true, enumerable: true },
280
+ inspect: { value: inspect, enumerable: true },
281
+ open: { value: open, enumerable: true },
282
+ /** Counts flushes since connecting: the pane re-reads when it changes. */
283
+ updates: { get: () => updates, enumerable: true },
284
+ });
package/src/effect.js CHANGED
@@ -28,6 +28,15 @@ let scheduled = null; // promise for the pending microtask flush
28
28
  let flushing = false;
29
29
  const effectOf = new WeakMap();
30
30
 
31
+ // @dev
32
+ /**
33
+ * For the DevTools hook (devtools.js), in development only: `label` is what the effect being
34
+ * created binds (element.js sets it around each binding), and `flushed` hears which effects each
35
+ * flush re-ran.
36
+ */
37
+ export const dev = { label: null, flushed: null };
38
+ // @dev-end
39
+
31
40
  const watcher = new Signal.subtle.Watcher(() => {
32
41
  scheduled ??= new Promise((resolve) => queueMicrotask(() => {
33
42
  try { flush(); } finally { scheduled = null; resolve(); }
@@ -113,6 +122,9 @@ class Effect extends Owner {
113
122
  super(parent);
114
123
  this.id = ++nextId;
115
124
  this.cleanup = undefined;
125
+ // @dev
126
+ this.label = dev.label;
127
+ // @dev-end
116
128
  this.computed = new Signal.Computed(() => {
117
129
  this.disposeChildren();
118
130
  this.runCleanup();
@@ -209,11 +221,19 @@ export function root(fn) {
209
221
  export function flush() {
210
222
  if (flushing) return;
211
223
  flushing = true;
224
+ // @dev
225
+ const ran = [];
226
+ // @dev-end
212
227
  try {
213
228
  for (let round = 0; ; round++) {
214
229
  const pending = watcher.getPending();
215
230
  watcher.watch(); // re-arm before running, so writes made by effects schedule another round
216
- if (pending.length === 0) return;
231
+ if (pending.length === 0) {
232
+ // @dev
233
+ if (ran.length) dev.flushed?.(ran);
234
+ // @dev-end
235
+ return;
236
+ }
217
237
  if (round >= MAX_ROUNDS) {
218
238
  report(new Error('zubra: effects keep re-triggering each other; stopped after 100 rounds'));
219
239
  return;
@@ -222,6 +242,9 @@ export function flush() {
222
242
  for (const e of effects) {
223
243
  if (e.disposed || e.paused) continue;
224
244
  try { e.run(); } catch (error) { report(error); }
245
+ // @dev
246
+ ran.push(e);
247
+ // @dev-end
225
248
  }
226
249
  }
227
250
  } finally {
package/src/element.js CHANGED
@@ -12,6 +12,10 @@ import { Signal } from './signal.js';
12
12
  import { wrap, raw } from './deep.js';
13
13
  import { effect, scope, root as rootScope, tick, batch } from './effect.js';
14
14
  import { RESOURCE } from './resource.js';
15
+ // @dev (left out of the production build: scripts/build-production.js)
16
+ import { dev as devEffects } from './effect.js';
17
+ import { register as devRegister, listened as devListened } from './devhook.js';
18
+ // @dev-end
15
19
 
16
20
  const { untrack } = Signal.subtle;
17
21
  const TEXT = 3;
@@ -690,6 +694,12 @@ function listen(part, node) {
690
694
  }
691
695
 
692
696
  function bind(part, node, hydrating, context) {
697
+ // @dev
698
+ // The effects created here are this part's (for the DevTools hook, devtools.js).
699
+ const outerLabel = devEffects.label;
700
+ devEffects.label = { part, node };
701
+ if (part.on && !part.global) devListened(part, node);
702
+ // @dev-end
693
703
  if (part.text) {
694
704
  effect(() => {
695
705
  const text = part.text();
@@ -764,6 +774,9 @@ function bind(part, node, hydrating, context) {
764
774
  } else if (part.slot) {
765
775
  bindSlot(part, node, hydrating, context);
766
776
  }
777
+ // @dev
778
+ devEffects.label = outerLabel;
779
+ // @dev-end
767
780
  }
768
781
 
769
782
  /** The signal saying whether slot `name` has content (shared by hasSlot() and the slot). */
@@ -1738,6 +1751,9 @@ function defineElement(tag, definition) {
1738
1751
  mount: [], unmount: [], host: this, props: $p, instance: ++instances, ids: 0, filled: new Map(), states: [], kept, hydrated: new Signal.State(false),
1739
1752
  internals: this.#internals, formReset: [], formRestore: [], formDisabled: [],
1740
1753
  };
1754
+ // @dev
1755
+ let named = null; // the script's signals by name (a --devtools build)
1756
+ // @dev-end
1741
1757
  // The element's own root: an effect that inserted it (a list row) doesn't own it, so
1742
1758
  // re-running that effect can't dispose the element's effects.
1743
1759
  this.#scope = rootScope(() => {
@@ -1749,7 +1765,17 @@ function defineElement(tag, definition) {
1749
1765
  const initial = (name, compute) => {
1750
1766
  if (!this.#given.has(name)) this.#signals[name].set(compute());
1751
1767
  };
1752
- try { parts = setup ? setup($p, (name, detail) => this.#emit(name, detail), initial) : []; } finally { current = previous; }
1768
+ // @dev
1769
+ // A --devtools build hands over its script's signals by name, for the DevTools hook.
1770
+ const dev = (names) => { named = names; };
1771
+ // @dev-end
1772
+ try {
1773
+ parts = setup ? setup($p, (name, detail) => this.#emit(name, detail), initial
1774
+ // @dev
1775
+ , dev
1776
+ // @dev-end
1777
+ ) : [];
1778
+ } finally { current = previous; }
1753
1779
  // A form control with one two-way prop: that's what it submits, and what a form reset
1754
1780
  // restores, unless the script says otherwise.
1755
1781
  const models = Object.keys(props).filter((name) => props[name].model);
@@ -1829,6 +1855,9 @@ function defineElement(tag, definition) {
1829
1855
  // always calls formDisabledCallback after connectedCallback, so this never runs there (the
1830
1856
  // browser tests confirm the behaviour without it); it guards engines that order the
1831
1857
  // callbacks the other way, which the test suite can't currently exercise.
1858
+ // @dev
1859
+ devRegister(this, { tag, definition, props: this.#signals, named, scope: this.#scope });
1860
+ // @dev-end
1832
1861
  if (this.#disabled) for (const fn of this.#hooks.formDisabled) fn(true);
1833
1862
  }
1834
1863
  }
package/src/remark.js CHANGED
@@ -9,10 +9,12 @@
9
9
  // - an zb-if region holds the branch the current values choose, and an zb-each region one row
10
10
  // per item, each with the shape of its block (checked element by element);
11
11
  // - a light-DOM slot region is the rest of its range, when nothing follows it there; its
12
- // content is the fallback if it has the fallback's shape, and supplied content otherwise.
13
- // Anything else (an zb-await, content projected into a child component, a region the server
14
- // left to the browser, other than one left empty) can't be placed for sure, and the element
15
- // renders afresh as before.
12
+ // content is the fallback if it has the fallback's shape, and supplied content otherwise;
13
+ // - an zb-await region holds its zb-then block, for a value known at build time, or its pending
14
+ // block, which the element renders afresh anyway (so only where the region ends matters).
15
+ // Anything else (content projected into a child component, a region the server left to the
16
+ // browser, other than one left empty or a pending zb-await) can't be placed for sure, and the
17
+ // element renders afresh as before.
16
18
 
17
19
  const TEXT = 3;
18
20
  const COMMENT = 8;
@@ -55,7 +57,12 @@ export function remark(root, template, parts, { blueprint, isOpen, isClose, read
55
57
  let index = 0;
56
58
  for (; t; t = t.nextSibling) {
57
59
  if (t.nodeType === TEXT) {
58
- if (cursor?.nodeType === TEXT) cursor = cursor.nextSibling; // static text (if kept)
60
+ // Static text (if kept): the same words as the template's. Text that isn't (a page's own
61
+ // `<x-a>Save</x-a>` where the template has only whitespace) isn't server markup.
62
+ if (cursor?.nodeType === TEXT) {
63
+ if (cursor.data.trim() !== t.data.trim()) throw fail;
64
+ cursor = cursor.nextSibling;
65
+ }
59
66
  continue;
60
67
  }
61
68
  const path = [...prefix, index];
@@ -146,8 +153,40 @@ export function remark(root, template, parts, { blueprint, isOpen, isClose, read
146
153
  insert(parent, null, ']');
147
154
  }
148
155
  cursor = null;
156
+ } else if (part.await) {
157
+ // zb-await: the server rendered the zb-then block for a value it knew at build time (a
158
+ // plain value here too), or the pending block in a region it left to the element, which
159
+ // the element renders afresh anyway (`[?`): only its extent has to be found, by the
160
+ // pending block's shape. Failing both, an empty region left to the element, as above.
161
+ const saved = edits.length;
162
+ const start = cursor;
163
+ const value = untrack(() => $r(part.await()));
164
+ const thenable = (item) => item != null && typeof $r(item)?.then === 'function';
165
+ const settled = !(Array.isArray(value) ? value.some(thenable) : thenable(value));
166
+ const attempts = [];
167
+ if (settled && part.blocks[1]) attempts.push(['[', part.blocks[1], [new State(value), new State(() => {})]]);
168
+ if (part.blocks[0]) attempts.push(['[?', part.blocks[0], []]);
169
+ let placed = false;
170
+ for (const [open, chosen, args] of attempts) {
171
+ try {
172
+ insert(parent, cursor, open);
173
+ cursor = block(chosen, args);
174
+ insert(parent, cursor, ']');
175
+ placed = true;
176
+ break;
177
+ } catch (error) {
178
+ if (error !== fail) throw error;
179
+ edits.length = saved;
180
+ cursor = start;
181
+ }
182
+ }
183
+ if (!placed) {
184
+ if (!followsRegion(t, skipText(cursor))) throw fail;
185
+ insert(parent, cursor, '[?');
186
+ insert(parent, cursor, ']');
187
+ }
149
188
  } else {
150
- throw fail; // zb-await
189
+ throw fail;
151
190
  }
152
191
  // Past the template's matching close.
153
192
  let depth = 0;