@barefootjs/xslate 0.31.4 → 0.31.5

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.
@@ -458,10 +458,11 @@ export function Parent() {
458
458
  // `ir.metadata.localConstants` with no notion of AST scope — it used to
459
459
  // substitute an outer const's literal value even at an occurrence that is
460
460
  // actually an enclosing loop callback's own (shadowing) parameter, so every
461
- // iteration rendered the same hard-coded literal. Guarded with the same
462
- // coarse `collectLoopBoundNames` exclusion as #2212: any name a loop binds
463
- // anywhere in the component never inlines, falling back to the bare
464
- // identifier.
461
+ // iteration rendered the same hard-coded literal. Guarded via the threaded,
462
+ // position-accurate `BindingScope` (#2482 Stage 2 previously a coarse
463
+ // whole-component exclusion, same as #2212's, that also suppressed a
464
+ // genuinely non-shadowed occurrence outside the loop; the threaded scope
465
+ // only shadows AT the enclosing loop's own body).
465
466
  describe('XslateAdapter - const inlining vs loop-param shadowing (#2221)', () => {
466
467
  test('a loop param shadowing an outer literal const emits the identifier, not the const value', () => {
467
468
  const { template } = compileAndGenerate(`
@@ -500,11 +501,11 @@ function Widget({ values }: { values: number[] }) {
500
501
  expect(template).toContain('1 + 5')
501
502
  })
502
503
 
503
- // The accepted coarse-exclusion trade-off (same as #2212): a name that is
504
- // loop-bound ANYWHERE in the component never inlines, even at a genuinely
505
- // non-shadowed occurrence outside the loop the bare identifier is
506
- // emitted instead of the value.
507
- test('a const referenced outside the loop whose name is loop-bound elsewhere falls back to the identifier (accepted trade-off)', () => {
504
+ // Position-accurate scope (#2482 Stage 2): a name that is loop-bound
505
+ // elsewhere in the component but NOT at THIS occurrence still inlines
506
+ // here the coarse whole-component exclusion this used to hit (and
507
+ // accept as a trade-off) is gone; only the loop's own body shadows.
508
+ test('a const referenced outside the loop whose name is loop-bound elsewhere still inlines outside, stays the identifier inside', () => {
508
509
  const { template } = compileAndGenerate(`
509
510
  function Widget({ values }: { values: number[] }) {
510
511
  const label: string = 'x'
@@ -514,7 +515,7 @@ function Widget({ values }: { values: number[] }) {
514
515
  </div>
515
516
  }
516
517
  `)
517
- expect(template).not.toContain("1 + 'x'")
518
+ expect(template).toContain("1 + 'x'")
518
519
  expect(template).toContain('2 + $label')
519
520
  })
520
521
  })
@@ -526,9 +527,8 @@ function Widget({ values }: { values: number[] }) {
526
527
  // substitute the outer const's member value even at an occurrence that is
527
528
  // actually an enclosing loop callback's own (shadowing) parameter, so every
528
529
  // iteration rendered the same hard-coded literal instead of the per-item
529
- // value. Guarded with the same coarse `staticLoopSourceBoundNames`
530
- // exclusion as #2221: any name a loop binds anywhere in the component
531
- // never inlines, falling back to the bare `$cfg.x` member expression.
530
+ // value. Guarded via the threaded, position-accurate `BindingScope` (#2482
531
+ // Stage 2), same as #2221's fix above.
532
532
  describe('XslateAdapter - record-literal member lookup vs loop-param shadowing (#2237)', () => {
533
533
  test('a loop param shadowing an outer module object const emits the member access, not the outer literal', () => {
534
534
  const { template } = compileAndGenerate(`
@@ -553,12 +553,12 @@ function Widget({ variant }: { variant: 'solid' | 'ghost' }) {
553
553
  expect(template).toContain("<: 'bg-ghost' :>")
554
554
  })
555
555
 
556
- // The accepted coarse-exclusion trade-off (same as #2221/#2212): an
557
- // object name that is loop-bound ANYWHERE in the component never
558
- // inlines its member lookups, even at a genuinely non-shadowed
559
- // occurrence outside the loop the bare member expression is emitted
560
- // instead of the value.
561
- test('a record member referenced outside the loop whose object name is loop-bound elsewhere falls back to the member expression (accepted trade-off)', () => {
556
+ // Position-accurate scope (#2482 Stage 2): an object name that is
557
+ // loop-bound elsewhere in the component but NOT at THIS occurrence still
558
+ // inlines its member lookup here the coarse whole-component exclusion
559
+ // this used to hit (and accept as a trade-off) is gone; only the loop's
560
+ // own body shadows.
561
+ test('a record member referenced outside the loop whose object name is loop-bound elsewhere still inlines outside, stays the member expression inside', () => {
562
562
  const { template } = compileAndGenerate(`
563
563
  const cfg = { x: 'outer-lit' }
564
564
  function Widget({ rows }: { rows: { x: string }[] }) {
@@ -568,11 +568,49 @@ function Widget({ rows }: { rows: { x: string }[] }) {
568
568
  </div>
569
569
  }
570
570
  `)
571
- expect(template).not.toContain("<: 'outer-lit' :>")
571
+ expect(template).toContain("<: 'outer-lit' :>")
572
572
  expect(template).toContain('<: $cfg.x :>')
573
573
  })
574
574
  })
575
575
 
576
+ // Copilot review on #2600 (#2482 Stage 2 follow-up): the position-accurate
577
+ // `this.scope` (replacing the old coarse `staticLoopSourceBoundNames` set)
578
+ // was only threaded around `renderChildren(loop.children)` in `renderLoop`
579
+ // — narrower than the coarse set's always-on coverage. Two row-context
580
+ // conversions sit OUTSIDE that window: a `.map()` preamble local's own
581
+ // initializer, and the whole-item-conditional `loop-i:` key anchor
582
+ // (`loop.key`). Both genuinely evaluate PER ROW and must see the row's own
583
+ // bindings — a loop param shadowing a same-named outer const must resolve
584
+ // to the row value there too, exactly as it already does inside the loop
585
+ // body proper. `renderLoop` now enters the row scope before converting
586
+ // either and pops it after, closing the gap.
587
+ describe('XslateAdapter - row scope covers preamble/key conversions outside renderChildren (#2482 Stage 2 Copilot follow-up)', () => {
588
+ test('a loop param shadowing an outer const resolves to the row value inside a whole-item-conditional loop-i: key anchor', () => {
589
+ const { template } = compileAndGenerate(`
590
+ function Widget({ values }: { values: number[] }) {
591
+ const label: string = 'x'
592
+ return <ul>{values.map((label) => (label > 0 ? <li key={label}>{label}</li> : null))}</ul>
593
+ }
594
+ `)
595
+ expect(template).toContain('$bf.comment("loop-i:" ~ $label)')
596
+ expect(template).not.toContain('$bf.comment("loop-i:" ~ \'x\')')
597
+ })
598
+
599
+ test('a loop param shadowing an outer const resolves to the row value inside a .map() preamble local initializer', () => {
600
+ const { template } = compileAndGenerate(`
601
+ function Widget({ values }: { values: number[] }) {
602
+ const label: string = 'x'
603
+ return <ul>{values.map((label) => {
604
+ const cls = label
605
+ return <li key={label} class={cls}>{label}</li>
606
+ })}</ul>
607
+ }
608
+ `)
609
+ expect(template).toContain(': my $cls = $label;')
610
+ expect(template).not.toContain(": my $cls = 'x';")
611
+ })
612
+ })
613
+
576
614
  describe('XslateAdapter - scriptAssets (Vite late-binding, PR1)', () => {
577
615
  const CLIENT_COMPONENT = `
578
616
  'use client'
@@ -78,8 +78,8 @@ import {
78
78
  dangerousInnerHtmlMetacharViolation,
79
79
  dangerousInnerHtmlDiagnostic,
80
80
  resolveStaticLoopSource,
81
- collectLoopBoundNames,
82
81
  derivesScopeFromSlot,
82
+ BindingScope,
83
83
  } from '@barefootjs/jsx'
84
84
  import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result.ts'
85
85
  import ts from 'typescript'
@@ -246,30 +246,24 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
246
246
  private localConstants: IRMetadata['localConstants'] = []
247
247
 
248
248
  /**
249
- * Every name a `.map()`/`.filter()` loop callback binds as its item/index
250
- * parameter anywhere in the component (#2208 fable review). A static
251
- * loop-SOURCE name (e.g. a function-scope `const items = [...]`) must
252
- * never resolve through `resolveStaticLoopSource` at a use site where a
253
- * DIFFERENT, enclosing loop's own callback param shadows it — same
254
- * shadowing hazard, and same coarse-but-safe mitigation, as #2212's
255
- * `collectLoopBoundNames` use in `collectStringValueNames`.
249
+ * The one canonical, position-accurate "names bound by an enclosing loop
250
+ * callback" service (#2482 Stage 2) replaces the two ad-hoc devices
251
+ * this adapter used to carry side by side: a coarse whole-component
252
+ * shadow-name Set (built once at `generate()` entry, used only to guard
253
+ * static-const inlining) and a ref-counted, position-accurate live map
254
+ * (pushed/popped around `renderLoop`'s body, used for the boolean-prop/
255
+ * nullable-optional classification sites (#2488) and `emitSpread`'s
256
+ * local-const fallback (#2489)). Both consulted the "is this name
257
+ * loop-bound" question with DIFFERENT meanings — the coarse/live drift
258
+ * #2482 Stage 2 ends. Every shadow-guard site now reads this ONE
259
+ * threaded, immutable scope: `enterLoopRow`/pop-by-reference around
260
+ * `renderChildren(loop.children)` in `renderLoop`, mirroring the Stage
261
+ * 1a/1b `ctx.scope` precedent in `jsx-to-ir.ts`. `IRLoop` already
262
+ * structurally satisfies `LoopBindingSource`
263
+ * (`param`/`index`/`paramBindings`/`preamble`), so `renderLoop` passes
264
+ * the loop node straight to `enterLoopRow`.
256
265
  */
257
- private staticLoopSourceBoundNames: Set<string> = new Set()
258
-
259
- /**
260
- * Names currently bound by an enclosing loop body — the block-param
261
- * locals `renderLoop` introduces (item, index, per-binding destructure
262
- * fields, `.map()` preamble declarations) — ref-counted so nested loops
263
- * compose. This is the POSITION-ACCURATE twin of the coarse
264
- * `staticLoopSourceBoundNames` above: that set is a whole-component
265
- * union used only to guard static-const inlining (safe to over-suppress
266
- * there — the fallback is just "don't inline"), whereas the boolean-prop
267
- * and nullable-optional classification sites (#2488) and `emitSpread`'s
268
- * local-const fallback (#2489) need to know whether a name is loop-bound
269
- * AT THIS POSITION, because for those the coarse fallback would silently
270
- * mis-render a genuine occurrence of the same name outside the loop too.
271
- */
272
- private loopBoundNames: Map<string, number> = new Map()
266
+ private scope: BindingScope = BindingScope.EMPTY
273
267
 
274
268
  /**
275
269
  * Optional, no-default props that are `undef` when the caller omits them.
@@ -303,8 +297,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
303
297
  // Per-compile prop classifications (see `props/prop-classes.ts`).
304
298
  this.booleanTypedProps = collectBooleanTypedProps(ir)
305
299
  this.localConstants = ir.metadata.localConstants ?? []
306
- this.staticLoopSourceBoundNames = collectLoopBoundNames(ir)
307
- this.loopBoundNames.clear()
300
+ this.scope = BindingScope.EMPTY
308
301
  this.nullableOptionalProps = collectNullableOptionalProps(ir)
309
302
  this.stringValueNames = collectStringValueNames(ir)
310
303
  this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
@@ -808,9 +801,11 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
808
801
  // bare identifier expression below, same as before #2208 — which
809
802
  // still trips the pre-existing BF101 gate for an unresolvable local
810
803
  // const reference (a loud, conservative refusal, not a silent wrong
811
- // value).
804
+ // value). Canonical, position-accurate predicate (#2482 Stage 2) — the
805
+ // enclosing loop scope's own membership, not a coarse whole-component
806
+ // union.
812
807
  const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
813
- isNameShadowed: name => this.staticLoopSourceBoundNames.has(name),
808
+ isNameShadowed: this.scope.asShadowPredicate(),
814
809
  })
815
810
  const staticArray = staticItems !== null ? staticValueToKolon(staticItems) : null
816
811
 
@@ -925,6 +920,34 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
925
920
  // loop-child naming convention). renderedChildren above was computed with
926
921
  // the previous flag; recompute under the loop flag.
927
922
 
923
+ // This loop's row scope, for the position-accurate shadow guard
924
+ // (#2488/#2489, canonicalized on `BindingScope` in #2482 Stage 2).
925
+ // `IRLoop` already structurally satisfies `LoopBindingSource`
926
+ // (`param`/`index`/`paramBindings`/`preamble`) — `enterLoopRow(loop)`
927
+ // binds exactly the for-header target(s), each destructure binding,
928
+ // the index (when present), and the `.map()` preamble's declared
929
+ // locals, mirroring what THIS renderLoop's for-header + `indexLocalLines`
930
+ // actually introduce. Restored by reference (immutable — no ref-count
931
+ // bookkeeping) so nested loops compose for free. Entered BEFORE
932
+ // `preambleLines`/`bodyChildren` are converted and popped AFTER, not
933
+ // just around `renderChildren` — Copilot review on #2600 caught that
934
+ // the narrower window silently regressed the module-const shadow guard
935
+ // (`_resolveLiteralConst`/`_resolveStaticRecordLiteral`, now
936
+ // scope-driven instead of the old coarse whole-component set) for two
937
+ // row-context conversions that sit OUTSIDE `renderChildren`: a
938
+ // `.map()` preamble local's own initializer (`d.raw`, below) and the
939
+ // whole-item-conditional `loop-i:` key anchor (`loop.key`, in
940
+ // `bodyChildren` below) — both genuinely evaluate PER ROW and must see
941
+ // the row's own bindings. `loop.filterPredicate` deliberately stays
942
+ // OUTSIDE this window (further down, after the pop): per the Stage 0
943
+ // design a filter/sort callback's own param is a separate `callback`
944
+ // frame, never folded into the `.map()` row — and empirically
945
+ // `XslateFilterEmitter` (the filter predicate's dedicated,
946
+ // self-contained emitter) never consults `this.scope` at all, so its
947
+ // position relative to the pop is inert either way.
948
+ const prevScope = this.scope
949
+ this.scope = prevScope.enterLoopRow(loop)
950
+
928
951
  // Per-row locals for a `.map()` callback preamble (#2447), in source
929
952
  // order so a later initializer sees an earlier local — same as the
930
953
  // source block. Phase 1 refuses the loop outright when the preamble
@@ -933,40 +956,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
933
956
  const preambleLines = (loop.preamble?.declarations ?? []).map(
934
957
  d => `: my $${d.name} = ${this.convertExpressionToKolon(d.raw, d.valueParsed)};`,
935
958
  )
936
- // Names this loop binds in body scope, for the position-accurate
937
- // `loopBoundNames` guard (#2488) — mirrors the ERB adapter's `loopBound`
938
- // derivation, adapted to what THIS renderLoop actually binds: the
939
- // for-header target(s) (`loopVar` / `objectIteration` key+value /
940
- // `iterationShape === 'keys'`'s `param`), each `indexLocalLines` name
941
- // (explicit index, or a destructure binding), and the `.map()`
942
- // preamble's declared locals. Ref-counted so nested loops compose.
943
- const loopBound: string[] = []
944
- if (loop.objectIteration === 'entries') {
945
- // `.kv()` binds the pair var the key/value locals are derived from (#2488).
946
- loopBound.push('__bf_pair', loop.index ?? param, param)
947
- } else if (loop.objectIteration === 'keys' || loop.objectIteration === 'values') {
948
- loopBound.push(param)
949
- } else if (loop.iterationShape === 'keys') {
950
- // The header still binds the throwaway loop var; `param` is only the
951
- // index alias derived from it beneath (#2488).
952
- loopBound.push('__bf_item', param)
953
- } else if (supportableDestructure) {
954
- loopBound.push('__bf_item', ...(loop.paramBindings ?? []).map(b => b.name))
955
- if (loop.index) loopBound.push(loop.index)
956
- } else {
957
- loopBound.push(param)
958
- if (loop.index) loopBound.push(loop.index)
959
- }
960
- for (const d of loop.preamble?.declarations ?? []) loopBound.push(d.name)
961
- for (const n of loopBound) {
962
- this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
963
- }
964
959
  const childrenUnderLoop = this.renderChildren(loop.children)
965
- for (const n of loopBound) {
966
- const c = (this.loopBoundNames.get(n) ?? 1) - 1
967
- if (c <= 0) this.loopBoundNames.delete(n)
968
- else this.loopBoundNames.set(n, c)
969
- }
970
960
  this.currentLoopKeyDepth = prevLoopKeyDepth
971
961
  this.inLoop = prevInLoop
972
962
  void renderedChildren
@@ -974,11 +964,13 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
974
964
  // Whole-item conditional: prepend an always-present `<!--bf-loop-i:KEY-->`
975
965
  // anchor before each item's (possibly empty) conditional content so the
976
966
  // client's `mapArrayAnchored` can hydrate every SSR-rendered item by its
977
- // anchor.
967
+ // anchor. Still under the row scope (see above) — `loop.key` is a
968
+ // per-row expression.
978
969
  const bodyChildren =
979
970
  loop.bodyIsItemConditional && loop.key
980
971
  ? `<: $bf.comment("loop-i:" ~ ${this.convertExpressionToKolon(loop.key)}) | mark_raw :>\n${childrenUnderLoop}`
981
972
  : childrenUnderLoop
973
+ this.scope = prevScope
982
974
 
983
975
  const lines: string[] = []
984
976
  // Scoped per-call-site marker so sibling `.map()`s under the same parent
@@ -1441,13 +1433,13 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1441
1433
  // function-scope (`!isModule`) consts whose value is NOT itself a bare
1442
1434
  // identifier (loop guard) are considered.
1443
1435
  //
1444
- // `loopBoundNames` guard (#2489): an enclosing `.map()` callback's own
1445
- // param can shadow this outer const's name (`.map((attrs) => <p
1436
+ // `this.scope` shadow guard (#2489): an enclosing `.map()` callback's
1437
+ // own param can shadow this outer const's name (`.map((attrs) => <p
1446
1438
  // {...attrs} />)`) — without the guard this forwarded the OUTER
1447
1439
  // const's value at every iteration instead of the per-item value.
1448
- // Must be the LIVE map, not `staticLoopSourceBoundNames` — the same
1449
- // name spread at ROOT must still resolve the const.
1450
- if (/^[A-Za-z_$][\w$]*$/.test(trimmed) && !this.loopBoundNames.has(trimmed)) {
1440
+ // Must be the threaded, position-accurate scope — the same name
1441
+ // spread at ROOT (no enclosing loop) must still resolve the const.
1442
+ if (/^[A-Za-z_$][\w$]*$/.test(trimmed) && !this.scope.isBound(trimmed)) {
1451
1443
  const localConst = (this.localConstants ?? []).find(
1452
1444
  c => c.name === trimmed && !c.isModule,
1453
1445
  )
@@ -1845,9 +1837,9 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1845
1837
  return this.booleanTypedProps.has(bare)
1846
1838
  }
1847
1839
 
1848
- /** Position-accurate loop-bound-name check — see `loopBoundNames`'s docstring. */
1840
+ /** Position-accurate loop-bound-name check — see `this.scope`'s docstring. */
1849
1841
  private isLoopBoundName(name: string): boolean {
1850
- return this.loopBoundNames.has(name)
1842
+ return this.scope.isBound(name)
1851
1843
  }
1852
1844
 
1853
1845
  /**
@@ -1857,17 +1849,16 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1857
1849
  * stash, so a bare `$totalPages` renders empty.
1858
1850
  *
1859
1851
  * The lookup is a flat name match with no notion of AST scope, so a
1860
- * name that any loop callback binds as its item/index param never
1861
- * inlines (#2221) — the occurrence may be the loop's own (shadowing)
1862
- * binding, and substituting the outer const's value there renders every
1863
- * iteration with the same hard-coded literal. Coarse (a genuinely
1864
- * non-shadowed same-named const elsewhere in the component also stops
1865
- * inlining, falling back to the bare identifier) but safe — the same
1866
- * trade-off as #2212's `collectLoopBoundNames` use in
1867
- * `collectStringValueNames`.
1852
+ * name bound by the CURRENTLY ENCLOSING loop's item/index/destructure/
1853
+ * preamble param never inlines (#2221) — the occurrence may be the
1854
+ * loop's own (shadowing) binding, and substituting the outer const's
1855
+ * value there renders every iteration with the same hard-coded literal.
1856
+ * Position-accurate via the threaded `this.scope` (#2482 Stage 2) — a
1857
+ * same-named const elsewhere in the component, outside any loop that
1858
+ * shadows it, still inlines.
1868
1859
  */
1869
1860
  private _resolveLiteralConst(name: string): string | null {
1870
- if (this.staticLoopSourceBoundNames.has(name)) return null
1861
+ if (this.scope.isBound(name)) return null
1871
1862
  const c = (this.localConstants ?? []).find(lc => lc.name === name)
1872
1863
  if (c?.value === undefined) return null
1873
1864
  const v = c.value.trim()
@@ -1885,15 +1876,16 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1885
1876
  * scope, so an enclosing loop callback's own param of the same name
1886
1877
  * (`.map((cfg) => <li>{cfg.x}</li>)` shadowing a module `const cfg = {…}`)
1887
1878
  * still resolved to the OUTER const's member value at every iteration
1888
- * (#2237) — the sibling hazard to #2221's `_resolveLiteralConst`. Same
1889
- * coarse-but-safe `staticLoopSourceBoundNames` guard: any name a loop
1890
- * binds anywhere in the component never inlines, falling back to the bare
1891
- * `$cfg.x` member expression (which an Xslate `: for` loop binds
1892
- * correctly at the shadowed occurrences).
1879
+ * (#2237) — the sibling hazard to #2221's `_resolveLiteralConst`.
1880
+ * Position-accurate via the threaded `this.scope` (#2482 Stage 2), passed
1881
+ * as `lookupStaticRecordLiteral`'s required guard: a name the CURRENTLY
1882
+ * ENCLOSING loop binds never inlines, falling back to the bare `$cfg.x`
1883
+ * member expression (which an Xslate `: for` loop binds correctly at the
1884
+ * shadowed occurrence); a same-named const elsewhere, outside any loop
1885
+ * that shadows it, still inlines.
1893
1886
  */
1894
1887
  private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
1895
- if (this.staticLoopSourceBoundNames.has(objectName)) return null
1896
- const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
1888
+ const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants, name => this.scope.isBound(name))
1897
1889
  if (!hit) return null
1898
1890
  return hit.kind === 'number'
1899
1891
  ? hit.text