@jxsuite/runtime 4.0.0 → 4.0.1

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": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "description": "JSON-native reactive web component runtime",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/runtime.ts CHANGED
@@ -2467,6 +2467,11 @@ function renderMappedArrayInto(
2467
2467
  parentEl.append(anchor);
2468
2468
  const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef, key: keyRef } = arrayDef;
2469
2469
  const keyPointer = isRefObj(keyRef) ? keyRef.$ref : null;
2470
+ /* The instantiation chain the list belongs to (spec.md §16.9), for the same reason
2471
+ {@link renderSwitch} keeps one: a row that arrives with new data is inserted from an effect
2472
+ re-run, and the instance it holds must connect under the chain of the definition that drew
2473
+ the list. */
2474
+ const instancePath = _instancePath;
2470
2475
 
2471
2476
  let warned = false;
2472
2477
  const warnOnce = (message: string): void => {
@@ -2572,7 +2577,7 @@ function renderMappedArrayInto(
2572
2577
 
2573
2578
  pauseTracking();
2574
2579
  try {
2575
- reconcile(list, keys);
2580
+ underInstancePath(reexpand(instancePath), () => reconcile(list, keys));
2576
2581
  } finally {
2577
2582
  resetTracking();
2578
2583
  }
@@ -2687,6 +2692,11 @@ export function resolveTagName(tagName: unknown, scope: JxScope): string {
2687
2692
  function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions) {
2688
2693
  const path = options?._path ?? [];
2689
2694
  const container = document.createElement(resolveTagName(def.tagName, state));
2695
+ /* The instantiation chain this switch belongs to (spec.md §16.9), read now because the effect
2696
+ below re-runs with nothing on the stack. A case that flips after the first paint puts it back
2697
+ around the insertion, so an instance the new case creates connects under the chain of the
2698
+ definition that drew the switch rather than under none. See {@link _instancePath}. */
2699
+ const instancePath = _instancePath;
2690
2700
 
2691
2701
  if (options?.onNodeCreated) {
2692
2702
  options.onNodeCreated(container, path, def, state);
@@ -2753,7 +2763,9 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2753
2763
  return;
2754
2764
  }
2755
2765
  container.replaceChildren();
2756
- scope.run(() => container.append(renderNode(doc, childScope, childOpts)));
2766
+ underInstancePath(reexpand(instancePath), () =>
2767
+ scope.run(() => container.append(renderNode(doc, childScope, childOpts))),
2768
+ );
2757
2769
  })
2758
2770
  .catch((error: unknown) =>
2759
2771
  console.error("Jx $switch: failed to load external case", caseDef.$ref, error),
@@ -2766,7 +2778,9 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
2766
2778
  // Change to anything it read. The bindings inside it track for themselves.
2767
2779
  pauseTracking();
2768
2780
  try {
2769
- scope.run(() => container.append(renderNode(caseDef, state, childOpts)));
2781
+ underInstancePath(reexpand(instancePath), () =>
2782
+ scope.run(() => container.append(renderNode(caseDef, state, childOpts))),
2783
+ );
2770
2784
  } finally {
2771
2785
  resetTracking();
2772
2786
  }
@@ -3943,6 +3957,219 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
3943
3957
  }
3944
3958
  }
3945
3959
 
