@jxsuite/runtime 6.0.2 → 6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/runtime",
3
- "version": "6.0.2",
3
+ "version": "6.1.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.2.0",
37
37
  "@vue/reactivity": "3.5.44"
38
38
  },
39
39
  "devDependencies": {
package/src/runtime.ts CHANGED
@@ -1163,6 +1163,69 @@ export function setTemplateErrorHandler(
1163
1163
  _templateErrorHandler = handler;
1164
1164
  }
1165
1165
 
1166
+ /** Warn about a throwing template, once per source. */
1167
+ function warnTemplateError(source: string, error: Error): void {
1168
+ if (!reportedTemplates.has(source)) {
1169
+ reportedTemplates.add(source);
1170
+ console.warn(`jx: template ${JSON.stringify(source)} threw:`, error);
1171
+ }
1172
+ }
1173
+
1174
+ /** Lookups in flight whose answers can make a throwing template valid (§6.1). */
1175
+ let pendingLookups = 0;
1176
+
1177
+ /** Each binding still throwing while a lookup is in flight, by its `run`, with its latest throw. */
1178
+ const heldTemplateErrors = new Map<() => void, { error: Error; source: string }>();
1179
+
1180
+ /** Who {@link lookupsSettled} has yet to answer. */
1181
+ const lookupWaiters: (() => void)[] = [];
1182
+
1183
+ /**
1184
+ * Hold template warnings until `lookup` and every other lookup in flight have settled.
1185
+ *
1186
+ * `lookup` must write its answer to state before it settles: the write re-runs the bindings waiting
1187
+ * on it, so whatever is still held when the last one settles is a template the data did not fix,
1188
+ * and only that is reported. A lookup its own answer starts is counted before this one is
1189
+ * released.
1190
+ *
1191
+ * @param {Promise<void>} lookup - Never rejects: it reports its own failure
1192
+ */
1193
+ function holdTemplateErrorsFor(lookup: Promise<void>): void {
1194
+ pendingLookups += 1;
1195
+ void lookup.finally(() => {
1196
+ pendingLookups -= 1;
1197
+ if (pendingLookups === 0) {
1198
+ /* The console, even if a handler was installed meanwhile: these throws happened outside that
1199
+ handler's render, and Studio's render critic would refuse its page for them. */
1200
+ for (const { error, source } of heldTemplateErrors.values()) {
1201
+ warnTemplateError(source, error);
1202
+ }
1203
+ heldTemplateErrors.clear();
1204
+ for (const wake of lookupWaiters.splice(0)) {
1205
+ wake();
1206
+ }
1207
+ }
1208
+ });
1209
+ }
1210
+
1211
+ /**
1212
+ * Answers once no lookup the runtime started is in flight (embedding.md §5.2).
1213
+ *
1214
+ * A host whose question is whether a document renders awaits this between `buildScope` and
1215
+ * `renderNode`, so its templates read the data rather than the `null` before it. A lookup can hang,
1216
+ * and this waits for it, so bounding the wait is the host's job.
1217
+ *
1218
+ * @returns {Promise<void>}
1219
+ */
1220
+ export function lookupsSettled(): Promise<void> {
1221
+ if (pendingLookups === 0) {
1222
+ return Promise.resolve();
1223
+ }
1224
+ return new Promise((wake) => {
1225
+ lookupWaiters.push(wake);
1226
+ });
1227
+ }
1228
+
1166
1229
  /**
1167
1230
  * Run a template binding as an effect that survives its expression throwing.
1168
1231
  *
@@ -1172,7 +1235,8 @@ export function setTemplateErrorHandler(
1172
1235
  * and the throw unwound the whole `renderNode` above it: one early read blanked the page. Caught
1173
1236
  * INSIDE the effect, every dependency read before the throw stays tracked, so the binding re-runs
1174
1237
  * 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.
1238
+ * default), and the error is reported once per source, after any lookup in flight has settled: a
1239
+ * template that only needed that lookup's answer is not reported at all.
1176
1240
  *
1177
1241
  * @param {string} source - The template, for the report
1178
1242
  * @param {() => void} run - Evaluates the template and writes it
@@ -1182,12 +1246,15 @@ function bindTemplate(source: string, run: () => void, fallback?: () => void): v
1182
1246
  effect(() => {
1183
1247
  try {
1184
1248
  run();
1249
+ heldTemplateErrors.delete(run);
1185
1250
  } catch (error) {
1251
+ const thrown = error instanceof Error ? error : new Error(String(error));
1186
1252
  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);
1253
+ _templateErrorHandler(thrown, source);
1254
+ } else if (pendingLookups > 0) {
1255
+ heldTemplateErrors.set(run, { error: thrown, source });
1256
+ } else {
1257
+ warnTemplateError(source, thrown);
1191
1258
  }
1192
1259
  fallback?.();
1193
1260
  }
@@ -1857,11 +1924,74 @@ export function renderNode(
1857
1924
  el.append(renderNode(child, localState, childOpts));
1858
1925
  }
1859
1926
  }
1927
+ } else if (isTemplateString(kids)) {
1928
+ renderComputedChildren(el, kids, localState, {
1929
+ ...options,
1930
+ _ns: childNs,
1931
+ _path: [...path, "children"],
1932
+ });
1860
1933
  }
1861
1934
 
1862
1935
  return el;
1863
1936
  }
1864
1937
 
1938
+ /**
1939
+ * Draw a whole-`children` template, the way the site build's template pass expands one (spec.md
1940
+ * §8.4). This is what an item's or an entry's parsed body looks like to something that renders the
1941
+ * source document rather than the build's output: Studio's Preview, chiefly.
1942
+ *
1943
+ * A result that is not an array draws nothing, because a plain string is not a `children` value.
1944
+ * The children are rendered in a detached scope of their own, replaced when the template's value
1945
+ * changes (a collection that was still loading), and with tracking paused for the reason
1946
+ * {@link renderSwitch} gives: the bindings inside track for themselves.
1947
+ *
1948
+ * @param {Element} el - The element whose children these are
1949
+ * @param {string} template - The `children` value
1950
+ * @param {JxScope} state
1951
+ * @param {JxRenderOptions} options - `_path` is the `children` position of `el`
1952
+ */
1953
+ function renderComputedChildren(
1954
+ el: Element,
1955
+ template: string,
1956
+ state: JxScope,
1957
+ options: JxRenderOptions,
1958
+ ): void {
1959
+ const path = options._path ?? [];
1960
+ let live: EffectScope | null = null;
1961
+ const retire = (): void => {
1962
+ // Detached before it is stopped, for the reason `teardown` in `renderMappedArray` gives.
1963
+ el.replaceChildren();
1964
+ live?.stop();
1965
+ live = null;
1966
+ };
1967
+ bindTemplate(
1968
+ template,
1969
+ () => {
1970
+ const nodes = evaluateValueTemplate(template, state);
1971
+ retire();
1972
+ if (!Array.isArray(nodes)) {
1973
+ return;
1974
+ }
1975
+ const scope = effectScope(true);
1976
+ live = scope;
1977
+ pauseTracking();
1978
+ try {
1979
+ scope.run(() => {
1980
+ /* SAFETY: a children template that yields an array stands for the child list itself, the
1981
+ same reading the site build's template pass gives it. */
1982
+ for (const [i, child] of (nodes as (JxElement | string)[]).entries()) {
1983
+ el.append(renderNode(child, state, { ...options, _path: [...path, i] }));
1984
+ }
1985
+ });
1986
+ } finally {
1987
+ resetTracking();
1988
+ }
1989
+ },
1990
+ retire,
1991
+ );
1992
+ onScopeDispose(() => live?.stop(), true);
1993
+ }
1994
+
1865
1995
  // ─── Template string utilities ────────────────────────────────────────────────
1866
1996
 
1867
1997
  /**
@@ -3297,29 +3427,32 @@ export async function resolvePrototype(
3297
3427
  }
3298
3428
  });
3299
3429
 
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;
3430
+ const doFetch = () => {
3431
+ holdTemplateErrorsFor(
3432
+ fetch(url, {
3433
+ method: def.method ?? "GET",
3434
+ signal: controller.signal,
3435
+ ...(def.headers && { headers: def.headers }),
3436
+ ...(def.body && {
3437
+ body: isNonNullObject(def.body) ? JSON.stringify(def.body) : def.body,
3438
+ }),
3312
3439
  })
3313
- .catch((error) => {
3314
- if (!(error instanceof Error && error.name === "AbortError")) {
3315
- s.value = { error: String(error) };
3316
- }
3317
- });
3440
+ .then((r): Promise<JsonValue> => (r.ok ? r.json() : Promise.reject(r.statusText)))
3441
+ .then((d) => {
3442
+ s.value = d;
3443
+ })
3444
+ .catch((error) => {
3445
+ if (!(error instanceof Error && error.name === "AbortError")) {
3446
+ s.value = { error: String(error) };
3447
+ }
3448
+ }),
3449
+ );
3450
+ };
3318
3451
 
3319
3452
  if (debounceMs > 0) {
3320
3453
  debounceTimer = setTimeout(doFetch, debounceMs);
3321
3454
  } else {
3322
- void doFetch();
3455
+ doFetch();
3323
3456
  }
3324
3457
  });
3325
3458
  }
@@ -3850,24 +3983,25 @@ async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: stri
3850
3983
 
3851
3984
  // Always wrap in ref for reactivity
3852
3985
  const s: Ref<JxScopeValue> = ref(null);
3986
+ const resolveInto = (resolvedConfig: JxScope) => {
3987
+ holdTemplateErrorsFor(
3988
+ doResolve(resolvedConfig)
3989
+ .then((value) => {
3990
+ s.value = value;
3991
+ })
3992
+ .catch((error) => console.error("Jx dev proxy:", error)),
3993
+ );
3994
+ };
3853
3995
  if (hasTemplates) {
3854
3996
  effect(() => {
3855
3997
  const resolvedConfig: JxScope = {};
3856
3998
  for (const [k, v] of Object.entries(config)) {
3857
3999
  resolvedConfig[k] = isTemplateString(v) ? evaluateTemplate(v, state) : v;
3858
4000
  }
3859
- doResolve(resolvedConfig)
3860
- .then((value) => {
3861
- s.value = value;
3862
- })
3863
- .catch((error) => console.error("Jx dev proxy:", error));
4001
+ resolveInto(resolvedConfig);
3864
4002
  });
3865
4003
  } else {
3866
- doResolve(config)
3867
- .then((value) => {
3868
- s.value = value;
3869
- })
3870
- .catch((error) => console.error("Jx dev proxy:", error));
4004
+ resolveInto(config);
3871
4005
  }
3872
4006
  return s;
3873
4007
  }
@@ -3900,19 +4034,21 @@ function latestServerCall(
3900
4034
  const n = seq;
3901
4035
  /* The executor runs before the constructor returns, so the call still starts synchronously,
3902
4036
  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
- }
4037
+ holdTemplateErrorsFor(
4038
+ new Promise<JsonValue>((settle) => {
4039
+ settle(invoke(args));
3910
4040
  })
3911
- .catch((error) => {
3912
- if (n === seq) {
3913
- console.error(label, error);
3914
- }
3915
- });
4041
+ .then((result) => {
4042
+ if (n === seq) {
4043
+ s.value = result;
4044
+ }
4045
+ })
4046
+ .catch((error) => {
4047
+ if (n === seq) {
4048
+ console.error(label, error);
4049
+ }
4050
+ }),
4051
+ );
3916
4052
  };
3917
4053
  }
3918
4054