@jxsuite/runtime 6.2.2 → 7.0.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,
@@ -52,7 +57,6 @@ import type {
52
57
  JxScopeValue,
53
58
  } from "./types.ts";
54
59
  import {
55
- bodyReturnsValue,
56
60
  hasStructuredBody,
57
61
  isBoolean,
58
62
  isExpressionDef,
@@ -71,14 +75,31 @@ import {
71
75
  isString,
72
76
  isTagExpression,
73
77
  isTemplateString,
74
- paramNames,
78
+ templateExpressions,
75
79
  } from "@jxsuite/schema/guards";
76
80
  import type { TagNameSlot } from "@jxsuite/schema/guards";
81
+ import {
82
+ classifyFunctionEntries,
83
+ declaresBodyAndSrc,
84
+ handlerParamNames,
85
+ } from "@jxsuite/schema/function-role";
86
+ import type { FunctionRole } from "@jxsuite/schema/function-role";
77
87
  import { formatSrcset, parseSrcset } from "@jxsuite/schema/asset-paths";
78
88
  import { runStatements } from "./statements.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";
98
+
79
99
  import { readCookie, serializeCookie } from "./cookie.ts";
80
100
  import type {
81
101
  CemParameter,
102
+ JsonObject,
82
103
  JsonValue,
83
104
  JxAttributeValue,
84
105
  JxClassCtorDef,
@@ -92,10 +113,26 @@ import type {
92
113
  JxPrototypeDef,
93
114
  JxRef,
94
115
  JxServerFnDef,
116
+ JxStateDefinition,
95
117
  JxStyle,
96
118
  } from "@jxsuite/schema/types";
97
119
  import type { EffectScope, Ref } from "@vue/reactivity";
98
120
 
121
+ export {
122
+ bodyNeedsEraser,
123
+ prepareBodies,
124
+ setBodyEraser,
125
+ setBodyEraserLoader,
126
+ setModuleLoader,
127
+ } from "./body-eraser.ts";
128
+ export type {
129
+ BodyEraser,
130
+ BodyEraserLoader,
131
+ BodyEraserModule,
132
+ ModuleLoader,
133
+ ModuleLoadOptions,
134
+ } from "./body-eraser.ts";
135
+
99
136
  // ─── Public API ───────────────────────────────────────────────────────────────
100
137
 
101
138
  /**
@@ -222,7 +259,7 @@ function assertHostScope(scope: JxScope): void {
222
259
  * root it can remove and a `dispose()` that stops every effect the render created. Nothing here
223
260
  * writes a module global, so a shell can hold many roots with different needs at once.
224
261
  *
225
- * @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
226
263
  * URL)
227
264
  * @param {ParentNode} target - Where the rendered root is appended
228
265
  * @param {JxMountOptions} [options]
@@ -230,13 +267,14 @@ function assertHostScope(scope: JxScope): void {
230
267
  * @docs extending/embedding/runtime-host
231
268
  */
232
269
  export async function mount<S extends JxHostScope<S> = JxScope>(
233
- doc: JxDocument,
270
+ source: JxDocument,
234
271
  target: ParentNode,
235
272
  options: JxMountOptions<S> = {},
236
273
  ): Promise<JxMount> {
237
274
  const hostScope: JxScope = options.scope ? hostScopeOf(options.scope) : {};
238
275
  assertHostScope(hostScope);
239
276
  const base = options.base ? new URL(options.base, location.href).href : location.href;
277
+ const doc = inlineDocument(source, base);
240
278
  const ctx: JxContext = {
241
279
  base,
242
280
  media: options.media ?? doc.$media ?? null,
@@ -278,6 +316,16 @@ export async function mount<S extends JxHostScope<S> = JxScope>(
278
316
  if (options.onNodeMoved) {
279
317
  renderOptions.onNodeMoved = options.onNodeMoved;
280
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
+ }
281
329
  const { result: root, stop } = runScoped(() => renderNode(doc, scope, renderOptions));
282
330
  ctx.root = root;
283
331
 
@@ -357,17 +405,18 @@ function responseContentType(res: Partial<Pick<Response, "headers">>): string {
357
405
 
358
406
  export async function resolve(source: string | JxDocument, ctx?: JxContext): Promise<JxDocument> {
359
407
  if (!isString(source)) {
408
+ await prepareBodies(source);
360
409
  return source;
361
410
  }
362
411
  // A mount's resolver answers for THIS mount and its hit never enters the shared cache.
363
412
  const served = ctx?.resolver?.(source);
364
413
  if (served) {
365
- return served;
414
+ return withOwnScripts(await served, source);
366
415
  }
367
416
  if (_resolveCache.has(source)) {
368
417
  return _resolveCache.get(source)!;
369
418
  }
370
- const p = fetch(source).then(async (res) => {
419
+ const fetched = fetch(source).then(async (res) => {
371
420
  if (!res.ok) {
372
421
  throw new Error(`Jx: failed to fetch ${source} (${res.status})`);
373
422
  }
@@ -406,6 +455,12 @@ export async function resolve(source: string | JxDocument, ctx?: JxContext): Pro
406
455
  the JSON is that document; the runtime does not schema-validate documents by design. */
407
456
  return (await res.json()) as JxDocument;
408
457
  });
458
+ // A body that needs the eraser has it before anything compiles, which happens synchronously.
459
+ const p = fetched.then(async (read) => {
460
+ const doc = withOwnScripts(read, source);
461
+ await prepareBodies(doc);
462
+ return doc;
463
+ });
409
464
  _resolveCache.set(source, p);
410
465
  return p;
411
466
  }
@@ -475,6 +530,64 @@ export function setSkipAutoRequests(v: boolean) {
475
530
  _autoRequestConfig.skip = v;
476
531
  }
477
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
+
478
591
  // ─── Dev-proxy auth token (Studio cross-origin canvas) ────────────────────────
479
592
  // The Studio canvas iframe is served from a token-gated loopback origin (createProjectServer): its
480
593
  // POST /__jx_resolve__ + /__jx_server__ routes 403 without ?token=<rpcToken>. The iframe boot reads
@@ -680,9 +793,42 @@ export function setCanvasDelinkCommands(on: boolean) {
680
793
  */
681
794
  let _canvasInsideStampedHost = false;
682
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
+
683
818
  /** Whether the node being rendered is inside a stamped host, and so part of the edited page. */
684
819
  function canvasStamped(el: HTMLElement): boolean {
685
- 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;
686
832
  }
687
833
 
688
834
  /** The attribute name to stamp `key` on `el` under — `href` → `data-jx-href` on de-linked anchors. */
@@ -880,18 +1026,20 @@ function boundPropKey(
880
1026
  /**
881
1027
  * Build the reactive scope (state) from the document using the five-shape detection algorithm.
882
1028
  *
883
- * @param {JxDocument} doc
1029
+ * @param {JxDocument} source - The document
884
1030
  * @param {JxScope} [parentScope] Default is `{}`
885
1031
  * @param {string} [base] Base URL for resolving $src imports. Default is `location.href`
886
1032
  * @param {JxContext} [ctx] The mount context; absent on the legacy direct path
887
1033
  * @returns {Promise<JxScope>} Reactive proxy (state)
888
1034
  */
889
1035
  export async function buildScope(
890
- doc: JxDocument,
1036
+ source: JxDocument,
891
1037
  parentScope: JxScope = {},
892
1038
  base: string = location.href,
893
1039
  ctx?: JxContext,
894
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);
895
1043
  // A body run without an event dispatches from the mount's root, read when it fires.
896
1044
  const dispatchRoot = (): EventTarget | null => ctx?.root ?? null;
897
1045
  const raw: JxScope = {};
@@ -1007,8 +1155,15 @@ export async function buildScope(
1007
1155
  }
1008
1156
  }
1009
1157
 
1010
- // Third pass: $prototype: "Function" entries
1158
+ // Third pass: $prototype: "Function" entries. One verdict per entry, from the document: the same
1159
+ // One every compiled target reads (spec.md §5.3 4b, 4d).
1160
+ const roles = classifyFunctionEntries(doc);
1011
1161
  for (const [key, def] of Object.entries(defs)) {
1162
+ /* Refused ahead of the structured-body branch, so a structured body beside a `$src` is caught
1163
+ too, and by presence, so `body: ""` is (spec.md §5.3 4d). */
1164
+ if (isFunctionDef(def) && declaresBodyAndSrc(def)) {
1165
+ throw new Error(`Jx: '${key}' declares both body and $src — these are mutually exclusive`);
1166
+ }
1012
1167
  if (hasStructuredBody(def)) {
1013
1168
  // Structured body (spec §20): with parameters → a callable mapping positional args onto
1014
1169
  // $args names (invoked via the call operator); without → an event handler.
@@ -1029,7 +1184,7 @@ export async function buildScope(
1029
1184
  state[key] = handler;
1030
1185
  }
1031
1186
  } else if (isFunctionDef(def)) {
1032
- state[key] = await resolveFunction(def, state, key, base);
1187
+ state[key] = await resolveFunction(def, state, key, roles.get(key) ?? "callable", base);
1033
1188
  }
1034
1189
  }
1035
1190
 
@@ -1283,6 +1438,7 @@ function bindTemplate(source: string, run: () => void, fallback?: () => void): v
1283
1438
  run();
1284
1439
  heldTemplateErrors.delete(run);
1285
1440
  } catch (error) {
1441
+ _templateThrows += 1;
1286
1442
  const thrown = error instanceof Error ? error : new Error(String(error));
1287
1443
  if (_templateErrorHandler) {
1288
1444
  _templateErrorHandler(thrown, source);
@@ -1296,6 +1452,20 @@ function bindTemplate(source: string, run: () => void, fallback?: () => void): v
1296
1452
  });
1297
1453
  }
1298
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
+
1299
1469
  /** A compiled template: the scope, the row context, its item and index, and the page context. */
1300
1470
  type TemplateFn<R> = (
1301
1471
  state: JxScope,
@@ -1323,11 +1493,15 @@ function rowContext(state: JxScope): JxRowContext | undefined {
1323
1493
  * Whether a template is ONE expression end to end — `"${a.b}"` rather than `"a ${b}"` or
1324
1494
  * `"${a}${b}"`.
1325
1495
  *
1326
- * Depth-counted rather than pattern-matched. A greedy `/^\$\{(.+)\}$/` also matched `"${a} /
1327
- * ${b}"`, spliced the interior into `return (a} / ${b)`, and the SyntaxError became a silent null:
1328
- * the node rendered empty and a `$head` entry shipped its own template text. A brace scan is exact
1329
- * where a tightened regex is not — `"${`${a}-x`}"` is still one expression, and must keep its raw
1330
- * 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.
1331
1505
  *
1332
1506
  * Lives here rather than in the compiler because both renderers must draw the line in the same
1333
1507
  * place. It is what decides whether a value keeps its own type, and a value that is a boolean in
@@ -1341,19 +1515,8 @@ export function isSingleExpression(str: string): boolean {
1341
1515
  if (!str.startsWith("${") || !str.endsWith("}")) {
1342
1516
  return false;
1343
1517
  }
1344
- let depth = 0;
1345
- for (let i = 1; i < str.length; i += 1) {
1346
- const c = str[i];
1347
- if (c === "{") {
1348
- depth += 1;
1349
- } else if (c === "}") {
1350
- depth -= 1;
1351
- if (depth === 0) {
1352
- return i === str.length - 1;
1353
- }
1354
- }
1355
- }
1356
- return false;
1518
+ const [only, ...more] = templateExpressions(str);
1519
+ return only !== undefined && more.length === 0 && only.start === 0 && only.end === str.length;
1357
1520
  }
1358
1521
 
1359
1522
  /**
@@ -1392,6 +1555,26 @@ function evaluateValueTemplate(str: string, state: JxScope): JxScopeValue {
1392
1555
  return fn(...templateArgs(state));
1393
1556
  }
1394
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
+
1395
1578
  // ─── Step 2b: Function resolution (Shape 4) ─────────────────────────────────
1396
1579
 
1397
1580
  /**
@@ -1451,6 +1634,14 @@ async function importSource(src: string, base?: string): Promise<ImportedModule>
1451
1634
  const specifier = src.slice(NPM_SCHEME.length);
1452
1635
  return importModule(_npmModuleResolver?.(specifier) ?? specifier);
1453
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
+ }
1454
1645
  if (base) {
1455
1646
  try {
1456
1647
  return await importModule(new URL(src, base).href);
@@ -1461,6 +1652,145 @@ async function importSource(src: string, base?: string): Promise<ImportedModule>
1461
1652
  return importModule(src);
1462
1653
  }
1463
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
+
1464
1794
  function importModule(specifier: string): Promise<ImportedModule> {
1465
1795
  /* SAFETY: trust boundary — a module exports whatever it exports. `import()` resolves to the
1466
1796
  module namespace object, whose keys are its export names, and every export is typed as the scope
@@ -1586,24 +1916,95 @@ function seededModule(src: string): ImportedModule | Promise<ImportedModule> | u
1586
1916
  */
1587
1917
  type ScopeFunction = (...args: JxScopeValue[]) => JxScopeValue;
1588
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
+
1589
1986
  /**
1590
1987
  * Resolve a $prototype: "Function" entry into a function or computed.
1591
1988
  *
1592
- * Functions receive state as their first parameter at call time. Functions with a return statement
1593
- * in their body are wrapped in computed() for reactive evaluation.
1989
+ * Functions receive state as their first parameter at call time. A `computed` role (decided by
1990
+ * `classifyFunctionEntries` from the document) is wrapped in computed() for reactive evaluation.
1594
1991
  *
1595
1992
  * @param {JxFunctionDef} def - State entry with $prototype: "Function"
1596
1993
  * @param {JxScope} state - Reactive scope proxy
1597
1994
  * @param {string} key - Def key name
1995
+ * @param {FunctionRole} role - The document's verdict for this entry
1598
1996
  * @param {string} [base] - Base URL for resolving $src imports
1599
1997
  * @returns {Promise<JxScopeValue>}
1600
1998
  */
1601
- async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string, base?: string) {
1602
- if (def.body && def.$src) {
1603
- throw new Error(`Jx: '${key}' declares both body and $src — these are mutually exclusive`);
1604
- }
1999
+ async function resolveFunction(
2000
+ def: JxFunctionDef,
2001
+ state: JxScope,
2002
+ key: string,
2003
+ role: FunctionRole,
2004
+ base?: string,
2005
+ ) {
1605
2006
  if (!def.body && !def.$src) {
1606
- const params = resolveParamNames(def);
2007
+ const params = handlerParamNames(def);
1607
2008
  const noop = new Function(...params, "");
1608
2009
  Object.defineProperty(noop, "name", {
1609
2010
  configurable: true,
@@ -1615,50 +2016,52 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
1615
2016
  let fn: ScopeFunction;
1616
2017
 
1617
2018
  if (isString(def.body)) {
1618
- const params = resolveParamNames(def);
2019
+ const params = handlerParamNames(def);
1619
2020
  /* SAFETY: `new Function` returns an ordinary function of the named parameters, `state` first.
1620
2021
  A call never checks its arguments, and what the body returns is one more scope member. */
1621
- fn = new Function(...params, def.body) as ScopeFunction;
2022
+ fn = compileBody(params, def.body, `'${key}'`) as ScopeFunction;
1622
2023
  Object.defineProperty(fn, "name", {
1623
2024
  configurable: true,
1624
2025
  value: def.name ?? key,
1625
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;
1626
2049
  } else {
1627
2050
  // $src: dynamic import (the body/$src dichotomy was validated above)
1628
- const src = def.$src;
1629
- if (!src) {
2051
+ if (!def.$src) {
1630
2052
  throw new Error(`Jx: '${key}' has neither body nor $src`);
1631
2053
  }
1632
- const exportName = def.$export ?? key;
1633
- let mod: ImportedModule;
1634
- const seeded = seededModule(src);
1635
- if (seeded instanceof Promise) {
1636
- mod = await seeded;
1637
- } else if (seeded) {
1638
- mod = seeded;
1639
- } else {
1640
- mod = await importSource(src, base);
1641
- _moduleCache.set(src, mod);
1642
- }
1643
- const candidate = mod[exportName] ?? mod.default?.[exportName];
1644
- if (!isFunction(candidate)) {
1645
- throw new TypeError(`Jx: export "${exportName}" not found or not a function in "${src}"`);
1646
- }
1647
- /* SAFETY: the guard proved a function, and a function accepts any call: the runtime passes the
1648
- scope first, as a Function entry's contract says, and binds the result as a scope member. */
1649
- 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
+ );
1650
2061
  }
1651
2062
 
1652
- // Detect computed: body contains a return statement, or $src function introspection.
1653
- // Functions with parameters (event handlers, callbacks) are never computed.
1654
- const hasParams = (def.parameters ?? def.arguments ?? []).length > 0;
1655
- let isComputed = false;
1656
- if (!hasParams) {
1657
- isComputed = isString(def.body)
1658
- ? bodyReturnsValue(def.body)
1659
- : fn.length <= 1 && bodyReturnsValue(fn.toString());
1660
- }
1661
- if (isComputed) {
2063
+ // The role comes from the document (spec.md §5.3 4b, 4d), never from the import's arity or source.
2064
+ if (role === "computed") {
1662
2065
  return computed(() => fn(state));
1663
2066
  }
1664
2067
 
@@ -1667,19 +2070,6 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
1667
2070
 
1668
2071
  // ─── Step 3: Render ───────────────────────────────────────────────────────────
1669
2072
 
1670
- /**
1671
- * Extract parameter names from a function definition. Supports both legacy "arguments" (string
1672
- * array) and CEM-compatible "parameters" (object array). Always ensures "state" is the first
1673
- * parameter.
1674
- *
1675
- * @param {JxFunctionDef | JxPrototypeDef} def
1676
- * @returns {string[]}
1677
- */
1678
- function resolveParamNames(def: JxFunctionDef | JxPrototypeDef) {
1679
- const names = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
1680
- return names.length > 0 && names[0] === "state" ? names : ["state", ...names];
1681
- }
1682
-
1683
2073
  /**
1684
2074
  * Reserved Jx keys — never set as DOM properties.
1685
2075
  *
@@ -1699,6 +2089,7 @@ export const RESERVED_KEYS = new Set([
1699
2089
  "$prototype",
1700
2090
  "$src",
1701
2091
  "$export",
2092
+ "$script",
1702
2093
  "$media",
1703
2094
  "$map",
1704
2095
  "timing",
@@ -1991,6 +2382,8 @@ export function renderNode(
1991
2382
  if (isMappedArray(child)) {
1992
2383
  // Array pseudo-element among siblings: expand inline, no wrapper.
1993
2384
  renderMappedArrayInto(el, child, localState, childOpts);
2385
+ } else if (isString(child) && isSingleExpression(child)) {
2386
+ renderTemplatedChild(el, child, localState, childOpts);
1994
2387
  } else {
1995
2388
  el.append(renderNode(child, localState, childOpts));
1996
2389
  }
@@ -2009,7 +2402,7 @@ export function renderNode(
2009
2402
  /**
2010
2403
  * Draw a whole-`children` template, the way the site build's template pass expands one (spec.md
2011
2404
  * §8.4). This is what an item's or an entry's parsed body looks like to something that renders the
2012
- * 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.
2013
2406
  *
2014
2407
  * A result that is not an array draws nothing, because a plain string is not a `children` value.
2015
2408
  * The children are rendered in a detached scope of their own, replaced when the template's value
@@ -2027,7 +2420,6 @@ function renderComputedChildren(
2027
2420
  state: JxScope,
2028
2421
  options: JxRenderOptions,
2029
2422
  ): void {
2030
- const path = options._path ?? [];
2031
2423
  let live: EffectScope | null = null;
2032
2424
  const retire = (): void => {
2033
2425
  // Detached before it is stopped, for the reason `teardown` in `renderMappedArray` gives.
@@ -2043,26 +2435,138 @@ function renderComputedChildren(
2043
2435
  if (!Array.isArray(nodes)) {
2044
2436
  return;
2045
2437
  }
2046
- const scope = effectScope(true);
2047
- live = scope;
2048
- pauseTracking();
2049
- try {
2050
- scope.run(() => {
2051
- /* SAFETY: a children template that yields an array stands for the child list itself, the
2052
- same reading the site build's template pass gives it. */
2053
- for (const [i, child] of (nodes as (JxElement | string)[]).entries()) {
2054
- el.append(renderNode(child, state, { ...options, _path: [...path, i] }));
2055
- }
2056
- });
2057
- } finally {
2058
- resetTracking();
2059
- }
2438
+ live = effectScope(true);
2439
+ drawTemplateNodes(live, nodes, state, options, (node) => {
2440
+ el.append(node);
2441
+ });
2060
2442
  },
2061
2443
  retire,
2062
2444
  );
2063
2445
  onScopeDispose(() => live?.stop(), true);
2064
2446
  }
2065
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
+
2066
2570
  // ─── Template string utilities ────────────────────────────────────────────────
2067
2571
 
2068
2572
  /**
@@ -2121,6 +2625,11 @@ function bindHandler(
2121
2625
  }
2122
2626
  return true;
2123
2627
  }
2628
+ if (isFunctionDef(val) && declaresBodyAndSrc(val)) {
2629
+ throw new Error(
2630
+ `Jx: inline '${key}' handler declares both body and $src — these are mutually exclusive`,
2631
+ );
2632
+ }
2124
2633
  if (hasStructuredBody(val)) {
2125
2634
  const { body } = val;
2126
2635
  el.addEventListener(type, (e) => {
@@ -2129,10 +2638,10 @@ function bindHandler(
2129
2638
  return true;
2130
2639
  }
2131
2640
  if (isFunctionDef(val) && isString(val.body)) {
2132
- const params = resolveParamNames(val);
2641
+ const params = handlerParamNames(val);
2133
2642
  /* SAFETY: `new Function` returns an ordinary function of the named parameters, `state` first;
2134
2643
  the listener calls it with the scope and the event, and discards what it returns. */
2135
- const fn = new Function(...params, val.body) as JxEventHandler;
2644
+ const fn = compileBody(params, val.body, `the ${type} handler`) as JxEventHandler;
2136
2645
  el.addEventListener(type, (e) => fn(scope, e));
2137
2646
  return true;
2138
2647
  }
@@ -2931,12 +3440,19 @@ function applyStyleInto(
2931
3440
  const scope = effectScope();
2932
3441
  scope.run(() => {
2933
3442
  for (const { name, value } of sources) {
2934
- effect(() => {
2935
- const resolved = isRefValue(value)
2936
- ? resolveRef(value.$ref, state)
2937
- : evaluateTemplate(value, state);
3443
+ const write = (resolved: JxScopeValue): void => {
2938
3444
  el.style.setProperty(name, canvasStyleValue(String(resolved ?? "")));
2939
- });
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
+ }
2940
3456
  }
2941
3457
  });
2942
3458
  entry.stop = () => scope.stop();
@@ -3080,7 +3596,13 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
3080
3596
  *
3081
3597
  * `options._path` is the array node's own document path (`[…, "children", i]`, or `[…, "children"]`
3082
3598
  * for a legacy whole-children repeater); item instances render at `[…that…, "map", index]`, and a
3083
- * 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.
3084
3606
  *
3085
3607
  * @param {HTMLElement} parentEl
3086
3608
  * @param {import("@jxsuite/schema/types").JxMappedArray} arrayDef
@@ -3103,6 +3625,8 @@ function renderMappedArrayInto(
3103
3625
  re-run, and the instance it holds must connect under the chain of the definition that drew
3104
3626
  the list. */
3105
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;
3106
3630
 
3107
3631
  let warned = false;
3108
3632
  const warnOnce = (message: string): void => {
@@ -3138,7 +3662,9 @@ function renderMappedArrayInto(
3138
3662
  const scope = effectScope(true);
3139
3663
  const childOpts = options ? { ...options, _path: [...path, "map", index] } : undefined;
3140
3664
  const node = scope.run(() => renderNode(mapDef!, child, childOpts))!;
3141
- return { key, map, node, scope };
3665
+ const row: MappedRow = { key, map, node, scope };
3666
+ listRows.set(node, row);
3667
+ return row;
3142
3668
  };
3143
3669
 
3144
3670
  const reconcile = (list: readonly JxScopeValue[], keys: readonly MappedRow["key"][]): void => {
@@ -3204,10 +3730,27 @@ function renderMappedArrayInto(
3204
3730
  items = [...unsorted].toSorted(compare);
3205
3731
  }
3206
3732
  }
3207
- 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
+ }
3208
3748
 
3209
3749
  // Keys are read HERE, tracked, so a key that changes reconciles the list.
3210
- 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);
3211
3754
  const seen = new Set<MappedRow["key"]>();
3212
3755
  for (let i = 0; i < keys.length; i++) {
3213
3756
  if (seen.has(keys[i])) {
@@ -3222,7 +3765,15 @@ function renderMappedArrayInto(
3222
3765
 
3223
3766
  pauseTracking();
3224
3767
  try {
3225
- 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
+ });
3226
3777
  } finally {
3227
3778
  resetTracking();
3228
3779
  }
@@ -3238,6 +3789,7 @@ function renderMappedArrayInto(
3238
3789
  let stopped = false;
3239
3790
  const runner = effect(update, {
3240
3791
  scheduler: () => {
3792
+ _listRedraws += 1;
3241
3793
  if (queued) {
3242
3794
  return;
3243
3795
  }
@@ -3260,13 +3812,58 @@ function renderMappedArrayInto(
3260
3812
  }, true);
3261
3813
  }
3262
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
+
3263
3818
  /** A rendered row of a mapped array. */
3264
3819
  interface MappedRow {
3265
3820
  key: JxScopeValue;
3266
3821
  /** The row's `$map` context; `item` and `index` are written in place when the row is reused. */
3267
3822
  map: JxRowContext;
3268
3823
  node: HTMLElement | Text;
3269
- 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;
3270
3867
  }
3271
3868
 
3272
3869
  /**
@@ -3342,6 +3939,8 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
3342
3939
  around the insertion, so an instance the new case creates connects under the chain of the
3343
3940
  definition that drew the switch rather than under none. See {@link _instancePath}. */
3344
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;
3345
3944
 
3346
3945
  if (options?.onNodeCreated) {
3347
3946
  options.onNodeCreated(container, path, def, state);
@@ -3405,13 +4004,16 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
3405
4004
  if (gen !== generation) {
3406
4005
  return;
3407
4006
  }
3408
- 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);
3409
4009
  if (gen !== generation) {
3410
4010
  return;
3411
4011
  }
3412
4012
  container.replaceChildren();
3413
4013
  underInstancePath(reexpand(instancePath), () =>
3414
- scope.run(() => container.append(renderNode(doc, childScope, childOpts))),
4014
+ insideStampedHost(insideStamped, () =>
4015
+ scope.run(() => container.append(renderNode(doc, childScope, childOpts))),
4016
+ ),
3415
4017
  );
3416
4018
  })
3417
4019
  .catch((error) =>
@@ -3426,7 +4028,9 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
3426
4028
  pauseTracking();
3427
4029
  try {
3428
4030
  underInstancePath(reexpand(instancePath), () =>
3429
- scope.run(() => container.append(renderNode(caseDef, state, childOpts))),
4031
+ insideStampedHost(insideStamped, () =>
4032
+ scope.run(() => container.append(renderNode(caseDef, state, childOpts))),
4033
+ ),
3430
4034
  );
3431
4035
  } finally {
3432
4036
  resetTracking();
@@ -3498,22 +4102,59 @@ export async function resolvePrototype(
3498
4102
  }
3499
4103
  });
3500
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
+
3501
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
+ }
3502
4145
  holdTemplateErrorsFor(
3503
- fetch(url, {
3504
- method: def.method ?? "GET",
3505
- signal: controller.signal,
3506
- ...(def.headers && { headers: def.headers }),
3507
- ...(def.body && {
3508
- body: isNonNullObject(def.body) ? JSON.stringify(def.body) : def.body,
3509
- }),
3510
- })
3511
- .then((r): Promise<JsonValue> => (r.ok ? r.json() : Promise.reject(r.statusText)))
4146
+ lookup
3512
4147
  .then((d) => {
3513
- 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
+ }
3514
4152
  })
3515
4153
  .catch((error) => {
3516
- if (!(error instanceof Error && error.name === "AbortError")) {
4154
+ if (
4155
+ !controller.signal.aborted &&
4156
+ !(error instanceof Error && error.name === "AbortError")
4157
+ ) {
3517
4158
  s.value = { error: String(error) };
3518
4159
  }
3519
4160
  }),
@@ -3867,6 +4508,7 @@ async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string
3867
4508
  // Fall back to dev proxy (server will handle .class.json resolution)
3868
4509
  return resolveViaDevProxy(def, state, key, base);
3869
4510
  }
4511
+ await prepareBodies(classDef);
3870
4512
 
3871
4513
  // Hybrid mode: $implementation points to the real JS module
3872
4514
  if (classDef.$implementation) {
@@ -3934,6 +4576,7 @@ function classFromSchema(classDef: JxClassDef) {
3934
4576
  const rawCtor = classDef.$defs?.constructor;
3935
4577
  const ctor = isCtorDef(rawCtor) ? rawCtor : undefined;
3936
4578
  const methods = classDef.$defs?.methods ?? {};
4579
+ const className = classDef.title ?? "a class";
3937
4580
 
3938
4581
  // oxlint-disable-next-line typescript/no-extraneous-class -- methods are attached to the prototype dynamically below
3939
4582
  class DynClass {
@@ -3961,7 +4604,10 @@ function classFromSchema(classDef: JxClassDef) {
3961
4604
  /* SAFETY: `new Function` returns an ordinary function of the one named parameter; `call`
3962
4605
  runs it with the instance under construction as `this`, which is the constructor
3963
4606
  body's contract. */
3964
- const init = new Function("config", bodyStr) as (this: DynClass, config: JxScope) => void;
4607
+ const init = compileBody(["config"], bodyStr, `the constructor of ${className}`) as (
4608
+ this: DynClass,
4609
+ config: JxScope,
4610
+ ) => void;
3965
4611
  init.call(this, config);
3966
4612
  }
3967
4613
  }
@@ -3986,7 +4632,11 @@ function classFromSchema(classDef: JxClassDef) {
3986
4632
  if (typedMethod.getter) {
3987
4633
  /* SAFETY: `new Function` returns an ordinary function; as a getter it is called with no
3988
4634
  arguments and the instance as `this`, and what it returns is the property's value. */
3989
- descriptor.get = new Function(typedMethod.getter.body ?? "") as () => JxScopeValue;
4635
+ descriptor.get = compileBody(
4636
+ [],
4637
+ typedMethod.getter.body ?? "",
4638
+ `the getter ${name} of ${className}`,
4639
+ ) as () => JxScopeValue;
3990
4640
  }
3991
4641
  if (typedMethod.setter) {
3992
4642
  const sp = (typedMethod.setter.parameters ?? []).map(
@@ -3994,18 +4644,20 @@ function classFromSchema(classDef: JxClassDef) {
3994
4644
  );
3995
4645
  /* SAFETY: `new Function` returns an ordinary function of the named parameters; as a setter
3996
4646
  it receives the assigned value as its one argument, and its return is discarded. */
3997
- descriptor.set = new Function(...sp, typedMethod.setter.body ?? "") as (
3998
- v: JxScopeValue,
3999
- ) => void;
4647
+ descriptor.set = compileBody(
4648
+ sp,
4649
+ typedMethod.setter.body ?? "",
4650
+ `the setter ${name} of ${className}`,
4651
+ ) as (v: JxScopeValue) => void;
4000
4652
  }
4001
4653
  Object.defineProperty(DynClass.prototype, name, {
4002
4654
  ...descriptor,
4003
4655
  configurable: true,
4004
4656
  });
4005
4657
  } else if (typedMethod.scope === "static") {
4006
- DynClass[name] = new Function(...params, bodyStr);
4658
+ DynClass[name] = compileBody(params, bodyStr, `${name} of ${className}`);
4007
4659
  } else {
4008
- DynClass.prototype[name] = new Function(...params, bodyStr);
4660
+ DynClass.prototype[name] = compileBody(params, bodyStr, `${name} of ${className}`);
4009
4661
  }
4010
4662
  }
4011
4663
 
@@ -4098,6 +4750,7 @@ function latestServerCall(
4098
4750
  s: Ref<JxScopeValue>,
4099
4751
  invoke: ServerCall,
4100
4752
  label: string,
4753
+ describe: (args: JxScope) => RemoteLookup,
4101
4754
  ): (args: JxScope) => void {
4102
4755
  let seq = 0;
4103
4756
  return (args: JxScope) => {
@@ -4105,17 +4758,29 @@ function latestServerCall(
4105
4758
  const n = seq;
4106
4759
  /* The executor runs before the constructor returns, so the call still starts synchronously,
4107
4760
  and a throw inside it rejects this promise instead of escaping to the caller. */
4108
- holdTemplateErrorsFor(
4761
+ const run = (): Promise<JsonValue> =>
4109
4762
  new Promise<JsonValue>((settle) => {
4110
4763
  settle(invoke(args));
4111
- })
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
4112
4775
  .then((result) => {
4113
4776
  if (n === seq) {
4114
4777
  s.value = result;
4115
4778
  }
4116
4779
  })
4117
4780
  .catch((error) => {
4118
- 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")) {
4119
4784
  console.error(label, error);
4120
4785
  }
4121
4786
  }),
@@ -4123,6 +4788,32 @@ function latestServerCall(
4123
4788
  };
4124
4789
  }
4125
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
+
4126
4817
  /**
4127
4818
  * One call of a `timing: "server"` function, with its resolved arguments. Its answer is JSON, or a
4128
4819
  * promise of JSON: on the proxy path the answer crosses the wire as JSON, and a host-registered
@@ -4186,7 +4877,9 @@ async function resolveServerFunction(
4186
4877
  // Always wrap in ref for reactivity
4187
4878
  const s: Ref<JxScopeValue> = ref(null);
4188
4879
  if (hasReactiveArg) {
4189
- 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
+ );
4190
4883
  effect(() => {
4191
4884
  const args = resolveArgs();
4192
4885
  onEffectCleanup(() => {});
@@ -4250,7 +4943,9 @@ async function resolveServerFunctionViaProxy(
4250
4943
 
4251
4944
  // Always wrap in ref for reactivity
4252
4945
  const s: Ref<JxScopeValue> = ref(null);
4253
- 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
+ );
4254
4949
  if (hasReactiveArg) {
4255
4950
  effect(() => {
4256
4951
  const args = resolveArgs();
@@ -4594,6 +5289,50 @@ export async function redefineElement(doc: JxDocument, baseUrl?: string): Promis
4594
5289
  _elementDefs.set(tagName, { base, doc });
4595
5290
  }
4596
5291
 
5292
+ /** The project formulas every component scope in this realm inherits (spec.md §13.4). */
5293
+ let _projectFormulas: Record<string, JxStateDefinition> = {};
5294
+ /** A definition with the inherited formulas merged in, built once per definition. */
5295
+ let _withFormulas = new WeakMap<JxDocument, JxDocument>();
5296
+
5297
+ /**
5298
+ * Give this realm its project's `state`, from which a component inherits the pure named formulas
5299
+ * (spec.md §13.4, §19.4c).
5300
+ *
5301
+ * A page receives project state merged into its own document, but a component's document is its own
5302
+ * scope and is fetched as written, so the host that knows the project says so once: the site shell
5303
+ * before it renders the page, Studio before it draws a canvas. Only closed pure formulas are taken
5304
+ * (`inheritableFormulas`); data state never crosses. Realm-wide because the custom element registry
5305
+ * is: one tag has one definition per realm, whatever mounted it.
5306
+ *
5307
+ * @param {Record<string, JxStateDefinition> | null} [projectState] - `project.json` `state`; absent
5308
+ * or null clears it, and a component is then exactly its own document
5309
+ */
5310
+ export function setProjectFormulas(projectState?: Record<string, JxStateDefinition> | null): void {
5311
+ _projectFormulas = inheritableFormulas(projectState);
5312
+ _withFormulas = new WeakMap();
5313
+ }
5314
+
5315
+ /**
5316
+ * A component document as its scope is built: the inherited formulas first, then its own `state`,
5317
+ * so an entry the component declares shadows a project formula of that name. The document itself
5318
+ * when the project gives it nothing.
5319
+ *
5320
+ * @param {JxDocument} doc
5321
+ * @returns {JxDocument}
5322
+ */
5323
+ function withProjectFormulas(doc: JxDocument): JxDocument {
5324
+ const cached = _withFormulas.get(doc);
5325
+ if (cached) {
5326
+ return cached;
5327
+ }
5328
+ const own = doc.state ?? {};
5329
+ const added = Object.entries(_projectFormulas).filter(([key]) => !(key in own));
5330
+ const merged =
5331
+ added.length > 0 ? { ...doc, state: { ...Object.fromEntries(added), ...own } } : doc;
5332
+ _withFormulas.set(doc, merged);
5333
+ return merged;
5334
+ }
5335
+
4597
5336
  /**
4598
5337
  * Seed the module-level root `$media` map used as the fallback for components that declare their
4599
5338
  * own `@--name` style blocks but carry no own `$media` (buildScope ~279-280). `Jx()` sets this from
@@ -4964,7 +5703,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4964
5703
  _definedSources.add(base);
4965
5704
  source_ = await resolve(source);
4966
5705
  } else {
4967
- source_ = source;
5706
+ source_ = inlineDocument(source, new URL(base ?? location.href, location.href).href);
4968
5707
  }
4969
5708
  base ??= location.href;
4970
5709
 
@@ -4988,6 +5727,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4988
5727
  const initialDef = source_;
4989
5728
  const initialBase = base;
4990
5729
  const observedAttrs = initialDef.observedAttributes ?? [];
5730
+ _observedAttributes.set(tagName, observedAttrs);
4991
5731
 
4992
5732
  const ElementClass = class extends HTMLElement {
4993
5733
  _jxInitialized = false;
@@ -5023,7 +5763,9 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
5023
5763
 
5024
5764
  // The definition as it stands NOW — a redefinition since this class was made is honoured.
5025
5765
  const live = _elementDefs.get(tagName) ?? { base: initialBase, doc: initialDef };
5026
- const def = live.doc;
5766
+ _connectedDefs.set(this, live.doc);
5767
+ // With the project's formulas under its own state (§13.4): the scope, and what a prop may set.
5768
+ const def = withProjectFormulas(live.doc);
5027
5769
  const defBase = live.base;
5028
5770
 
5029
5771
  // The element is its own dispatch root: a body run without an event emits from the host.
@@ -5131,10 +5873,20 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
5131
5873
  * accessor: the property-first interface IS the props mechanism, so defining one would leave
5132
5874
  * `el["#cache"] = x` writing straight through everything above.
5133
5875
  */
5876
+ const declared = live.doc.state ?? {};
5134
5877
  for (const key of Object.keys(def.state ?? {})) {
5135
5878
  if (isPrivateStateKey(key)) {
5136
5879
  continue;
5137
5880
  }
5881
+ /* An inherited project formula (§13.4) is a function in the scope, not part of the
5882
+ element's interface: the component's author never chose its name. An accessor for one
5883
+ would put a project formula called `append`, `remove` or `title` over the host's own
5884
+ member of that name, on every component in the realm, and the render below then calls
5885
+ the formula instead of the DOM method. Only an instance that was GIVEN the name as a prop
5886
+ gets the accessor, so a live `$props` binding of that name still reaches state. */
5887
+ if (!(key in declared) && !(key in supplied)) {
5888
+ continue;
5889
+ }
5138
5890
  /* A name the platform already owns (`title`, `lang`, `hidden`) keeps its native setter, so
5139
5891
  the attribute still reflects — and ALSO reaches state. Skipping it entirely meant a prop
5140
5892
  delivered after connection (a template over data that resolves asynchronously) wrote
@@ -5194,7 +5946,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
5194
5946
  definition draws inside it is too — and the de-link rules cannot see that from the nodes
5195
5947
  themselves. Restored rather than cleared, because one definition may render another. */
5196
5948
  const wasInside = _canvasInsideStampedHost;
5197
- _canvasInsideStampedHost ||= this.dataset.jxPath !== undefined;
5949
+ _canvasInsideStampedHost ||= carriesCanvasStamp(this);
5198
5950
  /* And the chain, for the same span: an instance this definition draws connects inside
5199
5951
  `this.append` below and reads it there ({@link underInstancePath}). */
5200
5952
  let failure: Error | null = null;
@@ -5322,6 +6074,394 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
5322
6074
  customElements.define(tagName, ElementClass);
5323
6075
  }
5324
6076
 
6077
+ /**
6078
+ * Write one `$props` value onto an instance, before or after it connected.
6079
+ *
6080
+ * The same resolution `bindProperty` gives an ordinary element. A `$props: { src }` on a custom
6081
+ * element is an asset reference authored at the USAGE site, so it belongs to the document that
6082
+ * wrote it; a media prop the component forwards into its own `<img>` is resolved separately, inside
6083
+ * the component's render, against the same context.
6084
+ *
6085
+ * @param {HTMLElement} el - The instance.
6086
+ * @param {string} key - The prop's name.
6087
+ * @param {JxScopeValue} resolved - Its value, already resolved from any binding.
6088
+ */
6089
+ function writeInstanceProp(el: HTMLElement, key: string, resolved: JxScopeValue): void {
6090
+ const value = isString(resolved) ? canvasAssetValue(el.tagName, key, resolved) : resolved;
6091
+ elementProperties(el)[key] = value;
6092
+ /* A declared prop that shares its name with an `HTMLElement` property (`title`, `lang`) gets
6093
+ no forwarding accessor (§16.2), so the write above lands on the element and never reaches
6094
+ the instance's state. Connection reads it once; a binding that changes AFTER connection
6095
+ (a row whose data arrives later) was lost, and the instance went on drawing the value it
6096
+ connected with. The binding is the parent's `$props`, addressed to the definition's prop,
6097
+ so it writes that prop's state. */
6098
+ const instanceState = instanceStateOf(el);
6099
+ if (instanceState && key in HTMLElement.prototype && Object.hasOwn(toRaw(instanceState), key)) {
6100
+ instanceState[key] = value;
6101
+ }
6102
+ }
6103
+
6104
+ /**
6105
+ * The attributes each tag observes, as first defined: the list the platform froze, which a
6106
+ * redefinition does not change ({@link redefineElement}).
6107
+ */
6108
+ const _observedAttributes = new Map<string, readonly string[]>();
6109
+
6110
+ /**
6111
+ * The definition each instance connected with. A redefinition leaves a connected instance drawing
6112
+ * the old one (embedding.md §7), and nothing read from the new one describes it.
6113
+ */
6114
+ const _connectedDefs = new WeakMap<HTMLElement, JxDocument>();
6115
+
6116
+ /** Whether an instance is still drawn by the definition its tag has now. */
6117
+ function drawnByCurrent(el: HTMLElement): boolean {
6118
+ const drawn = _connectedDefs.get(el);
6119
+ return drawn !== undefined && drawn === _elementDefs.get(el.localName)?.doc;
6120
+ }
6121
+
6122
+ /** The markers of a state entry whose value is produced asynchronously or kept outside the scope. */
6123
+ function producedElsewhere(entry: JxDocumentValue | undefined): boolean {
6124
+ if (!isJsonObject(entry)) {
6125
+ return false;
6126
+ }
6127
+ const { $prototype: prototype } = entry;
6128
+ if (prototype !== undefined) {
6129
+ return prototype !== "Function";
6130
+ }
6131
+ return "$src" in entry || "timing" in entry;
6132
+ }
6133
+
6134
+ /** Whether a `$props` value is a scalar written as it stands: no binding, no object, no `null`. */
6135
+ function isScalarProp(value: JxScopeValue | undefined): value is string | number | boolean {
6136
+ return (isString(value) && !isTemplateString(value)) || isNumber(value) || isBoolean(value);
6137
+ }
6138
+
6139
+ /**
6140
+ * The platform's own properties that reflect a string to an attribute and read it back unchanged,
6141
+ * so a declared prop of that name holds after a write exactly what connection would have read.
6142
+ */
6143
+ const VERBATIM_PROPS: ReadonlySet<string> = new Set(["id", "lang", "role", "slot", "title"]);
6144
+
6145
+ /** Whether a document value is drawn through a binding (a `$ref` or a `${…}` template). */
6146
+ function isBoundProp(value: JxDocumentValue | undefined): boolean {
6147
+ return isRefValue(value) || isTemplateString(value);
6148
+ }
6149
+
6150
+ /**
6151
+ * Whether a definition, or one it draws, reads something ONCE when it renders, so that a later
6152
+ * write to its state would not reach what it drew, or would undo it:
6153
+ *
6154
+ * - A tag expression. The tag is chosen at creation and is not tracked (§19.6).
6155
+ * - A `$`-local bound to a `$ref`, which is a copy of the value taken at render, on a node or in the
6156
+ * `$props` of an element that is not a component; and an `id` bound to a `$ref`, written once.
6157
+ * - A `<slot>` a write can redraw (under a `$switch` case or a `$map` template, or with a `$switch`
6158
+ * of its own) or re-address (a bound `name`), or whose fallback is bound text. Slots are
6159
+ * distributed once, at connection (§8.5): the redrawn one is empty, and a replaced one is gone.
6160
+ * - A component it draws being handed a value by a route that component reads only when it connects
6161
+ * ({@link handsOverOnce}): bound `textContent`, slot content that is bound text, a mapped array
6162
+ * or a child with a bound `slot`, a bound `props.*` attribute, a bound attribute for a prop it
6163
+ * declares and does not observe (§16.5), or a bound prop or property the platform owns.
6164
+ * - An `onMount` in its state (§16.4), which runs once with the values it had then.
6165
+ * - A definition that can draw itself, whose instances are admitted against the props each connected
6166
+ * with (§16.9).
6167
+ * - Children or `innerHTML` drawn from a binding: what they draw is not in the document, so nothing
6168
+ * can be said about it.
6169
+ * - A state entry produced asynchronously or kept outside the scope (a `$prototype` other than
6170
+ * `Function`, a `$src`, a `timing`): a request, a class or a store answers for the value it was
6171
+ * built with, and a stored value outlives the write.
6172
+ *
6173
+ * The answer belongs to the definition, not to the instance: it is asked of the document, so a
6174
+ * `$switch` case that is not drawn yet counts. It is not remembered, because it depends on every
6175
+ * definition this one draws and any of them can be redefined (embedding.md §7).
6176
+ *
6177
+ * @param {JxDocument} def
6178
+ * @param {Set<JxDocument>} [drawing] - The definitions this walk is inside.
6179
+ * @returns {boolean}
6180
+ */
6181
+ function rendersOnce(def: JxDocument, drawing = new Set<JxDocument>()): boolean {
6182
+ const declared = def.state ?? {};
6183
+ if ("onMount" in declared || Object.values(declared).some((entry) => producedElsewhere(entry))) {
6184
+ return true;
6185
+ }
6186
+ drawing.add(def);
6187
+ const walk = (value: JxDocumentValue | undefined, isRoot: boolean, redrawn: boolean): boolean => {
6188
+ if (Array.isArray(value)) {
6189
+ return value.some((item) => walk(item, false, redrawn));
6190
+ }
6191
+ if (!isNonNullObject(value)) {
6192
+ return false;
6193
+ }
6194
+ /* SAFETY: a definition is a JSON document, so every member of every object in it is a document
6195
+ value; `Object.entries` on the union of node shapes says `any` only because it cannot name
6196
+ one type for them all. */
6197
+ const node = Object.fromEntries(Object.entries(value)) as Record<string, JxDocumentValue>;
6198
+ const { attributes, tagName } = node;
6199
+ if (tagName !== undefined && !isString(tagName)) {
6200
+ return true;
6201
+ }
6202
+ const attrs: Record<string, JxDocumentValue> = isJsonObject(attributes) ? attributes : {};
6203
+ if (
6204
+ tagName === "slot" &&
6205
+ (redrawn ||
6206
+ isBoundProp(attrs.name) ||
6207
+ isBoundProp(node.name) ||
6208
+ isBoundProp(node.textContent) ||
6209
+ node.$switch !== undefined)
6210
+ ) {
6211
+ return true;
6212
+ }
6213
+ if (isBoundProp(node.innerHTML) || (!isRoot && isBoundProp(node.children))) {
6214
+ return true;
6215
+ }
6216
+ const isInstance = !isRoot && isString(tagName) && tagName.includes("-");
6217
+ const inner = isInstance ? _elementDefs.get(tagName)?.doc : undefined;
6218
+ if (isInstance && handsOverOnce(node, attrs, inner)) {
6219
+ return true;
6220
+ }
6221
+ if (inner && (drawing.has(inner) || rendersOnce(inner, drawing))) {
6222
+ return true;
6223
+ }
6224
+ for (const [key, entry] of Object.entries(node)) {
6225
+ // State is the scope a write reaches, not something drawn; the root's `$props` are defaults.
6226
+ if (isRoot && (key === "state" || key === "$props")) {
6227
+ continue;
6228
+ }
6229
+ if (key === "$props") {
6230
+ if (!isInstance && isJsonObject(entry) && Object.values(entry).some((v) => isRefValue(v))) {
6231
+ return true;
6232
+ }
6233
+ continue;
6234
+ }
6235
+ if ((key === "id" || (key.startsWith("$") && !RESERVED_KEYS.has(key))) && isRefValue(entry)) {
6236
+ return true;
6237
+ }
6238
+ // `cases` is a map of nodes by name, not a node: a case may be called `state`.
6239
+ const drawn = key === "cases" && isJsonObject(entry) ? Object.values(entry) : entry;
6240
+ if (walk(drawn, false, redrawn || key === "cases" || key === "map")) {
6241
+ return true;
6242
+ }
6243
+ }
6244
+ return false;
6245
+ };
6246
+ const answer = walk(def, true, false);
6247
+ drawing.delete(def);
6248
+ return answer;
6249
+ }
6250
+
6251
+ /**
6252
+ * Whether a component a definition draws is handed a bound value by a route it reads only when it
6253
+ * connects ({@link rendersOnce}).
6254
+ *
6255
+ * @param node - The node that draws the component.
6256
+ * @param attrs - That node's `attributes`.
6257
+ * @param inner - The component's definition, when it is registered.
6258
+ * @returns {boolean}
6259
+ */
6260
+ function handsOverOnce(
6261
+ node: Record<string, JxDocumentValue>,
6262
+ attrs: Record<string, JxDocumentValue>,
6263
+ inner: JxDocument | undefined,
6264
+ ): boolean {
6265
+ if (isBoundProp(node.textContent)) {
6266
+ return true;
6267
+ }
6268
+ /* Its slot content is grouped, and tested for being empty, once (§8.5): text that fills in later,
6269
+ a row that arrives later and a child that changes group are not distributed again. */
6270
+ const kids = Array.isArray(node.children) ? node.children : [];
6271
+ if (
6272
+ kids.some(
6273
+ (kid) =>
6274
+ isBoundProp(kid) ||
6275
+ isMappedArray(kid) ||
6276
+ (isJsonObject(kid) &&
6277
+ (isBoundProp(kid.slot) ||
6278
+ (isJsonObject(kid.attributes) && isBoundProp(kid.attributes.slot)))),
6279
+ )
6280
+ ) {
6281
+ return true;
6282
+ }
6283
+ /* A platform-named prop is read back through the platform's accessor at connection, which
6284
+ coerces it; forwarded later, as a prop or as a property of the node, it reaches the
6285
+ component's state as given. */
6286
+ const coerced = (given: Record<string, JxDocumentValue>): boolean =>
6287
+ Object.entries(given).some(
6288
+ ([key, value]) => isBoundProp(value) && key in HTMLElement.prototype && key !== "textContent",
6289
+ );
6290
+ if (coerced(node) || (isJsonObject(node.$props) && coerced(node.$props))) {
6291
+ return true;
6292
+ }
6293
+ const observed = isString(node.tagName) ? (_observedAttributes.get(node.tagName) ?? []) : [];
6294
+ return Object.entries(attrs).some(([name, given]) => {
6295
+ if (!isBoundProp(given)) {
6296
+ return false;
6297
+ }
6298
+ if (name.startsWith("props.")) {
6299
+ return true;
6300
+ }
6301
+ const prop = name.replaceAll(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
6302
+ // An unregistered component is one whose declarations cannot be read: assume it reads this.
6303
+ return !inner || (prop in (inner.state ?? {}) && !observed.includes(name));
6304
+ });
6305
+ }
6306
+
6307
+ /**
6308
+ * Whether anything the instance's own definition drew has an `onMount` that already ran: the
6309
+ * instance itself, or an instance inside it that no document node stands between.
6310
+ *
6311
+ * `onMount` runs once (§16.4), and what it read then it does not read again. An instance the
6312
+ * DOCUMENT placed inside this one (slot content, stamped `data-jx-path` by the canvas) takes
6313
+ * nothing from this instance's props, so it does not count.
6314
+ *
6315
+ * @param {HTMLElement} host
6316
+ * @param {JxScope} state
6317
+ * @returns {boolean}
6318
+ */
6319
+ function mountedFromProps(host: HTMLElement, state: JxScope): boolean {
6320
+ if (isScopeHook(state.onMount) || !drawnByCurrent(host)) {
6321
+ return true;
6322
+ }
6323
+ for (const inner of host.querySelectorAll<HTMLElement>("*")) {
6324
+ const innerState = instanceStateOf(inner);
6325
+ if (!innerState || (!isScopeHook(innerState.onMount) && drawnByCurrent(inner))) {
6326
+ continue;
6327
+ }
6328
+ let owner: HTMLElement | null = inner;
6329
+ while (owner && owner !== host && owner.dataset.jxPath === undefined) {
6330
+ owner = owner.parentElement;
6331
+ }
6332
+ if (owner === host) {
6333
+ return true;
6334
+ }
6335
+ }
6336
+ return false;
6337
+ }
6338
+
6339
+ /**
6340
+ * Write a changed `$props` block into an instance that is already connected, in place, and answer
6341
+ * whether that was done. `false` means the caller draws the instance again; nothing was written
6342
+ * unless the refusal is the last case below.
6343
+ *
6344
+ * This is the property-first interface (§16.2) used by an editor: the Studio canvas calls it when
6345
+ * an author edits a prop, so the instance's own bindings redraw what reads that prop and nothing
6346
+ * else in it is rebuilt. It refuses whenever writing the properties could leave the instance
6347
+ * showing something a fresh render of it would not:
6348
+ *
6349
+ * - The element is not a connected Jx instance, or its definition is gone.
6350
+ * - Nothing changed. A block equal to the last one is a request to draw again, not a write.
6351
+ * - A changed prop is not a scalar on both sides: a string, a number or a boolean, as it stands. A
6352
+ * `$ref` or a template is an effect the render that created the instance owns, and it would go on
6353
+ * writing the old source. An object, an array or `null` is refused because too much reads one
6354
+ * once: a list redraws its rows in a later microtask, where a throw can no longer be answered
6355
+ * for; a binding that reads a member survives `null` by keeping its last drawing; and the
6356
+ * canvas's inline-edit marker is stamped by the type the value had at render.
6357
+ * - A changed prop overrides a state entry that is produced elsewhere (a store, a request).
6358
+ * - A changed prop is named for a property the platform owns (`hidden`, `tabIndex`, `textContent`):
6359
+ * connection reads such a value back through the platform's accessor, which coerces it, reflects
6360
+ * it to an attribute, or for `textContent` makes it slot content, and a later write does none of
6361
+ * that the same way. The exception is a declared prop the platform reflects verbatim
6362
+ * ({@link VERBATIM_PROPS}) being given a string, which reads back as written.
6363
+ * - A removed prop has no scalar declared default to go back to, or its attribute is observed and on
6364
+ * the host, where a fresh instance would read it instead of the default.
6365
+ * - The definition, or one it draws, declares an `onMount`, or an instance already drawn has one: it
6366
+ * ran once with the old values.
6367
+ * - The instance, or one its definition drew, connected with a definition since replaced
6368
+ * (embedding.md §7), and still draws the old one.
6369
+ * - The definition, or one it draws, reads something once at render ({@link rendersOnce}): a tag
6370
+ * expression, a copied `$ref`, a slot a write would redraw, a nested component handed a value it
6371
+ * reads only at connection, or a definition that can draw itself.
6372
+ * - A write throws, a template binding throws on the new value and is survived (§6.1), or a write
6373
+ * reaches a mapped list. The first is a render that throws, which the fresh render reports
6374
+ * (§16.4); the second keeps what it last drew, which a fresh render never drew; the third redraws
6375
+ * in a later microtask, where nothing can answer for what it draws. All three are known only
6376
+ * after the fact: the props are written, and the caller's redraw discards them with the
6377
+ * instance.
6378
+ *
6379
+ * What it does not put back is state the instance changed itself (a counter its own handler moved):
6380
+ * the write changes the props that changed and leaves the rest of the scope as it stands.
6381
+ *
6382
+ * @param {HTMLElement} el - The instance host.
6383
+ * @param {JxElement["$props"]} before - The `$props` block it was drawn with.
6384
+ * @param {JxElement["$props"]} after - The block it should show now.
6385
+ * @returns {boolean}
6386
+ */
6387
+ export function updateInstanceProps(
6388
+ el: HTMLElement,
6389
+ before: JxElement["$props"],
6390
+ after: JxElement["$props"],
6391
+ ): boolean {
6392
+ const state = instanceStateOf(el);
6393
+ const registered = _elementDefs.get(el.localName)?.doc;
6394
+ if (!state || !registered) {
6395
+ return false;
6396
+ }
6397
+ // As the instance's scope was built: with the project's formulas under its own state (§13.4).
6398
+ const def = withProjectFormulas(registered);
6399
+ const defaults = declaredDefaults(def);
6400
+ const declared = def.state ?? {};
6401
+ const observed = _observedAttributes.get(el.localName) ?? [];
6402
+ const writes: (() => void)[] = [];
6403
+ for (const key of new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])) {
6404
+ const was = before?.[key];
6405
+ const now = after?.[key];
6406
+ // A private key was refused when the instance was drawn and is refused now: nothing to write.
6407
+ if (isPrivateStateKey(key) || JSON.stringify(was) === JSON.stringify(now)) {
6408
+ continue;
6409
+ }
6410
+ if (
6411
+ (was !== undefined && !isScalarProp(was)) ||
6412
+ (now !== undefined && !isScalarProp(now)) ||
6413
+ producedElsewhere(declared[key]) ||
6414
+ // What the instance holds now decided things at render too (a marker, a handler).
6415
+ (key in declared && !isScalarProp(toRaw(state)[key])) ||
6416
+ // The element class's own fields, which a property write would overwrite.
6417
+ key === "_state" ||
6418
+ key === "_jxInitialized"
6419
+ ) {
6420
+ return false;
6421
+ }
6422
+ if (
6423
+ key in HTMLElement.prototype &&
6424
+ !(VERBATIM_PROPS.has(key) && key in declared && isString(now))
6425
+ ) {
6426
+ return false;
6427
+ }
6428
+ if (now !== undefined) {
6429
+ writes.push(() => {
6430
+ writeInstanceProp(el, key, now);
6431
+ });
6432
+ continue;
6433
+ }
6434
+ if (!(key in declared)) {
6435
+ // Nothing reads an undeclared prop: a fresh instance simply would not carry it.
6436
+ writes.push(() => {
6437
+ Reflect.deleteProperty(el, key);
6438
+ });
6439
+ continue;
6440
+ }
6441
+ const fallback = defaults.get(key);
6442
+ const attribute = key.replaceAll(/[A-Z]/gu, (c) => `-${c.toLowerCase()}`);
6443
+ if (!isScalarProp(fallback) || (observed.includes(attribute) && el.hasAttribute(attribute))) {
6444
+ return false;
6445
+ }
6446
+ writes.push(() => {
6447
+ state[key] = fallback;
6448
+ });
6449
+ }
6450
+ if (writes.length === 0 || mountedFromProps(el, state) || rendersOnce(def)) {
6451
+ return false;
6452
+ }
6453
+ const thrown = _templateThrows;
6454
+ const redraws = _listRedraws;
6455
+ try {
6456
+ for (const write of writes) {
6457
+ write();
6458
+ }
6459
+ } catch {
6460
+ return false;
6461
+ }
6462
+ return _templateThrows === thrown && _listRedraws === redraws;
6463
+ }
6464
+
5325
6465
  /**
5326
6466
  * Render a registered custom element with $props (property-first interface).
5327
6467
  *
@@ -5348,27 +6488,8 @@ function renderCustomElementWithProps(
5348
6488
  if (refusePrivateProp(key, "$props")) {
5349
6489
  continue;
5350
6490
  }
5351
- /* The same resolution `bindProperty` gives an ordinary element. A `$props: { src }` on a custom
5352
- element is an asset reference authored at the USAGE site, so it belongs to the document that
5353
- wrote it — a media prop the component forwards into its own `<img>` is resolved separately,
5354
- inside the component's render, against the same context. */
5355
6491
  const write = (resolved: JxScopeValue): void => {
5356
- const value = isString(resolved) ? canvasAssetValue(el.tagName, key, resolved) : resolved;
5357
- elementProperties(el)[key] = value;
5358
- /* A declared prop that shares its name with an `HTMLElement` property (`title`, `lang`) gets
5359
- no forwarding accessor (§16.2), so the write above lands on the element and never reaches
5360
- the instance's state. Connection reads it once; a binding that changes AFTER connection
5361
- (a row whose data arrives later) was lost, and the instance went on drawing the value it
5362
- connected with. The binding is the parent's `$props`, addressed to the definition's prop,
5363
- so it writes that prop's state. */
5364
- const instanceState = instanceStateOf(el);
5365
- if (
5366
- instanceState &&
5367
- key in HTMLElement.prototype &&
5368
- Object.hasOwn(toRaw(instanceState), key)
5369
- ) {
5370
- instanceState[key] = value;
5371
- }
6492
+ writeInstanceProp(el, key, resolved);
5372
6493
  };
5373
6494
  if (isRefValue(val)) {
5374
6495
  const refVal = val;
@@ -5410,6 +6531,8 @@ function renderCustomElementWithProps(
5410
6531
  const childOpts = options && path ? { ...options, _path: [...path, "children", i] } : undefined;
5411
6532
  if (isMappedArray(child)) {
5412
6533
  renderMappedArrayInto(el, child, state, childOpts);
6534
+ } else if (isString(child) && isSingleExpression(child)) {
6535
+ renderTemplatedChild(el, child, state, childOpts);
5413
6536
  } else {
5414
6537
  el.append(renderNode(child, state, childOpts));
5415
6538
  }