@barefootjs/xslate 0.17.0 → 0.18.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.
@@ -13,13 +13,11 @@
13
13
  */
14
14
 
15
15
  import { describe, test, expect } from 'bun:test'
16
- import {
17
- runAdapterConformanceTests,
18
- TemplatePrimitiveCaseId,
19
- } from '@barefootjs/adapter-tests'
16
+ import { runAdapterConformanceTests } from '@barefootjs/adapter-tests'
20
17
  import { XslateAdapter } from '../adapter'
21
18
  import { renderXslateComponent, XslateNotAvailableError } from '../test-render'
22
19
  import { compileJSX, type ComponentIR } from '@barefootjs/jsx'
20
+ import { conformancePins } from '../conformance-pins'
23
21
 
24
22
  runAdapterConformanceTests({
25
23
  name: 'xslate',
@@ -33,91 +31,26 @@ runAdapterConformanceTests({
33
31
  // body children (TableCell) now receive `_bf_slot` for deterministic
34
32
  // parent-scope-derived IDs matching Hono.
35
33
  // Per-fixture build-time contracts for shapes the Xslate adapter
36
- // intentionally refuses to lower. Mirrors mojo's set the lowering
37
- // gates are shared code paths in the ported adapter.
38
- expectedDiagnostics: {
39
- // Sibling-imported child component in a loop body: emits a
40
- // cross-template call needing separate registration. BF103 makes
41
- // the requirement loud (same as mojo).
42
- 'static-array-children': [{ code: 'BF103', severity: 'error' }],
43
- // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
44
- // call it inside a keyed `.map`. With the standalone-filter fix in
45
- // place these reach the SAME BF103 (imported child in `.map`) as
46
- // mojo NOT BF101 — confirming the `.filter(...)` chain itself now
47
- // lowers and the only remaining gate is the imported-child one.
48
- 'todo-app': [{ code: 'BF103', severity: 'error' }],
49
- 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
50
- // Array-destructure loop param (`([k, v]) => …`) can't lower to a
51
- // single Kolon loop variable (same BF104 as mojo).
52
- 'static-array-from-props': [{ code: 'BF104', severity: 'error' }],
53
- // Both BF103 (imported child) and BF104 (destructure) fire.
54
- 'static-array-from-props-with-component': [
55
- { code: 'BF103', severity: 'error' },
56
- { code: 'BF104', severity: 'error' },
57
- ],
58
- // Rest-destructure `.map()` callbacks — the object-rest shape read via
59
- // member access (`rest-destructure-object-in-map`) now lowers via Kolon
60
- // `: my` binding locals (`$rest` aliases the item). The other three stay
61
- // refused: rest SPREAD needs a residual object, array-index / nested paths
62
- // can't unpack a tuple (same surface as mojo).
63
- 'rest-destructure-object-spread-in-map': [{ code: 'BF104', severity: 'error' }],
64
- 'rest-destructure-array-in-map': [{ code: 'BF104', severity: 'error' }],
65
- 'rest-destructure-nested-in-map': [{ code: 'BF104', severity: 'error' }],
66
- // XSLATE-SPECIFIC (mojo passes this): the site/ui Button auto-infers a
67
- // `<Slot>` sibling that spreads `{...props}` / `{...children.props}`
68
- // onto its root element. Kolon hashref method args can't splat a
69
- // runtime hash into named entries (no `%$h`-into-call-args form), so
70
- // the adapter refuses the spread with BF101 rather than emit a broken
71
- // render_child call. Mojo's EP `%= include` accepts a flat stash, so it
72
- // lowers the same shape; this is a genuine engine divergence, pinned
73
- // declaratively here.
74
- 'button': [{ code: 'BF101', severity: 'error' }],
75
- // `kbd` auto-infers the same `<Slot>` `{...props}` spread as `button`
76
- // above — refused with BF101 for the identical Kolon engine reason, not a
77
- // render-mismatch (so it's pinned here, not in `skipJsx`).
78
- 'kbd': [{ code: 'BF101', severity: 'error' }],
79
- // #1467 demo-corpus context providers (`radio-group`, `select`,
80
- // `dropdown-menu`, `combobox`, `command`) are no longer pinned — an
81
- // object-literal provider value (`{ value: currentValue,
82
- // onValueChange: (v) => {…} }`) lowers to a Kolon hashref via
83
- // `parseProviderObjectLiteral` (#1897): getter members snapshot
84
- // their body's SSR value, handler / function-shaped members lower
85
- // to `nil`. The command demo's `ref={(el) => {…}}` function prop on
86
- // an imported component is skipped at SSR like `on*` handlers.
87
- //
88
- // #1467 Phase 2e: `data-table` is no longer pinned here — it
89
- // compiles clean now (`selected()[index]` → `index-access`,
90
- // `.toFixed(2)` → `$bf.to_fixed`, `/* @client */` memo SSR-folded)
91
- // and renders to Hono parity on real Text::Xslate. The keyed-loop
92
- // scope-ID divergence (#1896) was fixed by the body-children
93
- // `inLoop` reset (loop-item children get `_bf_slot`); data-table is
94
- // off `skipJsx` entirely and only kept in `skipMarkerConformance`
95
- // below for the shared `/* @client */` keyed-map slot-id elision
96
- // contract (same as `todo-app`), not a render or BF101 gap.
97
- // `style-3-signals` / `style-object-dynamic` no longer pinned — a
98
- // `style={{ … }}` object literal now lowers to a CSS string with dynamic
99
- // values interpolated (`background-color:<: $color :>;padding:8px`) via
100
- // `tryLowerStyleObject` (#1322).
101
- // Tagged-template-literal call in a className — same family, same
102
- // refusal (BF101).
103
- 'tagged-template-classname': [{ code: 'BF101', severity: 'error' }],
104
- // NB: `.find` / `.findIndex` / `.findLast` / `.findLastIndex` are NOT
105
- // pinned here — unlike mojo (which refuses them), Xslate lowers them to
106
- // `$bf.find` / `find_index` / `find_last` / `find_last_index` via the same
107
- // Kolon-lambda mechanism as `.filter` / `.every` / `.some`, so they render.
108
- },
109
- // Template-primitive registry parity: same V1 surface as mojo, so the
110
- // same two cases stay skipped (bespoke user import + customSerialize
111
- // can't render server-side without user-supplied helper mappings).
112
- skipTemplatePrimitives: new Set([
113
- TemplatePrimitiveCaseId.USER_IMPORT_VIA_CONST,
114
- TemplatePrimitiveCaseId.NO_DOUBLE_REWRITE_OF_PROPS_OBJECT,
115
- ]),
116
- // Loop boundary markers for `@client` loops aren't emitted by the
117
- // Xslate adapter yet (ported from mojo, which skips the same set).
34
+ // intentionally refuses to lower. Lives in `../conformance-pins`
35
+ // mirrors mojo's set (the lowering gates are shared code paths in
36
+ // the ported adapter).
37
+ expectedDiagnostics: conformancePins,
38
+ // Template-primitive registry: `USER_IMPORT_VIA_CONST` and
39
+ // `NO_DOUBLE_REWRITE_OF_PROPS_OBJECT` now pass (#2069) a bespoke user
40
+ // import can never be added to the string-keyed registry, but the
41
+ // shared `RelocateEnv.loweringMatchers` acceptance path recognises it
42
+ // via a `LoweringPlugin` the case setup registers around the compile
43
+ // (see `packages/adapter-tests/src/cases/template-primitives.ts`). No
44
+ // skips left, so `skipTemplatePrimitives` is omitted entirely.
45
+ // `client-only` / `client-only-loop-with-sibling-cond` /
46
+ // `filter-nested-callback-predicate-client` are no longer skipped
47
+ // `renderLoop` now emits the `$bf.comment("loop:<id>")` boundary pair
48
+ // for clientOnly loops (Hono / Go parity), so mapArray() can locate
49
+ // its insertion anchor at hydration time (#872 / #1087).
118
50
  skipMarkerConformance: new Set([
119
- 'client-only',
120
- 'client-only-loop-with-sibling-cond',
51
+ // Same as Hono / Mojo: `/* @client */` markers on TodoApp's keyed
52
+ // `.map` intentionally elide a slot id from the SSR template that
53
+ // the IR still declares (s6). See hono-adapter.test for the contract.
121
54
  'todo-app',
122
55
  // #1467 Phase 2e: same `/* @client */` keyed-map elision (data-table).
123
56
  'data-table',
@@ -296,6 +229,98 @@ export function C() {
296
229
  // (`$bf.*_eval`), isomorphic with the Go / Mojo `*_eval` helpers. A predicate
297
230
  // the evaluator can't model (a method-call predicate) falls back to the Kolon
298
231
  // lambda runtime call. Template-text pins guard against silent divergence.
232
+ describe('XslateAdapter - #2075 searchParams()-derived memo seeding', () => {
233
+ test('seeds an aliased scalar derived memo from the canonical reader', () => {
234
+ const { template } = compileAndGenerate(`
235
+ 'use client'
236
+ import { createMemo, createSearchParams } from '@barefootjs/client'
237
+ export function SortStatus() {
238
+ const [sp] = createSearchParams()
239
+ const sort = createMemo(() => sp().get('sort') ?? 'date')
240
+ return <p>sort: {sort()}</p>
241
+ }
242
+ `)
243
+ expect(template).toContain(": my $sort = ($searchParams.get('sort') // 'date');")
244
+ })
245
+
246
+ // The Kolon lambda param and the `$bf` runtime object are
247
+ // lowering-internal, not out-of-scope template vars (#2075).
248
+ test('seeds a filter memo chained off the derived memo', () => {
249
+ const { template } = compileAndGenerate(`
250
+ 'use client'
251
+ import { createMemo, createSearchParams } from '@barefootjs/client'
252
+ export function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
253
+ const [searchParams] = createSearchParams()
254
+ const tag = createMemo(() => searchParams().get('tag') ?? '')
255
+ const visible = createMemo(() => props.items.filter((p) => !tag() || p.tags.includes(tag())))
256
+ return <ul>{visible().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
257
+ }
258
+ `)
259
+ expect(template).toContain(": my $tag = ($searchParams.get('tag') // '');")
260
+ expect(template).toContain(': my $visible = $bf.filter($items,')
261
+ })
262
+
263
+ // The seed-scope guard used to scan the LOWERED
264
+ // Kolon string, allowing every arrow-callback param tree-wide. That let an
265
+ // outer, unbound `p` (shadowed only inside the callback) slip past the
266
+ // guard as if it were the callback's own bound `$p` — emitting a bogus
267
+ // seed line. The guard now walks the parsed SOURCE tree with proper
268
+ // lexical scoping (`freeIdentifiers`), so this shape seeds nothing and
269
+ // falls back to the null/ssr-defaults path.
270
+ test('an outer unbound `p` shadowed only inside the callback does not seed', () => {
271
+ const { template } = compileAndGenerate(`
272
+ 'use client'
273
+ import { createMemo } from '@barefootjs/client'
274
+ export function C(props: { items: { ok: boolean }[] }) {
275
+ const visible = createMemo(() => props.items.filter((p) => p.ok) && p)
276
+ return <div>{String(visible())}</div>
277
+ }
278
+ `)
279
+ expect(template).not.toContain('my $visible')
280
+ })
281
+
282
+ // An out-of-scope bare `_` reference must not seed either — the old
283
+ // unconditional `allowed.add('_')` / `allowed.add('bf')` masked this.
284
+ test('an out-of-scope bare `_` reference does not seed', () => {
285
+ const { template } = compileAndGenerate(`
286
+ 'use client'
287
+ import { createMemo } from '@barefootjs/client'
288
+ export function C(props: { count: number }) {
289
+ const doubled = createMemo(() => props.count * 2 + _)
290
+ return <div>{doubled()}</div>
291
+ }
292
+ `)
293
+ expect(template).not.toContain('my $doubled')
294
+ })
295
+ })
296
+
297
+ describe('XslateAdapter - #2073 value-producing .map(cb)', () => {
298
+ // The blog-showcase shape (#1938/#1939): a value-returning `.map` (string
299
+ // projection, not JSX) lowers through the evaluator — `$bf.map_eval`
300
+ // projects each element (no flatten) and composes through `$bf.join`.
301
+ test('.map(t => `#${t}`).join(" ") emits $bf.map_eval composed into $bf.join', () => {
302
+ const { template } = compileAndGenerate(`
303
+ function TagLine({ tags }: { tags: string[] }) {
304
+ return <p>{tags.map((t) => \`#\${t}\`).join(' ')}</p>
305
+ }
306
+ export { TagLine }
307
+ `)
308
+ expect(template).toContain("$bf.join($bf.map_eval($tags,")
309
+ expect(template).toContain('"kind":"template-literal"')
310
+ })
311
+
312
+ test('.map(u => u.name) emits $bf.map_eval with the field projection', () => {
313
+ const { template } = compileAndGenerate(`
314
+ function NameList({ users }: { users: { name: string }[] }) {
315
+ return <div>{users.map((u) => u.name).join(', ')}</div>
316
+ }
317
+ export { NameList }
318
+ `)
319
+ expect(template).toContain('$bf.map_eval($users,')
320
+ expect(template).toContain('"property":"name"')
321
+ })
322
+ })
323
+
299
324
  describe('XslateAdapter - higher-order predicate lowering (#2018 P2)', () => {
300
325
  test('a serializable predicate lowers to $bf.filter_eval with the JSON body + env', () => {
301
326
  // A standalone `.filter().length` exercises the higher-order emitter (the
@@ -332,17 +357,39 @@ export { A }
332
357
  expect(findLast).toContain(', 0, {})')
333
358
  })
334
359
 
335
- test('a method-call predicate falls back to the Kolon-lambda runtime call', () => {
360
+ test('.includes() in a predicate now lowers via the evaluator, not the Kolon-lambda fallback', () => {
361
+ // #2075: `.includes(x)` joined the evaluator's `array-method` surface
362
+ // (shared with the Perl `Evaluator.pm` runtime), so a predicate built
363
+ // from it routes through `$bf.every_eval` like any other pure predicate.
336
364
  const { template } = compileAndGenerate(`
337
365
  function A({ items }: { items: { name: string }[] }) {
338
366
  return <div>{items.every(x => x.name.includes('a')) ? 'y' : 'n'}</div>
339
367
  }
340
368
  export { A }
341
369
  `)
342
- // No evaluator helper — the unsupported predicate keeps the `-> $x { … }`
343
- // lambda form passed to the runtime `$bf.every`.
370
+ expect(template).toContain('$bf.every_eval(')
371
+ expect(template).toContain('"method":"includes"')
372
+ expect(template).not.toContain('-> $x {')
373
+ })
374
+
375
+ test('a method-call predicate outside the evaluator surface falls back to the Kolon-lambda runtime call', () => {
376
+ const { template } = compileAndGenerate(`
377
+ function A({ items }: { items: { name: string }[] }) {
378
+ return <div>{items.every(x => x.name.toUpperCase() === 'A') ? 'y' : 'n'}</div>
379
+ }
380
+ export { A }
381
+ `)
382
+ // `.toUpperCase()` is outside the evaluator's `array-method` gate (only
383
+ // `includes` is recognized there), so the predicate keeps the
384
+ // `-> $x { … }` lambda form passed to the runtime `$bf.every`.
344
385
  expect(template).not.toContain('every_eval')
345
386
  expect(template).toContain('$bf.every(')
346
387
  expect(template).toContain('-> $x {')
347
388
  })
348
389
  })
390
+
391
+ // #2038 nested-callback-predicate loudness is pinned at the shared
392
+ // conformance layer: `filter-nested-callback-predicate` /
393
+ // `filter-nested-find-predicate` (BF101 via `expectedDiagnostics` above) and
394
+ // `filter-nested-callback-predicate-client` (the `/* @client */` suppression
395
+ // twin, which must render clean).
@@ -287,6 +287,27 @@ export function renderFlatMapEval(
287
287
  return `$bf.flat_map_eval(${recv}, '${escapePerlSingleQuote(json)}', '${param}', ${env})`
288
288
  }
289
289
 
290
+ /**
291
+ * Emit a value-producing `.map(cb)` via the runtime evaluator (#2073): the
292
+ * projection body serializes to JSON and `$bf.map_eval` projects each element,
293
+ * one result per element (no flatten — the JS `.map` contract). Composes
294
+ * through the array-method chain (`.map(cb).join(' ')`). Returns null when
295
+ * the projection is outside the evaluator surface (→ caller refuses with
296
+ * BF101). The JSX-returning `.map` is an IRLoop upstream and never reaches
297
+ * this emit.
298
+ */
299
+ export function renderMapEval(
300
+ recv: string,
301
+ body: ParsedExpr,
302
+ param: string,
303
+ emit: (e: ParsedExpr) => string,
304
+ ): string | null {
305
+ const json = serializeParsedExpr(body)
306
+ if (json === null) return null
307
+ const env = emitEvalEnvArg(body, [param], emit)
308
+ return `$bf.map_eval(${recv}, '${escapePerlSingleQuote(json)}', '${param}', ${env})`
309
+ }
310
+
290
311
  /**
291
312
  * Shared Kolon emit for `.sort(cmp)` / `.toSorted(cmp)`. Used by both the
292
313
  * filter-context emitter and the top-level emitter, plus the loop-array
@@ -305,7 +326,28 @@ export function renderSortMethod(recv: string, c: SortComparator): string {
305
326
  }
306
327
 
307
328
  // `.flat(depth?)` → `$bf.flat($recv, $depth)`.
308
- export function renderFlatMethod(recv: string, depth: FlatDepth): string {
329
+ export function renderFlatMethod(
330
+ recv: string,
331
+ depth: FlatDepth | { expr: ParsedExpr },
332
+ emit: (e: ParsedExpr) => string,
333
+ ): string {
334
+ if (typeof depth === 'object') {
335
+ // Dynamic depth (#2094): routed to a SEPARATE runtime helper
336
+ // (`$bf.flat_dynamic`), not `$bf.flat`. The literal-depth path below
337
+ // already uses `-1` as a compile-time SENTINEL baked into the template
338
+ // source meaning "the source literally said `Infinity`". A genuinely
339
+ // dynamic depth that happens to evaluate to `-1` at render time means
340
+ // the OPPOSITE in real JS (`[1,[2]].flat(-1)` never recurses — same as
341
+ // `.flat(0)`). Since both paths would otherwise hand the same
342
+ // literal-looking argument to one shared helper, that helper couldn't
343
+ // tell which case it's in — so `flat_dynamic` performs its own
344
+ // `ToIntegerOrInfinity`-style coercion (truncate toward zero; negative
345
+ // -> 0; NaN/non-numeric -> 0; +Infinity or a huge finite value ->
346
+ // flatten fully) and then delegates to `flat`. See `sub flat_dynamic`
347
+ // in BarefootJS.pm and the Go reference (`FlatDynamicDepth`/
348
+ // `coerceFlatDepth` in bf.go).
349
+ return `$bf.flat_dynamic(${recv}, ${emit(depth.expr)})`
350
+ }
309
351
  const d = depth === 'infinity' ? -1 : depth
310
352
  return `$bf.flat(${recv}, ${d})`
311
353
  }
@@ -35,6 +35,7 @@ import {
35
35
  renderPredicateEval,
36
36
  renderFlatMethod,
37
37
  renderFlatMapEval,
38
+ renderMapEval,
38
39
  } from './array-method.ts'
39
40
 
40
41
  /**
@@ -62,18 +63,20 @@ const PREDICATE_METHODS = new Set<HigherOrderMethod>([
62
63
  * filters. Higher-order predicates are emitted using Kolon's own scalar
63
64
  * comparison operators (which delegate to Perl semantics).
64
65
  *
65
- * NOTE: Kolon has no `grep { } @{...}` form, so nested higher-order chains
66
- * (`x.tags.filter(...).length`) inside a predicate route through the
67
- * top-level emitter's `$bf`-helper higher-order lowering. This emitter keeps
68
- * the scalar-comparison surface the predicates the adapter accepts actually
69
- * use; richer nested shapes fall back to the helper or surface as BF101 via
70
- * the top-level emitter.
66
+ * NOTE: Kolon has no `grep { } @{...}` form, so a nested higher-order call
67
+ * (`x.tags.filter(...)`, `other.some(...)`) inside a predicate has no faithful
68
+ * scalar lowering here. Such predicates surface BF101 via `onUnsupported`
69
+ * (#2038) instead of silently degrading to the callback's receiver.
71
70
  */
72
71
  export class XslateFilterEmitter implements ParsedExprEmitter {
73
72
  constructor(
74
73
  private readonly param: string,
75
74
  private readonly localVarMap: Map<string, string>,
76
75
  private readonly isStringName: (n: string) => boolean = () => false,
76
+ // Records a BF101 for predicate shapes this emitter can only degrade
77
+ // (#2038). Optional so emitter construction stays possible without an
78
+ // adapter; a missing hook keeps the old silent-degrade emit.
79
+ private readonly onUnsupported?: (message: string, reason?: string) => void,
77
80
  ) {}
78
81
 
79
82
  identifier(name: string): string {
@@ -155,12 +158,15 @@ export class XslateFilterEmitter implements ParsedExprEmitter {
155
158
  _restArgs: ParsedExpr[],
156
159
  emit: (e: ParsedExpr) => string,
157
160
  ): string {
158
- // Nested callback method inside a filter predicate has no Kolon scalar
159
- // form; defer to the receiver so the predicate at least references a real
160
- // value (a richer chain would surface its own diagnostic at the top level).
161
- // Matches the pre-#2018-P5 `higherOrder` arm, which returned `emit(object)`
162
- // for every method.
163
- void method
161
+ // A nested callback method inside a filter predicate has no Kolon scalar
162
+ // form. The pre-#2038 behavior degraded it to its receiver, which silently
163
+ // changes predicate semantics (`!other.some(r => …)` collapses to
164
+ // `!other`), so surface BF101 instead. The receiver emit is kept only so
165
+ // the template stays syntactically valid while the build fails.
166
+ this.onUnsupported?.(
167
+ `Filter predicate contains a nested '.${method}(...)' callback, which has no Kolon scalar form`,
168
+ `Rewrite the predicate without a nested callback method, or add /* @client */ for client-only evaluation (no SSR).`,
169
+ )
164
170
  return emit(object)
165
171
  }
166
172
 
@@ -177,8 +183,12 @@ export class XslateFilterEmitter implements ParsedExprEmitter {
177
183
  return renderArrayMethod(method, object, args, emit)
178
184
  }
179
185
 
180
- flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
181
- return renderFlatMethod(emit(object), depth)
186
+ flatMethod(
187
+ object: ParsedExpr,
188
+ depth: FlatDepth | { expr: ParsedExpr },
189
+ emit: (e: ParsedExpr) => string,
190
+ ): string {
191
+ return renderFlatMethod(emit(object), depth, emit)
182
192
  }
183
193
 
184
194
  conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
@@ -398,6 +408,18 @@ export class XslateTopLevelEmitter implements ParsedExprEmitter {
398
408
  return "''"
399
409
  }
400
410
 
411
+ // Value-producing `.map(cb)` (#2073): serialize the projection body +
412
+ // emit `$bf.map_eval`. (The JSX-returning `.map` is an IRLoop upstream.)
413
+ if (method === 'map') {
414
+ const evalForm = renderMapEval(recv, body, params[0], emit)
415
+ if (evalForm !== null) return evalForm
416
+ this.ctx._recordExprBF101(
417
+ `'.map(...)' projection is outside the Xslate adapter's evaluable surface`,
418
+ `Pre-compute the projected array, or move this position to a '/* @client */' boundary.`,
419
+ )
420
+ return "''"
421
+ }
422
+
401
423
  // Unknown callback method (should not arrive — CALLBACK_METHODS is closed).
402
424
  void object
403
425
  return recv
@@ -468,8 +490,12 @@ export class XslateTopLevelEmitter implements ParsedExprEmitter {
468
490
  return renderArrayMethod(method, object, args, emit)
469
491
  }
470
492
 
471
- flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
472
- return renderFlatMethod(emit(object), depth)
493
+ flatMethod(
494
+ object: ParsedExpr,
495
+ depth: FlatDepth | { expr: ParsedExpr },
496
+ emit: (e: ParsedExpr) => string,
497
+ ): string {
498
+ return renderFlatMethod(emit(object), depth, emit)
473
499
  }
474
500
 
475
501
  conditional(
@@ -519,12 +545,16 @@ export class XslateTopLevelEmitter implements ParsedExprEmitter {
519
545
  return "''"
520
546
  }
521
547
 
522
- objectLiteral(_properties: ObjectLiteralProperty[], _raw: string, _emit: (e: ParsedExpr) => string): string {
523
- // Mirror `unsupported`: a bare object literal reaching the dispatcher
524
- // lowers to the safe empty-string literal, exactly as before the
525
- // `object-literal` kind existed (byte-identical; Roadmap A-1). Object
526
- // values that round-trip to a Kolon hashref go through the dedicated
527
- // `objectLiteralToKolonHashref` lowering in the conditional/attr paths.
528
- return "''"
548
+ objectLiteral(properties: ObjectLiteralProperty[], _raw: string, _emit: (e: ParsedExpr) => string): string {
549
+ // The shared `isSupported` gate only ever lets this dispatcher see an
550
+ // object literal as the EMPTY (`?? {}`) fallback operand of `??`
551
+ // (expression-parser.ts, `logical` case) any other object literal is
552
+ // refused before reaching here. Emit Kolon's real empty hashref
553
+ // literal, matching the `'{}'` convention `objectLiteralToKolonHashref`
554
+ // already uses for the zero-property case in the spread path. A
555
+ // populated literal is structurally unreachable given the gate, but
556
+ // still degrades safely to the pre-existing empty-string sentinel
557
+ // rather than silently dropping keys.
558
+ return properties.length === 0 ? '{}' : "''"
529
559
  }
530
560
  }
@@ -51,14 +51,3 @@ export function collectRootScopeNodes(node: IRNode): Set<IRNode> {
51
51
  visit(node)
52
52
  return out
53
53
  }
54
-
55
- /**
56
- * True when every `$var` the lowered Kolon expression references is already in
57
- * scope — guards in-template memo seeding against an out-of-scope binding. (#1297)
58
- */
59
- export function referencedVarsAreAvailable(expr: string, available: ReadonlySet<string>): boolean {
60
- for (const m of expr.matchAll(/\$([A-Za-z_]\w*)/g)) {
61
- if (!available.has(m[1])) return false
62
- }
63
- return true
64
- }
@@ -13,13 +13,10 @@ import {
13
13
  type ComponentIR,
14
14
  type ContextConsumer,
15
15
  collectContextConsumers,
16
- extractArrowBodyExpression,
17
- isSupported,
18
- parseExpression,
16
+ computeSsrSeedPlan,
19
17
  } from '@barefootjs/jsx'
20
18
 
21
19
  import type { XslateMemoContext } from '../emit-context.ts'
22
- import { referencedVarsAreAvailable } from '../lib/ir-scope.ts'
23
20
 
24
21
  /** Kolon literal for a context-consumer's `createContext` default. */
25
22
  export function contextDefaultKolon(c: ContextConsumer): string {
@@ -49,77 +46,33 @@ export function generateContextConsumerSeed(ir: ComponentIR): string {
49
46
  }
50
47
 
51
48
  /**
52
- * Seed memos whose SSR default is `null` (not statically evaluable) by
53
- * computing them in-template from the already-seeded prop / signal vars
54
- * (`createMemo(() => props.value * 10)` → `: my $x = $value * 10;`). Without
55
- * this the memo's `$x` renders empty the reason
56
- * `props-reactivity-comparison` was skipped. Only emitted when every var the
57
- * lowering references is already in scope. (#1297)
49
+ * Emit `: my $<name> = <kolon>;` line-statements for every `derived` step of
50
+ * the backend-neutral SSR seed plan the scope/availability/ordering
51
+ * analysis lives in `computeSsrSeedPlan` (packages/jsx/src/ssr-seed-plan.ts);
52
+ * this only lowers each step's expression to Kolon and applies the
53
+ * backend-specific emit guards: skip an empty lowering, skip a lowering that
54
+ * references no `$var` at all (a constant init/body — e.g. a `derived` step
55
+ * with empty `frees` — keeps the existing static ssr-defaults seed instead),
56
+ * and skip a self-referencing lowering (Kolon's `my` shadows the RHS, so
57
+ * `: my $x = … $x …` would read the just-declared undefined lexical rather
58
+ * than the render var — the plan rules out SOURCE-level self-refs, but a
59
+ * lowered canonical name could still collide, so this stays as the cheap
60
+ * defense it always was). `env-reader` and `opaque` steps emit nothing (the
61
+ * runtime supplies the reader, or the adapter's ssr-defaults path already
62
+ * covers it). (#1297, #2075)
58
63
  */
59
64
  export function generateDerivedMemoSeed(ctx: XslateMemoContext, ir: ComponentIR): string {
60
- const memos = ir.metadata.memos ?? []
61
- const signals = ir.metadata.signals ?? []
62
- if (memos.length === 0 && signals.length === 0) return ''
63
- // Props seed first; each signal/memo adds its own name as it lands.
64
- const available = new Set<string>(ir.metadata.propsParams.map(p => p.name))
65
+ // Package G attached this to metadata at compile time; the `??` fallback
66
+ // only covers hand-built metadata in older tests that predate the attached
67
+ // plan same shared function, so there's no divergence from the compiler.
68
+ const plan = ir.metadata.ssrSeedPlan ?? computeSsrSeedPlan(ir.metadata)
65
69
  const lines: string[] = []
66
-
67
- // Prop/signal-derived signals (`createSignal(props.defaultOn ?? false)`):
68
- // a loop-child render gets no stash seed, so its `$on` would render nil;
69
- // and the static default can't capture the per-call prop. Seed it
70
- // in-template when the init lowers cleanly AND references an in-scope var.
71
- // Object/array/constant inits keep the existing ssr-defaults seeding.
72
- for (const signal of signals) {
73
- const kolon = tryLowerToKolon(ctx, signal.initialValue, available)
74
- // Kolon can't express `: my $x = … $x …` — declaring `my $x` makes the
75
- // RHS `$x` an undefined lexical rather than the render var. A same-name
76
- // signal (`createSignal(props.x ?? d)`, getter == prop) is just the prop
77
- // with a default, which the harness already seeds correctly from the
78
- // passed prop — skip the in-template seed for it. (Different-name
79
- // prop-derived signals like toggle's `on` from `defaultOn` are unaffected.)
80
- const refsSelf = kolon !== null && new RegExp(`\\$${signal.getter}\\b`).test(kolon)
81
- if (kolon !== null && !refsSelf) lines.push(`: my $${signal.getter} = ${kolon};`)
82
- available.add(signal.getter)
83
- }
84
-
85
- for (const memo of memos) {
86
- // Seed every memo whose body lowers cleanly — not just the ones whose
87
- // static SSR default is null. A statically-foldable prop-derived memo
88
- // (`createMemo(() => props.disabled ?? false)` → default `false`)
89
- // still depends on the per-call prop: the static stash seed bakes in
90
- // the absent-prop fold, so a caller passing `disabled => 1` would
91
- // render the default branch (#1897, select's disabled item). The
92
- // in-template recomputation reads the prop lexical already in scope;
93
- // block-bodied arrows / out-of-scope references fall back to the
94
- // static ssr-defaults seed. Same self-reference guard as the signal
95
- // loop above — Kolon's `my` shadows the render var on the RHS.
96
- const body = extractArrowBodyExpression(memo.computation)
97
- if (body !== null) {
98
- const kolon = tryLowerToKolon(ctx, body, available)
99
- const refsSelf = kolon !== null && new RegExp(`\\$${memo.name}\\b`).test(kolon)
100
- if (kolon !== null && !refsSelf) lines.push(`: my $${memo.name} = ${kolon};`)
101
- }
102
- available.add(memo.name)
70
+ for (const step of plan.steps) {
71
+ if (step.kind !== 'derived') continue
72
+ const kolon = ctx.convertExpressionToKolon(step.expr, step.parsed)
73
+ if (kolon === '' || !/\$[A-Za-z_]\w*/.test(kolon)) continue
74
+ if (new RegExp(`\\$${step.name}\\b`).test(kolon)) continue
75
+ lines.push(`: my $${step.name} = ${kolon};`)
103
76
  }
104
77
  return lines.length > 0 ? lines.join('\n') + '\n' : ''
105
78
  }
106
-
107
- /**
108
- * Lower a signal init / memo body to Kolon for an in-template SSR seed, or
109
- * `null` when it shouldn't be seeded this way: not a supported shape
110
- * (`isSupported` pre-check, so object/array literals don't fail the build),
111
- * references no in-scope var (a constant — keep ssr-defaults seeding), or
112
- * references an out-of-scope binding. (#1297)
113
- */
114
- export function tryLowerToKolon(
115
- ctx: XslateMemoContext,
116
- expr: string,
117
- available: ReadonlySet<string>,
118
- ): string | null {
119
- const trimmed = expr.trim()
120
- if (!trimmed) return null
121
- if (!isSupported(parseExpression(trimmed)).supported) return null
122
- const kolon = ctx.convertExpressionToKolon(trimmed)
123
- if (kolon === '' || !/\$[A-Za-z_]\w*/.test(kolon)) return null
124
- return referencedVarsAreAvailable(kolon, available) ? kolon : null
125
- }