@barefootjs/jsx 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.
@@ -22,28 +22,76 @@ export const ENV_SIGNAL_CLIENT_FACTORY: Record<string, string> = {
22
22
  }
23
23
 
24
24
  /**
25
- * The getter name(s) of the `searchParams` env signal in this component.
25
+ * One env signal's SSR-reader surface — the single place a future env signal
26
+ * registers itself so the adapter seed / memo paths stay open-closed:
27
+ * registering a new env signal is an analyzer factory entry + one registry
28
+ * entry here; adapter seed/memo paths consume the registry and need no edits.
29
+ */
30
+ export interface EnvSignalReader {
31
+ /** The analyzer's `envReader` key (`'search'`). */
32
+ key: string
33
+ /**
34
+ * Canonical per-request reader binding every adapter's lowering
35
+ * canonicalises to (`searchParams` → Perl `$searchParams`, Go
36
+ * `in.SearchParams` via capitalisation).
37
+ */
38
+ canonicalName: string
39
+ /** Reader method names the SSR lowerings recognise (`.get(key)`). */
40
+ methods: ReadonlySet<string>
41
+ }
42
+
43
+ /**
44
+ * Env-signal key → its {@link EnvSignalReader} descriptor. The open-closed
45
+ * contract: adding a new env signal is an analyzer factory entry
46
+ * (`ENV_SIGNAL_FACTORIES`, #2057) + one entry here — the adapter seed / memo
47
+ * paths consume this registry (via {@link envSignalReaderFor} /
48
+ * {@link envSignalLocalNames}) and need no edits.
49
+ */
50
+ export const ENV_SIGNAL_READERS: ReadonlyMap<string, EnvSignalReader> = new Map([
51
+ ['search', { key: 'search', canonicalName: 'searchParams', methods: new Set(['get']) }],
52
+ ])
53
+
54
+ /** Look up an env signal's reader descriptor by its `envReader` key, or `null` when unregistered/absent. */
55
+ export function envSignalReaderFor(key: string | undefined): EnvSignalReader | null {
56
+ if (key === undefined) return null
57
+ return ENV_SIGNAL_READERS.get(key) ?? null
58
+ }
59
+
60
+ /**
61
+ * The getter name(s) of env signal(s) in this component, optionally filtered
62
+ * to one `envReader` key.
26
63
  *
27
- * Recognised **structurally** (#2057): the env signal is now declared as a
64
+ * Recognised **structurally** (#2057): an env signal is declared as a
28
65
  * `createSignal`-shaped `const [searchParams, setSearchParams] =
29
66
  * createSearchParams()`, so the analyzer collects it into `metadata.signals`
30
- * with `envReader: 'search'` — exactly like any other signal, but tagged. This
67
+ * with `envReader: '<key>'` — exactly like any other signal, but tagged. This
31
68
  * function returns those getters (whatever the destructured name is —
32
69
  * `searchParams`, or an alias), so adapters match the reader `.get()` call
33
- * against the binding actually used, with **no `searchParams`-name allow-list**
34
- * (this supersedes the import-name matching, and the closed #2055).
70
+ * against the binding actually used, with **no name allow-list** (this
71
+ * supersedes the import-name matching, and the closed #2055).
35
72
  *
36
- * Empty when the component declares no env signal (the component keeps the
37
- * generic signal lowering).
73
+ * With `key` omitted, collects every env signal's getters regardless of
74
+ * which reader they belong to; with `key` given, only that reader's
75
+ * (`searchParamsLocalNames` is the `'search'`-filtered convenience below).
76
+ *
77
+ * Empty when the component declares no matching env signal (the component
78
+ * keeps the generic signal lowering).
38
79
  */
