@jxsuite/runtime 4.1.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runtime.ts CHANGED
@@ -39,38 +39,52 @@ import {
39
39
  import { evaluateExpression, evaluateOperand, isMutating } from "./expression.ts";
40
40
  import { readPath } from "./pointer.ts";
41
41
  import type {
42
- DynamicClass,
43
42
  JxContext,
44
43
  JxEventHandler,
44
+ JxHostScope,
45
45
  JxMount,
46
46
  JxMountOptions,
47
47
  JxPath,
48
48
  JxRenderOptions,
49
+ JxRowContext,
49
50
  JxScope,
51
+ JxScopeFunction,
52
+ JxScopeValue,
50
53
  } from "./types.ts";
51
54
  import {
52
55
  bodyReturnsValue,
53
56
  hasStructuredBody,
57
+ isBoolean,
54
58
  isExpressionDef,
59
+ isFunction,
55
60
  isFunctionDef,
56
61
  isJsonObject,
57
62
  isMappedArray,
58
63
  isNamedFormulaDef,
64
+ isNestedStyle,
65
+ isNonNullObject,
66
+ isNumber,
59
67
  isPrivateStateKey,
60
68
  isPrototypeDef,
61
69
  isRef as isRefValue,
62
70
  isServerFnDef,
71
+ isString,
63
72
  isTagExpression,
64
73
  isTemplateString,
65
74
  paramNames,
66
75
  } from "@jxsuite/schema/guards";
76
+ import type { TagNameSlot } from "@jxsuite/schema/guards";
67
77
  import { formatSrcset, parseSrcset } from "@jxsuite/schema/asset-paths";
68
78
  import { runStatements } from "./statements.ts";
69
79
  import { readCookie, serializeCookie } from "./cookie.ts";
70
80
  import type {
81
+ CemParameter,
82
+ JsonValue,
71
83
  JxAttributeValue,
84
+ JxClassCtorDef,
72
85
  JxClassDef,
73
86
  JxDocument,
87
+ JxDocumentValue,
74
88
  JxElement,
75
89
  JxFunctionDef,
76
90
  JxHeadEntry,
@@ -106,8 +120,8 @@ let _schemaVersionWarned = false;
106
120
  * runtime supports. A relative or non-hosted `$schema` (local dev schema paths) is ignored. This is
107
121
  * a diagnostic only — there is no document-format migration story yet (see spec §3.2 / §21.4).
108
122
  */
109
- function checkSchemaVersion($schema: unknown): void {
110
- if (_schemaVersionWarned || typeof $schema !== "string") {
123
+ function checkSchemaVersion($schema: JxDocumentValue | undefined): void {
124
+ if (_schemaVersionWarned || !isString($schema)) {
111
125
  return;
112
126
  }
113
127
  const match = $schema.match(/\/schema\/v(\d+)\b/);
@@ -127,7 +141,7 @@ export async function Jx(
127
141
  ) {
128
142
  const base = options?.base
129
143
  ? new URL(options.base, location.href).href
130
- : typeof source === "string"
144
+ : isString(source)
131
145
  ? new URL(source, location.href).href
132
146
  : location.href;
133
147
  const doc = await resolve(source);
@@ -139,18 +153,57 @@ export async function Jx(
139
153
  _rootMedia = doc.$media;
140
154
  }
141
155
 
142
- const mounted = await mount(doc, target, {
143
- base,
144
- ...(options?.onNodeCreated ? { onNodeCreated: options.onNodeCreated } : {}),
145
- ...(options?.signal ? { signal: options.signal } : {}),
146
- });
156
+ const mountOptions: JxMountOptions = { base };
157
+ if (options?.onNodeCreated) {
158
+ mountOptions.onNodeCreated = options.onNodeCreated;
159
+ }
160
+ if (options?.signal) {
161
+ mountOptions.signal = options.signal;
162
+ }
163
+ const mounted = await mount(doc, target, mountOptions);
147
164
  return mounted.scope;
148
165
  }
149
166
 
167
+ /**
168
+ * A lifecycle hook a document's state supplies (`onMount`, `onUnmount`, `onAdopted`) as the runtime
169
+ * calls it: with the scope, plus the host element for a defined element's `onMount`, which also
170
+ * receives the host as `this`.
171
+ */
172
+ type ScopeHook = (
173
+ this: HTMLElement | JxScope | undefined,
174
+ scope: JxScope,
175
+ host?: HTMLElement,
176
+ ) => void;
177
+
178
+ /**
179
+ * Whether a scope member can be called as a {@link ScopeHook}. Being a function is the whole
180
+ * contract: JavaScript checks neither the arguments a call passes nor what it returns, so every
181
+ * function accepts this call, and the runtime discards whatever it returns.
182
+ */
183
+ function isScopeHook(value: unknown): value is ScopeHook {
184
+ return isFunction(value);
185
+ }
186
+
150
187
  /** Host-scope keys the runtime owns: `$media`, `$map`, the `$`-locals and `#`-private entries. */
151
188
  const HOST_SCOPE_RESERVED = /^[$#]/;
152
189
 
153
- function assertHostScope(scope: Record<string, unknown>): void {
190
+ /**
191
+ * A host's scope as the runtime merges it: the host's own record, typed as the {@link JxScope} it
192
+ * joins.
193
+ *
194
+ * @param {S} scope - The host's scope, held to `JxScopeValue` member by member by its type
195
+ * @returns {JxScope}
196
+ */
197
+ function hostScopeOf<S extends JxHostScope<S>>(scope: S): JxScope {
198
+ /* SAFETY: `JxHostScope<S>` has already held every member of `S` to `JxScopeValue`, recursively
199
+ through arrays, records and function results. What TypeScript cannot conclude from that is an
200
+ index signature, which an interface never has implicitly, so a host's own interface is not
201
+ assignable to `JxScope` however its members are typed. Nothing is copied or converted: this is
202
+ the host's record, read by member name like every other scope. */
203
+ return scope as JxScope;
204
+ }
205
+
206
+ function assertHostScope(scope: JxScope): void {
154
207
  for (const key of Object.keys(scope)) {
155
208
  if (HOST_SCOPE_RESERVED.test(key)) {
156
209
  throw new TypeError(
@@ -176,26 +229,28 @@ function assertHostScope(scope: Record<string, unknown>): void {
176
229
  * @returns {Promise<JxMount>}
177
230
  * @docs extending/embedding/runtime-host
178
231
  */
179
- export async function mount(
232
+ export async function mount<S extends JxHostScope<S> = JxScope>(
180
233
  doc: JxDocument,
181
234
  target: ParentNode,
182
- options: JxMountOptions = {},
235
+ options: JxMountOptions<S> = {},
183
236
  ): Promise<JxMount> {
184
- const hostScope = options.scope ?? {};
237
+ const hostScope: JxScope = options.scope ? hostScopeOf(options.scope) : {};
185
238
  assertHostScope(hostScope);
186
239
  const base = options.base ? new URL(options.base, location.href).href : location.href;
187
240
  const ctx: JxContext = {
188
241
  base,
189
242
  media: options.media ?? doc.$media ?? null,
190
243
  root: null,
191
- ...(options.resolver ? { resolver: options.resolver } : {}),
192
- ...(options.skipServerFunctions === undefined
193
- ? {}
194
- : { skipServerFunctions: options.skipServerFunctions }),
195
- ...(options.skipAutoRequests === undefined
196
- ? {}
197
- : { skipAutoRequests: options.skipAutoRequests }),
198
244
  };
245
+ if (options.resolver) {
246
+ ctx.resolver = options.resolver;
247
+ }
248
+ if (options.skipServerFunctions !== undefined) {
249
+ ctx.skipServerFunctions = options.skipServerFunctions;
250
+ }
251
+ if (options.skipAutoRequests !== undefined) {
252
+ ctx.skipAutoRequests = options.skipAutoRequests;
253
+ }
199
254
  checkSchemaVersion(doc.$schema);
200
255
 
201
256
  // Register custom elements declared in $elements (depth-first)
@@ -219,8 +274,10 @@ export async function mount(
219
274
  }
220
275
  options.onNodeCreated?.(el, path, def, state);
221
276
  },
222
- ...(options.onNodeMoved ? { onNodeMoved: options.onNodeMoved } : {}),
223
277
  };
278
+ if (options.onNodeMoved) {
279
+ renderOptions.onNodeMoved = options.onNodeMoved;
280
+ }
224
281
  const { result: root, stop } = runScoped(() => renderNode(doc, scope, renderOptions));
225
282
  ctx.root = root;
226
283
 
@@ -232,11 +289,18 @@ export async function mount(
232
289
  }
233
290
  disposed = true;
234
291
  options.signal?.removeEventListener("abort", dispose);
292
+ /* The root leaves the document BEFORE its effects stop (embedding.md §2.2). Stopping releases
293
+ each element's styles, and on a connected subtree every one of those inline-property and
294
+ `data-jx` writes schedules a style invalidation for a node that is leaving anyway: a
295
+ Publish tab switch spent 22 ms there, and a dialog's close forced a recalculation of the
296
+ subtree it was removing. It is also the order a custom element's own `onUnmount` sees,
297
+ which runs from `disconnectedCallback` (spec.md §16.4). */
298
+ root.remove();
235
299
  stop();
236
- if (mounted && typeof scope.onUnmount === "function") {
237
- (scope.onUnmount as (s: JxScope) => unknown)(scope);
300
+ // Called as a method, so a hook that reads `this` sees the scope, as it always has.
301
+ if (mounted && isScopeHook(scope.onUnmount)) {
302
+ scope.onUnmount(scope);
238
303
  }
239
- root.remove();
240
304
  };
241
305
 
242
306
  if (options.signal?.aborted) {
@@ -246,8 +310,8 @@ export async function mount(
246
310
  options.signal?.addEventListener("abort", dispose, { once: true });
247
311
  target.append(root);
248
312
  mounted = true;
249
- if (typeof scope.onMount === "function") {
250
- (scope.onMount as (s: JxScope) => unknown)(scope);
313
+ if (isScopeHook(scope.onMount)) {
314
+ scope.onMount(scope);
251
315
  }
252
316
  }
253
317
  return { scope, root, elements, dispose };
@@ -279,8 +343,20 @@ export function preloadDocument(url: string, doc: JxDocument): void {
279
343
  }
280
344
  }
281
345
 
346
+ /**
347
+ * A response's Content-Type, or `""` when it carries no headers at all. A response we have no
348
+ * EVIDENCE about is one we proceed with rather than guess. Every real `Response` has headers; a
349
+ * host's or a test's `fetch` stand-in may not, which is what the parameter type admits.
350
+ *
351
+ * @param res The response, or a stand-in for one
352
+ * @returns {string}
353
+ */
354
+ function responseContentType(res: Partial<Pick<Response, "headers">>): string {
355
+ return res.headers?.get("Content-Type") ?? "";
356
+ }
357
+
282
358
  export async function resolve(source: string | JxDocument, ctx?: JxContext): Promise<JxDocument> {
283
- if (typeof source !== "string") {
359
+ if (!isString(source)) {
284
360
  return source;
285
361
  }
286
362
  // A mount's resolver answers for THIS mount and its hit never enters the shared cache.
@@ -305,16 +381,16 @@ export async function resolve(source: string | JxDocument, ctx?: JxContext): Pro
305
381
  * Checked on the content type rather than by sniffing the body: a host that says `text/html`
306
382
  * has told us plainly, and saying so costs nothing.
307
383
  */
308
- // Optional-chained: a response that carries no headers at all is one we have no EVIDENCE
309
- // About, and guessing is worse than proceeding. Every real `Response` has them.
310
- const contentType = res.headers?.get("Content-Type") ?? "";
384
+ const contentType = responseContentType(res);
311
385
  if (contentType.includes("text/html")) {
312
386
  throw new Error(
313
387
  `Jx: ${source} returned HTML, not a document — the host answered a missing file with its ` +
314
388
  `app shell (a single-page fallback), so this path is not served.`,
315
389
  );
316
390
  }
317
- // Trust boundary: fetched sources are Jx documents by contract.
391
+ /* SAFETY: trust boundary. `resolve` fetches only URLs that name a Jx document (a `Jx()` source,
392
+ an `$elements` entry, a `$switch` case), and the host answered 2xx with a non-HTML body, so
393
+ the JSON is that document; the runtime does not schema-validate documents by design. */
318
394
  return (await res.json()) as JxDocument;
319
395
  });
320
396
  _resolveCache.set(source, p);
@@ -378,11 +454,6 @@ function resolveProxyPath(path: string): string {
378
454
  return _resolveToken ? `${path}?token=${encodeURIComponent(_resolveToken)}` : path;
379
455
  }
380
456
 
381
- /** @deprecated No longer needed — ContentCollection/ContentEntry resolve via generic class path */
382
- export function setSkipContentResolution(_v: boolean) {
383
- // No-op retained for API compatibility
384
- }
385
-
386
457
  /**
387
458
  * Observe runtime-created reactive values: runs `fn` immediately inside a reactive effect and
388
459
  * re-runs it whenever a tracked value changes; returns a disposer. Dep tracking in @vue/reactivity
@@ -401,6 +472,12 @@ export function observeScope(fn: () => void): () => void {
401
472
  return () => scope.stop();
402
473
  }
403
474
 
475
+ /** What {@link runScoped} hands back: the callback's result, and the disposer for its effects. */
476
+ export interface ScopedRun<T> {
477
+ result: T;
478
+ stop: () => void;
479
+ }
480
+
404
481
  /**
405
482
  * Run `fn` inside a detached reactive scope of THIS module's reactivity instance, returning its
406
483
  * result plus a disposer that stops every effect `fn` created. Callers that render via
@@ -410,11 +487,13 @@ export function observeScope(fn: () => void): () => void {
410
487
  * its stop() silently leaks every binding effect of the superseded render.
411
488
  *
412
489
  * @param {() => T} fn - Work that may create reactive effects (typically a renderNode call).
413
- * @returns {{ result: T; stop: () => void }} The callback result and the scope disposer.
490
+ * @returns {ScopedRun<T>} The callback result and the scope disposer.
414
491
  */
415
- export function runScoped<T>(fn: () => T): { result: T; stop: () => void } {
492
+ export function runScoped<T>(fn: () => T): ScopedRun<T> {
416
493
  const scope = effectScope(true);
417
494
  try {
495
+ /* SAFETY: `run` answers undefined only for a scope that was already stopped, and this one was
496
+ created on the line above, so it is active and `run` returns what `fn` returned. */
418
497
  const result = scope.run(fn) as T;
419
498
  return { result, stop: () => scope.stop() };
420
499
  } catch (error) {
@@ -438,14 +517,14 @@ export function setCanvasViewportTranspose(on: boolean) {
438
517
  }
439
518
 
440
519
  const VIEWPORT_UNIT_RE = /(-?\d*\.?\d+)(?:s|l|d)?v(h|w|min|max|i|b)\b/gi;
441
- const VIEWPORT_UNIT_MAP: Record<string, string> = {
442
- b: "cqb",
443
- h: "cqh",
444
- i: "cqi",
445
- max: "cqmax",
446
- min: "cqmin",
447
- w: "cqw",
448
- };
520
+ const VIEWPORT_UNIT_MAP = new Map([
521
+ ["b", "cqb"],
522
+ ["h", "cqh"],
523
+ ["i", "cqi"],
524
+ ["max", "cqmax"],
525
+ ["min", "cqmin"],
526
+ ["w", "cqw"],
527
+ ]);
449
528
 
450
529
  /**
451
530
  * Transpose CSS viewport units → container-query units in a value string, but only when the
@@ -458,7 +537,8 @@ export function transposeCanvasUnits(value: string): string {
458
537
  }
459
538
  return value.replace(
460
539
  VIEWPORT_UNIT_RE,
461
- (_m, num: string, dim: string) => `${num}${VIEWPORT_UNIT_MAP[dim.toLowerCase()] ?? `cq${dim}`}`,
540
+ (_m, num: string, dim: string) =>
541
+ `${num}${VIEWPORT_UNIT_MAP.get(dim.toLowerCase()) ?? `cq${dim}`}`,
462
542
  );
463
543
  }
464
544
 
@@ -657,15 +737,15 @@ function canvasAssetValue(tagName: string, key: string, value: string): string {
657
737
  const name = key.toLowerCase();
658
738
  if (SRCSET_KEYS.has(name)) {
659
739
  const candidates = parseSrcset(value);
660
- let changed = false;
661
740
  const next = candidates.map((candidate) => {
662
741
  const resolved = _canvasAssetResolver?.(candidate.url, name) ?? null;
663
742
  if (resolved === null || resolved === candidate.url) {
664
743
  return candidate;
665
744
  }
666
- changed = true;
667
745
  return { descriptor: candidate.descriptor, url: resolved };
668
746
  });
747
+ // A rewritten candidate is a new object, so identity says whether anything changed.
748
+ const changed = next.some((candidate, index) => candidate !== candidates[index]);
669
749
  return changed ? formatSrcset(next) : value;
670
750
  }
671
751
  if (!isAssetKey(tagName, name)) {
@@ -727,18 +807,22 @@ export function setStampPropBindings(on: boolean) {
727
807
  const PURE_STATE_TEMPLATE = /^\s*\$\{\s*state\.(\w+)\s*\}\s*$/;
728
808
 
729
809
  /** The state key of an invertible `textContent` binding, or null (mixed template, deep path). */
730
- function boundPropKey(key: string, val: unknown, state: JxScope): string | null {
810
+ function boundPropKey(
811
+ key: string,
812
+ val: JxDocumentValue | undefined,
813
+ state: JxScope,
814
+ ): string | null {
731
815
  if (!_stampPropBindings || key !== "textContent") {
732
816
  return null;
733
817
  }
734
818
  let sub: string | null = null;
735
- if (isRefObj(val)) {
819
+ if (isRefValue(val)) {
736
820
  if (!val.$ref.startsWith("#/state/")) {
737
821
  return null;
738
822
  }
739
823
  const rest = val.$ref.slice("#/state/".length);
740
824
  sub = rest && !rest.includes("/") ? rest : null;
741
- } else if (typeof val === "string") {
825
+ } else if (isString(val)) {
742
826
  sub = PURE_STATE_TEMPLATE.exec(val)?.[1] ?? null;
743
827
  }
744
828
  if (!sub) {
@@ -748,11 +832,11 @@ function boundPropKey(key: string, val: unknown, state: JxScope): string | null
748
832
  // In the raw scope (auto-unwrapped by the reactive proxy), and function/object entries (handlers,
749
833
  // $prototype instances) are not prop-overridable either — an instance $props write against any of
750
834
  // Them would clobber the definition's behavior, so their bindings are never marked editable.
751
- const stored = (toRaw(state) as Record<string, unknown>)[sub];
752
- if (isRef(stored) || typeof stored === "function") {
835
+ const stored = toRaw(state)[sub];
836
+ if (isRef(stored) || isFunction(stored)) {
753
837
  return null;
754
838
  }
755
- if (typeof stored === "object" && stored !== null) {
839
+ if (isNonNullObject(stored)) {
756
840
  return null;
757
841
  }
758
842
  return sub;
@@ -790,7 +874,7 @@ export async function buildScope(
790
874
  configurable: true,
791
875
  enumerable: true,
792
876
  get: () => parentScope[key],
793
- set: (value: unknown) => {
877
+ set: (value: JxScopeValue) => {
794
878
  parentScope[key] = value;
795
879
  },
796
880
  });
@@ -819,7 +903,7 @@ export async function buildScope(
819
903
  // First pass: collect naked values, expanded defaults, plain objects
820
904
  for (const [key, def] of Object.entries(defs)) {
821
905
  // 1. String value
822
- if (typeof def === "string") {
906
+ if (isString(def)) {
823
907
  if (!def.includes("${")) {
824
908
  raw[key] = def;
825
909
  } // Shape 1: naked string
@@ -827,7 +911,7 @@ export async function buildScope(
827
911
  }
828
912
 
829
913
  // 2. Number, boolean, null
830
- if (typeof def === "number" || typeof def === "boolean" || def === null) {
914
+ if (isNumber(def) || isBoolean(def) || def === null) {
831
915
  raw[key] = def;
832
916
  continue;
833
917
  }
@@ -839,7 +923,7 @@ export async function buildScope(
839
923
  }
840
924
 
841
925
  // 4. Object
842
- if (typeof def === "object") {
926
+ if (isNonNullObject(def)) {
843
927
  if (def.$prototype) {
844
928
  continue;
845
929
  } // Handled in later passes
@@ -863,10 +947,10 @@ export async function buildScope(
863
947
  // Wrap in Vue reactive proxy — deep reactivity from this point on
864
948
  const state = reactive(raw);
865
949
 
866
- // Second pass: template strings → computed
950
+ // Second pass: template strings → computed, a single expression keeping its own type (§5.3)
867
951
  for (const [key, def] of Object.entries(defs)) {
868
- if (typeof def === "string" && def.includes("${")) {
869
- state[key] = computed(() => evaluateTemplate(def, state));
952
+ if (isString(def) && def.includes("${")) {
953
+ state[key] = computed(() => evaluateValueTemplate(def, state));
870
954
  }
871
955
  }
872
956
 
@@ -876,21 +960,9 @@ export async function buildScope(
876
960
  const node = def.$expression;
877
961
  if (isNamedFormulaDef(def)) {
878
962
  // A named formula: callable with positional args mapped onto its declared parameters.
879
- const params = def.parameters as (string | { name?: string; default?: unknown })[];
880
- state[key] = (...argValues: unknown[]) => {
881
- const args: Record<string, unknown> = {};
882
- for (const [i, p] of params.entries()) {
883
- const name = typeof p === "string" ? p : (p?.name ?? "");
884
- if (!name) {
885
- continue;
886
- }
887
- args[name] =
888
- argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
889
- ? p.default
890
- : argValues[i];
891
- }
892
- return evaluateExpression(node, state, null, { args });
893
- };
963
+ const params = def.parameters;
964
+ state[key] = (...argValues: JxScopeValue[]) =>
965
+ evaluateExpression(node, state, null, { args: bindPositionalArgs(params, argValues) });
894
966
  } else if (isMutating(node.operator)) {
895
967
  const handler: JxEventHandler = (s, event) => evaluateExpression(node, s, event);
896
968
  state[key] = handler;
@@ -907,23 +979,16 @@ export async function buildScope(
907
979
  // $args names (invoked via the call operator); without → an event handler.
908
980
  const { body } = def;
909
981
  if (Array.isArray(def.parameters) && def.parameters.length > 0) {
910
- const params = def.parameters as (string | { name?: string; default?: unknown })[];
911
- state[key] = (...argValues: unknown[]) => {
912
- const args: Record<string, unknown> = {};
913
- for (const [i, p] of params.entries()) {
914
- const name = typeof p === "string" ? p : (p?.name ?? "");
915
- if (!name) {
916
- continue;
917
- }
918
- args[name] =
919
- argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
920
- ? p.default
921
- : argValues[i];
922
- }
923
- return runStatements(body, state, null, { args, target: dispatchRoot });
924
- };
982
+ const params = def.parameters;
983
+ state[key] = (...argValues: JxScopeValue[]) =>
984
+ runStatements(body, state, null, {
985
+ args: bindPositionalArgs(params, argValues),
986
+ target: dispatchRoot,
987
+ });
925
988
  } else {
926
- const handler: JxEventHandler = (s, event) => {
989
+ /* The event is optional in fact: a `call` operator or a host invokes a handler with the
990
+ scope alone, and the body then runs with no event rather than an undefined one. */
991
+ const handler = (s: JxScope, event?: Event | null): void => {
927
992
  void runStatements(body, s, event ?? null, { target: dispatchRoot });
928
993
  };
929
994
  state[key] = handler;
@@ -960,14 +1025,42 @@ export async function buildScope(
960
1025
  return state;
961
1026
  }
962
1027
 
1028
+ /**
1029
+ * Map a call's positional arguments onto a definition's declared parameter names, as `$args/<name>`
1030
+ * refs read them. A parameter given as an object supplies its `default` for a position the call
1031
+ * left undefined. Parameters come from a document that is not schema-validated at runtime, so an
1032
+ * entry that names nothing (null, or an object without a `name`) binds nothing.
1033
+ *
1034
+ * @param parameters The declared parameters, in call order
1035
+ * @param argValues The values the call passed, in the same order
1036
+ * @returns {JxScope} Name to value, for the body's `$args` refs
1037
+ */
1038
+ function bindPositionalArgs(
1039
+ parameters: readonly (string | CemParameter | null)[],
1040
+ argValues: readonly JxScopeValue[],
1041
+ ): JxScope {
1042
+ const args: JxScope = {};
1043
+ for (const [i, p] of parameters.entries()) {
1044
+ const name = isString(p) ? p : (p?.name ?? "");
1045
+ if (!name) {
1046
+ continue;
1047
+ }
1048
+ args[name] =
1049
+ argValues[i] === undefined && p !== null && !isString(p) && "default" in p
1050
+ ? p.default
1051
+ : argValues[i];
1052
+ }
1053
+ return args;
1054
+ }
1055
+
963
1056
  /**
964
1057
  * Check whether an object contains any JSON Schema keywords. Used to discriminate Shape 2b (pure
965
1058
  * type definition) from Shape 1 (naked object).
966
1059
  *
967
- * @param {JxScope} obj
1060
+ * @param obj A state entry object, as the document wrote it
968
1061
  * @returns {boolean}
969
1062
  */
970
- function hasSchemaKeywords(obj: object) {
1063
+ function hasSchemaKeywords(obj: Readonly<Record<string, JxDocumentValue | undefined>>) {
971
1064
  for (const k of Object.keys(obj)) {
972
1065
  if (SCHEMA_KEYWORDS.has(k)) {
973
1066
  return true;
@@ -977,23 +1070,151 @@ function hasSchemaKeywords(obj: object) {
977
1070
  }
978
1071
  export { hasSchemaKeywords };
979
1072
 
1073
+ /**
1074
+ * The names a template may read without a `state.` prefix, in the order {@link templateArgs}
1075
+ * supplies them.
1076
+ *
1077
+ * `$site` and `$page` are here for the reason `evaluateStaticTemplate` in the compiler binds them:
1078
+ * every example in site-architecture.md §8.2 and §13.4 writes the bare `${$page.locale}`, and
1079
+ * `injectContext` (or the Studio canvas) puts the two on the document's state, not in the
1080
+ * template's lexical scope. Without them the live runtime threw `ReferenceError: $page is not
1081
+ * defined` on a template the build renders, so a page that prerendered correctly drew nothing where
1082
+ * it hydrated or rendered live.
1083
+ */
1084
+ const TEMPLATE_PARAMS = ["state", "$map", "item", "index", "$site", "$page"] as const;
1085
+
1086
+ /** The values {@link TEMPLATE_PARAMS} name, read from one scope. */
1087
+ function templateArgs(state: JxScope): Parameters<TemplateFn<unknown>> {
1088
+ const $map = rowContext(state);
1089
+ return [state, $map, $map?.item, $map?.index, state.$site, state.$page];
1090
+ }
1091
+
980
1092
  /**
981
1093
  * Evaluate a template string in the context of state and optional $map. Templates use
982
- * `state.varName` and `$map.item` syntax.
1094
+ * `state.varName`, `$map.item` (or `item`) and the page context `$page` / `$site`.
983
1095
  *
984
1096
  * @param {string} str
985
1097
  * @param {JxScope} state
986
1098
  * @returns {string}
987
1099
  */
988
1100
  function evaluateTemplate(str: string, state: JxScope): string {
989
- const $map = state?.$map as { item?: unknown; index?: number } | undefined;
990
- const fn = new Function("state", "$map", "item", "index", `return \`${str}\``) as (
991
- state: JxScope,
992
- $map: unknown,
993
- item: unknown,
994
- index: number | undefined,
995
- ) => string;
996
- return fn(state, $map, $map?.item, $map?.index);
1101
+ let fn = textTemplates.get(str);
1102
+ if (!fn) {
1103
+ /* SAFETY: `new Function` compiles exactly the TEMPLATE_PARAMS, and its body returns a template
1104
+ literal, so every call answers a string (or throws, which the caller sees). */
1105
+ fn = new Function(...TEMPLATE_PARAMS, `return \`${str}\``) as TemplateFn<string>;
1106
+ remember(textTemplates, str, fn);
1107
+ }
1108
+ return fn(...templateArgs(state));
1109
+ }
1110
+
1111
+ /**
1112
+ * Compiled templates, by source. A compiled template closes over nothing — everything it reads
1113
+ * arrives as one of its TEMPLATE_PARAMS — so one function per distinct source serves every binding
1114
+ * that spells it. Compiling is the expensive half: a `$map` row re-bound to a fresh item re-runs
1115
+ * each of its template effects, and a 40-row window re-binding on every scroll step spent seconds
1116
+ * in `new Function` alone. A source that fails to compile throws before it is stored, so it throws
1117
+ * again on the next evaluation exactly as it did before the cache existed.
1118
+ */
1119
+ const textTemplates = new Map<string, TemplateFn<string>>();
1120
+
1121
+ /** {@link textTemplates}, for single-expression templates compiled to keep their own type. */
1122
+ const valueTemplates = new Map<string, TemplateFn<JxScopeValue>>();
1123
+
1124
+ /**
1125
+ * How many sources a template cache holds before it starts over. Editing mints a new source per
1126
+ * keystroke, so the cache is bounded; clearing it wholesale costs one recompile per live binding,
1127
+ * which is what every evaluation cost before there was a cache.
1128
+ */
1129
+ const TEMPLATE_CACHE_MAX = 4096;
1130
+
1131
+ /**
1132
+ * Store a compiled template, emptying the cache first when it is full.
1133
+ *
1134
+ * @param {Map<string, F>} cache
1135
+ * @param {string} source
1136
+ * @param {F} fn
1137
+ */
1138
+ function remember<F>(cache: Map<string, F>, source: string, fn: F): void {
1139
+ if (cache.size >= TEMPLATE_CACHE_MAX) {
1140
+ cache.clear();
1141
+ }
1142
+ cache.set(source, fn);
1143
+ }
1144
+
1145
+ /** Template sources already reported as throwing, so a binding re-run per keystroke warns once. */
1146
+ const reportedTemplates = new Set<string>();
1147
+
1148
+ /** Who hears a throwing template binding, when a host asked to; otherwise the console does. */
1149
+ let _templateErrorHandler: ((error: Error, source: string) => void) | null = null;
1150
+
1151
+ /**
1152
+ * Route throwing template bindings to `handler` instead of a console warning, or back with `null`.
1153
+ *
1154
+ * A binding survives its expression throwing (§6.1), which is right for a page and wrong for a host
1155
+ * whose question IS whether the document renders: Studio's render critic must still refuse a page
1156
+ * whose `${nonExistent}` can never evaluate. Every throw is delivered, not only the first.
1157
+ *
1158
+ * @param {((error: Error, source: string) => void) | null} handler
1159
+ */
1160
+ export function setTemplateErrorHandler(
1161
+ handler: ((error: Error, source: string) => void) | null,
1162
+ ): void {
1163
+ _templateErrorHandler = handler;
1164
+ }
1165
+
1166
+ /**
1167
+ * Run a template binding as an effect that survives its expression throwing.
1168
+ *
1169
+ * A binding is live: `${state.posts.length}` over a collection that resolves asynchronously reads
1170
+ * `null` first and an array a moment later. Unguarded, the first run's TypeError escaped `effect()`
1171
+ * — which stops the effect before rethrowing — so the binding never re-ran when the data arrived,
1172
+ * and the throw unwound the whole `renderNode` above it: one early read blanked the page. Caught
1173
+ * INSIDE the effect, every dependency read before the throw stays tracked, so the binding re-runs
1174
+ * on the change that makes it valid. Until then it shows `fallback`'s answer (nothing written, by
1175
+ * default), and the error is reported once per source.
1176
+ *
1177
+ * @param {string} source - The template, for the report
1178
+ * @param {() => void} run - Evaluates the template and writes it
1179
+ * @param {() => void} [fallback] - What a throwing evaluation writes instead
1180
+ */
1181
+ function bindTemplate(source: string, run: () => void, fallback?: () => void): void {
1182
+ effect(() => {
1183
+ try {
1184
+ run();
1185
+ } catch (error) {
1186
+ if (_templateErrorHandler) {
1187
+ _templateErrorHandler(error instanceof Error ? error : new Error(String(error)), source);
1188
+ } else if (!reportedTemplates.has(source)) {
1189
+ reportedTemplates.add(source);
1190
+ console.warn(`jx: template ${JSON.stringify(source)} threw:`, error);
1191
+ }
1192
+ fallback?.();
1193
+ }
1194
+ });
1195
+ }
1196
+
1197
+ /** A compiled template: the scope, the row context, its item and index, and the page context. */
1198
+ type TemplateFn<R> = (
1199
+ state: JxScope,
1200
+ $map: JxRowContext | undefined,
1201
+ item: JxRowContext["item"],
1202
+ index: JxRowContext["index"] | undefined,
1203
+ $site: JxScopeValue,
1204
+ $page: JxScopeValue,
1205
+ ) => R;
1206
+
1207
+ /**
1208
+ * The row context a scope carries, or undefined outside a mapped row.
1209
+ *
1210
+ * @param {JxScope} state
1211
+ * @returns {JxRowContext | undefined}
1212
+ */
1213
+ function rowContext(state: JxScope): JxRowContext | undefined {
1214
+ /* SAFETY: `$map` is runtime vocabulary. A host scope may not supply it (`assertHostScope`), a
1215
+ node never binds it as a `$`-local (it is in RESERVED_KEYS), and the one writer is
1216
+ `renderMappedArrayInto`, which binds the `{ index, item }` row record; elsewhere it is absent. */
1217
+ return state.$map as JxRowContext | undefined;
997
1218
  }
998
1219
 
999
1220
  /**
@@ -1034,50 +1255,137 @@ export function isSingleExpression(str: string): boolean {
1034
1255
  }
1035
1256
 
1036
1257
  /**
1037
- * Evaluate a template for an ATTRIBUTE, keeping the expression's own type.
1038
- *
1039
- * `evaluateTemplate` interpolates into a string, which is what text wants and what an attribute
1040
- * cannot afford: `"${state.expanded}"` has to arrive as the boolean `false`, or `booleanAttrValue`
1041
- * never sees a boolean, `open="false"` is written, and HTML reads that as open. Only a single
1042
- * expression can carry a type at all — `"a ${b}"` is text by construction — which is the same line
1258
+ * Evaluate a template as a VALUE — a state entry (Shape 3) or an attribute — keeping the
1259
+ * expression's own type.
1260
+ *
1261
+ * `evaluateTemplate` interpolates into a string, which is what text wants and what a value cannot
1262
+ * afford: `"${state.expanded}"` has to arrive as the boolean `false`, or `booleanAttrValue` never
1263
+ * sees a boolean, `open="false"` is written, and HTML reads that as open. A state entry is the same
1264
+ * case one step removed: `"isBrand": "${state.name === 'github'}"` held the string `"false"`, which
1265
+ * is truthy, so every `state.isBrand ? … : …` took the wrong branch in Studio and in a hydrated
1266
+ * component while the prerendered page took the right one. Only a single expression can carry a
1267
+ * type at all — `"a ${b}"` is text by construction — which is the same line
1043
1268
  * `evaluateStaticTemplate` draws in the compiler, and drawing it identically is what keeps a
1044
1269
  * prerendered page and the same document rendered live in agreement.
1045
1270
  *
1046
1271
  * @param {string} str - Template source
1047
1272
  * @param {JxScope} state - Reactive scope
1048
- * @returns {unknown} The expression's value, or the interpolated string
1273
+ * @returns {JxScopeValue} The expression's value, or the interpolated string
1049
1274
  */
1050
- function evaluateAttrTemplate(str: string, state: JxScope): unknown {
1275
+ function evaluateValueTemplate(str: string, state: JxScope): JxScopeValue {
1051
1276
  if (!isSingleExpression(str)) {
1052
1277
  return evaluateTemplate(str, state);
1053
1278
  }
1054
- const $map = state?.$map as { item?: unknown; index?: number } | undefined;
1055
- const fn = new Function("state", "$map", "item", "index", `return (${str.slice(2, -1)})`) as (
1056
- state: JxScope,
1057
- $map: unknown,
1058
- item: unknown,
1059
- index: number | undefined,
1060
- ) => unknown;
1061
- return fn(state, $map, $map?.item, $map?.index);
1279
+ let fn = valueTemplates.get(str);
1280
+ if (!fn) {
1281
+ /* SAFETY: `new Function` compiles exactly the TEMPLATE_PARAMS; the body returns one
1282
+ expression's value, which is whatever the scope it reads holds, so it is typed as a scope
1283
+ member and narrowed by the attribute writer. */
1284
+ fn = new Function(
1285
+ ...TEMPLATE_PARAMS,
1286
+ `return (${str.slice(2, -1)})`,
1287
+ ) as TemplateFn<JxScopeValue>;
1288
+ remember(valueTemplates, str, fn);
1289
+ }
1290
+ return fn(...templateArgs(state));
1062
1291
  }
1063
1292
 
1064
1293
  // ─── Step 2b: Function resolution (Shape 4) ─────────────────────────────────
1065
1294
 
1066
- /** Shape of a dynamically imported module: named exports plus an optional default. */
1067
- type ImportedModule = Record<string, unknown> & { default?: Record<string, unknown> };
1295
+ /**
1296
+ * A module namespace as the runtime reads it: exports looked up by name, then under `default` (the
1297
+ * shape a CommonJS module takes when imported). An export is whatever the module exported, so each
1298
+ * lookup checks what it found before using it.
1299
+ */
1300
+ interface ImportedModule {
1301
+ readonly [exportName: string]: JxScopeValue;
1302
+ readonly default?: ImportedModule;
1303
+ }
1304
+
1305
+ /**
1306
+ * Import a module namespace by specifier.
1307
+ *
1308
+ * Not `async`: the import's own promise is handed back, so awaiting this costs exactly the ticks
1309
+ * awaiting `import()` did, and scope construction keeps its order against its siblings'.
1310
+ *
1311
+ * @param {string} specifier
1312
+ * @returns {Promise<ImportedModule>}
1313
+ */
1314
+ /**
1315
+ * How the host serves the package an `npm:` specifier names, or null for none.
1316
+ *
1317
+ * `npm:@jxsuite/search/client` is a package specifier (spec.md §5.3, 4c), and the build bundles it
1318
+ * (`compiler/src/site/bundler.ts`). The interpreter had no answer for it: `new URL()` reads `npm:`
1319
+ * as a URL scheme, so it was handed to `import()` verbatim and the browser refused it as a
1320
+ * cross-origin request to a scheme it does not fetch. A host that renders documents live knows
1321
+ * where its packages are served (the dev server bundles `/node_modules/<package>/<subpath>` on
1322
+ * demand), so it says so here. With no resolver the bare specifier is imported, which an import map
1323
+ * can answer.
1324
+ */
1325
+ let _npmModuleResolver: ((specifier: string) => string) | null = null;
1068
1326
 
1069
- /** Minimal contract an externally-imported resolver class may implement. */
1070
- interface ExternalClassInstance {
1071
- value?: unknown;
1072
- resolve?: () => unknown;
1073
- subscribe?: (cb: (newVal: unknown) => void) => void;
1327
+ /**
1328
+ * Install (or with `null`, remove) the host's resolver for `npm:` specifiers.
1329
+ *
1330
+ * @param {((specifier: string) => string) | null} resolver - Package specifier → importable URL
1331
+ */
1332
+ export function setNpmModuleResolver(resolver: ((specifier: string) => string) | null): void {
1333
+ _npmModuleResolver = resolver;
1334
+ }
1335
+
1336
+ /** The scheme a package specifier is written with. */
1337
+ const NPM_SCHEME = "npm:";
1338
+
1339
+ /**
1340
+ * Import a module a document names by `$src`: an `npm:` package through the host's resolver, and
1341
+ * anything else from the document's base (then as written).
1342
+ *
1343
+ * @param {string} src - The `$src` as the document spells it
1344
+ * @param {string} [base] - The document's URL
1345
+ * @returns {Promise<ImportedModule>}
1346
+ */
1347
+ async function importSource(src: string, base?: string): Promise<ImportedModule> {
1348
+ if (src.startsWith(NPM_SCHEME)) {
1349
+ const specifier = src.slice(NPM_SCHEME.length);
1350
+ return importModule(_npmModuleResolver?.(specifier) ?? specifier);
1351
+ }
1352
+ if (base) {
1353
+ try {
1354
+ return await importModule(new URL(src, base).href);
1355
+ } catch {
1356
+ return importModule(src);
1357
+ }
1358
+ }
1359
+ return importModule(src);
1360
+ }
1361
+
1362
+ function importModule(specifier: string): Promise<ImportedModule> {
1363
+ /* SAFETY: trust boundary — a module exports whatever it exports. `import()` resolves to the
1364
+ module namespace object, whose keys are its export names, and every export is typed as the scope
1365
+ member it becomes: the runtime uses one only after `isFunction` has accepted it (a Function
1366
+ entry's `$src`, a server function, a `$implementation` class) or after testing it for
1367
+ presence, and both tests are defined for any value a module can export. `default` is only ever
1368
+ read through optional property access. */
1369
+ return import(specifier) as Promise<ImportedModule>;
1370
+ }
1371
+
1372
+ /**
1373
+ * An external class's instance as the runtime uses it (spec.md §12.3): a record read by member
1374
+ * name, three members of which are its contract with the runtime — a `value`, a `resolve()` that
1375
+ * produces one (possibly a promise), and a `subscribe()` that pushes later ones. Each is optional
1376
+ * and checked before it is used.
1377
+ */
1378
+ interface ExternalClassInstance extends JxScope {
1379
+ value?: JxScopeValue;
1380
+ resolve?: () => JxScopeValue | Promise<JxScopeValue>;
1381
+ subscribe?: (callback: (value: JxScopeValue) => void) => void;
1074
1382
  }
1075
1383
 
1076
1384
  /** Module cache for $src imports (shared with external class resolution). */
1077
1385
  const _moduleCache = new Map<string, ImportedModule>();
1078
1386
 
1079
1387
  /** A module the host will import on first use, keyed as the document spells its `$src`. */
1080
- type ModuleLoader = () => Promise<Record<string, unknown>>;
1388
+ type ModuleLoader = () => Promise<ImportedModule>;
1081
1389
 
1082
1390
  /**
1083
1391
  * Lazy registrations: a loader until it runs, then the in-flight load, so two entries resolving the
@@ -1109,21 +1417,18 @@ const _hostSeeded = new Set<string>();
1109
1417
  * specifier not registered here, the runtime calls the host's `/__jx_server__` proxy instead.
1110
1418
  *
1111
1419
  * @param {string} specifier - The `$src` value as written in the document
1112
- * @param {Record<string, unknown> | (() => Promise<Record<string, unknown>>)} mod - The imported
1113
- * module namespace, or a loader that imports it on first use
1420
+ * @param {ImportedModule | ModuleLoader} mod - The imported module namespace, or a loader that
1421
+ * imports it on first use
1114
1422
  */
1115
- export function preloadModule(
1116
- specifier: string,
1117
- mod: Record<string, unknown> | ModuleLoader,
1118
- ): void {
1423
+ export function preloadModule(specifier: string, mod: ImportedModule | ModuleLoader): void {
1119
1424
  _hostSeeded.add(specifier);
1120
- if (typeof mod === "function") {
1425
+ if (isFunction(mod)) {
1121
1426
  _moduleCache.delete(specifier);
1122
1427
  _moduleLoaders.set(specifier, mod);
1123
1428
  return;
1124
1429
  }
1125
1430
  _moduleLoaders.delete(specifier);
1126
- _moduleCache.set(specifier, mod as ImportedModule);
1431
+ _moduleCache.set(specifier, mod);
1127
1432
  }
1128
1433
 
1129
1434
  /**
@@ -1148,28 +1453,37 @@ function seededModule(src: string): ImportedModule | Promise<ImportedModule> | u
1148
1453
  if (!pending) {
1149
1454
  return undefined;
1150
1455
  }
1151
- if (typeof pending !== "function") {
1456
+ if (!isFunction(pending)) {
1152
1457
  return pending;
1153
1458
  }
1154
- /* `Promise.resolve` so a thenable from a host's loader is still a Promise the call sites can
1155
- tell from a namespace. */
1156
- const load = Promise.resolve(pending()).then(
1157
- (mod) => {
1158
- _moduleCache.set(src, mod as ImportedModule);
1459
+ /* Started here, outside the async body, so a loader that throws synchronously still throws to
1460
+ the caller. The async body turns a thenable from a host's loader into a Promise the call sites
1461
+ can tell from a namespace. */
1462
+ const started = pending();
1463
+ const load = (async () => {
1464
+ try {
1465
+ const mod = await started;
1466
+ _moduleCache.set(src, mod);
1159
1467
  _moduleLoaders.delete(src);
1160
- return mod as ImportedModule;
1161
- },
1162
- (error: unknown) => {
1468
+ return mod;
1469
+ } catch (error) {
1163
1470
  /* A failed load — a chunk the network dropped — puts the loader back, so the next document to
1164
1471
  name the specifier tries again rather than inheriting this rejection for good. */
1165
1472
  _moduleLoaders.set(src, pending);
1166
1473
  throw error;
1167
- },
1168
- );
1474
+ }
1475
+ })();
1169
1476
  _moduleLoaders.set(src, load);
1170
1477
  return load;
1171
1478
  }
1172
1479
 
1480
+ /**
1481
+ * A Function entry's implementation as the runtime calls it — a compiled `body` or a `$src` export
1482
+ * — with the scope as its first argument. JavaScript checks none of the arguments, and what the
1483
+ * call returns is bound into the scope like any other member.
1484
+ */
1485
+ type ScopeFunction = (...args: JxScopeValue[]) => JxScopeValue;
1486
+
1173
1487
  /**
1174
1488
  * Resolve a $prototype: "Function" entry into a function or computed.
1175
1489
  *
@@ -1180,7 +1494,7 @@ function seededModule(src: string): ImportedModule | Promise<ImportedModule> | u
1180
1494
  * @param {JxScope} state - Reactive scope proxy
1181
1495
  * @param {string} key - Def key name
1182
1496
  * @param {string} [base] - Base URL for resolving $src imports
1183
- * @returns {Promise<unknown>}
1497
+ * @returns {Promise<JxScopeValue>}
1184
1498
  */
1185
1499
  async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string, base?: string) {
1186
1500
  if (def.body && def.$src) {
@@ -1196,11 +1510,13 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
1196
1510
  return noop;
1197
1511
  }
1198
1512
 
1199
- let fn: ((...args: unknown[]) => unknown) | undefined;
1513
+ let fn: ScopeFunction;
1200
1514
 
1201
- if (typeof def.body === "string") {
1515
+ if (isString(def.body)) {
1202
1516
  const params = resolveParamNames(def);
1203
- fn = new Function(...params, def.body) as (...args: unknown[]) => unknown;
1517
+ /* SAFETY: `new Function` returns an ordinary function of the named parameters, `state` first.
1518
+ A call never checks its arguments, and what the body returns is one more scope member. */
1519
+ fn = new Function(...params, def.body) as ScopeFunction;
1204
1520
  Object.defineProperty(fn, "name", {
1205
1521
  configurable: true,
1206
1522
  value: def.name ?? key,
@@ -1219,23 +1535,16 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
1219
1535
  } else if (seeded) {
1220
1536
  mod = seeded;
1221
1537
  } else {
1222
- if (base) {
1223
- const resolvedSrc = new URL(src, base).href;
1224
- try {
1225
- mod = (await import(resolvedSrc)) as ImportedModule;
1226
- } catch {
1227
- mod = (await import(src)) as ImportedModule;
1228
- }
1229
- } else {
1230
- mod = (await import(src)) as ImportedModule;
1231
- }
1538
+ mod = await importSource(src, base);
1232
1539
  _moduleCache.set(src, mod);
1233
1540
  }
1234
1541
  const candidate = mod[exportName] ?? mod.default?.[exportName];
1235
- if (typeof candidate !== "function") {
1542
+ if (!isFunction(candidate)) {
1236
1543
  throw new TypeError(`Jx: export "${exportName}" not found or not a function in "${src}"`);
1237
1544
  }
1238
- fn = candidate as (...args: unknown[]) => unknown;
1545
+ /* SAFETY: the guard proved a function, and a function accepts any call: the runtime passes the
1546
+ scope first, as a Function entry's contract says, and binds the result as a scope member. */
1547
+ fn = candidate as ScopeFunction;
1239
1548
  }
1240
1549
 
1241
1550
  // Detect computed: body contains a return statement, or $src function introspection.
@@ -1243,11 +1552,9 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
1243
1552
  const hasParams = (def.parameters ?? def.arguments ?? []).length > 0;
1244
1553
  let isComputed = false;
1245
1554
  if (!hasParams) {
1246
- if (typeof def.body === "string") {
1247
- isComputed = bodyReturnsValue(def.body);
1248
- } else if (fn) {
1249
- isComputed = fn.length <= 1 && bodyReturnsValue(fn.toString());
1250
- }
1555
+ isComputed = isString(def.body)
1556
+ ? bodyReturnsValue(def.body)
1557
+ : fn.length <= 1 && bodyReturnsValue(fn.toString());
1251
1558
  }
1252
1559
  if (isComputed) {
1253
1560
  return computed(() => fn(state));
@@ -1311,6 +1618,72 @@ export const RESERVED_KEYS = new Set([
1311
1618
  "observedAttributes",
1312
1619
  ]);
1313
1620
 
1621
+ /**
1622
+ * A child scope: reads fall through to `parent`, writes land on the child.
1623
+ *
1624
+ * @param {JxScope} parent
1625
+ * @returns {JxScope}
1626
+ */
1627
+ function inheritScope(parent: JxScope): JxScope {
1628
+ /* SAFETY: `Object.create` is typed `any`, but what it returns is a plain object whose prototype
1629
+ is `parent`, so it is keyed by string exactly as the scope it extends is. */
1630
+ return Object.create(parent) as JxScope;
1631
+ }
1632
+
1633
+ /**
1634
+ * The named breakpoints a scope carries, or none.
1635
+ *
1636
+ * @param {JxScope} state
1637
+ * @returns {Record<string, string>}
1638
+ */
1639
+ function scopeMedia(state: JxScope): Record<string, string> {
1640
+ /* SAFETY: `$media` is runtime vocabulary a host scope may not supply (`assertHostScope`); the one
1641
+ writer is `buildScope`, which stores a document's or the mount's `$media` map, a record of
1642
+ breakpoint name to media query. */
1643
+ return (state["$media"] as Record<string, string> | undefined) ?? {};
1644
+ }
1645
+
1646
+ /**
1647
+ * An element as the property bag a Jx key writes into, `el[key] = value`. Reading or writing a key
1648
+ * the element does not define is an ordinary expando, as it is in JavaScript.
1649
+ */
1650
+ type ElementProperties = HTMLElement & Record<string, JxScopeValue>;
1651
+
1652
+ /**
1653
+ * View an element as its property bag.
1654
+ *
1655
+ * @param {HTMLElement} el
1656
+ * @returns {ElementProperties}
1657
+ */
1658
+ function elementProperties(el: HTMLElement): ElementProperties {
1659
+ /* SAFETY: every element is an ordinary extensible object, so any string key can be read (a
1660
+ missing one is undefined) and written; the added index signature claims nothing more. */
1661
+ return el as ElementProperties;
1662
+ }
1663
+
1664
+ /**
1665
+ * The reactive state a connected Jx instance renders from, or null for any other element (and for
1666
+ * an instance that has not connected yet).
1667
+ *
1668
+ * @param {HTMLElement} el
1669
+ * @returns {JxScope | null}
1670
+ */
1671
+ function instanceStateOf(el: HTMLElement): JxScope | null {
1672
+ const state = elementProperties(el)._state;
1673
+ /* SAFETY: `_state` is written by one writer, the Jx element class's `connectedCallback`, which
1674
+ stores the scope `buildScope` answered; on any other element the expando is absent, and a
1675
+ non-object is refused below. */
1676
+ return isNonNullObject(state) ? (state as JxScope) : null;
1677
+ }
1678
+
1679
+ /**
1680
+ * Whether a scope member can be called as an event handler, `(scope, event)`. Being a function is
1681
+ * the whole contract, for the reason {@link isScopeHook} gives.
1682
+ */
1683
+ function isEventHandler(value: unknown): value is JxEventHandler {
1684
+ return isFunction(value);
1685
+ }
1686
+
1314
1687
  /**
1315
1688
  * Recursively render a Jx element definition into a DOM element.
1316
1689
  *
@@ -1356,12 +1729,18 @@ export function renderNode(
1356
1729
  const path = options?._path ?? [];
1357
1730
 
1358
1731
  // Text node children: bare strings/numbers/booleans produce DOM Text nodes
1359
- if (typeof def === "string" || typeof def === "number" || typeof def === "boolean") {
1732
+ if (isString(def) || isNumber(def) || isBoolean(def)) {
1360
1733
  const textNode = document.createTextNode(String(def));
1361
- if (typeof def === "string" && isTemplateString(def)) {
1362
- effect(() => {
1363
- textNode.textContent = evaluateTemplate(def, state);
1364
- });
1734
+ if (isTemplateString(def)) {
1735
+ bindTemplate(
1736
+ def,
1737
+ () => {
1738
+ textNode.textContent = evaluateTemplate(def, state);
1739
+ },
1740
+ () => {
1741
+ textNode.textContent = "";
1742
+ },
1743
+ );
1365
1744
  }
1366
1745
  return textNode;
1367
1746
  }
@@ -1371,9 +1750,9 @@ export function renderNode(
1371
1750
  for (const [key, val] of Object.entries(def)) {
1372
1751
  if (key.startsWith("$") && !RESERVED_KEYS.has(key)) {
1373
1752
  if (localState === state) {
1374
- localState = Object.create(state) as JxScope;
1753
+ localState = inheritScope(state);
1375
1754
  }
1376
- localState[key] = isRefObj(val) ? resolveRef(val.$ref, state) : val;
1755
+ localState[key] = isRefValue(val) ? resolveRef(val.$ref, state) : val;
1377
1756
  }
1378
1757
  }
1379
1758
 
@@ -1383,7 +1762,7 @@ export function renderNode(
1383
1762
  * the supported mechanism is $elements + a custom-element tag (see spec §13).
1384
1763
  */
1385
1764
  if ("$ref" in def && !def.tagName) {
1386
- const refTarget = String((def as { $ref?: unknown }).$ref ?? "");
1765
+ const refTarget = String(def.$ref ?? "");
1387
1766
  if (!warnedRefChildren.has(refTarget)) {
1388
1767
  warnedRefChildren.add(refTarget);
1389
1768
  console.warn(
@@ -1436,13 +1815,12 @@ export function renderNode(
1436
1815
  * DESCENDANTS are HTML, so the element and its children carry different namespaces.
1437
1816
  */
1438
1817
  const childNs = tagName === "foreignObject" ? null : ns;
1439
- /*
1440
- * A namespaced node comes back as Element; every apply* helper below is typed for HTMLElement
1441
- * and touches only members both share (style, dataset, setAttribute, append).
1442
- */
1443
- const el = (ns
1444
- ? document.createElementNS(ns, tagName)
1445
- : document.createElement(tagName)) as unknown as HTMLElement;
1818
+ /* SAFETY: a namespaced node is an Element, not an HTMLElement, but every apply* helper below
1819
+ touches only members SVG and MathML elements share with HTML ones (style, dataset,
1820
+ setAttribute, append), and `bindProperty` writes `className` as an attribute on them. */
1821
+ const el = ns
1822
+ ? (document.createElementNS(ns, tagName) as HTMLElement)
1823
+ : document.createElement(tagName);
1446
1824
 
1447
1825
  if (options?.onNodeCreated) {
1448
1826
  options.onNodeCreated(el, path, def, localState);
@@ -1455,12 +1833,7 @@ export function renderNode(
1455
1833
  if (def.style && tagName.includes("-")) {
1456
1834
  callSiteStyles.set(el, def.style);
1457
1835
  }
1458
- applyStyle(
1459
- el,
1460
- def.style ?? {},
1461
- (localState["$media"] as Record<string, string>) ?? {},
1462
- localState,
1463
- );
1836
+ applyStyle(el, def.style ?? {}, scopeMedia(localState), localState);
1464
1837
  applyAttributes(el, def.attributes ?? {}, localState);
1465
1838
 
1466
1839
  const kids = def.children;
@@ -1528,18 +1901,22 @@ function applyProperties(el: HTMLElement, def: JxElement, state: JxScope) {
1528
1901
  *
1529
1902
  * @param {HTMLElement} el
1530
1903
  * @param {string} key The `on`-prefixed key
1531
- * @param {unknown} val
1904
+ * @param {JxDocumentValue | undefined} val
1532
1905
  * @param {JxScope} state
1533
1906
  * @returns {boolean} Whether a listener was attached
1534
1907
  */
1535
- function bindHandler(el: HTMLElement, key: string, val: unknown, state: JxScope): boolean {
1908
+ function bindHandler(
1909
+ el: HTMLElement,
1910
+ key: string,
1911
+ val: JxDocumentValue | undefined,
1912
+ state: JxScope,
1913
+ ): boolean {
1536
1914
  const type = key.slice(2);
1537
1915
  const scope = state;
1538
- if (isRefObj(val)) {
1916
+ if (isRefValue(val)) {
1539
1917
  const handler = resolveRef(val.$ref, state);
1540
- if (typeof handler === "function") {
1541
- const handlerFn = handler as (s: JxScope, e: Event) => unknown;
1542
- el.addEventListener(type, (e) => handlerFn(scope, e));
1918
+ if (isEventHandler(handler)) {
1919
+ el.addEventListener(type, (e) => handler(scope, e));
1543
1920
  }
1544
1921
  return true;
1545
1922
  }
@@ -1550,9 +1927,11 @@ function bindHandler(el: HTMLElement, key: string, val: unknown, state: JxScope)
1550
1927
  });
1551
1928
  return true;
1552
1929
  }
1553
- if (isFunctionDef(val) && typeof val.body === "string") {
1930
+ if (isFunctionDef(val) && isString(val.body)) {
1554
1931
  const params = resolveParamNames(val);
1555
- const fn = new Function(...params, val.body) as (s: JxScope, e: Event) => unknown;
1932
+ /* SAFETY: `new Function` returns an ordinary function of the named parameters, `state` first;
1933
+ the listener calls it with the scope and the event, and discards what it returns. */
1934
+ const fn = new Function(...params, val.body) as JxEventHandler;
1556
1935
  el.addEventListener(type, (e) => fn(scope, e));
1557
1936
  return true;
1558
1937
  }
@@ -1585,11 +1964,16 @@ function bindDefinitionHandlers(host: HTMLElement, def: JxElement, state: JxScop
1585
1964
  /**
1586
1965
  * @param {HTMLElement} el
1587
1966
  * @param {string} key
1588
- * @param {unknown} val
1967
+ * @param {JxDocumentValue | undefined} val
1589
1968
  * @param {JxScope} state
1590
1969
  */
1591
- function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope) {
1592
- const target = el as unknown as Record<string, unknown>;
1970
+ function bindProperty(
1971
+ el: HTMLElement,
1972
+ key: string,
1973
+ val: JxDocumentValue | undefined,
1974
+ state: JxScope,
1975
+ ) {
1976
+ const target = elementProperties(el);
1593
1977
  const boundProp = boundPropKey(key, val, state);
1594
1978
  if (boundProp) {
1595
1979
  el.dataset.jxBoundProp = boundProp;
@@ -1597,15 +1981,16 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
1597
1981
  /* An `<img>` in a document says `{"tagName": "img", "src": "/hero.jpg"}`, so `src` arrives HERE
1598
1982
  as a top-level key and is written as a DOM PROPERTY — `attributes: { src }` is the rarer
1599
1983
  spelling. Both are the same reference and both need the same resolution. */
1600
- const asAsset = (resolved: unknown): unknown =>
1601
- typeof resolved === "string" ? canvasAssetValue(el.tagName, key, resolved) : resolved;
1984
+ const asAsset = (resolved: JxScopeValue): JxScopeValue =>
1985
+ isString(resolved) ? canvasAssetValue(el.tagName, key, resolved) : resolved;
1602
1986
  /*
1603
1987
  * `className` on an SVGElement is a read-only SVGAnimatedString, so assigning it throws in the
1604
1988
  * strict mode an ES module always runs under. It is the most common Jx element property, so the
1605
1989
  * attribute form is the only one that works on both.
1606
1990
  */
1607
- const write = (resolved: unknown): void => {
1608
- const node = el as unknown as Element;
1991
+ const write = (resolved: JxScopeValue): void => {
1992
+ // Widened to Element: a namespaced node is typed HTMLElement here but is not one at runtime.
1993
+ const node: Element = el;
1609
1994
  if (key === "className" && !(node instanceof HTMLElement)) {
1610
1995
  node.setAttribute("class", resolved == null ? "" : String(resolved));
1611
1996
  return;
@@ -1619,9 +2004,11 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
1619
2004
  }
1620
2005
  target[key] = resolved;
1621
2006
  };
1622
- if (isRefObj(val)) {
1623
- const refVal = val as { $ref: string };
2007
+ if (isRefValue(val)) {
2008
+ const refVal = val;
1624
2009
  if (key === "id") {
2010
+ /* SAFETY: the `id` setter is a WebIDL DOMString attribute, which converts whatever it is
2011
+ given to a string itself; the cast only lets the bound value reach that conversion. */
1625
2012
  target[key] = resolveRef(refVal.$ref, state) as string;
1626
2013
  return;
1627
2014
  }
@@ -1633,8 +2020,9 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
1633
2020
 
1634
2021
  // Universal ${} reactivity — template strings in element properties
1635
2022
  if (isTemplateString(val)) {
1636
- effect(() => {
1637
- write(asAsset(evaluateTemplate(val as string, state)));
2023
+ const template = val;
2024
+ bindTemplate(template, () => {
2025
+ write(asAsset(evaluateTemplate(template, state)));
1638
2026
  });
1639
2027
  return;
1640
2028
  }
@@ -1808,7 +2196,7 @@ function textSink(tag: HTMLStyleElement): StyleSink {
1808
2196
  write();
1809
2197
  },
1810
2198
  text() {
1811
- return tag.textContent ?? "";
2199
+ return tag.textContent;
1812
2200
  },
1813
2201
  };
1814
2202
  }
@@ -1824,9 +2212,10 @@ function textSink(tag: HTMLStyleElement): StyleSink {
1824
2212
  * @returns {StyleSink}
1825
2213
  */
1826
2214
  function openSink(doc: Document): StyleSink {
1827
- const view = doc.defaultView as (Window & { CSSStyleSheet?: typeof CSSStyleSheet }) | null;
2215
+ // A window without constructable stylesheets (an older engine, a test DOM) lacks the global.
2216
+ const view: (Window & { CSSStyleSheet?: typeof CSSStyleSheet }) | null = doc.defaultView;
1828
2217
  const SheetConstructor = view?.CSSStyleSheet;
1829
- if (typeof SheetConstructor === "function" && Array.isArray(doc.adoptedStyleSheets)) {
2218
+ if (isFunction(SheetConstructor) && Array.isArray(doc.adoptedStyleSheets)) {
1830
2219
  try {
1831
2220
  const sheet = new SheetConstructor();
1832
2221
  doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
@@ -1839,10 +2228,21 @@ function openSink(doc: Document): StyleSink {
1839
2228
  }
1840
2229
  const tag = doc.createElement("style");
1841
2230
  tag.dataset.jxSheet = "";
1842
- (doc.head ?? doc.documentElement).append(tag);
2231
+ (documentHead(doc) ?? doc.documentElement).append(tag);
1843
2232
  return tag.sheet ? cssomSink(tag.sheet, () => tag.remove()) : textSink(tag);
1844
2233
  }
1845
2234
 
2235
+ /**
2236
+ * A document's `<head>`, or null when it has none. HTML defines `document.head` as nullable (a
2237
+ * document whose root element has no `head` child), though the DOM typings declare it non-null.
2238
+ *
2239
+ * @param {Document} doc
2240
+ * @returns {HTMLHeadElement | null}
2241
+ */
2242
+ function documentHead(doc: Document): HTMLHeadElement | null {
2243
+ return doc.head;
2244
+ }
2245
+
1846
2246
  /** One interned rule set: the rules as written, and how many elements point at them. */
1847
2247
  interface InternEntry {
1848
2248
  inserted: InsertedRules;
@@ -2104,7 +2504,7 @@ function declaresDisplay(style: JxStyle | undefined): boolean {
2104
2504
  `@media` block as "the author supplied one", so the element stayed `inline` at rest and
2105
2505
  became a block on hover — a live trap rather than a subtlety. An author who wants the
2106
2506
  platform's own default writes `display: revert` in the base block, which this sees. */
2107
- return Boolean(style) && typeof style === "object" && "display" in style;
2507
+ return isNonNullObject(style) && "display" in style;
2108
2508
  }
2109
2509
 
2110
2510
  /**
@@ -2129,19 +2529,20 @@ function mergeStyle(base: JxStyle, over: JxStyle | undefined): JxStyle {
2129
2529
  if (!over) {
2130
2530
  return base;
2131
2531
  }
2132
- const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
2133
- for (const [key, value] of Object.entries(over as Record<string, unknown>)) {
2532
+ const out: JxStyle = { ...base };
2533
+ for (const [key, value] of Object.entries(over)) {
2134
2534
  const prior = out[key];
2535
+ /* A block merges into whatever object the definition holds there. A repeated-block list is
2536
+ spread to its index keys first, which is what spreading it as a style object always did. */
2135
2537
  out[key] =
2136
- prior !== null &&
2137
- typeof prior === "object" &&
2138
- value !== null &&
2139
- typeof value === "object" &&
2140
- !Array.isArray(value)
2141
- ? mergeStyle(prior as JxStyle, value as JxStyle)
2538
+ isNestedStyle(value) && isNonNullObject(prior)
2539
+ ? mergeStyle(
2540
+ Array.isArray(prior) ? Object.fromEntries(Object.entries(prior)) : prior,
2541
+ value,
2542
+ )
2142
2543
  : value;
2143
2544
  }
2144
- return out as JxStyle;
2545
+ return out;
2145
2546
  }
2146
2547
 
2147
2548
  /**
@@ -2212,7 +2613,7 @@ function applyStyleInto(
2212
2613
  live: boolean,
2213
2614
  ) {
2214
2615
  releaseElementStyles(el);
2215
- const doc = el.ownerDocument ?? document;
2616
+ const doc = el.ownerDocument;
2216
2617
  const sheetState = sheetStateFor(doc);
2217
2618
 
2218
2619
  /* One gate for the whole call, so every recursion answers alike. Off in production and in
@@ -2235,8 +2636,9 @@ function applyStyleInto(
2235
2636
  variable inherits, which is correct, and the declaration stays in the rule where `:hover` can
2236
2637
  override it. The serial keeps two elements' variables apart: a descendant rule reads `var()`
2237
2638
  from the nearest ancestor that set it, and without it a styled child would shadow its parent's
2238
- `--jx-r0` with its own. It also makes the rule text — and so the scope handle hashed from it —
2239
- unique per element, which is exactly the sharing a reactive rule set must not have. */
2639
+ `--jx-r0` with its own. Only a DESCENDANT rule needs that: a self-target declaration reads the
2640
+ variable the element itself sets, so it is named by position alone (`--jx-s<n>`) and elements
2641
+ that style alike keep sharing one rule set. */
2240
2642
  let serial = -1;
2241
2643
  let indirect = 0;
2242
2644
  const sources: { name: string; value: string | JxRef }[] = [];
@@ -2258,15 +2660,26 @@ function applyStyleInto(
2258
2660
  sources.push({ name: property, value });
2259
2661
  return null;
2260
2662
  }
2261
- if (serial < 0) {
2262
- ({ serial } = sheetState);
2263
- sheetState.serial += 1;
2264
- }
2265
2663
  /* Counted separately from `sources`, so an inline custom property before it cannot shift the
2266
2664
  name of an indirected one. The name is IN the rule text, and the interning handle is a
2267
2665
  hash of that text — a number that moved with unrelated declarations would give two
2268
2666
  otherwise identical styles two rules. */
2269
- const name = `--jx-r${serial}-${indirect}`;
2667
+ let name: string;
2668
+ if (target === "self") {
2669
+ /* A SELF-target rule reads its variable from the element that sets it inline, which is
2670
+ this element — no ancestor's value can reach it — so the name needs no serial, only its
2671
+ position. Without this, every instance of a definition with one reactive declaration
2672
+ (the kit's tree row binds its indent) interned a rule set of its own: an Outline
2673
+ scrolling through a long page minted fifteen rules per row and grew the shared sheet
2674
+ past fourteen thousand, slowing every insert and every style recalculation after. */
2675
+ name = `--jx-s${indirect}`;
2676
+ } else {
2677
+ if (serial < 0) {
2678
+ ({ serial } = sheetState);
2679
+ sheetState.serial += 1;
2680
+ }
2681
+ name = `--jx-r${serial}-${indirect}`;
2682
+ }
2270
2683
  indirect += 1;
2271
2684
  sources.push({ name, value });
2272
2685
  return `var(${name})`;
@@ -2318,7 +2731,7 @@ function applyStyleInto(
2318
2731
  scope.run(() => {
2319
2732
  for (const { name, value } of sources) {
2320
2733
  effect(() => {
2321
- const resolved = isRefObj(value)
2734
+ const resolved = isRefValue(value)
2322
2735
  ? resolveRef(value.$ref, state)
2323
2736
  : evaluateTemplate(value, state);
2324
2737
  el.style.setProperty(name, canvasStyleValue(String(resolved ?? "")));
@@ -2411,7 +2824,7 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
2411
2824
  const attr = canvasAttrName(el, k);
2412
2825
  /* Inside the effects, not before them. A `$ref` or `${…}` value is not a string until the
2413
2826
  effect runs — which is exactly why the document walk this replaced could never see it. */
2414
- const write = (resolved: unknown) => {
2827
+ const write = (resolved: JxScopeValue) => {
2415
2828
  if (resolved === null || resolved === undefined) {
2416
2829
  /* Nothing is an attribute the element does not have. A binding that resolves to `null`
2417
2830
  has to take the attribute WITH it, for the same reason a `false` does below — and an
@@ -2419,7 +2832,7 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
2419
2832
  el.removeAttribute(attr);
2420
2833
  return;
2421
2834
  }
2422
- if (typeof resolved === "boolean") {
2835
+ if (isBoolean(resolved)) {
2423
2836
  const text = booleanAttrValue(attr, resolved);
2424
2837
  /* `removeAttribute`, not `setAttribute(attr, "false")`. A binding that flips back has to
2425
2838
  take the attribute WITH it: the element is being re-used, so anything left behind is the
@@ -2434,10 +2847,10 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
2434
2847
  }
2435
2848
  el.setAttribute(attr, canvasAssetValue(el.tagName, k, String(resolved)));
2436
2849
  };
2437
- if (isRefObj(v)) {
2850
+ if (isRefValue(v)) {
2438
2851
  effect(() => write(resolveRef(v.$ref, state)));
2439
2852
  } else if (isTemplateString(v)) {
2440
- effect(() => write(evaluateAttrTemplate(v, state)));
2853
+ bindTemplate(v, () => write(evaluateValueTemplate(v, state)));
2441
2854
  } else {
2442
2855
  write(v);
2443
2856
  }
@@ -2483,7 +2896,7 @@ function renderMappedArrayInto(
2483
2896
  const anchor = document.createComment("jx-array");
2484
2897
  parentEl.append(anchor);
2485
2898
  const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef, key: keyRef } = arrayDef;
2486
- const keyPointer = isRefObj(keyRef) ? keyRef.$ref : null;
2899
+ const keyPointer = isRefValue(keyRef) ? keyRef.$ref : null;
2487
2900
  /* The instantiation chain the list belongs to (spec.md §16.9), for the same reason
2488
2901
  {@link renderSwitch} keeps one: a row that arrives with new data is inserted from an effect
2489
2902
  re-run, and the instance it holds must connect under the chain of the definition that drew
@@ -2501,14 +2914,22 @@ function renderMappedArrayInto(
2501
2914
  /** The rows currently rendered, in DOM order. */
2502
2915
  let rows: MappedRow[] = [];
2503
2916
 
2917
+ /* Out of the document FIRST, then stopped: stopping releases every element's styles
2918
+ (`releaseElementStyles`), and on a connected node each of those inline-property and `data-jx`
2919
+ writes schedules a style invalidation for a node that is about to leave. Detached, they are
2920
+ plain writes. See `dispose` in `mount`. */
2504
2921
  const teardown = (row: MappedRow): void => {
2505
- row.scope.stop();
2506
2922
  row.node.remove();
2923
+ row.scope.stop();
2507
2924
  };
2508
2925
 
2509
- const createRow = (key: unknown, item: unknown, index: number): MappedRow => {
2926
+ const createRow = (
2927
+ key: MappedRow["key"],
2928
+ item: MappedRow["map"]["item"],
2929
+ index: number,
2930
+ ): MappedRow => {
2510
2931
  const map = shallowReactive({ index, item });
2511
- const child = Object.create(state) as JxScope;
2932
+ const child = inheritScope(state);
2512
2933
  child.$map = map;
2513
2934
  // The flat aliases stay readable by legacy readers, and follow the reactive context.
2514
2935
  Object.defineProperty(child, "$map/item", { enumerable: true, get: () => map.item });
@@ -2519,8 +2940,8 @@ function renderMappedArrayInto(
2519
2940
  return { key, map, node, scope };
2520
2941
  };
2521
2942
 
2522
- const reconcile = (list: unknown[], keys: unknown[]): void => {
2523
- const previous = new Map<unknown, MappedRow>();
2943
+ const reconcile = (list: readonly JxScopeValue[], keys: readonly MappedRow["key"][]): void => {
2944
+ const previous = new Map<MappedRow["key"], MappedRow>();
2524
2945
  for (const row of rows) {
2525
2946
  previous.set(row.key, row);
2526
2947
  }
@@ -2563,24 +2984,30 @@ function renderMappedArrayInto(
2563
2984
  };
2564
2985
 
2565
2986
  const update = (): void => {
2566
- let items: unknown = isRefObj(itemsSrc) ? resolveRef(itemsSrc.$ref, state) : itemsSrc;
2567
- if (Array.isArray(items) && isRefObj(filterRef)) {
2987
+ let items: JxScopeValue = isRefValue(itemsSrc) ? resolveRef(itemsSrc.$ref, state) : itemsSrc;
2988
+ if (Array.isArray(items) && isRefValue(filterRef)) {
2568
2989
  const fn = resolveRef(filterRef.$ref, state);
2569
- if (typeof fn === "function") {
2570
- items = items.filter(fn as (v: unknown) => boolean);
2990
+ if (isFunction(fn)) {
2991
+ /* SAFETY: the guard proved a function, and a function accepts any call: `Array#filter`
2992
+ passes each item (with its index and the list) and reads only the result's truthiness. */
2993
+ items = items.filter(fn as (item: MappedRow["map"]["item"]) => boolean);
2571
2994
  }
2572
2995
  }
2573
- if (Array.isArray(items) && isRefObj(sortRef)) {
2996
+ if (Array.isArray(items) && isRefValue(sortRef)) {
2574
2997
  const fn = resolveRef(sortRef.$ref, state);
2575
- if (typeof fn === "function") {
2576
- items = [...(items as unknown[])].toSorted(fn as (a: unknown, b: unknown) => number);
2998
+ if (isFunction(fn)) {
2999
+ /* SAFETY: the guard proved a function, and a function accepts any call: `toSorted` passes
3000
+ two items and orders by the sign of the number the comparator returns. */
3001
+ const compare = fn as (a: MappedRow["map"]["item"], b: MappedRow["map"]["item"]) => number;
3002
+ const unsorted: readonly JxScopeValue[] = items;
3003
+ items = [...unsorted].toSorted(compare);
2577
3004
  }
2578
3005
  }
2579
- const list = Array.isArray(items) && mapDef ? (items as unknown[]) : [];
3006
+ const list: readonly JxScopeValue[] = Array.isArray(items) && mapDef ? items : [];
2580
3007
 
2581
3008
  // Keys are read HERE, tracked, so a key that changes reconciles the list.
2582
3009
  const keys = list.map((item, index) => mappedRowKey(keyPointer, item, index, warnOnce));
2583
- const seen = new Set<unknown>();
3010
+ const seen = new Set<MappedRow["key"]>();
2584
3011
  for (let i = 0; i < keys.length; i++) {
2585
3012
  if (seen.has(keys[i])) {
2586
3013
  warnOnce(
@@ -2634,9 +3061,9 @@ function renderMappedArrayInto(
2634
3061
 
2635
3062
  /** A rendered row of a mapped array. */
2636
3063
  interface MappedRow {
2637
- key: unknown;
3064
+ key: JxScopeValue;
2638
3065
  /** The row's `$map` context; `item` and `index` are written in place when the row is reused. */
2639
- map: { index: number; item: unknown };
3066
+ map: JxRowContext;
2640
3067
  node: HTMLElement | Text;
2641
3068
  scope: EffectScope;
2642
3069
  }
@@ -2648,10 +3075,10 @@ interface MappedRow {
2648
3075
  */
2649
3076
  function mappedRowKey(
2650
3077
  pointer: string | null,
2651
- item: unknown,
3078
+ item: MappedRow["map"]["item"],
2652
3079
  index: number,
2653
3080
  warnOnce: (message: string) => void,
2654
- ): unknown {
3081
+ ): MappedRow["key"] {
2655
3082
  if (pointer === null) {
2656
3083
  return index;
2657
3084
  }
@@ -2677,12 +3104,12 @@ function mappedRowKey(
2677
3104
  * schema already held to the tag-name pattern — the property that lets the compiler enumerate the
2678
3105
  * candidates and lets `createElement` be called without a second thought here.
2679
3106
  *
2680
- * @param {unknown} tagName The element's declared tag.
3107
+ * @param {TagNameSlot} tagName The element's declared tag.
2681
3108
  * @param {JxScope} scope The state the discriminant reads.
2682
3109
  * @returns {string} A literal tag name; `"div"` when nothing is declared.
2683
3110
  */
2684
- export function resolveTagName(tagName: unknown, scope: JxScope): string {
2685
- if (typeof tagName === "string") {
3111
+ export function resolveTagName(tagName: TagNameSlot, scope: JxScope): string {
3112
+ if (isString(tagName)) {
2686
3113
  return tagName;
2687
3114
  }
2688
3115
  if (!isTagExpression(tagName)) {
@@ -2720,7 +3147,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2720
3147
  }
2721
3148
 
2722
3149
  applyProperties(container, def, state);
2723
- applyStyle(container, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
3150
+ applyStyle(container, def.style ?? {}, scopeMedia(state), state);
2724
3151
  applyAttributes(container, def.attributes ?? {}, state);
2725
3152
  /* Every case renders in a detached scope of its own, stopped before the next case renders and
2726
3153
  when the container's own scope stops. A generation counter names the case a pending external
@@ -2739,9 +3166,10 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2739
3166
  };
2740
3167
 
2741
3168
  effect(() => {
2742
- if (!isRefObj(def.$switch)) {
2743
- retire();
3169
+ if (!isRefValue(def.$switch)) {
3170
+ // Detached before it is stopped, for the reason `teardown` in `renderMappedArray` gives.
2744
3171
  container.replaceChildren();
3172
+ retire();
2745
3173
  generation += 1;
2746
3174
  return;
2747
3175
  }
@@ -2750,12 +3178,13 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2750
3178
  return;
2751
3179
  }
2752
3180
  renderedKey = key;
2753
- retire();
2754
3181
  /* `replaceChildren()` rather than `innerHTML = ""`: identical semantics, and it is not a
2755
3182
  Trusted Types injection sink — under `require-trusted-types-for 'script'` an innerHTML write
2756
3183
  needs a policy even when the string is empty. Four sinks that were never injecting anything
2757
- is four fewer things a policy has to be permissive about. */
3184
+ is four fewer things a policy has to be permissive about. Detached BEFORE the case is
3185
+ stopped, for the reason `teardown` in `renderMappedArray` gives. */
2758
3186
  container.replaceChildren();
3187
+ retire();
2759
3188
  generation += 1;
2760
3189
  const caseDef = def.cases?.[key];
2761
3190
  if (!caseDef) {
@@ -2766,7 +3195,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2766
3195
  live = scope;
2767
3196
  const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
2768
3197
 
2769
- if (isRefObj(caseDef)) {
3198
+ if (isRefValue(caseDef)) {
2770
3199
  // External $ref — fetch and render asynchronously, against the mount's base so a bundled
2771
3200
  // Document need not live at the page URL.
2772
3201
  const { href } = new URL(caseDef.$ref, options?._ctx?.base ?? location.href);
@@ -2784,7 +3213,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2784
3213
  scope.run(() => container.append(renderNode(doc, childScope, childOpts))),
2785
3214
  );
2786
3215
  })
2787
- .catch((error: unknown) =>
3216
+ .catch((error) =>
2788
3217
  console.error("Jx $switch: failed to load external case", caseDef.$ref, error),
2789
3218
  );
2790
3219
  return;
@@ -2809,6 +3238,15 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2809
3238
 
2810
3239
  // ─── Prototype namespaces (Shape 5) ──────────────────────────────────────────
2811
3240
 
3241
+ /**
3242
+ * `ref()`, for a value that is one scope member: the same function, under a signature that says so.
3243
+ *
3244
+ * `ref`'s own return type unwraps its argument's type recursively, and over the recursive
3245
+ * `JxScopeValue` union (an array of scope values holds scope values) that unwrapping never
3246
+ * terminates (TS2589). The value goes in as it is, so the ref is typed as holding a scope member.
3247
+ */
3248
+ const scopeRef: (value: JxScopeValue) => Ref<JxScopeValue> = ref;
3249
+
2812
3250
  /**
2813
3251
  * Resolve a $prototype definition into a value for the reactive scope.
2814
3252
  *
@@ -2819,7 +3257,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2819
3257
  * @param {JxScope} state - Reactive scope proxy
2820
3258
  * @param {string} key - Def key (for diagnostics)
2821
3259
  * @param {string} [base] - Base URL for resolving $src imports
2822
- * @returns {Promise<unknown>}
3260
+ * @returns {Promise<JxScopeValue>}
2823
3261
  */
2824
3262
  export async function resolvePrototype(
2825
3263
  def: JxPrototypeDef,
@@ -2835,7 +3273,7 @@ export async function resolvePrototype(
2835
3273
 
2836
3274
  switch (def.$prototype) {
2837
3275
  case "Request": {
2838
- const s: Ref<unknown> = ref(null);
3276
+ const s: Ref<JxScopeValue> = ref(null);
2839
3277
  const debounceMs = def.debounce ?? 0;
2840
3278
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
2841
3279
 
@@ -2865,14 +3303,14 @@ export async function resolvePrototype(
2865
3303
  signal: controller.signal,
2866
3304
  ...(def.headers && { headers: def.headers }),
2867
3305
  ...(def.body && {
2868
- body: typeof def.body === "object" ? JSON.stringify(def.body) : def.body,
3306
+ body: isNonNullObject(def.body) ? JSON.stringify(def.body) : def.body,
2869
3307
  }),
2870
3308
  })
2871
- .then((r) => (r.ok ? r.json() : Promise.reject(r.statusText)))
3309
+ .then((r): Promise<JsonValue> => (r.ok ? r.json() : Promise.reject(r.statusText)))
2872
3310
  .then((d) => {
2873
3311
  s.value = d;
2874
3312
  })
2875
- .catch((error: unknown) => {
3313
+ .catch((error) => {
2876
3314
  if (!(error instanceof Error && error.name === "AbortError")) {
2877
3315
  s.value = { error: String(error) };
2878
3316
  }
@@ -2894,8 +3332,11 @@ export async function resolvePrototype(
2894
3332
  const p: Record<string, string> = {};
2895
3333
  for (const [k, v] of Object.entries(def)) {
2896
3334
  if (k !== "$prototype") {
3335
+ /* SAFETY: the record goes straight to `URLSearchParams`, whose WebIDL record<USVString,
3336
+ USVString> conversion stringifies every value itself; a bound number or boolean
3337
+ reaches that conversion exactly as it always has. */
2897
3338
  p[k] = (
2898
- isRefObj(v)
3339
+ isRefValue(v)
2899
3340
  ? resolveRef(v.$ref, state)
2900
3341
  : isTemplateString(v)
2901
3342
  ? evaluateTemplate(v, state)
@@ -2911,14 +3352,16 @@ export async function resolvePrototype(
2911
3352
  case "SessionStorage": {
2912
3353
  const store = def.$prototype === "LocalStorage" ? localStorage : sessionStorage;
2913
3354
  const k = def.key ?? key;
2914
- let init: unknown;
3355
+ let init: JxScopeValue;
2915
3356
  try {
2916
3357
  const s = store.getItem(k);
2917
- init = s !== null ? (JSON.parse(s) as unknown) : (def.default ?? null);
3358
+ /* SAFETY: `JSON.parse` answers the JSON value the stored text encodes, or throws, which the
3359
+ catch below answers with the entry's default. */
3360
+ init = s !== null ? (JSON.parse(s) as JsonValue) : (def.default ?? null);
2918
3361
  } catch {
2919
3362
  init = def.default ?? null;
2920
3363
  }
2921
- const storageState: Ref<unknown> = ref(init);
3364
+ const storageState = scopeRef(init);
2922
3365
  // Persist on change
2923
3366
  effect(() => {
2924
3367
  const v = storageState.value;
@@ -2937,28 +3380,33 @@ export async function resolvePrototype(
2937
3380
 
2938
3381
  case "Cookie": {
2939
3382
  const name = def.name ?? key;
2940
- const read = () => {
3383
+ const read = (): JsonValue => {
2941
3384
  const raw = readCookie(document.cookie, name);
2942
3385
  if (raw === null) {
2943
3386
  return null;
2944
3387
  }
2945
3388
  try {
2946
- return JSON.parse(decodeURIComponent(raw));
3389
+ /* SAFETY: `JSON.parse` answers the JSON value the cookie encodes, or throws, which the
3390
+ catch below answers with the raw text. */
3391
+ return JSON.parse(decodeURIComponent(raw)) as JsonValue;
2947
3392
  } catch {
2948
3393
  return raw;
2949
3394
  }
2950
3395
  };
2951
- const cookieState: Ref<unknown> = ref(read() ?? def.default ?? null);
3396
+ const cookieState = scopeRef(read() ?? def.default ?? null);
2952
3397
  // Persist on change
2953
3398
  effect(() => {
3399
+ /* SAFETY: the store is seeded with JSON (`read` parses it, `default` is JSON) and holds
3400
+ what the page writes back; `serializeCookie` reads it only through `JSON.stringify`,
3401
+ which is defined for any value and keeps exactly the JSON a cookie can carry. */
2954
3402
  // oxlint-disable-next-line unicorn/no-document-cookie -- the Cookie $prototype IS the cookie store binding
2955
- document.cookie = serializeCookie(name, cookieState.value, def);
3403
+ document.cookie = serializeCookie(name, cookieState.value as JsonValue, def);
2956
3404
  });
2957
3405
  return cookieState;
2958
3406
  }
2959
3407
 
2960
3408
  case "IndexedDB": {
2961
- const idbState: Ref<unknown> = ref(null);
3409
+ const idbState: Ref<JxScopeValue> = ref(null);
2962
3410
  const {
2963
3411
  database,
2964
3412
  store,
@@ -2972,6 +3420,7 @@ export async function resolvePrototype(
2972
3420
  }
2973
3421
  const req = indexedDB.open(database, version);
2974
3422
  req.addEventListener("upgradeneeded", (e) => {
3423
+ // SAFETY: an open request's events are dispatched at the request itself.
2975
3424
  const db: IDBDatabase = (e.target as IDBOpenDBRequest).result;
2976
3425
  if (!db.objectStoreNames.contains(store)) {
2977
3426
  const os = db.createObjectStore(store, { autoIncrement, keyPath });
@@ -2981,6 +3430,7 @@ export async function resolvePrototype(
2981
3430
  }
2982
3431
  });
2983
3432
  req.addEventListener("success", (e) => {
3433
+ // SAFETY: an open request's events are dispatched at the request itself.
2984
3434
  const db: IDBDatabase = (e.target as IDBOpenDBRequest).result;
2985
3435
  idbState.value = {
2986
3436
  database,
@@ -3008,14 +3458,19 @@ export async function resolvePrototype(
3008
3458
  case "FormData": {
3009
3459
  const fd = new FormData();
3010
3460
  for (const [k, v] of Object.entries(def.fields ?? {})) {
3461
+ /* SAFETY: `append(name, value)` converts a non-Blob value to a USVString itself, so a JSON
3462
+ number or boolean field reaches that conversion exactly as it always has. */
3011
3463
  fd.append(k, v as string);
3012
3464
  }
3013
3465
  return fd;
3014
3466
  }
3015
3467
 
3016
3468
  case "Blob": {
3017
- return new Blob((def.parts as BlobPart[]) ?? [], {
3018
- type: typeof def.type === "string" ? def.type : "text/plain",
3469
+ /* SAFETY: a Blob entry's `parts` is the BlobPart list its document declares; anything else
3470
+ reaches the Blob constructor, whose own WebIDL sequence check refuses it as before. */
3471
+ const parts = def.parts as BlobPart[] | null | undefined;
3472
+ return new Blob(parts ?? [], {
3473
+ type: isString(def.type) ? def.type : "text/plain",
3019
3474
  });
3020
3475
  }
3021
3476
 
@@ -3048,6 +3503,28 @@ const EXTERNAL_RESERVED = new Set([
3048
3503
  "name",
3049
3504
  ]);
3050
3505
 
3506
+ /**
3507
+ * The config an external class is constructed with: the entry's own keys, minus the runtime's.
3508
+ *
3509
+ * @param {JxPrototypeDef} def
3510
+ * @returns {JxScope}
3511
+ */
3512
+ function externalConfig(def: JxPrototypeDef): JxScope {
3513
+ const config: JxScope = {};
3514
+ for (const [k, v] of Object.entries(def)) {
3515
+ if (!EXTERNAL_RESERVED.has(k)) {
3516
+ config[k] = v;
3517
+ }
3518
+ }
3519
+ return config;
3520
+ }
3521
+
3522
+ /**
3523
+ * A class an external module exports, as the runtime uses it: a function, which is what `typeof`
3524
+ * says of every class, constructed with the entry's config.
3525
+ */
3526
+ type ExternalClass = JxScopeFunction & (new (config: JxScope) => ExternalClassInstance);
3527
+
3051
3528
  /**
3052
3529
  * Resolve an external class prototype via $src.
3053
3530
  *
@@ -3055,7 +3532,7 @@ const EXTERNAL_RESERVED = new Set([
3055
3532
  * @param {JxScope} state
3056
3533
  * @param {string} key
3057
3534
  * @param {string} [base]
3058
- * @returns {Promise<unknown>}
3535
+ * @returns {Promise<Ref<JxScopeValue>>}
3059
3536
  */
3060
3537
  async function resolveExternalPrototype(
3061
3538
  def: JxPrototypeDef,
@@ -3080,13 +3557,18 @@ async function resolveExternalPrototype(
3080
3557
  * Import a JS module and instantiate a class from it. Internal helper used by resolveClassJson for
3081
3558
  * $implementation.
3082
3559
  *
3083
- * @param {JxScope} def - Original state entry (for config extraction)
3560
+ * @param {JxPrototypeDef} def - Original state entry (for config extraction)
3084
3561
  * @param {string} src - JS module URL to import
3085
3562
  * @param {string} exportName - Export name to look up
3086
3563
  * @param {string} [base] - Base URL for resolution
3087
- * @returns {Promise<unknown>}
3564
+ * @returns {Promise<Ref<JxScopeValue>>}
3088
3565
  */
3089
- async function importAndInstantiate(def: JxScope, src: string, exportName: string, base?: string) {
3566
+ async function importAndInstantiate(
3567
+ def: JxPrototypeDef,
3568
+ src: string,
3569
+ exportName: string,
3570
+ base?: string,
3571
+ ) {
3090
3572
  let mod: ImportedModule;
3091
3573
  const seeded = seededModule(src);
3092
3574
  if (seeded instanceof Promise) {
@@ -3095,11 +3577,11 @@ async function importAndInstantiate(def: JxScope, src: string, exportName: strin
3095
3577
  mod = seeded;
3096
3578
  } else {
3097
3579
  try {
3098
- mod = (await import(src)) as ImportedModule;
3580
+ mod = await importModule(src);
3099
3581
  } catch {
3100
3582
  if (base) {
3101
3583
  const resolvedSrc = new URL(src, base).href;
3102
- mod = (await import(resolvedSrc)) as ImportedModule;
3584
+ mod = await importModule(resolvedSrc);
3103
3585
  } else {
3104
3586
  throw new Error(`Failed to import "${src}"`);
3105
3587
  }
@@ -3111,33 +3593,27 @@ async function importAndInstantiate(def: JxScope, src: string, exportName: strin
3111
3593
  if (!ExportedClass) {
3112
3594
  throw new Error(`Jx: export "${exportName}" not found in "${src}"`);
3113
3595
  }
3114
- if (typeof ExportedClass !== "function") {
3596
+ if (!isFunction(ExportedClass)) {
3115
3597
  throw new TypeError(`Jx: "${exportName}" from "${src}" is not a class`);
3116
3598
  }
3117
3599
 
3118
- const config: JxScope = {};
3119
- for (const [k, v] of Object.entries(def)) {
3120
- if (!EXTERNAL_RESERVED.has(k)) {
3121
- config[k] = v;
3122
- }
3123
- }
3600
+ /* SAFETY: trust boundary — the external class contract, spec.md §12.3. A `$implementation`
3601
+ module's export is written as a class constructed with the entry's config; `isFunction` is all
3602
+ that can be checked before `new`, and a function that is not constructible throws its own
3603
+ TypeError there, as it always has. The instance is read only through `ExternalClassInstance`'s
3604
+ optional members, each checked before it is used, and the one it offers is the entry's value. */
3605
+ const instance = new (ExportedClass as ExternalClass)(externalConfig(def));
3124
3606
 
3125
- const Ctor = ExportedClass as new (config: JxScope) => ExternalClassInstance;
3126
- const instance = new Ctor(config);
3127
-
3128
- let value: unknown;
3129
- if (typeof instance.resolve === "function") {
3130
- value = await instance.resolve();
3131
- } else if ("value" in instance) {
3132
- ({ value } = instance);
3133
- } else {
3134
- value = instance;
3135
- }
3607
+ const value = isFunction(instance.resolve)
3608
+ ? await instance.resolve()
3609
+ : "value" in instance
3610
+ ? instance.value
3611
+ : instance;
3136
3612
 
3137
3613
  // Always wrap in ref for reactivity with external classes
3138
- const s: Ref<unknown> = ref(value);
3139
- if (typeof instance.subscribe === "function") {
3140
- instance.subscribe((newVal: unknown) => {
3614
+ const s = scopeRef(value);
3615
+ if (isFunction(instance.subscribe)) {
3616
+ instance.subscribe((newVal) => {
3141
3617
  s.value = newVal;
3142
3618
  });
3143
3619
  }
@@ -3155,7 +3631,7 @@ async function importAndInstantiate(def: JxScope, src: string, exportName: strin
3155
3631
  * @param {JxScope} state
3156
3632
  * @param {string} key
3157
3633
  * @param {string} [base]
3158
- * @returns {Promise<unknown>}
3634
+ * @returns {Promise<Ref<JxScopeValue>>}
3159
3635
  */
3160
3636
  async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string, base?: string) {
3161
3637
  const src = def.$src!;
@@ -3179,7 +3655,9 @@ async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string
3179
3655
  if (!res.ok) {
3180
3656
  throw new Error(`HTTP ${res.status}`);
3181
3657
  }
3182
- // Trust boundary: fetched .class.json sources are class definitions by contract.
3658
+ /* SAFETY: trust boundary. Only a `$src` ending in `.class.json` reaches this fetch
3659
+ (`resolveExternalPrototype` refuses anything else), and a 2xx answer for one is a class
3660
+ definition document; the runtime does not schema-validate documents by design. */
3183
3661
  classDef = (await res.json()) as JxClassDef;
3184
3662
  } catch {
3185
3663
  // Fall back to dev proxy (server will handle .class.json resolution)
@@ -3206,71 +3684,81 @@ async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string
3206
3684
 
3207
3685
  // Self-contained: construct class dynamically from schema
3208
3686
  const DynClass = classFromSchema(classDef);
3209
- const config: JxScope = {};
3210
- for (const [k, v] of Object.entries(def)) {
3211
- if (!EXTERNAL_RESERVED.has(k)) {
3212
- config[k] = v;
3213
- }
3214
- }
3215
- const instance = new DynClass(config) as ExternalClassInstance;
3687
+ /* SAFETY: every member of ExternalClassInstance is optional, and each is checked below before it
3688
+ is used, so any constructed object satisfies it; a schema class that defines `resolve`,
3689
+ `value` or `subscribe` methods or fields is the shape it names. */
3690
+ const instance = new DynClass(externalConfig(def)) as ExternalClassInstance;
3216
3691
 
3217
- let value: unknown;
3218
- if (typeof instance.resolve === "function") {
3219
- value = await instance.resolve();
3220
- } else if ("value" in instance) {
3221
- ({ value } = instance);
3222
- } else {
3223
- value = instance;
3224
- }
3692
+ const value = isFunction(instance.resolve)
3693
+ ? await instance.resolve()
3694
+ : "value" in instance
3695
+ ? instance.value
3696
+ : instance;
3225
3697
 
3226
3698
  // Always wrap in ref for reactivity
3227
- const s: Ref<unknown> = ref(value);
3228
- if (typeof instance.subscribe === "function") {
3229
- instance.subscribe((newVal: unknown) => {
3699
+ const s = scopeRef(value);
3700
+ if (isFunction(instance.subscribe)) {
3701
+ instance.subscribe((newVal) => {
3230
3702
  s.value = newVal;
3231
3703
  });
3232
3704
  }
3233
3705
  return s;
3234
3706
  }
3235
3707
 
3708
+ /**
3709
+ * Whether a class document's `$defs.constructor` is its own constructor definition, rather than the
3710
+ * `Object.prototype.constructor` function every object parsed from JSON inherits.
3711
+ *
3712
+ * @param rawCtor What `$defs.constructor` reads as
3713
+ */
3714
+ function isCtorDef(
3715
+ rawCtor: NonNullable<JxClassDef["$defs"]>["constructor"],
3716
+ ): rawCtor is JxClassCtorDef {
3717
+ return isNonNullObject(rawCtor);
3718
+ }
3719
+
3236
3720
  /**
3237
3721
  * Dynamically construct a class from a .class.json schema definition. Browser-side: maps private
3238
3722
  * fields to _-prefixed public fields.
3239
3723
  *
3240
3724
  * @param {import("@jxsuite/schema/types").JxClassDef} classDef
3241
- * @returns {DynamicClass}
3725
+ * @returns {typeof DynClass} A class whose instances hold the schema's fields by name
3242
3726
  */
3243
3727
  function classFromSchema(classDef: JxClassDef) {
3244
3728
  const fields = classDef.$defs?.fields ?? {};
3245
3729
  // JSON objects inherit Object.prototype.constructor — only an own object value counts.
3246
3730
  const rawCtor = classDef.$defs?.constructor;
3247
- const ctor = typeof rawCtor === "object" ? rawCtor : undefined;
3731
+ const ctor = isCtorDef(rawCtor) ? rawCtor : undefined;
3248
3732
  const methods = classDef.$defs?.methods ?? {};
3249
3733
 
3250
3734
  // oxlint-disable-next-line typescript/no-extraneous-class -- methods are attached to the prototype dynamically below
3251
3735
  class DynClass {
3252
- constructor(config: Record<string, unknown> = {}) {
3736
+ /** Static methods, attached by name below. */
3737
+ static [member: string]: JxScopeValue;
3738
+ /** An instance holds its fields, and reaches its methods, by the names the schema gives. */
3739
+ [member: string]: JxScopeValue;
3740
+
3741
+ constructor(config: JxScope = {}) {
3253
3742
  for (const [key, typedField] of Object.entries(fields)) {
3254
3743
  const id = typedField.identifier ?? key;
3255
3744
  const propName = typedField.access === "private" ? `_${id}` : id;
3256
3745
  if (config[id] !== undefined) {
3257
- (this as Record<string, unknown>)[propName] = config[id];
3746
+ this[propName] = config[id];
3258
3747
  } else if (typedField.initializer !== undefined) {
3259
- (this as Record<string, unknown>)[propName] = typedField.initializer;
3748
+ this[propName] = typedField.initializer;
3260
3749
  } else if (typedField.default !== undefined) {
3261
- (this as Record<string, unknown>)[propName] = structuredClone(typedField.default);
3750
+ this[propName] = structuredClone(typedField.default);
3262
3751
  } else {
3263
- (this as Record<string, unknown>)[propName] = null;
3752
+ this[propName] = null;
3264
3753
  }
3265
3754
  }
3266
3755
  if (ctor?.body) {
3267
3756
  const bodyStr = Array.isArray(ctor.body) ? ctor.body.join("\n") : ctor.body;
3268
- (
3269
- new Function("config", bodyStr) as (
3270
- this: unknown,
3271
- config: Record<string, unknown>,
3272
- ) => void
3273
- ).call(this, config);
3757
+ /* SAFETY: `new Function` returns an ordinary function of the one named parameter; `call`
3758
+ runs it with the instance under construction as `this`, which is the constructor
3759
+ body's contract. */
3760
+ const init = new Function("config", bodyStr) as (this: DynClass, config: JxScope) => void;
3761
+ init.call(this, config);
3274
3762
  }
3275
3763
  }
3276
3764
  }
@@ -3279,10 +3767,11 @@ function classFromSchema(classDef: JxClassDef) {
3279
3767
  const name = typedMethod.identifier ?? key;
3280
3768
  const params = (typedMethod.parameters ?? []).map((p) => {
3281
3769
  if (p.$ref) {
3282
- return p.$ref.split("/").pop() as string;
3770
+ // `split` always yields at least one segment, so `pop` always finds one.
3771
+ return p.$ref.split("/").pop() ?? "";
3283
3772
  }
3284
3773
  const n = p.identifier ?? p.name;
3285
- return typeof n === "string" ? n : "arg";
3774
+ return isString(n) ? n : "arg";
3286
3775
  });
3287
3776
  const bodyStr = Array.isArray(typedMethod.body)
3288
3777
  ? typedMethod.body.join("\n")
@@ -3291,22 +3780,28 @@ function classFromSchema(classDef: JxClassDef) {
3291
3780
  if (typedMethod.role === "accessor") {
3292
3781
  const descriptor: PropertyDescriptor = {};
3293
3782
  if (typedMethod.getter) {
3294
- descriptor.get = new Function(typedMethod.getter.body ?? "") as () => unknown;
3783
+ /* SAFETY: `new Function` returns an ordinary function; as a getter it is called with no
3784
+ arguments and the instance as `this`, and what it returns is the property's value. */
3785
+ descriptor.get = new Function(typedMethod.getter.body ?? "") as () => JxScopeValue;
3295
3786
  }
3296
3787
  if (typedMethod.setter) {
3297
3788
  const sp = (typedMethod.setter.parameters ?? []).map(
3298
3789
  (p) => p.$ref?.split("/").pop() ?? "v",
3299
3790
  );
3300
- descriptor.set = new Function(...sp, typedMethod.setter.body ?? "") as (v: unknown) => void;
3791
+ /* SAFETY: `new Function` returns an ordinary function of the named parameters; as a setter
3792
+ it receives the assigned value as its one argument, and its return is discarded. */
3793
+ descriptor.set = new Function(...sp, typedMethod.setter.body ?? "") as (
3794
+ v: JxScopeValue,
3795
+ ) => void;
3301
3796
  }
3302
3797
  Object.defineProperty(DynClass.prototype, name, {
3303
3798
  ...descriptor,
3304
3799
  configurable: true,
3305
3800
  });
3306
3801
  } else if (typedMethod.scope === "static") {
3307
- (DynClass as unknown as DynamicClass)[name] = new Function(...params, bodyStr);
3802
+ DynClass[name] = new Function(...params, bodyStr);
3308
3803
  } else {
3309
- (DynClass as unknown as DynamicClass).prototype[name] = new Function(...params, bodyStr);
3804
+ DynClass.prototype[name] = new Function(...params, bodyStr);
3310
3805
  }
3311
3806
  }
3312
3807
 
@@ -3314,8 +3809,7 @@ function classFromSchema(classDef: JxClassDef) {
3314
3809
  configurable: true,
3315
3810
  value: classDef.title,
3316
3811
  });
3317
- const dynCtor = DynClass as unknown as DynamicClass;
3318
- return dynCtor;
3812
+ return DynClass;
3319
3813
  }
3320
3814
 
3321
3815
  /**
@@ -3327,17 +3821,12 @@ function classFromSchema(classDef: JxClassDef) {
3327
3821
  * @param {JxScope} state
3328
3822
  * @param {string} key
3329
3823
  * @param {string} [base]
3330
- * @returns {Promise<unknown>}
3824
+ * @returns {Promise<Ref<JxScopeValue>>}
3331
3825
  */
3332
3826
  async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: string, base?: string) {
3333
- const config: JxScope = {};
3334
- for (const [k, v] of Object.entries(def)) {
3335
- if (!EXTERNAL_RESERVED.has(k)) {
3336
- config[k] = v;
3337
- }
3338
- }
3827
+ const config = externalConfig(def);
3339
3828
 
3340
- const hasTemplates = Object.values(config).some((v: unknown) => isTemplateString(v));
3829
+ const hasTemplates = Object.values(config).some((v) => isTemplateString(v));
3341
3830
 
3342
3831
  /** @param {JxScope} resolvedConfig */
3343
3832
  const doResolve = (resolvedConfig: JxScope) =>
@@ -3351,15 +3840,16 @@ async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: stri
3351
3840
  }),
3352
3841
  headers: { "Content-Type": "application/json" },
3353
3842
  method: "POST",
3354
- }).then((r) => {
3843
+ }).then((r): Promise<JsonValue> => {
3355
3844
  if (!r.ok) {
3356
3845
  throw new Error(`Jx dev proxy ${r.status} for "${key}"`);
3357
3846
  }
3847
+ // The dev proxy answers with the resolved value serialized as JSON.
3358
3848
  return r.json();
3359
3849
  });
3360
3850
 
3361
3851
  // Always wrap in ref for reactivity
3362
- const s: Ref<unknown> = ref(null);
3852
+ const s: Ref<JxScopeValue> = ref(null);
3363
3853
  if (hasTemplates) {
3364
3854
  effect(() => {
3365
3855
  const resolvedConfig: JxScope = {};
@@ -3367,17 +3857,17 @@ async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: stri
3367
3857
  resolvedConfig[k] = isTemplateString(v) ? evaluateTemplate(v, state) : v;
3368
3858
  }
3369
3859
  doResolve(resolvedConfig)
3370
- .then((value: unknown) => {
3860
+ .then((value) => {
3371
3861
  s.value = value;
3372
3862
  })
3373
- .catch((error: unknown) => console.error("Jx dev proxy:", error));
3863
+ .catch((error) => console.error("Jx dev proxy:", error));
3374
3864
  });
3375
3865
  } else {
3376
3866
  doResolve(config)
3377
- .then((value: unknown) => {
3867
+ .then((value) => {
3378
3868
  s.value = value;
3379
3869
  })
3380
- .catch((error: unknown) => console.error("Jx dev proxy:", error));
3870
+ .catch((error) => console.error("Jx dev proxy:", error));
3381
3871
  }
3382
3872
  return s;
3383
3873
  }
@@ -3393,15 +3883,15 @@ async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: stri
3393
3883
  * write started it. The proxy and a host-registered module share it, so a reactive entry's calls
3394
3884
  * are ordered and reported the same way on either path.
3395
3885
  *
3396
- * @param {Ref<unknown>} s - The entry's value
3397
- * @param {(args: JxScope) => unknown} invoke - One call, with resolved arguments: it may return a
3398
- * value or a promise, or throw
3886
+ * @param {Ref<JxScopeValue>} s - The entry's value
3887
+ * @param {ServerCall} invoke - One call, with resolved arguments: it may return a value or a
3888
+ * promise, or throw
3399
3889
  * @param {string} label - The `console.error` prefix a failed call is reported under
3400
3890
  * @returns {(args: JxScope) => void} Starts a call
3401
3891
  */
3402
3892
  function latestServerCall(
3403
- s: Ref<unknown>,
3404
- invoke: (args: JxScope) => unknown,
3893
+ s: Ref<JxScopeValue>,
3894
+ invoke: ServerCall,
3405
3895
  label: string,
3406
3896
  ): (args: JxScope) => void {
3407
3897
  let seq = 0;
@@ -3410,15 +3900,15 @@ function latestServerCall(
3410
3900
  const n = seq;
3411
3901
  /* The executor runs before the constructor returns, so the call still starts synchronously,
3412
3902
  and a throw inside it rejects this promise instead of escaping to the caller. */
3413
- new Promise<unknown>((settle) => {
3903
+ new Promise<JsonValue>((settle) => {
3414
3904
  settle(invoke(args));
3415
3905
  })
3416
- .then((result: unknown) => {
3906
+ .then((result) => {
3417
3907
  if (n === seq) {
3418
3908
  s.value = result;
3419
3909
  }
3420
3910
  })
3421
- .catch((error: unknown) => {
3911
+ .catch((error) => {
3422
3912
  if (n === seq) {
3423
3913
  console.error(label, error);
3424
3914
  }
@@ -3426,6 +3916,13 @@ function latestServerCall(
3426
3916
  };
3427
3917
  }
3428
3918
 
3919
+ /**
3920
+ * One call of a `timing: "server"` function, with its resolved arguments. Its answer is JSON, or a
3921
+ * promise of JSON: on the proxy path the answer crosses the wire as JSON, and a host-registered
3922
+ * module honours the same contract so the two paths are interchangeable.
3923
+ */
3924
+ type ServerCall = (args: JxScope) => JsonValue | Promise<JsonValue>;
3925
+
3429
3926
  /**
3430
3927
  * Resolve a timing: "server" entry. The runtime never imports a server module itself: every call is
3431
3928
  * a POST to the host's `/__jx_server__` proxy, which runs the function server-side with `env`, so
@@ -3438,7 +3935,7 @@ function latestServerCall(
3438
3935
  * @param {JxScope} state
3439
3936
  * @param {string} key
3440
3937
  * @param {string} [base]
3441
- * @returns {Promise<unknown>}
3938
+ * @returns {Promise<Ref<JxScopeValue>>}
3442
3939
  */
3443
3940
  async function resolveServerFunction(
3444
3941
  def: JxServerFnDef,
@@ -3461,23 +3958,26 @@ async function resolveServerFunction(
3461
3958
  if (!candidate) {
3462
3959
  throw new Error(`Jx: export "${exportName}" not found in "${src}" for "${key}"`);
3463
3960
  }
3464
- if (typeof candidate !== "function") {
3961
+ if (!isFunction(candidate)) {
3465
3962
  throw new TypeError(`Jx: "${exportName}" from "${src}" is not a function`);
3466
3963
  }
3467
- const fn = candidate as (args: JxScope) => unknown;
3964
+ /* SAFETY: the guard proved a function, and a function accepts any call. A `timing: "server"`
3965
+ export is written to be called through the proxy, which hands it one arguments object and
3966
+ sends its answer back as JSON, so that is the contract it is held to in process too. */
3967
+ const fn = candidate as ServerCall;
3468
3968
 
3469
3969
  const rawArgs = def.arguments ?? {};
3470
- const hasReactiveArg = Object.values(rawArgs).some((v: unknown) => isRefObj(v));
3970
+ const hasReactiveArg = Object.values(rawArgs).some((v) => isRefValue(v));
3471
3971
  const resolveArgs = () => {
3472
3972
  const args: JxScope = {};
3473
3973
  for (const [k, v] of Object.entries(rawArgs)) {
3474
- args[k] = isRefObj(v) ? resolveRef(v.$ref, state) : v;
3974
+ args[k] = isRefValue(v) ? resolveRef(v.$ref, state) : v;
3475
3975
  }
3476
3976
  return args;
3477
3977
  };
3478
3978
 
3479
3979
  // Always wrap in ref for reactivity
3480
- const s: Ref<unknown> = ref(null);
3980
+ const s: Ref<JxScopeValue> = ref(null);
3481
3981
  if (hasReactiveArg) {
3482
3982
  const call = latestServerCall(s, fn, "Jx server function:");
3483
3983
  effect(() => {
@@ -3499,25 +3999,25 @@ async function resolveServerFunction(
3499
3999
  * the entry's last value and logs, and the answer to a superseded call is dropped, so a slow answer
3500
4000
  * to an earlier call can never overwrite a later one (`latestServerCall`).
3501
4001
  *
3502
- * @param {JxScope} def
4002
+ * @param {JxServerFnDef} def
3503
4003
  * @param {JxScope} state
3504
4004
  * @param {string} key
3505
4005
  * @param {string} [base]
3506
- * @returns {Promise<unknown>}
4006
+ * @returns {Promise<Ref<JxScopeValue>>}
3507
4007
  */
3508
4008
  async function resolveServerFunctionViaProxy(
3509
- def: JxScope,
4009
+ def: JxServerFnDef,
3510
4010
  state: JxScope,
3511
4011
  key: string,
3512
4012
  base?: string,
3513
4013
  ) {
3514
4014
  const rawArgs = def.arguments ?? {};
3515
- const hasReactiveArg = Object.values(rawArgs).some((v: unknown) => isRefObj(v));
4015
+ const hasReactiveArg = Object.values(rawArgs).some((v) => isRefValue(v));
3516
4016
 
3517
4017
  const resolveArgs = () => {
3518
4018
  const args: JxScope = {};
3519
4019
  for (const [k, v] of Object.entries(rawArgs)) {
3520
- args[k] = isRefObj(v) ? resolveRef((v as { $ref: string }).$ref, state) : v;
4020
+ args[k] = isRefValue(v) ? resolveRef(v.$ref, state) : v;
3521
4021
  }
3522
4022
  return args;
3523
4023
  };
@@ -3533,15 +4033,16 @@ async function resolveServerFunctionViaProxy(
3533
4033
  }),
3534
4034
  headers: { "Content-Type": "application/json" },
3535
4035
  method: "POST",
3536
- }).then((r) => {
4036
+ }).then((r): Promise<JsonValue> => {
3537
4037
  if (!r.ok) {
3538
4038
  throw new Error(`Jx server proxy ${r.status} for "${key}"`);
3539
4039
  }
4040
+ // The proxy answers with the function's result serialized as JSON.
3540
4041
  return r.json();
3541
4042
  });
3542
4043
 
3543
4044
  // Always wrap in ref for reactivity
3544
- const s: Ref<unknown> = ref(null);
4045
+ const s: Ref<JxScopeValue> = ref(null);
3545
4046
  const call = latestServerCall(s, doResolve, "Jx server proxy:");
3546
4047
  if (hasReactiveArg) {
3547
4048
  effect(() => {
@@ -3563,10 +4064,11 @@ async function resolveServerFunctionViaProxy(
3563
4064
  *
3564
4065
  * @param {string} ref
3565
4066
  * @param {JxScope} state - Reactive scope proxy (or child scope)
3566
- * @returns {unknown}
4067
+ * @returns {JxScopeValue}
3567
4068
  */
3568
4069
  export function resolveRef(refPath: string, state: JxScope) {
3569
- if (typeof refPath !== "string") {
4070
+ // A `$ref` read from a document is not schema-validated, so a non-string one passes through.
4071
+ if (!isString(refPath)) {
3570
4072
  return refPath;
3571
4073
  }
3572
4074
  if (refPath.startsWith("$map/") || refPath.startsWith("#/$map/")) {
@@ -3575,9 +4077,11 @@ export function resolveRef(refPath: string, state: JxScope) {
3575
4077
  rendered ZERO rows with no warning. */
3576
4078
  const parts = (refPath.startsWith("#/") ? refPath.slice(2) : refPath).split("/");
3577
4079
  const [, key] = parts; // "item" or "index"
3578
- const map = state.$map as Record<string, unknown> | undefined;
4080
+ /* SAFETY: `$map` holds only the row record `renderMappedArrayInto` binds (see `rowContext`), an
4081
+ ordinary object read here by whichever member the ref names. */
4082
+ const map = state.$map as Record<string, JxScopeValue> | undefined;
3579
4083
  const base = map?.[key!] ?? state[`$map/${key}`];
3580
- return parts.length > 2 ? getPath(base, parts.slice(2).join("/")) : base;
4084
+ return parts.length > 2 ? readPath(base, parts.slice(2).join("/")) : base;
3581
4085
  }
3582
4086
  if (refPath.startsWith("#/state/")) {
3583
4087
  // One call, not a hand-split leading token: slicing at the first `/` skipped unescaping it, so
@@ -3592,10 +4096,10 @@ export function resolveRef(refPath: string, state: JxScope) {
3592
4096
  return readPath(state, refPath.slice("parent#/".length));
3593
4097
  }
3594
4098
  if (refPath.startsWith("window#/")) {
3595
- return getPath(globalThis.window, refPath.slice("window#/".length));
4099
+ return readPath(globalThis.window, refPath.slice("window#/".length));
3596
4100
  }
3597
4101
  if (refPath.startsWith("document#/")) {
3598
- return getPath(globalThis.document, refPath.slice("document#/".length));
4102
+ return readPath(globalThis.document, refPath.slice("document#/".length));
3599
4103
  }
3600
4104
  return readPath(state, refPath) ?? null;
3601
4105
  }
@@ -3606,32 +4110,12 @@ export function resolveRef(refPath: string, state: JxScope) {
3606
4110
  * Check if v is a Vue ref (including computed).
3607
4111
  *
3608
4112
  * @param {unknown} v
3609
- * @returns {boolean}
4113
+ * @returns {v is Ref<unknown>}
3610
4114
  */
3611
- export function isSignal(v: unknown) {
4115
+ export function isSignal(v: unknown): v is Ref<unknown> {
3612
4116
  return isRef(v);
3613
4117
  }
3614
4118
 
3615
- /**
3616
- * @param {unknown} v
3617
- * @returns {v is import("@jxsuite/schema/types").JxRef}
3618
- */
3619
- function isRefObj(v: unknown): v is JxRef {
3620
- return isRefValue(v);
3621
- }
3622
-
3623
- /**
3624
- * Walk a path off a value. Delegates to the one tokenizer (`pointer.ts`) — this used to split on
3625
- * `/[./]/`, which no other path in the codebase agreed with.
3626
- *
3627
- * @param {unknown} obj
3628
- * @param {string} path
3629
- * @returns {unknown}
3630
- */
3631
- function getPath(obj: unknown, path: string) {
3632
- return readPath(obj, path);
3633
- }
3634
-
3635
4119
  /**
3636
4120
  * Write an observed attribute into element state, coerced by the type the state entry already holds
3637
4121
  * (spec §16.5): a number parses, a boolean is presence (`"false"` counts as absent), and anything
@@ -3651,40 +4135,39 @@ function absorbAttribute(
3651
4135
  state: JxScope,
3652
4136
  name: string,
3653
4137
  value: string | null,
3654
- defaults?: ReadonlyMap<string, unknown>,
4138
+ defaults?: ReadonlyMap<string, JxScopeValue>,
3655
4139
  ): void {
3656
4140
  const camelKey = name.replaceAll(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
3657
4141
  const current = state[camelKey];
3658
- if (typeof current === "boolean") {
4142
+ if (isBoolean(current)) {
3659
4143
  // Presence IS the value for a boolean, so a removal is already spelled by the same rule.
3660
4144
  state[camelKey] = value !== null && value !== "false";
3661
4145
  return;
3662
4146
  }
3663
4147
  if (value === null) {
3664
4148
  const declared = defaults?.get(camelKey);
3665
- state[camelKey] = declared === undefined ? (typeof current === "number" ? 0 : "") : declared;
4149
+ state[camelKey] = declared === undefined ? (isNumber(current) ? 0 : "") : declared;
3666
4150
  return;
3667
4151
  }
3668
- state[camelKey] = typeof current === "number" ? Number(value) : value;
4152
+ state[camelKey] = isNumber(current) ? Number(value) : value;
3669
4153
  }
3670
4154
 
3671
4155
  /**
3672
4156
  * The declared default of every state entry that has one, by state key.
3673
4157
  *
3674
4158
  * @param def The element definition
3675
- * @returns {Map<string, unknown>} Key → default
4159
+ * @returns {Map<string, JxScopeValue>} Key → default
3676
4160
  */
3677
- function declaredDefaults(def: JxElement): Map<string, unknown> {
3678
- const out = new Map<string, unknown>();
4161
+ function declaredDefaults(def: JxElement): Map<string, JxScopeValue> {
4162
+ const out = new Map<string, JxScopeValue>();
3679
4163
  for (const [key, entry] of Object.entries(def.state ?? {})) {
3680
- if (entry !== null && typeof entry === "object") {
3681
- const obj = entry as Record<string, unknown>;
3682
- if ("default" in obj) {
3683
- out.set(key, obj["default"]);
4164
+ if (isNonNullObject(entry)) {
4165
+ if ("default" in entry) {
4166
+ out.set(key, entry.default);
3684
4167
  continue;
3685
4168
  }
3686
4169
  // A computed entry has no default: its value is produced, and a removal leaves it alone.
3687
- if (COMPUTED_STATE_KEYS.some((marker) => marker in obj)) {
4170
+ if (COMPUTED_STATE_KEYS.some((marker) => marker in entry)) {
3688
4171
  continue;
3689
4172
  }
3690
4173
  }
@@ -3745,6 +4228,27 @@ function instanceSupplies(el: HTMLElement, key: string): boolean {
3745
4228
  return kebab !== key && el.hasAttribute(kebab);
3746
4229
  }
3747
4230
 
4231
+ /**
4232
+ * The platform's setter for `key` on an element, when the name is one the platform already owns.
4233
+ *
4234
+ * @param {HTMLElement} el
4235
+ * @param {string} key
4236
+ * @returns {((value: JxScopeValue) => void) | undefined}
4237
+ */
4238
+ function nativeSetter(el: HTMLElement, key: string): ((value: JxScopeValue) => void) | undefined {
4239
+ for (
4240
+ let proto: object | null = Reflect.getPrototypeOf(el);
4241
+ proto;
4242
+ proto = Reflect.getPrototypeOf(proto)
4243
+ ) {
4244
+ const descriptor = Object.getOwnPropertyDescriptor(proto, key);
4245
+ if (descriptor) {
4246
+ return descriptor.set;
4247
+ }
4248
+ }
4249
+ return undefined;
4250
+ }
4251
+
3748
4252
  function refusePrivateProp(key: string, where?: string): boolean {
3749
4253
  if (!isPrivateStateKey(key)) {
3750
4254
  return false;
@@ -3772,12 +4276,12 @@ export function _resetPrivatePropWarnings(): void {
3772
4276
  * @returns {JxScope}
3773
4277
  */
3774
4278
  function mergeProps(def: JxElement, parentState: JxScope): JxScope {
3775
- const child = Object.create(parentState) as JxScope;
4279
+ const child = inheritScope(parentState);
3776
4280
  for (const [k, v] of Object.entries(def.$props ?? {})) {
3777
4281
  if (refusePrivateProp(k, "$props")) {
3778
4282
  continue;
3779
4283
  }
3780
- child[k] = isRefObj(v) ? resolveRef(v.$ref, parentState) : v;
4284
+ child[k] = isRefValue(v) ? resolveRef(v.$ref, parentState) : v;
3781
4285
  }
3782
4286
  return child;
3783
4287
  }
@@ -3818,12 +4322,12 @@ export type { CssBuildOptions, CssKeyframeBlock, CssRule, CssRuleTarget } from "
3818
4322
  * custom property survives it: `--fooBar` and `--foo-bar` are two different properties, and
3819
4323
  * kebab-casing one renames it out from under the `var()` that reads it.
3820
4324
  *
3821
- * @param {Record<string, unknown> | object} rules
4325
+ * @param {JxStyle} rules
3822
4326
  * @returns {string}
3823
4327
  */
3824
- export function toCSSText(rules: Record<string, unknown> | object) {
4328
+ export function toCSSText(rules: JxStyle) {
3825
4329
  return Object.entries(rules)
3826
- .filter(([k, v]) => !isNestedSelectorKey(k) && (v === null || typeof v !== "object"))
4330
+ .filter(([k, v]) => !isNestedSelectorKey(k) && !isNonNullObject(v))
3827
4331
  .map(([p, v]) => `${cssPropertyName(p)}: ${canvasStyleValue(String(v))}`)
3828
4332
  .join("; ");
3829
4333
  }
@@ -3891,9 +4395,9 @@ export async function redefineElement(doc: JxDocument, baseUrl?: string): Promis
3891
4395
  * before `buildScope`, and re-set it every render so a stale map from a previous document cannot
3892
4396
  * leak.
3893
4397
  *
3894
- * @param {Record<string, string>} map
4398
+ * @param {Record<string, string> | null} [map] - Absent or null clears it
3895
4399
  */
3896
- export function setRootMedia(map: Record<string, string>): void {
4400
+ export function setRootMedia(map?: Record<string, string> | null): void {
3897
4401
  _rootMedia = map ?? {};
3898
4402
  }
3899
4403
 
@@ -3911,7 +4415,7 @@ async function registerElements(
3911
4415
  ) {
3912
4416
  for (const entry of elements) {
3913
4417
  // Bare string: npm package side-effect import (registers custom elements)
3914
- if (typeof entry === "string") {
4418
+ if (isString(entry)) {
3915
4419
  try {
3916
4420
  // Bare specifiers need a URL path for the browser; the dev server resolves
3917
4421
  // /node_modules/<pkg> to the package entry point via exports/module/main.
@@ -3923,7 +4427,7 @@ async function registerElements(
3923
4427
  }
3924
4428
  continue;
3925
4429
  }
3926
- if (!isRefObj(entry)) {
4430
+ if (!isRefValue(entry)) {
3927
4431
  continue;
3928
4432
  }
3929
4433
  const { href } = new URL(entry.$ref, base);
@@ -3951,7 +4455,7 @@ async function registerElements(
3951
4455
  * @param {import("@jxsuite/schema/types").JxHeadEntry[]} entries
3952
4456
  * @param {string} _base - Document base URL for resolving relative paths
3953
4457
  */
3954
- function injectHead(entries: JxHeadEntry[], _base: string) {
4458
+ function injectHead(entries: readonly (JxHeadEntry | null | undefined)[], _base: string) {
3955
4459
  for (const entry of entries) {
3956
4460
  if (!entry || !entry.tagName) {
3957
4461
  continue;
@@ -3961,7 +4465,7 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
3961
4465
  // Resolve href/src: bare npm specifiers -> /node_modules/ path
3962
4466
  for (const key of ["href", "src"] as const) {
3963
4467
  const v = attrs[key];
3964
- if (typeof v !== "string" || v === "") {
4468
+ if (!isString(v) || v === "") {
3965
4469
  continue;
3966
4470
  }
3967
4471
  if (!v.startsWith("/") && !v.startsWith(".") && !v.startsWith("http")) {
@@ -3987,10 +4491,9 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
3987
4491
  if (entry.textContent) {
3988
4492
  // An object is a structured data block (JSON-LD, site-architecture.md §8.5). The compiler
3989
4493
  // Serializes it the same way, so the interpreted page and the built page agree.
3990
- el.textContent =
3991
- typeof entry.textContent === "object"
3992
- ? JSON.stringify(entry.textContent, null, 2)
3993
- : entry.textContent;
4494
+ el.textContent = isNonNullObject(entry.textContent)
4495
+ ? JSON.stringify(entry.textContent, null, 2)
4496
+ : entry.textContent;
3994
4497
  }
3995
4498
  document.head.append(el);
3996
4499
  }
@@ -3999,18 +4502,18 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
3999
4502
  // ─── Instantiation chain (spec.md §16.9) ─────────────────────────────────────
4000
4503
 
4001
4504
  /**
4002
- * How deep component nesting may go before an instance refuses to render.
4003
- *
4004
- * The same number the compiler holds in `MAX_COMPONENT_NESTING` (`@jxsuite/compiler`, `shared.ts`),
4005
- * and for the same reason: the cycle check in {@link extendInstancePath} catches a definition that
4006
- * renders ITSELF — same tag, same props — which is the only shape that can never terminate, while a
4007
- * self-reference whose props change at every level is data-driven recursion that a `$switch` may or
4008
- * may not bottom out. The seen-set cannot tell those apart, so this cap is what turns a forgotten
4009
- * stop into a diagnostic instead of a page that never settles. It is written twice because the
4010
- * compiler depends on the runtime and not the other way round, and the runtime's browser bundle
4011
- * cannot reach into the build tool; spec.md §16.9 is the one statement both are held to, and the
4012
- * compiler's `nesting-cap-agreement.test.ts` asserts its copy against this export, so the two
4013
- * numbers cannot part without a red test in the workspace that owns the copy.
4505
+ * How deep component nesting may go before an instance refuses to render, and before the compiler
4506
+ * (`@jxsuite/compiler`, `shared.ts`, which imports this export) refuses to continue a build.
4507
+ *
4508
+ * The cycle check in {@link extendInstancePath} catches a definition that renders ITSELF — same tag,
4509
+ * same props — which is the only shape that can never terminate, while a self-reference whose props
4510
+ * change at every level (`depth: "${state.depth + 1}"`) is data-driven recursion that a `$switch`
4511
+ * may or may not bottom out. The seen-set cannot tell those apart, so this cap is what turns a
4512
+ * forgotten stop into a diagnostic instead of a page that never settles or a build that overflows
4513
+ * its stack. Thirty-two is far beyond any component library's real depth and far below the call
4514
+ * stack's. It lives here because the compiler depends on the runtime and not the other way round,
4515
+ * so one declaration serves both sides of spec.md §16.9: a page that builds is a page that
4516
+ * renders.
4014
4517
  */
4015
4518
  export const MAX_COMPONENT_NESTING = 32;
4016
4519
 
@@ -4018,7 +4521,7 @@ export const MAX_COMPONENT_NESTING = 32;
4018
4521
  interface InstanceFrame {
4019
4522
  tag: string;
4020
4523
  /** Every value the instance supplied that its definition's state read (§16.9). */
4021
- props: Record<string, unknown>;
4524
+ props: JxScope;
4022
4525
  }
4023
4526
 
4024
4527
  /**
@@ -4071,6 +4574,30 @@ const ROOT_INSTANCE_PATH: InstancePath = { expansion: 0, frames: [] };
4071
4574
  */
4072
4575
  let _instancePath: InstancePath = ROOT_INSTANCE_PATH;
4073
4576
 
4577
+ /**
4578
+ * Say that an instance was not rendered, on the two surfaces a host can reach (spec §16.4, §16.9):
4579
+ * the console, and a bubbling, composed `jx-error` `ErrorEvent` from the element carrying the
4580
+ * error. Nothing is said for a host no longer in the document, whose replacement reports for it.
4581
+ *
4582
+ * @param {HTMLElement} host - The instance
4583
+ * @param {string} tagName - Its tag, for the console line
4584
+ * @param {Error} error - Why
4585
+ */
4586
+ function reportUnrendered(host: HTMLElement, tagName: string, error: Error): void {
4587
+ if (!host.isConnected) {
4588
+ return;
4589
+ }
4590
+ console.error(`Jx: <${tagName}> was not rendered:`, error);
4591
+ host.dispatchEvent(
4592
+ new ErrorEvent("jx-error", {
4593
+ bubbles: true,
4594
+ composed: true,
4595
+ error,
4596
+ message: error.message,
4597
+ }),
4598
+ );
4599
+ }
4600
+
4074
4601
  /**
4075
4602
  * Run `fn` with `path` as the chain, so every instance that CONNECTS during it reads that chain.
4076
4603
  *
@@ -4119,11 +4646,11 @@ function reexpand(path: InstancePath): InstancePath {
4119
4646
  * ended. Inside plain data the comparison IS the JSON form, so a member JSON cannot see (a
4120
4647
  * function, a `Map` two levels down) is not seen here either; §16.9 says so.
4121
4648
  *
4122
- * @param {unknown} a
4123
- * @param {unknown} b
4649
+ * @param {JxScopeValue} a
4650
+ * @param {JxScopeValue} b
4124
4651
  * @returns {boolean}
4125
4652
  */
4126
- function samePropValue(a: unknown, b: unknown): boolean {
4653
+ function samePropValue(a: JxScopeValue, b: JxScopeValue): boolean {
4127
4654
  if (Object.is(a, b)) {
4128
4655
  return true;
4129
4656
  }
@@ -4140,11 +4667,11 @@ function samePropValue(a: unknown, b: unknown): boolean {
4140
4667
  /**
4141
4668
  * Whether a value is an array or a plain object — the shapes whose JSON form is the whole value.
4142
4669
  *
4143
- * @param {unknown} value
4144
- * @returns {value is object}
4670
+ * @param {JxScopeValue} value
4671
+ * @returns {boolean}
4145
4672
  */
4146
- function isPlainData(value: unknown): value is object {
4147
- if (typeof value !== "object" || value === null) {
4673
+ function isPlainData(value: JxScopeValue): boolean {
4674
+ if (!isNonNullObject(value)) {
4148
4675
  return false;
4149
4676
  }
4150
4677
  const raw: unknown = toRaw(value);
@@ -4220,19 +4747,19 @@ const _definedSources = new Set<string>();
4220
4747
 
4221
4748
  export async function defineElement(source: string | JxDocument, baseUrl?: string) {
4222
4749
  let base = baseUrl;
4223
- let doc = source;
4224
- if (typeof source === "string") {
4750
+ let source_: JxDocument;
4751
+ if (isString(source)) {
4225
4752
  base = new URL(source, base ?? location.href).href;
4226
4753
  if (_definedSources.has(base)) {
4227
4754
  return;
4228
4755
  }
4229
4756
  _definedSources.add(base);
4230
- doc = await resolve(source);
4757
+ source_ = await resolve(source);
4758
+ } else {
4759
+ source_ = source;
4231
4760
  }
4232
4761
  base ??= location.href;
4233
4762
 
4234
- const source_: JxDocument = doc as JxDocument;
4235
-
4236
4763
  const { tagName } = source_;
4237
4764
  if (!tagName || !tagName.includes("-")) {
4238
4765
  throw new Error(`Jx defineElement: tagName "${tagName}" must contain a hyphen`);
@@ -4284,7 +4811,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4284
4811
  the same values render the same thing, forever. Only a value the definition's state READS
4285
4812
  is recorded — a `$props` key no state entry declares changes nothing about the render, so
4286
4813
  a definition that "varies" one at each level is still rendering itself. */
4287
- const supplied: Record<string, unknown> = {};
4814
+ const supplied: JxScope = {};
4288
4815
 
4289
4816
  // The definition as it stands NOW — a redefinition since this class was made is honoured.
4290
4817
  const live = _elementDefs.get(tagName) ?? { base: initialBase, doc: initialDef };
@@ -4312,8 +4839,13 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4312
4839
  const propsAttr = this.dataset.jxProps;
4313
4840
  if (propsAttr) {
4314
4841
  try {
4315
- const props = JSON.parse(propsAttr) as Record<string, unknown>;
4316
- for (const [key, val] of Object.entries(props)) {
4842
+ /* SAFETY: `JSON.parse` answers the JSON value the attribute encodes, or throws, which the
4843
+ catch below ignores as it always has. */
4844
+ const props = JSON.parse(propsAttr) as JsonValue;
4845
+ /* The site build writes an object. An array or string payload still yields its index
4846
+ keys, and a number, boolean or null yields none, as `Object.entries` always gave. */
4847
+ const entries = isNonNullObject(props) || isString(props) ? Object.entries(props) : [];
4848
+ for (const [key, val] of entries) {
4317
4849
  if (refusePrivateProp(key, "data-jx-props")) {
4318
4850
  continue;
4319
4851
  }
@@ -4351,13 +4883,10 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4351
4883
  if (isPrivateStateKey(key)) {
4352
4884
  continue;
4353
4885
  }
4354
- if (
4355
- key in this &&
4356
- (this as Record<string, unknown>)[key] !== undefined &&
4357
- instanceSupplies(this, key)
4358
- ) {
4359
- state[key] = (this as Record<string, unknown>)[key];
4360
- supplied[key] = (this as Record<string, unknown>)[key];
4886
+ const own = elementProperties(this);
4887
+ if (key in this && own[key] !== undefined && instanceSupplies(this, key)) {
4888
+ state[key] = own[key];
4889
+ supplied[key] = own[key];
4361
4890
  }
4362
4891
  }
4363
4892
 
@@ -4386,18 +4915,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4386
4915
  ownPath = extendInstancePath(enclosingPath, { props: supplied, tag: tagName });
4387
4916
  } catch (error) {
4388
4917
  this.replaceChildren();
4389
- if (!this.isConnected) {
4390
- return;
4391
- }
4392
- console.error(`Jx: <${tagName}> was not rendered:`, error);
4393
- this.dispatchEvent(
4394
- new ErrorEvent("jx-error", {
4395
- bubbles: true,
4396
- composed: true,
4397
- error,
4398
- message: error instanceof Error ? error.message : String(error),
4399
- }),
4400
- );
4918
+ reportUnrendered(this, tagName, error instanceof Error ? error : new Error(String(error)));
4401
4919
  return;
4402
4920
  }
4403
4921
  /*
@@ -4409,21 +4927,26 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4409
4927
  if (isPrivateStateKey(key)) {
4410
4928
  continue;
4411
4929
  }
4412
- if (!(key in HTMLElement.prototype)) {
4413
- Object.defineProperty(this, key, {
4414
- configurable: true,
4415
- get: () => state[key],
4416
- set: (v: unknown) => {
4417
- state[key] = v;
4418
- },
4419
- });
4420
- }
4930
+ /* A name the platform already owns (`title`, `lang`, `hidden`) keeps its native setter, so
4931
+ the attribute still reflects — and ALSO reaches state. Skipping it entirely meant a prop
4932
+ delivered after connection (a template over data that resolves asynchronously) wrote
4933
+ only the attribute: the instance kept the empty value it connected with, and a card's
4934
+ `title` never rendered. */
4935
+ const native = nativeSetter(this, key);
4936
+ Object.defineProperty(this, key, {
4937
+ configurable: true,
4938
+ get: () => state[key],
4939
+ set: (v: JxScopeValue) => {
4940
+ native?.call(this, v);
4941
+ state[key] = v;
4942
+ },
4943
+ });
4421
4944
  }
4422
4945
 
4423
4946
  this._state = state;
4424
4947
 
4425
4948
  // Capture light DOM children (for slot distribution) before rendering
4426
- const slottedChildren = [...this.childNodes];
4949
+ const slottedChildren = captureSlotted(this);
4427
4950
  this.replaceChildren();
4428
4951
 
4429
4952
  /* Custom elements default to `display: inline`; a Jx container behaves like a `<div>`, so
@@ -4432,14 +4955,16 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4432
4955
  without one. At (0,1,0) a consumer's rule, a cascade layer or a parent's descendant rule
4433
4956
  all win normally, and the definition's own `display` still wins because it is written into
4434
4957
  the same rule after this. */
4435
- const defStyle = declaresDisplay(def.style) ? def.style : { display: "block", ...def.style };
4958
+ const ownStyle = def.style;
4959
+ const defStyle =
4960
+ ownStyle && declaresDisplay(ownStyle) ? ownStyle : { display: "block", ...ownStyle };
4436
4961
  /* Definition first, call site second, so the call site wins at equal specificity by source
4437
4962
  order — the promise §9.2 already makes for base-before-nested. Merged into ONE call
4438
4963
  because each `applyStyle` releases what the last one interned. */
4439
- const hostStyle = mergeStyle(defStyle as JxStyle, callSiteStyles.get(this));
4964
+ const hostStyle = mergeStyle(defStyle, callSiteStyles.get(this));
4440
4965
 
4441
4966
  // Render template into light DOM (once, not in effect — inner effects handle reactivity)
4442
- applyStyle(this, hostStyle, (state["$media"] as Record<string, string>) ?? {}, state);
4967
+ applyStyle(this, hostStyle, scopeMedia(state), state);
4443
4968
  applyAttributes(this, def.attributes ?? {}, state);
4444
4969
  bindDefinitionHandlers(this, def, state);
4445
4970
 
@@ -4452,46 +4977,122 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4452
4977
  bindProperty(this, "textContent", def.textContent, state);
4453
4978
  }
4454
4979
  const children = Array.isArray(def.children) ? def.children : [];
4455
- /* An instance the studio stamped is part of the page being edited, so everything the
4456
- definition draws inside it is too — and the de-link rules cannot see that from the nodes
4457
- themselves. Restored rather than cleared, because one definition may render another. */
4458
- const wasInside = _canvasInsideStampedHost;
4459
- _canvasInsideStampedHost ||= this.dataset.jxPath !== undefined;
4460
- /* And the chain, for the same span: an instance this definition draws connects inside
4461
- `this.append` below and reads it there ({@link underInstancePath}). */
4462
- try {
4463
- underInstancePath(ownPath, () => {
4464
- for (const childDef of children) {
4465
- this.append(renderNode(childDef, state));
4466
- }
4467
- });
4468
- } finally {
4469
- _canvasInsideStampedHost = wasInside;
4470
- }
4471
-
4472
- // Slot distribution (light DOM)
4473
- distributeSlots(this, slottedChildren);
4980
+ /* A render that throws is reported the way a refusal is (§16.4), for the same reason: thrown
4981
+ onward from this async callback it is an unhandled rejection naming no element. The
4982
+ children drawn before the throw stay; the one that threw, its later siblings and `onMount`
4983
+ do not happen, and slot content is still distributed into the slots that were drawn. */
4984
+ const draw = (): Error | null => {
4985
+ /* An instance the studio stamped is part of the page being edited, so everything the
4986
+ definition draws inside it is too — and the de-link rules cannot see that from the nodes
4987
+ themselves. Restored rather than cleared, because one definition may render another. */
4988
+ const wasInside = _canvasInsideStampedHost;
4989
+ _canvasInsideStampedHost ||= this.dataset.jxPath !== undefined;
4990
+ /* And the chain, for the same span: an instance this definition draws connects inside
4991
+ `this.append` below and reads it there ({@link underInstancePath}). */
4992
+ let failure: Error | null = null;
4993
+ try {
4994
+ underInstancePath(ownPath, () => {
4995
+ for (const childDef of children) {
4996
+ this.append(renderNode(childDef, state));
4997
+ }
4998
+ });
4999
+ } catch (error) {
5000
+ failure = error instanceof Error ? error : new Error(String(error));
5001
+ } finally {
5002
+ _canvasInsideStampedHost = wasInside;
5003
+ }
5004
+ // Slot distribution (light DOM)
5005
+ distributeSlots(this, slottedChildren);
5006
+ return failure;
5007
+ };
4474
5008
 
4475
5009
  /* Lifecycle: onMount, with the HOST as its second argument.
4476
5010
  A sidecar that must reach the element it belongs to had no way to: the runtime handed it
4477
5011
  the scope alone, so elements resorted to dispatching a `jx-ready` event at themselves and
4478
5012
  catching it with a root handler purely to learn `currentTarget`. The host is what the
4479
- callback needed; the event round trip was the workaround. */
4480
- const { onMount } = state;
4481
- if (typeof onMount === "function") {
4482
- queueMicrotask(() => (onMount as (s: JxScope, host: HTMLElement) => unknown)(state, this));
5013
+ callback needed; the event round trip was the workaround.
5014
+ And with the host as its RECEIVER, which is how the compiled element calls it: there a
5015
+ state function is an arrow inside the element class, so a body's `this` is the element.
5016
+ Called bare here, a body written for the built site (jxsuite.com's search reads
5017
+ `const host = this`) read the window instead, and threw on every page that drew it live. */
5018
+ const mounted = (): void => {
5019
+ const { onMount } = state;
5020
+ if (isScopeHook(onMount)) {
5021
+ queueMicrotask(() => onMount.call(this, state, this));
5022
+ }
5023
+ };
5024
+
5025
+ let attempt = runScoped(draw);
5026
+ const failure = attempt.result;
5027
+ if (!failure) {
5028
+ mounted();
5029
+ return;
4483
5030
  }
5031
+ reportUnrendered(this, tagName, failure);
5032
+ /* …and it is drawn again when what it was given changes (§16.4). A prop bound to data that
5033
+ arrives later (a collection the page resolves after its first render, a row a list fills
5034
+ in) reaches the instance AFTER it connected, so the render that threw was a render of
5035
+ the empty values, not of the instance. Without a retry it stayed blank for good even once
5036
+ every value it needed was there. The watch reads the definition's authored entries only,
5037
+ the ones a prop can write: a computed entry is what threw, and reading it here would
5038
+ throw again. Writes are coalesced into one microtask, because a parent sets its props one
5039
+ at a time. The same error is reported once, not once per attempt. */
5040
+ const inputs = [...declaredDefaults(def).keys()].filter((key) => !isPrivateStateKey(key));
5041
+ let lastMessage = failure.message;
5042
+ let queued = false;
5043
+ const watch = effect(
5044
+ () => {
5045
+ for (const key of inputs) {
5046
+ void state[key];
5047
+ }
5048
+ },
5049
+ {
5050
+ scheduler: () => {
5051
+ if (queued) {
5052
+ return;
5053
+ }
5054
+ queued = true;
5055
+ queueMicrotask(() => {
5056
+ queued = false;
5057
+ if (!this.isConnected) {
5058
+ watch.effect.stop();
5059
+ return;
5060
+ }
5061
+ watch();
5062
+ attempt.stop();
5063
+ this.replaceChildren();
5064
+ pauseTracking();
5065
+ try {
5066
+ attempt = runScoped(draw);
5067
+ } finally {
5068
+ resetTracking();
5069
+ }
5070
+ const again = attempt.result;
5071
+ if (!again) {
5072
+ watch.effect.stop();
5073
+ mounted();
5074
+ } else if (again.message !== lastMessage) {
5075
+ lastMessage = again.message;
5076
+ reportUnrendered(this, tagName, again);
5077
+ }
5078
+ });
5079
+ },
5080
+ },
5081
+ );
4484
5082
  }
4485
5083
 
4486
5084
  disconnectedCallback() {
4487
- if (typeof this._state?.onUnmount === "function") {
4488
- (this._state.onUnmount as (s: JxScope) => unknown)(this._state);
5085
+ // Called as a method, so a hook that reads `this` sees the scope.
5086
+ const state = this._state;
5087
+ if (state && isScopeHook(state.onUnmount)) {
5088
+ state.onUnmount(state);
4489
5089
  }
4490
5090
  }
4491
5091
 
4492
5092
  adoptedCallback() {
4493
- if (typeof this._state?.onAdopted === "function") {
4494
- (this._state.onAdopted as (s: JxScope) => unknown)(this._state);
5093
+ const state = this._state;
5094
+ if (state && isScopeHook(state.onAdopted)) {
5095
+ state.onAdopted(state);
4495
5096
  }
4496
5097
  }
4497
5098
 
@@ -4543,11 +5144,25 @@ function renderCustomElementWithProps(
4543
5144
  element is an asset reference authored at the USAGE site, so it belongs to the document that
4544
5145
  wrote it — a media prop the component forwards into its own `<img>` is resolved separately,
4545
5146
  inside the component's render, against the same context. */
4546
- const write = (resolved: unknown): void => {
4547
- (el as unknown as Record<string, unknown>)[key] =
4548
- typeof resolved === "string" ? canvasAssetValue(el.tagName, key, resolved) : resolved;
5147
+ const write = (resolved: JxScopeValue): void => {
5148
+ const value = isString(resolved) ? canvasAssetValue(el.tagName, key, resolved) : resolved;
5149
+ elementProperties(el)[key] = value;
5150
+ /* A declared prop that shares its name with an `HTMLElement` property (`title`, `lang`) gets
5151
+ no forwarding accessor (§16.2), so the write above lands on the element and never reaches
5152
+ the instance's state. Connection reads it once; a binding that changes AFTER connection
5153
+ (a row whose data arrives later) was lost, and the instance went on drawing the value it
5154
+ connected with. The binding is the parent's `$props`, addressed to the definition's prop,
5155
+ so it writes that prop's state. */
5156
+ const instanceState = instanceStateOf(el);
5157
+ if (
5158
+ instanceState &&
5159
+ key in HTMLElement.prototype &&
5160
+ Object.hasOwn(toRaw(instanceState), key)
5161
+ ) {
5162
+ instanceState[key] = value;
5163
+ }
4549
5164
  };
4550
- if (isRefObj(val)) {
5165
+ if (isRefValue(val)) {
4551
5166
  const refVal = val;
4552
5167
  write(resolveRef(refVal.$ref, state));
4553
5168
  // Reactive forwarding: re-set the property when the source changes
@@ -4555,7 +5170,7 @@ function renderCustomElementWithProps(
4555
5170
  write(resolveRef(refVal.$ref, state));
4556
5171
  });
4557
5172
  } else if (isTemplateString(val)) {
4558
- effect(() => {
5173
+ bindTemplate(val, () => {
4559
5174
  write(evaluateTemplate(val, state));
4560
5175
  });
4561
5176
  } else {
@@ -4571,7 +5186,7 @@ function renderCustomElementWithProps(
4571
5186
  if (def.style) {
4572
5187
  callSiteStyles.set(el, def.style);
4573
5188
  }
4574
- applyStyle(el, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
5189
+ applyStyle(el, def.style ?? {}, scopeMedia(state), state);
4575
5190
  applyAttributes(el, def.attributes ?? {}, state);
4576
5191
  /* And its ordinary properties and handlers — `id`, `hidden`, `className`, an `onselect` — exactly
4577
5192
  as an ordinary element gets them. A custom element used in a document is still an element in
@@ -4596,47 +5211,184 @@ function renderCustomElementWithProps(
4596
5211
  }
4597
5212
 
4598
5213
  /**
4599
- * Light DOM slot distribution.
5214
+ * Whether a node is an element, by its `nodeType` (which, unlike `instanceof`, holds across
5215
+ * realms).
4600
5216
  *
4601
- * @param {HTMLElement} host
4602
- * @param {ChildNode[]} slottedChildren
4603
- */
4604
- function distributeSlots(host: HTMLElement, slottedChildren: ChildNode[]) {
4605
- /* No early return when nothing was slotted. A slot leaves no node WHETHER OR NOT it received
4606
- anything — an unmatched one unwraps to its own fallback children, and a host given nothing at
4607
- all is just every slot unmatched. Returning early here left the slot standing in exactly the
4608
- case an element most wants to detect: `[part="label"]:empty` answered false for a control with
4609
- no label, because the surviving slot was still a child. */
4610
- const slots = host.querySelectorAll("slot");
4611
- if (slots.length === 0) {
4612
- return;
4613
- }
5217
+ * @param {Node} node
5218
+ * @returns {node is Element}
5219
+ */
5220
+ function isElementNode(node: Node): node is Element {
5221
+ return node.nodeType === Node.ELEMENT_NODE;
5222
+ }
5223
+
5224
+ /**
5225
+ * Whether a node is a text node, by its `nodeType`.
5226
+ *
5227
+ * @param {Node} node
5228
+ * @returns {node is Text}
5229
+ */
5230
+ function isTextNode(node: Node): node is Text {
5231
+ return node.nodeType === Node.TEXT_NODE;
5232
+ }
5233
+
5234
+ /**
5235
+ * Whether a node is a comment, by its `nodeType`.
5236
+ *
5237
+ * @param {Node} node
5238
+ * @returns {node is Comment}
5239
+ */
5240
+ function isCommentNode(node: Node): node is Comment {
5241
+ return node.nodeType === Node.COMMENT_NODE;
5242
+ }
5243
+
5244
+ // ─── Slot distribution (spec.md §8.5) ─────────────────────────────────────────
5245
+
5246
+ /**
5247
+ * Text that is only HTML whitespace (space, tab, line feed, form feed, carriage return): never slot
5248
+ * content. A non-breaking space is not in the set, and `+` keeps an empty text node out of it, so
5249
+ * the text node a binding is about to write stays with its group.
5250
+ */
5251
+ export const SLOT_WHITESPACE = /^[\t\n\f\r ]+$/u;
4614
5252
 
4615
- const named = new Map<string | null, ChildNode[]>();
4616
- const unnamed: ChildNode[] = [];
5253
+ /** The word that opens a prerendered slot group's comment bracket: `<!--jx-slot n name-->`. */
5254
+ export const SLOT_MARK = "jx-slot";
4617
5255
 
4618
- for (const child of slottedChildren) {
4619
- if (child.nodeType === Node.ELEMENT_NODE && (child as Element).getAttribute("slot")) {
4620
- const name = (child as Element).getAttribute("slot");
4621
- if (!named.has(name)) {
4622
- named.set(name, []);
5256
+ /** The attribute a prerendered instance carries when its slot groups are bracketed by `n`. */
5257
+ export const SLOT_ATTR = "data-jx-slots";
5258
+
5259
+ /**
5260
+ * The `Symbol.for` key of the shared group record: node → the slot group it belongs to.
5261
+ *
5262
+ * Shared through `globalThis` because each compiled component module carries its own copy of the
5263
+ * distribution helper, and a record one of them (or this runtime) writes has to be read by
5264
+ * whichever element captures the node next.
5265
+ */
5266
+ export const SLOT_RECORD_KEY = "jx.slot.group";
5267
+
5268
+ /** The record itself; read before a node's own `slot` attribute, and deleted once read. */
5269
+ const slotRecord: WeakMap<Node, string> = (() => {
5270
+ const key = Symbol.for(SLOT_RECORD_KEY);
5271
+ /* SAFETY: the one value ever stored under this registered symbol is this WeakMap, written by
5272
+ this runtime or by a compiled module's inlined helper (`slotHelperSource` in the compiler). */
5273
+ const holder = globalThis as typeof globalThis & Record<symbol, WeakMap<Node, string>>;
5274
+ holder[key] ??= new WeakMap<Node, string>();
5275
+ return holder[key];
5276
+ })();
5277
+
5278
+ /**
5279
+ * The children an element distributes into its slots, taken before its template renders.
5280
+ *
5281
+ * A prerendered instance is the exception, and the reason this is a function. Its children are the
5282
+ * prerendered markup, and re-rendering would capture the component's own template as slot content.
5283
+ * The prerender therefore stamps it `data-jx-slots="n"` and brackets every group it placed with
5284
+ * `<!--jx-slot n name-->` … `<!--/jx-slot n-->`; this returns exactly the nodes inside this host's
5285
+ * brackets, recording each one's group name, and removes the attribute. The id is needed because a
5286
+ * forwarded group sits inside a nested instance's own brackets, and the name because a nested
5287
+ * instance that upgrades first must not regroup the enclosing instance's nodes.
5288
+ *
5289
+ * @param {Element} host
5290
+ * @returns {ChildNode[]}
5291
+ */
5292
+ export function captureSlotted(host: Element): ChildNode[] {
5293
+ const id = host.getAttribute(SLOT_ATTR);
5294
+ if (id === null) {
5295
+ return [...host.childNodes];
5296
+ }
5297
+ host.removeAttribute(SLOT_ATTR);
5298
+ const opener = `${SLOT_MARK} ${id} `;
5299
+ const closer = `/${SLOT_MARK} ${id}`;
5300
+ const openers: Comment[] = [];
5301
+ const walker = host.ownerDocument.createTreeWalker(host, NodeFilter.SHOW_COMMENT);
5302
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
5303
+ if (isCommentNode(n) && n.data.startsWith(opener)) {
5304
+ openers.push(n);
5305
+ }
5306
+ }
5307
+ const captured: ChildNode[] = [];
5308
+ for (const open of openers) {
5309
+ const name = decodeURIComponent(open.data.slice(opener.length));
5310
+ for (let n = open.nextSibling; n; n = n.nextSibling) {
5311
+ if (isCommentNode(n) && n.data === closer) {
5312
+ break;
4623
5313
  }
4624
- (named.get(name) as ChildNode[]).push(child);
4625
- } else {
4626
- unnamed.push(child);
5314
+ slotRecord.set(n, name);
5315
+ captured.push(n);
4627
5316
  }
4628
5317
  }
5318
+ return captured;
5319
+ }
5320
+
5321
+ /**
5322
+ * Whether a group holds content: an element, or a text node that is not empty. A comment (a lit
5323
+ * part marker, a repeater's anchor) travels with its group but does not stop the fallback showing.
5324
+ *
5325
+ * @param {readonly ChildNode[]} group
5326
+ * @returns {boolean}
5327
+ */
5328
+ function slotGroupHasContent(group: readonly ChildNode[]): boolean {
5329
+ return group.some((n) => isElementNode(n) || (isTextNode(n) && n.data !== ""));
5330
+ }
4629
5331
 
4630
- /* A `<slot>` UNWRAPS: its matches stand in its place and the slot itself leaves no node.
4631
- It used to survive, holding its matches as children. The box tree looked right, because a
4632
- slot is `display: contents` — but the SELECTOR tree did not, so every `& > x` rule in a
4633
- definition silently stopped matching a slotted child, which is now a grandchild. Measured on
4634
- the field row: 68.40px where the class form gives 88.40px, and its help text 80px wide
4635
- instead of 260px, with `[data-span]` dead. An unmatched slot unwraps to its own fallback
4636
- children, which is what the platform's slot does when nothing is assigned to it. */
5332
+ /**
5333
+ * Light-DOM slot distribution: replace each `<slot>` under `host` with the captured children of its
5334
+ * group, or with its own fallback children when that group has no content (spec.md §8.5).
5335
+ *
5336
+ * - **Group.** A node's group is its record when it has one, else an element's `slot` attribute, else
5337
+ * the unnamed group (`""`). Whitespace-only text is discarded: compiled hosts are parsed HTML
5338
+ * full of formatting whitespace, and whitespace placed in a part makes its `:empty` false.
5339
+ * - **Place.** Slots are taken in tree order and the FIRST of a name takes its group; a later one of
5340
+ * the same name shows its fallback. That is the DOM's "find a slot", so the answer does not
5341
+ * change when a component moves to a shadow root. A slot inside a fallback that was not shown
5342
+ * still takes its name, which keeps the static prerender's answer the same as this one.
5343
+ * - **Forward.** A slot written directly inside ANOTHER custom element's children is that element's
5344
+ * child. What it places there is recorded with the slot's own group (its record, else its `slot`
5345
+ * attribute), so the nested element distributes it by that name whether it captures before or
5346
+ * after this runs.
5347
+ *
5348
+ * No early return when nothing was slotted: a slot leaves no node either way, and a surviving slot
5349
+ * made `[part="label"]:empty` false for a control with no label. A slot is replaced, not filled:
5350
+ * the selector tree is what a definition's `& > x` rules match, and a slot left as a wrapper turned
5351
+ * every slotted child into a grandchild (§16.6).
5352
+ *
5353
+ * @param {ParentNode} host
5354
+ * @param {readonly ChildNode[]} captured
5355
+ */
5356
+ export function distributeSlots(host: ParentNode, captured: readonly ChildNode[]): void {
5357
+ const slots = [...host.querySelectorAll("slot")];
5358
+ if (slots.length === 0) {
5359
+ return;
5360
+ }
5361
+ const groups = new Map<string, ChildNode[]>();
5362
+ for (const n of captured) {
5363
+ if (isTextNode(n) && SLOT_WHITESPACE.test(n.data)) {
5364
+ continue;
5365
+ }
5366
+ const recorded = slotRecord.get(n);
5367
+ slotRecord.delete(n);
5368
+ const name = recorded ?? (isElementNode(n) ? (n.getAttribute("slot") ?? "") : "");
5369
+ let group = groups.get(name);
5370
+ if (!group) {
5371
+ group = [];
5372
+ groups.set(name, group);
5373
+ }
5374
+ group.push(n);
5375
+ }
5376
+ const taken = new Set<string>();
4637
5377
  for (const slot of slots) {
4638
- const name = slot.getAttribute("name");
4639
- const matches = name ? (named.get(name) ?? []) : unnamed;
4640
- slot.replaceWith(...(matches.length > 0 ? matches : [...slot.childNodes]));
5378
+ const name = slot.getAttribute("name") ?? "";
5379
+ let group: ChildNode[] = [];
5380
+ if (!taken.has(name)) {
5381
+ taken.add(name);
5382
+ group = groups.get(name) ?? [];
5383
+ }
5384
+ const placed = slotGroupHasContent(group) ? group : [...group, ...slot.childNodes];
5385
+ const parent = slot.parentElement;
5386
+ if (parent && parent !== host && parent.localName.includes("-")) {
5387
+ const forwarded = slotRecord.get(slot) ?? slot.getAttribute("slot") ?? "";
5388
+ for (const n of placed) {
5389
+ slotRecord.set(n, forwarded);
5390
+ }
5391
+ }
5392
+ slot.replaceWith(...placed);
4641
5393
  }
4642
5394
  }