3960
+ // ─── Instantiation chain (spec.md §16.9) ─────────────────────────────────────
3961
+
3962
+ /**
3963
+ * How deep component nesting may go before an instance refuses to render.
3964
+ *
3965
+ * The same number the compiler holds in `MAX_COMPONENT_NESTING` (`@jxsuite/compiler`, `shared.ts`),
3966
+ * and for the same reason: the cycle check in {@link extendInstancePath} catches a definition that
3967
+ * renders ITSELF — same tag, same props — which is the only shape that can never terminate, while a
3968
+ * self-reference whose props change at every level is data-driven recursion that a `$switch` may or
3969
+ * may not bottom out. The seen-set cannot tell those apart, so this cap is what turns a forgotten
3970
+ * stop into a diagnostic instead of a page that never settles. It is written twice because the
3971
+ * compiler depends on the runtime and not the other way round, and the runtime's browser bundle
3972
+ * cannot reach into the build tool; spec.md §16.9 is the one statement both are held to, and the
3973
+ * compiler's `nesting-cap-agreement.test.ts` asserts its copy against this export, so the two
3974
+ * numbers cannot part without a red test in the workspace that owns the copy.
3975
+ */
3976
+ export const MAX_COMPONENT_NESTING = 32;
3977
+
3978
+ /** One instance on the chain: its tag, and the props it was given, as an identity. */
3979
+ interface InstanceFrame {
3980
+ tag: string;
3981
+ /** Every value the instance supplied that its definition's state read (§16.9). */
3982
+ props: Record<string, unknown>;
3983
+ }
3984
+
3985
+ /**
3986
+ * The chain an instance is created under, and where on it the current expansion began.
3987
+ *
3988
+ * `frames` is every instance from the page-level one down, outermost first. `expansion` is the
3989
+ * index of the first frame that belongs to the SAME synchronous pass as the instance being created:
3990
+ * the cycle check (§16.9 rule 1) looks back no further than that, while the cap (rule 2) counts
3991
+ * every frame. The distinction is what an effect re-run does to certainty. Along one pass a
3992
+ * repeated frame is a render that repeats itself, deterministically, forever. Across a re-run it is
3993
+ * not: the frames above the boundary rendered to completion once, and whether the page asks for one
3994
+ * of them again is its data — an `onMount` that flips a `$switch` into the same tag every time, or
3995
+ * a click that opens one more level of a node that starts closed — which the runtime cannot tell
3996
+ * apart. So the cycle check is not asked across the boundary, and the loop that lives in re-runs is
3997
+ * ended by the cap instead: thirty-three empty hosts and a diagnostic, not a page that never
3998
+ * settles.
3999
+ */
4000
+ interface InstancePath {
4001
+ frames: readonly InstanceFrame[];
4002
+ expansion: number;
4003
+ }
4004
+
4005
+ /** The chain a page-level render runs under: nothing above it. */
4006
+ const ROOT_INSTANCE_PATH: InstancePath = { expansion: 0, frames: [] };
4007
+
4008
+ /**
4009
+ * The chain of instances whose definitions are being rendered RIGHT NOW.
4010
+ *
4011
+ * A module-level stack rather than a WeakMap from element to chain, and it is worth saying why that
4012
+ * survives `connectedCallback` being async. The callback awaits `buildScope` before it touches the
4013
+ * DOM, and by the time it resumes, whatever the enclosing instance pushed here has long been popped
4014
+ * — so the stack alone would be empty for every instance and catch nothing. What saves it is WHEN
4015
+ * an instance connects: `this.append(child)` in the enclosing render runs the child's
4016
+ * `connectedCallback` synchronously, and every way a definition brings an instance into being —
4017
+ * `renderCustomElementWithProps`, the bare-tag branch of `renderNode`, a nested element inside
4018
+ * either, and a prerendered host drawing its children again as it upgrades — appends it inside that
4019
+ * same synchronous stretch. So the callback reads the stack ONCE, before its first `await`, and
4020
+ * carries the copy across the gap. A WeakMap would need a write at each of those creation sites to
4021
+ * say the same thing.
4022
+ *
4023
+ * A subtree an effect renders LATER — a `$switch` case that flips after the first paint, a `$map`
4024
+ * row that arrives with new data — connects with nothing on the stack, because no enclosing render
4025
+ * is running when the effect re-runs; a write from `onMount` or a click is what triggered it. So
4026
+ * the two renderers that create nodes from an effect each capture the stack when they are first
4027
+ * rendered (which is inside the enclosing pass) and put it back, re-expanded, around every later
4028
+ * insertion ({@link underInstancePath}). That is the one thing this stack needs beyond the
4029
+ * synchronous stretch, and it is what makes the `onMount`-flip shape a diagnostic rather than a
4030
+ * hang. Reading `@vue/reactivity`'s `getCurrentScope()` there instead would find nothing: an effect
4031
+ * re-run restores no scope, and a case or a row renders in a DETACHED scope with no parent.
4032
+ */
4033
+ let _instancePath: InstancePath = ROOT_INSTANCE_PATH;
4034
+
4035
+ /**
4036
+ * Run `fn` with `path` as the chain, so every instance that CONNECTS during it reads that chain.
4037
+ *
4038
+ * Restored rather than cleared afterwards: the enclosing render's chain is what was there, and a
4039
+ * restore needs no argument about whether anything was.
4040
+ *
4041
+ * @param {InstancePath} path - The chain the instances `fn` connects are created under
4042
+ * @param {() => T} fn - The render, or the insertion that connects what a render made
4043
+ * @returns {T} What `fn` returned
4044
+ */
4045
+ function underInstancePath<T>(path: InstancePath, fn: () => T): T {
4046
+ const was = _instancePath;
4047
+ _instancePath = path;
4048
+ try {
4049
+ return fn();
4050
+ } finally {
4051
+ _instancePath = was;
4052
+ }
4053
+ }
4054
+
4055
+ /**
4056
+ * The chain a later insertion runs under: the same frames, with the expansion beginning now.
4057
+ *
4058
+ * Called on EVERY run of a `$switch` case or a `$map` reconcile, the first one included — harmless
4059
+ * there, because a first run builds into a detached container and nothing connects until the
4060
+ * enclosing pass appends it, under the enclosing pass's own chain.
4061
+ *
4062
+ * @param {InstancePath} path - The chain captured when the renderer was first rendered
4063
+ * @returns {InstancePath}
4064
+ */
4065
+ function reexpand(path: InstancePath): InstancePath {
4066
+ return { expansion: path.frames.length, frames: path.frames };
4067
+ }
4068
+
4069
+ /**
4070
+ * Whether two prop values are the same input to a definition.
4071
+ *
4072
+ * Reference equality first — a `$ref` into the parent's state forwards the same primitive, the same
4073
+ * reactive proxy or the same function to the next level — and structural equality for plain data
4074
+ * rebuilt at each level (`data-jx-props` parsed twice is two objects with one meaning). Plain data
4075
+ * is what JSON can describe: an array, or an object with no prototype but `Object`'s, reactive or
4076
+ * raw. Anything else — a `Map`, a `Set`, a class instance, a circular object — is different from
4077
+ * everything but itself, because its JSON form would not say what it holds and two different values
4078
+ * that serialise alike would close a cycle that was never there. The cost of that policy is a loop
4079
+ * the depth cap catches instead; the alternative is a false diagnostic on a page that would have
4080
+ * ended. Inside plain data the comparison IS the JSON form, so a member JSON cannot see (a
4081
+ * function, a `Map` two levels down) is not seen here either; §16.9 says so.
4082
+ *
4083
+ * @param {unknown} a
4084
+ * @param {unknown} b
4085
+ * @returns {boolean}
4086
+ */
4087
+ function samePropValue(a: unknown, b: unknown): boolean {
4088
+ if (Object.is(a, b)) {
4089
+ return true;
4090
+ }
4091
+ if (!isPlainData(a) || !isPlainData(b)) {
4092
+ return false;
4093
+ }
4094
+ try {
4095
+ return JSON.stringify(a) === JSON.stringify(b);
4096
+ } catch {
4097
+ return false;
4098
+ }
4099
+ }
4100
+
4101
+ /**
4102
+ * Whether a value is an array or a plain object — the shapes whose JSON form is the whole value.
4103
+ *
4104
+ * @param {unknown} value
4105
+ * @returns {value is object}
4106
+ */
4107
+ function isPlainData(value: unknown): value is object {
4108
+ if (typeof value !== "object" || value === null) {
4109
+ return false;
4110
+ }
4111
+ const raw: unknown = toRaw(value);
4112
+ if (Array.isArray(raw)) {
4113
+ return true;
4114
+ }
4115
+ const proto: unknown = Object.getPrototypeOf(raw);
4116
+ return proto === Object.prototype || proto === null;
4117
+ }
4118
+
4119
+ /**
4120
+ * Whether two frames are the same instance: one tag, one set of prop names, equal values.
4121
+ *
4122
+ * @param {InstanceFrame} a
4123
+ * @param {InstanceFrame} b
4124
+ * @returns {boolean}
4125
+ */
4126
+ function sameFrame(a: InstanceFrame, b: InstanceFrame): boolean {
4127
+ if (a.tag !== b.tag) {
4128
+ return false;
4129
+ }
4130
+ const keys = Object.keys(a.props);
4131
+ if (keys.length !== Object.keys(b.props).length) {
4132
+ return false;
4133
+ }
4134
+ return keys.every(
4135
+ (key) => Object.hasOwn(b.props, key) && samePropValue(a.props[key], b.props[key]),
4136
+ );
4137
+ }
4138
+
4139
+ /**
4140
+ * The chain an instance's own render runs under: the enclosing chain plus itself — or the
4141
+ * diagnostic that says why it must not render at all.
4142
+ *
4143
+ * The two messages are the compiler's, word for word (`renderComponentInstance` in
4144
+ * `@jxsuite/compiler`), so a page that fails to build and a canvas that fails to paint say the same
4145
+ * thing about the same definition. Meeting a frame already on the chain, within the current
4146
+ * expansion, is the instance rendering itself with the same props, which never ends; a chain at the
4147
+ * cap is a self-reference whose props change — or whose repeats live in re-runs, where the cycle
4148
+ * check is not asked ({@link InstancePath}) — and whose stop case never came.
4149
+ *
4150
+ * @param {InstancePath} path - The chain the instance was created under
4151
+ * @param {InstanceFrame} frame - The instance about to render
4152
+ * @returns {InstancePath} The chain its children render under
4153
+ * @throws {Error} The named diagnostic — never a stack overflow, never a hang
4154
+ * @docs framework/concepts/components
4155
+ */
4156
+ function extendInstancePath(path: InstancePath, frame: InstanceFrame): InstancePath {
4157
+ const chain = [...path.frames.map((f) => f.tag), frame.tag].join(" → ");
4158
+ if (path.frames.slice(path.expansion).some((f) => sameFrame(f, frame))) {
4159
+ throw new Error(
4160
+ `Component <${frame.tag}> renders itself: ${chain}. A component cannot appear inside its own ` +
4161
+ `definition with the same props — the expansion would never end.`,
4162
+ );
4163
+ }
4164
+ if (path.frames.length >= MAX_COMPONENT_NESTING) {
4165
+ throw new Error(
4166
+ `Component nesting exceeds ${MAX_COMPONENT_NESTING} levels: ${chain}. A component that ` +
4167
+ `renders itself with changing props needs a case that stops.`,
4168
+ );
4169
+ }
4170
+ return { expansion: path.expansion, frames: [...path.frames, frame] };
4171
+ }
4172
+
3946
4173
  /**
3947
4174
  * Register a custom element from a Jx document.
3948
4175
  *
@@ -4009,6 +4236,17 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4009
4236
  }
4010
4237
  this._jxInitialized = true;
4011
4238
 
4239
+ /* The chain this instance was created under, read BEFORE the first `await`: the enclosing
4240
+ render is still on the stack now and will not be when this callback resumes. See
4241
+ {@link _instancePath} for why that ordering is the whole mechanism. */
4242
+ const enclosingPath = _instancePath;
4243
+ /* What this instance was given, recorded beside each write into state below. It is the
4244
+ instance's identity on the chain (§16.9): two nested instances of one tag that were given
4245
+ the same values render the same thing, forever. Only a value the definition's state READS
4246
+ is recorded — a `$props` key no state entry declares changes nothing about the render, so
4247
+ a definition that "varies" one at each level is still rendering itself. */
4248
+ const supplied: Record<string, unknown> = {};
4249
+
4012
4250
  // The definition as it stands NOW — a redefinition since this class was made is honoured.