39
- export function searchParamsLocalNames(metadata: IRMetadata): Set<string> {
80
+ export function envSignalLocalNames(metadata: IRMetadata, key?: string): Set<string> {
40
81
  const names = new Set<string>()
41
82
  for (const s of metadata.signals) {
42
- if (s.envReader === 'search') names.add(s.getter)
83
+ if (s.envReader !== undefined && (key === undefined || s.envReader === key)) {
84
+ names.add(s.getter)
85
+ }
43
86
  }
44
87
  return names
45
88
  }
46
89
 
90
+ /** The getter name(s) of the `searchParams` env signal in this component. See {@link envSignalLocalNames}. */
91
+ export function searchParamsLocalNames(metadata: IRMetadata): Set<string> {
92
+ return envSignalLocalNames(metadata, 'search')
93
+ }
94
+
47
95
  /**
48
96
  * True when the component declares the `searchParams` env signal. Convenience
49
97
  * for adapters/harnesses that only need to gate on presence (the lowering
package/src/compiler.ts CHANGED
@@ -23,6 +23,7 @@ import { generateModuleExports, collectInlineExportedNames } from './module-expo
23
23
  import { applyCssLayerPrefix } from './css-layer-prefixer.ts'
24
24
  import { preprocessInlineJsxCallbacks } from './preprocess-inline-jsx-callbacks.ts'
25
25
  import { extractSsrDefaults } from './ssr-defaults.ts'
26
+ import { computeSsrSeedPlan } from './ssr-seed-plan.ts'
26
27
 
27
28
  /**
28
29
  * Extended compile options with required adapter
@@ -482,7 +483,7 @@ function compileMultipleComponents(
482
483
  export function buildMetadata(
483
484
  ctx: ReturnType<typeof analyzeComponent>,
484
485
  ): IRMetadata {
485
- return {
486
+ const metadata: IRMetadata = {
486
487
  componentName: ctx.componentName || 'Unknown',
487
488
  hasDefaultExport: ctx.hasDefaultExport,
488
489
  isExported: ctx.isExported,
@@ -512,6 +513,10 @@ export function buildMetadata(
512
513
  localFunctions: ctx.localFunctions,
513
514
  localConstants: ctx.localConstants,
514
515
  }
516
+ // Computed from the assembled metadata (not `ctx`): the plan reads the
517
+ // exact fields adapters see, so the two can never disagree.
518
+ metadata.ssrSeedPlan = computeSsrSeedPlan(metadata)
519
+ return metadata
515
520
  }
516
521
 
517
522
  // =============================================================================
@@ -247,8 +247,12 @@ export interface SupportResult {
247
247
  const UNSUPPORTED_METHODS = new Set([
248
248
  // Higher-order array methods. Seven of these (`filter`, `every`,
249
249
  // `some`, `find`, `findIndex`, `findLast`, `findLastIndex`) are
250
- // intercepted as `higher-order` IR before reaching this gate;
251
- // `map` is intercepted as an IRLoop. `reduce` / `reduceRight` stay
250
+ // intercepted as `higher-order` IR before reaching this gate.
251
+ // `map` is intercepted as an IRLoop when its callback returns JSX,
252
+ // and as a `CALLBACK_METHODS` evaluator lowering (`map_eval`, #2073)
253
+ // when it returns a value — it stays listed here so the fall-throughs
254
+ // (a bare `arr.map` reference, a function-reference callback) still
255
+ // refuse loudly. `reduce` / `reduceRight` stay
252
256
  // listed here so the shapes the Tier C catalogue can't lower still
253
257
  // refuse loudly: the `convertNode` call branch intercepts a matching
254
258
  // `.reduce(fn, init)` / `.reduceRight(fn, init)` into the structured
@@ -603,11 +607,12 @@ export function tsNodeToParsedExpr(node: ts.Node): ParsedExpr {
603
607
  * Higher-order array methods whose callback body the runtime evaluator drives
604
608
  * (#2018). Recognised generically as a `call` whose callee is `<recv>.<method>`
605
609
  * and whose first argument is an `arrow`; the adapter serializes the arrow body
606
- * to the evaluator. `map` is excluded — a JSX-returning `.map` is an IRLoop
607
- * upstream, and a value-returning `.map` has no template lowering.
610
+ * to the evaluator. A JSX-returning `.map` / `.flatMap` is an IRLoop upstream
611
+ * and never reaches this recognition; the value-returning `.map(cb)` form
612
+ * (e.g. `tags.map(t => \`#${t}\`).join(' ')`) lowers via `map_eval` (#2073).
608
613
  */
609
614
  export const CALLBACK_METHODS: ReadonlySet<string> = new Set([
610
- 'filter', 'every', 'some', 'find', 'findIndex', 'findLast', 'findLastIndex',
615
+ 'filter', 'map', 'every', 'some', 'find', 'findIndex', 'findLast', 'findLastIndex',
611
616
  'sort', 'toSorted', 'reduce', 'reduceRight', 'flatMap',
612
617
  ])
613
618
 
@@ -2207,8 +2212,12 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2207
2212
  // the receiver and the callback BODY are supported. Recognised before the
2208
2213
  // `UNSUPPORTED_METHODS` gate so the eval-lowered shapes aren't refused
2209
2214
  // (a BARE method reference — `arr.filter` uncalled, no arrow arg — still
2210
- // falls through to the gate). A nested callback inside the body refuses
2211
- // at the adapter's `serializeParsedExpr` purity gate, not here.
2215
+ // falls through to the gate). A nested callback inside the body is NOT
2216
+ // refused here: the evaluator refuses it (`serializeParsedExpr` null)
2217
+ // and each adapter then either lowers it faithfully (Mojo's inline
2218
+ // `grep`, Go's `len (bf_filter_eval …)`) or surfaces BF101 at its
2219
+ // predicate fallback's exact degrade points (#2038) — a blanket refusal
2220
+ // here would break the faithful shapes (#1443 PR4).
2212
2221
  const cb = asCallbackMethodCall(expr)
2213
2222
  if (cb) {
2214
2223
  const objSupport = checkSupport(cb.object)
@@ -3121,6 +3130,82 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
3121
3130
  }
3122
3131
  }
