@jxsuite/compiler 1.4.0 → 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.
@@ -7,34 +7,45 @@
7
7
 
8
8
  import { RESERVED_KEYS, camelToKebab } from "@jxsuite/runtime";
9
9
  import {
10
+ PREFORMATTED_TAGS,
10
11
  collectStyles,
11
12
  compileExpression,
13
+ compileOperandSource,
12
14
  compileStatements,
13
15
  emitFormulaFn,
16
+ emitRequestFetch,
14
17
  escapeHtml,
15
18
  isMutating,
16
19
  isSchemaOnly,
20
+ srcImportBinding,
17
21
  tagNameToClassName,
18
22
  } from "../shared.ts";
19
23
  import {
24
+ bodyReturnsValue,
20
25
  hasStructuredBody,
21
26
  isExpressionDef,
22
27
  isFunctionDef,
23
28
  isMappedArray,
24
29
  isNamedFormulaDef,
25
30
  isRef,
31
+ isTagExpression,
26
32
  paramNames,
27
33
  } from "@jxsuite/schema/guards";
28
34
  import { parseJxDocument } from "@jxsuite/schema/parse";
29
35
  import type { ExpressionNode } from "../shared.ts";
36
+ import { resolveShadowMode } from "../shadow.ts";
37
+ import type { ShadowMode } from "../shadow.ts";
30
38
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
39
+ import { escapeToken, objectKey, refAccessor, refSegments } from "@jxsuite/runtime/pointer";
31
40
  import type {
32
41
  JxDocument,
33
42
  JxExpressionDef,
34
43
  JxFunctionDef,
35
44
  JxMappedArray,
36
45
  JxMutableNode,
46
+ JxPrototypeDef,
37
47
  JxStyle,
48
+ ProjectConfig,
38
49
  } from "@jxsuite/schema/types";
39
50
 
40
51
  /** Options accepted by compileElement. */
@@ -50,6 +61,8 @@ export interface CompileElementOptions {
50
61
  $media?: Record<string, string>;
51
62
  formats?: FormatRegistry;
52
63
  basePath?: string | null;
64
+ /** Project `defaults`, read for `defaults.shadow` (spec.md §16.6). */
65
+ defaults?: ProjectConfig["defaults"];
53
66
  [key: string]: unknown;
54
67
  }
55
68
 
@@ -147,6 +160,7 @@ export async function compileElement(
147
160
  className,
148
161
  elementImports,
149
162
  rewriteSrc ? (spec: string) => rewriteSrc(spec, currentDir) : undefined,
163
+ resolveShadowMode(doc, opts.defaults),
150
164
  );
151
165
  const outputPath = filePath ? filePath.replace(/\.json$/, ".js") : `${tagName}.js`;
152
166
  files.push({ content: jsContent, path: outputPath, tagName });
@@ -248,6 +262,68 @@ function extractInitialValue(def: JxMutableNode) {
248
262
  return JSON.stringify(def);
249
263
  }
250
264
 
