@jxsuite/runtime 6.0.2 → 6.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/runtime",
3
- "version": "6.0.2",
3
+ "version": "6.2.0",
4
4
  "description": "JSON-native reactive web component runtime",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  "upgrade": "bun update --latest && bun install"
34
34
  },
35
35
  "dependencies": {
36
- "@jxsuite/schema": "^5.1.0",
36
+ "@jxsuite/schema": "^5.3.0",
37
37
  "@vue/reactivity": "3.5.44"
38
38
  },
39
39
  "devDependencies": {
package/src/runtime.ts CHANGED
@@ -388,6 +388,19 @@ export async function resolve(source: string | JxDocument, ctx?: JxContext): Pro
388
388
  `app shell (a single-page fallback), so this path is not served.`,
389
389
  );
390
390
  }
391
+ /* A site origin answers `.jxn` as JSON, so only an editing server or a bare file host gets here
392
+ with KDL, and the parser is the host's to supply: a lazy import here would be inlined by any
393
+ host that bundles the runtime into one file, and ship the parser to every reader. */
394
+ if (JXN_URL.test(source) && !contentType.includes("json")) {
395
+ if (!_documentParser) {
396
+ throw new Error(
397
+ `Jx: ${source} is a .jxn file and this page has no parser for it. Serve it from a Jx ` +
398
+ `site, which answers JSON, or call setDocumentParser(parseJxn) with parseJxn from ` +
399
+ `@jxsuite/schema/jxn.`,
400
+ );
401
+ }
402
+ return _documentParser(await res.text(), source);
403
+ }
391
404
  /* SAFETY: trust boundary. `resolve` fetches only URLs that name a Jx document (a `Jx()` source,
392
405
  an `$elements` entry, a `$switch` case), and the host answered 2xx with a non-HTML body, so
393
406
  the JSON is that document; the runtime does not schema-validate documents by design. */
@@ -399,6 +412,28 @@ export async function resolve(source: string | JxDocument, ctx?: JxContext): Pro
399
412
 
400
413
  // ─── Step 2: Build scope ──────────────────────────────────────────────────────
401
414
 
