@jxsuite/runtime 1.3.1 → 2.0.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
@@ -23,7 +23,8 @@ import {
23
23
  ref,
24
24
  toRaw,
25
25
  } from "@vue/reactivity";
26
- import { evaluateExpression, isMutating } from "./expression.ts";
26
+ import { evaluateExpression, evaluateOperand, isMutating } from "./expression.ts";
27
+ import { readPath } from "./pointer.ts";
27
28
  import type { DynamicClass, JxEventHandler, JxPath, JxRenderOptions, JxScope } from "./types.ts";
28
29
  import {
29
30
  bodyReturnsValue,
@@ -33,13 +34,16 @@ import {
33
34
  isJsonObject,
34
35
  isMappedArray,
35
36
  isNamedFormulaDef,
37
+ isPrivateStateKey,
36
38
  isPrototypeDef,
37
39
  isRef as isRefValue,
38
40
  isServerFnDef,
41
+ isTagExpression,
39
42
  isTemplateString,
40
43
  paramNames,
41
44
  } from "@jxsuite/schema/guards";
42
45
  import { runStatements } from "./statements.ts";
46
+ import { readCookie, serializeCookie } from "./cookie.ts";
43
47
  import type {
44
48
  JxAttributeValue,
45
49
  JxClassDef,
@@ -170,6 +174,25 @@ export function setSkipServerFunctions(v: boolean) {
170
174
  _serverFnConfig.skip = v;
171
175
  }
172
176
 
177
+ /** @type {{ skip: boolean }} */
178
+ const _autoRequestConfig = { skip: false };
179
+ /**
180
+ * Set to true to suppress AUTOMATIC `$prototype: "Request"` fetches — the sibling of
181
+ * {@link setSkipServerFunctions}, and used by Studio for the same reason.
182
+ *
183
+ * `buildScope` re-resolves every `state` entry on each full canvas render, so an auto-request
184
+ * re-issued its HTTP call every time the canvas rebuilt. In edit/design mode that meant authoring
185
+ * actions — a signal edit, or pressing Enter inside component-wrapped content, both of which force
186
+ * a full render — fired network requests. Live data belongs to preview and to the deployed site.
187
+ *
188
+ * Only non-`manual` requests are gated: a `manual` one fires from an event handler, and edit mode
189
+ * strips handlers before rendering. The returned ref is unchanged either way — it simply stays at
190
+ * its initial `null`, which is the same state bindings observe before any fetch resolves.
191
+ */
192
+ export function setSkipAutoRequests(v: boolean) {
193
+ _autoRequestConfig.skip = v;
194
+ }
195
+
173
196
  // ─── Dev-proxy auth token (Studio cross-origin canvas) ────────────────────────
174
197
  // The Studio canvas iframe is served from a token-gated loopback origin (createProjectServer): its
175
198
  // POST /__jx_resolve__ + /__jx_server__ routes 403 without ?token=<rpcToken>. The iframe boot reads
@@ -764,8 +787,18 @@ export function renderNode(
764
787
  }
765
788
  }
766
789
 
767
- // Custom element with $props: set JS properties on the element instance
768
- const tagName = def.tagName ?? "div";
790
+ /* RESOLVED ONCE, HERE, before anything branches on it.
791
+ A tag may be a name or a choice between names (`ElementTagName`). Resolving at the top means
792
+ every test below — the hyphen check that routes to the custom-element path, `customElements.get`,
793
+ `createElement` itself — sees a literal, exactly as it did when a tag could only be written out.
794
+
795
+ It is not tracked. `tagName` is in RESERVED_KEYS so no binding sweep reaches it, and a tag that
796
+ changed after mount would mean replacing the node: the subtree's listeners, its focus, its typed
797
+ input values and its component instances all go with it, and this runtime has no dispose walk to
798
+ pay that bill (renderSwitch, two hundred lines up, still leaks its previous case's effects).
799
+ `jx validate` warns when a tag discriminant is also an assignment target, so the case where this
800
+ rule bites is caught before it ships rather than found as a `<div>` that never became an `<a>`. */
801
+ const tagName = resolveTagName(def.tagName, localState);
769
802
  const isCustomEl = tagName.includes("-") && customElements.get(tagName);
770
803
 
771
804
  if (def.$props && isCustomEl) {
@@ -1230,6 +1263,34 @@ function renderMappedArrayInto(
1230
1263
  });
1231
1264
  }
