@barefootjs/go-template 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.
@@ -58,6 +58,13 @@ runAdapterConformanceTests({
58
58
  // a `SearchParams bf.SearchParams` binding (zero value → empty query → the
59
59
  // author's default). See #1922; Mojo / Xslate stay skipped pending their own
60
60
  // env-signal lowering + per-request Perl reader.
61
+ //
62
+ // `search-params-derived-filter` (#2075) is no longer skipped: a LIST-
63
+ // valued memo derived from the searchParams() env signal (the blog
64
+ // `visible` filter shape) now SSR-computes via `bf.FilterEval` — the
65
+ // memo's field type is `[]any` and its constructor init serializes the
66
+ // `.filter` predicate to the runtime evaluator. See the #2075 constructor
67
+ // pins below.
61
68
  skipJsx: [],
62
69
  // Per-fixture build-time contracts for shapes the Go template
63
70
  // adapter intentionally refuses to lower. Lives here (not on the
@@ -98,6 +105,17 @@ runAdapterConformanceTests({
98
105
  // (`cn\`base \${tone()}\``) likewise can't lower into Go template
99
106
  // syntax — same BF101 refusal.
100
107
  'tagged-template-classname': [{ code: 'BF101', severity: 'error' }],
108
+ // #2038: a filter predicate whose body contains a NESTED callback call
109
+ // (`t => !picked().some(p => …)` / `t => picked().find(p => …)`). The
110
+ // evaluator refuses nested arrows and `renderFilterExpr` has no faithful
111
+ // Go form for the inner call (its `call` arm used to silently drop the
112
+ // arrow argument and render only the callee) — the compiler is loud
113
+ // instead of lossy. The `/* @client */` twin
114
+ // (`filter-nested-callback-predicate-client`) has no pin here: it must
115
+ // render clean on every adapter, which asserts the suppression contract.
116
+ // https://github.com/piconic-ai/barefootjs/issues/2038
117
+ 'filter-nested-callback-predicate': [{ code: 'BF101', severity: 'error' }],
118
+ 'filter-nested-find-predicate': [{ code: 'BF101', severity: 'error' }],
101
119
  // #1310: rest destructure in .map() callback. The object-rest shape read
102
120
  // via member access (`rest-destructure-object-in-map`) now lowers — each
103
121
  // binding resolves to a field on a synthetic `$__bf_item0` range var (the
@@ -154,6 +172,11 @@ runAdapterConformanceTests({
154
172
  // `string-trim` no longer pinned — pre-existing `bf_trim`
155
173
  // (wraps `strings.TrimSpace`) handles the strip (#1448 Tier A
156
174
  // ninth PR, closing out Tier A).
175
+ // #2073 follow-up: a function-reference `.map(format)` callback has no
176
+ // arrow body to serialize — not a CALLBACK_METHODS shape — so the
177
+ // UNSUPPORTED_METHODS gate refuses it with BF101 rather than emitting
178
+ // a broken template.
179
+ 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
157
180
  },
158
181
  // `JSON_STRINGIFY_VIA_CONST` and `MATH_FLOOR_VIA_CONST` now pass
159
182
  // via `GoTemplateAdapter.templatePrimitives` (#1188). The two
@@ -1830,6 +1853,31 @@ export function TodoList() {
1830
1853
  const bf101 = adapter.errors.filter(e => e.code === 'BF101')
1831
1854
  expect(bf101).toEqual([])
1832
1855
  })
1856
+
1857
+ test('nested-callback refusal keeps the emitted template syntactically valid (#2038)', () => {
1858
+ // The BF101 contract itself is pinned at the shared conformance layer
1859
+ // (`filter-nested-callback-predicate` / `filter-nested-find-predicate`
1860
+ // expectedDiagnostics; the `/* @client */` suppression twin renders
1861
+ // clean). This test pins the GO-SPECIFIC half: the refusal must emit
1862
+ // the `false` sentinel — not a half-rendered predicate like `.Some` —
1863
+ // so `text/template` parsing doesn't cascade into secondary errors.
1864
+ const adapter = new GoTemplateAdapter()
1865
+ const result = compileAndGenerate(`
1866
+ "use client"
1867
+ import { createSignal } from "@barefootjs/client"
1868
+
1869
+ type Item = { id: number }
1870
+
1871
+ export function Picker() {
1872
+ const [items, setItems] = createSignal<Item[]>([])
1873
+ const [picked, setPicked] = createSignal<Item[]>([])
1874
+ return <ul>{items().filter(t => !picked().some(p => p.id === t.id)).map(t => <li key={t.id}>{t.id}</li>)}</ul>
1875
+ }
1876
+ `, adapter)
1877
+ expect(adapter.errors.some(e => e.code === 'BF101')).toBe(true)
1878
+ expect(result.template).not.toContain('.Some')
1879
+ expect(result.template).toContain('{{if false}}')
1880
+ })
1833
1881
  })
1834
1882
 
1835
1883
  describe('find/findIndex - adapter specific', () => {
@@ -2991,6 +3039,238 @@ export { C }
2991
3039
  })
2992
3040
  })