415
+ /** The path part of a URL naming a `.jxn` file. */
416
+ const JXN_URL = /\.jxn(?:[?#]|$)/iu;
417
+
418
+ /** What reads a `.jxn` file's text. Absent, the runtime refuses one rather than guessing. */
419
+ let _documentParser: ((text: string, source: string) => JxDocument | Promise<JxDocument>) | null =
420
+ null;
421
+
422
+ /**
423
+ * Supply what reads a `.jxn` file, for a host that serves one as its text.
424
+ *
425
+ * A compiled site never needs this, and a `$ref` from a Jx site origin does not: both get JSON.
426
+ * Studio's canvas does, because an editing server answers a file as it is.
427
+ *
428
+ * @param parser `parseJxn` from `@jxsuite/schema/jxn`, or a function that loads it first, or null
429
+ * to take it away again.
430
+ */
431
+ export function setDocumentParser(
432
+ parser: ((text: string, source: string) => JxDocument | Promise<JxDocument>) | null,
433
+ ): void {
434
+ _documentParser = parser;
435
+ }
436
+
402
437
  /** JSON Schema keywords used to identify pure type definitions (Shape 2b). */
403
438
  const SCHEMA_KEYWORDS = new Set([
404
439
  "type",
@@ -1163,6 +1198,69 @@ export function setTemplateErrorHandler(
1163
1198
  _templateErrorHandler = handler;
1164
1199
  }
1165
1200
 
1201
+ /** Warn about a throwing template, once per source. */
1202
+ function warnTemplateError(source: string, error: Error): void {
1203
+ if (!reportedTemplates.has(source)) {
1204
+ reportedTemplates.add(source);
1205
+ console.warn(`jx: template ${JSON.stringify(source)} threw:`, error);
1206
+ }
1207
+ }
1208
+
1209
+ /** Lookups in flight whose answers can make a throwing template valid (§6.1). */
1210
+ let pendingLookups = 0;
1211
+
1212
+ /** Each binding still throwing while a lookup is in flight, by its `run`, with its latest throw. */
1213
+ const heldTemplateErrors = new Map<() => void, { error: Error; source: string }>();
1214
+
1215
+ /** Who {@link lookupsSettled} has yet to answer. */
1216
+ const lookupWaiters: (() => void)[] = [];
1217
+
1218
+ /**
1219
+ * Hold template warnings until `lookup` and every other lookup in flight have settled.
1220
+ *
1221
+ * `lookup` must write its answer to state before it settles: the write re-runs the bindings waiting
1222
+ * on it, so whatever is still held when the last one settles is a template the data did not fix,
1223
+ * and only that is reported. A lookup its own answer starts is counted before this one is
1224
+ * released.
1225
+ *
1226
+ * @param {Promise<void>} lookup - Never rejects: it reports its own failure
1227
+ */
1228
+ function holdTemplateErrorsFor(lookup: Promise<void>): void {
1229
+ pendingLookups += 1;
1230
+ void lookup.finally(() => {
1231
+ pendingLookups -= 1;
1232
+ if (pendingLookups === 0) {
1233
+ /* The console, even if a handler was installed meanwhile: these throws happened outside that
1234
+ handler's render, and Studio's render critic would refuse its page for them. */
1235
+ for (const { error, source } of heldTemplateErrors.values()) {
1236
+ warnTemplateError(source, error);
1237
+ }
1238
+ heldTemplateErrors.clear();
1239
+ for (const wake of lookupWaiters.splice(0)) {
1240
+ wake();
1241
+ }
1242
+ }
1243
+ });
1244
+ }
1245
+
1246
+ /**
1247
+ * Answers once no lookup the runtime started is in flight (embedding.md §5.2).
1248
+ *
1249
+ * A host whose question is whether a document renders awaits this between `buildScope` and
1250
+ * `renderNode`, so its templates read the data rather than the `null` before it. A lookup can hang,
1251
+ * and this waits for it, so bounding the wait is the host's job.
1252
+ *
1253
+ * @returns {Promise<void>}
1254
+ */
1255
+ export function lookupsSettled(): Promise<void> {
1256
+ if (pendingLookups === 0) {
1257
+ return Promise.resolve();
1258
+ }
1259
+ return new Promise((wake) => {
1260
+ lookupWaiters.push(wake);
1261
+ });
1262
+ }
1263
+
1166
1264
  /**
1167
1265
  * Run a template binding as an effect that survives its expression throwing.
1168
1266
  *
@@ -1172,7 +1270,8 @@ export function setTemplateErrorHandler(
1172
1270
  * and the throw unwound the whole `renderNode` above it: one early read blanked the page. Caught
1173
1271
  * INSIDE the effect, every dependency read before the throw stays tracked, so the binding re-runs
1174
1272
  * 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.
1273
+ * default), and the error is reported once per source, after any lookup in flight has settled: a
1274
+ * template that only needed that lookup's answer is not reported at all.
1176
1275
  *
1177
1276
  * @param {string} source - The template, for the report
1178
1277
  * @param {() => void} run - Evaluates the template and writes it
@@ -1182,12 +1281,15 @@ function bindTemplate(source: string, run: () => void, fallback?: () => void): v
1182
1281
  effect(() => {
1183
1282
  try {
1184
1283
  run();
1284
+ heldTemplateErrors.delete(run);
1185
1285
  } catch (error) {
1286
+ const thrown = error instanceof Error ? error : new Error(String(error));
1186
1287
  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);
1288
+ _templateErrorHandler(thrown, source);
1289
+ } else if (pendingLookups > 0) {
1290
+ heldTemplateErrors.set(run, { error: thrown, source });
1291
+ } else {
1292
+ warnTemplateError(source, thrown);
1191
1293
  }
1192
1294
  fallback?.();
1193
1295
  }
@@ -1619,7 +1721,15 @@ export const RESERVED_KEYS = new Set([
1619
1721
  ]);
1620
1722
 
1621
1723
  /**
1622
- * A child scope: reads fall through to `parent`, writes land on the child.
1724
+ * A child scope: a `$map` row, or a node carrying `$`-prefixed local bindings or `$props`. Reads
1725
+ * fall through to `parent`, and so does a write to any key the chain above already holds.
1726
+ *
1727
+ * The write is the half a bare `Object.create(parent)` gets wrong, and it gets it wrong silently.
1728
+ * An assignment that reaches the reactive proxy through a prototype chain arrives with the child as
1729
+ * its receiver, so the proxy defines the value on the child and triggers nothing: the handler's
1730
+ * `count += 1` shadows `count`, the root never changes, and the bindings reading it never re-run. A
1731
+ * compiled document assigns to the root directly, so the same document counted there and sat still
1732
+ * here. A value this scope owns is declared with {@link bindLocal}, which is what shadows.
1623
1733
  *
1624
1734
  * @param {JxScope} parent
1625
1735
  * @returns {JxScope}
@@ -1627,7 +1737,35 @@ export const RESERVED_KEYS = new Set([
1627
1737
  function inheritScope(parent: JxScope): JxScope {
1628
1738
  /* SAFETY: `Object.create` is typed `any`, but what it returns is a plain object whose prototype
1629
1739
  is `parent`, so it is keyed by string exactly as the scope it extends is. */
1630
- return Object.create(parent) as JxScope;
1740
+ const local = Object.create(parent) as JxScope;
1741
+ return new Proxy(local, {
1742
+ set(target, key: string, value: JxScopeValue) {
1743
+ if (Object.hasOwn(target, key)) {
1744
+ return Reflect.set(target, key, value);
1745
+ }
1746
+ if (key in parent) {
1747
+ return Reflect.set(parent, key, value);
1748
+ }
1749
+ bindLocal(target, key, value);
1750
+ return true;
1751
+ },
1752
+ });
1753
+ }
1754
+
1755
+ /**
1756
+ * Declare `key` on a child scope itself, shadowing whatever its parents hold under that name.
1757
+ *
1758
+ * @param {JxScope} scope
1759
+ * @param {string} key
1760
+ * @param {JxScopeValue} value
1761
+ */
1762
+ function bindLocal(scope: JxScope, key: string, value: JxScopeValue): void {
1763
+ Object.defineProperty(scope, key, {
1764
+ configurable: true,
1765
+ enumerable: true,
1766
+ value,
1767
+ writable: true,
1768
+ });
1631
1769
  }
1632
1770
 
1633
1771
  /**
@@ -1752,7 +1890,7 @@ export function renderNode(
1752
1890
  if (localState === state) {
1753
1891
  localState = inheritScope(state);
1754
1892
  }
1755
- localState[key] = isRefValue(val) ? resolveRef(val.$ref, state) : val;
1893
+ bindLocal(localState, key, isRefValue(val) ? resolveRef(val.$ref, state) : val);
1756
1894
  }
1757
1895
  }
1758
1896
 
@@ -1857,11 +1995,74 @@ export function renderNode(
1857
1995
  el.append(renderNode(child, localState, childOpts));
1858
1996
  }
1859
1997
  }
1998
+ } else if (isTemplateString(kids)) {
1999
+ renderComputedChildren(el, kids, localState, {
2000
+ ...options,
2001
+ _ns: childNs,
2002
+ _path: [...path, "children"],
2003
+ });
1860
2004
  }
1861
2005
 
1862
2006
  return el;
1863
2007
  }
1864
2008
 
2009
+ /**
2010
+ * Draw a whole-`children` template, the way the site build's template pass expands one (spec.md
2011
+ * §8.4). This is what an item's or an entry's parsed body looks like to something that renders the
2012
+ * source document rather than the build's output: Studio's Preview, chiefly.
2013
+ *
2014
+ * A result that is not an array draws nothing, because a plain string is not a `children` value.
2015
+ * The children are rendered in a detached scope of their own, replaced when the template's value
2016
+ * changes (a collection that was still loading), and with tracking paused for the reason
2017
+ * {@link renderSwitch} gives: the bindings inside track for themselves.
2018
+ *
2019
+ * @param {Element} el - The element whose children these are
2020
+ * @param {string} template - The `children` value
2021
+ * @param {JxScope} state
2022
+ * @param {JxRenderOptions} options - `_path` is the `children` position of `el`
2023
+ */
2024
+ function renderComputedChildren(
2025
+ el: Element,
2026
+ template: string,
2027
+ state: JxScope,
2028
+ options: JxRenderOptions,
2029
+ ): void {
2030
+ const path = options._path ?? [];
2031
+ let live: EffectScope | null = null;
2032
+ const retire = (): void => {
2033
+ // Detached before it is stopped, for the reason `teardown` in `renderMappedArray` gives.
2034
+ el.replaceChildren();
2035
+ live?.stop();
2036
+ live = null;
2037
+ };
2038
+ bindTemplate(
2039
+ template,
2040
+ () => {
2041
+ const nodes = evaluateValueTemplate(template, state);
2042
+ retire();
2043
+ if (!Array.isArray(nodes)) {
2044
+ return;
2045
+ }
2046
+ const scope = effectScope(true);
2047
+ live = scope;
2048
+ pauseTracking();
2049
+ try {
2050
+ scope.run(() => {
2051
+ /* SAFETY: a children template that yields an array stands for the child list itself, the
2052
+ same reading the site build's template pass gives it. */
2053
+ for (const [i, child] of (nodes as (JxElement | string)[]).entries()) {
2054
+ el.append(renderNode(child, state, { ...options, _path: [...path, i] }));
2055
+ }
2056
+ });
2057
+ } finally {
2058
+ resetTracking();
2059
+ }
2060
+ },
2061
+ retire,
2062
+ );
2063
+ onScopeDispose(() => live?.stop(), true);
2064
+ }
2065
+
1865
2066
  // ─── Template string utilities ────────────────────────────────────────────────
1866
2067
 
1867
2068
  /**
@@ -2930,7 +3131,7 @@ function renderMappedArrayInto(
2930
3131
  ): MappedRow => {
2931
3132
  const map = shallowReactive({ index, item });
2932
3133
  const child = inheritScope(state);
2933
- child.$map = map;
3134
+ bindLocal(child, "$map", map);
2934
3135
  // The flat aliases stay readable by legacy readers, and follow the reactive context.
2935
3136
  Object.defineProperty(child, "$map/item", { enumerable: true, get: () => map.item });
2936
3137
  Object.defineProperty(child, "$map/index", { enumerable: true, get: () => map.index });
@@ -3297,29 +3498,32 @@ export async function resolvePrototype(
3297
3498
  }
3298
3499
  });
3299
3500
 
3300
- const doFetch = () =>
3301
- fetch(url, {
3302
- method: def.method ?? "GET",
3303
- signal: controller.signal,
3304
- ...(def.headers && { headers: def.headers }),
3305
- ...(def.body && {
3306
- body: isNonNullObject(def.body) ? JSON.stringify(def.body) : def.body,
3307
- }),
3308
- })
3309
- .then((r): Promise<JsonValue> => (r.ok ? r.json() : Promise.reject(r.statusText)))
3310
- .then((d) => {
3311
- s.value = d;
3501
+ const doFetch = () => {
3502
+ holdTemplateErrorsFor(
3503
+ fetch(url, {
3504
+ method: def.method ?? "GET",
3505
+ signal: controller.signal,
3506
+ ...(def.headers && { headers: def.headers }),
3507
+ ...(def.body && {
3508
+ body: isNonNullObject(def.body) ? JSON.stringify(def.body) : def.body,
3509
+ }),
3312
3510
  })
3313
- .catch((error) => {
3314
- if (!(error instanceof Error && error.name === "AbortError")) {
3315
- s.value = { error: String(error) };
3316
- }
3317
- });
3511
+ .then((r): Promise<JsonValue> => (r.ok ? r.json() : Promise.reject(r.statusText)))
3512
+ .then((d) => {
3513
+ s.value = d;
3514
+ })
3515
+ .catch((error) => {
3516
+ if (!(error instanceof Error && error.name === "AbortError")) {
3517
+ s.value = { error: String(error) };
3518
+ }
3519
+ }),
3520
+ );
3521
+ };
3318
3522
 
3319
3523
  if (debounceMs > 0) {
3320
3524
  debounceTimer = setTimeout(doFetch, debounceMs);
3321
3525
  } else {
3322
- void doFetch();
3526
+ doFetch();
3323
3527
  }
3324
3528
  });
3325
3529
  }
@@ -3850,24 +4054,25 @@ async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: stri
3850
4054
 
3851
4055
  // Always wrap in ref for reactivity
3852
4056
  const s: Ref<JxScopeValue> = ref(null);
4057
+ const resolveInto = (resolvedConfig: JxScope) => {
4058
+ holdTemplateErrorsFor(
4059
+ doResolve(resolvedConfig)
4060
+ .then((value) => {
4061
+ s.value = value;
4062
+ })
4063
+ .catch((error) => console.error("Jx dev proxy:", error)),
4064
+ );
4065
+ };
3853
4066
  if (hasTemplates) {
3854
4067
  effect(() => {
3855
4068
  const resolvedConfig: JxScope = {};
3856
4069
  for (const [k, v] of Object.entries(config)) {
3857
4070
  resolvedConfig[k] = isTemplateString(v) ? evaluateTemplate(v, state) : v;
3858
4071
  }
3859
- doResolve(resolvedConfig)
3860
- .then((value) => {
3861
- s.value = value;
3862
- })
3863
- .catch((error) => console.error("Jx dev proxy:", error));
4072
+ resolveInto(resolvedConfig);
3864
4073
  });
3865
4074
  } else {
3866
- doResolve(config)
3867
- .then((value) => {
3868
- s.value = value;
3869
- })
3870
- .catch((error) => console.error("Jx dev proxy:", error));
4075
+ resolveInto(config);
3871
4076
  }
3872
4077
  return s;
3873
4078
  }
@@ -3900,19 +4105,21 @@ function latestServerCall(
3900
4105
  const n = seq;
3901
4106
  /* The executor runs before the constructor returns, so the call still starts synchronously,
3902
4107
  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
- }
4108
+ holdTemplateErrorsFor(
4109
+ new Promise<JsonValue>((settle) => {
4110
+ settle(invoke(args));
3910
4111
  })
3911
- .catch((error) => {
3912
- if (n === seq) {
3913
- console.error(label, error);
3914
- }
3915
- });
4112
+ .then((result) => {
4113
+ if (n === seq) {
4114
+ s.value = result;
4115
+ }
4116
+ })
4117
+ .catch((error) => {
4118
+ if (n === seq) {
4119
+ console.error(label, error);
4120
+ }
4121
+ }),
4122
+ );
3916
4123
  };
3917
4124
  }
3918
4125
 
@@ -4281,7 +4488,7 @@ function mergeProps(def: JxElement, parentState: JxScope): JxScope {
4281
4488
  if (refusePrivateProp(k, "$props")) {
4282
4489
  continue;
4283
4490
  }
4284
- child[k] = isRefValue(v) ? resolveRef(v.$ref, parentState) : v;
4491
+ bindLocal(child, k, isRefValue(v) ? resolveRef(v.$ref, parentState) : v);
4285
4492
  }
4286
4493
  return child;
4287
4494
  }