265
+ /** Lifecycle hooks the generated element calls itself, so they are never `$ref`'d (spec.md §16.4). */
266
+ const LIFECYCLE_KEYS = new Set(["onMount", "onUnmount", "onAdopted"]);
267
+
268
+ /**
269
+ * Collect the state keys a document uses as a _callable_, at any depth: bound to an `on*` event,
270
+ * invoked by an `$expression` `call` node, or called as `state.key(…)` from a template string or
271
+ * another function's body.
272
+ *
273
+ * A bodyless `$src` Function carries no body to classify by, and unlike the interpreter — which
274
+ * introspects the imported function — a compiler only has the document to go on. Anything not used
275
+ * as a callable has its return value read reactively, and so becomes a computed (spec.md §5.3 4b).
276
+ * Defaulting the other way round would turn a called helper into a value and break its call site.
277
+ *
278
+ * @param {JxDocument} doc
279
+ * @returns {Set<string>}
280
+ */
281
+ function collectCallableRefs(doc: JxDocument) {
282
+ const keys = new Set<string>();
283
+ const seen = new Set<object>();
284
+
285
+ const addStateRef = (ref: unknown) => {
286
+ if (typeof ref === "string" && ref.startsWith("#/state/")) {
287
+ keys.add(ref.slice("#/state/".length).split("/")[0] as string);
288
+ }
289
+ };
290
+
291
+ const visit = (node: unknown) => {
292
+ if (typeof node === "string") {
293
+ // `${state.formatDate(d)}` in a template, or `state.helper(x)` in another function's body.
294
+ for (const call of node.matchAll(/\bstate\.([A-Za-z_$][\w$]*)\s*\(/g)) {
295
+ keys.add(call[1] as string);
296
+ }
297
+ return;
298
+ }
299
+ if (!node || typeof node !== "object" || seen.has(node)) {
300
+ return;
301
+ }
302
+ seen.add(node);
303
+ if (Array.isArray(node)) {
304
+ for (const item of node) {
305
+ visit(item);
306
+ }
307
+ return;
308
+ }
309
+ const record = node as Record<string, unknown>;
310
+ // An `$expression` `call` node names its callee in `target` (spec.md §19.4c).
311
+ if (record.operator === "call" && isRef(record.target)) {
312
+ addStateRef((record.target as { $ref: string }).$ref);
313
+ }
314
+ for (const [key, val] of Object.entries(record)) {
315
+ if (key.startsWith("on") && key !== "observedAttributes" && isRef(val)) {
316
+ addStateRef(val.$ref);
317
+ }
318
+ visit(val);
319
+ }
320
+ };
321
+
322
+ visit(doc.children);
323
+ visit(doc.state);
324
+ return keys;
325
+ }
326
+
251
327
  /**
252
328
  * Generate a complete ES module string for a custom element.
253
329
  *
@@ -261,6 +337,7 @@ export function emitElementModule(
261
337
  className: string,
262
338
  elementImports: string[],
263
339
  rewriteSrc?: (specifier: string) => string,
340
+ shadow: ShadowMode | null = null,
264
341
  ) {
265
342
  const lines: string[] = ["// Generated by @jxsuite/compiler — do not edit manually"];
266
343
 
@@ -282,7 +359,7 @@ export function emitElementModule(
282
359
  if (!srcImportMap.has(srcPath)) {
283
360
  srcImportMap.set(srcPath, []);
284
361
  }
285
- (srcImportMap.get(srcPath) as string[]).push(key);
362
+ (srcImportMap.get(srcPath) as string[]).push(srcImportBinding(key, d));
286
363
  }
287
364
  }
288
365
  for (const [srcPath, names] of srcImportMap) {
@@ -292,12 +369,25 @@ export function emitElementModule(
292
369
  lines.push(`import { ${names.join(", ")} } from '${importPath}';`);
293
370
  }
294
371
 
372
+ // `$prototype: "Request"` entries each get an auto-fetch effect in connectedCallback, plus the
373
+ // Machinery to stop those effects again on disconnect. Both are emitted only when one exists, so
374
+ // Documents without a Request produce exactly the same module as before.
375
+ const requestEntries = Object.entries(defs).filter(([, def]) => {
376
+ const d = def as JxMutableNode;
377
+ return Boolean(d) && typeof d === "object" && !Array.isArray(d) && d.$prototype === "Request";
378
+ });
379
+
295
380
  lines.push(
296
- `import { reactive, computed, effect } from '@vue/reactivity';`,
381
+ `import { reactive, computed, effect, stop } from '@vue/reactivity';`,
297
382
  `import { render, html } from 'lit-html';`,
298
383
  "",
299
384
  `class ${className} extends HTMLElement {`,
300
- " #dispose = null;",
385
+ // One registry for every effect the element creates — render, dynamic host styles, Request
386
+ // Auto-fetches. Calling an @vue/reactivity runner re-runs it, so teardown has to be `stop()`,
387
+ // And a single list keeps every effect on the same lifecycle instead of leaking the ones nobody
388
+ // Held a handle to.
389
+ " #effects = [];",
390
+ ...(shadow === null ? [] : [" #root = null;"]),
301
391
  "",
302
392
  // Constructor: build reactive state
303
393
  " constructor() {",
@@ -309,6 +399,9 @@ export function emitElementModule(
309
399
  const functionEntries: [string, JxExpressionDef | JxFunctionDef][] = [];
310
400
 
311
401
  const formulaEntries: [string, JxExpressionDef][] = [];
402
+ /** `"${…}"` state entries — computed, exactly as the runtime treats them. */
403
+ const templateEntries: [string, string][] = [];
404
+ const callableRefs = collectCallableRefs(doc);
312
405
  for (const [key, def] of Object.entries(defs)) {
313
406
  const d = def as JxMutableNode;
314
407
  if (isExpressionDef(d)) {
@@ -321,11 +414,34 @@ export function emitElementModule(
321
414
  computedEntries.push([key, d]);
322
415
  }
323
416
  } else if (isFunctionDef(d)) {
324
- if (typeof d.body === "string" && d.body.includes("return")) {
417
+ if (d.$src && typeof d.body !== "string") {
418
+ // Bodyless `$src` entry — classify by document usage, not by an absent body. `body` and
419
+ // `$src` are mutually exclusive (spec.md §5.3 4d), so this is the only shape a
420
+ // Spec-conformant external Function takes.
421
+ if (callableRefs.has(key) || LIFECYCLE_KEYS.has(key)) {
422
+ functionEntries.push([key, d]);
423
+ } else {
424
+ computedEntries.push([key, d]);
425
+ }
426
+ } else if (typeof d.body === "string" && bodyReturnsValue(d.body)) {
325
427
  computedEntries.push([key, d]);
326
428
  } else {
327
429
  functionEntries.push([key, d]);
328
430
  }
431
+ } else if (typeof def === "string" && def.includes("${")) {
432
+ /* A TEMPLATE STRING IS A COMPUTED, and the runtime has always said so — `runtime.ts`'s
433
+ second state pass is `if (typeof def === "string" && def.includes("${")) state[key] =
434
+ computed(() => evaluateTemplate(def, state))`, and `StateEntry`'s own schema description
435
+ reads "string with ${} → computed". This branch did not exist, so the compiler fell
436
+ through to `extractInitialValue` and emitted the template as a LITERAL.
437
+
438
+ The consequence is the worst shape a bug can take: the same component behaved one way in
439
+ Studio's canvas, which runs the runtime, and another way on the deployed site, which runs
440
+ this. A real site's `$switch` discriminant (`imageKey: "${state.image ? 'set' : ''}"`)
441
+ evaluated correctly in the editor and shipped as the literal text of its own expression,
442
+ so the case never matched and the image silently never rendered in production. Found by
443
+ building that site and reading the emitted component. */
444
+ templateEntries.push([key, def]);
329
445
  } else {
330
446
  // Use extractInitialValue to get the correct initial value
331
447
  const initVal = extractInitialValue(d);
@@ -338,7 +454,11 @@ export function emitElementModule(
338
454
  // Emit reactive({...}) with initial state values
339
455
  lines.push(" this.state = reactive({");
340
456
  for (const [key, initVal] of stateEntries) {
341
- lines.push(` ${key}: ${initVal},`);
457
+ /*
458
+ * A state key is author data, not an identifier: `{"user.name": 1}` is a legal document, and
459
+ * pasting it raw emitted `user.name: 1,` — a SyntaxError this build reported as a success.
460
+ */
461
+ lines.push(` ${objectKey(key)}: ${initVal},`);
342
462
  }
343
463
  lines.push(" });");
344
464
 
@@ -350,7 +470,9 @@ export function emitElementModule(
350
470
  eventParam: "e",
351
471
  statePrefix: "s",
352
472
  });
353
- lines.push(` this.state.${key} = (s, e) => { ${compiled}; };`);
473
+ lines.push(
474
+ ` ${refAccessor("this.state", escapeToken(key))} = (s, e) => { ${compiled}; };`,
475
+ );
354
476
  } else if (hasStructuredBody(def)) {
355
477
  // Structured body (spec §20) — statements compiled against this.state; dispatch target is
356
478
  // The component instance itself (WHATWG dispatchEvent on the custom element).
@@ -360,19 +482,51 @@ export function emitElementModule(
360
482
  indent: " ",
361
483
  statePrefix: "this.state",
362
484
  });
363
- lines.push(` this.state.${key} = (s, e) => {`, compiled, " };");
485
+ lines.push(
486
+ ` ${refAccessor("this.state", escapeToken(key))} = (s, e) => {`,
487
+ compiled,
488
+ " };",
489
+ );
364
490
  } else {
365
491
  const args = def.parameters ? paramNames(def.parameters) : (def.arguments ?? ["state"]);
366
492
  const paramList = args.join(", ");
367
- if (def.$src) {
368
- // $src function — wrap imported function so it receives state
369
- lines.push(` this.state.${key} = (${paramList}) => ${key}(${paramList});`);
493
+ const body = typeof def.body === "string" ? def.body : "";
494
+ // Every call site invokes a state function as `(state, event)` — the template's
495
+ // `s.fn(s, e)`, `onMount`, `onUnmount`. Declared names that already start with `state` line
496
+ // Up positionally, so they are emitted directly. Any other declaration — including the
497
+ // `"parameters": ["event"]` form that examples/components/{todo-app,fetch-demo}.json use — is
498
+ // Mapped onto the arguments by name, the way the client target does it
499
+ // (compile-client.ts), with `state` bound as the outer parameter so a body referencing bare
500
+ // `state` resolves regardless of the declared arity.
501
+ if (args[0] === "state") {
502
+ if (def.$src) {
503
+ // $src function — wrap imported function so it receives state
504
+ lines.push(
505
+ ` ${refAccessor("this.state", escapeToken(key))} = (${paramList}) => ${key}(${paramList});`,
506
+ );
507
+ } else {
508
+ lines.push(
509
+ ` ${refAccessor("this.state", escapeToken(key))} = (${paramList}) => {`,
510
+ ` ${body}`,
511
+ " };",
512
+ );
513
+ }
370
514
  } else {
371
- lines.push(
372
- ` this.state.${key} = (${paramList}) => {`,
373
- ` ${typeof def.body === "string" ? def.body : ""}`,
374
- " };",
375
- );
515
+ const callArgs = args.map((a: string) => (a === "state" ? "state" : "e")).join(", ");
516
+ if (def.$src) {
517
+ lines.push(
518
+ ` ${refAccessor("this.state", escapeToken(key))} = (state, e) => ${key}(${callArgs});`,
519
+ );
520
+ } else {
521
+ lines.push(
522
+ ` ${refAccessor("this.state", escapeToken(key))} = (state, e) => {`,
523
+ ` const _fn = (${paramList}) => {`,
524
+ ` ${body}`,
525
+ " };",
526
+ ` return _fn(${callArgs});`,
527
+ " };",
528
+ );
529
+ }
376
530
  }
377
531
  }
378
532
  }
@@ -384,7 +538,19 @@ export function emitElementModule(
384
538
  eventParam: "e",
385
539
  statePrefix: "this.state",
386
540
  });
387
- lines.push(` this.state.${key} = ${emitFormulaFn(def, compiled)};`);
541
+ lines.push(
542
+ ` ${refAccessor("this.state", escapeToken(key))} = ${emitFormulaFn(def, compiled)};`,
543
+ );
544
+ }
545
+
546
+ /* Emitted with the same `state.` → `this.state.` rewrite the inline-body path below uses, into a
547
+ template literal — which is what `evaluateTemplate` is, so the result is a string on both
548
+ sides rather than a string here and a raw value there. */
549
+ for (const [key, template] of templateEntries) {
550
+ lines.push(
551
+ "",
552
+ ` ${refAccessor("this.state", escapeToken(key))} = computed(() => \`${template.replaceAll("state.", "this.state.")}\`);`,
553
+ );
388
554
  }