3123
3132
 
3133
+ /**
3134
+ * Rewrite every zero-arg `call` node whose callee is a bare identifier in
3135
+ * `names` into that identifier — `tag()` → `tag` — leaving everything else
3136
+ * untouched. Returns a new tree; the input is never mutated.
3137
+ *
3138
+ * Rationale: in an SSR seed/constructor context a signal/memo getter call
3139
+ * reads the already-computed SEEDED value, so `tag()` reduces to "the value
3140
+ * bound to `tag`". Materialising the call lets the runtime evaluator (which
3141
+ * refuses any non-builtin call, {@link toEvalNode}'s `evalBuiltinCalleeName`
3142
+ * gate) evaluate a predicate that reads sibling memos — e.g. a `.filter`
3143
+ * predicate `(p) => !tag() || p.tags.includes(tag())` — with the getter's
3144
+ * value supplied through the evaluator's `base_env` instead of an
3145
+ * unsupported call node. `names` is caller-supplied (typically the sibling
3146
+ * signals/memos seeded alongside the derived memo being lowered), so a call
3147
+ * to an unrelated function is left as a `call` node and still refused by the
3148
+ * evaluator's builtin gate if it reaches `serializeParsedExpr`.
3149
+ */
3150
+ export function materializeGetterCalls(expr: ParsedExpr, names: ReadonlySet<string>): ParsedExpr {
3151
+ const rw = (e: ParsedExpr): ParsedExpr => materializeGetterCalls(e, names)
3152
+ switch (expr.kind) {
3153
+ case 'call':
3154
+ if (
3155
+ expr.args.length === 0 &&
3156
+ expr.callee.kind === 'identifier' &&
3157
+ names.has(expr.callee.name)
3158
+ ) {
3159
+ return { kind: 'identifier', name: expr.callee.name }
3160
+ }
3161
+ return { kind: 'call', callee: rw(expr.callee), args: expr.args.map(rw) }
3162
+ case 'binary':
3163
+ return { kind: 'binary', op: expr.op, left: rw(expr.left), right: rw(expr.right) }
3164
+ case 'logical':
3165
+ return { kind: 'logical', op: expr.op, left: rw(expr.left), right: rw(expr.right) }
3166
+ case 'unary':
3167
+ return { kind: 'unary', op: expr.op, argument: rw(expr.argument) }
3168
+ case 'conditional':
3169
+ return {
3170
+ kind: 'conditional',
3171
+ test: rw(expr.test),
3172
+ consequent: rw(expr.consequent),
3173
+ alternate: rw(expr.alternate),
3174
+ }
3175
+ case 'member':
3176
+ return { kind: 'member', object: rw(expr.object), property: expr.property, computed: expr.computed }
3177
+ case 'index-access':
3178
+ return { kind: 'index-access', object: rw(expr.object), index: rw(expr.index) }
3179
+ case 'template-literal':
3180
+ return {
3181
+ kind: 'template-literal',
3182
+ parts: expr.parts.map(p => (p.type === 'string' ? p : { type: 'expression', expr: rw(p.expr) })),
3183
+ }
3184
+ case 'array-literal':
3185
+ return { kind: 'array-literal', elements: expr.elements.map(rw) }
3186
+ case 'array-method':
3187
+ // `flat`'s `args` is always `[]` (the depth is carried structurally in
3188
+ // `flatDepth`, not `args`) — still rewrite `object`, just skip the
3189
+ // `args.map` that every other method needs.
3190
+ if (expr.method === 'flat') return { ...expr, object: rw(expr.object) }
3191
+ return { ...expr, object: rw(expr.object), args: expr.args.map(rw) }
3192
+ case 'object-literal':
3193
+ return {
3194
+ kind: 'object-literal',
3195
+ raw: expr.raw,
3196
+ properties: expr.properties.map(p => ({ ...p, value: rw(p.value) })),
3197
+ }
3198
+ case 'arrow':
3199
+ return { kind: 'arrow', params: expr.params, body: rw(expr.body) }
3200
+ // Leaves / opaque shapes — nothing to rewrite.
3201
+ case 'identifier':
3202
+ case 'literal':
3203
+ case 'regex':
3204
+ case 'unsupported':
3205
+ return expr
3206
+ }
3207
+ }
3208
+
3124
3209
  /**
3125
3210
  * Serialize a pure-expression `ParsedExpr` (a higher-order callback body) into
3126
3211
  * the minimal JSON the runtime evaluator consumes — the format pinned by the
@@ -3201,10 +3286,20 @@ export function freeVarsInBody(body: ParsedExpr, params: ReadonlySet<string>): s
3201
3286
  // carries the ref on its `value` identifier, which is visited here.)
3202
3287
  for (const p of e.properties) visit(p.value)
3203
3288
  return
3289
+ case 'array-method':
3290
+ // Only `.includes(x)` is serializable ({@link toEvalNode}); its
3291
+ // `object` (the receiver) and `args` (the needle) are the value
3292
+ // positions serialized, so visit both when the tree reaches here
3293
+ // with that method. Every other `array-method` is non-serializable
3294
+ // and doesn't occur in a serializable body.
3295
+ if (e.method === 'includes') {
3296
+ visit(e.object)
3297
+ e.args.forEach(visit)
3298
+ }
3299
+ return
3204
3300
  // Non-serializable kinds don't occur in a serializable body
3205
3301
  // (serializeParsedExpr returns null for them); nothing to collect.
3206
3302
  case 'literal':
3207
- case 'array-method':
3208
3303
  case 'arrow':
3209
3304
  case 'regex':
3210
3305
  case 'unsupported':
@@ -3215,6 +3310,87 @@ export function freeVarsInBody(body: ParsedExpr, params: ReadonlySet<string>): s
3215
3310
  return [...found].sort()
3216
3311
  }
3217
3312
 
3313
+ /**
3314
+ * Every value-position identifier in `expr` NOT bound by an enclosing arrow's
3315
+ * own parameters — with proper lexical scoping: an arrow's params bind only
3316
+ * within that arrow's body, and nested arrows accumulate onto the enclosing
3317
+ * bound set. Unlike {@link freeVarsInBody} (which assumes a single flat param
3318
+ * set and never recurses into nested `arrow` nodes, since a serializable
3319
+ * evaluator body never contains one), this walks the full source-level tree —
3320
+ * including arrows — so a caller can ask "is this name free ANYWHERE in the
3321
+ * expression, honoring each arrow's own scope" rather than only within one
3322
+ * callback body.
3323
+ *
3324
+ * Walks the same value positions as {@link serializeParsedExpr} /
3325
+ * {@link freeVarsInBody}: call callee (skipped when it resolves to an
3326
+ * evaluator builtin — see {@link evalBuiltinCalleeName} — so `Math.floor(x)`
3327
+ * doesn't report `Math` as free) + args, binary/logical/unary operands,
3328
+ * conditional branches, a member's OBJECT only (the property name is not a
3329
+ * reference), an index-access's object + index, template-literal expression
3330
+ * parts, array-literal elements, array-method object + args, and an
3331
+ * object-literal's property VALUES (not keys). An `arrow` recurses into its
3332
+ * body with its own params added to the bound set.
3333
+ *
3334
+ * Returns `null` when the tree contains an `unsupported` node (or any other
3335
+ * shape this walk can't analyze) — the caller must fail safe rather than
3336
+ * assume nothing is free.
3337
+ */
3338
+ export function freeIdentifiers(expr: ParsedExpr): Set<string> | null {
3339
+ const free = new Set<string>()
3340
+
3341
+ function visit(e: ParsedExpr, bound: ReadonlySet<string>): boolean {
3342
+ switch (e.kind) {
3343
+ case 'literal':
3344
+ case 'regex':
3345
+ return true
3346
+ case 'identifier':
3347
+ if (!bound.has(e.name)) free.add(e.name)
3348
+ return true
3349
+ case 'call': {
3350
+ const isBuiltinCallee = evalBuiltinCalleeName(e.callee) !== null
3351
+ if (!isBuiltinCallee && !visit(e.callee, bound)) return false
3352
+ for (const a of e.args) if (!visit(a, bound)) return false
3353
+ return true
3354
+ }
3355
+ case 'member':
3356
+ return visit(e.object, bound)
3357
+ case 'index-access':
3358
+ return visit(e.object, bound) && visit(e.index, bound)
3359
+ case 'binary':
3360
+ case 'logical':
3361
+ return visit(e.left, bound) && visit(e.right, bound)
3362
+ case 'unary':
3363
+ return visit(e.argument, bound)
3364
+ case 'conditional':
3365
+ return visit(e.test, bound) && visit(e.consequent, bound) && visit(e.alternate, bound)
3366
+ case 'template-literal':
3367
+ for (const p of e.parts) {
3368
+ if (p.type === 'expression' && !visit(p.expr, bound)) return false
3369
+ }
3370
+ return true
3371
+ case 'array-literal':
3372
+ for (const el of e.elements) if (!visit(el, bound)) return false
3373
+ return true
3374
+ case 'array-method':
3375
+ if (!visit(e.object, bound)) return false
3376
+ for (const a of e.args) if (!visit(a, bound)) return false
3377
+ return true
3378
+ case 'object-literal':
3379
+ for (const p of e.properties) if (!visit(p.value, bound)) return false
3380
+ return true
3381
+ case 'arrow': {
3382
+ const inner = new Set(bound)
3383
+ for (const p of e.params) inner.add(p)
3384
+ return visit(e.body, inner)
3385
+ }
3386
+ case 'unsupported':
3387
+ return false
3388
+ }
3389
+ }
3390
+
3391
+ return visit(expr, new Set()) ? free : null
3392
+ }
3393
+
3218
3394
  // Operators the evaluator implements (Go `eval.go` evalBinary / evalUnary, Perl