4013
4251
  const live = _elementDefs.get(tagName) ?? { base: initialBase, doc: initialDef };
4014
4252
  const def = live.doc;
@@ -4026,6 +4264,9 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4026
4264
  for (const attr of observedAttrs) {
4027
4265
  if (this.hasAttribute(attr)) {
4028
4266
  absorbAttribute(state, attr, this.getAttribute(attr));
4267
+ // The raw string, not the coerced value: coercion is a function of it, so two equal
4268
+ // Strings are one input.
4269
+ supplied[attr] = this.getAttribute(attr);
4029
4270
  }
4030
4271
  }
4031
4272
 
@@ -4039,6 +4280,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4039
4280
  }
4040
4281
  if (key in (def.state ?? {})) {
4041
4282
  state[key] = val;
4283
+ supplied[key] = val;
4042
4284
  }
4043
4285
  }
4044
4286
  } catch {}
@@ -4060,6 +4302,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4060
4302
  }
4061
4303
  if (key in (def.state ?? {})) {
4062
4304
  state[key] = this.getAttribute(name);
4305
+ supplied[key] = this.getAttribute(name);
4063
4306
  this.removeAttribute(name);
4064
4307
  }
4065
4308
  }
@@ -4075,8 +4318,49 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4075
4318
  instanceSupplies(this, key)
