@jxsuite/runtime 6.3.0 → 7.1.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.
package/src/runtime.ts CHANGED
@@ -36,7 +36,12 @@ import {
36
36
  isNestedSelectorKey,
37
37
  transposeCanvasOverlaySelector,
38
38
  } from "./css.ts";
39
- import { evaluateExpression, evaluateOperand, isMutating } from "./expression.ts";
39
+ import {
40
+ evaluateExpression,
41
+ evaluateOperand,
42
+ inheritableFormulas,
43
+ isMutating,
44
+ } from "./expression.ts";
40
45
  import { readPath } from "./pointer.ts";
41
46
  import type {
42
47
  JxContext,
@@ -70,6 +75,7 @@ import {
70
75
  isString,
71
76
  isTagExpression,
72
77
  isTemplateString,
78
+ templateExpressions,
73
79
  } from "@jxsuite/schema/guards";
74
80
  import type { TagNameSlot } from "@jxsuite/schema/guards";
75
81
  import {
@@ -80,11 +86,20 @@ import {
80
86
  import type { FunctionRole } from "@jxsuite/schema/function-role";
81
87
  import { formatSrcset, parseSrcset } from "@jxsuite/schema/asset-paths";
82
88
  import { runStatements } from "./statements.ts";
83
- import { compileBody, prepareBodies } from "./body-eraser.ts";
89
+ import { canLoadModules, compileBody, moduleLoaderFor, prepareBodies } from "./body-eraser.ts";
90
+ import {
91
+ isScriptSpecifier,
92
+ isTypeScriptPath,
93
+ parseScriptSpecifier,
94
+ qualifyScripts,
95
+ scriptRefSide,
96
+ scriptSpecifier,
97
+ } from "@jxsuite/schema/document-scripts";
84
98
 
85
99
  import { readCookie, serializeCookie } from "./cookie.ts";
86
100
  import type {
87
101
  CemParameter,
102
+ JsonObject,
88
103
  JsonValue,
89
104
  JxAttributeValue,
90
105
  JxClassCtorDef,
@@ -98,6 +113,7 @@ import type {
98
113
  JxPrototypeDef,
99
114
  JxRef,
100
115
  JxServerFnDef,
116
+ JxStateDefinition,
101
117
  JxStyle,
102
118
  } from "@jxsuite/schema/types";
103
119
  import type { EffectScope, Ref } from "@vue/reactivity";
@@ -107,8 +123,15 @@ export {
107
123
  prepareBodies,
108
124
  setBodyEraser,
109
125
  setBodyEraserLoader,
126
+ setModuleLoader,
127
+ } from "./body-eraser.ts";
128
+ export type {
129
+ BodyEraser,
130
+ BodyEraserLoader,
131
+ BodyEraserModule,
132
+ ModuleLoader,
133
+ ModuleLoadOptions,
110
134
  } from "./body-eraser.ts";
111
- export type { BodyEraser, BodyEraserLoader, BodyEraserModule } from "./body-eraser.ts";
112
135
 
113
136
  // ─── Public API ───────────────────────────────────────────────────────────────
114
137
 
@@ -236,7 +259,7 @@ function assertHostScope(scope: JxScope): void {
236
259
  * root it can remove and a `dispose()` that stops every effect the render created. Nothing here
237
260
  * writes a module global, so a shell can hold many roots with different needs at once.
238
261
  *
239
- * @param {JxDocument} doc - The document to render (already an object; use {@link resolve} for a
262
+ * @param {JxDocument} source - The document to render (already an object; use {@link resolve} for a
240
263
  * URL)
241
264
  * @param {ParentNode} target - Where the rendered root is appended
242
265
  * @param {JxMountOptions} [options]
@@ -244,13 +267,14 @@ function assertHostScope(scope: JxScope): void {
244
267
  * @docs extending/embedding/runtime-host
245
268
  */
246
269
  export async function mount<S extends JxHostScope<S> = JxScope>(
247
- doc: JxDocument,
270
+ source: JxDocument,
248
271
  target: ParentNode,
249
272
  options: JxMountOptions<S> = {},
250
273
  ): Promise<JxMount> {
251
274
  const hostScope: JxScope = options.scope ? hostScopeOf(options.scope) : {};
252
275
  assertHostScope(hostScope);
253
276
  const base = options.base ? new URL(options.base, location.href).href : location.href;
277
+ const doc = inlineDocument(source, base);
254
278
  const ctx: JxContext = {
255
279
  base,
256
280
  media: options.media ?? doc.$media ?? null,
@@ -292,6 +316,16 @@ export async function mount<S extends JxHostScope<S> = JxScope>(
292
316
  if (options.onNodeMoved) {
293
317
  renderOptions.onNodeMoved = options.onNodeMoved;
294
318
  }
319
+ // The authoring hooks (off unless a host names them): see `JxRenderOptions`.
320
+ if (options.rowLimit !== undefined) {
321
+ renderOptions.rowLimit = options.rowLimit;
322
+ }
323
+ if (options.emptyRow) {
324
+ renderOptions.emptyRow = options.emptyRow;
325
+ }
326
+ if (options.onListReconciled) {
327
+ renderOptions.onListReconciled = options.onListReconciled;
328
+ }
295
329
  const { result: root, stop } = runScoped(() => renderNode(doc, scope, renderOptions));
296
330
  ctx.root = root;
297
331
 
@@ -377,7 +411,7 @@ export async function resolve(source: string | JxDocument, ctx?: JxContext): Pro
377
411
  // A mount's resolver answers for THIS mount and its hit never enters the shared cache.
378
412
  const served = ctx?.resolver?.(source);
379
413
  if (served) {
380
- return served;
414
+ return withOwnScripts(await served, source);
381
415
  }
382
416
  if (_resolveCache.has(source)) {
383
417
  return _resolveCache.get(source)!;
@@ -422,7 +456,8 @@ export async function resolve(source: string | JxDocument, ctx?: JxContext): Pro
422
456
  return (await res.json()) as JxDocument;
423
457
  });
424
458
  // A body that needs the eraser has it before anything compiles, which happens synchronously.
425
- const p = fetched.then(async (doc) => {
459
+ const p = fetched.then(async (read) => {
460
+ const doc = withOwnScripts(read, source);
426
461
  await prepareBodies(doc);
427
462
  return doc;
428
463
  });
@@ -495,6 +530,64 @@ export function setSkipAutoRequests(v: boolean) {
495
530
  _autoRequestConfig.skip = v;
496
531
  }
497
532
 
533
+ /**
534
+ * One remote lookup the runtime is about to start: an automatic `Request` fetch, or a call of a
535
+ * `timing: "server"` function (embedding.md §5.2).
536
+ */
537
+ export interface RemoteLookup {
538
+ kind: "request" | "server";
539
+ /** The state key the entry is declared under. */
540
+ key: string;
541
+ /** The scope the entry belongs to: a document's, or a component instance's. */
542
+ scope: JxScope;
543
+ /**
544
+ * What the lookup asks for, with every binding in it resolved. A request: `method`, `url`, and
545
+ * `headers` and `body` where the entry declares them. A server function: `$src`, `$export`,
546
+ * `arguments`, and `$base` where the document has one. Two lookups that ask for the same thing
547
+ * hold equal values here, which is what a host keys a cache on.
548
+ */
549
+ request: JsonObject;
550
+ }
551
+
552
+ /**
553
+ * A host's answer to a {@link RemoteLookup}. `{ value }` is written to the entry at once and nothing
554
+ * is in flight; a promise stands for the lookup (its answer is written when it settles, and a
555
+ * rejection is the entry's failure, as the lookup's own would be); `null` starts nothing, and the
556
+ * entry keeps the value it has.
557
+ */
558
+ export type RemoteLookupAnswer = { value: JsonValue } | Promise<JsonValue> | null;
559
+
560
+ /**
561
+ * What a host installs with {@link setRemoteLookupHandler}. `run` starts the lookup itself and
562
+ * answers with its promise; a handler that never calls it has kept the lookup from happening.
563
+ */
564
+ export type RemoteLookupHandler = (
565
+ lookup: RemoteLookup,
566
+ run: () => Promise<JsonValue>,
567
+ ) => RemoteLookupAnswer;
568
+
569
+ let _remoteLookupHandler: RemoteLookupHandler | null = null;
570
+
571
+ /**
572
+ * Put `handler` between the runtime and every remote lookup it starts, or take it away with `null`
573
+ * (embedding.md §5.2).
574
+ *
575
+ * The two skip switches above are all or nothing, and a host that draws a document over and over
576
+ * (Studio's canvas renders on every structural edit) needs a third answer: run this lookup once and
577
+ * hand every later render what it answered. The handler is asked each time a lookup would start,
578
+ * after the skip switches have had their say, and decides per lookup: let it run, answer it from
579
+ * what it holds, or leave it alone.
580
+ *
581
+ * A lookup started through `run` is the handler's. It is not aborted when the binding that asked
582
+ * for it is torn down, because the handler may be keeping the answer for the next one that asks;
583
+ * the runtime only declines to write an answer that arrives for a binding no longer there.
584
+ *
585
+ * @param {RemoteLookupHandler | null} handler
586
+ */
587
+ export function setRemoteLookupHandler(handler: RemoteLookupHandler | null): void {
588
+ _remoteLookupHandler = handler;
589
+ }
590
+
498
591
  // ─── Dev-proxy auth token (Studio cross-origin canvas) ────────────────────────
499
592
  // The Studio canvas iframe is served from a token-gated loopback origin (createProjectServer): its
500
593
  // POST /__jx_resolve__ + /__jx_server__ routes 403 without ?token=<rpcToken>. The iframe boot reads
@@ -700,9 +793,42 @@ export function setCanvasDelinkCommands(on: boolean) {
700
793
  */
701
794
  let _canvasInsideStampedHost = false;
702
795
 
796
+ /**
797
+ * Run a later redraw (a `$switch` case that flips, a `$map` row that arrives) as inside a stamped
798
+ * host or not, the way its first render was.
799
+ *
800
+ * The flag is true only while a stamped instance draws, and a redraw comes from an effect with
801
+ * nothing on the stack (a list's from a microtask), so without this a case or a row drawn after the
802
+ * first paint kept its native `popover`, `inert` and `commandfor` inside the canvas.
803
+ *
804
+ * @param {boolean} inside - What the flag was when the list or switch was first rendered.
805
+ * @param {() => T} fn - The redraw.
806
+ * @returns {T}
807
+ */
808
+ function insideStampedHost<T>(inside: boolean, fn: () => T): T {
809
+ const was = _canvasInsideStampedHost;
810
+ _canvasInsideStampedHost = inside;
811
+ try {
812
+ return fn();
813
+ } finally {
814
+ _canvasInsideStampedHost = was;
815
+ }
816
+ }
817
+
703
818
  /** Whether the node being rendered is inside a stamped host, and so part of the edited page. */
704
819
  function canvasStamped(el: HTMLElement): boolean {
705
- return _canvasInsideStampedHost || el.dataset.jxPath !== undefined;
820
+ return _canvasInsideStampedHost || carriesCanvasStamp(el);
821
+ }
822
+
823
+ /**
824
+ * Whether the studio stamped this element as part of the page being edited: with its document path
825
+ * (`data-jx-path`), or, for a mapped row that is not the one being edited, with the path it echoes
826
+ * (`data-jx-echo-path`). An echo row draws the same document nodes as the active one, so it is
827
+ * de-linked like it: left out, every row but one would keep live popovers, dialogs and commands in
828
+ * an editable canvas.
829
+ */
830
+ function carriesCanvasStamp(el: HTMLElement): boolean {
831
+ return el.dataset.jxPath !== undefined || el.dataset.jxEchoPath !== undefined;
706
832
  }
707
833
 
708
834
  /** The attribute name to stamp `key` on `el` under — `href` → `data-jx-href` on de-linked anchors. */
@@ -900,18 +1026,20 @@ function boundPropKey(
900
1026
  /**
901
1027
  * Build the reactive scope (state) from the document using the five-shape detection algorithm.
902
1028
  *
903
- * @param {JxDocument} doc
1029
+ * @param {JxDocument} source - The document
904
1030
  * @param {JxScope} [parentScope] Default is `{}`
905
1031
  * @param {string} [base] Base URL for resolving $src imports. Default is `location.href`
906
1032
  * @param {JxContext} [ctx] The mount context; absent on the legacy direct path
907
1033
  * @returns {Promise<JxScope>} Reactive proxy (state)
908
1034
  */
909
1035
  export async function buildScope(
910
- doc: JxDocument,
1036
+ source: JxDocument,
911
1037
  parentScope: JxScope = {},
912
1038
  base: string = location.href,
913
1039
  ctx?: JxContext,
914
1040
  ) {
1041
+ // Already done for a document that was mounted or read from a URL; this is for one handed in raw.
1042
+ const doc = inlineDocument(source, base);
915
1043
  // A body run without an event dispatches from the mount's root, read when it fires.
916
1044
  const dispatchRoot = (): EventTarget | null => ctx?.root ?? null;
917
1045
  const raw: JxScope = {};
@@ -1310,6 +1438,7 @@ function bindTemplate(source: string, run: () => void, fallback?: () => void): v
1310
1438
  run();
1311
1439
  heldTemplateErrors.delete(run);
1312
1440
  } catch (error) {
1441
+ _templateThrows += 1;
1313
1442
  const thrown = error instanceof Error ? error : new Error(String(error));
1314
1443
  if (_templateErrorHandler) {
1315
1444
  _templateErrorHandler(thrown, source);
@@ -1323,6 +1452,20 @@ function bindTemplate(source: string, run: () => void, fallback?: () => void): v
1323
1452
  });
1324
1453
  }
1325
1454
 
1455
+ /**
1456
+ * How many template evaluations have thrown and been survived. {@link updateInstanceProps} reads it
1457
+ * around its writes: a binding that throws keeps what it last drew, which a fresh render of the
1458
+ * same values never drew.
1459
+ */
1460
+ let _templateThrows = 0;
1461
+
1462
+ /**
1463
+ * How many times a mapped list has been asked to redraw. A list redraws in a later microtask, so
1464
+ * {@link updateInstanceProps} cannot answer for what that draws (a row that throws, for one) and
1465
+ * reads this around its writes instead.
1466
+ */
1467
+ let _listRedraws = 0;
1468
+
1326
1469
  /** A compiled template: the scope, the row context, its item and index, and the page context. */
1327
1470
  type TemplateFn<R> = (
1328
1471
  state: JxScope,
@@ -1350,11 +1493,15 @@ function rowContext(state: JxScope): JxRowContext | undefined {
1350
1493
  * Whether a template is ONE expression end to end — `"${a.b}"` rather than `"a ${b}"` or
1351
1494
  * `"${a}${b}"`.
1352
1495
  *
1353
- * Depth-counted rather than pattern-matched. A greedy `/^\$\{(.+)\}$/` also matched `"${a} /
1354
- * ${b}"`, spliced the interior into `return (a} / ${b)`, and the SyntaxError became a silent null:
1355
- * the node rendered empty and a `$head` entry shipped its own template text. A brace scan is exact
1356
- * where a tightened regex is not — `"${`${a}-x`}"` is still one expression, and must keep its raw
1357
- * value.
1496
+ * Scanned rather than pattern-matched. A greedy `/^\$\{(.+)\}$/` also matched `"${a} / ${b}"`,
1497
+ * spliced the interior into `return (a} / ${b)`, and the SyntaxError became a silent null: the node
1498
+ * rendered empty and a `$head` entry shipped its own template text. A scan is exact where a
1499
+ * tightened regex is not — `"${`${a}-x`}"` is still one expression, and must keep its raw value.
1500
+ *
1501
+ * The scan is `templateExpressions`, the one every reader of a template shares, so a `}` inside a
1502
+ * string does not end the expression: a brace count read `"${a ? '}' : b}"` as text around an
1503
+ * expression, and its value arrived as a string where the same expression without the quoted brace
1504
+ * kept its type.
1358
1505
  *
1359
1506
  * Lives here rather than in the compiler because both renderers must draw the line in the same
1360
1507
  * place. It is what decides whether a value keeps its own type, and a value that is a boolean in
@@ -1368,19 +1515,8 @@ export function isSingleExpression(str: string): boolean {
1368
1515
  if (!str.startsWith("${") || !str.endsWith("}")) {
1369
1516
  return false;
1370
1517
  }
1371
- let depth = 0;
1372
- for (let i = 1; i < str.length; i += 1) {
1373
- const c = str[i];
1374
- if (c === "{") {
1375
- depth += 1;
1376
- } else if (c === "}") {
1377
- depth -= 1;
1378
- if (depth === 0) {
1379
- return i === str.length - 1;
1380
- }
1381
- }
1382
- }
1383
- return false;
1518
+ const [only, ...more] = templateExpressions(str);
1519
+ return only !== undefined && more.length === 0 && only.start === 0 && only.end === str.length;
1384
1520
  }
1385
1521
 
1386
1522
  /**
@@ -1419,6 +1555,26 @@ function evaluateValueTemplate(str: string, state: JxScope): JxScopeValue {
1419
1555
  return fn(...templateArgs(state));
1420
1556
  }
1421
1557
 
1558
+ /**
1559
+ * Evaluate a template against a scope, for editor tooling: the value a binding would write.
1560
+ *
1561
+ * It is the runtime's own reading, so a tool that shows a template's live value cannot disagree
1562
+ * with the render beside it. A template that is one whole `${…}` answers the expression's own value
1563
+ * (an array, a boolean, a record); anything else answers the interpolated string. The expression
1564
+ * sees exactly the names a rendered template does: `state` (the scope), `$map`, `item` and `index`
1565
+ * (the scope's row context, undefined outside a mapped row), and `$site` and `$page` (read off the
1566
+ * scope). Nothing is caught: an expression that throws, or a source that does not compile, throws
1567
+ * here, and reads made during the call are tracked by whatever effect is running.
1568
+ *
1569
+ * @param {string} str - Template source
1570
+ * @param {JxScope} scope - The scope to read, a row's own scope for a template inside a `map`
1571
+ * @returns {JxScopeValue} The expression's value, or the interpolated string
1572
+ * @docs extending/embedding/runtime-host
1573
+ */
1574
+ export function evaluateTemplateValue(str: string, scope: JxScope): JxScopeValue {
1575
+ return evaluateValueTemplate(str, scope);
1576
+ }
1577
+
1422
1578
  // ─── Step 2b: Function resolution (Shape 4) ─────────────────────────────────
1423
1579
 
1424
1580
  /**
@@ -1478,6 +1634,14 @@ async function importSource(src: string, base?: string): Promise<ImportedModule>
1478
1634
  const specifier = src.slice(NPM_SCHEME.length);
1479
1635
  return importModule(_npmModuleResolver?.(specifier) ?? specifier);
1480
1636
  }
1637
+ const typed = typeScriptSource(src, base);
1638
+ // Asked only where an answer is possible, so a page with no loader waits no longer than it did.
1639
+ if (typed !== null && (canLoadModules() || isScriptSpecifier(src))) {
1640
+ const loaded = await importTypeScript(typed, src, base);
1641
+ if (loaded) {
1642
+ return loaded;
1643
+ }
1644
+ }
1481
1645
  if (base) {
1482
1646
  try {
1483
1647
  return await importModule(new URL(src, base).href);
@@ -1488,6 +1652,145 @@ async function importSource(src: string, base?: string): Promise<ImportedModule>
1488
1652
  return importModule(src);
1489
1653
  }
1490
1654
 
1655
+ /** The schemes a page can fetch a module's text from. */
1656
+ const FETCHED_SCHEMES = new Set(["http:", "https:"]);
1657
+
1658
+ /**
1659
+ * What the module loader is asked for when a `$src` names TypeScript (spec.md §23.2): a document
1660
+ * script's specifier as it is, or the URL of a `.ts` file a page can fetch. Null for anything a
1661
+ * browser imports as it is.
1662
+ *
1663
+ * @param {string} src - The `$src`, qualified if it named its document's script
1664
+ * @param {string} [base] - The document's URL
1665
+ * @returns {string | null}
1666
+ * @throws {Error} For `#client` or `#server` in a document with no `$script`
1667
+ */
1668
+ function typeScriptSource(src: string, base?: string): string | null {
1669
+ if (isScriptSpecifier(src)) {
1670
+ return src;
1671
+ }
1672
+ if (scriptRefSide(src) !== null) {
1673
+ throw new Error(
1674
+ `Jx: $src "${src}" names the script of the document it is written in, and that document ` +
1675
+ `has no $script`,
1676
+ );
1677
+ }
1678
+ const from = base ?? location.href;
1679
+ if (!URL.canParse(src, from)) {
1680
+ return null;
1681
+ }
1682
+ const url = new URL(src, from);
1683
+ // A `file:` path is a test runner's, which imports TypeScript itself; a seeded scheme never gets here.
1684
+ return FETCHED_SCHEMES.has(url.protocol) && isTypeScriptPath(url.pathname) ? url.href : null;
1685
+ }
1686
+
1687
+ /**
1688
+ * Import a module written in TypeScript through the host's module loader. Null when the page has
1689
+ * none and the module is a `.ts` file, which is then imported as written: a host that transpiles
1690
+ * what it serves still answers that. A document's script has no URL to fall back to, so it says
1691
+ * what is missing.
1692
+ *
1693
+ * @param {string} specifier - What {@link typeScriptSource} gave
1694
+ * @param {string} src - The `$src` as the document holds it
1695
+ * @param {string} [base] - The document's URL
1696
+ * @returns {Promise<ImportedModule | null>}
1697
+ * @throws {Error} For a document's script on a page with no module loader
1698
+ */
1699
+ async function importTypeScript(
1700
+ specifier: string,
1701
+ src: string,
1702
+ base?: string,
1703
+ ): Promise<ImportedModule | null> {
1704
+ const load = await moduleLoaderFor();
1705
+ if (load) {
1706
+ const module = await load(specifier, {
1707
+ base,
1708
+ resolveNpm: _npmModuleResolver,
1709
+ text: _documentScripts.get(src),
1710
+ });
1711
+ /* SAFETY: trust boundary, the same one as `importModule` below: the loader hands back the
1712
+ module namespace `import()` gave it, read by export name and checked at each use. */
1713
+ return module as ImportedModule;
1714
+ }
1715
+ if (isScriptSpecifier(src)) {
1716
+ throw new Error(
1717
+ `Jx: ${parseScriptSpecifier(src)?.path ?? src} has a script, and this page has nothing ` +
1718
+ `that can load one. Give it a module loader with setModuleLoader(loadModule), loadModule ` +
1719
+ `from @jxsuite/erase/browser, or let setBodyEraserLoader fetch that file when it is needed.`,
1720
+ );
1721
+ }
1722
+ return null;
1723
+ }
1724
+
1725
+ /**
1726
+ * The text of each document's client script the runtime has read, by the qualified specifier that
1727
+ * names it, so importing one fetches nothing.
1728
+ */
1729
+ const _documentScripts = new Map<string, string>();
1730
+
1731
+ /**
1732
+ * A document as the runtime uses it: every `$src` naming its own script qualified with the
1733
+ * document's address (spec.md §23.2), and its client script kept for the module loader.
1734
+ *
1735
+ * @param {JxDocument} doc - The document as read
1736
+ * @param {string} source - Where it was read from: a URL, or a key a host serves it under
1737
+ * @returns {JxDocument}
1738
+ */
1739
+ function withOwnScripts(doc: JxDocument, source: string): JxDocument {
1740
+ if (!doc.$script) {
1741
+ return doc;
1742
+ }
1743
+ // Its absolute URL, so one document is one module however each reference to it was spelled.
1744
+ const address = URL.canParse(source, location.href)
1745
+ ? new URL(source, location.href).href
1746
+ : source;
1747
+ const { client } = doc.$script;
1748
+ const specifier = scriptSpecifier(address, "client");
1749
+ if (isString(client) && _documentScripts.get(specifier) !== client) {
1750
+ /* Read again with its script edited, which an editing host does on every change: the module
1751
+ made from the old text is not this document's any more. One the host seeded stays, because
1752
+ the host is then what says what the module is. */
1753
+ if (_documentScripts.has(specifier) && !_hostSeeded.has(specifier)) {
1754
+ _moduleCache.delete(specifier);
1755
+ }
1756
+ _documentScripts.set(specifier, client);
1757
+ }
1758
+ const own = qualifyScripts(doc, address);
1759
+ _addressed.set(own, own);
1760
+ return own;
1761
+ }
1762
+
1763
+ /**
1764
+ * Each document that has a script, with the copy that names it: itself for one read from an
1765
+ * address, so mounting what `resolve()` gave does not address it a second time.
1766
+ */
1767
+ const _addressed = new WeakMap<JxDocument, JxDocument>();
1768
+ let _inlineCount = 0;
1769
+
1770
+ /**
1771
+ * A document handed over as an object, with every `$src` naming its own script qualified. It has no
1772
+ * URL of its own, so it stands at its base, and under a name no other document on the page has: two
1773
+ * documents mounted from one page are still two modules, and one mounted twice is one.
1774
+ *
1775
+ * @param {JxDocument} doc - The document as given
1776
+ * @param {string} base - What its relative references resolve against
1777
+ * @returns {JxDocument}
1778
+ */
1779
+ function inlineDocument(doc: JxDocument, base: string): JxDocument {
1780
+ if (!doc.$script) {
1781
+ return doc;
1782
+ }
1783
+ let own = _addressed.get(doc);
1784
+ if (!own) {
1785
+ _inlineCount += 1;
1786
+ const address = new URL(base, location.href);
1787
+ address.hash = `jx-inline-${_inlineCount}`;
1788
+ own = withOwnScripts(doc, address.href);
1789
+ _addressed.set(doc, own);
1790
+ }
1791
+ return own;
1792
+ }
1793
+
1491
1794
  function importModule(specifier: string): Promise<ImportedModule> {
1492
1795
  /* SAFETY: trust boundary — a module exports whatever it exports. `import()` resolves to the
1493
1796
  module namespace object, whose keys are its export names, and every export is typed as the scope
@@ -1613,6 +1916,73 @@ function seededModule(src: string): ImportedModule | Promise<ImportedModule> | u
1613
1916
  */
1614
1917
  type ScopeFunction = (...args: JxScopeValue[]) => JxScopeValue;
1615
1918
 
1919
+ /**
1920
+ * The module a `$src` names: the host's seeded copy, the cached one, or a fetch that is cached when
1921
+ * it settles. Returned as it comes, a module or a promise of one, so an eager caller adds no
1922
+ * microtask of its own: a kit element's scope is ready a fixed number of ticks after it connects,
1923
+ * and `jx-field` names the control it slots at the end of them.
1924
+ *
1925
+ * @param {string} src - The specifier
1926
+ * @param {string} [base] - Base URL for resolving relative specifiers
1927
+ */
1928
+ function acquireModule(src: string, base?: string): ImportedModule | Promise<ImportedModule> {
1929
+ const seeded = seededModule(src);
1930
+ if (seeded) {
1931
+ return seeded;
1932
+ }
1933
+ const loading = importSource(src, base);
1934
+ // A side branch: it caches the module without delaying what the caller awaits.
1935
+ loading.then(
1936
+ (mod) => {
1937
+ _moduleCache.set(src, mod);
1938
+ },
1939
+ () => {},
1940
+ );
1941
+ return loading;
1942
+ }
1943
+
1944
+ /**
1945
+ * The function a `$src` Function entry names, out of its module.
1946
+ *
1947
+ * @param {JxFunctionDef} def
1948
+ * @param {string} key - Def key name
1949
+ * @param {string} src - The specifier the module came from
1950
+ * @param {ImportedModule} mod
1951
+ */
1952
+ function exportedFunction(
1953
+ def: JxFunctionDef,
1954
+ key: string,
1955
+ src: string,
1956
+ mod: ImportedModule,
1957
+ ): ScopeFunction {
1958
+ const exportName = def.$export ?? key;
1959
+ const candidate = mod[exportName] ?? mod.default?.[exportName];
1960
+ if (!isFunction(candidate)) {
1961
+ throw new TypeError(`Jx: export "${exportName}" not found or not a function in "${src}"`);
1962
+ }
1963
+ /* SAFETY: the guard proved a function, and a function accepts any call: the runtime passes the
1964
+ scope first, as a Function entry's contract says, and binds the result as a scope member. */
1965
+ return candidate as ScopeFunction;
1966
+ }
1967
+
1968
+ /**
1969
+ * Import the function a `$src` Function entry names, for a caller that can wait for it: a `$lazy`
1970
+ * entry's first call. An eager entry does the same two steps inline, in `resolveFunction`.
1971
+ *
1972
+ * @param {JxFunctionDef} def
1973
+ * @param {string} key - Def key name
1974
+ * @param {string} src - The entry's `$src`
1975
+ * @param {string} [base] - Base URL for resolving relative specifiers
1976
+ */
1977
+ async function importFunctionExport(
1978
+ def: JxFunctionDef,
1979
+ key: string,
1980
+ src: string,
1981
+ base?: string,
1982
+ ): Promise<ScopeFunction> {
1983
+ return exportedFunction(def, key, src, await acquireModule(src, base));
1984
+ }
1985
+
1616
1986
  /**
1617
1987
  * Resolve a $prototype: "Function" entry into a function or computed.
1618
1988
  *
@@ -1654,30 +2024,40 @@ async function resolveFunction(
1654
2024
  configurable: true,
1655
2025
  value: def.name ?? key,
1656
2026
  });
2027
+ } else if (def.$lazy === true && def.$src) {
2028
+ const { $src: src } = def;
2029
+ /* Loaded on the first call, not here (spec.md §5.3 4d), as a compiled page does. The first call
2030
+ starts the import and every call waits on it, so the module loads once and a call returns a
2031
+ promise from then on. A computed reads its value now, which a promise cannot give, so the
2032
+ document is refused at build rather than rendering one. */
2033
+ if (role === "computed") {
2034
+ throw new Error(
2035
+ `Jx: "${key}" declares "$lazy": true but is used as a computed value, not called. ` +
2036
+ `A lazily loaded function resolves to a promise; a computed must resolve now. ` +
2037
+ `Remove "$lazy", or call ${key}() from a handler instead of binding its value.`,
2038
+ );
2039
+ }
2040
+ let loaded: Promise<ScopeFunction> | undefined;
2041
+ const load = (): Promise<ScopeFunction> =>
2042
+ (loaded ??= importFunctionExport(def, key, src, base));
2043
+ fn = (...args: JxScopeValue[]) => load().then((target) => target(...args));
2044
+ Object.defineProperty(fn, "name", {
2045
+ configurable: true,
2046
+ value: def.name ?? key,
2047
+ });
2048
+ return fn;
1657
2049
  } else {
1658
2050
  // $src: dynamic import (the body/$src dichotomy was validated above)
1659
- const src = def.$src;
1660
- if (!src) {
2051
+ if (!def.$src) {
1661
2052
  throw new Error(`Jx: '${key}' has neither body nor $src`);
1662
2053
  }
1663
- const exportName = def.$export ?? key;
1664
- let mod: ImportedModule;
1665
- const seeded = seededModule(src);
1666
- if (seeded instanceof Promise) {
1667
- mod = await seeded;
1668
- } else if (seeded) {
1669
- mod = seeded;
1670
- } else {
1671
- mod = await importSource(src, base);
1672
- _moduleCache.set(src, mod);
1673
- }
1674
- const candidate = mod[exportName] ?? mod.default?.[exportName];
1675
- if (!isFunction(candidate)) {
1676
- throw new TypeError(`Jx: export "${exportName}" not found or not a function in "${src}"`);
1677
- }
1678
- /* SAFETY: the guard proved a function, and a function accepts any call: the runtime passes the
1679
- scope first, as a Function entry's contract says, and binds the result as a scope member. */
1680
- fn = candidate as ScopeFunction;
2054
+ const acquired = acquireModule(def.$src, base);
2055
+ fn = exportedFunction(
2056
+ def,
2057
+ key,
2058
+ def.$src,
2059
+ acquired instanceof Promise ? await acquired : acquired,
2060
+ );
1681
2061
  }
1682
2062
 
1683
2063
  // The role comes from the document (spec.md §5.3 4b, 4d), never from the import's arity or source.
@@ -1709,6 +2089,7 @@ export const RESERVED_KEYS = new Set([
1709
2089
  "$prototype",
1710
2090
  "$src",
1711
2091
  "$export",
2092
+ "$script",
1712
2093
  "$media",
1713
2094
  "$map",
1714
2095
  "timing",
@@ -2001,6 +2382,8 @@ export function renderNode(
2001
2382
  if (isMappedArray(child)) {
2002
2383
  // Array pseudo-element among siblings: expand inline, no wrapper.
2003
2384
  renderMappedArrayInto(el, child, localState, childOpts);
2385
+ } else if (isString(child) && isSingleExpression(child)) {
2386
+ renderTemplatedChild(el, child, localState, childOpts);
2004
2387
  } else {
2005
2388
  el.append(renderNode(child, localState, childOpts));
2006
2389
  }
@@ -2019,7 +2402,7 @@ export function renderNode(
2019
2402
  /**
2020
2403
  * Draw a whole-`children` template, the way the site build's template pass expands one (spec.md
2021
2404
  * §8.4). This is what an item's or an entry's parsed body looks like to something that renders the
2022
- * source document rather than the build's output: Studio's Preview, chiefly.
2405
+ * source document rather than the build's output: Studio's editing canvas, chiefly.
2023
2406
  *
2024
2407
  * A result that is not an array draws nothing, because a plain string is not a `children` value.
2025
2408
  * The children are rendered in a detached scope of their own, replaced when the template's value
@@ -2037,7 +2420,6 @@ function renderComputedChildren(
2037
2420
  state: JxScope,
2038
2421
  options: JxRenderOptions,
2039
2422
  ): void {
2040
- const path = options._path ?? [];
2041
2423
  let live: EffectScope | null = null;
2042
2424
  const retire = (): void => {
2043
2425
  // Detached before it is stopped, for the reason `teardown` in `renderMappedArray` gives.
@@ -2053,26 +2435,138 @@ function renderComputedChildren(
2053
2435
  if (!Array.isArray(nodes)) {
2054
2436
  return;
2055
2437
  }
2056
- const scope = effectScope(true);
2057
- live = scope;
2058
- pauseTracking();
2059
- try {
2060
- scope.run(() => {
2061
- /* SAFETY: a children template that yields an array stands for the child list itself, the
2062
- same reading the site build's template pass gives it. */
2063
- for (const [i, child] of (nodes as (JxElement | string)[]).entries()) {
2064
- el.append(renderNode(child, state, { ...options, _path: [...path, i] }));
2065
- }
2066
- });
2067
- } finally {
2068
- resetTracking();
2069
- }
2438
+ live = effectScope(true);
2439
+ drawTemplateNodes(live, nodes, state, options, (node) => {
2440
+ el.append(node);
2441
+ });
2070
2442
  },
2071
2443
  retire,
2072
2444
  );
2073
2445
  onScopeDispose(() => live?.stop(), true);
2074
2446
  }
2075
2447
 
2448
+ /**
2449
+ * Render the members of a template's array value, each handed to `place` as it is made.
2450
+ *
2451
+ * The members render in `scope`, a detached scope the caller owns and stops when the value changes,
2452
+ * and with tracking paused for the reason {@link renderSwitch} gives: the bindings inside track for
2453
+ * themselves. A member is rendered as a child definition whatever it is, the reading the site
2454
+ * build's template pass gives it: an object is an element, anything else is text. A member that is
2455
+ * itself a template string is bound as text and never spliced a second time, which is also what the
2456
+ * build does (it steps over what it spliced in).
2457
+ *
2458
+ * Each member renders at `[…options._path, index]`: the template's own position, then the member's
2459
+ * place in the value. That is not a path into the document, which holds a string there; it is what
2460
+ * `onNodeCreated` reports for the member, and it cannot collide with a sibling's path.
2461
+ *
2462
+ * @param {EffectScope} scope - Owns every effect the members create
2463
+ * @param {readonly JxScopeValue[]} nodes - The template's value
2464
+ * @param {JxScope} state
2465
+ * @param {JxRenderOptions | undefined} options - `_path` is the template's own position
2466
+ * @param {(node: HTMLElement | Text) => void} place - Puts a rendered member in the DOM
2467
+ */
2468
+ function drawTemplateNodes(
2469
+ scope: EffectScope,
2470
+ nodes: readonly JxScopeValue[],
2471
+ state: JxScope,
2472
+ options: JxRenderOptions | undefined,
2473
+ place: (node: HTMLElement | Text) => void,
2474
+ ): void {
2475
+ const path = options?._path ?? [];
2476
+ /* Read HERE, tracked, before tracking is paused: the template's own read reaches the array and
2477
+ no further, so an array changed in place (a `push`, an index write, a `length` of zero) would
2478
+ otherwise leave what was drawn standing. The same string as text read every member when it
2479
+ joined them, and a list that stopped following its array would be a regression against that.
2480
+ SAFETY: a template that yields an array stands for the children themselves, the same reading
2481
+ the site build's template pass gives it. */
2482
+ const members = [...(nodes as readonly (JxElement | string)[])];
2483
+ pauseTracking();
2484
+ try {
2485
+ scope.run(() => {
2486
+ for (const [i, child] of members.entries()) {
2487
+ place(renderNode(child, state, options ? { ...options, _path: [...path, i] } : undefined));
2488
+ }
2489
+ });
2490
+ } finally {
2491
+ resetTracking();
2492
+ }
2493
+ }
2494
+
2495
+ /**
2496
+ * Render a string member of a `children` array that is one whole `${…}` (spec.md §8.4).
2497
+ *
2498
+ * Such a member is text unless its value is an array, and then it stands for the nodes the array
2499
+ * holds: the site build's template pass (`resolveDocTemplates`) splices them in where the string
2500
+ * stood, and this draws the same thing so a document reads alike prerendered and rendered live. The
2501
+ * build's rule is the whole rule: ANY array is spliced, every member of it (an object as an
2502
+ * element, anything else as text, mixed freely, an empty array as nothing), and a value that is not
2503
+ * an array leaves the string a text child. Only a single-expression template can yield an array at
2504
+ * all, so every other string child takes the plain text path in {@link renderNode} untouched.
2505
+ *
2506
+ * One Text node is always appended, exactly as before, and it is the stable anchor. While the value
2507
+ * is text it holds the text. While the value is an array it is empty and the spliced nodes stand
2508
+ * immediately before it, so a change replaces that range and nothing else: siblings on either side
2509
+ * are never touched, and a value that turns from an array into text (or back) needs no second
2510
+ * anchor. The spliced nodes own a detached scope that is stopped when the value changes.
2511
+ *
2512
+ * @param {Element} parent
2513
+ * @param {string} template - A single-expression template
2514
+ * @param {JxScope} state
2515
+ * @param {JxRenderOptions | undefined} options - `_path` is the string's own `children` position
2516
+ */
2517
+ function renderTemplatedChild(
2518
+ parent: Element,
2519
+ template: string,
2520
+ state: JxScope,
2521
+ options: JxRenderOptions | undefined,
2522
+ ): void {
2523
+ const anchor = document.createTextNode(template);
2524
+ parent.append(anchor);
2525
+ let live: EffectScope | null = null;
2526
+ let drawn: (HTMLElement | Text)[] = [];
2527
+ const retire = (): void => {
2528
+ // Detached before it is stopped, for the reason `teardown` in `renderMappedArray` gives.
2529
+ for (const node of drawn) {
2530
+ node.remove();
2531
+ }
2532
+ drawn = [];
2533
+ live?.stop();
2534
+ live = null;
2535
+ };
2536
+ bindTemplate(
2537
+ template,
2538
+ () => {
2539
+ const value = evaluateValueTemplate(template, state);
2540
+ retire();
2541
+ if (!Array.isArray(value)) {
2542
+ anchor.textContent = templateText(value);
2543
+ return;
2544
+ }
2545
+ anchor.textContent = "";
2546
+ live = effectScope(true);
2547
+ drawTemplateNodes(live, value, state, options, (node) => {
2548
+ drawn.push(node);
2549
+ anchor.before(node);
2550
+ });
2551
+ },
2552
+ () => {
2553
+ retire();
2554
+ anchor.textContent = "";
2555
+ },
2556
+ );
2557
+ onScopeDispose(() => live?.stop(), true);
2558
+ }
2559
+
2560
+ /**
2561
+ * A single expression's value as the text its template interpolates to: a template literal's own
2562
+ * conversion, so a Symbol still throws where `String()` would print it.
2563
+ */
2564
+ function templateText(value: JxScopeValue): string {
2565
+ /* SAFETY: not a claim that the value is a string. A template literal converts whatever it is
2566
+ handed, and the cast only names a type the literal accepts without a lint exemption. */
2567
+ return `${value as string}`;
2568
+ }
2569
+
2076
2570
  // ─── Template string utilities ────────────────────────────────────────────────
2077
2571
 
2078
2572
  /**
@@ -2946,12 +3440,19 @@ function applyStyleInto(
2946
3440
  const scope = effectScope();
2947
3441
  scope.run(() => {
2948
3442
  for (const { name, value } of sources) {
2949
- effect(() => {
2950
- const resolved = isRefValue(value)
2951
- ? resolveRef(value.$ref, state)
2952
- : evaluateTemplate(value, state);
3443
+ const write = (resolved: JxScopeValue): void => {
2953
3444
  el.style.setProperty(name, canvasStyleValue(String(resolved ?? "")));
2954
- });
3445
+ };
3446
+ if (isRefValue(value)) {
3447
+ effect(() => write(resolveRef(value.$ref, state)));
3448
+ } else {
3449
+ /* Survives its expression throwing, as every other template binding does (§6.1): the
3450
+ variable is left unwritten, so the declaration that reads it has no value and the
3451
+ cascade answers, and the binding re-runs when what it read changes. Unguarded, one
3452
+ `${state.theme.accent}` read before `theme` arrived, or one `${item.data.tint}` on a
3453
+ row with no `data`, threw out of the render and left the page blank. */
3454
+ bindTemplate(value, () => write(evaluateTemplate(value, state)));
3455
+ }
2955
3456
  }
2956
3457
  });
2957
3458
  entry.stop = () => scope.stop();
@@ -3095,7 +3596,13 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
3095
3596
  *
3096
3597
  * `options._path` is the array node's own document path (`[…, "children", i]`, or `[…, "children"]`
3097
3598
  * for a legacy whole-children repeater); item instances render at `[…that…, "map", index]`, and a
3098
- * row that moves reports its new path through `options.onNodeMoved`.
3599
+ * row that moves reports its new path through `options.onNodeMoved`: once, for the row's root. The
3600
+ * nodes inside the row are not reported, though each one's path begins with the row's and so
3601
+ * changed with it.
3602
+ *
3603
+ * Three options exist for an editor and do nothing unless a host sets them (`JxRenderOptions`):
3604
+ * `rowLimit` caps the rows drawn, `emptyRow` supplies one row for a list with none, and
3605
+ * `onListReconciled` reports what each reconciliation drew.
3099
3606
  *
3100
3607
  * @param {HTMLElement} parentEl
3101
3608
  * @param {import("@jxsuite/schema/types").JxMappedArray} arrayDef
@@ -3118,6 +3625,8 @@ function renderMappedArrayInto(
3118
3625
  re-run, and the instance it holds must connect under the chain of the definition that drew
3119
3626
  the list. */
3120
3627
  const instancePath = _instancePath;
3628
+ // And whether it is drawn inside a stamped host, which a later row must be drawn as too.
3629
+ const insideStamped = _canvasInsideStampedHost;
3121
3630
 
3122
3631
  let warned = false;
3123
3632
  const warnOnce = (message: string): void => {
@@ -3153,7 +3662,9 @@ function renderMappedArrayInto(
3153
3662
  const scope = effectScope(true);
3154
3663
  const childOpts = options ? { ...options, _path: [...path, "map", index] } : undefined;
3155
3664
  const node = scope.run(() => renderNode(mapDef!, child, childOpts))!;
3156
- return { key, map, node, scope };
3665
+ const row: MappedRow = { key, map, node, scope };
3666
+ listRows.set(node, row);
3667
+ return row;
3157
3668
  };
3158
3669
 
3159
3670
  const reconcile = (list: readonly JxScopeValue[], keys: readonly MappedRow["key"][]): void => {
@@ -3219,10 +3730,27 @@ function renderMappedArrayInto(
3219
3730
  items = [...unsorted].toSorted(compare);
3220
3731
  }
3221
3732
  }
3222
- const list: readonly JxScopeValue[] = Array.isArray(items) && mapDef ? items : [];
3733
+ let list: readonly JxScopeValue[] = Array.isArray(items) && mapDef ? items : [];
3734
+ const count = list.length;
3735
+
3736
+ /* The authoring hooks (`JxRenderOptions`), both absent unless a host names them. A list with
3737
+ nothing to draw may be given one row to stand for it, and a long one may be cut short. The
3738
+ placeholder is keyed by a value no item can be, so it is never taken for a real row: it is
3739
+ torn down when the list fills and rebuilt when it empties. */
3740
+ const placeholder = count === 0 && mapDef ? options?.emptyRow?.(arrayDef, path) : undefined;
3741
+ if (placeholder !== undefined) {
3742
+ list = [placeholder];
3743
+ }
3744
+ const limit = options?.rowLimit;
3745
+ if (isNumber(limit) && limit >= 0 && list.length > limit) {
3746
+ list = list.slice(0, Math.floor(limit));
3747
+ }
3223
3748
 
3224
3749
  // Keys are read HERE, tracked, so a key that changes reconciles the list.
3225
- const keys = list.map((item, index) => mappedRowKey(keyPointer, item, index, warnOnce));
3750
+ const keys =
3751
+ placeholder === undefined
3752
+ ? list.map((item, index) => mappedRowKey(keyPointer, item, index, warnOnce))
3753
+ : list.map(() => EMPTY_ROW_KEY);
3226
3754
  const seen = new Set<MappedRow["key"]>();
3227
3755
  for (let i = 0; i < keys.length; i++) {
3228
3756
  if (seen.has(keys[i])) {
@@ -3237,7 +3765,15 @@ function renderMappedArrayInto(
3237
3765
 
3238
3766
  pauseTracking();
3239
3767
  try {
3240
- underInstancePath(reexpand(instancePath), () => reconcile(list, keys));
3768
+ underInstancePath(reexpand(instancePath), () =>
3769
+ insideStampedHost(insideStamped, () => reconcile(list, keys)),
3770
+ );
3771
+ options?.onListReconciled?.(path, {
3772
+ count,
3773
+ empty: count === 0,
3774
+ nodes: rows.map((row) => row.node),
3775
+ rendered: rows.length,
3776
+ });
3241
3777
  } finally {
3242
3778
  resetTracking();
3243
3779
  }
@@ -3253,6 +3789,7 @@ function renderMappedArrayInto(
3253
3789
  let stopped = false;
3254
3790
  const runner = effect(update, {
3255
3791
  scheduler: () => {
3792
+ _listRedraws += 1;
3256
3793
  if (queued) {
3257
3794
  return;
3258
3795
  }
@@ -3275,13 +3812,58 @@ function renderMappedArrayInto(
3275
3812
  }, true);
3276
3813
  }
3277
3814
 
3815
+ /** The key of the one row `emptyRow` supplies: an object, so no item's key can equal it. */
3816
+ const EMPTY_ROW_KEY: MappedRow["key"] = Object.freeze({ emptyRow: true });
3817
+
3278
3818
  /** A rendered row of a mapped array. */
3279
3819
  interface MappedRow {
3280
3820
  key: JxScopeValue;
3281
3821
  /** The row's `$map` context; `item` and `index` are written in place when the row is reused. */
3282
3822
  map: JxRowContext;
3283
3823
  node: HTMLElement | Text;
3284
- scope: EffectScope;
3824
+ /** What stops the row's effects: its own scope, or the disposer a host handed over with a node. */
3825
+ scope: Pick<EffectScope, "stop">;
3826
+ }
3827
+
3828
+ /** The row each rendered row node stands for, so a host can hand its list another node for it. */
3829
+ const listRows = new WeakMap<Node, MappedRow>();
3830
+
3831
+ /**
3832
+ * Put `next` where the mapped-list row `current` stands, and make it that row's node from now on.
3833
+ * Answers whether `current` was a row's node; when it was not, nothing is touched.
3834
+ *
3835
+ * An authoring interface, beside {@link updateInstanceProps}: a host that edits a document draws a
3836
+ * changed template again for a row that is already on screen, with the row's own scope (the one
3837
+ * `onNodeCreated` reported for the old node). The list holds each row by its node, moves that node
3838
+ * when the row is reused at another index and removes it when the row goes, so a node swapped in
3839
+ * behind its back would be left standing when the list next redrew and the old one put back. Told
3840
+ * here, the list moves and removes the new node instead. The row keeps its key and its `$map`
3841
+ * record, so nothing is reconciled and no other row is touched.
3842
+ *
3843
+ * The old node's effects are stopped, after it has left the document for the reason `teardown` in
3844
+ * {@link renderMappedArrayInto} gives. `stop` is what ends the new node's effects when the row is
3845
+ * dropped or the list is torn down; the host created them, so only it can say how.
3846
+ *
3847
+ * @param current A node the list drew as a row's root.
3848
+ * @param next The node to stand there instead, rendered by the host.
3849
+ * @param [stop] Called once when the row is removed.
3850
+ */
3851
+ export function replaceListRow(
3852
+ current: Node,
3853
+ next: HTMLElement | Text,
3854
+ stop?: () => void,
3855
+ ): boolean {
3856
+ const row = listRows.get(current);
3857
+ if (!row || row.node !== current) {
3858
+ return false;
3859
+ }
3860
+ row.node.replaceWith(next);
3861
+ row.scope.stop();
3862
+ listRows.delete(current);
3863
+ row.node = next;
3864
+ row.scope = { stop: () => stop?.() };
3865
+ listRows.set(next, row);
3866
+ return true;
3285
3867
  }
3286
3868
 
3287
3869
  /**
@@ -3357,6 +3939,8 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
3357
3939
  around the insertion, so an instance the new case creates connects under the chain of the
3358
3940
  definition that drew the switch rather than under none. See {@link _instancePath}. */
3359
3941
  const instancePath = _instancePath;
3942
+ // And whether it is drawn inside a stamped host, which a later case must be drawn as too.
3943
+ const insideStamped = _canvasInsideStampedHost;
3360
3944
 
3361
3945
  if (options?.onNodeCreated) {
3362
3946
  options.onNodeCreated(container, path, def, state);
@@ -3420,13 +4004,16 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
3420
4004
  if (gen !== generation) {
3421
4005
  return;
3422
4006
  }
3423
- const childScope = await buildScope(doc, {}, href, options?._ctx);
4007
+ // An external case is a component boundary, so it inherits what a component does (§13.4).
4008
+ const childScope = await buildScope(withProjectFormulas(doc), {}, href, options?._ctx);
3424
4009
  if (gen !== generation) {
3425
4010
  return;
3426
4011
  }
3427
4012
  container.replaceChildren();
3428
4013
  underInstancePath(reexpand(instancePath), () =>
3429
- scope.run(() => container.append(renderNode(doc, childScope, childOpts))),
4014
+ insideStampedHost(insideStamped, () =>
4015
+ scope.run(() => container.append(renderNode(doc, childScope, childOpts))),
4016
+ ),
3430
4017
  );
3431
4018
  })
3432
4019
  .catch((error) =>
@@ -3441,7 +4028,9 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
3441
4028
  pauseTracking();
3442
4029
  try {
3443
4030
  underInstancePath(reexpand(instancePath), () =>
3444
- scope.run(() => container.append(renderNode(caseDef, state, childOpts))),
4031
+ insideStampedHost(insideStamped, () =>
4032
+ scope.run(() => container.append(renderNode(caseDef, state, childOpts))),
4033
+ ),
3445
4034
  );
3446
4035
  } finally {
3447
4036
  resetTracking();
@@ -3513,22 +4102,59 @@ export async function resolvePrototype(
3513
4102
  }
3514
4103
  });
3515
4104
 
4105
+ /** The fetch itself. With a signal it dies with this binding; without, it is a host's. */
4106
+ const request = (signal?: AbortSignal): Promise<JsonValue> =>
4107
+ fetch(url, {
4108
+ method: def.method ?? "GET",
4109
+ ...(signal && { signal }),
4110
+ ...(def.headers && { headers: def.headers }),
4111
+ ...(def.body && {
4112
+ body: isNonNullObject(def.body) ? JSON.stringify(def.body) : def.body,
4113
+ }),
4114
+ }).then((r): Promise<JsonValue> => (r.ok ? r.json() : Promise.reject(r.statusText)));
4115
+
3516
4116
  const doFetch = () => {
4117
+ const handler = _remoteLookupHandler;
4118
+ let lookup: Promise<JsonValue>;
4119
+ if (handler) {
4120
+ const answer = handler(
4121
+ {
4122
+ key,
4123
+ kind: "request",
4124
+ request: {
4125
+ method: def.method ?? "GET",
4126
+ url,
4127
+ ...(def.headers && { headers: def.headers }),
4128
+ ...(def.body !== undefined && { body: def.body }),
4129
+ },
4130
+ scope: state,
4131
+ },
4132
+ () => request(),
4133
+ );
4134
+ if (answer === null) {
4135
+ return;
4136
+ }
4137
+ if (!(answer instanceof Promise)) {
4138
+ s.value = answer.value;
4139
+ return;
4140
+ }
4141
+ lookup = answer;
4142
+ } else {
4143
+ lookup = request(controller.signal);
4144
+ }
3517
4145
  holdTemplateErrorsFor(
3518
- fetch(url, {
3519
- method: def.method ?? "GET",
3520
- signal: controller.signal,
3521
- ...(def.headers && { headers: def.headers }),
3522
- ...(def.body && {
3523
- body: isNonNullObject(def.body) ? JSON.stringify(def.body) : def.body,
3524
- }),
3525
- })
3526
- .then((r): Promise<JsonValue> => (r.ok ? r.json() : Promise.reject(r.statusText)))
4146
+ lookup
3527
4147
  .then((d) => {
3528
- s.value = d;
4148
+ // An answer a host kept may arrive for a binding that is gone: not written.
4149
+ if (!controller.signal.aborted) {
4150
+ s.value = d;
4151
+ }
3529
4152
  })
3530
4153
  .catch((error) => {
3531
- if (!(error instanceof Error && error.name === "AbortError")) {
4154
+ if (
4155
+ !controller.signal.aborted &&
4156
+ !(error instanceof Error && error.name === "AbortError")
4157
+ ) {
3532
4158
  s.value = { error: String(error) };
3533
4159
  }
3534
4160
  }),
@@ -4124,6 +4750,7 @@ function latestServerCall(
4124
4750
  s: Ref<JxScopeValue>,
4125
4751
  invoke: ServerCall,
4126
4752
  label: string,
4753
+ describe: (args: JxScope) => RemoteLookup,
4127
4754
  ): (args: JxScope) => void {
4128
4755
  let seq = 0;
4129
4756
  return (args: JxScope) => {
@@ -4131,17 +4758,29 @@ function latestServerCall(
4131
4758
  const n = seq;
4132
4759
  /* The executor runs before the constructor returns, so the call still starts synchronously,
4133
4760
  and a throw inside it rejects this promise instead of escaping to the caller. */
4134
- holdTemplateErrorsFor(
4761
+ const run = (): Promise<JsonValue> =>
4135
4762
  new Promise<JsonValue>((settle) => {
4136
4763
  settle(invoke(args));
4137
- })
4764
+ });
4765
+ const answer = _remoteLookupHandler ? _remoteLookupHandler(describe(args), run) : run();
4766
+ if (answer === null) {
4767
+ return;
4768
+ }
4769
+ if (!(answer instanceof Promise)) {
4770
+ s.value = answer.value;
4771
+ return;
4772
+ }
4773
+ holdTemplateErrorsFor(
4774
+ answer
4138
4775
  .then((result) => {
4139
4776
  if (n === seq) {
4140
4777
  s.value = result;
4141
4778
  }
4142
4779
  })
4143
4780
  .catch((error) => {
4144
- if (n === seq) {
4781
+ /* An abort is not a failure: it is how a host that stood for the lookup says nobody is
4782
+ waiting for it any more (a wait it ended), as it is for a request (embedding.md §5.2). */
4783
+ if (n === seq && !(error instanceof Error && error.name === "AbortError")) {
4145
4784
  console.error(label, error);
4146
4785
  }
4147
4786
  }),
@@ -4149,6 +4788,32 @@ function latestServerCall(
4149
4788
  };
4150
4789
  }
4151
4790
 
4791
+ /**
4792
+ * What one call of a `timing: "server"` entry asks for, as a host's {@link RemoteLookupHandler} is
4793
+ * told it.
4794
+ */
4795
+ function serverLookup(
4796
+ def: JxServerFnDef,
4797
+ state: JxScope,
4798
+ key: string,
4799
+ args: JxScope,
4800
+ base?: string,
4801
+ ): RemoteLookup {
4802
+ return {
4803
+ key,
4804
+ kind: "server",
4805
+ /* SAFETY: the arguments cross the proxy as JSON, so a server function's are JSON by contract;
4806
+ a value that is not is the call's own failure when it is serialized, as it always was. */
4807
+ request: {
4808
+ $export: def.$export,
4809
+ $src: def.$src,
4810
+ arguments: args,
4811
+ ...(base !== undefined && { $base: base }),
4812
+ } as JsonObject,
4813
+ scope: state,
4814
+ };
4815
+ }
4816
+
4152
4817
  /**
4153
4818
  * One call of a `timing: "server"` function, with its resolved arguments. Its answer is JSON, or a
4154
4819
  * promise of JSON: on the proxy path the answer crosses the wire as JSON, and a host-registered
@@ -4212,7 +4877,9 @@ async function resolveServerFunction(
4212
4877
  // Always wrap in ref for reactivity
4213
4878
  const s: Ref<JxScopeValue> = ref(null);
4214
4879
  if (hasReactiveArg) {
4215
- const call = latestServerCall(s, fn, "Jx server function:");
4880
+ const call = latestServerCall(s, fn, "Jx server function:", (args) =>
4881
+ serverLookup(def, state, key, args, base),
4882
+ );
4216
4883
  effect(() => {
4217
4884
  const args = resolveArgs();
4218
4885
  onEffectCleanup(() => {});
@@ -4276,7 +4943,9 @@ async function resolveServerFunctionViaProxy(
4276
4943
 
4277
4944
  // Always wrap in ref for reactivity
4278
4945
  const s: Ref<JxScopeValue> = ref(null);
4279
- const call = latestServerCall(s, doResolve, "Jx server proxy:");
4946
+ const call = latestServerCall(s, doResolve, "Jx server proxy:", (args) =>
4947
+ serverLookup(def, state, key, args, base),
4948
+ );
4280
4949
  if (hasReactiveArg) {
4281
4950
  effect(() => {
4282
4951
  const args = resolveArgs();
@@ -4362,15 +5031,27 @@ export function isSignal(v: unknown): v is Ref<unknown> {
4362
5031
  * type. The default is the value the entry had before anyone set the attribute, which is what the
4363
5032
  * removal is asking to go back to.
4364
5033
  *
4365
- * @param defaults The declared defaults by state key; a key absent from it has none.
5034
+ * A COMPUTED entry is never written, by a change or by a removal: its value is produced, and
5035
+ * attribute text replacing a `$prototype` or `$src` entry's function or record is not something a
5036
+ * removal could give back. `declaredDefaults` records such an entry as present with the value
5037
+ * `undefined`, which is what this reads.
5038
+ *
5039
+ * Exported for the compiler, which inlines the rule into generated modules and calls it for the
5040
+ * prerender; `observed-attr-agreement.test.ts` there proves the copy agrees.
5041
+ *
5042
+ * @param defaults The declared defaults by state key; a key absent from it has none, and a key
5043
+ * present with `undefined` is computed.
4366
5044
  */
4367
- function absorbAttribute(
5045
+ export function absorbAttribute(
4368
5046
  state: JxScope,
4369
5047
  name: string,
4370
5048
  value: string | null,
4371
5049
  defaults?: ReadonlyMap<string, JxScopeValue>,
4372
5050
  ): void {
4373
5051
  const camelKey = name.replaceAll(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
5052
+ if (defaults?.has(camelKey) && defaults.get(camelKey) === undefined) {
5053
+ return;
5054
+ }
4374
5055
  const current = state[camelKey];
4375
5056
  if (isBoolean(current)) {
4376
5057
  // Presence IS the value for a boolean, so a removal is already spelled by the same rule.
@@ -4386,25 +5067,44 @@ function absorbAttribute(
4386
5067
  }
4387
5068
 
4388
5069
  /**
4389
- * The declared default of every state entry that has one, by state key.
5070
+ * The declared default of every state entry, by state key, read the way `buildScope` reads the
5071
+ * entry's shape:
5072
+ *
5073
+ * - An object with `default` declares that value.
5074
+ * - A computed entry (a marker of {@link COMPUTED_STATE_KEYS}, or a `${…}` template string) is
5075
+ * recorded as PRESENT with the value `undefined`: its value is produced, so it has no default and
5076
+ * an observed attribute never writes it ({@link absorbAttribute}).
5077
+ * - A pure type definition (`{ "type": "number" }`), which `buildScope` leaves out of state, is
5078
+ * absent: it declares no default, and recording the schema object as one wrote that object into
5079
+ * state when its attribute was removed.
5080
+ * - Anything else is its own default: the shorthand `{ "label": "none" }`, a plain object, an array.
5081
+ *
5082
+ * Exported for the compiler, which serializes the observed keys' defaults into generated modules;
5083
+ * `observed-attr-agreement.test.ts` there proves the emitted map equals this function's output.
4390
5084
  *
4391
5085
  * @param def The element definition
4392
5086
  * @returns {Map<string, JxScopeValue>} Key → default
4393
5087
  */
4394
- function declaredDefaults(def: JxElement): Map<string, JxScopeValue> {
5088
+ export function declaredDefaults(def: JxElement): Map<string, JxScopeValue> {
4395
5089
  const out = new Map<string, JxScopeValue>();
4396
5090
  for (const [key, entry] of Object.entries(def.state ?? {})) {
4397
- if (isNonNullObject(entry)) {
5091
+ if (isString(entry) && isTemplateString(entry)) {
5092
+ out.set(key, undefined);
5093
+ continue;
5094
+ }
5095
+ if (isNonNullObject(entry) && !Array.isArray(entry)) {
4398
5096
  if ("default" in entry) {
4399
5097
  out.set(key, entry.default);
4400
5098
  continue;
4401
5099
  }
4402
- // A computed entry has no default: its value is produced, and a removal leaves it alone.
4403
5100
  if (COMPUTED_STATE_KEYS.some((marker) => marker in entry)) {
5101
+ out.set(key, undefined);
5102
+ continue;
5103
+ }
5104
+ if (hasSchemaKeywords(entry)) {
4404
5105
  continue;
4405
5106
  }
4406
5107
  }
4407
- // The shorthand `{ "label": "none" }` IS the default, the same way `buildScope` reads it.
4408
5108
  out.set(key, entry);
4409
5109
  }
4410
5110
  return out;
@@ -4444,11 +5144,14 @@ const _privatePropWarned = new Set<string>();
4444
5144
  * declares one. What the accessor DOES do is write the attribute, so the attribute is the evidence
4445
5145
  * that somebody set it, whether that was `$props` before connection or the author in the document.
4446
5146
  *
5147
+ * Exported for the compiler, which inlines the rule into generated modules; a compiler test proves
5148
+ * the copy agrees.
5149
+ *
4447
5150
  * @param {HTMLElement} el - The instance being connected.
4448
5151
  * @param {string} key - A state key declared by the component definition.
4449
5152
  * @returns {boolean}
4450
5153
  */
4451
- function instanceSupplies(el: HTMLElement, key: string): boolean {
5154
+ export function instanceSupplies(el: HTMLElement, key: string): boolean {
4452
5155
  if (Object.hasOwn(el, key)) {
4453
5156
  return true;
4454
5157
  }
@@ -4530,6 +5233,7 @@ export {
4530
5233
  camelToKebab,
4531
5234
  COLOR_SCHEME_ATTR,
4532
5235
  COLOR_SCHEME_STORAGE_KEY,
5236
+ commentedRuleText,
4533
5237
  cssPropertyName,
4534
5238
  cssRuleText,
4535
5239
  hashCss,
@@ -4620,6 +5324,50 @@ export async function redefineElement(doc: JxDocument, baseUrl?: string): Promis
4620
5324
  _elementDefs.set(tagName, { base, doc });
4621
5325
  }
4622
5326
 
5327
+ /** The project formulas every component scope in this realm inherits (spec.md §13.4). */
5328
+ let _projectFormulas: Record<string, JxStateDefinition> = {};
5329
+ /** A definition with the inherited formulas merged in, built once per definition. */
5330
+ let _withFormulas = new WeakMap<JxDocument, JxDocument>();
5331
+
5332
+ /**
5333
+ * Give this realm its project's `state`, from which a component inherits the pure named formulas
5334
+ * (spec.md §13.4, §19.4c).
5335
+ *
5336
+ * A page receives project state merged into its own document, but a component's document is its own
5337
+ * scope and is fetched as written, so the host that knows the project says so once: the site shell
5338
+ * before it renders the page, Studio before it draws a canvas. Only closed pure formulas are taken
5339
+ * (`inheritableFormulas`); data state never crosses. Realm-wide because the custom element registry
5340
+ * is: one tag has one definition per realm, whatever mounted it.
5341
+ *
5342
+ * @param {Record<string, JxStateDefinition> | null} [projectState] - `project.json` `state`; absent
5343
+ * or null clears it, and a component is then exactly its own document
5344
+ */
5345
+ export function setProjectFormulas(projectState?: Record<string, JxStateDefinition> | null): void {
5346
+ _projectFormulas = inheritableFormulas(projectState);
5347
+ _withFormulas = new WeakMap();
5348
+ }
5349
+
5350
+ /**
5351
+ * A component document as its scope is built: the inherited formulas first, then its own `state`,
5352
+ * so an entry the component declares shadows a project formula of that name. The document itself
5353
+ * when the project gives it nothing.
5354
+ *
5355
+ * @param {JxDocument} doc
5356
+ * @returns {JxDocument}
5357
+ */
5358
+ function withProjectFormulas(doc: JxDocument): JxDocument {
5359
+ const cached = _withFormulas.get(doc);
5360
+ if (cached) {
5361
+ return cached;
5362
+ }
5363
+ const own = doc.state ?? {};
5364
+ const added = Object.entries(_projectFormulas).filter(([key]) => !(key in own));
5365
+ const merged =
5366
+ added.length > 0 ? { ...doc, state: { ...Object.fromEntries(added), ...own } } : doc;
5367
+ _withFormulas.set(doc, merged);
5368
+ return merged;
5369
+ }
5370
+
4623
5371
  /**
4624
5372
  * Seed the module-level root `$media` map used as the fallback for components that declare their
4625
5373
  * own `@--name` style blocks but carry no own `$media` (buildScope ~279-280). `Jx()` sets this from
@@ -4990,7 +5738,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4990
5738
  _definedSources.add(base);
4991
5739
  source_ = await resolve(source);
4992
5740
  } else {
4993
- source_ = source;
5741
+ source_ = inlineDocument(source, new URL(base ?? location.href, location.href).href);
4994
5742
  }
4995
5743
  base ??= location.href;
4996
5744
 
@@ -5014,6 +5762,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
5014
5762
  const initialDef = source_;
5015
5763
  const initialBase = base;
5016
5764
  const observedAttrs = initialDef.observedAttributes ?? [];
5765
+ _observedAttributes.set(tagName, observedAttrs);
5017
5766
 
5018
5767
  const ElementClass = class extends HTMLElement {
5019
5768
  _jxInitialized = false;
@@ -5032,6 +5781,11 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
5032
5781
  return;
5033
5782
  }
5034
5783
  if (this._jxInitialized) {
5784
+ /* Put back in a document (moved to another parent, or removed and inserted). Everything
5785
+ below happens to an instance once, `onMount` included (§16.4): a sidecar wires up what it
5786
+ was handed at the first insertion and is not handed it twice, which is what lets a
5787
+ slotted element, moved by its parent's slot distribution straight after it rendered,
5788
+ mount once. `onUnmount` still runs on every removal. */
5035
5789
  return;
5036
5790
  }
5037
5791
  this._jxInitialized = true;
@@ -5049,7 +5803,9 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
5049
5803
 
5050
5804
  // The definition as it stands NOW — a redefinition since this class was made is honoured.
5051
5805
  const live = _elementDefs.get(tagName) ?? { base: initialBase, doc: initialDef };
5052
- const def = live.doc;
5806
+ _connectedDefs.set(this, live.doc);
5807
+ // With the project's formulas under its own state (§13.4): the scope, and what a prop may set.
5808
+ const def = withProjectFormulas(live.doc);
5053
5809
  const defBase = live.base;
5054
5810
 
5055
5811
  // The element is its own dispatch root: a body run without an event emits from the host.
@@ -5061,9 +5817,10 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
5061
5817
  for an attribute set before connection too — but on an instance with no state yet, so it
5062
5818
  had nothing to write into, and `<jx-icon name="plus">` rendered with the default. Before
5063
5819
  the `$props` merges below, so a property a parent set still wins (spec §16.2). */
5820
+ const connectDefaults = declaredDefaults(def);
5064
5821
  for (const attr of observedAttrs) {
5065
5822
  if (this.hasAttribute(attr)) {
5066
- absorbAttribute(state, attr, this.getAttribute(attr));
5823
+ absorbAttribute(state, attr, this.getAttribute(attr), connectDefaults);
5067
5824
  // The raw string, not the coerced value: coercion is a function of it, so two equal
5068
5825
  // Strings are one input.
5069
5826
  supplied[attr] = this.getAttribute(attr);
@@ -5157,10 +5914,20 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
5157
5914
  * accessor: the property-first interface IS the props mechanism, so defining one would leave
5158
5915
  * `el["#cache"] = x` writing straight through everything above.
5159
5916
  */
5917
+ const declared = live.doc.state ?? {};
5160
5918
  for (const key of Object.keys(def.state ?? {})) {
5161
5919
  if (isPrivateStateKey(key)) {
5162
5920
  continue;
5163
5921
  }
5922
+ /* An inherited project formula (§13.4) is a function in the scope, not part of the
5923
+ element's interface: the component's author never chose its name. An accessor for one
5924
+ would put a project formula called `append`, `remove` or `title` over the host's own
5925
+ member of that name, on every component in the realm, and the render below then calls
5926
+ the formula instead of the DOM method. Only an instance that was GIVEN the name as a prop
5927
+ gets the accessor, so a live `$props` binding of that name still reaches state. */
5928
+ if (!(key in declared) && !(key in supplied)) {
5929
+ continue;
5930
+ }
5164
5931
  /* A name the platform already owns (`title`, `lang`, `hidden`) keeps its native setter, so
5165
5932
  the attribute still reflects — and ALSO reaches state. Skipping it entirely meant a prop
5166
5933
  delivered after connection (a template over data that resolves asynchronously) wrote
@@ -5220,7 +5987,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
5220
5987
  definition draws inside it is too — and the de-link rules cannot see that from the nodes
5221
5988
  themselves. Restored rather than cleared, because one definition may render another. */
5222
5989
  const wasInside = _canvasInsideStampedHost;
5223
- _canvasInsideStampedHost ||= this.dataset.jxPath !== undefined;
5990
+ _canvasInsideStampedHost ||= carriesCanvasStamp(this);
5224
5991
  /* And the chain, for the same span: an instance this definition draws connects inside
5225
5992
  `this.append` below and reads it there ({@link underInstancePath}). */
5226
5993
  let failure: Error | null = null;
@@ -5271,7 +6038,11 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
5271
6038
  the ones a prop can write: a computed entry is what threw, and reading it here would
5272
6039
  throw again. Writes are coalesced into one microtask, because a parent sets its props one
5273
6040
  at a time. The same error is reported once, not once per attempt. */
5274
- const inputs = [...declaredDefaults(def).keys()].filter((key) => !isPrivateStateKey(key));
6041
+ const inputs = Object.keys(def.state ?? {}).filter(
6042
+ (key) =>
6043
+ !isPrivateStateKey(key) &&
6044
+ !(connectDefaults.has(key) && connectDefaults.get(key) === undefined),
6045
+ );
5275
6046
  let lastMessage = failure.message;
5276
6047
  let queued = false;
5277
6048
  const watch = effect(
@@ -5348,6 +6119,396 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
5348
6119
  customElements.define(tagName, ElementClass);
5349
6120
  }
5350
6121
 
6122
+ /**
6123
+ * Write one `$props` value onto an instance, before or after it connected.
6124
+ *
6125
+ * The same resolution `bindProperty` gives an ordinary element. A `$props: { src }` on a custom
6126
+ * element is an asset reference authored at the USAGE site, so it belongs to the document that
6127
+ * wrote it; a media prop the component forwards into its own `<img>` is resolved separately, inside
6128
+ * the component's render, against the same context.
6129
+ *
6130
+ * @param {HTMLElement} el - The instance.
6131
+ * @param {string} key - The prop's name.
6132
+ * @param {JxScopeValue} resolved - Its value, already resolved from any binding.
6133
+ */
6134
+ function writeInstanceProp(el: HTMLElement, key: string, resolved: JxScopeValue): void {
6135
+ const value = isString(resolved) ? canvasAssetValue(el.tagName, key, resolved) : resolved;
6136
+ elementProperties(el)[key] = value;
6137
+ /* A prop that shares its name with an `HTMLElement` property (`title`, `lang`) is forwarded
6138
+ like any other once the instance has connected (§16.2): its accessor calls the platform's
6139
+ setter, then writes state, so the write above already reaches state there. The write below
6140
+ covers the instance that holds the name in its state with NO accessor for it, which is a
6141
+ name its live definition does not declare and it was not given at connection (an inherited
6142
+ project formula, §13.4). There the write above lands on the platform's property alone, and
6143
+ the instance would go on drawing the value it connected with. The binding is the parent's
6144
+ `$props`, addressed to the instance's state, so it writes that state. */
6145
+ const instanceState = instanceStateOf(el);
6146
+ if (instanceState && key in HTMLElement.prototype && Object.hasOwn(toRaw(instanceState), key)) {
6147
+ instanceState[key] = value;
6148
+ }
6149
+ }
6150
+
6151
+ /**
6152
+ * The attributes each tag observes, as first defined: the list the platform froze, which a
6153
+ * redefinition does not change ({@link redefineElement}).
6154
+ */
6155
+ const _observedAttributes = new Map<string, readonly string[]>();
6156
+
6157
+ /**
6158
+ * The definition each instance connected with. A redefinition leaves a connected instance drawing
6159
+ * the old one (embedding.md §7), and nothing read from the new one describes it.
6160
+ */
6161
+ const _connectedDefs = new WeakMap<HTMLElement, JxDocument>();
6162
+
6163
+ /** Whether an instance is still drawn by the definition its tag has now. */
6164
+ function drawnByCurrent(el: HTMLElement): boolean {
6165
+ const drawn = _connectedDefs.get(el);
6166
+ return drawn !== undefined && drawn === _elementDefs.get(el.localName)?.doc;
6167
+ }
6168
+
6169
+ /** The markers of a state entry whose value is produced asynchronously or kept outside the scope. */
6170
+ function producedElsewhere(entry: JxDocumentValue | undefined): boolean {
6171
+ if (!isJsonObject(entry)) {
6172
+ return false;
6173
+ }
6174
+ const { $prototype: prototype } = entry;
6175
+ if (prototype !== undefined) {
6176
+ return prototype !== "Function";
6177
+ }
6178
+ return "$src" in entry || "timing" in entry;
6179
+ }
6180
+
6181
+ /** Whether a `$props` value is a scalar written as it stands: no binding, no object, no `null`. */
6182
+ function isScalarProp(value: JxScopeValue | undefined): value is string | number | boolean {
6183
+ return (isString(value) && !isTemplateString(value)) || isNumber(value) || isBoolean(value);
6184
+ }
6185
+
6186
+ /**
6187
+ * The platform's own properties that reflect a string to an attribute and read it back unchanged,
6188
+ * so a declared prop of that name holds after a write exactly what connection would have read.
6189
+ */
6190
+ const VERBATIM_PROPS: ReadonlySet<string> = new Set(["id", "lang", "role", "slot", "title"]);
6191
+
6192
+ /** Whether a document value is drawn through a binding (a `$ref` or a `${…}` template). */
6193
+ function isBoundProp(value: JxDocumentValue | undefined): boolean {
6194
+ return isRefValue(value) || isTemplateString(value);
6195
+ }
6196
+
6197
+ /**
6198
+ * Whether a definition, or one it draws, reads something ONCE when it renders, so that a later
6199
+ * write to its state would not reach what it drew, or would undo it:
6200
+ *
6201
+ * - A tag expression. The tag is chosen at creation and is not tracked (§19.6).
6202
+ * - A `$`-local bound to a `$ref`, which is a copy of the value taken at render, on a node or in the
6203
+ * `$props` of an element that is not a component; and an `id` bound to a `$ref`, written once.
6204
+ * - A `<slot>` a write can redraw (under a `$switch` case or a `$map` template, or with a `$switch`
6205
+ * of its own) or re-address (a bound `name`), or whose fallback is bound text. Slots are
6206
+ * distributed once, at connection (§8.5): the redrawn one is empty, and a replaced one is gone.
6207
+ * - A component it draws being handed a value by a route that component reads only when it connects
6208
+ * ({@link handsOverOnce}): bound `textContent`, slot content that is bound text, a mapped array
6209
+ * or a child with a bound `slot`, a bound `props.*` attribute, a bound attribute for a prop it
6210
+ * declares and does not observe (§16.5), or a bound prop or property the platform owns.
6211
+ * - An `onMount` in its state (§16.4), which runs once with the values it had then.
6212
+ * - A definition that can draw itself, whose instances are admitted against the props each connected
6213
+ * with (§16.9).
6214
+ * - Children or `innerHTML` drawn from a binding: what they draw is not in the document, so nothing
6215
+ * can be said about it.
6216
+ * - A state entry produced asynchronously or kept outside the scope (a `$prototype` other than
6217
+ * `Function`, a `$src`, a `timing`): a request, a class or a store answers for the value it was
6218
+ * built with, and a stored value outlives the write.
6219
+ *
6220
+ * The answer belongs to the definition, not to the instance: it is asked of the document, so a
6221
+ * `$switch` case that is not drawn yet counts. It is not remembered, because it depends on every
6222
+ * definition this one draws and any of them can be redefined (embedding.md §7).
6223
+ *
6224
+ * @param {JxDocument} def
6225
+ * @param {Set<JxDocument>} [drawing] - The definitions this walk is inside.
6226
+ * @returns {boolean}
6227
+ */
6228
+ function rendersOnce(def: JxDocument, drawing = new Set<JxDocument>()): boolean {
6229
+ const declared = def.state ?? {};
6230
+ if ("onMount" in declared || Object.values(declared).some((entry) => producedElsewhere(entry))) {
6231
+ return true;
6232
+ }
6233
+ drawing.add(def);
6234
+ const walk = (value: JxDocumentValue | undefined, isRoot: boolean, redrawn: boolean): boolean => {
6235
+ if (Array.isArray(value)) {
6236
+ return value.some((item) => walk(item, false, redrawn));
6237
+ }
6238
+ if (!isNonNullObject(value)) {
6239
+ return false;
6240
+ }
6241
+ /* SAFETY: a definition is a JSON document, so every member of every object in it is a document
6242
+ value; `Object.entries` on the union of node shapes says `any` only because it cannot name
6243
+ one type for them all. */
6244
+ const node = Object.fromEntries(Object.entries(value)) as Record<string, JxDocumentValue>;
6245
+ const { attributes, tagName } = node;
6246
+ if (tagName !== undefined && !isString(tagName)) {
6247
+ return true;
6248
+ }
6249
+ const attrs: Record<string, JxDocumentValue> = isJsonObject(attributes) ? attributes : {};
6250
+ if (
6251
+ tagName === "slot" &&
6252
+ (redrawn ||
6253
+ isBoundProp(attrs.name) ||
6254
+ isBoundProp(node.name) ||
6255
+ isBoundProp(node.textContent) ||
6256
+ node.$switch !== undefined)
6257
+ ) {
6258
+ return true;
6259
+ }
6260
+ if (isBoundProp(node.innerHTML) || (!isRoot && isBoundProp(node.children))) {
6261
+ return true;
6262
+ }
6263
+ const isInstance = !isRoot && isString(tagName) && tagName.includes("-");
6264
+ const inner = isInstance ? _elementDefs.get(tagName)?.doc : undefined;
6265
+ if (isInstance && handsOverOnce(node, attrs, inner)) {
6266
+ return true;
6267
+ }
6268
+ if (inner && (drawing.has(inner) || rendersOnce(inner, drawing))) {
6269
+ return true;
6270
+ }
6271
+ for (const [key, entry] of Object.entries(node)) {
6272
+ // State is the scope a write reaches, not something drawn; the root's `$props` are defaults.
6273
+ if (isRoot && (key === "state" || key === "$props")) {
6274
+ continue;
6275
+ }
6276
+ if (key === "$props") {
6277
+ if (!isInstance && isJsonObject(entry) && Object.values(entry).some((v) => isRefValue(v))) {
6278
+ return true;
6279
+ }
6280
+ continue;
6281
+ }
6282
+ if ((key === "id" || (key.startsWith("$") && !RESERVED_KEYS.has(key))) && isRefValue(entry)) {
6283
+ return true;
6284
+ }
6285
+ // `cases` is a map of nodes by name, not a node: a case may be called `state`.
6286
+ const drawn = key === "cases" && isJsonObject(entry) ? Object.values(entry) : entry;
6287
+ if (walk(drawn, false, redrawn || key === "cases" || key === "map")) {
6288
+ return true;
6289
+ }
6290
+ }
6291
+ return false;
6292
+ };
6293
+ const answer = walk(def, true, false);
6294
+ drawing.delete(def);
6295
+ return answer;
6296
+ }
6297
+
6298
+ /**
6299
+ * Whether a component a definition draws is handed a bound value by a route it reads only when it
6300
+ * connects ({@link rendersOnce}).
6301
+ *
6302
+ * @param node - The node that draws the component.
6303
+ * @param attrs - That node's `attributes`.
6304
+ * @param inner - The component's definition, when it is registered.
6305
+ * @returns {boolean}
6306
+ */
6307
+ function handsOverOnce(
6308
+ node: Record<string, JxDocumentValue>,
6309
+ attrs: Record<string, JxDocumentValue>,
6310
+ inner: JxDocument | undefined,
6311
+ ): boolean {
6312
+ if (isBoundProp(node.textContent)) {
6313
+ return true;
6314
+ }
6315
+ /* Its slot content is grouped, and tested for being empty, once (§8.5): text that fills in later,
6316
+ a row that arrives later and a child that changes group are not distributed again. */
6317
+ const kids = Array.isArray(node.children) ? node.children : [];
6318
+ if (
6319
+ kids.some(
6320
+ (kid) =>
6321
+ isBoundProp(kid) ||
6322
+ isMappedArray(kid) ||
6323
+ (isJsonObject(kid) &&
6324
+ (isBoundProp(kid.slot) ||
6325
+ (isJsonObject(kid.attributes) && isBoundProp(kid.attributes.slot)))),
6326
+ )
6327
+ ) {
6328
+ return true;
6329
+ }
6330
+ /* A platform-named prop is read back through the platform's accessor at connection, which
6331
+ coerces it; forwarded later, as a prop or as a property of the node, it reaches the
6332
+ component's state as given. */
6333
+ const coerced = (given: Record<string, JxDocumentValue>): boolean =>
6334
+ Object.entries(given).some(
6335
+ ([key, value]) => isBoundProp(value) && key in HTMLElement.prototype && key !== "textContent",
6336
+ );
6337
+ if (coerced(node) || (isJsonObject(node.$props) && coerced(node.$props))) {
6338
+ return true;
6339
+ }
6340
+ const observed = isString(node.tagName) ? (_observedAttributes.get(node.tagName) ?? []) : [];
6341
+ return Object.entries(attrs).some(([name, given]) => {
6342
+ if (!isBoundProp(given)) {
6343
+ return false;
6344
+ }
6345
+ if (name.startsWith("props.")) {
6346
+ return true;
6347
+ }
6348
+ const prop = name.replaceAll(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
6349
+ // An unregistered component is one whose declarations cannot be read: assume it reads this.
6350
+ return !inner || (prop in (inner.state ?? {}) && !observed.includes(name));
6351
+ });
6352
+ }
6353
+
6354
+ /**
6355
+ * Whether anything the instance's own definition drew has an `onMount` that already ran: the
6356
+ * instance itself, or an instance inside it that no document node stands between.
6357
+ *
6358
+ * `onMount` runs once (§16.4), and what it read then it does not read again. An instance the
6359
+ * DOCUMENT placed inside this one (slot content, stamped `data-jx-path` by the canvas) takes
6360
+ * nothing from this instance's props, so it does not count.
6361
+ *
6362
+ * @param {HTMLElement} host
6363
+ * @param {JxScope} state
6364
+ * @returns {boolean}
6365
+ */
6366
+ function mountedFromProps(host: HTMLElement, state: JxScope): boolean {
6367
+ if (isScopeHook(state.onMount) || !drawnByCurrent(host)) {
6368
+ return true;
6369
+ }
6370
+ for (const inner of host.querySelectorAll<HTMLElement>("*")) {
6371
+ const innerState = instanceStateOf(inner);
6372
+ if (!innerState || (!isScopeHook(innerState.onMount) && drawnByCurrent(inner))) {
6373
+ continue;
6374
+ }
6375
+ let owner: HTMLElement | null = inner;
6376
+ while (owner && owner !== host && owner.dataset.jxPath === undefined) {
6377
+ owner = owner.parentElement;
6378
+ }
6379
+ if (owner === host) {
6380
+ return true;
6381
+ }
6382
+ }
6383
+ return false;
6384
+ }
6385
+
6386
+ /**
6387
+ * Write a changed `$props` block into an instance that is already connected, in place, and answer
6388
+ * whether that was done. `false` means the caller draws the instance again; nothing was written
6389
+ * unless the refusal is the last case below.
6390
+ *
6391
+ * This is the property-first interface (§16.2) used by an editor: the Studio canvas calls it when
6392
+ * an author edits a prop, so the instance's own bindings redraw what reads that prop and nothing
6393
+ * else in it is rebuilt. It refuses whenever writing the properties could leave the instance
6394
+ * showing something a fresh render of it would not:
6395
+ *
6396
+ * - The element is not a connected Jx instance, or its definition is gone.
6397
+ * - Nothing changed. A block equal to the last one is a request to draw again, not a write.
6398
+ * - A changed prop is not a scalar on both sides: a string, a number or a boolean, as it stands. A
6399
+ * `$ref` or a template is an effect the render that created the instance owns, and it would go on
6400
+ * writing the old source. An object, an array or `null` is refused because too much reads one
6401
+ * once: a list redraws its rows in a later microtask, where a throw can no longer be answered
6402
+ * for; a binding that reads a member survives `null` by keeping its last drawing; and the
6403
+ * canvas's inline-edit marker is stamped by the type the value had at render.
6404
+ * - A changed prop overrides a state entry that is produced elsewhere (a store, a request).
6405
+ * - A changed prop is named for a property the platform owns (`hidden`, `tabIndex`, `textContent`):
6406
+ * connection reads such a value back through the platform's accessor, which coerces it, reflects
6407
+ * it to an attribute, or for `textContent` makes it slot content, and a later write does none of
6408
+ * that the same way. The exception is a declared prop the platform reflects verbatim
6409
+ * ({@link VERBATIM_PROPS}) being given a string, which reads back as written.
6410
+ * - A removed prop has no scalar declared default to go back to, or its attribute is observed and on
6411
+ * the host, where a fresh instance would read it instead of the default.
6412
+ * - The definition, or one it draws, declares an `onMount`, or an instance already drawn has one: it
6413
+ * ran once with the old values.
6414
+ * - The instance, or one its definition drew, connected with a definition since replaced
6415
+ * (embedding.md §7), and still draws the old one.
6416
+ * - The definition, or one it draws, reads something once at render ({@link rendersOnce}): a tag
6417
+ * expression, a copied `$ref`, a slot a write would redraw, a nested component handed a value it
6418
+ * reads only at connection, or a definition that can draw itself.
6419
+ * - A write throws, a template binding throws on the new value and is survived (§6.1), or a write
6420
+ * reaches a mapped list. The first is a render that throws, which the fresh render reports
6421
+ * (§16.4); the second keeps what it last drew, which a fresh render never drew; the third redraws
6422
+ * in a later microtask, where nothing can answer for what it draws. All three are known only
6423
+ * after the fact: the props are written, and the caller's redraw discards them with the
6424
+ * instance.
6425
+ *
6426
+ * What it does not put back is state the instance changed itself (a counter its own handler moved):
6427
+ * the write changes the props that changed and leaves the rest of the scope as it stands.
6428
+ *
6429
+ * @param {HTMLElement} el - The instance host.
6430
+ * @param {JxElement["$props"]} before - The `$props` block it was drawn with.
6431
+ * @param {JxElement["$props"]} after - The block it should show now.
6432
+ * @returns {boolean}
6433
+ */
6434
+ export function updateInstanceProps(
6435
+ el: HTMLElement,
6436
+ before: JxElement["$props"],
6437
+ after: JxElement["$props"],
6438
+ ): boolean {
6439
+ const state = instanceStateOf(el);
6440
+ const registered = _elementDefs.get(el.localName)?.doc;
6441
+ if (!state || !registered) {
6442
+ return false;
6443
+ }
6444
+ // As the instance's scope was built: with the project's formulas under its own state (§13.4).
6445
+ const def = withProjectFormulas(registered);
6446
+ const defaults = declaredDefaults(def);
6447
+ const declared = def.state ?? {};
6448
+ const observed = _observedAttributes.get(el.localName) ?? [];
6449
+ const writes: (() => void)[] = [];
6450
+ for (const key of new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])) {
6451
+ const was = before?.[key];
6452
+ const now = after?.[key];
6453
+ // A private key was refused when the instance was drawn and is refused now: nothing to write.
6454
+ if (isPrivateStateKey(key) || JSON.stringify(was) === JSON.stringify(now)) {
6455
+ continue;
6456
+ }
6457
+ if (
6458
+ (was !== undefined && !isScalarProp(was)) ||
6459
+ (now !== undefined && !isScalarProp(now)) ||
6460
+ producedElsewhere(declared[key]) ||
6461
+ // What the instance holds now decided things at render too (a marker, a handler).
6462
+ (key in declared && !isScalarProp(toRaw(state)[key])) ||
6463
+ // The element class's own fields, which a property write would overwrite.
6464
+ key === "_state" ||
6465
+ key === "_jxInitialized"
6466
+ ) {
6467
+ return false;
6468
+ }
6469
+ if (
6470
+ key in HTMLElement.prototype &&
6471
+ !(VERBATIM_PROPS.has(key) && key in declared && isString(now))
6472
+ ) {
6473
+ return false;
6474
+ }
6475
+ if (now !== undefined) {
6476
+ writes.push(() => {
6477
+ writeInstanceProp(el, key, now);
6478
+ });
6479
+ continue;
6480
+ }
6481
+ if (!(key in declared)) {
6482
+ // Nothing reads an undeclared prop: a fresh instance simply would not carry it.
6483
+ writes.push(() => {
6484
+ Reflect.deleteProperty(el, key);
6485
+ });
6486
+ continue;
6487
+ }
6488
+ const fallback = defaults.get(key);
6489
+ const attribute = key.replaceAll(/[A-Z]/gu, (c) => `-${c.toLowerCase()}`);
6490
+ if (!isScalarProp(fallback) || (observed.includes(attribute) && el.hasAttribute(attribute))) {
6491
+ return false;
6492
+ }
6493
+ writes.push(() => {
6494
+ state[key] = fallback;
6495
+ });
6496
+ }
6497
+ if (writes.length === 0 || mountedFromProps(el, state) || rendersOnce(def)) {
6498
+ return false;
6499
+ }
6500
+ const thrown = _templateThrows;
6501
+ const redraws = _listRedraws;
6502
+ try {
6503
+ for (const write of writes) {
6504
+ write();
6505
+ }
6506
+ } catch {
6507
+ return false;
6508
+ }
6509
+ return _templateThrows === thrown && _listRedraws === redraws;
6510
+ }
6511
+
5351
6512
  /**
5352
6513
  * Render a registered custom element with $props (property-first interface).
5353
6514
  *
@@ -5374,27 +6535,8 @@ function renderCustomElementWithProps(
5374
6535
  if (refusePrivateProp(key, "$props")) {
5375
6536
  continue;
5376
6537
  }
5377
- /* The same resolution `bindProperty` gives an ordinary element. A `$props: { src }` on a custom
5378
- element is an asset reference authored at the USAGE site, so it belongs to the document that
5379
- wrote it — a media prop the component forwards into its own `<img>` is resolved separately,
5380
- inside the component's render, against the same context. */
5381
6538
  const write = (resolved: JxScopeValue): void => {
5382
- const value = isString(resolved) ? canvasAssetValue(el.tagName, key, resolved) : resolved;
5383
- elementProperties(el)[key] = value;
5384
- /* A declared prop that shares its name with an `HTMLElement` property (`title`, `lang`) gets
5385
- no forwarding accessor (§16.2), so the write above lands on the element and never reaches
5386
- the instance's state. Connection reads it once; a binding that changes AFTER connection
5387
- (a row whose data arrives later) was lost, and the instance went on drawing the value it
5388
- connected with. The binding is the parent's `$props`, addressed to the definition's prop,
5389
- so it writes that prop's state. */
5390
- const instanceState = instanceStateOf(el);
5391
- if (
5392
- instanceState &&
5393
- key in HTMLElement.prototype &&
5394
- Object.hasOwn(toRaw(instanceState), key)
5395
- ) {
5396
- instanceState[key] = value;
5397
- }
6539
+ writeInstanceProp(el, key, resolved);
5398
6540
  };
5399
6541
  if (isRefValue(val)) {
5400
6542
  const refVal = val;
@@ -5436,6 +6578,8 @@ function renderCustomElementWithProps(
5436
6578
  const childOpts = options && path ? { ...options, _path: [...path, "children", i] } : undefined;
5437
6579
  if (isMappedArray(child)) {
5438
6580
  renderMappedArrayInto(el, child, state, childOpts);
6581
+ } else if (isString(child) && isSingleExpression(child)) {
6582
+ renderTemplatedChild(el, child, state, childOpts);
5439
6583
  } else {
5440
6584
  el.append(renderNode(child, state, childOpts));
5441
6585
  }