@jxsuite/runtime 4.0.3 → 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
@@ -1,16 +1,17 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
3
  * Jx — JSON-native reactive web component runtime
4
- * @version 3.0.0
5
- * @license MIT
6
4
  *
7
5
  * Four-step pipeline:
8
- * 1. resolve — fetch JSON source (or accept raw object)
9
- * 2. buildScope — state detection + reactive proxy construction
10
- * 3. render — walk resolved tree, build DOM, wire reactive effects
11
- * 4. output — append to target
6
+ *
7
+ * 1. `resolve` — fetch JSON source (or accept raw object)
8
+ * 2. `buildScope` — state detection + reactive proxy construction
9
+ * 3. `render` — walk resolved tree, build DOM, wire reactive effects
10
+ * 4. `output` — append to target
12
11
  *
13
12
  * @module jx
13
+ * @license MIT
14
+ * @version 3.0.0
14
15
  */
15
16
 
16
17
  import {
@@ -38,38 +39,52 @@ import {
38
39
  import { evaluateExpression, evaluateOperand, isMutating } from "./expression.ts";
39
40
  import { readPath } from "./pointer.ts";
40
41
  import type {
41
- DynamicClass,
42
42
  JxContext,
43
43
  JxEventHandler,
44
+ JxHostScope,
44
45
  JxMount,
45
46
  JxMountOptions,
46
47
  JxPath,
47
48
  JxRenderOptions,
49
+ JxRowContext,
48
50
  JxScope,
51
+ JxScopeFunction,
52
+ JxScopeValue,
49
53
  } from "./types.ts";
50
54
  import {
51
55
  bodyReturnsValue,
52
56
  hasStructuredBody,
57
+ isBoolean,
53
58
  isExpressionDef,
59
+ isFunction,
54
60
  isFunctionDef,
55
61
  isJsonObject,
56
62
  isMappedArray,
57
63
  isNamedFormulaDef,
64
+ isNestedStyle,
65
+ isNonNullObject,
66
+ isNumber,
58
67
  isPrivateStateKey,
59
68
  isPrototypeDef,
60
69
  isRef as isRefValue,
61
70
  isServerFnDef,
71
+ isString,
62
72
  isTagExpression,
63
73
  isTemplateString,
64
74
  paramNames,
65
75
  } from "@jxsuite/schema/guards";
76
+ import type { TagNameSlot } from "@jxsuite/schema/guards";
66
77
  import { formatSrcset, parseSrcset } from "@jxsuite/schema/asset-paths";
67
78
  import { runStatements } from "./statements.ts";
68
79
  import { readCookie, serializeCookie } from "./cookie.ts";
69
80
  import type {
81
+ CemParameter,
82
+ JsonValue,
70
83
  JxAttributeValue,
84
+ JxClassCtorDef,
71
85
  JxClassDef,
72
86
  JxDocument,
87
+ JxDocumentValue,
73
88
  JxElement,
74
89
  JxFunctionDef,
75
90
  JxHeadEntry,
@@ -105,8 +120,8 @@ let _schemaVersionWarned = false;
105
120
  * runtime supports. A relative or non-hosted `$schema` (local dev schema paths) is ignored. This is
106
121
  * a diagnostic only — there is no document-format migration story yet (see spec §3.2 / §21.4).
107
122
  */
108
- function checkSchemaVersion($schema: unknown): void {
109
- if (_schemaVersionWarned || typeof $schema !== "string") {
123
+ function checkSchemaVersion($schema: JxDocumentValue | undefined): void {
124
+ if (_schemaVersionWarned || !isString($schema)) {
110
125
  return;
111
126
  }
112
127
  const match = $schema.match(/\/schema\/v(\d+)\b/);
@@ -126,7 +141,7 @@ export async function Jx(
126
141
  ) {
127
142
  const base = options?.base
128
143
  ? new URL(options.base, location.href).href
129
- : typeof source === "string"
144
+ : isString(source)
130
145
  ? new URL(source, location.href).href
131
146
  : location.href;
132
147
  const doc = await resolve(source);
@@ -138,18 +153,57 @@ export async function Jx(
138
153
  _rootMedia = doc.$media;
139
154
  }
140
155
 
141
- const mounted = await mount(doc, target, {
142
- base,
143
- ...(options?.onNodeCreated ? { onNodeCreated: options.onNodeCreated } : {}),
144
- ...(options?.signal ? { signal: options.signal } : {}),
145
- });
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);
146
164
  return mounted.scope;
147
165
  }
148
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
+
149
187
  /** Host-scope keys the runtime owns: `$media`, `$map`, the `$`-locals and `#`-private entries. */
150
188
  const HOST_SCOPE_RESERVED = /^[$#]/;
151
189
 
152
- 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 {
153
207
  for (const key of Object.keys(scope)) {
154
208
  if (HOST_SCOPE_RESERVED.test(key)) {
155
209
  throw new TypeError(
@@ -175,26 +229,28 @@ function assertHostScope(scope: Record<string, unknown>): void {
175
229
  * @returns {Promise<JxMount>}
176
230
  * @docs extending/embedding/runtime-host
177
231
  */
178
- export async function mount(
232
+ export async function mount<S extends JxHostScope<S> = JxScope>(
179
233
  doc: JxDocument,
180
234
  target: ParentNode,
181
- options: JxMountOptions = {},
235
+ options: JxMountOptions<S> = {},
182
236
  ): Promise<JxMount> {
183
- const hostScope = options.scope ?? {};
237
+ const hostScope: JxScope = options.scope ? hostScopeOf(options.scope) : {};
184
238
  assertHostScope(hostScope);
185
239
  const base = options.base ? new URL(options.base, location.href).href : location.href;
186
240
  const ctx: JxContext = {
187
241
  base,
188
242
  media: options.media ?? doc.$media ?? null,
189
243
  root: null,
190
- ...(options.resolver ? { resolver: options.resolver } : {}),
191
- ...(options.skipServerFunctions === undefined
192
- ? {}
193
- : { skipServerFunctions: options.skipServerFunctions }),
194
- ...(options.skipAutoRequests === undefined
195
- ? {}
196
- : { skipAutoRequests: options.skipAutoRequests }),
197
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
+ }
198
254
  checkSchemaVersion(doc.$schema);
199
255
 
200
256
  // Register custom elements declared in $elements (depth-first)
@@ -218,8 +274,10 @@ export async function mount(
218
274
  }
219
275
  options.onNodeCreated?.(el, path, def, state);
220
276
  },
221
- ...(options.onNodeMoved ? { onNodeMoved: options.onNodeMoved } : {}),
222
277
  };
278
+ if (options.onNodeMoved) {
279
+ renderOptions.onNodeMoved = options.onNodeMoved;
280
+ }
223
281
  const { result: root, stop } = runScoped(() => renderNode(doc, scope, renderOptions));
224
282
  ctx.root = root;
225
283
 
@@ -231,11 +289,18 @@ export async function mount(
231
289
  }
232
290
  disposed = true;
233
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();
234
299
  stop();
235
- if (mounted && typeof scope.onUnmount === "function") {
236
- (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);
237
303
  }
238
- root.remove();
239
304
  };
240
305
 
241
306
  if (options.signal?.aborted) {
@@ -245,8 +310,8 @@ export async function mount(
245
310
  options.signal?.addEventListener("abort", dispose, { once: true });
246
311
  target.append(root);
247
312
  mounted = true;
248
- if (typeof scope.onMount === "function") {
249
- (scope.onMount as (s: JxScope) => unknown)(scope);
313
+ if (isScopeHook(scope.onMount)) {
314
+ scope.onMount(scope);
250
315
  }
251
316
  }
252
317
  return { scope, root, elements, dispose };
@@ -278,8 +343,20 @@ export function preloadDocument(url: string, doc: JxDocument): void {
278
343
  }
279
344
  }
280
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
+
281
358
  export async function resolve(source: string | JxDocument, ctx?: JxContext): Promise<JxDocument> {
282
- if (typeof source !== "string") {
359
+ if (!isString(source)) {
283
360
  return source;
284
361
  }
285
362
  // A mount's resolver answers for THIS mount and its hit never enters the shared cache.
@@ -304,16 +381,16 @@ export async function resolve(source: string | JxDocument, ctx?: JxContext): Pro
304
381
  * Checked on the content type rather than by sniffing the body: a host that says `text/html`
305
382
  * has told us plainly, and saying so costs nothing.
306
383
  */
307
- // Optional-chained: a response that carries no headers at all is one we have no EVIDENCE
308
- // About, and guessing is worse than proceeding. Every real `Response` has them.
309
- const contentType = res.headers?.get("Content-Type") ?? "";
384
+ const contentType = responseContentType(res);
310
385
  if (contentType.includes("text/html")) {
311
386
  throw new Error(
312
387
  `Jx: ${source} returned HTML, not a document — the host answered a missing file with its ` +
313
388
  `app shell (a single-page fallback), so this path is not served.`,
314
389
  );
315
390
  }
316
- // 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. */
317
394
  return (await res.json()) as JxDocument;
318
395
  });
319
396
  _resolveCache.set(source, p);
@@ -377,11 +454,6 @@ function resolveProxyPath(path: string): string {
377
454
  return _resolveToken ? `${path}?token=${encodeURIComponent(_resolveToken)}` : path;
378
455
  }
379
456
 
380
- /** @deprecated No longer needed — ContentCollection/ContentEntry resolve via generic class path */
381
- export function setSkipContentResolution(_v: boolean) {
382
- // No-op retained for API compatibility
383
- }
384
-
385
457
  /**
386
458
  * Observe runtime-created reactive values: runs `fn` immediately inside a reactive effect and
387
459
  * re-runs it whenever a tracked value changes; returns a disposer. Dep tracking in @vue/reactivity
@@ -400,6 +472,12 @@ export function observeScope(fn: () => void): () => void {
400
472
  return () => scope.stop();
401
473
  }
402
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
+
403
481
  /**
404
482
  * Run `fn` inside a detached reactive scope of THIS module's reactivity instance, returning its
405
483
  * result plus a disposer that stops every effect `fn` created. Callers that render via
@@ -409,11 +487,13 @@ export function observeScope(fn: () => void): () => void {
409
487
  * its stop() silently leaks every binding effect of the superseded render.
410
488
  *
411
489
  * @param {() => T} fn - Work that may create reactive effects (typically a renderNode call).
412
- * @returns {{ result: T; stop: () => void }} The callback result and the scope disposer.
490
+ * @returns {ScopedRun<T>} The callback result and the scope disposer.
413
491
  */
414
- export function runScoped<T>(fn: () => T): { result: T; stop: () => void } {
492
+ export function runScoped<T>(fn: () => T): ScopedRun<T> {
415
493
  const scope = effectScope(true);
416
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. */
417
497
  const result = scope.run(fn) as T;
418
498
  return { result, stop: () => scope.stop() };
419
499
  } catch (error) {
@@ -437,14 +517,14 @@ export function setCanvasViewportTranspose(on: boolean) {
437
517
  }
438
518
 
439
519
  const VIEWPORT_UNIT_RE = /(-?\d*\.?\d+)(?:s|l|d)?v(h|w|min|max|i|b)\b/gi;
440
- const VIEWPORT_UNIT_MAP: Record<string, string> = {
441
- b: "cqb",
442
- h: "cqh",
443
- i: "cqi",
444
- max: "cqmax",
445
- min: "cqmin",
446
- w: "cqw",
447
- };
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
+ ]);
448
528
 
449
529
  /**
450
530
  * Transpose CSS viewport units → container-query units in a value string, but only when the
@@ -457,7 +537,8 @@ export function transposeCanvasUnits(value: string): string {
457
537
  }
458
538
  return value.replace(
459
539
  VIEWPORT_UNIT_RE,
460
- (_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}`}`,
461
542
  );
462
543
  }
463
544
 
@@ -656,15 +737,15 @@ function canvasAssetValue(tagName: string, key: string, value: string): string {
656
737
  const name = key.toLowerCase();
657
738
  if (SRCSET_KEYS.has(name)) {
658
739
  const candidates = parseSrcset(value);
659
- let changed = false;
660
740
  const next = candidates.map((candidate) => {
661
741
  const resolved = _canvasAssetResolver?.(candidate.url, name) ?? null;
662
742
  if (resolved === null || resolved === candidate.url) {
663
743
  return candidate;
664
744
  }
665
- changed = true;
666
745
  return { descriptor: candidate.descriptor, url: resolved };
667
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]);
668
749
  return changed ? formatSrcset(next) : value;
669
750
  }
670
751
  if (!isAssetKey(tagName, name)) {
@@ -726,18 +807,22 @@ export function setStampPropBindings(on: boolean) {
726
807
  const PURE_STATE_TEMPLATE = /^\s*\$\{\s*state\.(\w+)\s*\}\s*$/;
727
808
 
728
809
  /** The state key of an invertible `textContent` binding, or null (mixed template, deep path). */
729
- 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 {
730
815
  if (!_stampPropBindings || key !== "textContent") {
731
816
  return null;
732
817
  }
733
818
  let sub: string | null = null;
734
- if (isRefObj(val)) {
819
+ if (isRefValue(val)) {
735
820
  if (!val.$ref.startsWith("#/state/")) {
736
821
  return null;
737
822
  }
738
823
  const rest = val.$ref.slice("#/state/".length);
739
824
  sub = rest && !rest.includes("/") ? rest : null;
740
- } else if (typeof val === "string") {
825
+ } else if (isString(val)) {
741
826
  sub = PURE_STATE_TEMPLATE.exec(val)?.[1] ?? null;
742
827
  }
743
828
  if (!sub) {
@@ -747,11 +832,11 @@ function boundPropKey(key: string, val: unknown, state: JxScope): string | null
747
832
  // In the raw scope (auto-unwrapped by the reactive proxy), and function/object entries (handlers,
748
833
  // $prototype instances) are not prop-overridable either — an instance $props write against any of
749
834
  // Them would clobber the definition's behavior, so their bindings are never marked editable.
750
- const stored = (toRaw(state) as Record<string, unknown>)[sub];
751
- if (isRef(stored) || typeof stored === "function") {
835
+ const stored = toRaw(state)[sub];
836
+ if (isRef(stored) || isFunction(stored)) {
752
837
  return null;
753
838
  }
754
- if (typeof stored === "object" && stored !== null) {
839
+ if (isNonNullObject(stored)) {
755
840
  return null;
756
841
  }
757
842
  return sub;
@@ -789,7 +874,7 @@ export async function buildScope(
789
874
  configurable: true,
790
875
  enumerable: true,
791
876
  get: () => parentScope[key],
792
- set: (value: unknown) => {
877
+ set: (value: JxScopeValue) => {
793
878
  parentScope[key] = value;
794
879
  },
795
880
  });
@@ -818,7 +903,7 @@ export async function buildScope(
818
903
  // First pass: collect naked values, expanded defaults, plain objects
819
904
  for (const [key, def] of Object.entries(defs)) {
820
905
  // 1. String value
821
- if (typeof def === "string") {
906
+ if (isString(def)) {
822
907
  if (!def.includes("${")) {
823
908
  raw[key] = def;
824
909
  } // Shape 1: naked string
@@ -826,7 +911,7 @@ export async function buildScope(
826
911
  }
827
912
 
828
913
  // 2. Number, boolean, null
829
- if (typeof def === "number" || typeof def === "boolean" || def === null) {
914
+ if (isNumber(def) || isBoolean(def) || def === null) {
830
915
  raw[key] = def;
831
916
  continue;
832
917
  }
@@ -838,7 +923,7 @@ export async function buildScope(
838
923
  }
839
924
 
840
925
  // 4. Object
841
- if (typeof def === "object") {
926
+ if (isNonNullObject(def)) {
842
927
  if (def.$prototype) {
843
928
  continue;
844
929
  } // Handled in later passes
@@ -862,10 +947,10 @@ export async function buildScope(
862
947
  // Wrap in Vue reactive proxy — deep reactivity from this point on
863
948
  const state = reactive(raw);
864
949
 
865
- // Second pass: template strings → computed
950
+ // Second pass: template strings → computed, a single expression keeping its own type (§5.3)
866
951
  for (const [key, def] of Object.entries(defs)) {
867
- if (typeof def === "string" && def.includes("${")) {
868
- state[key] = computed(() => evaluateTemplate(def, state));
952
+ if (isString(def) && def.includes("${")) {
953
+ state[key] = computed(() => evaluateValueTemplate(def, state));
869
954
  }
870
955
  }
871
956
 
@@ -875,21 +960,9 @@ export async function buildScope(
875
960
  const node = def.$expression;
876
961
  if (isNamedFormulaDef(def)) {
877
962
  // A named formula: callable with positional args mapped onto its declared parameters.
878
- const params = def.parameters as (string | { name?: string; default?: unknown })[];
879
- state[key] = (...argValues: unknown[]) => {
880
- const args: Record<string, unknown> = {};
881
- for (const [i, p] of params.entries()) {
882
- const name = typeof p === "string" ? p : (p?.name ?? "");
883
- if (!name) {
884
- continue;
885
- }
886
- args[name] =
887
- argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
888
- ? p.default
889
- : argValues[i];
890
- }
891
- return evaluateExpression(node, state, null, { args });
892
- };
963
+ const params = def.parameters;
964
+ state[key] = (...argValues: JxScopeValue[]) =>
965
+ evaluateExpression(node, state, null, { args: bindPositionalArgs(params, argValues) });
893
966
  } else if (isMutating(node.operator)) {
894
967
  const handler: JxEventHandler = (s, event) => evaluateExpression(node, s, event);
895
968
  state[key] = handler;
@@ -906,23 +979,16 @@ export async function buildScope(
906
979
  // $args names (invoked via the call operator); without → an event handler.
907
980
  const { body } = def;
908
981
  if (Array.isArray(def.parameters) && def.parameters.length > 0) {
909
- const params = def.parameters as (string | { name?: string; default?: unknown })[];
910
- state[key] = (...argValues: unknown[]) => {
911
- const args: Record<string, unknown> = {};
912
- for (const [i, p] of params.entries()) {
913
- const name = typeof p === "string" ? p : (p?.name ?? "");
914
- if (!name) {
915
- continue;
916
- }
917
- args[name] =
918
- argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
919
- ? p.default
920
- : argValues[i];
921
- }
922
- return runStatements(body, state, null, { args, target: dispatchRoot });
923
- };
982
+ const params = def.parameters;
983
+ state[key] = (...argValues: JxScopeValue[]) =>
984
+ runStatements(body, state, null, {
985
+ args: bindPositionalArgs(params, argValues),
986
+ target: dispatchRoot,
987
+ });
924
988
  } else {
925
- 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 => {
926
992
  void runStatements(body, s, event ?? null, { target: dispatchRoot });
927
993
  };
928
994
  state[key] = handler;
@@ -939,7 +1005,8 @@ export async function buildScope(
939
1005
  }
940
1006
  }
941
1007
 
942
- // Fifth pass: timing: "server" entries (dev mode — execute client-side, boundary unenforced)
1008
+ /* Fifth pass: timing: "server" entries, each called through the host's /__jx_server__ proxy, or
1009
+ in process only when the host registered the module itself (spec.md §11.4) */
943
1010
  if (!(ctx?.skipServerFunctions ?? _serverFnConfig.skip)) {
944
1011
  for (const [key, def] of Object.entries(defs)) {
945
1012
  if (isServerFnDef(def)) {
@@ -958,14 +1025,42 @@ export async function buildScope(
958
1025
  return state;
959
1026
  }
960
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
+
961
1056
  /**
962
1057
  * Check whether an object contains any JSON Schema keywords. Used to discriminate Shape 2b (pure
963
1058
  * type definition) from Shape 1 (naked object).
964
1059
  *
965
- * @param {JxScope} obj
1060
+ * @param obj A state entry object, as the document wrote it
966
1061
  * @returns {boolean}
967
1062
  */
968
- function hasSchemaKeywords(obj: object) {
1063
+ function hasSchemaKeywords(obj: Readonly<Record<string, JxDocumentValue | undefined>>) {
969
1064
  for (const k of Object.keys(obj)) {
970
1065
  if (SCHEMA_KEYWORDS.has(k)) {
971
1066
  return true;
@@ -975,23 +1070,151 @@ function hasSchemaKeywords(obj: object) {
975
1070
  }
976
1071
  export { hasSchemaKeywords };
977
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
+
978
1092
  /**
979
1093
  * Evaluate a template string in the context of state and optional $map. Templates use
980
- * `state.varName` and `$map.item` syntax.
1094
+ * `state.varName`, `$map.item` (or `item`) and the page context `$page` / `$site`.
981
1095
  *
982
1096
  * @param {string} str
983
1097
  * @param {JxScope} state
984
1098
  * @returns {string}
985
1099
  */
986
1100
  function evaluateTemplate(str: string, state: JxScope): string {
987
- const $map = state?.$map as { item?: unknown; index?: number } | undefined;
988
- const fn = new Function("state", "$map", "item", "index", `return \`${str}\``) as (
989
- state: JxScope,
990
- $map: unknown,
991
- item: unknown,
992
- index: number | undefined,
993
- ) => string;
994
- 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;
995
1218
  }
996
1219
 
997
1220
  /**
@@ -1032,50 +1255,137 @@ export function isSingleExpression(str: string): boolean {
1032
1255
  }
1033
1256
 
1034
1257
  /**
1035
- * Evaluate a template for an ATTRIBUTE, keeping the expression's own type.
1036
- *
1037
- * `evaluateTemplate` interpolates into a string, which is what text wants and what an attribute
1038
- * cannot afford: `"${state.expanded}"` has to arrive as the boolean `false`, or `booleanAttrValue`
1039
- * never sees a boolean, `open="false"` is written, and HTML reads that as open. Only a single
1040
- * 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
1041
1268
  * `evaluateStaticTemplate` draws in the compiler, and drawing it identically is what keeps a
1042
1269
  * prerendered page and the same document rendered live in agreement.
1043
1270
  *
1044
1271
  * @param {string} str - Template source
1045
1272
  * @param {JxScope} state - Reactive scope
1046
- * @returns {unknown} The expression's value, or the interpolated string
1273
+ * @returns {JxScopeValue} The expression's value, or the interpolated string
1047
1274
  */
1048
- function evaluateAttrTemplate(str: string, state: JxScope): unknown {
1275
+ function evaluateValueTemplate(str: string, state: JxScope): JxScopeValue {
1049
1276
  if (!isSingleExpression(str)) {
1050
1277
  return evaluateTemplate(str, state);
1051
1278
  }
1052
- const $map = state?.$map as { item?: unknown; index?: number } | undefined;
1053
- const fn = new Function("state", "$map", "item", "index", `return (${str.slice(2, -1)})`) as (
1054
- state: JxScope,
1055
- $map: unknown,
1056
- item: unknown,
1057
- index: number | undefined,
1058
- ) => unknown;
1059
- 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));
1060
1291
  }
1061
1292
 
1062
1293
  // ─── Step 2b: Function resolution (Shape 4) ─────────────────────────────────
1063
1294
 
1064
- /** Shape of a dynamically imported module: named exports plus an optional default. */
1065
- 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;
1326
+
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
+ }
1066
1371
 
1067
- /** Minimal contract an externally-imported resolver class may implement. */
1068
- interface ExternalClassInstance {
1069
- value?: unknown;
1070
- resolve?: () => unknown;
1071
- subscribe?: (cb: (newVal: unknown) => void) => void;
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;
1072
1382
  }
1073
1383
 
1074
1384
  /** Module cache for $src imports (shared with external class resolution). */
1075
1385
  const _moduleCache = new Map<string, ImportedModule>();
1076
1386
 
1077
1387
  /** A module the host will import on first use, keyed as the document spells its `$src`. */
1078
- type ModuleLoader = () => Promise<Record<string, unknown>>;
1388
+ type ModuleLoader = () => Promise<ImportedModule>;
1079
1389
 
1080
1390
  /**
1081
1391
  * Lazy registrations: a loader until it runs, then the in-flight load, so two entries resolving the
@@ -1083,6 +1393,14 @@ type ModuleLoader = () => Promise<Record<string, unknown>>;
1083
1393
  */
1084
1394
  const _moduleLoaders = new Map<string, ModuleLoader | Promise<ImportedModule>>();
1085
1395
 
1396
+ /**
1397
+ * The specifiers a host registered through `preloadModule`, in either form. Kept apart from
1398
+ * `_moduleCache` because that cache is shared: a Function entry's `$src` import lands there too,
1399
+ * and a `timing: "server"` entry naming the same specifier must still not run in the browser
1400
+ * (spec.md §11.4). Only the host's own registration admits a server module in process.
1401
+ */
1402
+ const _hostSeeded = new Set<string>();
1403
+
1086
1404
  /**
1087
1405
  * Seed the `$src` module cache with a module the host already imported, under the specifier the
1088
1406
  * document spells. A bundled document cannot have its sidecar fetched by URL — the bundler saw
@@ -1095,21 +1413,22 @@ const _moduleLoaders = new Map<string, ModuleLoader | Promise<ImportedModule>>()
1095
1413
  * canvas frame registers every kit behaviour this way, so a page that uses no kit element loads
1096
1414
  * none of them (embedding.md §6).
1097
1415
  *
1416
+ * A registered module is also how a host runs a `timing: "server"` entry in process; for any
1417
+ * specifier not registered here, the runtime calls the host's `/__jx_server__` proxy instead.
1418
+ *
1098
1419
  * @param {string} specifier - The `$src` value as written in the document
1099
- * @param {Record<string, unknown> | (() => Promise<Record<string, unknown>>)} mod - The imported
1100
- * 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
1101
1422
  */
1102
- export function preloadModule(
1103
- specifier: string,
1104
- mod: Record<string, unknown> | ModuleLoader,
1105
- ): void {
1106
- if (typeof mod === "function") {
1423
+ export function preloadModule(specifier: string, mod: ImportedModule | ModuleLoader): void {
1424
+ _hostSeeded.add(specifier);
1425
+ if (isFunction(mod)) {
1107
1426
  _moduleCache.delete(specifier);
1108
1427
  _moduleLoaders.set(specifier, mod);
1109
1428
  return;
1110
1429
  }
1111
1430
  _moduleLoaders.delete(specifier);
1112
- _moduleCache.set(specifier, mod as ImportedModule);
1431
+ _moduleCache.set(specifier, mod);
1113
1432
  }
1114
1433
 
1115
1434
  /**
@@ -1134,28 +1453,37 @@ function seededModule(src: string): ImportedModule | Promise<ImportedModule> | u
1134
1453
  if (!pending) {
1135
1454
  return undefined;
1136
1455
  }
1137
- if (typeof pending !== "function") {
1456
+ if (!isFunction(pending)) {
1138
1457
  return pending;
1139
1458
  }
1140
- /* `Promise.resolve` so a thenable from a host's loader is still a Promise the call sites can
1141
- tell from a namespace. */
1142
- const load = Promise.resolve(pending()).then(
1143
- (mod) => {
1144
- _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);
1145
1467
  _moduleLoaders.delete(src);
1146
- return mod as ImportedModule;
1147
- },
1148
- (error: unknown) => {
1468
+ return mod;
1469
+ } catch (error) {
1149
1470
  /* A failed load — a chunk the network dropped — puts the loader back, so the next document to
1150
1471
  name the specifier tries again rather than inheriting this rejection for good. */
1151
1472
  _moduleLoaders.set(src, pending);
1152
1473
  throw error;
1153
- },
1154
- );
1474
+ }
1475
+ })();
1155
1476
  _moduleLoaders.set(src, load);
1156
1477
  return load;
1157
1478
  }
1158
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
+
1159
1487
  /**
1160
1488
  * Resolve a $prototype: "Function" entry into a function or computed.
1161
1489
  *
@@ -1166,7 +1494,7 @@ function seededModule(src: string): ImportedModule | Promise<ImportedModule> | u
1166
1494
  * @param {JxScope} state - Reactive scope proxy
1167
1495
  * @param {string} key - Def key name
1168
1496
  * @param {string} [base] - Base URL for resolving $src imports
1169
- * @returns {Promise<unknown>}
1497
+ * @returns {Promise<JxScopeValue>}
1170
1498
  */
1171
1499
  async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string, base?: string) {
1172
1500
  if (def.body && def.$src) {
@@ -1182,11 +1510,13 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
1182
1510
  return noop;
1183
1511
  }
1184
1512
 
1185
- let fn: ((...args: unknown[]) => unknown) | undefined;
1513
+ let fn: ScopeFunction;
1186
1514
 
1187
- if (typeof def.body === "string") {
1515
+ if (isString(def.body)) {
1188
1516
  const params = resolveParamNames(def);
1189
- 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;
1190
1520
  Object.defineProperty(fn, "name", {
1191
1521
  configurable: true,
1192
1522
  value: def.name ?? key,
@@ -1205,23 +1535,16 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
1205
1535
  } else if (seeded) {
1206
1536
  mod = seeded;
1207
1537
  } else {
1208
- if (base) {
1209
- const resolvedSrc = new URL(src, base).href;
1210
- try {
1211
- mod = (await import(resolvedSrc)) as ImportedModule;
1212
- } catch {
1213
- mod = (await import(src)) as ImportedModule;
1214
- }
1215
- } else {
1216
- mod = (await import(src)) as ImportedModule;
1217
- }
1538
+ mod = await importSource(src, base);
1218
1539
  _moduleCache.set(src, mod);
1219
1540
  }
1220
1541
  const candidate = mod[exportName] ?? mod.default?.[exportName];
1221
- if (typeof candidate !== "function") {
1542
+ if (!isFunction(candidate)) {
1222
1543
  throw new TypeError(`Jx: export "${exportName}" not found or not a function in "${src}"`);
1223
1544
  }
1224
- 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;
1225
1548
  }
1226
1549
 
1227
1550
  // Detect computed: body contains a return statement, or $src function introspection.
@@ -1229,11 +1552,9 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
1229
1552
  const hasParams = (def.parameters ?? def.arguments ?? []).length > 0;
1230
1553
  let isComputed = false;
1231
1554
  if (!hasParams) {
1232
- if (typeof def.body === "string") {
1233
- isComputed = bodyReturnsValue(def.body);
1234
- } else if (fn) {
1235
- isComputed = fn.length <= 1 && bodyReturnsValue(fn.toString());
1236
- }
1555
+ isComputed = isString(def.body)
1556
+ ? bodyReturnsValue(def.body)
1557
+ : fn.length <= 1 && bodyReturnsValue(fn.toString());
1237
1558
  }
1238
1559
  if (isComputed) {
1239
1560
  return computed(() => fn(state));
@@ -1297,6 +1618,72 @@ export const RESERVED_KEYS = new Set([
1297
1618
  "observedAttributes",
1298
1619
  ]);
1299
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
+
1300
1687
  /**
1301
1688
  * Recursively render a Jx element definition into a DOM element.
1302
1689
  *
@@ -1342,12 +1729,18 @@ export function renderNode(
1342
1729
  const path = options?._path ?? [];
1343
1730
 
1344
1731
  // Text node children: bare strings/numbers/booleans produce DOM Text nodes
1345
- if (typeof def === "string" || typeof def === "number" || typeof def === "boolean") {
1732
+ if (isString(def) || isNumber(def) || isBoolean(def)) {
1346
1733
  const textNode = document.createTextNode(String(def));
1347
- if (typeof def === "string" && isTemplateString(def)) {
1348
- effect(() => {
1349
- textNode.textContent = evaluateTemplate(def, state);
1350
- });
1734
+ if (isTemplateString(def)) {
1735
+ bindTemplate(
1736
+ def,
1737
+ () => {
1738
+ textNode.textContent = evaluateTemplate(def, state);
1739
+ },
1740
+ () => {
1741
+ textNode.textContent = "";
1742
+ },
1743
+ );
1351
1744
  }
1352
1745
  return textNode;
1353
1746
  }
@@ -1357,9 +1750,9 @@ export function renderNode(
1357
1750
  for (const [key, val] of Object.entries(def)) {
1358
1751
  if (key.startsWith("$") && !RESERVED_KEYS.has(key)) {
1359
1752
  if (localState === state) {
1360
- localState = Object.create(state) as JxScope;
1753
+ localState = inheritScope(state);
1361
1754
  }
1362
- localState[key] = isRefObj(val) ? resolveRef(val.$ref, state) : val;
1755
+ localState[key] = isRefValue(val) ? resolveRef(val.$ref, state) : val;
1363
1756
  }
1364
1757
  }
1365
1758
 
@@ -1369,7 +1762,7 @@ export function renderNode(
1369
1762
  * the supported mechanism is $elements + a custom-element tag (see spec §13).
1370
1763
  */
1371
1764
  if ("$ref" in def && !def.tagName) {
1372
- const refTarget = String((def as { $ref?: unknown }).$ref ?? "");
1765
+ const refTarget = String(def.$ref ?? "");
1373
1766
  if (!warnedRefChildren.has(refTarget)) {
1374
1767
  warnedRefChildren.add(refTarget);
1375
1768
  console.warn(
@@ -1422,13 +1815,12 @@ export function renderNode(
1422
1815
  * DESCENDANTS are HTML, so the element and its children carry different namespaces.
1423
1816
  */
1424
1817
  const childNs = tagName === "foreignObject" ? null : ns;
1425
- /*
1426
- * A namespaced node comes back as Element; every apply* helper below is typed for HTMLElement
1427
- * and touches only members both share (style, dataset, setAttribute, append).
1428
- */
1429
- const el = (ns
1430
- ? document.createElementNS(ns, tagName)
1431
- : 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);
1432
1824
 
1433
1825
  if (options?.onNodeCreated) {
1434
1826
  options.onNodeCreated(el, path, def, localState);
@@ -1441,12 +1833,7 @@ export function renderNode(
1441
1833
  if (def.style && tagName.includes("-")) {
1442
1834
  callSiteStyles.set(el, def.style);
1443
1835
  }
1444
- applyStyle(
1445
- el,
1446
- def.style ?? {},
1447
- (localState["$media"] as Record<string, string>) ?? {},
1448
- localState,
1449
- );
1836
+ applyStyle(el, def.style ?? {}, scopeMedia(localState), localState);
1450
1837
  applyAttributes(el, def.attributes ?? {}, localState);
1451
1838
 
1452
1839
  const kids = def.children;
@@ -1514,18 +1901,22 @@ function applyProperties(el: HTMLElement, def: JxElement, state: JxScope) {
1514
1901
  *
1515
1902
  * @param {HTMLElement} el
1516
1903
  * @param {string} key The `on`-prefixed key
1517
- * @param {unknown} val
1904
+ * @param {JxDocumentValue | undefined} val
1518
1905
  * @param {JxScope} state
1519
1906
  * @returns {boolean} Whether a listener was attached
1520
1907
  */
1521
- 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 {
1522
1914
  const type = key.slice(2);
1523
1915
  const scope = state;
1524
- if (isRefObj(val)) {
1916
+ if (isRefValue(val)) {
1525
1917
  const handler = resolveRef(val.$ref, state);
1526
- if (typeof handler === "function") {
1527
- const handlerFn = handler as (s: JxScope, e: Event) => unknown;
1528
- el.addEventListener(type, (e) => handlerFn(scope, e));
1918
+ if (isEventHandler(handler)) {
1919
+ el.addEventListener(type, (e) => handler(scope, e));
1529
1920
  }
1530
1921
  return true;
1531
1922
  }
@@ -1536,9 +1927,11 @@ function bindHandler(el: HTMLElement, key: string, val: unknown, state: JxScope)
1536
1927
  });
1537
1928
  return true;
1538
1929
  }
1539
- if (isFunctionDef(val) && typeof val.body === "string") {
1930
+ if (isFunctionDef(val) && isString(val.body)) {
1540
1931
  const params = resolveParamNames(val);
1541
- 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;
1542
1935
  el.addEventListener(type, (e) => fn(scope, e));
1543
1936
  return true;
1544
1937
  }
@@ -1571,11 +1964,16 @@ function bindDefinitionHandlers(host: HTMLElement, def: JxElement, state: JxScop
1571
1964
  /**
1572
1965
  * @param {HTMLElement} el
1573
1966
  * @param {string} key
1574
- * @param {unknown} val
1967
+ * @param {JxDocumentValue | undefined} val
1575
1968
  * @param {JxScope} state
1576
1969
  */
1577
- function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope) {
1578
- 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);
1579
1977
  const boundProp = boundPropKey(key, val, state);
1580
1978
  if (boundProp) {
1581
1979
  el.dataset.jxBoundProp = boundProp;
@@ -1583,15 +1981,16 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
1583
1981
  /* An `<img>` in a document says `{"tagName": "img", "src": "/hero.jpg"}`, so `src` arrives HERE
1584
1982
  as a top-level key and is written as a DOM PROPERTY — `attributes: { src }` is the rarer
1585
1983
  spelling. Both are the same reference and both need the same resolution. */
1586
- const asAsset = (resolved: unknown): unknown =>
1587
- typeof resolved === "string" ? canvasAssetValue(el.tagName, key, resolved) : resolved;
1984
+ const asAsset = (resolved: JxScopeValue): JxScopeValue =>
1985
+ isString(resolved) ? canvasAssetValue(el.tagName, key, resolved) : resolved;
1588
1986
  /*
1589
1987
  * `className` on an SVGElement is a read-only SVGAnimatedString, so assigning it throws in the
1590
1988
  * strict mode an ES module always runs under. It is the most common Jx element property, so the
1591
1989
  * attribute form is the only one that works on both.
1592
1990
  */
1593
- const write = (resolved: unknown): void => {
1594
- 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;
1595
1994
  if (key === "className" && !(node instanceof HTMLElement)) {
1596
1995
  node.setAttribute("class", resolved == null ? "" : String(resolved));
1597
1996
  return;
@@ -1605,9 +2004,11 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
1605
2004
  }
1606
2005
  target[key] = resolved;
1607
2006
  };
1608
- if (isRefObj(val)) {
1609
- const refVal = val as { $ref: string };
2007
+ if (isRefValue(val)) {
2008
+ const refVal = val;
1610
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. */
1611
2012
  target[key] = resolveRef(refVal.$ref, state) as string;
1612
2013
  return;
1613
2014
  }
@@ -1619,8 +2020,9 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
1619
2020
 
1620
2021
  // Universal ${} reactivity — template strings in element properties
1621
2022
  if (isTemplateString(val)) {
1622
- effect(() => {
1623
- write(asAsset(evaluateTemplate(val as string, state)));
2023
+ const template = val;
2024
+ bindTemplate(template, () => {
2025
+ write(asAsset(evaluateTemplate(template, state)));
1624
2026
  });
1625
2027
  return;
1626
2028
  }
@@ -1794,7 +2196,7 @@ function textSink(tag: HTMLStyleElement): StyleSink {
1794
2196
  write();
1795
2197
  },
1796
2198
  text() {
1797
- return tag.textContent ?? "";
2199
+ return tag.textContent;
1798
2200
  },
1799
2201
  };
1800
2202
  }
@@ -1810,9 +2212,10 @@ function textSink(tag: HTMLStyleElement): StyleSink {
1810
2212
  * @returns {StyleSink}
1811
2213
  */
1812
2214
  function openSink(doc: Document): StyleSink {
1813
- 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;
1814
2217
  const SheetConstructor = view?.CSSStyleSheet;
1815
- if (typeof SheetConstructor === "function" && Array.isArray(doc.adoptedStyleSheets)) {
2218
+ if (isFunction(SheetConstructor) && Array.isArray(doc.adoptedStyleSheets)) {
1816
2219
  try {
1817
2220
  const sheet = new SheetConstructor();
1818
2221
  doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
@@ -1825,10 +2228,21 @@ function openSink(doc: Document): StyleSink {
1825
2228
  }
1826
2229
  const tag = doc.createElement("style");
1827
2230
  tag.dataset.jxSheet = "";
1828
- (doc.head ?? doc.documentElement).append(tag);
2231
+ (documentHead(doc) ?? doc.documentElement).append(tag);
1829
2232
  return tag.sheet ? cssomSink(tag.sheet, () => tag.remove()) : textSink(tag);
1830
2233
  }
1831
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
+
1832
2246
  /** One interned rule set: the rules as written, and how many elements point at them. */
1833
2247
  interface InternEntry {
1834
2248
  inserted: InsertedRules;
@@ -2090,7 +2504,7 @@ function declaresDisplay(style: JxStyle | undefined): boolean {
2090
2504
  `@media` block as "the author supplied one", so the element stayed `inline` at rest and
2091
2505
  became a block on hover — a live trap rather than a subtlety. An author who wants the
2092
2506
  platform's own default writes `display: revert` in the base block, which this sees. */
2093
- return Boolean(style) && typeof style === "object" && "display" in style;
2507
+ return isNonNullObject(style) && "display" in style;
2094
2508
  }
2095
2509
 
2096
2510
  /**
@@ -2115,19 +2529,20 @@ function mergeStyle(base: JxStyle, over: JxStyle | undefined): JxStyle {
2115
2529
  if (!over) {
2116
2530
  return base;
2117
2531
  }
2118
- const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
2119
- 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)) {
2120
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. */
2121
2537
  out[key] =
2122
- prior !== null &&
2123
- typeof prior === "object" &&
2124
- value !== null &&
2125
- typeof value === "object" &&
2126
- !Array.isArray(value)
2127
- ? 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
+ )
2128
2543
  : value;
2129
2544
  }
2130
- return out as JxStyle;
2545
+ return out;
2131
2546
  }
2132
2547
 
2133
2548
  /**
@@ -2198,7 +2613,7 @@ function applyStyleInto(
2198
2613
  live: boolean,
2199
2614
  ) {
2200
2615
  releaseElementStyles(el);
2201
- const doc = el.ownerDocument ?? document;
2616
+ const doc = el.ownerDocument;
2202
2617
  const sheetState = sheetStateFor(doc);
2203
2618
 
2204
2619
  /* One gate for the whole call, so every recursion answers alike. Off in production and in
@@ -2221,8 +2636,9 @@ function applyStyleInto(
2221
2636
  variable inherits, which is correct, and the declaration stays in the rule where `:hover` can
2222
2637
  override it. The serial keeps two elements' variables apart: a descendant rule reads `var()`
2223
2638
  from the nearest ancestor that set it, and without it a styled child would shadow its parent's
2224
- `--jx-r0` with its own. It also makes the rule text — and so the scope handle hashed from it —
2225
- 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. */
2226
2642
  let serial = -1;
2227
2643
  let indirect = 0;
2228
2644
  const sources: { name: string; value: string | JxRef }[] = [];
@@ -2244,15 +2660,26 @@ function applyStyleInto(
2244
2660
  sources.push({ name: property, value });
2245
2661
  return null;
2246
2662
  }
2247
- if (serial < 0) {
2248
- ({ serial } = sheetState);
2249
- sheetState.serial += 1;
2250
- }
2251
2663
  /* Counted separately from `sources`, so an inline custom property before it cannot shift the
2252
2664
  name of an indirected one. The name is IN the rule text, and the interning handle is a
2253
2665
  hash of that text — a number that moved with unrelated declarations would give two
2254
2666
  otherwise identical styles two rules. */
2255
- 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
+ }
2256
2683
  indirect += 1;
2257
2684
  sources.push({ name, value });
2258
2685
  return `var(${name})`;
@@ -2304,7 +2731,7 @@ function applyStyleInto(
2304
2731
  scope.run(() => {
2305
2732
  for (const { name, value } of sources) {
2306
2733
  effect(() => {
2307
- const resolved = isRefObj(value)
2734
+ const resolved = isRefValue(value)
2308
2735
  ? resolveRef(value.$ref, state)
2309
2736
  : evaluateTemplate(value, state);
2310
2737
  el.style.setProperty(name, canvasStyleValue(String(resolved ?? "")));
@@ -2397,7 +2824,7 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
2397
2824
  const attr = canvasAttrName(el, k);
2398
2825
  /* Inside the effects, not before them. A `$ref` or `${…}` value is not a string until the
2399
2826
  effect runs — which is exactly why the document walk this replaced could never see it. */
2400
- const write = (resolved: unknown) => {
2827
+ const write = (resolved: JxScopeValue) => {
2401
2828
  if (resolved === null || resolved === undefined) {
2402
2829
  /* Nothing is an attribute the element does not have. A binding that resolves to `null`
2403
2830
  has to take the attribute WITH it, for the same reason a `false` does below — and an
@@ -2405,7 +2832,7 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
2405
2832
  el.removeAttribute(attr);
2406
2833
  return;
2407
2834
  }
2408
- if (typeof resolved === "boolean") {
2835
+ if (isBoolean(resolved)) {
2409
2836
  const text = booleanAttrValue(attr, resolved);
2410
2837
  /* `removeAttribute`, not `setAttribute(attr, "false")`. A binding that flips back has to
2411
2838
  take the attribute WITH it: the element is being re-used, so anything left behind is the
@@ -2420,10 +2847,10 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
2420
2847
  }
2421
2848
  el.setAttribute(attr, canvasAssetValue(el.tagName, k, String(resolved)));
2422
2849
  };
2423
- if (isRefObj(v)) {
2850
+ if (isRefValue(v)) {
2424
2851
  effect(() => write(resolveRef(v.$ref, state)));
2425
2852
  } else if (isTemplateString(v)) {
2426
- effect(() => write(evaluateAttrTemplate(v, state)));
2853
+ bindTemplate(v, () => write(evaluateValueTemplate(v, state)));
2427
2854
  } else {
2428
2855
  write(v);
2429
2856
  }
@@ -2469,7 +2896,7 @@ function renderMappedArrayInto(
2469
2896
  const anchor = document.createComment("jx-array");
2470
2897
  parentEl.append(anchor);
2471
2898
  const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef, key: keyRef } = arrayDef;
2472
- const keyPointer = isRefObj(keyRef) ? keyRef.$ref : null;
2899
+ const keyPointer = isRefValue(keyRef) ? keyRef.$ref : null;
2473
2900
  /* The instantiation chain the list belongs to (spec.md §16.9), for the same reason
2474
2901
  {@link renderSwitch} keeps one: a row that arrives with new data is inserted from an effect
2475
2902
  re-run, and the instance it holds must connect under the chain of the definition that drew
@@ -2487,14 +2914,22 @@ function renderMappedArrayInto(
2487
2914
  /** The rows currently rendered, in DOM order. */
2488
2915
  let rows: MappedRow[] = [];
2489
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`. */
2490
2921
  const teardown = (row: MappedRow): void => {
2491
- row.scope.stop();
2492
2922
  row.node.remove();
2923
+ row.scope.stop();
2493
2924
  };
2494
2925
 
2495
- 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 => {
2496
2931
  const map = shallowReactive({ index, item });
2497
- const child = Object.create(state) as JxScope;
2932
+ const child = inheritScope(state);
2498
2933
  child.$map = map;
2499
2934
  // The flat aliases stay readable by legacy readers, and follow the reactive context.
2500
2935
  Object.defineProperty(child, "$map/item", { enumerable: true, get: () => map.item });
@@ -2505,8 +2940,8 @@ function renderMappedArrayInto(
2505
2940
  return { key, map, node, scope };
2506
2941
  };
2507
2942
 
2508
- const reconcile = (list: unknown[], keys: unknown[]): void => {
2509
- 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>();
2510
2945
  for (const row of rows) {
2511
2946
  previous.set(row.key, row);
2512
2947
  }
@@ -2549,24 +2984,30 @@ function renderMappedArrayInto(
2549
2984
  };
2550
2985
 
2551
2986
  const update = (): void => {
2552
- let items: unknown = isRefObj(itemsSrc) ? resolveRef(itemsSrc.$ref, state) : itemsSrc;
2553
- if (Array.isArray(items) && isRefObj(filterRef)) {
2987
+ let items: JxScopeValue = isRefValue(itemsSrc) ? resolveRef(itemsSrc.$ref, state) : itemsSrc;
2988
+ if (Array.isArray(items) && isRefValue(filterRef)) {
2554
2989
  const fn = resolveRef(filterRef.$ref, state);
2555
- if (typeof fn === "function") {
2556
- 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);
2557
2994
  }
2558
2995
  }
2559
- if (Array.isArray(items) && isRefObj(sortRef)) {
2996
+ if (Array.isArray(items) && isRefValue(sortRef)) {
2560
2997
  const fn = resolveRef(sortRef.$ref, state);
2561
- if (typeof fn === "function") {
2562
- 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);
2563
3004
  }
2564
3005
  }
2565
- const list = Array.isArray(items) && mapDef ? (items as unknown[]) : [];
3006
+ const list: readonly JxScopeValue[] = Array.isArray(items) && mapDef ? items : [];
2566
3007
 
2567
3008
  // Keys are read HERE, tracked, so a key that changes reconciles the list.
2568
3009
  const keys = list.map((item, index) => mappedRowKey(keyPointer, item, index, warnOnce));
2569
- const seen = new Set<unknown>();
3010
+ const seen = new Set<MappedRow["key"]>();
2570
3011
  for (let i = 0; i < keys.length; i++) {
2571
3012
  if (seen.has(keys[i])) {
2572
3013
  warnOnce(
@@ -2620,9 +3061,9 @@ function renderMappedArrayInto(
2620
3061
 
2621
3062
  /** A rendered row of a mapped array. */
2622
3063
  interface MappedRow {
2623
- key: unknown;
3064
+ key: JxScopeValue;
2624
3065
  /** The row's `$map` context; `item` and `index` are written in place when the row is reused. */
2625
- map: { index: number; item: unknown };
3066
+ map: JxRowContext;
2626
3067
  node: HTMLElement | Text;
2627
3068
  scope: EffectScope;
2628
3069
  }
@@ -2634,10 +3075,10 @@ interface MappedRow {
2634
3075
  */
2635
3076
  function mappedRowKey(
2636
3077
  pointer: string | null,
2637
- item: unknown,
3078
+ item: MappedRow["map"]["item"],
2638
3079
  index: number,
2639
3080
  warnOnce: (message: string) => void,
2640
- ): unknown {
3081
+ ): MappedRow["key"] {
2641
3082
  if (pointer === null) {
2642
3083
  return index;
2643
3084
  }
@@ -2663,12 +3104,12 @@ function mappedRowKey(
2663
3104
  * schema already held to the tag-name pattern — the property that lets the compiler enumerate the
2664
3105
  * candidates and lets `createElement` be called without a second thought here.
2665
3106
  *
2666
- * @param {unknown} tagName The element's declared tag.
3107
+ * @param {TagNameSlot} tagName The element's declared tag.
2667
3108
  * @param {JxScope} scope The state the discriminant reads.
2668
3109
  * @returns {string} A literal tag name; `"div"` when nothing is declared.
2669
3110
  */
2670
- export function resolveTagName(tagName: unknown, scope: JxScope): string {
2671
- if (typeof tagName === "string") {
3111
+ export function resolveTagName(tagName: TagNameSlot, scope: JxScope): string {
3112
+ if (isString(tagName)) {
2672
3113
  return tagName;
2673
3114
  }
2674
3115
  if (!isTagExpression(tagName)) {
@@ -2706,7 +3147,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2706
3147
  }
2707
3148
 
2708
3149
  applyProperties(container, def, state);
2709
- applyStyle(container, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
3150
+ applyStyle(container, def.style ?? {}, scopeMedia(state), state);
2710
3151
  applyAttributes(container, def.attributes ?? {}, state);
2711
3152
  /* Every case renders in a detached scope of its own, stopped before the next case renders and
2712
3153
  when the container's own scope stops. A generation counter names the case a pending external
@@ -2725,9 +3166,10 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2725
3166
  };
2726
3167
 
2727
3168
  effect(() => {
2728
- if (!isRefObj(def.$switch)) {
2729
- retire();
3169
+ if (!isRefValue(def.$switch)) {
3170
+ // Detached before it is stopped, for the reason `teardown` in `renderMappedArray` gives.
2730
3171
  container.replaceChildren();
3172
+ retire();
2731
3173
  generation += 1;
2732
3174
  return;
2733
3175
  }
@@ -2736,12 +3178,13 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2736
3178
  return;
2737
3179
  }
2738
3180
  renderedKey = key;
2739
- retire();
2740
3181
  /* `replaceChildren()` rather than `innerHTML = ""`: identical semantics, and it is not a
2741
3182
  Trusted Types injection sink — under `require-trusted-types-for 'script'` an innerHTML write
2742
3183
  needs a policy even when the string is empty. Four sinks that were never injecting anything
2743
- 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. */
2744
3186
  container.replaceChildren();
3187
+ retire();
2745
3188
  generation += 1;
2746
3189
  const caseDef = def.cases?.[key];
2747
3190
  if (!caseDef) {
@@ -2752,7 +3195,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2752
3195
  live = scope;
2753
3196
  const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
2754
3197
 
2755
- if (isRefObj(caseDef)) {
3198
+ if (isRefValue(caseDef)) {
2756
3199
  // External $ref — fetch and render asynchronously, against the mount's base so a bundled
2757
3200
  // Document need not live at the page URL.
2758
3201
  const { href } = new URL(caseDef.$ref, options?._ctx?.base ?? location.href);
@@ -2770,7 +3213,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2770
3213
  scope.run(() => container.append(renderNode(doc, childScope, childOpts))),
2771
3214
  );
2772
3215
  })
2773
- .catch((error: unknown) =>
3216
+ .catch((error) =>
2774
3217
  console.error("Jx $switch: failed to load external case", caseDef.$ref, error),
2775
3218
  );
2776
3219
  return;
@@ -2795,6 +3238,15 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2795
3238
 
2796
3239
  // ─── Prototype namespaces (Shape 5) ──────────────────────────────────────────
2797
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
+
2798
3250
  /**
2799
3251
  * Resolve a $prototype definition into a value for the reactive scope.
2800
3252
  *
@@ -2805,7 +3257,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2805
3257
  * @param {JxScope} state - Reactive scope proxy
2806
3258
  * @param {string} key - Def key (for diagnostics)
2807
3259
  * @param {string} [base] - Base URL for resolving $src imports
2808
- * @returns {Promise<unknown>}
3260
+ * @returns {Promise<JxScopeValue>}
2809
3261
  */
2810
3262
  export async function resolvePrototype(
2811
3263
  def: JxPrototypeDef,
@@ -2821,7 +3273,7 @@ export async function resolvePrototype(
2821
3273
 
2822
3274
  switch (def.$prototype) {
2823
3275
  case "Request": {
2824
- const s: Ref<unknown> = ref(null);
3276
+ const s: Ref<JxScopeValue> = ref(null);
2825
3277
  const debounceMs = def.debounce ?? 0;
2826
3278
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
2827
3279
 
@@ -2851,14 +3303,14 @@ export async function resolvePrototype(
2851
3303
  signal: controller.signal,
2852
3304
  ...(def.headers && { headers: def.headers }),
2853
3305
  ...(def.body && {
2854
- body: typeof def.body === "object" ? JSON.stringify(def.body) : def.body,
3306
+ body: isNonNullObject(def.body) ? JSON.stringify(def.body) : def.body,
2855
3307
  }),
2856
3308
  })
2857
- .then((r) => (r.ok ? r.json() : Promise.reject(r.statusText)))
3309
+ .then((r): Promise<JsonValue> => (r.ok ? r.json() : Promise.reject(r.statusText)))
2858
3310
  .then((d) => {
2859
3311
  s.value = d;
2860
3312
  })
2861
- .catch((error: unknown) => {
3313
+ .catch((error) => {
2862
3314
  if (!(error instanceof Error && error.name === "AbortError")) {
2863
3315
  s.value = { error: String(error) };
2864
3316
  }
@@ -2880,8 +3332,11 @@ export async function resolvePrototype(
2880
3332
  const p: Record<string, string> = {};
2881
3333
  for (const [k, v] of Object.entries(def)) {
2882
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. */
2883
3338
  p[k] = (
2884
- isRefObj(v)
3339
+ isRefValue(v)
2885
3340
  ? resolveRef(v.$ref, state)
2886
3341
  : isTemplateString(v)
2887
3342
  ? evaluateTemplate(v, state)
@@ -2897,14 +3352,16 @@ export async function resolvePrototype(
2897
3352
  case "SessionStorage": {
2898
3353
  const store = def.$prototype === "LocalStorage" ? localStorage : sessionStorage;
2899
3354
  const k = def.key ?? key;
2900
- let init: unknown;
3355
+ let init: JxScopeValue;
2901
3356
  try {
2902
3357
  const s = store.getItem(k);
2903
- 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);
2904
3361
  } catch {
2905
3362
  init = def.default ?? null;
2906
3363
  }
2907
- const storageState: Ref<unknown> = ref(init);
3364
+ const storageState = scopeRef(init);
2908
3365
  // Persist on change
2909
3366
  effect(() => {
2910
3367
  const v = storageState.value;
@@ -2923,28 +3380,33 @@ export async function resolvePrototype(
2923
3380
 
2924
3381
  case "Cookie": {
2925
3382
  const name = def.name ?? key;
2926
- const read = () => {
3383
+ const read = (): JsonValue => {
2927
3384
  const raw = readCookie(document.cookie, name);
2928
3385
  if (raw === null) {
2929
3386
  return null;
2930
3387
  }
2931
3388
  try {
2932
- 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;
2933
3392
  } catch {
2934
3393
  return raw;
2935
3394
  }
2936
3395
  };
2937
- const cookieState: Ref<unknown> = ref(read() ?? def.default ?? null);
3396
+ const cookieState = scopeRef(read() ?? def.default ?? null);
2938
3397
  // Persist on change
2939
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. */
2940
3402
  // oxlint-disable-next-line unicorn/no-document-cookie -- the Cookie $prototype IS the cookie store binding
2941
- document.cookie = serializeCookie(name, cookieState.value, def);
3403
+ document.cookie = serializeCookie(name, cookieState.value as JsonValue, def);
2942
3404
  });
2943
3405
  return cookieState;
2944
3406
  }
2945
3407
 
2946
3408
  case "IndexedDB": {
2947
- const idbState: Ref<unknown> = ref(null);
3409
+ const idbState: Ref<JxScopeValue> = ref(null);
2948
3410
  const {
2949
3411
  database,
2950
3412
  store,
@@ -2958,6 +3420,7 @@ export async function resolvePrototype(
2958
3420
  }
2959
3421
  const req = indexedDB.open(database, version);
2960
3422
  req.addEventListener("upgradeneeded", (e) => {
3423
+ // SAFETY: an open request's events are dispatched at the request itself.
2961
3424
  const db: IDBDatabase = (e.target as IDBOpenDBRequest).result;
2962
3425
  if (!db.objectStoreNames.contains(store)) {
2963
3426
  const os = db.createObjectStore(store, { autoIncrement, keyPath });
@@ -2967,6 +3430,7 @@ export async function resolvePrototype(
2967
3430
  }
2968
3431
  });
2969
3432
  req.addEventListener("success", (e) => {
3433
+ // SAFETY: an open request's events are dispatched at the request itself.
2970
3434
  const db: IDBDatabase = (e.target as IDBOpenDBRequest).result;
2971
3435
  idbState.value = {
2972
3436
  database,
@@ -2994,14 +3458,19 @@ export async function resolvePrototype(
2994
3458
  case "FormData": {
2995
3459
  const fd = new FormData();
2996
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. */
2997
3463
  fd.append(k, v as string);
2998
3464
  }
2999
3465
  return fd;
3000
3466
  }
3001
3467
 
3002
3468
  case "Blob": {
3003
- return new Blob((def.parts as BlobPart[]) ?? [], {
3004
- 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",
3005
3474
  });
3006
3475
  }
3007
3476
 
@@ -3034,6 +3503,28 @@ const EXTERNAL_RESERVED = new Set([
3034
3503
  "name",
3035
3504
  ]);
3036
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
+
3037
3528
  /**
3038
3529
  * Resolve an external class prototype via $src.
3039
3530
  *
@@ -3041,7 +3532,7 @@ const EXTERNAL_RESERVED = new Set([
3041
3532
  * @param {JxScope} state
3042
3533
  * @param {string} key
3043
3534
  * @param {string} [base]
3044
- * @returns {Promise<unknown>}
3535
+ * @returns {Promise<Ref<JxScopeValue>>}
3045
3536
  */
3046
3537
  async function resolveExternalPrototype(
3047
3538
  def: JxPrototypeDef,
@@ -3066,13 +3557,18 @@ async function resolveExternalPrototype(
3066
3557
  * Import a JS module and instantiate a class from it. Internal helper used by resolveClassJson for
3067
3558
  * $implementation.
3068
3559
  *
3069
- * @param {JxScope} def - Original state entry (for config extraction)
3560
+ * @param {JxPrototypeDef} def - Original state entry (for config extraction)
3070
3561
  * @param {string} src - JS module URL to import
3071
3562
  * @param {string} exportName - Export name to look up
3072
3563
  * @param {string} [base] - Base URL for resolution
3073
- * @returns {Promise<unknown>}
3564
+ * @returns {Promise<Ref<JxScopeValue>>}
3074
3565
  */
3075
- 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
+ ) {
3076
3572
  let mod: ImportedModule;
3077
3573
  const seeded = seededModule(src);
3078
3574
  if (seeded instanceof Promise) {
@@ -3081,11 +3577,11 @@ async function importAndInstantiate(def: JxScope, src: string, exportName: strin
3081
3577
  mod = seeded;
3082
3578
  } else {
3083
3579
  try {
3084
- mod = (await import(src)) as ImportedModule;
3580
+ mod = await importModule(src);
3085
3581
  } catch {
3086
3582
  if (base) {
3087
3583
  const resolvedSrc = new URL(src, base).href;
3088
- mod = (await import(resolvedSrc)) as ImportedModule;
3584
+ mod = await importModule(resolvedSrc);
3089
3585
  } else {
3090
3586
  throw new Error(`Failed to import "${src}"`);
3091
3587
  }
@@ -3097,33 +3593,27 @@ async function importAndInstantiate(def: JxScope, src: string, exportName: strin
3097
3593
  if (!ExportedClass) {
3098
3594
  throw new Error(`Jx: export "${exportName}" not found in "${src}"`);
3099
3595
  }
3100
- if (typeof ExportedClass !== "function") {
3596
+ if (!isFunction(ExportedClass)) {
3101
3597
  throw new TypeError(`Jx: "${exportName}" from "${src}" is not a class`);
3102
3598
  }
3103
3599
 
3104
- const config: JxScope = {};
3105
- for (const [k, v] of Object.entries(def)) {
3106
- if (!EXTERNAL_RESERVED.has(k)) {
3107
- config[k] = v;
3108
- }
3109
- }
3110
-
3111
- const Ctor = ExportedClass as new (config: JxScope) => ExternalClassInstance;
3112
- const instance = new Ctor(config);
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));
3113
3606
 
3114
- let value: unknown;
3115
- if (typeof instance.resolve === "function") {
3116
- value = await instance.resolve();
3117
- } else if ("value" in instance) {
3118
- ({ value } = instance);
3119
- } else {
3120
- value = instance;
3121
- }
3607
+ const value = isFunction(instance.resolve)
3608
+ ? await instance.resolve()
3609
+ : "value" in instance
3610
+ ? instance.value
3611
+ : instance;
3122
3612
 
3123
3613
  // Always wrap in ref for reactivity with external classes
3124
- const s: Ref<unknown> = ref(value);
3125
- if (typeof instance.subscribe === "function") {
3126
- instance.subscribe((newVal: unknown) => {
3614
+ const s = scopeRef(value);
3615
+ if (isFunction(instance.subscribe)) {
3616
+ instance.subscribe((newVal) => {
3127
3617
  s.value = newVal;
3128
3618
  });
3129
3619
  }
@@ -3141,7 +3631,7 @@ async function importAndInstantiate(def: JxScope, src: string, exportName: strin
3141
3631
  * @param {JxScope} state
3142
3632
  * @param {string} key
3143
3633
  * @param {string} [base]
3144
- * @returns {Promise<unknown>}
3634
+ * @returns {Promise<Ref<JxScopeValue>>}
3145
3635
  */
3146
3636
  async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string, base?: string) {
3147
3637
  const src = def.$src!;
@@ -3165,7 +3655,9 @@ async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string
3165
3655
  if (!res.ok) {
3166
3656
  throw new Error(`HTTP ${res.status}`);
3167
3657
  }
3168
- // 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. */
3169
3661
  classDef = (await res.json()) as JxClassDef;
3170
3662
  } catch {
3171
3663
  // Fall back to dev proxy (server will handle .class.json resolution)
@@ -3192,71 +3684,81 @@ async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string
3192
3684
 
3193
3685
  // Self-contained: construct class dynamically from schema
3194
3686
  const DynClass = classFromSchema(classDef);
3195
- const config: JxScope = {};
3196
- for (const [k, v] of Object.entries(def)) {
3197
- if (!EXTERNAL_RESERVED.has(k)) {
3198
- config[k] = v;
3199
- }
3200
- }
3201
- 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;
3202
3691
 
3203
- let value: unknown;
3204
- if (typeof instance.resolve === "function") {
3205
- value = await instance.resolve();
3206
- } else if ("value" in instance) {
3207
- ({ value } = instance);
3208
- } else {
3209
- value = instance;
3210
- }
3692
+ const value = isFunction(instance.resolve)
3693
+ ? await instance.resolve()
3694
+ : "value" in instance
3695
+ ? instance.value
3696
+ : instance;
3211
3697
 
3212
3698
  // Always wrap in ref for reactivity
3213
- const s: Ref<unknown> = ref(value);
3214
- if (typeof instance.subscribe === "function") {
3215
- instance.subscribe((newVal: unknown) => {
3699
+ const s = scopeRef(value);
3700
+ if (isFunction(instance.subscribe)) {
3701
+ instance.subscribe((newVal) => {
3216
3702
  s.value = newVal;
3217
3703
  });
3218
3704
  }
3219
3705
  return s;
3220
3706
  }
3221
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
+
3222
3720
  /**
3223
3721
  * Dynamically construct a class from a .class.json schema definition. Browser-side: maps private
3224
3722
  * fields to _-prefixed public fields.
3225
3723
  *
3226
3724
  * @param {import("@jxsuite/schema/types").JxClassDef} classDef
3227
- * @returns {DynamicClass}
3725
+ * @returns {typeof DynClass} A class whose instances hold the schema's fields by name
3228
3726
  */
3229
3727
  function classFromSchema(classDef: JxClassDef) {
3230
3728
  const fields = classDef.$defs?.fields ?? {};
3231
3729
  // JSON objects inherit Object.prototype.constructor — only an own object value counts.
3232
3730
  const rawCtor = classDef.$defs?.constructor;
3233
- const ctor = typeof rawCtor === "object" ? rawCtor : undefined;
3731
+ const ctor = isCtorDef(rawCtor) ? rawCtor : undefined;
3234
3732
  const methods = classDef.$defs?.methods ?? {};
3235
3733
 
3236
3734
  // oxlint-disable-next-line typescript/no-extraneous-class -- methods are attached to the prototype dynamically below
3237
3735
  class DynClass {
3238
- 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 = {}) {
3239
3742
  for (const [key, typedField] of Object.entries(fields)) {
3240
3743
  const id = typedField.identifier ?? key;
3241
3744
  const propName = typedField.access === "private" ? `_${id}` : id;
3242
3745
  if (config[id] !== undefined) {
3243
- (this as Record<string, unknown>)[propName] = config[id];
3746
+ this[propName] = config[id];
3244
3747
  } else if (typedField.initializer !== undefined) {
3245
- (this as Record<string, unknown>)[propName] = typedField.initializer;
3748
+ this[propName] = typedField.initializer;
3246
3749
  } else if (typedField.default !== undefined) {
3247
- (this as Record<string, unknown>)[propName] = structuredClone(typedField.default);
3750
+ this[propName] = structuredClone(typedField.default);
3248
3751
  } else {
3249
- (this as Record<string, unknown>)[propName] = null;
3752
+ this[propName] = null;
3250
3753
  }
3251
3754
  }
3252
3755
  if (ctor?.body) {
3253
3756
  const bodyStr = Array.isArray(ctor.body) ? ctor.body.join("\n") : ctor.body;
3254
- (
3255
- new Function("config", bodyStr) as (
3256
- this: unknown,
3257
- config: Record<string, unknown>,
3258
- ) => void
3259
- ).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);
3260
3762
  }
3261
3763
  }
3262
3764
  }
@@ -3265,10 +3767,11 @@ function classFromSchema(classDef: JxClassDef) {
3265
3767
  const name = typedMethod.identifier ?? key;
3266
3768
  const params = (typedMethod.parameters ?? []).map((p) => {
3267
3769
  if (p.$ref) {
3268
- 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() ?? "";
3269
3772
  }
3270
3773
  const n = p.identifier ?? p.name;
3271
- return typeof n === "string" ? n : "arg";
3774
+ return isString(n) ? n : "arg";
3272
3775
  });
3273
3776
  const bodyStr = Array.isArray(typedMethod.body)
3274
3777
  ? typedMethod.body.join("\n")
@@ -3277,22 +3780,28 @@ function classFromSchema(classDef: JxClassDef) {
3277
3780
  if (typedMethod.role === "accessor") {
3278
3781
  const descriptor: PropertyDescriptor = {};
3279
3782
  if (typedMethod.getter) {
3280
- 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;
3281
3786
  }
3282
3787
  if (typedMethod.setter) {
3283
3788
  const sp = (typedMethod.setter.parameters ?? []).map(
3284
3789
  (p) => p.$ref?.split("/").pop() ?? "v",
3285
3790
  );
3286
- 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;
3287
3796
  }
3288
3797
  Object.defineProperty(DynClass.prototype, name, {
3289
3798
  ...descriptor,
3290
3799
  configurable: true,
3291
3800
  });
3292
3801
  } else if (typedMethod.scope === "static") {
3293
- (DynClass as unknown as DynamicClass)[name] = new Function(...params, bodyStr);
3802
+ DynClass[name] = new Function(...params, bodyStr);
3294
3803
  } else {
3295
- (DynClass as unknown as DynamicClass).prototype[name] = new Function(...params, bodyStr);
3804
+ DynClass.prototype[name] = new Function(...params, bodyStr);
3296
3805
  }
3297
3806
  }
3298
3807
 
@@ -3300,8 +3809,7 @@ function classFromSchema(classDef: JxClassDef) {
3300
3809
  configurable: true,
3301
3810
  value: classDef.title,
3302
3811
  });
3303
- const dynCtor = DynClass as unknown as DynamicClass;
3304
- return dynCtor;
3812
+ return DynClass;
3305
3813
  }
3306
3814
 
3307
3815
  /**
@@ -3313,17 +3821,12 @@ function classFromSchema(classDef: JxClassDef) {
3313
3821
  * @param {JxScope} state
3314
3822
  * @param {string} key
3315
3823
  * @param {string} [base]
3316
- * @returns {Promise<unknown>}
3824
+ * @returns {Promise<Ref<JxScopeValue>>}
3317
3825
  */
3318
3826
  async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: string, base?: string) {
3319
- const config: JxScope = {};
3320
- for (const [k, v] of Object.entries(def)) {
3321
- if (!EXTERNAL_RESERVED.has(k)) {
3322
- config[k] = v;
3323
- }
3324
- }
3827
+ const config = externalConfig(def);
3325
3828
 
3326
- const hasTemplates = Object.values(config).some((v: unknown) => isTemplateString(v));
3829
+ const hasTemplates = Object.values(config).some((v) => isTemplateString(v));
3327
3830
 
3328
3831
  /** @param {JxScope} resolvedConfig */
3329
3832
  const doResolve = (resolvedConfig: JxScope) =>
@@ -3337,15 +3840,16 @@ async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: stri
3337
3840
  }),
3338
3841
  headers: { "Content-Type": "application/json" },
3339
3842
  method: "POST",
3340
- }).then((r) => {
3843
+ }).then((r): Promise<JsonValue> => {
3341
3844
  if (!r.ok) {
3342
3845
  throw new Error(`Jx dev proxy ${r.status} for "${key}"`);
3343
3846
  }
3847
+ // The dev proxy answers with the resolved value serialized as JSON.
3344
3848
  return r.json();
3345
3849
  });
3346
3850
 
3347
3851
  // Always wrap in ref for reactivity
3348
- const s: Ref<unknown> = ref(null);
3852
+ const s: Ref<JxScopeValue> = ref(null);
3349
3853
  if (hasTemplates) {
3350
3854
  effect(() => {
3351
3855
  const resolvedConfig: JxScope = {};
@@ -3353,32 +3857,85 @@ async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: stri
3353
3857
  resolvedConfig[k] = isTemplateString(v) ? evaluateTemplate(v, state) : v;
3354
3858
  }
3355
3859
  doResolve(resolvedConfig)
3356
- .then((value: unknown) => {
3860
+ .then((value) => {
3357
3861
  s.value = value;
3358
3862
  })
3359
- .catch((error: unknown) => console.error("Jx dev proxy:", error));
3863
+ .catch((error) => console.error("Jx dev proxy:", error));
3360
3864
  });
3361
3865
  } else {
3362
3866
  doResolve(config)
3363
- .then((value: unknown) => {
3867
+ .then((value) => {
3364
3868
  s.value = value;
3365
3869
  })
3366
- .catch((error: unknown) => console.error("Jx dev proxy:", error));
3870
+ .catch((error) => console.error("Jx dev proxy:", error));
3367
3871
  }
3368
3872
  return s;
3369
3873
  }
3370
3874
 
3371
- // ─── Server function resolution (dev mode) ────────────────────────────────────
3875
+ // ─── Server function resolution ───────────────────────────────────────────────
3372
3876
 
3373
3877
  /**
3374
- * Resolve a timing: "server" entry in dev mode by executing the function client-side. In
3375
- * production, the compiler replaces this with a fetch to the generated server handler.
3376
- *
3377
- * @param {JxScope} def
3878
+ * Start one `timing: "server"` entry's calls, wherever they run. Each call takes the next number,
3879
+ * and only the latest may write the entry or report a failure: the answer to a call whose arguments
3880
+ * have since changed is dropped whenever it arrives, and a failed call keeps the entry's last
3881
+ * value. A call that throws before it returns a promise (an argument the proxy cannot serialize, or
3882
+ * a host function that throws synchronously) fails the same way, rather than out of whichever state
3883
+ * write started it. The proxy and a host-registered module share it, so a reactive entry's calls
3884
+ * are ordered and reported the same way on either path.
3885
+ *
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
3889
+ * @param {string} label - The `console.error` prefix a failed call is reported under
3890
+ * @returns {(args: JxScope) => void} Starts a call
3891
+ */
3892
+ function latestServerCall(
3893
+ s: Ref<JxScopeValue>,
3894
+ invoke: ServerCall,
3895
+ label: string,
3896
+ ): (args: JxScope) => void {
3897
+ let seq = 0;
3898
+ return (args: JxScope) => {
3899
+ seq += 1;
3900
+ const n = seq;
3901
+ /* The executor runs before the constructor returns, so the call still starts synchronously,
3902
+ and a throw inside it rejects this promise instead of escaping to the caller. */
3903
+ new Promise<JsonValue>((settle) => {
3904
+ settle(invoke(args));
3905
+ })
3906
+ .then((result) => {
3907
+ if (n === seq) {
3908
+ s.value = result;
3909
+ }
3910
+ })
3911
+ .catch((error) => {
3912
+ if (n === seq) {
3913
+ console.error(label, error);
3914
+ }
3915
+ });
3916
+ };
3917
+ }
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
+
3926
+ /**
3927
+ * Resolve a timing: "server" entry. The runtime never imports a server module itself: every call is
3928
+ * a POST to the host's `/__jx_server__` proxy, which runs the function server-side with `env`, so
3929
+ * the runtime never requests the module's source (spec.md §11.4). A host may still serve that file
3930
+ * at its URL, which is why a secret belongs in `env` and never in the module body. The one
3931
+ * exception is the host's own act: a module it registered with `preloadModule` (embedding.md §6) is
3932
+ * called in process, and a reactive entry's calls are ordered there as the proxy orders them.
3933
+ *
3934
+ * @param {JxServerFnDef} def
3378
3935
  * @param {JxScope} state
3379
3936
  * @param {string} key
3380
3937
  * @param {string} [base]
3381
- * @returns {Promise<unknown>}
3938
+ * @returns {Promise<Ref<JxScopeValue>>}
3382
3939
  */
3383
3940
  async function resolveServerFunction(
3384
3941
  def: JxServerFnDef,
@@ -3389,61 +3946,44 @@ async function resolveServerFunction(
3389
3946
  const src = def.$src;
3390
3947
  const exportName = def.$export;
3391
3948
 
3392
- let mod: ImportedModule;
3393
- const seeded = seededModule(src);
3394
- if (seeded instanceof Promise) {
3395
- mod = await seeded;
3396
- } else if (seeded) {
3397
- mod = seeded;
3398
- } else {
3399
- try {
3400
- mod = (await import(src)) as ImportedModule;
3401
- } catch {
3402
- if (base) {
3403
- try {
3404
- const resolvedSrc = new URL(src, base).href;
3405
- mod = (await import(resolvedSrc)) as ImportedModule;
3406
- } catch {
3407
- // Module cannot run in the browser — fall back to dev server proxy
3408
- return resolveServerFunctionViaProxy(def, state, key, base);
3409
- }
3410
- } else {
3411
- return resolveServerFunctionViaProxy(def, state, key, base);
3412
- }
3413
- }
3414
- _moduleCache.set(src, mod);
3949
+ /* The host-seeded set, not the module cache: a Function entry's own `$src` import lands in that
3950
+ cache too, and reusing it here would put a server module in the browser after all. */
3951
+ const seeded = _hostSeeded.has(src) ? seededModule(src) : undefined;
3952
+ if (!seeded) {
3953
+ return resolveServerFunctionViaProxy(def, state, key, base);
3415
3954
  }
3955
+ const mod = seeded instanceof Promise ? await seeded : seeded;
3416
3956
 
3417
3957
  const candidate = mod[exportName] ?? mod.default?.[exportName];
3418
3958
  if (!candidate) {
3419
3959
  throw new Error(`Jx: export "${exportName}" not found in "${src}" for "${key}"`);
3420
3960
  }
3421
- if (typeof candidate !== "function") {
3961
+ if (!isFunction(candidate)) {
3422
3962
  throw new TypeError(`Jx: "${exportName}" from "${src}" is not a function`);
3423
3963
  }
3424
- const fn = candidate as (args: JxScope) => Promise<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;
3425
3968
 
3426
3969
  const rawArgs = def.arguments ?? {};
3427
- const hasReactiveArg = Object.values(rawArgs).some((v: unknown) => isRefObj(v));
3970
+ const hasReactiveArg = Object.values(rawArgs).some((v) => isRefValue(v));
3428
3971
  const resolveArgs = () => {
3429
3972
  const args: JxScope = {};
3430
3973
  for (const [k, v] of Object.entries(rawArgs)) {
3431
- args[k] = isRefObj(v) ? resolveRef(v.$ref, state) : v;
3974
+ args[k] = isRefValue(v) ? resolveRef(v.$ref, state) : v;
3432
3975
  }
3433
3976
  return args;
3434
3977
  };
3435
3978
 
3436
3979
  // Always wrap in ref for reactivity
3437
- const s: Ref<unknown> = ref(null);
3980
+ const s: Ref<JxScopeValue> = ref(null);
3438
3981
  if (hasReactiveArg) {
3982
+ const call = latestServerCall(s, fn, "Jx server function:");
3439
3983
  effect(() => {
3440
3984
  const args = resolveArgs();
3441
3985
  onEffectCleanup(() => {});
3442
- fn(args)
3443
- .then((result: unknown) => {
3444
- s.value = result;
3445
- })
3446
- .catch(() => {});
3986
+ call(args);
3447
3987
  });
3448
3988
  } else {
3449
3989
  s.value = await fn(resolveArgs());
@@ -3452,29 +3992,32 @@ async function resolveServerFunction(
3452
3992
  }
3453
3993
 
3454
3994
  /**
3455
- * Dev-mode fallback: when a timing: "server" module cannot run in the browser, proxy the function
3456
- * call through the Jx dev server (POST /**jx_server**). Supports reactive $ref arguments via Vue
3457
- * effect().
3458
- *
3459
- * @param {JxScope} def
3995
+ * Call a timing: "server" entry through the host's `/__jx_server__` proxy, which imports the module
3996
+ * server-side and calls the export with `env` (server.md §3.3). This is the only path an
3997
+ * interpreting runtime takes for a module the host did not register. A `$ref` argument makes the
3998
+ * call reactive: it re-posts from an `effect()` when the reference changes. A failed call leaves
3999
+ * the entry's last value and logs, and the answer to a superseded call is dropped, so a slow answer
4000
+ * to an earlier call can never overwrite a later one (`latestServerCall`).
4001
+ *
4002
+ * @param {JxServerFnDef} def
3460
4003
  * @param {JxScope} state
3461
4004
  * @param {string} key
3462
4005
  * @param {string} [base]
3463
- * @returns {Promise<unknown>}
4006
+ * @returns {Promise<Ref<JxScopeValue>>}
3464
4007
  */
3465
4008
  async function resolveServerFunctionViaProxy(
3466
- def: JxScope,
4009
+ def: JxServerFnDef,
3467
4010
  state: JxScope,
3468
4011
  key: string,
3469
4012
  base?: string,
3470
4013
  ) {
3471
4014
  const rawArgs = def.arguments ?? {};
3472
- const hasReactiveArg = Object.values(rawArgs).some((v: unknown) => isRefObj(v));
4015
+ const hasReactiveArg = Object.values(rawArgs).some((v) => isRefValue(v));
3473
4016
 
3474
4017
  const resolveArgs = () => {
3475
4018
  const args: JxScope = {};
3476
4019
  for (const [k, v] of Object.entries(rawArgs)) {
3477
- args[k] = isRefObj(v) ? resolveRef((v as { $ref: string }).$ref, state) : v;
4020
+ args[k] = isRefValue(v) ? resolveRef(v.$ref, state) : v;
3478
4021
  }
3479
4022
  return args;
3480
4023
  };
@@ -3490,31 +4033,25 @@ async function resolveServerFunctionViaProxy(
3490
4033
  }),
3491
4034
  headers: { "Content-Type": "application/json" },
3492
4035
  method: "POST",
3493
- }).then((r) => {
4036
+ }).then((r): Promise<JsonValue> => {
3494
4037
  if (!r.ok) {
3495
4038
  throw new Error(`Jx server proxy ${r.status} for "${key}"`);
3496
4039
  }
4040
+ // The proxy answers with the function's result serialized as JSON.
3497
4041
  return r.json();
3498
4042
  });
3499
4043
 
3500
4044
  // Always wrap in ref for reactivity
3501
- const s: Ref<unknown> = ref(null);
4045
+ const s: Ref<JxScopeValue> = ref(null);
4046
+ const call = latestServerCall(s, doResolve, "Jx server proxy:");
3502
4047
  if (hasReactiveArg) {
3503
4048
  effect(() => {
3504
4049
  const args = resolveArgs();
3505
4050
  onEffectCleanup(() => {});
3506
- doResolve(args)
3507
- .then((result: unknown) => {
3508
- s.value = result;
3509
- })
3510
- .catch((error: unknown) => console.error("Jx server proxy:", error));
4051
+ call(args);
3511
4052
  });
3512
4053
  } else {
3513
- doResolve(resolveArgs())
3514
- .then((result: unknown) => {
3515
- s.value = result;
3516
- })
3517
- .catch((error: unknown) => console.error("Jx server proxy:", error));
4054
+ call(resolveArgs());
3518
4055
  }
3519
4056
  return s;
3520
4057
  }
@@ -3527,10 +4064,11 @@ async function resolveServerFunctionViaProxy(
3527
4064
  *
3528
4065
  * @param {string} ref
3529
4066
  * @param {JxScope} state - Reactive scope proxy (or child scope)
3530
- * @returns {unknown}
4067
+ * @returns {JxScopeValue}
3531
4068
  */
3532
4069
  export function resolveRef(refPath: string, state: JxScope) {
3533
- 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)) {
3534
4072
  return refPath;
3535
4073
  }
3536
4074
  if (refPath.startsWith("$map/") || refPath.startsWith("#/$map/")) {
@@ -3539,9 +4077,11 @@ export function resolveRef(refPath: string, state: JxScope) {
3539
4077
  rendered ZERO rows with no warning. */
3540
4078
  const parts = (refPath.startsWith("#/") ? refPath.slice(2) : refPath).split("/");
3541
4079
  const [, key] = parts; // "item" or "index"
3542
- 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;
3543
4083
  const base = map?.[key!] ?? state[`$map/${key}`];
3544
- return parts.length > 2 ? getPath(base, parts.slice(2).join("/")) : base;
4084
+ return parts.length > 2 ? readPath(base, parts.slice(2).join("/")) : base;
3545
4085
  }
3546
4086
  if (refPath.startsWith("#/state/")) {
3547
4087
  // One call, not a hand-split leading token: slicing at the first `/` skipped unescaping it, so
@@ -3556,10 +4096,10 @@ export function resolveRef(refPath: string, state: JxScope) {
3556
4096
  return readPath(state, refPath.slice("parent#/".length));
3557
4097
  }
3558
4098
  if (refPath.startsWith("window#/")) {
3559
- return getPath(globalThis.window, refPath.slice("window#/".length));
4099
+ return readPath(globalThis.window, refPath.slice("window#/".length));
3560
4100
  }
3561
4101
  if (refPath.startsWith("document#/")) {
3562
- return getPath(globalThis.document, refPath.slice("document#/".length));
4102
+ return readPath(globalThis.document, refPath.slice("document#/".length));
3563
4103
  }
3564
4104
  return readPath(state, refPath) ?? null;
3565
4105
  }
@@ -3570,32 +4110,12 @@ export function resolveRef(refPath: string, state: JxScope) {
3570
4110
  * Check if v is a Vue ref (including computed).
3571
4111
  *
3572
4112
  * @param {unknown} v
3573
- * @returns {boolean}
4113
+ * @returns {v is Ref<unknown>}
3574
4114
  */
3575
- export function isSignal(v: unknown) {
4115
+ export function isSignal(v: unknown): v is Ref<unknown> {
3576
4116
  return isRef(v);
3577
4117
  }
3578
4118
 
3579
- /**
3580
- * @param {unknown} v
3581
- * @returns {v is import("@jxsuite/schema/types").JxRef}
3582
- */
3583
- function isRefObj(v: unknown): v is JxRef {
3584
- return isRefValue(v);
3585
- }
3586
-
3587
- /**
3588
- * Walk a path off a value. Delegates to the one tokenizer (`pointer.ts`) — this used to split on
3589
- * `/[./]/`, which no other path in the codebase agreed with.
3590
- *
3591
- * @param {unknown} obj
3592
- * @param {string} path
3593
- * @returns {unknown}
3594
- */
3595
- function getPath(obj: unknown, path: string) {
3596
- return readPath(obj, path);
3597
- }
3598
-
3599
4119
  /**
3600
4120
  * Write an observed attribute into element state, coerced by the type the state entry already holds
3601
4121
  * (spec §16.5): a number parses, a boolean is presence (`"false"` counts as absent), and anything
@@ -3615,40 +4135,39 @@ function absorbAttribute(
3615
4135
  state: JxScope,
3616
4136
  name: string,
3617
4137
  value: string | null,
3618
- defaults?: ReadonlyMap<string, unknown>,
4138
+ defaults?: ReadonlyMap<string, JxScopeValue>,
3619
4139
  ): void {
3620
4140
  const camelKey = name.replaceAll(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
3621
4141
  const current = state[camelKey];
3622
- if (typeof current === "boolean") {
4142
+ if (isBoolean(current)) {
3623
4143
  // Presence IS the value for a boolean, so a removal is already spelled by the same rule.
3624
4144
  state[camelKey] = value !== null && value !== "false";
3625
4145
  return;
3626
4146
  }
3627
4147
  if (value === null) {
3628
4148
  const declared = defaults?.get(camelKey);
3629
- state[camelKey] = declared === undefined ? (typeof current === "number" ? 0 : "") : declared;
4149
+ state[camelKey] = declared === undefined ? (isNumber(current) ? 0 : "") : declared;
3630
4150
  return;
3631
4151
  }
3632
- state[camelKey] = typeof current === "number" ? Number(value) : value;
4152
+ state[camelKey] = isNumber(current) ? Number(value) : value;
3633
4153
  }
3634
4154
 
3635
4155
  /**
3636
4156
  * The declared default of every state entry that has one, by state key.
3637
4157
  *
3638
4158
  * @param def The element definition
3639
- * @returns {Map<string, unknown>} Key → default
4159
+ * @returns {Map<string, JxScopeValue>} Key → default
3640
4160
  */
3641
- function declaredDefaults(def: JxElement): Map<string, unknown> {
3642
- const out = new Map<string, unknown>();
4161
+ function declaredDefaults(def: JxElement): Map<string, JxScopeValue> {
4162
+ const out = new Map<string, JxScopeValue>();
3643
4163
  for (const [key, entry] of Object.entries(def.state ?? {})) {
3644
- if (entry !== null && typeof entry === "object") {
3645
- const obj = entry as Record<string, unknown>;
3646
- if ("default" in obj) {
3647
- out.set(key, obj["default"]);
4164
+ if (isNonNullObject(entry)) {
4165
+ if ("default" in entry) {
4166
+ out.set(key, entry.default);
3648
4167
  continue;
3649
4168
  }
3650
4169
  // A computed entry has no default: its value is produced, and a removal leaves it alone.
3651
- if (COMPUTED_STATE_KEYS.some((marker) => marker in obj)) {
4170
+ if (COMPUTED_STATE_KEYS.some((marker) => marker in entry)) {
3652
4171
  continue;
3653
4172
  }
3654
4173
  }
@@ -3709,6 +4228,27 @@ function instanceSupplies(el: HTMLElement, key: string): boolean {
3709
4228
  return kebab !== key && el.hasAttribute(kebab);
3710
4229
  }
3711
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
+
3712
4252
  function refusePrivateProp(key: string, where?: string): boolean {
3713
4253
  if (!isPrivateStateKey(key)) {
3714
4254
  return false;
@@ -3736,12 +4276,12 @@ export function _resetPrivatePropWarnings(): void {
3736
4276
  * @returns {JxScope}
3737
4277
  */
3738
4278
  function mergeProps(def: JxElement, parentState: JxScope): JxScope {
3739
- const child = Object.create(parentState) as JxScope;
4279
+ const child = inheritScope(parentState);
3740
4280
  for (const [k, v] of Object.entries(def.$props ?? {})) {
3741
4281
  if (refusePrivateProp(k, "$props")) {
3742
4282
  continue;
3743
4283
  }
3744
- child[k] = isRefObj(v) ? resolveRef(v.$ref, parentState) : v;
4284
+ child[k] = isRefValue(v) ? resolveRef(v.$ref, parentState) : v;
3745
4285
  }
3746
4286
  return child;
3747
4287
  }
@@ -3782,12 +4322,12 @@ export type { CssBuildOptions, CssKeyframeBlock, CssRule, CssRuleTarget } from "
3782
4322
  * custom property survives it: `--fooBar` and `--foo-bar` are two different properties, and
3783
4323
  * kebab-casing one renames it out from under the `var()` that reads it.
3784
4324
  *
3785
- * @param {Record<string, unknown> | object} rules
4325
+ * @param {JxStyle} rules
3786
4326
  * @returns {string}
3787
4327
  */
3788
- export function toCSSText(rules: Record<string, unknown> | object) {
4328
+ export function toCSSText(rules: JxStyle) {
3789
4329
  return Object.entries(rules)
3790
- .filter(([k, v]) => !isNestedSelectorKey(k) && (v === null || typeof v !== "object"))
4330
+ .filter(([k, v]) => !isNestedSelectorKey(k) && !isNonNullObject(v))
3791
4331
  .map(([p, v]) => `${cssPropertyName(p)}: ${canvasStyleValue(String(v))}`)
3792
4332
  .join("; ");
3793
4333
  }
@@ -3855,9 +4395,9 @@ export async function redefineElement(doc: JxDocument, baseUrl?: string): Promis
3855
4395
  * before `buildScope`, and re-set it every render so a stale map from a previous document cannot
3856
4396
  * leak.
3857
4397
  *
3858
- * @param {Record<string, string>} map
4398
+ * @param {Record<string, string> | null} [map] - Absent or null clears it
3859
4399
  */
3860
- export function setRootMedia(map: Record<string, string>): void {
4400
+ export function setRootMedia(map?: Record<string, string> | null): void {
3861
4401
  _rootMedia = map ?? {};
3862
4402
  }
3863
4403
 
@@ -3875,7 +4415,7 @@ async function registerElements(
3875
4415
  ) {
3876
4416
  for (const entry of elements) {
3877
4417
  // Bare string: npm package side-effect import (registers custom elements)
3878
- if (typeof entry === "string") {
4418
+ if (isString(entry)) {
3879
4419
  try {
3880
4420
  // Bare specifiers need a URL path for the browser; the dev server resolves
3881
4421
  // /node_modules/<pkg> to the package entry point via exports/module/main.
@@ -3887,7 +4427,7 @@ async function registerElements(
3887
4427
  }
3888
4428
  continue;
3889
4429
  }
3890
- if (!isRefObj(entry)) {
4430
+ if (!isRefValue(entry)) {
3891
4431
  continue;
3892
4432
  }
3893
4433
  const { href } = new URL(entry.$ref, base);
@@ -3915,7 +4455,7 @@ async function registerElements(
3915
4455
  * @param {import("@jxsuite/schema/types").JxHeadEntry[]} entries
3916
4456
  * @param {string} _base - Document base URL for resolving relative paths
3917
4457
  */
3918
- function injectHead(entries: JxHeadEntry[], _base: string) {
4458
+ function injectHead(entries: readonly (JxHeadEntry | null | undefined)[], _base: string) {
3919
4459
  for (const entry of entries) {
3920
4460
  if (!entry || !entry.tagName) {
3921
4461
  continue;
@@ -3925,7 +4465,7 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
3925
4465
  // Resolve href/src: bare npm specifiers -> /node_modules/ path
3926
4466
  for (const key of ["href", "src"] as const) {
3927
4467
  const v = attrs[key];
3928
- if (typeof v !== "string" || v === "") {
4468
+ if (!isString(v) || v === "") {
3929
4469
  continue;
3930
4470
  }
3931
4471
  if (!v.startsWith("/") && !v.startsWith(".") && !v.startsWith("http")) {
@@ -3951,10 +4491,9 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
3951
4491
  if (entry.textContent) {
3952
4492
  // An object is a structured data block (JSON-LD, site-architecture.md §8.5). The compiler
3953
4493
  // Serializes it the same way, so the interpreted page and the built page agree.
3954
- el.textContent =
3955
- typeof entry.textContent === "object"
3956
- ? JSON.stringify(entry.textContent, null, 2)
3957
- : entry.textContent;
4494
+ el.textContent = isNonNullObject(entry.textContent)
4495
+ ? JSON.stringify(entry.textContent, null, 2)
4496
+ : entry.textContent;
3958
4497
  }
3959
4498
  document.head.append(el);
3960
4499
  }
@@ -3963,18 +4502,18 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
3963
4502
  // ─── Instantiation chain (spec.md §16.9) ─────────────────────────────────────
3964
4503
 
3965
4504
  /**
3966
- * How deep component nesting may go before an instance refuses to render.
3967
- *
3968
- * The same number the compiler holds in `MAX_COMPONENT_NESTING` (`@jxsuite/compiler`, `shared.ts`),
3969
- * and for the same reason: the cycle check in {@link extendInstancePath} catches a definition that
3970
- * renders ITSELF — same tag, same props — which is the only shape that can never terminate, while a
3971
- * self-reference whose props change at every level is data-driven recursion that a `$switch` may or
3972
- * may not bottom out. The seen-set cannot tell those apart, so this cap is what turns a forgotten
3973
- * stop into a diagnostic instead of a page that never settles. It is written twice because the
3974
- * compiler depends on the runtime and not the other way round, and the runtime's browser bundle
3975
- * cannot reach into the build tool; spec.md §16.9 is the one statement both are held to, and the
3976
- * compiler's `nesting-cap-agreement.test.ts` asserts its copy against this export, so the two
3977
- * 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.
3978
4517
  */
3979
4518
  export const MAX_COMPONENT_NESTING = 32;
3980
4519
 
@@ -3982,7 +4521,7 @@ export const MAX_COMPONENT_NESTING = 32;
3982
4521
  interface InstanceFrame {
3983
4522
  tag: string;
3984
4523
  /** Every value the instance supplied that its definition's state read (§16.9). */
3985
- props: Record<string, unknown>;
4524
+ props: JxScope;
3986
4525
  }
3987
4526
 
3988
4527
  /**
@@ -4035,6 +4574,30 @@ const ROOT_INSTANCE_PATH: InstancePath = { expansion: 0, frames: [] };
4035
4574
  */
4036
4575
  let _instancePath: InstancePath = ROOT_INSTANCE_PATH;
4037
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
+
4038
4601
  /**
4039
4602
  * Run `fn` with `path` as the chain, so every instance that CONNECTS during it reads that chain.
4040
4603
  *
@@ -4083,11 +4646,11 @@ function reexpand(path: InstancePath): InstancePath {
4083
4646
  * ended. Inside plain data the comparison IS the JSON form, so a member JSON cannot see (a
4084
4647
  * function, a `Map` two levels down) is not seen here either; §16.9 says so.
4085
4648
  *
4086
- * @param {unknown} a
4087
- * @param {unknown} b
4649
+ * @param {JxScopeValue} a
4650
+ * @param {JxScopeValue} b
4088
4651
  * @returns {boolean}
4089
4652
  */
4090
- function samePropValue(a: unknown, b: unknown): boolean {
4653
+ function samePropValue(a: JxScopeValue, b: JxScopeValue): boolean {
4091
4654
  if (Object.is(a, b)) {
4092
4655
  return true;
4093
4656
  }
@@ -4104,11 +4667,11 @@ function samePropValue(a: unknown, b: unknown): boolean {
4104
4667
  /**
4105
4668
  * Whether a value is an array or a plain object — the shapes whose JSON form is the whole value.
4106
4669
  *
4107
- * @param {unknown} value
4108
- * @returns {value is object}
4670
+ * @param {JxScopeValue} value
4671
+ * @returns {boolean}
4109
4672
  */
4110
- function isPlainData(value: unknown): value is object {
4111
- if (typeof value !== "object" || value === null) {
4673
+ function isPlainData(value: JxScopeValue): boolean {
4674
+ if (!isNonNullObject(value)) {
4112
4675
  return false;
4113
4676
  }
4114
4677
  const raw: unknown = toRaw(value);
@@ -4184,19 +4747,19 @@ const _definedSources = new Set<string>();
4184
4747
 
4185
4748
  export async function defineElement(source: string | JxDocument, baseUrl?: string) {
4186
4749
  let base = baseUrl;
4187
- let doc = source;
4188
- if (typeof source === "string") {
4750
+ let source_: JxDocument;
4751
+ if (isString(source)) {
4189
4752
  base = new URL(source, base ?? location.href).href;
4190
4753
  if (_definedSources.has(base)) {
4191
4754
  return;
4192
4755
  }
4193
4756
  _definedSources.add(base);
4194
- doc = await resolve(source);
4757
+ source_ = await resolve(source);
4758
+ } else {
4759
+ source_ = source;
4195
4760
  }
4196
4761
  base ??= location.href;
4197
4762
 
4198
- const source_: JxDocument = doc as JxDocument;
4199
-
4200
4763
  const { tagName } = source_;
4201
4764
  if (!tagName || !tagName.includes("-")) {
4202
4765
  throw new Error(`Jx defineElement: tagName "${tagName}" must contain a hyphen`);
@@ -4248,7 +4811,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4248
4811
  the same values render the same thing, forever. Only a value the definition's state READS
4249
4812
  is recorded — a `$props` key no state entry declares changes nothing about the render, so
4250
4813
  a definition that "varies" one at each level is still rendering itself. */
4251
- const supplied: Record<string, unknown> = {};
4814
+ const supplied: JxScope = {};
4252
4815
 
4253
4816
  // The definition as it stands NOW — a redefinition since this class was made is honoured.
4254
4817
  const live = _elementDefs.get(tagName) ?? { base: initialBase, doc: initialDef };
@@ -4276,8 +4839,13 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4276
4839
  const propsAttr = this.dataset.jxProps;
4277
4840
  if (propsAttr) {
4278
4841
  try {
4279
- const props = JSON.parse(propsAttr) as Record<string, unknown>;
4280
- 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) {
4281
4849
  if (refusePrivateProp(key, "data-jx-props")) {
4282
4850
  continue;
4283
4851
  }
@@ -4315,13 +4883,10 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4315
4883
  if (isPrivateStateKey(key)) {
4316
4884
  continue;
4317
4885
  }
4318
- if (
4319
- key in this &&
4320
- (this as Record<string, unknown>)[key] !== undefined &&
4321
- instanceSupplies(this, key)
4322
- ) {
4323
- state[key] = (this as Record<string, unknown>)[key];
4324
- 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];
4325
4890
  }
4326
4891
  }
4327
4892
 
@@ -4350,18 +4915,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4350
4915
  ownPath = extendInstancePath(enclosingPath, { props: supplied, tag: tagName });
4351
4916
  } catch (error) {
4352
4917
  this.replaceChildren();
4353
- if (!this.isConnected) {
4354
- return;
4355
- }
4356
- console.error(`Jx: <${tagName}> was not rendered:`, error);
4357
- this.dispatchEvent(
4358
- new ErrorEvent("jx-error", {
4359
- bubbles: true,
4360
- composed: true,
4361
- error,
4362
- message: error instanceof Error ? error.message : String(error),
4363
- }),
4364
- );
4918
+ reportUnrendered(this, tagName, error instanceof Error ? error : new Error(String(error)));
4365
4919
  return;
4366
4920
  }
4367
4921
  /*
@@ -4373,21 +4927,26 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4373
4927
  if (isPrivateStateKey(key)) {
4374
4928
  continue;
4375
4929
  }
4376
- if (!(key in HTMLElement.prototype)) {
4377
- Object.defineProperty(this, key, {
4378
- configurable: true,
4379
- get: () => state[key],
4380
- set: (v: unknown) => {
4381
- state[key] = v;
4382
- },
4383
- });
4384
- }
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
+ });
4385
4944
  }
4386
4945
 
4387
4946
  this._state = state;
4388
4947
 
4389
4948
  // Capture light DOM children (for slot distribution) before rendering
4390
- const slottedChildren = [...this.childNodes];
4949
+ const slottedChildren = captureSlotted(this);
4391
4950
  this.replaceChildren();
4392
4951
 
4393
4952
  /* Custom elements default to `display: inline`; a Jx container behaves like a `<div>`, so
@@ -4396,14 +4955,16 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4396
4955
  without one. At (0,1,0) a consumer's rule, a cascade layer or a parent's descendant rule
4397
4956
  all win normally, and the definition's own `display` still wins because it is written into
4398
4957
  the same rule after this. */
4399
- 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 };
4400
4961
  /* Definition first, call site second, so the call site wins at equal specificity by source
4401
4962
  order — the promise §9.2 already makes for base-before-nested. Merged into ONE call
4402
4963
  because each `applyStyle` releases what the last one interned. */
4403
- const hostStyle = mergeStyle(defStyle as JxStyle, callSiteStyles.get(this));
4964
+ const hostStyle = mergeStyle(defStyle, callSiteStyles.get(this));
4404
4965
 
4405
4966
  // Render template into light DOM (once, not in effect — inner effects handle reactivity)
4406
- applyStyle(this, hostStyle, (state["$media"] as Record<string, string>) ?? {}, state);
4967
+ applyStyle(this, hostStyle, scopeMedia(state), state);
4407
4968
  applyAttributes(this, def.attributes ?? {}, state);
4408
4969
  bindDefinitionHandlers(this, def, state);
4409
4970
 
@@ -4416,46 +4977,122 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4416
4977
  bindProperty(this, "textContent", def.textContent, state);
4417
4978
  }
4418
4979
  const children = Array.isArray(def.children) ? def.children : [];
4419
- /* An instance the studio stamped is part of the page being edited, so everything the
4420
- definition draws inside it is too — and the de-link rules cannot see that from the nodes
4421
- themselves. Restored rather than cleared, because one definition may render another. */
4422
- const wasInside = _canvasInsideStampedHost;
4423
- _canvasInsideStampedHost ||= this.dataset.jxPath !== undefined;
4424
- /* And the chain, for the same span: an instance this definition draws connects inside
4425
- `this.append` below and reads it there ({@link underInstancePath}). */
4426
- try {
4427
- underInstancePath(ownPath, () => {
4428
- for (const childDef of children) {
4429
- this.append(renderNode(childDef, state));
4430
- }
4431
- });
4432
- } finally {
4433
- _canvasInsideStampedHost = wasInside;
4434
- }
4435
-
4436
- // Slot distribution (light DOM)
4437
- 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
+ };
4438
5008
 
4439
5009
  /* Lifecycle: onMount, with the HOST as its second argument.
4440
5010
  A sidecar that must reach the element it belongs to had no way to: the runtime handed it
4441
5011
  the scope alone, so elements resorted to dispatching a `jx-ready` event at themselves and
4442
5012
  catching it with a root handler purely to learn `currentTarget`. The host is what the
4443
- callback needed; the event round trip was the workaround. */
4444
- const { onMount } = state;
4445
- if (typeof onMount === "function") {
4446
- 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;
4447
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
+ );
4448
5082
  }
4449
5083
 
4450
5084
  disconnectedCallback() {
4451
- if (typeof this._state?.onUnmount === "function") {
4452
- (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);
4453
5089
  }
4454
5090
  }
4455
5091
 
4456
5092
  adoptedCallback() {
4457
- if (typeof this._state?.onAdopted === "function") {
4458
- (this._state.onAdopted as (s: JxScope) => unknown)(this._state);
5093
+ const state = this._state;
5094
+ if (state && isScopeHook(state.onAdopted)) {
5095
+ state.onAdopted(state);
4459
5096
  }
4460
5097
  }
4461
5098
 
@@ -4507,11 +5144,25 @@ function renderCustomElementWithProps(
4507
5144
  element is an asset reference authored at the USAGE site, so it belongs to the document that
4508
5145
  wrote it — a media prop the component forwards into its own `<img>` is resolved separately,
4509
5146
  inside the component's render, against the same context. */
4510
- const write = (resolved: unknown): void => {
4511
- (el as unknown as Record<string, unknown>)[key] =
4512
- 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
+ }
4513
5164
  };
4514
- if (isRefObj(val)) {
5165
+ if (isRefValue(val)) {
4515
5166
  const refVal = val;
4516
5167
  write(resolveRef(refVal.$ref, state));
4517
5168
  // Reactive forwarding: re-set the property when the source changes
@@ -4519,7 +5170,7 @@ function renderCustomElementWithProps(
4519
5170
  write(resolveRef(refVal.$ref, state));
4520
5171
  });
4521
5172
  } else if (isTemplateString(val)) {
4522
- effect(() => {
5173
+ bindTemplate(val, () => {
4523
5174
  write(evaluateTemplate(val, state));
4524
5175
  });
4525
5176
  } else {
@@ -4535,7 +5186,7 @@ function renderCustomElementWithProps(
4535
5186
  if (def.style) {
4536
5187
  callSiteStyles.set(el, def.style);
4537
5188
  }
4538
- applyStyle(el, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
5189
+ applyStyle(el, def.style ?? {}, scopeMedia(state), state);
4539
5190
  applyAttributes(el, def.attributes ?? {}, state);
4540
5191
  /* And its ordinary properties and handlers — `id`, `hidden`, `className`, an `onselect` — exactly
4541
5192
  as an ordinary element gets them. A custom element used in a document is still an element in
@@ -4560,47 +5211,184 @@ function renderCustomElementWithProps(
4560
5211
  }
4561
5212
 
4562
5213
  /**
4563
- * Light DOM slot distribution.
5214
+ * Whether a node is an element, by its `nodeType` (which, unlike `instanceof`, holds across
5215
+ * realms).
4564
5216
  *
4565
- * @param {HTMLElement} host
4566
- * @param {ChildNode[]} slottedChildren
4567
- */
4568
- function distributeSlots(host: HTMLElement, slottedChildren: ChildNode[]) {
4569
- /* No early return when nothing was slotted. A slot leaves no node WHETHER OR NOT it received
4570
- anything — an unmatched one unwraps to its own fallback children, and a host given nothing at
4571
- all is just every slot unmatched. Returning early here left the slot standing in exactly the
4572
- case an element most wants to detect: `[part="label"]:empty` answered false for a control with
4573
- no label, because the surviving slot was still a child. */
4574
- const slots = host.querySelectorAll("slot");
4575
- if (slots.length === 0) {
4576
- return;
4577
- }
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) ─────────────────────────────────────────
4578
5245
 
4579
- const named = new Map<string | null, ChildNode[]>();
4580
- const unnamed: ChildNode[] = [];
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;
5252
+
5253
+ /** The word that opens a prerendered slot group's comment bracket: `<!--jx-slot n name-->`. */
5254
+ export const SLOT_MARK = "jx-slot";
5255
+
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
+ })();
4581
5277
 
4582
- for (const child of slottedChildren) {
4583
- if (child.nodeType === Node.ELEMENT_NODE && (child as Element).getAttribute("slot")) {
4584
- const name = (child as Element).getAttribute("slot");
4585
- if (!named.has(name)) {
4586
- named.set(name, []);
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;
4587
5313
  }
4588
- (named.get(name) as ChildNode[]).push(child);
4589
- } else {
4590
- unnamed.push(child);
5314
+ slotRecord.set(n, name);
5315
+ captured.push(n);
4591
5316
  }
4592
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
+ }
4593
5331
 
4594
- /* A `<slot>` UNWRAPS: its matches stand in its place and the slot itself leaves no node.
4595
- It used to survive, holding its matches as children. The box tree looked right, because a
4596
- slot is `display: contents` — but the SELECTOR tree did not, so every `& > x` rule in a
4597
- definition silently stopped matching a slotted child, which is now a grandchild. Measured on
4598
- the field row: 68.40px where the class form gives 88.40px, and its help text 80px wide
4599
- instead of 260px, with `[data-span]` dead. An unmatched slot unwraps to its own fallback
4600
- 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>();
4601
5377
  for (const slot of slots) {
4602
- const name = slot.getAttribute("name");
4603
- const matches = name ? (named.get(name) ?? []) : unnamed;
4604
- 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);
4605
5393
  }
4606
5394
  }