2993
3041
 
3042
+ describe('GoTemplateAdapter - #2075 searchParams()-derived memo constructor', () => {
3043
+ // A memo derived from the createSearchParams() env signal SSR-computes in
3044
+ // the generated constructor from the canonical `in.SearchParams` reader —
3045
+ // including under a local alias. `Get` returns "" for an absent key, so
3046
+ // the `??` default fires on "" — the same documented `?? → or` divergence
3047
+ // as the template-position lowering.
3048
+ test('computes an aliased `?? default` derived memo from in.SearchParams', () => {
3049
+ const adapter = new GoTemplateAdapter()
3050
+ const ir = compileToIR(`
3051
+ 'use client'
3052
+ import { createMemo, createSearchParams } from '@barefootjs/client'
3053
+ export function SortStatus() {
3054
+ const [sp] = createSearchParams()
3055
+ const sort = createMemo(() => sp().get('sort') ?? 'date')
3056
+ return <p>sort: {sort()}</p>
3057
+ }
3058
+ `, adapter)
3059
+ const { types } = adapter.generate(ir)
3060
+ expect(types).toContain(
3061
+ 'Sort: func() string { if v := in.SearchParams.Get("sort"); v != "" { return v }; return "date" }(),',
3062
+ )
3063
+ })
3064
+
3065
+ test('computes a bare env read memo from in.SearchParams', () => {
3066
+ const adapter = new GoTemplateAdapter()
3067
+ const ir = compileToIR(`
3068
+ 'use client'
3069
+ import { createMemo, createSearchParams } from '@barefootjs/client'
3070
+ export function QueryEcho() {
3071
+ const [searchParams] = createSearchParams()
3072
+ const q = createMemo(() => searchParams().get('q'))
3073
+ return <p>{q()}</p>
3074
+ }
3075
+ `, adapter)
3076
+ const { types } = adapter.generate(ir)
3077
+ expect(types).toContain('Q: in.SearchParams.Get("q"),')
3078
+ })
3079
+
3080
+ // #2075 residual: a LIST-valued memo (`.filter(arrow)` over a props array)
3081
+ // chained off the env-derived `tag` memo — the `search-params-derived-filter`
3082
+ // shared fixture's shape. The field is `[]any` (not the misclassified `bool`
3083
+ // the `/=>\s*!/` heuristic used to produce from the predicate's `!tag()`),
3084
+ // and the constructor seeds it via `bf.FilterEval`, whose serialized
3085
+ // predicate JSON is embedded (escaped) in the Go string literal.
3086
+ test('computes a list-filter memo chained off a searchParams()-derived memo via bf.FilterEval', () => {
3087
+ const adapter = new GoTemplateAdapter()
3088
+ const ir = compileToIR(`
3089
+ 'use client'
3090
+ import { createMemo, createSearchParams } from '@barefootjs/client'
3091
+ export function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
3092
+ const [searchParams] = createSearchParams()
3093
+ const tag = createMemo(() => searchParams().get('tag') ?? '')
3094
+ const visible = createMemo(() => props.items.filter((p) => !tag() || p.tags.includes(tag())))
3095
+ return <ul>{visible().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
3096
+ }
3097
+ `, adapter)
3098
+ const { types } = adapter.generate(ir)
3099
+ expect(types).toContain('Visible []any')
3100
+ expect(types).toContain('Visible: bf.FilterEval(in.Items,')
3101
+ // The predicate JSON is escaped inside a Go double-quoted string literal,
3102
+ // so assert on the substring that survives escaping (backslash-escaped
3103
+ // quotes around the JSON keys/values).
3104
+ expect(types).toContain('\\"method\\":\\"includes\\"')
3105
+ })
3106
+
3107
+ // #2077 review finding 3: `tag` (an earlier sibling the `visible` filter's
3108
+ // predicate closes over) used to be recomputed verbatim a second time
3109
+ // inline in `visible`'s `bf.FilterEval` env map — the same
3110
+ // `in.SearchParams.Get("tag")` expression emitted twice in the constructor.
3111
+ // It's now hoisted to a shared local before the `return`, referenced by
3112
+ // BOTH the `Tag:` field and the env-map entry.
3113
+ test('hoists the earlier sibling memo instead of duplicating its expression', () => {
3114
+ const adapter = new GoTemplateAdapter()
3115
+ const ir = compileToIR(`
3116
+ 'use client'
3117
+ import { createMemo, createSearchParams } from '@barefootjs/client'
3118
+ export function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
3119
+ const [searchParams] = createSearchParams()
3120
+ const tag = createMemo(() => searchParams().get('tag') ?? '')
3121
+ const visible = createMemo(() => props.items.filter((p) => !tag() || p.tags.includes(tag())))
3122
+ return <ul>{visible().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
3123
+ }
3124
+ `, adapter)
3125
+ const { types } = adapter.generate(ir)
3126
+ expect(types).toContain('var memoTag string =')
3127
+ expect(types).toContain('Tag: memoTag,')
3128
+ expect(types).toContain('"tag": memoTag')
3129
+ // The multi-token `Get("tag")` expression appears exactly once in the
3130
+ // whole constructor — the hoisted local, not a second inline copy.
3131
+ expect(types.split('in.SearchParams.Get("tag")').length).toBe(2)
3132
+ })
3133
+ })
3134
+
3135
+ describe('GoTemplateAdapter - env-derived memo sourced from the SSR seed plan (Package I)', () => {
3136
+ // `CompileState.ssrSeedPlan` (and `envSignalReadersByLocal` derived from it)
3137
+ // is now the single authority for env-reader recognition. `compileToIR`
3138
+ // already JSON-round-trips the IR (mirrors the adapter conformance
3139
+ // harness), so each `env-reader` step's `reader.methods` — a ReadonlySet —
3140
+ // arrives serialized to `{}`; `primeCompileState` re-resolves a live reader
3141
+ // via `envSignalReaderFor(step.reader.key)` before trusting `.methods`.
3142
+ // Pins that the env-derived memo constructor still SSR-computes through
3143
+ // that re-resolution instead of silently falling back to a zero value.
3144
+ test('computes an env-derived memo constructor through the JSON-round-tripped ssrSeedPlan', () => {
3145
+ const adapter = new GoTemplateAdapter()
3146
+ const ir = compileToIR(`
3147
+ 'use client'
3148
+ import { createMemo, createSearchParams } from '@barefootjs/client'
3149
+ export function QueryEcho() {
3150
+ const [searchParams] = createSearchParams()
3151
+ const q = createMemo(() => searchParams().get('q'))
3152
+ return <p>{q()}</p>
3153
+ }
3154
+ `, adapter)
3155
+ const { types } = adapter.generate(ir)
3156
+ expect(types).toContain('Q: in.SearchParams.Get("q"),')
3157
+ })
3158
+ })
3159
+
3160
+ describe('GoTemplateAdapter - #2077 review sibling-memo recursion guard', () => {
3161
+ // Finding 1: mutually-referencing memos (each resolving the other as a
3162
+ // filter-arm free var, or as a bare-getter ternary condition) used to
3163
+ // recurse without bound and blow the compiler's call stack
3164
+ // (`RangeError: Maximum call stack size exceeded`, verified by probe before
3165
+ // the fix). The cycle/declaration-order guard makes both directions fall
3166
+ // back to the field's Go zero value instead of throwing.
3167
+ test('mutual filter-arm memos compile without throwing and fall back to zero values', () => {
3168
+ const adapter = new GoTemplateAdapter()
3169
+ const ir = compileToIR(`
3170
+ 'use client'
3171
+ import { createMemo } from '@barefootjs/client'
3172
+ export function C(props: { items: { title: string }[] }) {
3173
+ const a = createMemo(() => props.items.filter((x) => Boolean(b())))
3174
+ const b = createMemo(() => props.items.filter((x) => Boolean(a())))
3175
+ return <ul>{a().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
3176
+ }
3177
+ `, adapter)
3178
+ let types = ''
3179
+ expect(() => { types = adapter.generate(ir).types }).not.toThrow()
3180
+ expect(types).toContain('A []any')
3181
+ expect(types).toContain('B []any')
3182
+ // Neither memo can resolve the OTHER as a free var (order rules out both
3183
+ // directions at once — `a` can't see `b` because `b` is declared later,
3184
+ // and vice versa), so `a`'s own computation falls back to the `[]any`
3185
+ // zero value (hoisted since `b` references it), and `b`'s free-var
3186
+ // resolution of `a` reuses that hoisted zero-valued local.
3187
+ expect(types).toContain('var memoA []any = nil')
3188
+ expect(types).toContain('A: memoA,')
3189
+ expect(types).toContain('B: bf.FilterEval(in.Items,')
3190
+ expect(types).toContain('map[string]any{"a": memoA}')
3191
+ })
3192
+
3193
+ test('mutual ternary memos (bare-getter condition) compile without throwing and fall back to zero values', () => {
3194
+ const adapter = new GoTemplateAdapter()
3195
+ const ir = compileToIR(`
3196
+ 'use client'
3197
+ import { createMemo } from '@barefootjs/client'
3198
+ export function C() {
3199
+ const a = createMemo(() => (b() ? 'A' : 'Z'))
3200
+ const b = createMemo(() => (a() ? 'B' : 'Y'))
3201
+ return <p>{a()}{b()}</p>
3202
+ }
3203
+ `, adapter)
3204
+ let types = ''
3205
+ expect(() => { types = adapter.generate(ir).types }).not.toThrow()
3206
+ expect(types).toContain('A: "",')
3207
+ expect(types).toContain('B: "",')
3208
+ })
3209
+
3210
+ test('self-referencing filter-arm memo compiles without throwing', () => {
3211
+ const adapter = new GoTemplateAdapter()
3212
+ const ir = compileToIR(`
3213
+ 'use client'
3214
+ import { createMemo } from '@barefootjs/client'
3215
+ export function C(props: { items: { title: string }[] }) {
3216
+ const a = createMemo(() => props.items.filter((x) => Boolean(a())))
3217
+ return <ul>{a().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
3218
+ }
3219
+ `, adapter)
3220
+ let types = ''
3221
+ expect(() => { types = adapter.generate(ir).types }).not.toThrow()
3222
+ expect(types).toContain('A: nil,')
3223
+ })
3224
+
3225
+ test('self-referencing ternary memo compiles without throwing', () => {
3226
+ const adapter = new GoTemplateAdapter()
3227
+ const ir = compileToIR(`
3228
+ 'use client'
3229
+ import { createMemo } from '@barefootjs/client'
3230
+ export function C() {
3231
+ const a = createMemo(() => (a() ? 'A' : 'Z'))
3232
+ return <p>{a()}</p>
3233
+ }
3234
+ `, adapter)
3235
+ let types = ''
3236
+ expect(() => { types = adapter.generate(ir).types }).not.toThrow()
3237
+ expect(types).toContain('A: "",')
3238
+ })
3239
+ })
3240
+
3241
+ describe('GoTemplateAdapter - #2073 value-producing .map(cb)', () => {
3242
+ function emitMap(expr: string): string {
3243
+ const adapter = new GoTemplateAdapter()
3244
+ const ir = compileToIR(`
3245
+ function C({ tags, users }: { tags: string[]; users: { name: string }[] }) {
3246
+ return <div>{${expr}}</div>
3247
+ }
3248
+ export { C }
3249
+ `, adapter)
3250
+ return adapter.generate(ir).template ?? ''
3251
+ }
3252
+
3253
+ // The blog-showcase shape (#1938/#1939): a value-returning `.map` (string
3254
+ // projection, not JSX) lowers through the evaluator — `bf_map_eval` projects
3255
+ // each element (no flatten) and composes through `bf_join`. (Like the
3256
+ // flatMap pins below, the projection JSON's quotes are backslash-escaped in
3257
+ // the Go-template string; the JSON itself is verified by the render
3258
+ // conformance fixtures + the runtime TestMapEval.)
3259
+ test('.map(t => `#${t}`).join(" ") emits bf_map_eval composed into bf_join', () => {
3260
+ const t = emitMap("tags.map(t => `#${t}`).join(' ')")
3261
+ expect(t).toContain('bf_join (bf_map_eval .Tags')
3262
+ })
3263
+
3264
+ test('.map(u => u.name) emits bf_map_eval with the receiver', () => {
3265
+ const t = emitMap("users.map(u => u.name).join(', ')")
3266
+ expect(t).toContain('bf_map_eval .Users')
3267
+ })
3268
+
3269
+ // The function-reference `.map(format)` BF101 refusal is now covered
3270
+ // cross-adapter by the `array-map-function-reference` shared fixture's
3271
+ // `expectedDiagnostics` entry above.
3272
+ })
3273
+
2994
3274
  describe('GoTemplateAdapter - #1448 Tier C .flatMap(field projection)', () => {
2995
3275
  function emitFlatMap(expr: string): string {
2996
3276
  const adapter = new GoTemplateAdapter()
@@ -62,8 +62,9 @@ import {
62
62
  isLowerableObjectRestDestructure,
63
63
  type ContextConsumer,
64
64
  collectModuleStringConsts as collectModuleStringConstsShared,
65
- searchParamsLocalNames,
66
- prepareLoweringMatchers
65
+ prepareLoweringMatchers,
66
+ envSignalReaderFor,
67
+ computeSsrSeedPlan,
67
68
  } from '@barefootjs/jsx'
68
69
  import { findInterpolationEnd } from '@barefootjs/jsx/scanner'
69
70
  import { BF_REGION } from '@barefootjs/shared'
@@ -85,6 +86,7 @@ import {
85
86
  emitReduceEval,
86
87
  emitPredicateEval,
87
88
  emitFlatMapEval,
89
+ emitMapEval,
88
90
  stringTolerantEqOperands,
89
91
  buildUnsupportedSuggestion,
90
92
  GO_REMEDIATION_OPTIONS,
@@ -115,10 +117,10 @@ import {
115
117
  objectLiteralToGoMap,
116
118
  } from "./value/value-lowering.ts"
117
119
  import { typeInfoToGo } from "./type/type-codegen.ts"
118
- import { isBooleanMemo, isStringTernaryMemo } from "./memo/memo-type.ts"
120
+ import { isBooleanMemo, isListFilterMemo, isStringTernaryMemo } from "./memo/memo-type.ts"
119
121
  import { lowerCtorExpr } from "./memo/ctor-lowering.ts"
120
122
  import { resolveBlockBodyMemoModuleConst } from "./memo/memo-value.ts"
121
- import { computeMemoInitialValue, computeMemoInitialValueOrNull } from "./memo/memo-compute.ts"
123
+ import { computeMemoInitialValue, computeMemoInitialValueOrNull, filterArmEarlierSiblingRefs } from "./memo/memo-compute.ts"
122
124
  import { collectSpreadSlots, buildSpreadInitializer } from "./spread/spread-codegen.ts"
123
125
  import { buildPropTypeOverrides, resolvePropGoType, collectNillablePropNames } from "./props/prop-types.ts"
124
126
 
@@ -272,7 +274,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
272
274
  this.state.currentMemos = ir.metadata.memos ?? []
273
275
  this.state.currentTypeDefinitions = ir.metadata.typeDefinitions ?? []
274
276
  this.state.contextConsumers = collectContextConsumers(ir.metadata)
275
- this.state.searchParamsLocals = searchParamsLocalNames(ir.metadata)
277
+ // Single authority (Package G): the plan already decided which signals are
278
+ // per-request env readers, in declaration order. `ir.metadata.ssrSeedPlan`
279
+ // may be absent for hand-built metadata (tests), hence the fallback to the
280
+ // same shared computation rather than a second, divergent derivation.
281
+ this.state.ssrSeedPlan = ir.metadata.ssrSeedPlan ?? computeSsrSeedPlan(ir.metadata)
282
+ this.state.envSignalReadersByLocal = new Map()
283
+ this.state.searchParamsLocals = new Set()
284
+ for (const step of this.state.ssrSeedPlan.steps) {
285
+ if (step.kind !== 'env-reader') continue
286
+ // `reader.methods` is a ReadonlySet — JSON round-tripping (adapter
287
+ // conformance harness) serializes it to `{}`, so re-resolve a live
288
+ // reader from the registry by key instead of trusting `step.reader`.
289
+ const reader = envSignalReaderFor(step.reader.key)
290
+ if (reader) this.state.envSignalReadersByLocal.set(step.name, reader)
291
+ if (step.reader.key === 'search') this.state.searchParamsLocals.add(step.name)
292
+ }
276
293
  this.state.loweringMatchers = prepareLoweringMatchers(ir.metadata)
277
294
  augmentInheritedPropAccesses(ir)
278
295
  }
@@ -1000,6 +1017,38 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1000
1017
 
1001
1018
  this.emitDynamicBodyWrappers(lines, ir, componentName, dynamicWithBody, propFallbackVars, emittedWrapperVars)
1002
1019
 
1020
+ // Sibling-memo hoisting (#2075/#2077 review finding 3): a filter-arm memo
1021
+ // whose predicate free vars reference an EARLIER sibling memo would
1022
+ // otherwise recompute that sibling's whole expression a second time
1023
+ // inline in its `bf.FilterEval` env map, duplicating it against the
1024
+ // sibling's own field. Two-pass: first collect which memos are
1025
+ // referenced this way, then hoist each into a local emitted once before
1026
+ // the `return`; the memo-field loop below (and the referencing memo's own
1027
+ // env-map entry, via `ctx.state.hoistedMemoLocals`) both reuse the local
1028
+ // instead of re-emitting the expression. Declared `var name Type = value`
1029
+ // (not `:=`) because an unresolved computation can fall back to the bare
1030
+ // `nil` literal, which `:=` can't type-infer.
1031
+ const memoPropsParamMap = new Map(ir.metadata.propsParams.map(p => [p.name, p]))
1032
+ this.state.hoistedMemoLocals = new Map()
1033
+ const hoistNames = new Set<string>()
1034
+ for (const memo of ir.metadata.memos) {
1035
+ for (const name of filterArmEarlierSiblingRefs(this.emitCtx, memo, ir.metadata.signals, ir.metadata.propsParams)) {
1036
+ hoistNames.add(name)
1037
+ }
1038
+ }
1039
+ if (hoistNames.size > 0) {
1040
+ for (const memo of ir.metadata.memos) {
1041
+ if (!hoistNames.has(memo.name)) continue
1042
+ const goType = this.inferMemoType(memo, ir.metadata.signals, memoPropsParamMap)
1043
+ const value = computeMemoInitialValue(this.emitCtx, memo, ir.metadata.signals, ir.metadata.propsParams, propFallbackVars, goType)
1044
+ let localName = `memo${capitalizeFieldName(memo.name)}`
1045
+ while (GO_KEYWORDS.has(localName)) localName += '_'
1046
+ lines.push(`\tvar ${localName} ${goType} = ${value}`)
1047
+ this.state.hoistedMemoLocals.set(memo.name, localName)
1048
+ }
1049
+ lines.push('')
1050
+ }
1051
+
1003
1052
  lines.push(`\treturn ${propsTypeName}{`)
1004
1053
  lines.push('\t\tScopeID: scopeID,')
1005
1054
  // Host context, for when *this* component is itself a slot-attached child.
@@ -1097,11 +1146,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1097
1146
  }
1098
1147
 
1099
1148
  // Memo initial values (from signal initials). Prop-shadowing memos were
1100
- // folded into the prop field above.
1101
- const memoPropsParamMap = new Map(ir.metadata.propsParams.map(p => [p.name, p]))
1149
+ // folded into the prop field above; a memo the pre-pass already hoisted
1150
+ // (above) reuses that local instead of recomputing its expression.
1102
1151
  for (const memo of ir.metadata.memos) {
1103
1152
  const fieldName = capitalizeFieldName(memo.name)
1104
1153
  if (propFieldNames.has(fieldName)) continue
1154
+ const hoistedLocal = this.state.hoistedMemoLocals.get(memo.name)
1155
+ if (hoistedLocal) {
1156
+ lines.push(`\t\t${fieldName}: ${hoistedLocal},`)
1157
+ continue
1158
+ }
1105
1159
  // Pass the inferred Go type so an unresolved computation zeroes to that
1106
1160
  // type (`false` for a boolean memo), not the int `0`.
1107
1161
  const goType = this.inferMemoType(memo, ir.metadata.signals, memoPropsParamMap)
@@ -2095,10 +2149,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2095
2149
  }
2096
2150
 
2097
2151
  // A memo: when no pattern applies, return null so the caller OMITS the
2098
- // field and Go's typed zero value applies.
2152
+ // field and Go's typed zero value applies. Seed the resolution stack
2153
+ // with this memo's own name — a fresh top-level computation, so
2154
+ // self-reference must be caught on the first recursion.
2099
2155
  const memo = memos.find(m => m.name === getterName)
2100
2156
  if (memo) {
2101
- return computeMemoInitialValueOrNull(this.emitCtx, memo, signals, propsParams)
2157
+ return computeMemoInitialValueOrNull(
2158
+ this.emitCtx, memo, signals, propsParams, undefined, new Set([memo.name]),
2159
+ )
2102
2160
  }
2103
2161
  }
2104
2162
 
@@ -2111,6 +2169,19 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2111
2169
  signals: { getter: string; initialValue: string; type: TypeInfo }[],
2112
2170
  propsParamMap: Map<string, { name: string; type: TypeInfo; defaultValue?: string }>
2113
2171
  ): string {
2172
+ // A LIST-valued `.filter(arrow)` memo (#2075 — the blog PostList `visible`
2173
+ // shape) is a slice of the receiver's boxed elements, not a scalar.
2174
+ // Decided FIRST, ahead of the memo's declared `type` and every other
2175
+ // heuristic below: the analyzer's simple per-memo type inference doesn't
2176
+ // model `.filter`'s predicate shape and can land on a bogus primitive
2177
+ // (observed: `boolean`, from the predicate's own `!`/`||` structure),
2178
+ // which would otherwise sail through the `typeInfoToGo(memo.type) ===
2179
+ // 'interface{}'` gates below unchallenged. `bf.FilterEval` (the SSR
2180
+ // constructor lowering, memo-compute.ts) returns `[]any`; the template's
2181
+ // `range` / reflective field access handle the boxed elements the same
2182
+ // way it already handles other `interface{}`-typed slices.
2183
+ if (isListFilterMemo(memo)) return '[]any'
2184
+
2114
2185
  // A template-literal memo always produces a string. Decide this first so a
2115
2186
  // class-string `/` (e.g. `ring-ring/50`) doesn't trip the arithmetic
2116
2187
  // heuristic below into `int`. The analyzer classified the body shape from
@@ -3077,6 +3148,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3077
3148
  return this.pushCallbackBF101(method, true)
3078
3149
  }