1232
1265
 
1266
+ /**
1267
+ * An element's tag: a name, or the branch of a {@link JxTagExpression} the state selects.
1268
+ *
1269
+ * Every branch of the expression is itself a `TagName`, so this can only ever return something the
1270
+ * schema already held to the tag-name pattern — the property that lets the compiler enumerate the
1271
+ * candidates and lets `createElement` be called without a second thought here.
1272
+ *
1273
+ * @param {unknown} tagName The element's declared tag.
1274
+ * @param {JxScope} scope The state the discriminant reads.
1275
+ * @returns {string} A literal tag name; `"div"` when nothing is declared.
1276
+ */
1277
+ export function resolveTagName(tagName: unknown, scope: JxScope): string {
1278
+ if (typeof tagName === "string") {
1279
+ return tagName;
1280
+ }
1281
+ if (!isTagExpression(tagName)) {
1282
+ return "div";
1283
+ }
1284
+ const expression = tagName.$expression;
1285
+ if (expression.operator === "?:") {
1286
+ // `evaluateOperand`, not `evaluateExpression`: the discriminant is an OPERAND — a pointer, a
1287
+ // Literal or a nested node — exactly as it is for a `$switch`, whose docstring names this.
1288
+ return evaluateOperand(expression.target, scope, null) ? expression.value : expression.initial;
1289
+ }
1290
+ const key = evaluateOperand(expression.target, scope, null);
1291
+ return expression.cases[String(key)] ?? expression.default;
1292
+ }
1293
+
1233
1294
  // ─── $switch ──────────────────────────────────────────────────────────────────
1234
1295
 
1235
1296
  /**
@@ -1240,7 +1301,7 @@ function renderMappedArrayInto(
1240
1301
  */