4076
4319
  ) {
4077
4320
  state[key] = (this as Record<string, unknown>)[key];
4321
+ supplied[key] = (this as Record<string, unknown>)[key];
4078
4322
  }
4079
4323
  }
4324
+
4325
+ /* Every input is known now, so this is where an instance that would never finish is
4326
+ refused (§16.9): before it has a state, a style or a child, so a refused host is an empty
4327
+ element the page renders around — emptied of what the definition wrote into it for its
4328
+ slots too, since nothing will distribute that. The diagnostic is REPORTED rather than
4329
+ thrown onward: a throw from an async `connectedCallback` is an unhandled rejection that
4330
+ names no element and stops nothing. It goes two ways. The console is the runtime's
4331
+ surface for a render that fails after the page has settled (`$switch` external cases and
4332
+ the dev proxies report there too). The `jx-error` event is the surface a HOST can hold:
4333
+ it bubbles from the refused element with the error on it, so a page, or the studio canvas
4334
+ wrapping one, can collect it where a console line inside an iframe reaches nobody. The
4335
+ rest of the page keeps rendering, which is the point.
4336
+
4337
+ Neither surface is used for a host that is no longer in the document. A refused instance
4338
+ was connected when this callback began, and one that is not by the time the verdict is in
4339
+ belongs to a subtree the page discarded during the `await` above: a prerendered host of the
4340
+ same tag that was parsed INSIDE another and re-rendered from the definition by the outer
4341
+ one's upgrade, which captures it as slot content and empties itself. Its replacement is
4342
+ the instance that reports, on the chain the outer upgrade draws; reporting from the
4343
+ discarded copy too said one defect twice. The refusal itself still stands, so a host
4344
+ re-attached later is an empty element and not a hang. */
4345
+ let ownPath: InstancePath;
4346
+ try {
4347
+ ownPath = extendInstancePath(enclosingPath, { props: supplied, tag: tagName });
4348
+ } catch (error) {
4349
+ this.replaceChildren();
4350
+ if (!this.isConnected) {
4351
+ return;
4352
+ }
4353
+ console.error(`Jx: <${tagName}> was not rendered:`, error);
4354
+ this.dispatchEvent(
4355
+ new ErrorEvent("jx-error", {
4356
+ bubbles: true,
4357
+ composed: true,
4358
+ error,
4359
+ message: error instanceof Error ? error.message : String(error),
4360
+ }),
4361
+ );
4362
+ return;
4363
+ }
4080
4364
  /*
4081
4365
  * Set up property getters/setters that forward into reactive state. Private entries get NO
4082
4366
  * accessor: the property-first interface IS the props mechanism, so defining one would leave
@@ -4134,10 +4418,14 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
4134
4418
  themselves. Restored rather than cleared, because one definition may render another. */
4135
4419
  const wasInside = _canvasInsideStampedHost;
4136
4420
  _canvasInsideStampedHost ||= this.dataset.jxPath !== undefined;
4421
+ /* And the chain, for the same span: an instance this definition draws connects inside
4422
+ `this.append` below and reads it there ({@link underInstancePath}). */
4137
4423
  try {
4138
- for (const childDef of children) {
4139
- this.append(renderNode(childDef, state));
4140
- }
4424
+ underInstancePath(ownPath, () => {
4425
+ for (const childDef of children) {
4426
+ this.append(renderNode(childDef, state));
4427
+ }
4428
+ });
4141
4429
  } finally {
4142
4430
  _canvasInsideStampedHost = wasInside;
4143
4431
  }