3079
3150
 
3151
+ // Value-producing `.map(cb)` (#2073): eval-only. (The JSX-returning
3152
+ // `.map` is an IRLoop upstream and never reaches this dispatch.)
3153
+ if (method === 'map') {
3154
+ const evalForm = emitMapEval(recv, body, params[0] ?? '_', emit)
3155
+ if (evalForm !== null) return evalForm
3156
+ return this.pushCallbackBF101(method, true)
3157
+ }
3158
+
3080
3159
  return this.pushCallbackBF101(method, true)
3081
3160
  }
3082
3161
 
@@ -3657,6 +3736,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3657
3736
  if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
3658
3737
  return `$.${capitalizeFieldName(expr.callee.name)}`
3659
3738
  }
3739
+ // A nested callback method call (`other.some(r => …)`) reaching this
3740
+ // arm has no Go template form in filter context — the fallthrough
3741
+ // below renders only the callee and silently DROPS the arrow argument,
3742
+ // changing predicate semantics (#2038). The one faithful nested shape
3743
+ // (`.filter(cb).length` → `len (bf_filter_eval …)`) is intercepted at
3744
+ // the `member` arm before this node is visited; everything else must
3745
+ // be loud.
3746
+ if (asCallbackMethodCall(expr) !== null) {
3747
+ return this.refuseFilterExprNode(expr)
3748
+ }
3660
3749
  const result = this.renderFilterExpr(expr.callee, param, localVarMap)
