@barefootjs/mojolicious 0.17.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,10 +2,10 @@
2
2
  import {
3
3
  BaseAdapter,
4
4
  isBooleanAttr,
5
- parseExpression as parseExpression3,
5
+ parseExpression as parseExpression2,
6
6
  stringifyParsedExpr as stringifyParsedExpr2,
7
7
  parseStyleObjectEntries,
8
- isSupported as isSupported2,
8
+ isSupported,
9
9
  exprToString,
10
10
  parseProviderObjectLiteral,
11
11
  emitParsedExpr as emitParsedExpr2,
@@ -128,13 +128,6 @@ function collectRootScopeNodes(node) {
128
128
  visit(node);
129
129
  return out;
130
130
  }
131
- function referencedVarsAreAvailable(expr, available) {
132
- for (const m of expr.matchAll(/\$([A-Za-z_]\w*)/g)) {
133
- if (!available.has(m[1]))
134
- return false;
135
- }
136
- return true;
137
- }
138
131
 
139
132
  // src/adapter/expr/array-method.ts
140
133
  import {
@@ -307,6 +300,13 @@ function renderFlatMapEval(recv, body, param, emit) {
307
300
  const env = emitEvalEnvArg(body, [param], emit);
308
301
  return `bf->flat_map_eval(${recv}, '${escapePerlSingleQuote(json)}', '${param}', ${env})`;
309
302
  }
303
+ function renderMapEval(recv, body, param, emit) {
304
+ const json = serializeParsedExpr(body);
305
+ if (json === null)
306
+ return null;
307
+ const env = emitEvalEnvArg(body, [param], emit);
308
+ return `bf->map_eval(${recv}, '${escapePerlSingleQuote(json)}', '${param}', ${env})`;
309
+ }
310
310
  function renderSortMethod(recv, c) {
311
311
  const keyHashes = c.keys.map((k) => {
312
312
  const keyEntry = k.key.kind === "self" ? `key_kind => 'self'` : `key_kind => 'field', key => '${k.key.field}'`;
@@ -360,10 +360,12 @@ class MojoFilterEmitter {
360
360
  param;
361
361
  localVarMap;
362
362
  isStringName;
363
- constructor(param, localVarMap, isStringName = () => false) {
363
+ onUnsupported;
364
+ constructor(param, localVarMap, isStringName = () => false, onUnsupported) {
364
365
  this.param = param;
365
366
  this.localVarMap = localVarMap;
366
367
  this.isStringName = isStringName;
368
+ this.onUnsupported = onUnsupported;
367
369
  }
368
370
  identifier(name) {
369
371
  if (name === this.param)
@@ -442,12 +444,14 @@ class MojoFilterEmitter {
442
444
  return `(${l} // ${r})`;
443
445
  }
444
446
  callbackMethod(method, object, arrow, _restArgs, emit) {
445
- if (!PREDICATE_METHODS.has(method))
447
+ if (!PREDICATE_METHODS.has(method)) {
448
+ this.onUnsupported?.(`Filter predicate contains a nested '.${method}(...)' callback, which has no Perl scalar form`, `Rewrite the predicate without a nested callback method, or add /* @client */ for client-only evaluation (no SSR).`);
446
449
  return "1";
450
+ }
447
451
  const param = arrow.params[0];
448
452
  const predicate = arrow.body;
449
453
  const arrayExpr = emit(object);
450
- const predBody = emitParsedExpr(predicate, new MojoFilterEmitter(param, this.localVarMap, this.isStringName));
454
+ const predBody = emitParsedExpr(predicate, new MojoFilterEmitter(param, this.localVarMap, this.isStringName, this.onUnsupported));
451
455
  const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, "g"), "$_");
452
456
  if (method === "filter")
453
457
  return `[grep { ${grepBody} } @{${arrayExpr}}]`;
@@ -455,6 +459,7 @@ class MojoFilterEmitter {
455
459
  return `!(grep { !(${grepBody}) } @{${arrayExpr}})`;
456
460
  if (method === "some")
457
461
  return `!!(grep { ${grepBody} } @{${arrayExpr}})`;
462
+ this.onUnsupported?.(`Filter predicate contains a nested '.${method}(...)' callback, which has no Perl scalar form`, `Rewrite the predicate without a nested callback method, or add /* @client */ for client-only evaluation (no SSR).`);
458
463
  return arrayExpr;
459
464
  }
460
465
  arrayLiteral(elements, emit) {
@@ -620,6 +625,13 @@ class MojoTopLevelEmitter {
620
625
  this.ctx._recordExprBF101(`.flatMap(...) projection is not lowerable to a template flat-map`, `Pre-compute the projection in the route handler, or mark the loop @client-only.`);
621
626
  return "''";
622
627
  }
628
+ if (method === "map") {
629
+ const evalForm = renderMapEval(recv, body, params[0], emit);
630
+ if (evalForm !== null)
631
+ return evalForm;
632
+ this.ctx._recordExprBF101(`.map(...) projection is not lowerable to a template map`, `Pre-compute the projection in the route handler, or mark the position @client-only.`);
633
+ return "''";
634
+ }
623
635
  const cb = {
624
636
  method,
625
637
  object,
@@ -863,9 +875,7 @@ function recordIndexAccessToPerl(ctx, val) {
863
875
  // src/adapter/memo/seed.ts
864
876
  import {
865
877
  collectContextConsumers,
866
- extractArrowBodyExpression,
867
- isSupported,
868
- parseExpression as parseExpression2
878
+ computeSsrSeedPlan
869
879
  } from "@barefootjs/jsx";
870
880
  function contextDefaultPerl(c) {
871
881
  const d = c.defaultValue;
@@ -886,42 +896,20 @@ function generateContextConsumerSeed(ir) {
886
896
  `;
887
897
  }
888
898
  function generateDerivedMemoSeed(ctx, ir) {
889
- const memos = ir.metadata.memos ?? [];
890
- const signals = ir.metadata.signals ?? [];
891
- if (memos.length === 0 && signals.length === 0)
892
- return "";
893
- const available = new Set(ir.metadata.propsParams.map((p) => p.name));
899
+ const plan = ir.metadata.ssrSeedPlan ?? computeSsrSeedPlan(ir.metadata);
894
900
  const lines = [];
895
- for (const signal of signals) {
896
- const perl = tryLowerToPerl(ctx, signal.initialValue, available);
897
- if (perl !== null)
898
- lines.push(`% my $${signal.getter} = ${perl};`);
899
- available.add(signal.getter);
900
- }
901
- for (const memo of memos) {
902
- const body = extractArrowBodyExpression(memo.computation);
903
- if (body !== null) {
904
- const perl = tryLowerToPerl(ctx, body, available);
905
- if (perl !== null)
906
- lines.push(`% my $${memo.name} = ${perl};`);
907
- }
908
- available.add(memo.name);
901
+ for (const step of plan.steps) {
902
+ if (step.kind !== "derived")
903
+ continue;
904
+ const perl = ctx.convertExpressionToPerl(step.expr, step.parsed);
905
+ if (perl === "" || !/\$[A-Za-z_]\w*/.test(perl))
906
+ continue;
907
+ lines.push(`% my $${step.name} = ${perl};`);
909
908
  }
910
909
  return lines.length > 0 ? lines.join(`
911
910
  `) + `
912
911
  ` : "";
913
912
  }
914
- function tryLowerToPerl(ctx, expr, available) {
915
- const trimmed = expr.trim();
916
- if (!trimmed)
917
- return null;
918
- if (!isSupported(parseExpression2(trimmed)).supported)
919
- return null;
920
- const perl = ctx.convertExpressionToPerl(trimmed);
921
- if (perl === "" || !/\$[A-Za-z_]\w*/.test(perl))
922
- return null;
923
- return referencedVarsAreAvailable(perl, available) ? perl : null;
924
- }
925
913
 
926
914
  // src/adapter/value/parsed-literal.ts
927
915
  function isStringTypeInfo(type) {
@@ -1043,7 +1031,7 @@ class MojoAdapter extends BaseAdapter {
1043
1031
  return this.booleanTypedProps.has(bare);
1044
1032
  }
1045
1033
  parseUndefinedAlternateTernary(expr) {
1046
- const parsed = parseExpression3(expr.trim());
1034
+ const parsed = parseExpression2(expr.trim());
1047
1035
  if (parsed?.kind !== "conditional")
1048
1036
  return null;
1049
1037
  const alt = parsed.alternate;
@@ -1291,8 +1279,9 @@ ${whenTrue}
1291
1279
  return `<%== bf->comment("cond-start:${condId}") %>${content}<%== bf->comment("cond-end:${condId}") %>`;
1292
1280
  }
1293
1281
  renderLoop(loop) {
1294
- if (loop.clientOnly)
1295
- return "";
1282
+ if (loop.clientOnly) {
1283
+ return `<%== bf->comment("loop:${loop.markerId}") %><%== bf->comment("/loop:${loop.markerId}") %>`;
1284
+ }
1296
1285
  const destructure = !!(loop.paramBindings && loop.paramBindings.length > 0);
1297
1286
  const supportableDestructure = destructure && isLowerableObjectRestDestructure(loop);
1298
1287
  if (destructure && !supportableDestructure) {
@@ -1547,7 +1536,7 @@ ${children}`;
1547
1536
  if (localConst?.value !== undefined) {
1548
1537
  const initTrimmed = localConst.value.trim();
1549
1538
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(initTrimmed)) {
1550
- const resolved = conditionalSpreadToPerl(this.spreadCtx, parseExpression3(initTrimmed));
1539
+ const resolved = conditionalSpreadToPerl(this.spreadCtx, parseExpression2(initTrimmed));
1551
1540
  if (resolved !== null) {
1552
1541
  return `<%== bf->spread_attrs(${resolved}) %>`;
1553
1542
  }
@@ -1565,7 +1554,7 @@ ${children}`;
1565
1554
  if (!entries)
1566
1555
  return null;
1567
1556
  for (const e of entries) {
1568
- if (e.kind === "expr" && !isSupported2(parseExpression3(e.expr)).supported)
1557
+ if (e.kind === "expr" && !isSupported(parseExpression2(e.expr)).supported)
1569
1558
  return null;
1570
1559
  }
1571
1560
  return entries.map((e) => e.kind === "literal" ? `${this.escapeAttrText(e.cssKey)}:${this.escapeAttrText(e.value)}` : `${this.escapeAttrText(e.cssKey)}:<%= ${this.convertExpressionToPerl(e.expr)} %>`).join(";");
@@ -1601,7 +1590,7 @@ ${children}`;
1601
1590
  return `${BF_COND}="${condId}"`;
1602
1591
  }
1603
1592
  renderPerlFilterExpr(expr, param, localVarMap = new Map) {
1604
- return emitParsedExpr2(expr, new MojoFilterEmitter(param, localVarMap, (n) => this._isStringValueName(n)));
1593
+ return emitParsedExpr2(expr, new MojoFilterEmitter(param, localVarMap, (n) => this._isStringValueName(n), (message, reason) => this._recordExprBF101(message, reason)));
1605
1594
  }
1606
1595
  convertTemplateLiteralPartsToPerl(literalParts) {
1607
1596
  const parts = [];
@@ -1646,8 +1635,8 @@ ${children}`;
1646
1635
  const hasTaggedTemplate = /[A-Za-z_$][\w$]*\s*`/.test(probe);
1647
1636
  if (!startsAsObjectLiteral && !hasTaggedTemplate)
1648
1637
  return false;
1649
- const parsed = parseExpression3(expr.trim());
1650
- const support = isSupported2(parsed);
1638
+ const parsed = parseExpression2(expr.trim());
1639
+ const support = isSupported(parsed);
1651
1640
  if (parsed.kind !== "unsupported" && support.supported)
1652
1641
  return false;
1653
1642
  const reason = support.reason ?? (parsed.kind === "unsupported" ? parsed.reason : undefined);
@@ -1695,7 +1684,7 @@ ${reason}` : "";
1695
1684
  const trimmed = expr.trim();
1696
1685
  if (trimmed === "")
1697
1686
  return "''";
1698
- parsed = parseExpression3(trimmed);
1687
+ parsed = parseExpression2(trimmed);
1699
1688
  }
1700
1689
  if (parsed.kind === "call") {
1701
1690
  for (const matcher of this._loweringMatchers) {
@@ -1706,7 +1695,7 @@ ${reason}` : "";
1706
1695
  }
1707
1696
  }
1708
1697
  }
1709
- const support = isSupported2(parsed);
1698
+ const support = isSupported(parsed);
1710
1699
  if (!support.supported) {
1711
1700
  this.errors.push({
1712
1701
  code: "BF101",
@@ -1,5 +1,5 @@
1
1
  package BarefootJS::Backend::Mojo;
2
- our $VERSION = "0.16.0";
2
+ our $VERSION = "0.17.0";
3
3
  use Mojo::Base -base, -signatures;
4
4
 
5
5
  use Mojo::ByteStream qw(b);
@@ -1,5 +1,5 @@
1
1
  package Mojolicious::Plugin::BarefootJS::DevReload;
2
- our $VERSION = "0.16.0";
2
+ our $VERSION = "0.17.0";
3
3
  use Mojo::Base 'Mojolicious::Plugin', -signatures;
4
4
 
5
5
  =head1 NAME
@@ -1,5 +1,5 @@
1
1
  package Mojolicious::Plugin::BarefootJS;
2
- our $VERSION = "0.16.0";
2
+ our $VERSION = "0.17.0";
3
3
  use Mojo::Base 'Mojolicious::Plugin', -signatures;
4
4
 
5
5
  use Mojo::File qw(path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/mojolicious",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Mojolicious EP template adapter for BarefootJS - generates .html.ep files from IR",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -52,7 +52,7 @@
52
52
  "directory": "packages/adapter-mojolicious"
53
53
  },
54
54
  "dependencies": {
55
- "@barefootjs/shared": "0.17.0"
55
+ "@barefootjs/shared": "0.17.1"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@barefootjs/jsx": ">=0.2.0",
@@ -60,6 +60,6 @@
60
60
  },
61
61
  "devDependencies": {
62
62
  "@barefootjs/adapter-tests": "0.1.0",
63
- "@barefootjs/jsx": "0.17.0"
63
+ "@barefootjs/jsx": "0.17.1"
64
64
  }
65
65
  }
@@ -65,6 +65,14 @@ runAdapterConformanceTests({
65
65
  // (`cn\`base \${tone()}\``) — same family as #1322 above and refused
66
66
  // via the same gate.
67
67
  'tagged-template-classname': [{ code: 'BF101', severity: 'error' }],
68
+ // #2038: a filter predicate containing a nested `.find(...)` callback.
69
+ // `find*` returns an element, not a boolean — there is no inline grep
70
+ // form, and the emitter used to degrade the call to its receiver.
71
+ // The nested `.some` sibling (`filter-nested-callback-predicate`) is
72
+ // NOT pinned: Mojo lowers it to a real inline Perl `grep` and must
73
+ // render to Hono parity instead.
74
+ // https://github.com/piconic-ai/barefootjs/issues/2038
75
+ 'filter-nested-find-predicate': [{ code: 'BF101', severity: 'error' }],
68
76
  // #1467 demo-corpus context providers (`radio-group`, `accordion`,
69
77
  // `dialog`, `popover`, `select`, `dropdown-menu`, `combobox`,
70
78
  // `command`) are no longer pinned — an object-literal provider value
@@ -126,6 +134,11 @@ runAdapterConformanceTests({
126
134
  // runtime `bf->find` / `find_index` / `find_last` / `find_last_index` helpers
127
135
  // (per-element coderef predicate), matching Xslate. `.join` was never
128
136
  // pinned (handled by `renderArrayMethod`'s `case 'join'`).
137
+ // #2073 follow-up: a function-reference `.map(format)` callback has no
138
+ // arrow body to serialize — not a CALLBACK_METHODS shape — so the
139
+ // UNSUPPORTED_METHODS gate refuses it with BF101 rather than emitting
140
+ // a broken template.
141
+ 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
129
142
  },
130
143
  // `JSON_STRINGIFY_VIA_CONST` and `MATH_FLOOR_VIA_CONST` now pass
131
144
  // via `MojoAdapter.templatePrimitives` (#1189). The two remaining
@@ -143,16 +156,12 @@ runAdapterConformanceTests({
143
156
  TemplatePrimitiveCaseId.USER_IMPORT_VIA_CONST,
144
157
  TemplatePrimitiveCaseId.NO_DOUBLE_REWRITE_OF_PROPS_OBJECT,
145
158
  ]),
146
- // Mojo `renderLoop` does not yet emit the `bf->comment("loop:<id>")`
147
- // boundary markers when the loop is `@client` (Hono and Go both do).
148
- // The client runtime relies on these markers to locate the insertion
149
- // anchor when hydrating the array; without them, mapArray() resolves
150
- // anchor = null and appends after sibling markers (#872 parity).
151
- // Tracked as a follow-up; remove from this set when Mojo emits the
152
- // boundary pair for clientOnly loops too.
159
+ // `client-only` / `client-only-loop-with-sibling-cond` /
160
+ // `filter-nested-callback-predicate-client` are no longer skipped
161
+ // `renderLoop` now emits the `bf->comment("loop:<id>")` boundary pair
162
+ // for clientOnly loops (Hono / Go parity), so mapArray() can locate
163
+ // its insertion anchor at hydration time (#872 / #1087).
153
164
  skipMarkerConformance: new Set([
154
- 'client-only',
155
- 'client-only-loop-with-sibling-cond',
156
165
  // Same as Hono: `/* @client */` markers on TodoApp's keyed `.map`
157
166
  // intentionally elide a slot id from the SSR template that the IR
158
167
  // still declares (s6). See hono-adapter.test for the contract.
@@ -792,6 +801,31 @@ export function C() {
792
801
  expect(template).toContain('scalar(@{[grep { $_->{active} } @{$t->{tags}}]})')
793
802
  })
794
803
 
804
+ test('lowers nested .some(...) in filter predicate to an inline grep — no BF101 (#2038)', () => {
805
+ // The evaluator refuses the nested arrow (`serializeParsedExpr` → null),
806
+ // but the Perl filter emitter has a FAITHFUL form for nested
807
+ // filter / every / some: a real inline `grep` closing over the outer
808
+ // loop var. Pin the emitted EP shape positively so the #2038 loudness
809
+ // fix (which targets the degrade-only arms: nested `find*`,
810
+ // sort / reduce / flatMap — see the `filter-nested-find-predicate`
811
+ // expectedDiagnostics entry) never over-reaches into this supported
812
+ // shape. The rendered-HTML side of this contract lives in the shared
813
+ // `filter-nested-callback-predicate` fixture (Hono-parity render).
814
+ const adapter = new MojoAdapter()
815
+ const result = compileJSX(`'use client'
816
+ import { createSignal } from '@barefootjs/client'
817
+ type Item = { id: number }
818
+ export function Picker() {
819
+ const [items] = createSignal<Item[]>([])
820
+ const [picked] = createSignal<Item[]>([])
821
+ return <ul>{items().filter(t => !picked().some(p => p.id === t.id)).map(t => <li key={t.id}>{t.id}</li>)}</ul>
822
+ }`, 'C.tsx', { adapter })
823
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
824
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
825
+ expect(template).toContain('grep')
826
+ expect(template).toContain('@{$picked}')
827
+ })
828
+
795
829
  test('lowers .filter(function (x) { return x.done }).map(...) — function-keyword filter (#1443)', () => {
796
830
  // Function expressions with a single `return <expr>` body normalise
797
831
  // to the arrow-fn IR shape at parse time, so the higher-order
@@ -1035,11 +1069,26 @@ export { A }`, 'A.tsx', { adapter })
1035
1069
  expect(template).toContain('"property":"done"')
1036
1070
  }
1037
1071
 
1038
- // Fallback: a method-call predicate (`x => x.name.includes('a')`) is
1039
- // outside the evaluator surface, so `.every` keeps the inline grep form.
1072
+ // #2075: `.includes(x)` is now in the evaluator surface (`array-method`
1073
+ // gate, shared with the Perl `Evaluator.pm` runtime), so a method-call
1074
+ // predicate built from it ALSO routes through `every_eval` rather than
1075
+ // falling back — it's no longer the "unsupported method call" example.
1076
+ const includesAdapter = new MojoAdapter()
1077
+ const includesResult = compileJSX(`function A({ items }: { items: { name: string }[] }) {
1078
+ return <div>{items.every(x => x.name.includes('a')) ? 'y' : 'n'}</div>
1079
+ }
1080
+ export { A }`, 'A.tsx', { adapter: includesAdapter })
1081
+ const includesTemplate = includesResult.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
1082
+ expect(includesTemplate).toContain('bf->every_eval($items,')
1083
+ expect(includesTemplate).toContain('"method":"includes"')
1084
+ expect(includesTemplate).not.toContain('grep {')
1085
+
1086
+ // Fallback: a method-call predicate the evaluator still can't model
1087
+ // (`.toUpperCase()` is outside the `array-method` gate — only `includes`
1088
+ // is recognized there) keeps the inline grep form.
1040
1089
  const adapter = new MojoAdapter()
1041
1090
  const fb = compileJSX(`function A({ items }: { items: { name: string }[] }) {
1042
- return <div>{items.every(x => x.name.includes('a')) ? 'y' : 'n'}</div>
1091
+ return <div>{items.every(x => x.name.toUpperCase() === 'A') ? 'y' : 'n'}</div>
1043
1092
  }
1044
1093
  export { A }`, 'A.tsx', { adapter })
1045
1094
  const fbTemplate = fb.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
@@ -1488,6 +1537,110 @@ export { C }
1488
1537
  })
1489
1538
  })
1490
1539
 
1540
+ describe('MojoAdapter - #2075 searchParams()-derived memo seeding', () => {
1541
+ // A memo derived from the createSearchParams() env signal must seed
1542
+ // in-template from the canonical per-request `$searchParams` reader —
1543
+ // including under a local alias (`const [sp] = …`), which the expression
1544
+ // lowering canonicalises.
1545
+ test('seeds an aliased scalar derived memo from the canonical reader', () => {
1546
+ const { template } = compileAndGenerate(`
1547
+ 'use client'
1548
+ import { createMemo, createSearchParams } from '@barefootjs/client'
1549
+ export function SortStatus() {
1550
+ const [sp] = createSearchParams()
1551
+ const sort = createMemo(() => sp().get('sort') ?? 'date')
1552
+ return <p>sort: {sort()}</p>
1553
+ }
1554
+ `)
1555
+ expect(template).toContain("% my $sort = ($searchParams->get('sort') // 'date');")
1556
+ })
1557
+
1558
+ // A list-filter memo chained off the derived memo seeds too: the inline
1559
+ // grep's `$_` topic and the callback param are lowering-internal bindings,
1560
+ // not out-of-scope template vars (the pre-#2075 availability check
1561
+ // rejected them and the list rendered empty at SSR).
1562
+ test('seeds a filter memo chained off the derived memo', () => {
1563
+ const { template } = compileAndGenerate(`
1564
+ 'use client'
1565
+ import { createMemo, createSearchParams } from '@barefootjs/client'
1566
+ export function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
1567
+ const [searchParams] = createSearchParams()
1568
+ const tag = createMemo(() => searchParams().get('tag') ?? '')
1569
+ const visible = createMemo(() => props.items.filter((p) => !tag() || p.tags.includes(tag())))
1570
+ return <ul>{visible().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
1571
+ }
1572
+ `)
1573
+ expect(template).toContain("% my $tag = ($searchParams->get('tag') // '');")
1574
+ expect(template).toMatch(/% my \$visible = \[grep/)
1575
+ })
1576
+
1577
+ // The seed-scope guard used to scan the LOWERED
1578
+ // Perl string, allowing every arrow-callback param tree-wide. That let an
1579
+ // outer, unbound `p` (shadowed only inside the callback) slip past the
1580
+ // guard as if it were the callback's own bound `$p` — emitting a bogus
1581
+ // seed line that would crash Perl strict mode. The guard now walks the
1582
+ // parsed SOURCE tree with proper lexical scoping (`freeIdentifiers`), so
1583
+ // this shape seeds nothing and falls back to the null/ssr-defaults path.
1584
+ test('an outer unbound `p` shadowed only inside the callback does not seed', () => {
1585
+ const { template } = compileAndGenerate(`
1586
+ 'use client'
1587
+ import { createMemo } from '@barefootjs/client'
1588
+ export function C(props: { items: { ok: boolean }[] }) {
1589
+ const visible = createMemo(() => props.items.filter((p) => p.ok) && p)
1590
+ return <div>{String(visible())}</div>
1591
+ }
1592
+ `)
1593
+ expect(template).not.toContain('my $visible')
1594
+ })
1595
+
1596
+ // An out-of-scope bare `_` reference (not the `grep` topic var of an
1597
+ // in-scope higher-order lowering) must not seed either — the old
1598
+ // unconditional `allowed.add('_')` masked this.
1599
+ test('an out-of-scope bare `_` reference does not seed', () => {
1600
+ const { template } = compileAndGenerate(`
1601
+ 'use client'
1602
+ import { createMemo } from '@barefootjs/client'
1603
+ export function C(props: { count: number }) {
1604
+ const doubled = createMemo(() => props.count * 2 + _)
1605
+ return <div>{doubled()}</div>
1606
+ }
1607
+ `)
1608
+ expect(template).not.toContain('my $doubled')
1609
+ })
1610
+ })
1611
+
1612
+ describe('MojoAdapter - #2073 value-producing .map(cb)', () => {
1613
+ function emitMap(expr: string): string {
1614
+ const a = new MojoAdapter()
1615
+ const ir = compileToIR(`
1616
+ function C({ tags, users }: { tags: string[]; users: { name: string }[] }) {
1617
+ return <div>{${expr}}</div>
1618
+ }
1619
+ export { C }
1620
+ `, a)
1621
+ return a.generate(ir).template ?? ''
1622
+ }
1623
+
1624
+ // The blog-showcase shape (#1938/#1939): a value-returning `.map` (string
1625
+ // projection, not JSX) lowers through the evaluator — `bf->map_eval`
1626
+ // projects each element (no flatten) and composes through `.join`.
1627
+ test('.map(t => `#${t}`).join(" ") emits bf->map_eval composed into join', () => {
1628
+ const t = emitMap("tags.map(t => `#${t}`).join(' ')")
1629
+ expect(t).toContain(`join(' ', @{bf->map_eval($tags,`)
1630
+ expect(t).toContain(`"kind":"template-literal"`)
1631
+ })
1632
+
1633
+ test('.map(u => u.name) emits bf->map_eval with the field projection', () => {
1634
+ const t = emitMap("users.map(u => u.name).join(', ')")
1635
+ expect(t).toContain(`bf->map_eval($users,`)
1636
+ expect(t).toContain(`"property":"name"`)
1637
+ })
1638
+
1639
+ // The function-reference `.map(format)` BF101 refusal is now covered
1640
+ // cross-adapter by the `array-map-function-reference` shared fixture's
1641
+ // `expectedDiagnostics` entry above.
1642
+ })
1643
+
1491
1644
  describe('MojoAdapter - #1448 Tier C .flatMap(field projection)', () => {
1492
1645
  function emitFlatMap(expr: string): string {
1493
1646
  const a = new MojoAdapter()
@@ -372,6 +372,27 @@ export function renderFlatMapEval(
372
372
  return `bf->flat_map_eval(${recv}, '${escapePerlSingleQuote(json)}', '${param}', ${env})`
373
373
  }
374
374
 
375
+ /**
376
+ * Emit a value-producing `.map(cb)` via the runtime evaluator (#2073): the
377
+ * projection `body` serializes to JSON and `bf->map_eval` projects each
378
+ * element, one result per element (no flatten — the JS `.map` contract).
379
+ * Composes through the array-method chain (`.map(cb).join(' ')`). Returns
380
+ * null when the projection is outside the evaluator surface, and the caller
381
+ * records BF101. The JSX-returning `.map` is an IRLoop upstream and never
382
+ * reaches this emit.
383
+ */
384
+ export function renderMapEval(
385
+ recv: string,
386
+ body: ParsedExpr,
387
+ param: string,
388
+ emit: (e: ParsedExpr) => string,
389
+ ): string | null {
390
+ const json = serializeParsedExpr(body)
391
+ if (json === null) return null
392
+ const env = emitEvalEnvArg(body, [param], emit)
393
+ return `bf->map_eval(${recv}, '${escapePerlSingleQuote(json)}', '${param}', ${env})`
394
+ }
395
+
375
396
  /**
376
397
  * Shared Mojo emit for `.sort(cmp)` / `.toSorted(cmp)` (#1448 Tier B).
377
398
  * Used by both the filter-context emitter and the top-level emitter,
@@ -38,6 +38,7 @@ import {
38
38
  renderPredicateEval,
39
39
  renderFlatMethod,
40
40
  renderFlatMapEval,
41
+ renderMapEval,
41
42
  } from './array-method.ts'
42
43
 
43
44
  /**
@@ -81,6 +82,11 @@ export class MojoFilterEmitter implements ParsedExprEmitter {
81
82
  // against it lowers to `eq`/`ne` (#1672). Defaults to "never" for callers
82
83
  // that don't thread it through.
83
84
  private readonly isStringName: (n: string) => boolean = () => false,
85
+ // Records a BF101 for nested callback shapes this emitter can only
86
+ // degrade — `find*` and the non-predicate methods (#2038). Optional so
87
+ // emitter construction stays possible without an adapter; a missing hook
88
+ // keeps the old silent-degrade emit.
89
+ private readonly onUnsupported?: (message: string, reason?: string) => void,
84
90
  ) {}
85
91
 
86
92
  identifier(name: string): string {
@@ -172,20 +178,34 @@ export class MojoFilterEmitter implements ParsedExprEmitter {
172
178
  ): string {
173
179
  // Filter context only meaningfully handles the predicate methods
174
180
  // (filter / every / some land here through nested `.filter(...)` chains).
175
- // Sort / reduce / flatMap never arise inside a predicate, so route them to
176
- // the truthy sentinel like the old `default` arm did.
177
- if (!PREDICATE_METHODS.has(method)) return '1'
181
+ // Sort / reduce / flatMap have no scalar Perl form here the old
182
+ // `default` arm degraded them to the truthy sentinel, silently rewriting
183
+ // the predicate, so surface BF101 instead (#2038).
184
+ if (!PREDICATE_METHODS.has(method)) {
185
+ this.onUnsupported?.(
186
+ `Filter predicate contains a nested '.${method}(...)' callback, which has no Perl scalar form`,
187
+ `Rewrite the predicate without a nested callback method, or add /* @client */ for client-only evaluation (no SSR).`,
188
+ )
189
+ return '1'
190
+ }
178
191
  // The predicate body is also a filter context, but with this
179
192
  // callback's own `param` (potentially shadowing the outer one),
180
193
  // so we spin up a nested emitter with the inner param.
181
194
  const param = arrow.params[0]
182
195
  const predicate = arrow.body
183
196
  const arrayExpr = emit(object)
184
- const predBody = emitParsedExpr(predicate, new MojoFilterEmitter(param, this.localVarMap, this.isStringName))
197
+ const predBody = emitParsedExpr(predicate, new MojoFilterEmitter(param, this.localVarMap, this.isStringName, this.onUnsupported))
185
198
  const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, 'g'), '$_')
186
199
  if (method === 'filter') return `[grep { ${grepBody} } @{${arrayExpr}}]`
187
200
  if (method === 'every') return `!(grep { !(${grepBody}) } @{${arrayExpr}})`
188
201
  if (method === 'some') return `!!(grep { ${grepBody} } @{${arrayExpr}})`
202
+ // `find` / `findIndex` / `findLast` / `findLastIndex` return an element
203
+ // (or index), not a boolean — there is no inline grep form. Degrading to
204
+ // the receiver silently changes predicate semantics, so be loud (#2038).
205
+ this.onUnsupported?.(
206
+ `Filter predicate contains a nested '.${method}(...)' callback, which has no Perl scalar form`,
207
+ `Rewrite the predicate without a nested callback method, or add /* @client */ for client-only evaluation (no SSR).`,
208
+ )
189
209
  return arrayExpr
190
210
  }
191
211
 
@@ -448,6 +468,18 @@ export class MojoTopLevelEmitter implements ParsedExprEmitter {
448
468
  return "''"
449
469
  }
450
470
 
471
+ // Value-producing map (#2073): eval-only; BF101 when the projection is
472
+ // outside the surface. (The JSX-returning `.map` is an IRLoop upstream.)
473
+ if (method === 'map') {
474
+ const evalForm = renderMapEval(recv, body, params[0], emit)
475
+ if (evalForm !== null) return evalForm
476
+ this.ctx._recordExprBF101(
477
+ `.map(...) projection is not lowerable to a template map`,
478
+ `Pre-compute the projection in the route handler, or mark the position @client-only.`,
479
+ )
480
+ return "''"
481
+ }
482
+
451
483
  // Predicate methods: filter / find / every / some / findIndex / findLast /
452
484
  // findLastIndex. Eval-first (#2018 P2), then the inline `grep` / `bf->find`
453
485
  // fallback for a predicate the evaluator can't model.
@@ -55,16 +55,3 @@ export function collectRootScopeNodes(node: IRNode): Set<IRNode> {
55
55
  visit(node)
56
56
  return out
57
57
  }
58
-
59
- /**
60
- * True when every `$var` the lowered (Perl / Kolon) expression references is
61
- * in the available set — i.e. the template already has that var in scope.
62
- * Guards in-template memo seeding from referencing an out-of-scope binding
63
- * (which would trip Perl strict mode). (#1297)
64
- */
65
- export function referencedVarsAreAvailable(expr: string, available: ReadonlySet<string>): boolean {
66
- for (const m of expr.matchAll(/\$([A-Za-z_]\w*)/g)) {
67
- if (!available.has(m[1])) return false
68
- }
69
- return true
70
- }