3219
3395
  // `Evaluator.pm` _binary / _unary). An op outside these sets — loose `==`,
3220
3396
  // `instanceof`, `**`, bitwise/shift, or the parser's `'unknown'` sentinel — is
@@ -3346,10 +3522,24 @@ function toEvalNode(e: ParsedExpr): Record<string, unknown> | null {
3346
3522
  }
3347
3523
  return { kind: 'object-literal', properties }
3348
3524
  }
3525
+ case 'array-method': {
3526
+ // `.includes(x)` is the one `array-method` the evaluator executes
3527
+ // (Go `eval.go` / Perl `Evaluator.pm`, includes support): the
3528
+ // receiver-type dispatch (array SameValueZero membership vs string
3529
+ // substring) happens at evaluator runtime, same as the SSR template
3530
+ // lowering's `bf_includes` / `$bf->includes`. Every other
3531
+ // `array-method` (`join`, `slice`, `flat`, …) is outside the
3532
+ // evaluator's surface and refuses below.
3533
+ if (e.method === 'includes' && e.args.length === 1) {
3534
+ const object = toEvalNode(e.object)
3535
+ const arg = toEvalNode(e.args[0])
3536
+ return object && arg ? { kind: 'array-method', method: 'includes', object, args: [arg] } : null
3537
+ }
3538
+ return null
3539
+ }
3349
3540
  // Outside the evaluator's pure-expression surface — refuse so the caller