389
555
 
390
556
  // Emit computed signals — $src or inline body
@@ -395,11 +561,15 @@ export function emitElementModule(
395
561
  eventParam: "e",
396
562
  statePrefix: "this.state",
397
563
  });
398
- lines.push(` this.state.${key} = computed(() => ${compiled});`);
564
+ lines.push(
565
+ ` ${refAccessor("this.state", escapeToken(key))} = computed(() => ${compiled});`,
566
+ );
399
567
  } else if (def.$src) {
400
- lines.push(` this.state.${key} = computed(() => ${key}(this.state));`);
568
+ lines.push(
569
+ ` ${refAccessor("this.state", escapeToken(key))} = computed(() => ${key}(this.state));`,
570
+ );
401
571
  } else {
402
- lines.push(` this.state.${key} = computed(() => {`);
572
+ lines.push(` ${refAccessor("this.state", escapeToken(key))} = computed(() => {`);
403
573
  const body = (typeof def.body === "string" ? def.body : "").replaceAll(
404
574
  "state.",
405
575
  "this.state.",
@@ -419,36 +589,101 @@ export function emitElementModule(
419
589
  }
420
590
  }
421
591
 
592
+ // Subtrees a branching construct would otherwise repeat are lifted into `const _cN = html`…``
593
+ // Declarations emitted inside `template()`, above the `return` — inside it, so they are rebuilt
594
+ // Per render and read the same `s` the template does.
595
+ const hoistScope = createHoistScope();
596
+ const templateBody = emitLitChildren(doc.children, doc.style, " ", false, false, hoistScope);
597
+
422
598
  lines.push(
423
599
  // Template method
424
600
  " template() {",
425
601
  " const s = this.state;",
602
+ ...hoistScope.decls,
426
603
  " return html`",
427
- emitLitChildren(doc.children, doc.style, " "),
604
+ templateBody,
428
605
  " `;",
429
606
  " }",
430
607
  "",
608
+ ...(shadow === null
609
+ ? []
610
+ : [
611
+ /*
612
+ * The hydration contract. A declarative shadow root the parser already materialized
613
+ * MUST be adopted rather than replaced — calling `attachShadow` over one throws, and
614
+ * even where it did not, replacing it would discard the server-rendered markup this
615
+ * whole feature exists to ship.
616
+ *
617
+ * An `open` root is on the element. A `closed` one is not, by definition, and
618
+ * `ElementInternals` is the standard's only way back to it — which is why the two modes
619
+ * need different code rather than a mode string in one call.
620
+ */
621
+ " #renderRoot() {",
622
+ " if (this.#root) return this.#root;",
623
+ shadow === "closed"
624
+ ? " this.#root = this.attachInternals().shadowRoot ?? this.attachShadow({ mode: 'closed' });"
625
+ : " this.#root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });",
626
+ " return this.#root;",
627
+ " }",
628
+ "",
629
+ ]),
431
630
  // ConnectedCallback
432
631
  " connectedCallback() {",
632
+ /*
633
+ * Each of the three absorption paths below skips a `#`-prefixed key: private state is not part
634
+ * of the component's interface and is never settable through $props (spec.md §5.6). Emitted as
635
+ * a runtime check rather than resolved at build time because all three read keys that arrive
636
+ * with the instance — an attribute payload, an attribute name, a property a parent set — none
637
+ * of which the compiler can see. The interpreter enforces the same rule in `runtime.ts`; the
638
+ * two tiers agreeing is the point, since a document is authored against whichever runs.
639
+ */
433
640
  // Read $props from data-jx-props attribute (set by compiler for pre-rendered instances)
434
641
  " const _pa = this.getAttribute('data-jx-props');",
435
642
  " if (_pa) {",
436
643
  " try {",
437
644
  " const _p = JSON.parse(_pa);",
438
645
  " for (const [k, v] of Object.entries(_p)) {",
646
+ " if (k.startsWith('#')) continue;",
439
647
  " if (k in this.state) this.state[k] = v;",
440
648
  " }",
441
649
  " } catch {}",
442
650
  " this.removeAttribute('data-jx-props');",
443
651
  " }",
652
+ // Literal `props.*` attributes — the live-render mirror of the build-time lift in site-build.
653
+ // String values only: HTML lowercases attribute names, so a state key must be lowercase to match.
654
+ // Names are collected first because removing while iterating the live NamedNodeMap skips entries.
655
+ " const _pn = this.getAttributeNames().filter(n => n.startsWith('props.') && n.length > 6);",
656
+ " for (const _n of _pn) {",
657
+ " const _k = _n.slice(6);",
658
+ " if (_k.startsWith('#')) { this.removeAttribute(_n); continue; }",
659
+ " if (_k in this.state) {",
660
+ " this.state[_k] = this.getAttribute(_n);",
661
+ " this.removeAttribute(_n);",
662
+ " }",
663
+ " }",
444
664
  // Merge JS properties set before connection (by parent runtime).
445
665
  // Only check own properties to avoid inherited DOM properties like `title`.
446
666
  " for (const key of Object.keys(this.state)) {",
667
+ " if (key.startsWith('#')) continue;",
447
668
  " if (this.hasOwnProperty(key) && this[key] !== undefined) {",
448
669
  " this.state[key] = this[key];",
449
670
  " }",
450
671
  " }",
451
672
  );
673
+
674
+ // `$prototype: "Request"` entries fetch on connect — after the `$props`/property merge above, so
675
+ // A template `url` interpolates the values the parent passed in rather than the initial ones.
676
+ // Runners are collected so disconnectedCallback can stop them: a reactive URL would otherwise
677
+ // Keep fetching after removal, and every re-insertion would stack another effect on top.
678
+ for (const [key, def] of requestEntries) {
679
+ lines.push(
680
+ emitRequestFetch(key, def as unknown as JxPrototypeDef, {
681
+ collect: "this.#effects",
682
+ indent: " ",
683
+ statePrefix: "this.state",
684
+ }),
685
+ );
686
+ }
452
687
  if (doc.style && typeof doc.style === "object") {
453
688
  const dynamicStyles: [string, string][] = [];
454
689
  for (const [prop, value] of Object.entries(doc.style)) {
@@ -470,7 +705,7 @@ export function emitElementModule(
470
705
  }
471
706
  }
472
707
  if (dynamicStyles.length > 0) {
473
- lines.push(" effect(() => {");
708
+ lines.push(" this.#effects.push(effect(() => {");
474
709
  for (const [cssProp, value] of dynamicStyles) {
475
710
  const expr = value.replaceAll(
476
711
  /\$\{([^}]+)\}/g,
@@ -478,11 +713,18 @@ export function emitElementModule(
478
713
  );
479
714
  lines.push(` this.style['${cssProp}'] = \`${expr}\`;`);
480
715
  }
481
- lines.push(" });");
716
+ lines.push(" }));");
482
717
  }
483
718
  }
484
719
  const hasSlot = treeHasSlot(doc.children);
485
- if (hasSlot) {
720
+ /*
721
+ * The slot emulation is a light-DOM construction: save the host's children, render over them,
722
+ * then splice them back where the literal `<slot>` element landed. Inside a shadow root that
723
+ * would be actively wrong — the light children ARE the slotted content and must stay in the
724
+ * light tree, where a real `<slot>` distributes them. This is the single largest behavioural
725
+ * difference between the two modes, and the reason shadow cannot be the default.
726
+ */
727
+ if (hasSlot && shadow === null) {
486
728
  // Save light DOM children (slotted content) before clearing
487
729
  lines.push(
488
730
  " const _slotted = Array.from(this.childNodes).filter(n => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));",
@@ -492,10 +734,42 @@ export function emitElementModule(
492
734
  " if (this.hasAttribute('data-jx-prerendered')) {",
493
735
  " this.removeAttribute('data-jx-prerendered');",
494
736
  " }",
495
- " this.innerHTML = '';",
496
- " this.#dispose = effect(() => render(this.template(), this));",
497
737
  );
498
- if (hasSlot) {
738
+ if (shadow === null) {
739
+ lines.push(
740
+ /*
741
+ * `replaceChildren()`, not `innerHTML = ''`: identical semantics for clearing an element, and
742
+ * it is not a DOM injection sink. The runtime made the same change for the same reason — a
743
+ * write that was never injecting anything still costs a page a Trusted Types policy under
744
+ * `require-trusted-types-for 'script'`, and this one is emitted into every light-DOM element
745
+ * module a site ships.
746
+ */
747
+ " this.replaceChildren();",
748
+ " this.#effects.push(effect(() => render(this.template(), this)));",
749
+ );
750
+ } else {
751
+ /*
752
+ * Nothing clears the LIGHT DOM here: those children are the slotted content, and a real
753
+ * `<slot>` distributes them.
754
+ *
755
+ * The shadow root is cleared, and for the same reason light mode clears `innerHTML`: lit
756
+ * renders into a container by appending its own tree, so leaving the declarative markup in
757
+ * place shows the component twice. A declarative shadow root is a first paint, not a
758
+ * hydration target — Jx does not use lit-ssr's `hydrate`, and the light path discards its
759
+ * prerender too.
760
+ *
761
+ * The stylesheet link is the one thing that survives: it is what styles this root, the
762
+ * document's head cannot reach in here, and re-adding it would refetch it.
763
+ */
764
+ lines.push(
765
+ " const _root = this.#renderRoot();",
766
+ " for (const _n of [..._root.childNodes]) {",
767
+ " if (!(_n.nodeType === 1 && _n.tagName === 'LINK')) _n.remove();",
768
+ " }",
769
+ " this.#effects.push(effect(() => render(this.template(), _root)));",
770
+ );
771
+ }
772
+ if (hasSlot && shadow === null) {
499
773
  // Replace <slot> placeholder with saved slotted content
500
774
  lines.push(
501
775
  " const _slot = this.querySelector('slot');",
@@ -514,7 +788,8 @@ export function emitElementModule(
514
788
  "",
515
789
  // DisconnectedCallback
516
790
  " disconnectedCallback() {",
517
- " if (this.#dispose) { this.#dispose(); this.#dispose = null; }",
791
+ " for (const _e of this.#effects) { stop(_e); }",
792
+ " this.#effects.length = 0;",
518
793
  " if (typeof this.state.onUnmount === 'function') { this.state.onUnmount(this.state); }",
519
794
  " }",
520
795
  "}",
@@ -526,18 +801,62 @@ export function emitElementModule(
526
801
  return lines.join("\n");
527
802
  }
528
803
 
804
+ /**
805
+ * Declarations that must be emitted _beside_ a lit template rather than inside it.
806
+ *
807
+ * `emitLitNode` writes one template string, so a construct with N branches recursed once per branch
808
+ * and wrote the whole subtree N times into the bundle — the authored document contains it once. A
809
+ * scope collects `const _cN = html`…`;` declarations for subtrees that would otherwise repeat, and
810
+ * the branches reference those instead.
811
+ *
812
+ * One scope per template body: `template()` has its own, and each map callback gets a fresh one
813
+ * because its declarations close over that callback's `item`/`index`.
814
+ */
815
+ interface HoistScope {
816
+ decls: string[];
817
+ byText: Map<string, string>;
818
+ }
819
+
820
+ function createHoistScope(): HoistScope {
821
+ return { byText: new Map(), decls: [] };
822
+ }
823
+
824
+ /**
825
+ * Hoist an emitted subtree into a `const` and return the `${_cN}` expression referencing it.
826
+ * Identical subtrees collapse onto a single declaration.
827
+ *
828
+ * @param {HoistScope} scope
829
+ * @param {string} text
830
+ * @returns {string}
831
+ */
832
+ function hoistSubtree(scope: HoistScope, text: string): string {
833
+ const existing = scope.byText.get(text);
834
+ if (existing) {
835
+ return existing;
836
+ }
837
+ const name = `_c${scope.decls.length}`;
838
+ scope.decls.push(` const ${name} = html\`\n${text}\n \`;`);
839
+ const ref = `\${${name}}`;
840
+ scope.byText.set(text, ref);
841
+ return ref;
842
+ }
843
+
529
844
  /**
530
845
  * Convert Jx children to lit-html template content.
531
846
  *
532
847
  * @param {JxMutableNode["children"]} children
533
848
  * @param {JxStyle | null | undefined} parentStyle
534
849
  * @param {string} indent
850
+ * @param {boolean} [preformatted]
535
851
  * @returns {string}
536
852
  */
537
853
  function emitLitChildren(
538
854
  children: JxMutableNode["children"],
539
855
  _parentStyle: JxStyle | null | undefined,
540
856
  indent: string,
857
+ preformatted = false,
858
+ inMap = false,
859
+ scope: HoistScope | null = null,
541
860
  ) {
542
861
  if (!children) {
543
862
  return "";
@@ -555,17 +874,28 @@ function emitLitChildren(
555
874
  // Mixed children: elements/text plus array pseudo-elements expanded inline among siblings.
556
875
  return children
557
876
  .map((child: JxMutableNode | string) =>
558
- isMappedArray(child) ? emitMappedArray(child, indent) : emitLitNode(child, indent),
877
+ isMappedArray(child)
878
+ ? emitMappedArray(child, indent)
879
+ : emitLitNode(child, preformatted ? "" : indent, preformatted, inMap, scope),
559
880
  )
560
- .join("\n");
881
+ .join(preformatted ? "" : "\n");
561
882
  }
562
883
 
563
884
  /**
564
885
  * @param {JxMutableNode | string} def
565
886
  * @param {string} indent
887
+ * @param {boolean} [preformatted]
566
888
  * @returns {string}
567
889
  */
568
- function emitLitNode(def: JxMutableNode | string, indent: string) {
890
+ function emitLitNode(
891
+ def: JxMutableNode | string,
892
+ indent: string,
893
+ preformatted = false,
894
+ inMap = false,
895
+ scope: HoistScope | null = null,
896
+ /** Pre-emitted inner content, so a branching construct can share one subtree across branches. */
897
+ innerOverride: string | null = null,
898
+ ): string {
569
899
  // String children are text nodes
570
900
  if (typeof def === "string") {
571
901
  if (def.includes("${")) {
@@ -580,7 +910,70 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
580
910
  return "";
581
911
  }
582
912
 
913
+ /* A CHOSEN TAG BECOMES ONE TEMPLATE PER CANDIDATE, keyed by the discriminant — the same shape
914
+ this function already emits for `$switch` twenty lines down, because it is the same problem:
915
+ lit cannot bind a tag name, and the alternative (`lit-html/static.js`'s `unsafeStatic`) is an
916
+ HTML-injection primitive with an unbounded template cache, deliberately avoided in this repo.
917
+
918
+ The candidates are literal `TagName`s by schema, so this terminates and every branch is a legal
919
+ element. The subtree is written once in the DOCUMENT, and — since the hoist scope below lifts it
920
+ into a single `const` the branches share — once in the BUNDLE too. */
921
+ if (isTagExpression(def.tagName)) {
922
+ const expression = def.tagName.$expression;
923
+ // The same two shapes `$switch` accepts: a pointer, or an expression node.
924
+ // `compileOperandSource` — the compiler's counterpart to the runtime's `evaluateOperand`, so
925
+ // Both sides read the discriminant the same way.
926
+ const discriminant = compileOperandSource(expression.target, {
927
+ eventParam: "e",
928
+ statePrefix: "s",
929
+ });
930
+ const branches =
931
+ expression.operator === "?:"
932
+ ? [
933
+ [true, expression.value],
934
+ [false, expression.initial],
935
+ ]
936
+ : [...Object.entries(expression.cases), ["__default__", expression.default]];
937
+ // The inner content is identical across candidates — only the tag differs — so it is emitted
938
+ // Once and every branch references it. Skipped when the candidates disagree about being
939
+ // Preformatted, because `white-space` decides how the subtree itself is indented.
940
+ const candidateTags = branches.map(([, candidate]) => String(candidate));
941
+ const sharedPre = candidateTags.map((t) => preformatted || PREFORMATTED_TAGS.has(t));
942
+ let innerRef: string | null = null;
943
+ if (scope && sharedPre.every((p) => p === sharedPre[0])) {
944
+ const sharedInner = emitNodeInner(def, `${indent} `, sharedPre[0] === true, inMap, scope);
945
+ if (sharedInner.trim() !== "") {
946
+ innerRef = hoistSubtree(scope, sharedInner);
947
+ }
948
+ }
949
+ const rendered: [string | boolean | undefined, string][] = branches.map(([key, candidate]) => {
950
+ const asLiteral = { ...def, tagName: candidate as string };
951
+ return [key, emitLitNode(asLiteral, `${indent} `, preformatted, inMap, scope, innerRef)];
952
+ });
953
+ if (expression.operator === "?:") {
954
+ const [, yes] = rendered[0]!;
955
+ const [, no] = rendered[1]!;
956
+ return `${indent}\${${discriminant}
957
+ ? html\`
958
+ ${yes}
959
+ \`
960
+ : html\`
961
+ ${no}
962
+ \`}`;
963
+ }
964
+ const entries = rendered
965
+ .filter(([key]) => key !== "__default__")
966
+ .map(([key, text]) => ` ${JSON.stringify(key)}: html\`\n${text}\n \``);
967
+ const [, fallback] = rendered.at(-1)!;
968
+ return (
969
+ `${indent}${"$"}{{\n${entries.join(",\n")}\n}` +
970
+ `[String(${discriminant})] ?? html\`\n${fallback}\n\`}`
971
+ );
972
+ }
973
+
583
974
  const tag = def.tagName ?? "div";
975
+ // `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
976
+ const inPre = preformatted || PREFORMATTED_TAGS.has(tag);
584
977
 
585
978
  const parts: string[] = [];
586
979
 
@@ -596,11 +989,14 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
596
989
  }
597
990
  }
598
991
 
992
+ // `id`/`className` take the same template rewriting as every other attribute — inside a map
993
+ // These routinely carry `${$map.item…}`/`${$map.index}`, which emitMappedArray already rewrites
994
+ // On the map root but nothing rewrote on its descendants.
599
995
  if (def.id) {
600
- parts.push(`id="${def.id}"`);
996
+ parts.push(`id="${toLitExpr(String(def.id))}"`);
601
997
  }
602
998
  if (def.className) {
603
- parts.push(`class="${def.className}"`);
999
+ parts.push(`class="${toLitExpr(String(def.className))}"`);
604
1000
  }
605
1001
 
606
1002
  for (const [key, val] of Object.entries(def)) {
@@ -624,6 +1020,12 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
624
1020
  parts.push(`.${key}="\${${refToExpr((val as JxMutableNode).$ref as string)}}"`);
625
1021
  } else if (typeof val === "string" && val.includes("${")) {
626
1022
  parts.push(`.${key}="${toLitExpr(val)}"`);
1023
+ } else {
1024
+ // A literal IDL value (`value: "a"`, `checked: true`) is still a property the author asked
1025
+ // For. Without this branch it was dropped outright — including every `${…}` a build-time map
1026
+ // Expansion had already resolved to a constant, which is how an unrolled `<option>` lost its
1027
+ // Value and fell back to using its own label as its key.
1028
+ parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
627
1029
  }
628
1030
  }
629
1031
 
@@ -631,12 +1033,21 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
631
1033
  for (const [key, val] of Object.entries(def.$props)) {
632
1034
  if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
633
1035
  parts.push(`.${key}="\${${refToExpr((val as JxMutableNode).$ref as string)}}"`);
1036
+ } else if (typeof val === "string" && val.includes("${")) {
1037
+ // A template value is a binding, not text. JSON-quoting it emitted the template's own source
1038
+ // As the prop value (`.label="${"${state.x}"}"`), so the component received the literal
1039
+ // String `${state.x}` forever.
1040
+ parts.push(`.${key}="${toLitExpr(val)}"`);
634
1041
  } else {
635
1042
  parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
636
1043
  }
637
1044
  }
638
1045
  }
639
1046
 
1047
+ // A handler bound inside a map publishes its iteration to state before running, matching the
1048
+ // Interpreter's child scope and the client target's handler wrapper. Bodies read it as
1049
+ // `state.$map.index` / `state.$map.item`.
1050
+ const mapCtx = inMap ? "s.$map = $map; " : "";
640
1051
  for (const [key, val] of Object.entries(def)) {
641
1052
  if (!key.startsWith("on") || key === "observedAttributes") {
642
1053
  continue;
@@ -644,7 +1055,9 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
644
1055
  const eventName = key.slice(2).toLowerCase();
645
1056
  if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
646
1057
  parts.push(
647
- `@${eventName}="\${(e) => ${refToExpr((val as JxMutableNode).$ref as string)}(s, e)}"`,
1058
+ mapCtx
1059
+ ? `@${eventName}="\${(e) => { ${mapCtx}${refToExpr((val as JxMutableNode).$ref as string)}(s, e); }}"`
1060
+ : `@${eventName}="\${(e) => ${refToExpr((val as JxMutableNode).$ref as string)}(s, e)}"`,
648
1061
  );
649
1062
  } else if (val && typeof val === "object" && "$expression" in /** @type {any} */ val) {
650
1063
  const compiled = compileExpression(
@@ -654,9 +1067,9 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
654
1067
  statePrefix: "s",
655
1068
  },
656
1069
  );
657
- parts.push(`@${eventName}="\${(e) => { ${compiled}; }}"`);
1070
+ parts.push(`@${eventName}="\${(e) => { ${mapCtx}${compiled}; }}"`);
658
1071
  } else if (isFunctionDef(val)) {
659
- parts.push(`@${eventName}="\${(e) => { ${inlineHandlerBody(val)} }}"`);
1072
+ parts.push(`@${eventName}="\${(e) => { ${mapCtx}${inlineHandlerBody(val)} }}"`);
660
1073
  }
661
1074
  }
662
1075
 
@@ -672,29 +1085,70 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
672
1085
  return `${indent}<${tag}${attrsStr}\n${indent}>`;
673
1086
  }
674
1087
 
675
- let inner = "";
1088
+ const inner = innerOverride ?? emitNodeInner(def, indent, inPre, inMap, scope);
1089
+
1090
+ // Inside a <pre>, the newline before `>` would land in the element's own text.
1091
+ return inPre
1092
+ ? `<${tag}${attrsStr}>${inner}</${tag}>`
1093
+ : `${indent}<${tag}${attrsStr}\n${indent}>${inner}</${tag}>`;
1094
+ }
1095
+
1096
+ /**
1097
+ * Emit an element's inner content — the `$switch` lookup, text, raw HTML, or children.
1098
+ *
1099
+ * Split out of `emitLitNode` so a chosen `tagName` can emit it once and share it across candidates
1100
+ * instead of recursing into the same subtree per branch.
1101
+ *
1102
+ * @param {JxMutableNode} def
1103
+ * @param {string} indent
1104
+ * @param {boolean} inPre
1105
+ * @param {boolean} inMap
1106
+ * @param {HoistScope | null} scope
1107
+ * @returns {string}
1108
+ */
1109
+ function emitNodeInner(
1110
+ def: JxMutableNode,
1111
+ indent: string,
1112
+ inPre: boolean,
1113
+ inMap: boolean,
1114
+ scope: HoistScope | null,
1115
+ ): string {
676
1116
  if (def.$switch) {
677
1117
  const switchRef = isRef(def.$switch) ? def.$switch.$ref : (def.$switch as string);
678
1118
  const switchExpr = refToExpr(switchRef);
679
1119
  const cases = (def.cases ?? {}) as Record<string, JxMutableNode>;
680
- const caseEntries: string[] = [];
1120
+ const rendered: [string, string][] = [];
681
1121
  for (const [key, caseDef] of Object.entries(cases)) {
682
1122
  if (!caseDef || isRef(caseDef)) {
683
1123
  continue;
684
1124
  }
685
- const renderedCase = emitLitNode(caseDef, `${indent} `);
686
- caseEntries.push(` ${JSON.stringify(key)}: html\`\n${renderedCase}\n \``);
1125
+ rendered.push([key, emitLitNode(caseDef, `${indent} `, false, inMap, scope)]);
687
1126
  }
688
- inner = `\${{\n${caseEntries.join(",\n")}\n}[${switchExpr}]}`;
689
- } else if (def.textContent !== undefined) {
690
- inner = toLitTextContent(def.textContent);
691
- } else if (def.innerHTML !== undefined) {
692
- inner = def.innerHTML;
693
- } else if (def.children) {
694
- inner = `\n${emitLitChildren(def.children, def.style, `${indent} `)}\n${indent}`;
1127
+ // Cases that emit byte-identical subtrees share one hoisted `const` rather than repeating it
1128
+ // Once per case — the authored document names the subtree once (#126).
1129
+ const repeated = new Set(
1130
+ rendered
1131
+ .map(([, text]) => text)
1132
+ .filter((text, i, all) => all.indexOf(text) !== i && text.trim() !== ""),
1133
+ );
1134
+ const caseEntries = rendered.map(([key, text]) => {
1135
+ const body = scope && repeated.has(text) ? hoistSubtree(scope, text) : text;
1136
+ return ` ${JSON.stringify(key)}: html\`\n${body}\n \``;
1137
+ });
1138
+ return `\${{\n${caseEntries.join(",\n")}\n}[${switchExpr}]}`;
695
1139
  }
696
-
697
- return `${indent}<${tag}${attrsStr}\n${indent}>${inner}</${tag}>`;
1140
+ if (def.textContent !== undefined) {
1141
+ return toLitTextContent(def.textContent);
1142
+ }
1143
+ if (def.innerHTML !== undefined) {
1144
+ return def.innerHTML;
1145
+ }
1146
+ if (def.children) {
1147
+ return inPre
1148
+ ? emitLitChildren(def.children, def.style, "", true, inMap, scope)
1149
+ : `\n${emitLitChildren(def.children, def.style, `${indent} `, false, inMap, scope)}\n${indent}`;
1150
+ }
1151
+ return "";
698
1152
  }
699
1153
 
700
1154
  /**
@@ -713,10 +1167,65 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
713
1167
  const tag = mapDef.tagName ?? "div";
714
1168
  const parts: string[] = [];
715
1169
 
1170
+ // The map root gets the same attribute treatment as any other element (emitLitNode above);
1171
+ // `${item…}`/`${index…}` templates resolve against the map callback's own parameters.
1172
+ if (mapDef.attributes) {
1173
+ for (const [key, val] of Object.entries(mapDef.attributes)) {
1174
+ if (val && typeof val === "object" && isRef(val)) {
1175
+ parts.push(`${key}="\${${mapRefToExpr(val.$ref)}}"`);
1176
+ } else if (typeof val === "string" && val.includes("${")) {
1177
+ parts.push(`${key}="${toLitExpr(val)}"`);
1178
+ } else {
1179
+ parts.push(`${key}="${val}"`);
1180
+ }
1181
+ }
1182
+ }
1183
+
1184
+ if (mapDef.id) {
1185
+ parts.push(`id="${toLitExpr(String(mapDef.id))}"`);
1186
+ }
1187
+ if (mapDef.className) {
1188
+ parts.push(`class="${toLitExpr(String(mapDef.className))}"`);
1189
+ }
1190
+
1191
+ // IDL properties (`value`, `checked`, `selected`, …) go straight on the element, exactly as they
1192
+ // Do outside a map (emitLitNode above). Without this loop the map body silently dropped them, so
1193
+ // An `<option value="${$map.item.v}">` emitted no value at all and every option fell back to its
1194
+ // Own label — a change handler then received a display string instead of the key.
1195
+ for (const [key, val] of Object.entries(mapDef)) {
1196
+ if (
1197
+ RESERVED_KEYS.has(key) ||
1198
+ key.startsWith("$") ||
1199
+ key.startsWith("on") ||
1200
+ key === "tagName" ||
1201
+ key === "id" ||
1202
+ key === "className" ||
1203
+ key === "style" ||
1204
+ key === "children" ||
1205
+ key === "textContent" ||
1206
+ key === "innerHTML" ||
1207
+ key === "attributes"
1208
+ ) {
1209
+ continue;
1210
+ }
1211
+
1212
+ if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
1213
+ parts.push(`.${key}="\${${mapRefToExpr((val as JxMutableNode).$ref as string)}}"`);
1214
+ } else if (typeof val === "string" && val.includes("${")) {
1215
+ parts.push(`.${key}="${toLitExpr(val)}"`);
1216
+ } else {
1217
+ parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
1218
+ }
1219
+ }
1220
+
716
1221
  if (mapDef.$props) {
717
1222
  for (const [key, val] of Object.entries(mapDef.$props)) {
718
1223
  if (isRef(val)) {
719
1224
  parts.push(`.${key}="\${${mapRefToExpr(val.$ref)}}"`);
1225
+ } else if (typeof val === "string" && val.includes("${")) {
1226
+ // `$props: { label: "${$map.item.name}" }` is the ordinary way to pass per-item data to a
1227
+ // Component in a list; JSON-quoting it handed over the template source instead.
1228
+ parts.push(`.${key}="${toLitExpr(val)}"`);
720
1229
  } else {
721
1230
  parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
722
1231
  }
@@ -734,26 +1243,46 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
734
1243
  }
735
1244
  const eventName = key.slice(2).toLowerCase();
736
1245
  if (isRef(val)) {
737
- parts.push(`@${eventName}="\${(e) => ${refToExpr(val.$ref)}(s, e)}"`);
1246
+ parts.push(`@${eventName}="\${(e) => { s.$map = $map; ${refToExpr(val.$ref)}(s, e); }}"`);
738
1247
  } else if (isExpressionDef(val)) {
739
1248
  const compiled = compileExpression(val.$expression as ExpressionNode, {
740
1249
  eventParam: "e",
741
1250
  statePrefix: "s",
742
1251
  });
743
- parts.push(`@${eventName}="\${(e) => { ${compiled}; }}"`);
1252
+ parts.push(`@${eventName}="\${(e) => { s.$map = $map; ${compiled}; }}"`);
744
1253
  }
745
1254
  }
746
1255
 
747
1256
  const attrsStr = parts.length > 0 ? `\n${indent} ${parts.join(`\n${indent} `)}` : "";
748
1257
 
1258
+ // The map body gets its own hoist scope: a subtree lifted out of a branch here closes over this
1259
+ // Callback's `item`/`index`, so its declaration belongs inside the callback, not in `template()`.
1260
+ const mapScope = createHoistScope();
749
1261
  let inner = "";
750
1262
  if (mapDef.textContent !== undefined) {
751
1263
  inner = toLitTextContent(mapDef.textContent);
752
1264
  } else if (mapDef.children) {
753
- inner = `\n${emitLitChildren(mapDef.children, null, `${indent} `)}\n${indent} `;
1265
+ inner = `\n${emitLitChildren(mapDef.children, null, `${indent} `, false, true, mapScope)}\n${indent} `;
754
1266
  }
755
1267
 
756
- return `${indent}\${${itemsExpr}.map((item, index) => html\`\n${indent} <${tag}${attrsStr}\n${indent} >${inner}</${tag}>\n${indent}\`)}`;
1268
+ const body = `html\`\n${indent} <${tag}${attrsStr}\n${indent} >${inner}</${tag}>\n${indent}\``;
1269
+
1270
+ // `${$map.item…}` and `${$map.index}` are spec-sanctioned template forms: spec.md §6.6 names
1271
+ // `$map` alongside `item`/`index` as an iteration binding, and the interpreter passes it into the
1272
+ // Template evaluator as a real parameter. The compiled callback bound only `item`/`index`, so
1273
+ // Every such template survived into the module as a dead `$map` reference and threw at render.
1274
+ // Binding the same object here resolves every access form — `$map.item`, `$map?.item`,
1275
+ // `$map["item"]` — and nests correctly, since an inner map shadows the outer binding exactly as
1276
+ // The interpreter's child scope does. Emitted only when referenced, so output is otherwise
1277
+ // Unchanged.
1278
+ const prelude =
1279
+ (body.includes("$map") ? `const $map = { item, index }; ` : "") +
1280
+ mapScope.decls.map((d) => `${d.trim()} `).join("");
1281
+ const callback = prelude
1282
+ ? `(item, index) => { ${prelude}return ${body}; }`
1283
+ : `(item, index) => ${body}`;
1284
+
1285
+ return `${indent}\${${itemsExpr}.map(${callback})}`;
757
1286
  }
758
1287
 
759
1288
  /**
@@ -764,14 +1293,38 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
764
1293
  */
765
1294
  function refToExpr(ref: string) {
766
1295
  if (ref.startsWith("#/state/")) {
767
- const path = ref.slice("#/state/".length);
768
- return `s.${path.replaceAll("/", ".")}`;
1296
+ return refAccessor("s", ref.slice("#/state/".length));
769
1297
  }
770
1298
  if (ref.startsWith("$map/")) {
771
- const path = ref.slice("$map/".length);
772
- return path.replaceAll("/", ".");
1299
+ return mapVarAccessor(ref.slice("$map/".length));
1300
+ }
1301
+ /*
1302
+ * The remaining schemes, mirroring `compileRef` in @jxsuite/runtime. They used to fall through to
1303
+ * the state branch, so `parent#/user` emitted `s.parent#/user` — a syntax error. A prop reaches
1304
+ * this element through `state`, so `parent#/` reads from `s` exactly as `#/state/` does.
1305
+ */
1306
+ if (ref.startsWith("parent#/")) {
1307
+ return refAccessor("s", ref.slice("parent#/".length));
773
1308
  }
774
- return `s.${ref}`;
1309
+ if (ref.startsWith("window#/")) {
1310
+ return refAccessor("window", ref.slice("window#/".length));
1311
+ }
1312
+ if (ref.startsWith("document#/")) {
1313
+ return refAccessor("document", ref.slice("document#/".length));
1314
+ }
1315
+ return refAccessor("s", ref);
1316
+ }
1317
+
1318
+ /**
1319
+ * `$map/item/title` reads the loop variable `item`, so its first segment is an emitted identifier
1320
+ * rather than a member of some receiver — the one ref form whose base is not fixed.
1321
+ *
1322
+ * @param {string} path
1323
+ * @returns {string}
1324
+ */
1325
+ function mapVarAccessor(path: string) {
1326
+ const [head, ...rest] = refSegments(path);
1327
+ return refAccessor(head ?? "", rest.map((seg) => escapeToken(seg)).join("/"));
775
1328
  }
776
1329
 
777
1330
  /**
@@ -780,7 +1333,7 @@ function refToExpr(ref: string) {
780
1333
  */
781
1334
  function mapRefToExpr(ref: string) {
782
1335
  if (ref.startsWith("$map/")) {
783
- return ref.slice("$map/".length).replaceAll("/", ".");
1336
+ return mapVarAccessor(ref.slice("$map/".length));
784
1337
  }
785
1338
  return refToExpr(ref);
786
1339
  }
@@ -828,6 +1381,15 @@ function inlineHandlerBody(def: JxFunctionDef) {
828
1381
  });
829
1382
  }
830
1383
  const body = typeof def.body === "string" ? def.body : "";
1384
+ const declared = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
1385
+ if (declared.length > 0) {
1386
+ // The call site supplies only `(e)`, so map the declared names onto the arguments the handler
1387
+ // Actually has — `state` to the `s` alias, anything else to the event. Binding by name means the
1388
+ // Body needs no textual rewriting, and a declared `event` stops silently resolving to the
1389
+ // Deprecated `window.event` global (any other declared name threw ReferenceError outright).
1390
+ const args = declared.map((name: string) => (name === "state" ? "s" : "e")).join(", ");
1391
+ return `((${declared.join(", ")}) => { ${body} })(${args});`;
1392
+ }
831
1393
  return body.replaceAll(/(?<!this\.)state\./g, "s.").replaceAll(/(?<!this\.)state(?!\.)/g, "s");
832
1394
  }
833
1395