@barefootjs/mojolicious 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.
@@ -0,0 +1,203 @@
1
+ // Stock-route smoke test for the Mojo scaffold contract (#2126).
2
+ //
3
+ // The scaffold's happy path is: `npm run dev` (which starts `bf build
4
+ // --watch` and morbo *concurrently*), then open `/`. That page renders a
5
+ // component whose EP template reads props/signals as bare scalars
6
+ // (`% my $count = ($initial // 0);`), and Mojo templates compile under
7
+ // `use strict` — so every one of those scalars must be declared by the
8
+ // time the template runs. Two production bugs hid in that path:
9
+ //
10
+ // 1. The route passes no props, so the stash seeding has to come from
11
+ // the manifest's `ssrDefaults` via the plugin's `before_render`
12
+ // hook — including props only referenced through `props.X` reads.
13
+ // 2. The plugin used to load the manifest once at register time, so
14
+ // booting before the first build wrote `manifest.json` disabled
15
+ // auto-init for the server's lifetime: every render of `/` died
16
+ // with `Global symbol "$initial" requires explicit package name`
17
+ // (HTTP 500) until a manual restart.
18
+ //
19
+ // This test boots a real Mojolicious app whose `app.pl` mirrors the
20
+ // scaffold's (plugin + dist/templates renderer path + a `/` route that
21
+ // passes NO props) against a template + manifest produced by the real
22
+ // compiler, and asserts the page renders — in both boot orders. It is
23
+ // the in-repo equivalent of "scaffold → build → curl / expects 200".
24
+ //
25
+ // Runs only when `perl` with Mojolicious is installed (same skip policy
26
+ // as the conformance render tests). The DevReload plugin is omitted:
27
+ // it's dev-only plumbing with its own coverage in t/dev_reload.t.
28
+
29
+ import { describe, test, expect, beforeAll } from 'bun:test'
30
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
31
+ import { tmpdir } from 'node:os'
32
+ import path from 'node:path'
33
+ import { compileJSX } from '@barefootjs/jsx'
34
+ import { MojoAdapter } from '../adapter/mojo-adapter'
35
+
36
+ const MOJO_LIB_DIR = path.resolve(import.meta.dir, '../../lib')
37
+ const PERL_CORE_LIB_DIR = path.resolve(import.meta.dir, '../../../adapter-perl/lib')
38
+
39
+ function perlWithMojoAvailable(): boolean {
40
+ try {
41
+ const proc = Bun.spawnSync(['perl', '-MMojolicious', '-MTest::Mojo', '-e1'], {
42
+ env: process.env,
43
+ })
44
+ return proc.exitCode === 0
45
+ } catch {
46
+ return false
47
+ }
48
+ }
49
+
50
+ const PERL_AVAILABLE = perlWithMojoAvailable()
51
+
52
+ // The starter-Counter shape: a prop read only through `props.X ?? …`
53
+ // (never passed by the stock route) feeding a signal, plus a memo.
54
+ // Exactly the pattern that dies under strict when seeding breaks.
55
+ // `props.label` is read directly in the body (no signal/memo in
56
+ // between) — that flattens to a bare `<%= $label %>` too, so it pins
57
+ // that ssrDefaults seeds every declared prop, not just the ones a
58
+ // signal initializer references.
59
+ const COUNTER_TSX = `'use client'
60
+
61
+ import { createSignal, createMemo } from '@barefootjs/client'
62
+
63
+ interface CounterProps {
64
+ initial?: number
65
+ label?: string
66
+ }
67
+
68
+ export function Counter(props: CounterProps) {
69
+ const [count, setCount] = createSignal(props.initial ?? 0)
70
+ const doubled = createMemo(() => count() * 2)
71
+
72
+ return (
73
+ <div className="counter">
74
+ <p className="counter-label">{props.label}</p>
75
+ <p className="counter-value">count: {count()}</p>
76
+ <p className="counter-doubled">doubled: {doubled()}</p>
77
+ <button onClick={() => setCount(n => n + 1)}>+1</button>
78
+ </div>
79
+ )
80
+ }
81
+ `
82
+
83
+ // Mirrors the scaffold's app.pl essentials. The `/` route deliberately
84
+ // passes NO props — the manifest seeding alone must carry it. The
85
+ // second route pins that a caller-supplied prop still wins.
86
+ const APP_PL = `#!/usr/bin/env perl
87
+ use Mojolicious::Lite -signatures;
88
+
89
+ plugin 'BarefootJS';
90
+
91
+ app->renderer->paths->[0] = app->home->child('dist/templates');
92
+
93
+ get '/' => sub ($c) {
94
+ $c->render(template => 'Counter', layout => 'default');
95
+ };
96
+
97
+ get '/with-prop' => sub ($c) {
98
+ $c->render(template => 'Counter', layout => 'default', initial => 5);
99
+ };
100
+
101
+ app->start;
102
+
103
+ __DATA__
104
+
105
+ @@ layouts/default.html.ep
106
+ <!DOCTYPE html>
107
+ <html><body>
108
+ <main><%== content %></main>
109
+ %== $c->bf->scripts
110
+ </body></html>
111
+ `
112
+
113
+ // Test::Mojo harness. SMOKE_SCENARIO picks the boot order:
114
+ // manifest-at-boot — the manifest exists before the app loads
115
+ // (server restarted after a completed build).
116
+ // manifest-after-boot — the app loads first, the manifest appears
117
+ // afterwards (the concurrent `bf build --watch`
118
+ // + morbo dev race on a fresh scaffold).
119
+ const SMOKE_PL = `use Mojo::Base -strict;
120
+ use Test::More;
121
+ use Test::Mojo;
122
+ use Mojo::File qw(curfile path);
123
+
124
+ my $home = curfile->dirname;
125
+ my $scenario = $ENV{SMOKE_SCENARIO} // 'manifest-at-boot';
126
+ my $staged = $home->child('manifest.staged.json');
127
+ my $manifest = $home->child('dist/templates/manifest.json');
128
+
129
+ unlink "$manifest";
130
+ $staged->copy_to("$manifest") if $scenario eq 'manifest-at-boot';
131
+
132
+ my $t = Test::Mojo->new($home->child('app.pl'));
133
+
134
+ $staged->copy_to("$manifest") if $scenario eq 'manifest-after-boot';
135
+
136
+ # Stock route: no props passed — manifest ssrDefaults must seed every
137
+ # bare template scalar ($initial included) or strict mode 500s.
138
+ $t->get_ok('/')->status_is(200)
139
+ ->text_like('p.counter-value', qr/count:\\s*0/)
140
+ ->text_like('p.counter-doubled', qr/doubled:\\s*0/)
141
+ ->content_like(qr/Counter\\.client\\.js/, 'auto-init registered the client bundle');
142
+
143
+ # Caller-supplied prop wins over the manifest default.
144
+ $t->get_ok('/with-prop')->status_is(200)
145
+ ->text_like('p.counter-value', qr/count:\\s*5/)
146
+ ->text_like('p.counter-doubled', qr/doubled:\\s*10/);
147
+
148
+ done_testing;
149
+ `
150
+
151
+ describe.skipIf(!PERL_AVAILABLE)('Mojo scaffold stock route (#2126)', () => {
152
+ let appDir: string
153
+
154
+ beforeAll(() => {
155
+ appDir = mkdtempSync(path.join(tmpdir(), 'bf-mojo-stock-route-'))
156
+ mkdirSync(path.join(appDir, 'dist/templates'), { recursive: true })
157
+
158
+ const result = compileJSX(COUNTER_TSX, 'Counter.tsx', { adapter: new MojoAdapter() })
159
+ const template = result.files.find(f => f.type === 'markedTemplate')
160
+ const ssrDefaults = result.files.find(f => f.type === 'ssrDefaults')
161
+ if (!template || !ssrDefaults) throw new Error('compileJSX produced no template/ssrDefaults')
162
+
163
+ writeFileSync(path.join(appDir, 'dist/templates/Counter.html.ep'), template.content)
164
+ // Same entry shape `bf build` writes to dist/templates/manifest.json.
165
+ writeFileSync(
166
+ path.join(appDir, 'manifest.staged.json'),
167
+ JSON.stringify({
168
+ Counter: {
169
+ markedTemplate: 'templates/Counter.html.ep',
170
+ ssrDefaults: JSON.parse(ssrDefaults.content),
171
+ },
172
+ }),
173
+ )
174
+ writeFileSync(path.join(appDir, 'app.pl'), APP_PL)
175
+ writeFileSync(path.join(appDir, 'smoke.pl'), SMOKE_PL)
176
+ })
177
+
178
+ async function runScenario(scenario: string): Promise<void> {
179
+ const perl5lib = [MOJO_LIB_DIR, PERL_CORE_LIB_DIR, process.env.PERL5LIB]
180
+ .filter(Boolean)
181
+ .join(':')
182
+ const proc = Bun.spawn(['perl', 'smoke.pl'], {
183
+ cwd: appDir,
184
+ env: { ...process.env, PERL5LIB: perl5lib, SMOKE_SCENARIO: scenario },
185
+ stdout: 'pipe',
186
+ stderr: 'pipe',
187
+ })
188
+ const [stdout, stderr, exitCode] = await Promise.all([
189
+ new Response(proc.stdout).text(),
190
+ new Response(proc.stderr).text(),
191
+ proc.exited,
192
+ ])
193
+ expect(exitCode, `TAP output:\n${stdout}\n${stderr}`).toBe(0)
194
+ }
195
+
196
+ test('renders / with the manifest present at boot', async () => {
197
+ await runScenario('manifest-at-boot')
198
+ })
199
+
200
+ test('renders / when the manifest appears after boot (concurrent dev-startup race)', async () => {
201
+ await runScenario('manifest-after-boot')
202
+ })
203
+ })
@@ -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,
@@ -402,7 +423,28 @@ export function renderSortMethod(recv: string, c: SortComparator): string {
402
423
  // `.flat(depth?)` → `bf->flat($recv, $depth)`. The `Infinity` form lowers
403
424
  // to the `-1` sentinel (flatten fully); a finite depth flattens that many
404
425
  // levels (`0` = shallow copy). See `sub flat` in BarefootJS.pm. (#1448)
405
- export function renderFlatMethod(recv: string, depth: FlatDepth): string {
426
+ export function renderFlatMethod(
427
+ recv: string,
428
+ depth: FlatDepth | { expr: ParsedExpr },
429
+ emit: (e: ParsedExpr) => string,
430
+ ): string {
431
+ if (typeof depth === 'object') {
432
+ // Dynamic depth (#2094): routed to a SEPARATE runtime helper
433
+ // (`bf->flat_dynamic`), not `bf->flat`. The literal-depth path below
434
+ // already uses `-1` as a compile-time SENTINEL baked into the template
435
+ // source meaning "the source literally said `Infinity`". A genuinely
436
+ // dynamic depth that happens to evaluate to `-1` at render time means
437
+ // the OPPOSITE in real JS (`[1,[2]].flat(-1)` never recurses — same as
438
+ // `.flat(0)`). Since both paths would otherwise hand the same
439
+ // literal-looking argument to one shared helper, that helper couldn't
440
+ // tell which case it's in — so `flat_dynamic` performs its own
441
+ // `ToIntegerOrInfinity`-style coercion (truncate toward zero; negative
442
+ // -> 0; NaN/non-numeric -> 0; +Infinity or a huge finite value ->
443
+ // flatten fully) and then delegates to `flat`. See `sub flat_dynamic`
444
+ // in BarefootJS.pm and the Go reference (`FlatDynamicDepth`/
445
+ // `coerceFlatDepth` in bf.go).
446
+ return `bf->flat_dynamic(${recv}, ${emit(depth.expr)})`
447
+ }
406
448
  const d = depth === 'infinity' ? -1 : depth
407
449
  return `bf->flat(${recv}, ${d})`
408
450
  }
@@ -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
 
@@ -212,8 +232,12 @@ export class MojoFilterEmitter implements ParsedExprEmitter {
212
232
  return renderArrayMethod(method, object, args, emit)
213
233
  }
214
234
 
215
- flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
216
- return renderFlatMethod(emit(object), depth)
235
+ flatMethod(
236
+ object: ParsedExpr,
237
+ depth: FlatDepth | { expr: ParsedExpr },
238
+ emit: (e: ParsedExpr) => string,
239
+ ): string {
240
+ return renderFlatMethod(emit(object), depth, emit)
217
241
  }
218
242
 
219
243
  conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
@@ -448,6 +472,18 @@ export class MojoTopLevelEmitter implements ParsedExprEmitter {
448
472
  return "''"
449
473
  }
450
474
 
475
+ // Value-producing map (#2073): eval-only; BF101 when the projection is
476
+ // outside the surface. (The JSX-returning `.map` is an IRLoop upstream.)
477
+ if (method === 'map') {
478
+ const evalForm = renderMapEval(recv, body, params[0], emit)
479
+ if (evalForm !== null) return evalForm
480
+ this.ctx._recordExprBF101(
481
+ `.map(...) projection is not lowerable to a template map`,
482
+ `Pre-compute the projection in the route handler, or mark the position @client-only.`,
483
+ )
484
+ return "''"
485
+ }
486
+
451
487
  // Predicate methods: filter / find / every / some / findIndex / findLast /
452
488
  // findLastIndex. Eval-first (#2018 P2), then the inline `grep` / `bf->find`
453
489
  // fallback for a predicate the evaluator can't model.
@@ -527,8 +563,12 @@ export class MojoTopLevelEmitter implements ParsedExprEmitter {
527
563
  return renderArrayMethod(method, object, args, emit)
528
564
  }
529
565
 
530
- flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
531
- return renderFlatMethod(emit(object), depth)
566
+ flatMethod(
567
+ object: ParsedExpr,
568
+ depth: FlatDepth | { expr: ParsedExpr },
569
+ emit: (e: ParsedExpr) => string,
570
+ ): string {
571
+ return renderFlatMethod(emit(object), depth, emit)
532
572
  }
533
573
 
534
574
  conditional(
@@ -596,12 +636,16 @@ export class MojoTopLevelEmitter implements ParsedExprEmitter {
596
636
  return "''"
597
637
  }
598
638
 
599
- objectLiteral(_properties: ObjectLiteralProperty[], _raw: string, _emit: (e: ParsedExpr) => string): string {
600
- // Mirror `unsupported`: a bare object literal reaching the dispatcher
601
- // lowers to the safe Perl empty-string literal, exactly as before the
602
- // `object-literal` kind existed (byte-identical; Roadmap A-1). Object
603
- // values that round-trip to a Perl hashref go through the dedicated
604
- // `objectLiteralToPerlHashref` lowering in the conditional/attr paths.
605
- return "''"
639
+ objectLiteral(properties: ObjectLiteralProperty[], _raw: string, _emit: (e: ParsedExpr) => string): string {
640
+ // The shared `isSupported` gate only ever lets this dispatcher see an
641
+ // object literal as the EMPTY (`?? {}`) fallback operand of `??`
642
+ // (expression-parser.ts, `logical` case) any other object literal is
643
+ // refused before reaching here. Emit Perl's real empty hashref literal,
644
+ // matching the `'{}'` convention `objectLiteralToPerlHashref` already
645
+ // uses for the zero-property case in the spread path. A populated
646
+ // literal is structurally unreachable given the gate, but still
647
+ // degrades safely to the pre-existing empty-string sentinel rather
648
+ // than silently dropping keys.
649
+ return properties.length === 0 ? '{}' : "''"
606
650
  }
607
651
  }
@@ -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
- }
@@ -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 { MojoMemoContext } from '../emit-context.ts'
22
- import { referencedVarsAreAvailable } from '../lib/ir-scope.ts'
23
20
 
24
21
  /** Perl literal for a context-consumer's `createContext` default. */
25
22
  export function contextDefaultPerl(c: ContextConsumer): string {
@@ -49,78 +46,28 @@ 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
- * Targets the prop-derived memo shape (`createMemo(() => props.value * 10)`)
55
- * that the static `extractSsrDefaults` evaluator can't fold without this
56
- * the memo's `$x` renders empty (the reason `props-reactivity-comparison`
57
- * was skipped). Only emitted when the lowered expression references vars the
58
- * template already has in scope (props params + signals + prior memos), so a
59
- * memo over an out-of-scope binding stays on the null path rather than
60
- * tripping Perl strict mode. (#1297)
49
+ * Emit `% my $<name> = <perl>;` seed lines for every `derived` step of the
50
+ * backend-neutral SSR seed plan the scope/availability/ordering analysis
51
+ * lives in `computeSsrSeedPlan` (packages/jsx/src/ssr-seed-plan.ts); this
52
+ * only lowers each step's expression to Perl and applies the two
53
+ * backend-specific emit guards: skip an empty lowering, and skip a lowering
54
+ * that references no `$var` at all (a constant init/body e.g. a `derived`
55
+ * step with empty `frees` keeps the existing static ssr-defaults seed
56
+ * instead). `env-reader` and `opaque` steps emit nothing (the runtime
57
+ * supplies the reader, or the adapter's ssr-defaults path already covers it).
58
+ * (#1297, #2075)
61
59
  */
62
60
  export function generateDerivedMemoSeed(ctx: MojoMemoContext, ir: ComponentIR): string {
63
- const memos = ir.metadata.memos ?? []
64
- const signals = ir.metadata.signals ?? []
65
- if (memos.length === 0 && signals.length === 0) return ''
66
- // Props seed first; each signal/memo adds its own name as it lands so a
67
- // later one can reference an earlier one.
68
- const available = new Set<string>(ir.metadata.propsParams.map(p => p.name))
61
+ // Package G attached this to metadata at compile time; the `??` fallback
62
+ // only covers hand-built metadata in older tests that predate the attached
63
+ // plan same shared function, so there's no divergence from the compiler.
64
+ const plan = ir.metadata.ssrSeedPlan ?? computeSsrSeedPlan(ir.metadata)
69
65
  const lines: string[] = []
70
-
71
- // Prop/signal-derived signals (`createSignal(props.defaultOn ?? false)`):
72
- // a loop-child render receives no stash seed for the signal, so its `$on`
73
- // would trip strict mode; and even when an entry render seeds it, the
74
- // static default can't capture the per-call prop. Seed it in-template from
75
- // the passed prop — but ONLY when the init lowers cleanly AND references an
76
- // in-scope var (i.e. it's genuinely derived). Object/array/constant inits
77
- // (`createSignal({…})`, `createSignal([…])`, `createSignal('b')`) keep the
78
- // existing ssr-defaults seeding, so the spread / loop fixtures are
79
- // untouched.
80
- for (const signal of signals) {
81
- const perl = tryLowerToPerl(ctx, signal.initialValue, available)
82
- if (perl !== null) lines.push(`% my $${signal.getter} = ${perl};`)
83
- available.add(signal.getter)
84
- }
85
-
86
- for (const memo of memos) {
87
- // Seed every memo whose body lowers cleanly — not just the ones whose
88
- // static SSR default is null. A statically-foldable prop-derived memo
89
- // (`createMemo(() => props.disabled ?? false)` → default `false`)
90
- // still depends on the per-call prop: the static stash seed bakes in
91
- // the absent-prop fold, so a caller passing `disabled => 1` would
92
- // render the default branch (#1897, select's disabled item). The
93
- // in-template recomputation reads the prop lexical the stash already
94
- // seeded, so it's correct per call; block-bodied arrows /
95
- // out-of-scope references fall back to the static ssr-defaults seed.
96
- const body = extractArrowBodyExpression(memo.computation)
97
- if (body !== null) {
98
- const perl = tryLowerToPerl(ctx, body, available)
99
- if (perl !== null) lines.push(`% my $${memo.name} = ${perl};`)
100
- }
101
- available.add(memo.name)
66
+ for (const step of plan.steps) {
67
+ if (step.kind !== 'derived') continue
68
+ const perl = ctx.convertExpressionToPerl(step.expr, step.parsed)
69
+ if (perl === '' || !/\$[A-Za-z_]\w*/.test(perl)) continue
70
+ lines.push(`% my $${step.name} = ${perl};`)
102
71
  }
103
72
  return lines.length > 0 ? lines.join('\n') + '\n' : ''
104
73
  }
105
-
106
- /**
107
- * Lower a signal init / memo body to Perl for an in-template SSR seed, or
108
- * `null` when it shouldn't be seeded this way. Returns null — without
109
- * recording a BF101 — when the expression isn't a supported shape
110
- * (`isSupported` pre-check, so object/array literals don't fail the build),
111
- * when the lowering references no in-scope var (a constant — keep the
112
- * existing ssr-defaults seeding), or when it references an out-of-scope
113
- * binding. (#1297)
114
- */
115
- export function tryLowerToPerl(
116
- ctx: MojoMemoContext,
117
- expr: string,
118
- available: ReadonlySet<string>,
119
- ): string | null {
120
- const trimmed = expr.trim()
121
- if (!trimmed) return null
122
- if (!isSupported(parseExpression(trimmed)).supported) return null
123
- const perl = ctx.convertExpressionToPerl(trimmed)
124
- if (perl === '' || !/\$[A-Za-z_]\w*/.test(perl)) return null
125
- return referencedVarsAreAvailable(perl, available) ? perl : null
126
- }