3350
3541
  // falls back to BF101 / `@client`. A nested `arrow` (a callback inside the
3351
3542
  // body) is refused here, keeping the evaluator non-recursive.
3352
- case 'array-method':
3353
3543
  case 'arrow':
3354
3544
  case 'regex':
3355
3545
  case 'unsupported':
package/src/index.ts CHANGED
@@ -12,6 +12,10 @@ export type { CompileResult, CompileOptions, CompileOptionsWithAdapter, FileOutp
12
12
  export { extractSsrDefaults } from './ssr-defaults.ts'
13
13
  export type { SsrDefault } from './ssr-defaults.ts'
14
14
 
15
+ // Backend-neutral SSR seed plan (in-template derived signal/memo seeding)
16
+ export { computeSsrSeedPlan } from './ssr-seed-plan.ts'
17
+ export type { SsrSeedPlan, SsrSeedStep } from './ssr-seed-plan.ts'
18
+
15
19
  // Pure IR types
16
20
  export type {
17
21
  ComponentIR,
@@ -77,7 +81,8 @@ export type { JsxAdapterConfig } from './adapters/jsx-adapter.ts'
77
81
  export { rewriteImportsForTemplate } from './adapters/template-imports.ts'
78
82
  export { emitParsedExpr } from './adapters/parsed-expr-emitter.ts'
79
83
  export type { ParsedExprEmitter, HigherOrderMethod, ArrayMethod, SortMethod, LiteralType } from './adapters/parsed-expr-emitter.ts'
80
- export { importsSearchParams, searchParamsLocalNames, queryHrefLocalNames, matchSearchParamsMethodCall } from './adapters/env-signal.ts'
84
+ export { importsSearchParams, searchParamsLocalNames, envSignalLocalNames, envSignalReaderFor, ENV_SIGNAL_READERS, queryHrefLocalNames, matchSearchParamsMethodCall } from './adapters/env-signal.ts'
85
+ export type { EnvSignalReader } from './adapters/env-signal.ts'
81
86
  export { matchQueryHrefCall, queryHrefArgs, type QueryHrefCall, type QueryHrefTriple } from './query-href-lowering.ts'
82
87
  export {
83
88
  registerLoweringPlugin,
@@ -276,7 +281,7 @@ export {
276
281
  export { ErrorCodes, createError, formatError, generateCodeFrame } from './errors.ts'
277
282
 
278
283
  // Expression Parser
279
- export { parseExpression, tsNodeToParsedExpr, asCallbackMethodCall, CALLBACK_METHODS, sortComparatorFromArrow, serializeParsedExpr, freeVarsInBody, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, parseBlockBodyTolerant, foldBlockToExpr, predicateTernaryToLogical, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, parseProviderObjectLiteral, type ProviderObjectMember, type FoldBlockOptions } from './expression-parser.ts'
284
+ export { parseExpression, tsNodeToParsedExpr, asCallbackMethodCall, CALLBACK_METHODS, sortComparatorFromArrow, serializeParsedExpr, freeVarsInBody, freeIdentifiers, materializeGetterCalls, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, parseBlockBodyTolerant, foldBlockToExpr, predicateTernaryToLogical, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, parseProviderObjectLiteral, type ProviderObjectMember, type FoldBlockOptions } from './expression-parser.ts'
280
285
  export type { StyleObjectEntry } from './expression-parser.ts'
281
286
  export type { ParsedExpr, ObjectLiteralProperty, ParsedStatement, SortComparator, SortKey, FlatDepth, SupportLevel, SupportResult, TemplatePart } from './expression-parser.ts'
282
287
  export { buildLoopChainExpr } from './loop-chain.ts'
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Backend-neutral SSR seed plan — which signals/memos an adapter may seed
3
+ * in-template at SSR time, and from what scope.
4
+ *
5
+ * Design principle: the IR/analyzer side ANALYZES and attaches structured
6
+ * information; adapters only EMIT. The "is this binding derivable from names
7
+ * already in template scope" decision used to live (triplicated) in the
8
+ * template adapters' seed paths; this module computes it once, on the IR, so
9
+ * every adapter consumes the same plan and only supplies its own syntax.
10
+ *
11
+ * Ordering / acyclicity guarantee: `steps` lists the component's signals
12
+ * first, then its memos, each group in declaration order (matching
13
+ * `IRMetadata.signals` / `IRMetadata.memos`). A binding's name only enters
14
+ * the scope set AFTER its own step is decided, so a `derived` step's `frees`
15
+ * can only name `baseScope` entries or EARLIER steps — self- and
16
+ * forward-references are rejected by construction, and a consumer emitting
17
+ * the steps top-to-bottom never reads an undeclared local.
18
+ *
19
+ * Module-scope pure-string consts count as in-scope (they are part of
20
+ * `baseScope`) because every adapter compile-time-inlines them to their
21
+ * literal value — `collectModuleStringConsts` is the shared source of that
22
+ * set — so a reference to one is never a template-variable read.
23
+ *
24
+ * A `derived` step with EMPTY `frees` is a constant expression (e.g.
25
+ * `createSignal('b')`): the plan still classifies it as derived because the
26
+ * expression is fully analyzable; emit-side constant-skipping (adapters keep
27
+ * their existing ssr-defaults seeding for such inits) is an adapter concern,
28
+ * not a plan concern. Likewise the plan makes no backend-specific choices —
29
+ * no target-variable checks, no self-shadowing rules, no per-backend shape
30
+ * catalogs — those stay in the adapters.
31
+ */
32
+
33
+ import { collectModuleStringConsts } from './augment-inherited-props.ts'
34
+ import { envSignalReaderFor, type EnvSignalReader } from './adapters/env-signal.ts'
35
+ import {
36
+ extractArrowBodyExpression,
37
+ freeIdentifiers,
38
+ isSupported,
39
+ parseExpression,
40
+ type ParsedExpr,
41
+ } from './expression-parser.ts'
42
+ import type { IRMetadata } from './types.ts'
43
+
44
+ /**
45
+ * One binding in component declaration order (signals first, then memos —
46
+ * matching `IRMetadata` order and the adapters' iteration).
47
+ *
48
+ * - `env-reader`: an env signal whose `envReader` key resolves in the shared
49
+ * registry (`envSignalReaderFor`). The runtime provides the per-request
50
+ * reader, so there is nothing to seed; the name still enters scope so a
51
+ * later derived step may reference it. An `envReader` key UNKNOWN to the
52
+ * registry falls through to the normal derived/opaque rules instead.
53
+ * - `derived`: the binding's value expression is a supported shape whose free
54
+ * identifiers are all in scope at this point (baseScope + earlier steps) —
55
+ * an adapter may seed it in-template by lowering `parsed`/`expr`.
56
+ * - `opaque`: not seedable this way (empty init, unsupported shape,
57
+ * unanalyzable free set, out-of-scope reference, or a block-bodied memo).
58
+ * The name still enters scope for later steps; adapters keep their static
59
+ * ssr-defaults seeding for it.
60
+ */
61
+ export type SsrSeedStep =
62
+ | { kind: 'env-reader'; name: string; reader: EnvSignalReader }
63
+ | { kind: 'derived'; name: string; origin: 'signal' | 'memo'; expr: string; parsed: ParsedExpr; frees: string[] }
64
+ | { kind: 'opaque'; name: string; origin: 'signal' | 'memo' }
65
+
66
+ export interface SsrSeedPlan {
67
+ /**
68
+ * Names in scope before any step: props params, the props-object name
69
+ * (when the component takes an undestructured props object), and module
70
+ * pure-string consts (compile-time inlined by every adapter).
71
+ */
72
+ baseScope: string[]
73
+ steps: SsrSeedStep[]
74
+ }
75
+
76
+ /**
77
+ * Classify one value expression against the current scope: `derived` when it
78
+ * parses to a supported shape whose free identifiers are all `available`
79
+ * (an unanalyzable free set — `freeIdentifiers` → null — fails safe to
80
+ * opaque). The scope check runs over the parsed SOURCE tree, so a shadowed
81
+ * name (`items.filter((p) => p.ok) && p`, where the trailing `p` is a
82
+ * different, unbound reference from the callback's own param) is rejected.
83
+ */
84
+ function classify(
85
+ name: string,
86
+ origin: 'signal' | 'memo',
87
+ expr: string,
88
+ parsed: ParsedExpr,
89
+ available: ReadonlySet<string>,
90
+ ): SsrSeedStep {
91
+ if (!isSupported(parsed).supported) return { kind: 'opaque', name, origin }
92
+ const frees = freeIdentifiers(parsed)
93
+ if (frees === null) return { kind: 'opaque', name, origin }
94
+ for (const free of frees) {
95
+ if (!available.has(free)) return { kind: 'opaque', name, origin }
96
+ }
97
+ return { kind: 'derived', name, origin, expr, parsed, frees: [...frees] }
98
+ }
99
+
100
+ /**
101
+ * Compute the component's SSR seed plan from its metadata. See the module
102
+ * doc for the contract. Memo steps are gated to EXPRESSION-BODIED memos
103
+ * (`extractArrowBodyExpression` returns the body): a block-bodied memo is
104
+ * `opaque` even when the analyzer folded it to a `parsed` expression.
105
+ */
106
+ export function computeSsrSeedPlan(metadata: IRMetadata): SsrSeedPlan {
107
+ const baseScope: string[] = metadata.propsParams.map(p => p.name)
108
+ if (metadata.propsObjectName) baseScope.push(metadata.propsObjectName)
109
+ for (const name of collectModuleStringConsts(metadata.localConstants).keys()) {
110
+ baseScope.push(name)
111
+ }
112
+
113
+ const available = new Set<string>(baseScope)
114
+ const steps: SsrSeedStep[] = []
115
+
116
+ for (const signal of metadata.signals) {
117
+ if (signal.envReader) {
118
+ const reader = envSignalReaderFor(signal.envReader)
119
+ if (reader) {
120
+ steps.push({ kind: 'env-reader', name: signal.getter, reader })
121
+ available.add(signal.getter)
122
+ continue
123
+ }
124
+ }
125
+ const expr = signal.initialValue.trim()
126
+ steps.push(
127
+ expr === ''
128
+ ? { kind: 'opaque', name: signal.getter, origin: 'signal' }
129
+ : classify(signal.getter, 'signal', expr, parseExpression(expr), available),
130
+ )
131
+ available.add(signal.getter)
132
+ }
133
+
134
+ for (const memo of metadata.memos) {
135
+ const body = extractArrowBodyExpression(memo.computation)
136
+ const expr = body?.trim() ?? ''
137
+ steps.push(
138
+ expr === ''
139
+ ? { kind: 'opaque', name: memo.name, origin: 'memo' }
140
+ : classify(memo.name, 'memo', expr, memo.parsed ?? parseExpression(expr), available),
141
+ )
142
+ available.add(memo.name)
143
+ }
144
+
145
+ return { baseScope, steps }
146
+ }
package/src/types.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { ParsedExpr, ParsedStatement } from './expression-parser.ts'
8
+ import type { SsrSeedPlan } from './ssr-seed-plan.ts'
8
9
 
9
10
  /**
10
11
  * Loop-hoisted sort comparator for the `.sort().map()` / `.toSorted().map()`
@@ -1526,6 +1527,16 @@ export interface IRMetadata {
1526
1527
  * resolves the compiled module rather than the source `.tsx`.
1527
1528
  */
1528
1529
  clientSignalImportSources?: Set<string>
1530
+ /**
1531
+ * Backend-neutral SSR seed plan: per-binding derived/opaque/env-reader
1532
+ * classification in declaration order, plus the base scope, computed by
1533
+ * `computeSsrSeedPlan` from this metadata. Attached by `buildMetadata` and
1534
+ * serialized into IR JSON like the rest of the metadata; template adapters
1535
+ * consume it instead of re-deriving scope/derivability themselves (their
1536
+ * target-syntax choices — lowering, self-shadow rules, constant-emit
1537
+ * guards — stay adapter-side).
1538
+ */
1539
+ ssrSeedPlan?: SsrSeedPlan
1529
1540
  }
1530
1541
 
1531
1542
  // =============================================================================