@barefootjs/xslate 0.18.5 → 0.19.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/dist/index.js CHANGED
@@ -187308,7 +187308,13 @@ import {
187308
187308
  queryHrefArgs,
187309
187309
  isValidHelperId,
187310
187310
  sortComparatorFromArrow as sortComparatorFromArrow2,
187311
- isLowerableLoopDestructure
187311
+ isLowerableLoopDestructure,
187312
+ isDangerousInnerHtmlAttr,
187313
+ resolveDangerousInnerHtml,
187314
+ dangerousInnerHtmlMetacharViolation,
187315
+ dangerousInnerHtmlDiagnostic,
187316
+ resolveStaticLoopSource,
187317
+ collectLoopBoundNames as collectLoopBoundNames2
187312
187318
  } from "@barefootjs/jsx";
187313
187319
 
187314
187320
  // src/adapter/boolean-result.ts
@@ -187624,6 +187630,39 @@ function renderFlatMethod(recv, depth, emit) {
187624
187630
  return `$bf.flat(${recv}, ${d})`;
187625
187631
  }
187626
187632
 
187633
+ // src/adapter/lib/static-value.ts
187634
+ function staticValueToKolon(value) {
187635
+ if (value === null || value === undefined)
187636
+ return "nil";
187637
+ if (typeof value === "boolean")
187638
+ return null;
187639
+ if (typeof value === "number")
187640
+ return String(value);
187641
+ if (typeof value === "string")
187642
+ return `'${escapeKolonSingleQuoted(value)}'`;
187643
+ if (Array.isArray(value)) {
187644
+ const items = [];
187645
+ for (const el of value) {
187646
+ const serialized = staticValueToKolon(el);
187647
+ if (serialized === null)
187648
+ return null;
187649
+ items.push(serialized);
187650
+ }
187651
+ return `[${items.join(", ")}]`;
187652
+ }
187653
+ if (typeof value === "object") {
187654
+ const entries = [];
187655
+ for (const [key, val] of Object.entries(value)) {
187656
+ const serialized = staticValueToKolon(val);
187657
+ if (serialized === null)
187658
+ return null;
187659
+ entries.push(`${kolonHashKey(key)} => ${serialized}`);
187660
+ }
187661
+ return `{ ${entries.join(", ")} }`;
187662
+ }
187663
+ return null;
187664
+ }
187665
+
187627
187666
  // src/adapter/expr/emitters.ts