3661
3750
  if (this.filterExprUnsupported) return 'false'
3662
3751
  return result
@@ -3721,31 +3810,37 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3721
3810
  return `or (${left}) (${right})`
3722
3811
  }
3723
3812
 
3724
- default: {
3725
- // The filter predicate body contains a node kind we can't lower to a Go
3726
- // template action most commonly a nested higher-order
3727
- // (`x => x.tags.filter(...).length > 0`). Surface BF101 with the
3728
- // offending expression. Set the recursion-wide `filterExprUnsupported`
3729
- // flag so parent branches return `false` instead of wrapping the
3730
- // sentinel into `false.Length` / `gt false.Length 0` etc.: the build
3731
- // fails on BF101 anyway, but the emitted template must stay
3732
- // syntactically valid so `text/template` parsing doesn't cascade into
3733
- // confusing secondary errors.
3734
- this.filterExprUnsupported = true
3735
- this.state.errors.push({
3736
- code: 'BF101',
3737
- severity: 'error',
3738
- message: `Filter predicate contains an expression that cannot be lowered to a Go template action: ${exprToString(expr)}`,
3739
- loc: this.makeLoc(),
3740
- suggestion: {
3741
- message: 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Rewrite the predicate to avoid nested higher-order methods (`.filter()` / `.map()` / etc. inside the predicate body)',
3742
- },
3743
- })
3744
- return 'false'
3745
- }
3813
+ default:
3814
+ // The filter predicate body contains a node kind we can't lower to a
3815
+ // Go template action. Shared refusal with the nested-callback guard in
3816
+ // the `call` arm (#2038).
3817
+ return this.refuseFilterExprNode(expr)
3746
3818
  }