1241
1302
  function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions) {
1242
1303
  const path = options?._path ?? [];
1243
- const container = document.createElement(def.tagName ?? "div");
1304
+ const container = document.createElement(resolveTagName(def.tagName, state));
1244
1305
 
1245
1306
  if (options?.onNodeCreated) {
1246
1307
  options.onNodeCreated(container, path, def, state);
@@ -1252,7 +1313,11 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
1252
1313
  let generation = 0;
1253
1314
 
1254
1315
  effect(() => {
1255
- container.innerHTML = "";
1316
+ /* `replaceChildren()` rather than `innerHTML = ""`: identical semantics, and it is not a
1317
+ Trusted Types injection sink — under `require-trusted-types-for 'script'` an innerHTML write
1318
+ needs a policy even when the string is empty. Four sinks that were never injecting anything
1319
+ is four fewer things a policy has to be permissive about. */
1320
+ container.replaceChildren();
1256
1321
  if (!isRefObj(def.$switch)) {
1257
1322
  return;
1258
1323
  }
@@ -1264,7 +1329,8 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
1264
1329
 
1265
1330
  if (isRefObj(caseDef)) {
1266
1331
  // External $ref — fetch and render asynchronously
1267
- const gen = (generation += 1);
1332
+ generation += 1;
1333
+ const gen = generation;
1268
1334
  const { href } = new URL(caseDef.$ref, location.href);
1269
1335
  resolve(href)
1270
1336
  .then(async (doc) => {
@@ -1275,7 +1341,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
1275
1341
  if (gen !== generation) {
1276
1342
  return;
1277
1343
  }
1278
- container.innerHTML = "";
1344
+ container.replaceChildren();
1279
1345
  const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
1280
1346
  container.append(renderNode(doc, childScope, childOpts));
1281
1347
  })
@@ -1323,7 +1389,7 @@ export async function resolvePrototype(
1323
1389
  const debounceMs = def.debounce ?? 0;
1324
1390
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
1325
1391
 
1326
- if (!def.manual) {
1392
+ if (!def.manual && !_autoRequestConfig.skip) {
1327
1393
  effect(() => {
1328
1394
  let url: string | undefined;
1329
1395
  if (isTemplateString(def.url)) {
@@ -1422,38 +1488,21 @@ export async function resolvePrototype(
1422
1488
  case "Cookie": {
1423
1489
  const name = def.name ?? key;
1424
1490
  const read = () => {
1425
- const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
1426
- if (!m) {
1491
+ const raw = readCookie(document.cookie, name);
1492
+ if (raw === null) {
1427
1493
  return null;
1428
1494
  }
1429
1495
  try {
1430
- return JSON.parse(decodeURIComponent(m[1]!));
1496
+ return JSON.parse(decodeURIComponent(raw));
1431
1497
  } catch {
1432
- return m[1];
1498
+ return raw;
1433
1499
  }
1434
1500
  };
1435
1501
  const cookieState: Ref<unknown> = ref(read() ?? def.default ?? null);
1436
1502
  // Persist on change
1437
1503
  effect(() => {
1438
- const v = cookieState.value;
1439
- let s = `${name}=${encodeURIComponent(JSON.stringify(v))}`;
1440
- if (def.maxAge !== undefined) {
1441
- s += `; Max-Age=${def.maxAge}`;
1442
- }
1443
- if (def.path) {
1444
- s += `; Path=${def.path}`;
1445
- }
1446
- if (def.domain) {
1447
- s += `; Domain=${def.domain}`;
1448
- }
1449
- if (def.secure) {
1450
- s += `; Secure`;
1451
- }
1452
- if (def.sameSite) {
1453
- s += `; SameSite=${def.sameSite}`;
1454
- }
1455
1504
  // oxlint-disable-next-line unicorn/no-document-cookie -- the Cookie $prototype IS the cookie store binding
1456
- document.cookie = s;
1505
+ document.cookie = serializeCookie(name, cookieState.value, def);
1457
1506
  });
1458
1507
  return cookieState;
1459
1508
  }
@@ -2050,15 +2099,16 @@ export function resolveRef(refPath: string, state: JxScope) {
2050
2099
  return parts.length > 2 ? getPath(base, parts.slice(2).join("/")) : base;
2051
2100
  }
2052
2101
  if (refPath.startsWith("#/state/")) {
2053
- const sub = refPath.slice("#/state/".length);
2054
- const slash = sub.indexOf("/");
2055
- if (slash === -1) {
2056
- return state[sub];
2057
- }
2058
- return getPath(state[sub.slice(0, slash)], sub.slice(slash + 1));
2102
+ // One call, not a hand-split leading token: slicing at the first `/` skipped unescaping it, so
2103
+ // `#/state/a~1b/c` looked for a member called `a~1b` rather than `a/b`.
2104
+ return readPath(state, refPath.slice("#/state/".length));
2059
2105
  }
2060
2106
  if (refPath.startsWith("parent#/")) {
2061
- return state[refPath.slice("parent#/".length)];
2107
+ /*
2108
+ * A prop name may be a path into the prop. This read the whole path as one key and returned
2109
+ * undefined for `parent#/user/name`, while the compiler lowered it to a walk.
2110
+ */
2111
+ return readPath(state, refPath.slice("parent#/".length));
2062
2112
  }
2063
2113
  if (refPath.startsWith("window#/")) {
2064
2114
  return getPath(globalThis.window, refPath.slice("window#/".length));
@@ -2066,7 +2116,7 @@ export function resolveRef(refPath: string, state: JxScope) {
2066
2116
  if (refPath.startsWith("document#/")) {
2067
2117
  return getPath(globalThis.document, refPath.slice("document#/".length));
2068
2118
  }
2069
- return state[refPath] ?? null;
2119
+ return readPath(state, refPath) ?? null;
2070
2120
  }
2071
2121
 
2072
2122
  // ─── Utilities ────────────────────────────────────────────────────────────────
@@ -2098,16 +2148,51 @@ function isNestedSelector(k: string) {
2098
2148
  }
2099
2149
 
2100
2150
  /**
2151
+ * Walk a path off a value. Delegates to the one tokenizer (`pointer.ts`) — this used to split on
2152
+ * `/[./]/`, which no other path in the codebase agreed with.
2153
+ *
2101
2154
  * @param {unknown} obj
2102
2155
  * @param {string} path
2103
2156
  * @returns {unknown}
2104
2157
  */
2105
2158
  function getPath(obj: unknown, path: string) {
2106
- let current: unknown = obj;
2107
- for (const k of path.split(/[./]/)) {
2108
- current = (current as Record<string, unknown>)?.[k];
2159
+ return readPath(obj, path);
2160
+ }
2161
+
2162
+ /** Keys already reported, so a component rendered in a loop warns once rather than per instance. */
2163
+ const _privatePropWarned = new Set<string>();
2164
+
2165
+ /**
2166
+ * Refuse a `$props` write against a private (`#`) state key — `spec.md` §5.6.
2167
+ *
2168
+ * Ignored rather than thrown. A `$props` block naming a private entry is an authoring mistake, not
2169
+ * a broken document: the rest of the instance is well-formed and refusing to render it would turn a
2170
+ * typo into a blank page. But silence was the previous behavior and it is the wrong kind — the
2171
+ * author sees a prop that looks accepted and does nothing — so the write is dropped and named.
2172
+ *
2173
+ * @param {string} key
2174
+ * @param {string} [where] - The site refusing it, for a message that points somewhere
2175
+ * @returns {boolean} True when the key is private and the caller must skip it
2176
+ */
2177
+ function refusePrivateProp(key: string, where?: string): boolean {
2178
+ if (!isPrivateStateKey(key)) {
2179
+ return false;
2109
2180
  }
2110
- return current;
2181
+ const seen = where === undefined ? key : `${where}:${key}`;
2182
+ if (!_privatePropWarned.has(seen)) {
2183
+ _privatePropWarned.add(seen);
2184
+ console.warn(
2185
+ `Jx: "${key}" is private state (spec.md §5.6) and cannot be set through $props` +
2186
+ `${where === undefined ? "" : ` (${where})`}. The write was ignored; rename the entry ` +
2187
+ "without the leading # to make it part of the component's interface.",
2188
+ );
2189
+ }
2190
+ return true;
2191
+ }
2192
+
2193
+ /** Tests only — the warn-once set outlives a single render by design. */
2194
+ export function _resetPrivatePropWarnings(): void {
2195
+ _privatePropWarned.clear();
2111
2196
  }
2112
2197
 
2113
2198
  /**
@@ -2118,6 +2203,9 @@ function getPath(obj: unknown, path: string) {
2118
2203
  function mergeProps(def: JxElement, parentState: JxScope): JxScope {
2119
2204
  const child = Object.create(parentState) as JxScope;
2120
2205
  for (const [k, v] of Object.entries(def.$props ?? {})) {
2206
+ if (refusePrivateProp(k, "$props")) {
2207
+ continue;
2208
+ }
2121
2209
  child[k] = isRefObj(v) ? resolveRef(v.$ref, parentState) : v;
2122
2210
  }
2123
2211
  return child;
@@ -2306,7 +2394,12 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
2306
2394
  el.setAttribute(k, String(v));
2307
2395
  }
2308
2396
  if (entry.textContent) {
2309
- el.textContent = entry.textContent;
2397
+ // An object is a structured data block (JSON-LD, site-architecture.md §8.5). The compiler
2398
+ // Serializes it the same way, so the interpreted page and the built page agree.
2399
+ el.textContent =
2400
+ typeof entry.textContent === "object"
2401
+ ? JSON.stringify(entry.textContent, null, 2)
2402
+ : entry.textContent;
2310
2403
  }
2311
2404
  document.head.append(el);
2312
2405
  }
@@ -2383,6 +2476,9 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2383
2476
  try {
2384
2477
  const props = JSON.parse(propsAttr) as Record<string, unknown>;
2385
2478
  for (const [key, val] of Object.entries(props)) {
2479
+ if (refusePrivateProp(key, "data-jx-props")) {
2480
+ continue;
2481
+ }
2386
2482
  if (key in (def.state ?? {})) {
2387
2483
  state[key] = val;
2388
2484
  }
@@ -2400,6 +2496,10 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2400
2496
  );
2401
2497
  for (const name of propAttrNames) {
2402
2498
  const key = name.slice("props.".length);
2499
+ if (refusePrivateProp(key, "props.* attribute")) {
2500
+ this.removeAttribute(name);
2501
+ continue;
2502
+ }
2403
2503
  if (key in (def.state ?? {})) {
2404
2504
  state[key] = this.getAttribute(name);
2405
2505
  this.removeAttribute(name);
@@ -2408,12 +2508,22 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2408
2508
 
2409
2509
  // Merge $props set as JS properties by parent before connection
2410
2510
  for (const key of Object.keys(def.state ?? {})) {
2511
+ if (isPrivateStateKey(key)) {
2512
+ continue;
2513
+ }
2411
2514
  if (key in this && (this as Record<string, unknown>)[key] !== undefined) {
2412
2515
  state[key] = (this as Record<string, unknown>)[key];
2413
2516
  }
2414
2517
  }
2415
- // Set up property getters/setters that forward into reactive state
2518
+ /*
2519
+ * Set up property getters/setters that forward into reactive state. Private entries get NO
2520
+ * accessor: the property-first interface IS the props mechanism, so defining one would leave
2521
+ * `el["#cache"] = x` writing straight through everything above.
2522
+ */
2416
2523
  for (const key of Object.keys(def.state ?? {})) {
2524
+ if (isPrivateStateKey(key)) {
2525
+ continue;
2526
+ }
2417
2527
  if (!(key in HTMLElement.prototype)) {
2418
2528
  Object.defineProperty(this, key, {
2419
2529
  configurable: true,
@@ -2429,7 +2539,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2429
2539
 
2430
2540
  // Capture light DOM children (for slot distribution) before rendering
2431
2541
  const slottedChildren = [...this.childNodes];
2432
- this.innerHTML = "";
2542
+ this.replaceChildren();
2433
2543
 
2434
2544
  // Custom elements default to display:inline — use block so they behave as
2435
2545
  // Containers (matching <div> semantics). The component's own style can
@@ -2503,7 +2613,7 @@ function renderCustomElementWithProps(
2503
2613
  options?: JxRenderOptions,
2504
2614
  path?: JxPath,
2505
2615
  ) {
2506
- const el = document.createElement(def.tagName ?? "div");
2616
+ const el = document.createElement(resolveTagName(def.tagName, state));
2507
2617
 
2508
2618
  if (options?.onNodeCreated) {
2509
2619
  options.onNodeCreated(el, path ?? [], def, state);
@@ -2511,6 +2621,9 @@ function renderCustomElementWithProps(
2511
2621
 
2512
2622
  // Set JS properties from $props (before connection)
2513
2623
  for (const [key, val] of Object.entries(def.$props ?? {})) {
2624
+ if (refusePrivateProp(key, "$props")) {
2625
+ continue;
2626
+ }
2514
2627
  if (isRefObj(val)) {
2515
2628
  const refVal = val;
2516
2629
  const resolved = resolveRef(refVal.$ref, state);
@@ -2577,7 +2690,7 @@ function distributeSlots(host: HTMLElement, slottedChildren: ChildNode[]) {
2577
2690
  const name = slot.getAttribute("name");
2578
2691
  const matches = name ? (named.get(name) ?? []) : unnamed;
2579
2692
  if (matches.length > 0) {
2580
- slot.innerHTML = "";
2693
+ slot.replaceChildren();
2581
2694
  for (const child of matches) {
2582
2695
  slot.append(child);
2583
2696
  }