187628
187667
  import {
187629
187668
  groupBinaryOperand,
@@ -188167,6 +188206,9 @@ function generateDerivedMemoSeed(ctx, ir) {
188167
188206
  ` : "";
188168
188207
  }
188169
188208
 
188209
+ // src/adapter/props/prop-classes.ts
188210
+ import { collectLoopBoundNames } from "@barefootjs/jsx";
188211
+
188170
188212
  // src/adapter/value/parsed-literal.ts
188171
188213
  import { evalStringArrayJoin } from "@barefootjs/jsx";
188172
188214
  function isStringTypeInfo(type2) {
@@ -188184,7 +188226,7 @@ function collectBooleanTypedProps(ir) {
188184
188226
  return new Set(ir.metadata.propsParams.filter((prop) => prop.type?.primitive === "boolean" || prop.type?.raw === "boolean").map((prop) => prop.name));
188185
188227
  }
188186
188228
  function collectNullableOptionalProps(ir) {
188187
- return new Set(ir.metadata.propsParams.filter((p) => p.defaultValue === undefined && !p.isRest && p.type?.kind !== "primitive").map((p) => p.name));
188229
+ return new Set(ir.metadata.propsParams.filter((p) => p.defaultValue === undefined && !p.isRest && (p.type?.kind !== "primitive" || p.optional)).map((p) => p.name));
188188
188230
  }
188189
188231
  function collectStringValueNames(ir) {
188190
188232
  const names = new Set;
@@ -188197,6 +188239,12 @@ function collectStringValueNames(ir) {
188197
188239
  if (isStringTypeInfo(p.type))
188198
188240
  names.add(p.name);
188199
188241
  }
188242
+ for (const c of ir.metadata.localConstants) {
188243
+ if (isStringTypeInfo(c.type ?? undefined) || isBareStringLiteral(c.value))
188244
+ names.add(c.name);
188245
+ }
188246
+ for (const bound of collectLoopBoundNames(ir))
188247
+ names.delete(bound);
188200
188248
  return names;
188201
188249
  }
188202
188250
 
@@ -188232,6 +188280,7 @@ class XslateAdapter extends BaseAdapter {
188232
188280
  _searchParamsLocals = new Set;
188233
188281
  _loweringMatchers = [];
188234
188282
  localConstants = [];
188283
+ staticLoopSourceBoundNames = new Set;
188235
188284
  nullableOptionalProps = new Set;
188236
188285
  constructor(options = {}) {
188237
188286
  super();
@@ -188247,6 +188296,7 @@ class XslateAdapter extends BaseAdapter {
188247
188296
  this.propsParams = ir.metadata.propsParams.map((p) => ({ name: p.name }));
188248
188297
  this.booleanTypedProps = collectBooleanTypedProps(ir);
188249
188298
  this.localConstants = ir.metadata.localConstants ?? [];
188299
+ this.staticLoopSourceBoundNames = collectLoopBoundNames2(ir);
188250
188300
  this.nullableOptionalProps = collectNullableOptionalProps(ir);
188251
188301
  this.stringValueNames = collectStringValueNames(ir);
188252
188302
  this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants);
@@ -188363,7 +188413,8 @@ class XslateAdapter extends BaseAdapter {
188363
188413
  renderElement(element) {
188364
188414
  const tag = element.tag;
188365
188415
  const attrs = this.renderAttributes(element);
188366
- const children = this.renderChildren(element.children);
188416
+ const dangerousHtml = this.renderDangerousInnerHtml(element);
188417
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children);
188367
188418
  let hydrationAttrs = "";
188368
188419
  if (element.needsScope) {
188369
188420
  hydrationAttrs += ` ${this.renderScopeMarker("")}`;
@@ -188398,6 +188449,22 @@ class XslateAdapter extends BaseAdapter {
188398
188449
  }
188399
188450
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`;
188400
188451
  }
188452
+ renderDangerousInnerHtml(element) {
188453
+ const resolution = resolveDangerousInnerHtml(element);
188454
+ if (!resolution)
188455
+ return null;
188456
+ if (resolution.kind === "dynamic") {
188457
+ this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc));
188458
+ return "";
188459
+ }
188460
+ const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name);
188461
+ if (violation) {
188462
+ const attr = element.attrs.find(isDangerousInnerHtmlAttr);
188463
+ this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation));
188464
+ return "";
188465
+ }
188466
+ return resolution.html;
188467
+ }
188401
188468
  renderExpression(expr) {
188402
188469
  if (expr.clientOnly) {
188403
188470
  if (expr.slotId) {
@@ -188405,7 +188472,7 @@ class XslateAdapter extends BaseAdapter {
188405
188472
  }
188406
188473
  return "";
188407
188474
  }
188408
- const perlExpr = this.convertExpressionToKolon(expr.expr);
188475
+ const perlExpr = this.convertExpressionToKolon(expr.expr, expr.parsed);
188409
188476
  if (expr.slotId) {
188410
188477
  return `<: $bf.text_start("${expr.slotId}") | mark_raw :><: ${perlExpr} :><: $bf.text_end() | mark_raw :>`;
188411
188478
  }
@@ -188497,8 +188564,12 @@ ${whenTrue}
188497
188564
  }
188498
188565
  });
188499
188566
  }