3747
3819
  }
3748
3820
 
3821
+ /**
3822
+ * Refuse a filter-predicate node that has no Go template lowering. Surfaces
3823
+ * BF101 with the offending expression and sets the recursion-wide
3824
+ * `filterExprUnsupported` flag so parent branches return `false` instead of
3825
+ * wrapping the sentinel into `false.Length` / `gt false.Length 0` etc.: the
3826
+ * build fails on BF101 anyway, but the emitted template must stay
3827
+ * syntactically valid so `text/template` parsing doesn't cascade into
3828
+ * confusing secondary errors.
3829
+ */
3830
+ private refuseFilterExprNode(expr: ParsedExpr): string {
3831
+ this.filterExprUnsupported = true
3832
+ this.state.errors.push({
3833
+ code: 'BF101',
3834
+ severity: 'error',
3835
+ message: `Filter predicate contains an expression that cannot be lowered to a Go template action: ${exprToString(expr)}`,
3836
+ loc: this.makeLoc(),
3837
+ suggestion: {
3838
+ message: 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Rewrite the predicate to avoid nested higher-order methods (`.filter()` / `.map()` / etc. inside the predicate body)',
3839
+ },
3840
+ })
3841
+ return 'false'
3842
+ }
3843
+
3749
3844
  /**
3750
3845
  * Whether a ParsedExpr renders as a Go template function call (so it needs
3751
3846
  * parentheses around it).
@@ -13,10 +13,12 @@
13
13
  import type {
14
14
  CompilerError,
15
15
  ContextConsumer,
16
+ EnvSignalReader,
16
17
  IRMetadata,
17
18
  IRNode,
18
19
  LoweringMatcher,
19
20
  MemoInfo,
21
+ SsrSeedPlan,
20
22
  TypeDefinition,
21
23
  TypeInfo,
22
24
  } from '@barefootjs/jsx'
@@ -90,6 +92,35 @@ export class CompileState {
90
92
  */
91
93
  searchParamsLocals: Set<string> = new Set()
92
94
 
95
+ /**
96
+ * Env-signal getter (local binding name, e.g. `searchParams` or an alias) →
97
+ * its registered {@link EnvSignalReader}, keyed by the RECEIVER signal's own
98
+ * `envReader` registration rather than a hardcoded key — so a memo body's
99
+ * `<local>().<method>(...)` resolves to whichever reader that particular
100
+ * local belongs to (open-closed for a future second env signal).
101
+ */
102
+ envSignalReadersByLocal: Map<string, EnvSignalReader> = new Map()
103
+
104
+ /**
105
+ * The backend-neutral SSR seed plan (`computeSsrSeedPlan`, Package G) for
106
+ * the IR currently being compiled — single authority for which signal/memo
107
+ * getters are value-materializable at SSR time (`env-reader` steps are
108
+ * per-request readers; everything else is `derived`/`opaque`). Populated in
109
+ * `primeCompileState`; `envSignalReadersByLocal` and `searchParamsLocals`
110
+ * derive from it there, and the memo-analysis path in `memo-compute.ts`
111
+ * reads its `steps` directly instead of re-deriving from `metadata`.
112
+ */
113
+ ssrSeedPlan: SsrSeedPlan = { baseScope: [], steps: [] }
114
+
115
+ /**
116
+ * Memo name → the hoisted Go local variable name the constructor assigned
117
+ * its SSR value to (populated by the sibling-memo hoisting pre-pass before
118
+ * the main memo-field loop runs). When a later filter-arm memo's free-var
119
+ * resolution finds an entry here, it reuses the local instead of
120
+ * recomputing the sibling's expression a second time.
121
+ */
122
+ hoistedMemoLocals: Map<string, string> = new Map()
123
+
93
124
  /**
94
125
  * Call-lowering matchers active for this component (#2057), bound to its
95
126
  * metadata at init via `prepareLoweringMatchers`. Each maps a recognised call
@@ -192,6 +192,26 @@ export function emitFlatMapEval(
192
192
  return `bf_flat_map_eval ${wrapIfMultiToken(recv)} "${escapeGoString(json)}" "${param}" ${env}`
193
193
  }
194
194
 
195
+ /**
196
+ * Emit a value-producing `.map(cb)` via the evaluator (#2073): the projection
197
+ * body is serialized and evaluated per element by `bf_map_eval`, one result
198
+ * per element (no flatten — the JS `.map` contract). Composes through the
199
+ * array-method chain (`bf_join (bf_map_eval …) " "`). Returns null when the
200
+ * projection is outside the evaluator surface (→ caller pushes BF101). The
201
+ * JSX-returning `.map` is an IRLoop upstream and never reaches this emit.
202
+ */
203
+ export function emitMapEval(
204
+ recv: string,
205
+ body: ParsedExpr,
206
+ param: string,
207
+ emit: (e: ParsedExpr) => string,
208
+ ): string | null {
209
+ const json = serializeParsedExpr(body)
210
+ if (json === null) return null
211
+ const env = emitEvalEnvArg(body, [param], emit)
212
+ return `bf_map_eval ${wrapIfMultiToken(recv)} "${escapeGoString(json)}" "${param}" ${env}`
213
+ }
214
+
195
215
  /**
196
216
  * Make an equality comparison string-tolerant when exactly one side is a Go
197
217
  * string literal: JS `sorted === 'asc'` is loosely false for `sorted = false`,