188567
+ const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
188568
+ isNameShadowed: (name) => this.staticLoopSourceBoundNames.has(name)
188569
+ });
188570
+ const staticArray = staticItems !== null ? staticValueToKolon(staticItems) : null;
188500
188571
  const arrayName = loop.array.trim();
188501
- if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
188572
+ if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
188502
188573
  const arrayConst = (this.localConstants ?? []).find((c) => c.name === arrayName);
188503
188574
  if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
188504
188575
  this.errors.push({
@@ -188512,7 +188583,7 @@ ${whenTrue}
188512
188583
  });
188513
188584
  }
188514
188585
  }
188515
- const rawArray = this.convertExpressionToKolon(loop.array);
188586
+ const rawArray = staticArray ?? this.convertExpressionToKolon(loop.array);
188516
188587
  let array = rawArray;
188517
188588
  if (loop.sortComparator) {
188518
188589
  const sort = loop.sortComparator;
@@ -188825,6 +188896,8 @@ ${name}="<: ${val} :>"
188825
188896
  for (const attr of element.attrs) {
188826
188897
  if (attr.clientOnly)
188827
188898
  continue;
188899
+ if (isDangerousInnerHtmlAttr(attr))
188900
+ continue;
188828
188901
  let attrName;
188829
188902
  if (attr.name === "className")
188830
188903
  attrName = "class";
@@ -189008,6 +189081,8 @@ Options:
189008
189081
  return this.booleanTypedProps.has(bare);
189009
189082
  }
189010
189083
  _resolveLiteralConst(name) {
189084
+ if (this.staticLoopSourceBoundNames.has(name))
189085
+ return null;
189011
189086
  const c = (this.localConstants ?? []).find((lc) => lc.name === name);
189012
189087
  if (c?.value === undefined)
189013
189088
  return null;
@@ -189020,6 +189095,8 @@ Options:
189020
189095
  return null;
189021
189096
  }
189022
189097
  _resolveStaticRecordLiteral(objectName, key) {
189098
+ if (this.staticLoopSourceBoundNames.has(objectName))
189099
+ return null;
189023
189100
  const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants);
189024
189101
  if (!hit)
189025
189102
  return null;
@@ -189057,22 +189134,15 @@ Options:
189057
189134
  var xslateAdapter = new XslateAdapter;
189058
189135
  // src/conformance-pins.ts
189059
189136
  var conformancePins = {
189060
- "static-array-children": [{ code: "BF103", severity: "error" }],
189061
- "todo-app": [{ code: "BF103", severity: "error" }],
189062
- "todo-app-ssr": [{ code: "BF103", severity: "error" }],
189063
189137
  "static-array-from-props": [{ code: "BF101", severity: "error" }],
189064
- "static-array-from-props-with-component": [
189065
- { code: "BF103", severity: "error" },
189066
- { code: "BF101", severity: "error" }
189067
- ],
189138
+ "static-array-from-props-with-component": [{ code: "BF101", severity: "error" }],
189068
189139
  "filter-nested-callback-predicate": [
189069
189140
  { code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2038" }
189070
189141
  ],
189071
189142
  "filter-nested-find-predicate": [
189072
189143
  { code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2038" }
189073
189144
  ],
189074
- "array-map-function-reference": [{ code: "BF101", severity: "error" }],
189075
- "dangerous-inner-html": [{ code: "BF101", severity: "error" }]
189145
+ "dangerous-inner-html-dynamic": [{ code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2215" }]
189076
189146
  };
189077
189147
  // src/render-divergences.ts
189078
189148
  var renderDivergences = {};
@@ -1 +1 @@
1
- {"version":3,"file":"render-divergences.d.ts","sourceRoot":"","sources":["../src/render-divergences.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAExD,eAAO,MAAM,iBAAiB,EAAE,iBAAsB,CAAA"}
1
+ {"version":3,"file":"render-divergences.d.ts","sourceRoot":"","sources":["../src/render-divergences.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAExD,eAAO,MAAM,iBAAiB,EAAE,iBAK/B,CAAA"}
@@ -1,5 +1,5 @@
1
1
  package BarefootJS::Backend::Xslate;
2
- our $VERSION = "0.18.4";
2
+ our $VERSION = "0.18.7";
3
3
  use strict;
4
4
  use warnings;
5
5
  use utf8;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/xslate",
3
- "version": "0.18.5",
3
+ "version": "0.19.0",
4
4
  "description": "Text::Xslate (Kolon) adapter for BarefootJS — compiles IR to .tx templates and ships the Xslate rendering backend; runs under any PSGI/Plack app",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,14 +55,14 @@
55
55
  "directory": "packages/adapter-xslate"
56
56
  },
57
57
  "dependencies": {
58
- "@barefootjs/shared": "0.18.5"
58
+ "@barefootjs/shared": "0.19.0"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@barefootjs/jsx": ">=0.2.0"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@barefootjs/adapter-tests": "0.1.0",
65
- "@barefootjs/jsx": "0.18.5",
65
+ "@barefootjs/jsx": "0.19.0",
66
66
  "typescript": "^5.0.0"
67
67
  }
68
68
  }
@@ -63,6 +63,23 @@ runAdapterConformanceTests({
63
63
  // #1467 Phase 2e: same `/* @client */` keyed-map elision (data-table).
64
64
  'data-table',
65
65
  ]),
66
+ skipDataPoints: new Set<string>([
67
+ // #2255 — Perl length() counts codepoints under utf8; JS counts
68
+ // UTF-16 code units, so a surrogate-pair character is 2 in JS, 1 here.
69
+ 'string-length-text:astral',
70
+ // #2260 — controlled boolean props: the SSR seed evaluates only the
71
+ // static fallback of `props.X ?? internal()` chains.
72
+ 'toggle:gen:pressed:true',
73
+ 'switch:gen:checked:true',
74
+ 'checkbox:gen:checked:true',
75
+ // #2261 — invalid dynamic CSS value kept (escaped) where the oracle
76
+ // drops the property.
77
+ 'style-object-dynamic:gen:color:markup',
78
+ // #2262 — dynamic `.flat` depth 0/negative violates the documented
79
+ // shallow-copy contract (shared with the Mojo Perl runtime).
80
+ 'array-flat-dynamic-depth:gen:depth:zero',
81
+ 'array-flat-dynamic-depth:gen:depth:negative',
82
+ ]),
66
83
  onRenderError: (err, id) => {
67
84
  if (err instanceof XslateNotAvailableError) {
68
85
  console.log(`Skipping [${id}]: ${err.message}`)
@@ -421,3 +438,122 @@ export function Parent() {
421
438
  // `filter-nested-find-predicate` (BF101 via `expectedDiagnostics` above) and
422
439
  // `filter-nested-callback-predicate-client` (the `/* @client */` suppression
423
440
  // twin, which must render clean).
441
+
442
+ // #2221: `_resolveLiteralConst` is a flat name lookup against
443
+ // `ir.metadata.localConstants` with no notion of AST scope — it used to
444
+ // substitute an outer const's literal value even at an occurrence that is
445
+ // actually an enclosing loop callback's own (shadowing) parameter, so every
446
+ // iteration rendered the same hard-coded literal. Guarded with the same
447
+ // coarse `collectLoopBoundNames` exclusion as #2212: any name a loop binds
448
+ // anywhere in the component never inlines, falling back to the bare
449
+ // identifier.
450
+ describe('XslateAdapter - const inlining vs loop-param shadowing (#2221)', () => {
451
+ test('a loop param shadowing an outer literal const emits the identifier, not the const value', () => {
452
+ const { template } = compileAndGenerate(`
453
+ function Widget() {
454
+ const label: string = 'x'
455
+ return <ul>{[2, 5].map((label) => <li key={label}>{1 + label}</li>)}</ul>
456
+ }
457
+ `)
458
+ // The loop body must reference the per-iteration loop var...
459
+ expect(template).toContain('1 + $label')
460
+ // ...never the outer const's hard-coded value.
461
+ expect(template).not.toContain("1 + 'x'")
462
+ })
463
+
464
+ test('a numeric const shadowed by a loop param emits the identifier too', () => {
465
+ const { template } = compileAndGenerate(`
466
+ function Widget() {
467
+ const count = 7
468
+ return <ul>{[2, 5].map((count) => <li key={count}>{1 + count}</li>)}</ul>
469
+ }
470
+ `)
471
+ expect(template).toContain('1 + $count')
472
+ expect(template).not.toContain('1 + 7')
473
+ })
474
+
475
+ test('a literal const NOT shadowed by any loop still inlines (#1897 pin)', () => {
476
+ const { template } = compileAndGenerate(`
477
+ function Widget({ values }: { values: number[] }) {
478
+ const totalPages = 5
479
+ return <div>
480
+ <p>Page 1 of {1 + totalPages}</p>
481
+ <ul>{values.map((v) => <li key={v}>{v}</li>)}</ul>
482
+ </div>
483
+ }
484
+ `)
485
+ expect(template).toContain('1 + 5')
486
+ })
487
+
488
+ // The accepted coarse-exclusion trade-off (same as #2212): a name that is
489
+ // loop-bound ANYWHERE in the component never inlines, even at a genuinely
490
+ // non-shadowed occurrence outside the loop — the bare identifier is
491
+ // emitted instead of the value.
492
+ test('a const referenced outside the loop whose name is loop-bound elsewhere falls back to the identifier (accepted trade-off)', () => {
493
+ const { template } = compileAndGenerate(`
494
+ function Widget({ values }: { values: number[] }) {
495
+ const label: string = 'x'
496
+ return <div>
497
+ <p>{1 + label}</p>
498
+ <ul>{values.map((label) => <li key={label}>{2 + label}</li>)}</ul>
499
+ </div>
500
+ }
501
+ `)
502
+ expect(template).not.toContain("1 + 'x'")
503
+ expect(template).toContain('2 + $label')
504
+ })
505
+ })
506
+
507
+ // #2237: `_resolveStaticRecordLiteral` (`IDENT.key` on a module-scope
508
+ // object-literal const, e.g. `variantClasses.ghost` — #1896/#1897) is a
509
+ // flat name lookup on `objectName` with no notion of AST scope, the
510
+ // record-literal sibling of #2221's `_resolveLiteralConst` bug. It used to
511
+ // substitute the outer const's member value even at an occurrence that is
512
+ // actually an enclosing loop callback's own (shadowing) parameter, so every
513
+ // iteration rendered the same hard-coded literal instead of the per-item
514
+ // value. Guarded with the same coarse `staticLoopSourceBoundNames`
515
+ // exclusion as #2221: any name a loop binds anywhere in the component
516
+ // never inlines, falling back to the bare `$cfg.x` member expression.
517
+ describe('XslateAdapter - record-literal member lookup vs loop-param shadowing (#2237)', () => {
518
+ test('a loop param shadowing an outer module object const emits the member access, not the outer literal', () => {
519
+ const { template } = compileAndGenerate(`
520
+ const cfg = { x: 'outer-lit' }
521
+ function Widget({ rows }: { rows: { x: string }[] }) {
522
+ return <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
523
+ }
524
+ `)
525
+ // The loop body must reference the per-iteration member access...
526
+ expect(template).toContain('<: $cfg.x :>')
527
+ // ...never the outer const's hard-coded value.
528
+ expect(template).not.toContain("<: 'outer-lit' :>")
529
+ })
530
+
531
+ test('a module object const NOT shadowed by any loop still inlines (variantClasses.ghost shape, #1896/#1897 pin)', () => {
532
+ const { template } = compileAndGenerate(`
533
+ const variantClasses = { solid: 'bg-solid', ghost: 'bg-ghost' }
534
+ function Widget({ variant }: { variant: 'solid' | 'ghost' }) {
535
+ return <div>{variantClasses.ghost}</div>
536
+ }
537
+ `)
538
+ expect(template).toContain("<: 'bg-ghost' :>")
539
+ })
540
+
541
+ // The accepted coarse-exclusion trade-off (same as #2221/#2212): an
542
+ // object name that is loop-bound ANYWHERE in the component never
543
+ // inlines its member lookups, even at a genuinely non-shadowed
544
+ // occurrence outside the loop — the bare member expression is emitted
545
+ // instead of the value.
546
+ test('a record member referenced outside the loop whose object name is loop-bound elsewhere falls back to the member expression (accepted trade-off)', () => {
547
+ const { template } = compileAndGenerate(`
548
+ const cfg = { x: 'outer-lit' }
549
+ function Widget({ rows }: { rows: { x: string }[] }) {
550
+ return <div>
551
+ <p>{cfg.x}</p>
552
+ <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
553
+ </div>
554
+ }
555
+ `)
556
+ expect(template).not.toContain("<: 'outer-lit' :>")
557
+ expect(template).toContain('<: $cfg.x :>')
558
+ })
559
+ })
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Serialize a compile-time-evaluated JS value (`@barefootjs/jsx`'s
3
+ * `evaluateStaticLiteral`/`resolveStaticLoopSource`, #2208) into a native
4
+ * Text::Xslate (Kolon) literal. Used to inline a fully-static loop source
5
+ * (an inline array literal, or a function-scope local const with a static
6
+ * initializer) directly in a `for EXPR -> $item { ... }` header, rather
7
+ * than requiring a bound template variable.
8
+ *
9
+ * Booleans deliberately return `null` (defer to the caller's BF101
10
+ * refusal) rather than baking a `1`/absent-value stand-in — Kolon has no
11
+ * native boolean literal in this position, and guessing one would diverge
12
+ * from JS's `String(true) === "true"` at render.
13
+ *
14
+ * Returns `null` for a value this adapter can't represent as a literal —
15
+ * the caller falls back to its existing BF101 refusal instead of guessing.
16
+ */
17
+
18
+ import { escapeKolonSingleQuoted, kolonHashKey } from './kolon-naming.ts'
19
+
20
+ export function staticValueToKolon(value: unknown): string | null {
21
+ if (value === null || value === undefined) return 'nil'
22
+ if (typeof value === 'boolean') return null
23
+ if (typeof value === 'number') return String(value)
24
+ if (typeof value === 'string') return `'${escapeKolonSingleQuoted(value)}'`
25
+ if (Array.isArray(value)) {
26
+ const items: string[] = []
27
+ for (const el of value) {
28
+ const serialized = staticValueToKolon(el)
29
+ if (serialized === null) return null
30
+ items.push(serialized)
31
+ }
32
+ return `[${items.join(', ')}]`
33
+ }
34
+ if (typeof value === 'object') {
35
+ const entries: string[] = []
36
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
37
+ const serialized = staticValueToKolon(val)
38
+ if (serialized === null) return null
39
+ entries.push(`${kolonHashKey(key)} => ${serialized}`)
40
+ }
41
+ return `{ ${entries.join(', ')} }`
42
+ }
43
+ return null
44
+ }
@@ -7,7 +7,7 @@
7
7
  * adapter's `props/prop-types.ts`. No adapter instance state.
8
8
  */
9
9
 
10
- import type { ComponentIR } from '@barefootjs/jsx'
10
+ import { collectLoopBoundNames, type ComponentIR } from '@barefootjs/jsx'
11
11
  import { isStringTypeInfo, isBareStringLiteral } from '../value/parsed-literal.ts'
12
12
 
13
13
  /**
@@ -25,8 +25,9 @@ export function collectBooleanTypedProps(ir: ComponentIR): Set<string> {
25
25
  }
26
26
 
27
27
  /**
28
- * Bare references to optional, no-default, non-primitive props (e.g.
29
- * textarea's `rows`) are `undef` when omitted → `defined`-guarded in
28
+ * Bare references to presence-uncertain no-default props (non-primitive
29
+ * typed OR declared optional, #2259 — e.g. textarea's `rows`) are
30
+ * `undef` when omitted → `defined`-guarded in
30
31
  * `emitExpression`. See the `nullableOptionalProps` field docstring.
31
32
  */
32
33
  export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
@@ -36,19 +37,37 @@ export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
36
37
  p =>
37
38
  p.defaultValue === undefined &&
38
39
  !p.isRest &&
39
- p.type?.kind !== 'primitive',
40
+ (p.type?.kind !== 'primitive' || p.optional),
40
41
  )
41
42
  .map(p => p.name),
42
43
  )
43
44
  }
44
45
 
45
46
  /**
46
- * String-typed signals and props. A signal is string-typed when its inferred
47
- * type is `string` (or, defensively, when its initial value is a bare string
48
- * literal); a prop when its annotated type is `string`. In the Mojo adapter
49
- * this drives `eq`/`ne` selection for string equality; the Kolon emitters
50
- * don't consume the distinction (Kolon's `==`/`!=` compare strings and numbers
51
- * correctly), so this set is carried for parity with the Mojo adapter.
47
+ * String-typed signals, props, and same-file local consts (#2212). A
48
+ * signal is string-typed when its inferred type is `string` (or,
49
+ * defensively, when its initial value is a bare string literal); a prop
50
+ * when its annotated type is `string`; a local const the same way. Consumed
51
+ * by `isStringConcatBinary`/`isStringTypedOperand` (`@barefootjs/jsx`) to
52
+ * pick Kolon's `~` over JS `+`'s numeric fallback (#2163, #2212)
53
+ * including now for a bare identifier operand, not just a prop/getter/
54
+ * literal. In the Mojo adapter this ALSO drives `eq`/`ne` selection for
55
+ * string equality; the Kolon emitters don't consume that distinction
56
+ * (Kolon's `==`/`!=` compare strings and numbers correctly), so that half
57
+ * of this set is carried only for parity with the Mojo adapter.
58
+ *
59
+ * Excludes any name bound as a `.map()`/`.filter()` loop callback's item
60
+ * or index parameter ANYWHERE in the component (Fable review, #2212): the
61
+ * lookup below is a flat, scope-blind `Set<string>` with no notion of a
62
+ * loop param shadowing an outer string-typed binding of the same name
63
+ * (`items.map((name) => 1 + name)` inside a component that also has a
64
+ * string `name` prop) — left unguarded, that shadowed `name` would be
65
+ * misdetected as string-typed and `1 + name` would silently lower to `~`
66
+ * instead of staying numeric `+`. Subtracting loop-bound names is coarse
67
+ * (it also suppresses a genuinely non-shadowed same-named string
68
+ * elsewhere in the component) but safe: the suppressed case just falls
69
+ * back to today's numeric `+` — the same, already-accepted residual as an
70
+ * unresolvable operand — never silently-wrong output.
52
71
  */
53
72
  export function collectStringValueNames(ir: ComponentIR): Set<string> {
54
73
  const names = new Set<string>()
@@ -60,5 +79,9 @@ export function collectStringValueNames(ir: ComponentIR): Set<string> {
60
79
  for (const p of ir.metadata.propsParams) {
61
80
  if (isStringTypeInfo(p.type)) names.add(p.name)
62
81
  }
82
+ for (const c of ir.metadata.localConstants) {
83
+ if (isStringTypeInfo(c.type ?? undefined) || isBareStringLiteral(c.value)) names.add(c.name)
84
+ }
85
+ for (const bound of collectLoopBoundNames(ir)) names.delete(bound)
63
86
  return names
64
87
  }