@barefootjs/jsx 0.32.0 → 0.33.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.
Files changed (72) hide show
  1. package/dist/adapters/dangerous-inner-html.d.ts.map +1 -1
  2. package/dist/adapters/parsed-expr-emitter.d.ts +22 -0
  3. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/errors.d.ts +1 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/expression-parser.d.ts +29 -6
  8. package/dist/expression-parser.d.ts.map +1 -1
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +274 -80
  12. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +8 -0
  15. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +12 -0
  18. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +1 -0
  20. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/imports.d.ts +2 -2
  25. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/phases/provider-and-child-inits.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/types.d.ts +21 -0
  28. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  29. package/dist/query-href-lowering.d.ts.map +1 -1
  30. package/dist/ssr-seed-plan.d.ts.map +1 -1
  31. package/dist/static-literal.d.ts.map +1 -1
  32. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +2 -4
  35. package/src/__tests__/child-components-in-map.test.ts +11 -3
  36. package/src/__tests__/client-js-generation.test.ts +37 -1
  37. package/src/__tests__/expression-parser.test.ts +43 -6
  38. package/src/__tests__/inline-jsx-callback.test.ts +55 -0
  39. package/src/__tests__/ir-jsx-props.test.ts +148 -0
  40. package/src/__tests__/issue-2705-branch-inner-loop-container.test.ts +91 -0
  41. package/src/__tests__/markup-prop-brand.test.ts +49 -0
  42. package/src/__tests__/nested-loop-conditional.test.ts +20 -11
  43. package/src/__tests__/return-through-local-var.test.ts +269 -0
  44. package/src/__tests__/serialize-parsed-expr.test.ts +17 -3
  45. package/src/__tests__/ssr-defaults.test.ts +41 -0
  46. package/src/__tests__/ssr-seed-plan.test.ts +12 -2
  47. package/src/adapters/dangerous-inner-html.ts +1 -0
  48. package/src/adapters/parsed-expr-emitter.ts +49 -3
  49. package/src/analyzer.ts +71 -0
  50. package/src/errors.ts +17 -1
  51. package/src/expression-parser.ts +200 -89
  52. package/src/index.ts +2 -2
  53. package/src/ir-to-client-js/collect-elements.ts +31 -20
  54. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +19 -0
  55. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +22 -3
  56. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
  57. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +12 -0
  58. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +9 -0
  59. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +7 -1
  60. package/src/ir-to-client-js/element-refs.ts +8 -0
  61. package/src/ir-to-client-js/emit-reactive.ts +91 -23
  62. package/src/ir-to-client-js/imports.ts +5 -0
  63. package/src/ir-to-client-js/index.ts +4 -0
  64. package/src/ir-to-client-js/phases/provider-and-child-inits.ts +5 -1
  65. package/src/ir-to-client-js/types.ts +21 -0
  66. package/src/jsx-to-ir.ts +161 -9
  67. package/src/query-href-lowering.ts +4 -0
  68. package/src/rich-type-refusal.ts +1 -1
  69. package/src/ssr-defaults.ts +51 -2
  70. package/src/ssr-seed-plan.ts +20 -3
  71. package/src/static-literal.ts +15 -1
  72. package/src/to-locale-date-lowering.ts +4 -0
package/src/jsx-to-ir.ts CHANGED
@@ -1066,7 +1066,37 @@ function buildIRRoot(analyzer: AnalyzerContext): IRNode | null {
1066
1066
  // scope; the inner IR must not double-mark a nested element as root.
1067
1067
  ctx.isRoot = false
1068
1068
  const ir = transformJsxExpression(jsxReturn, ctx)
1069
- if (ir === null) return null
1069
+ if (ir === null) {
1070
+ // BF027 (#2720): a bare identifier at return position that names a
1071
+ // local `const`/`let` PROVEN to hold JSX (`jsxConstants` — pure JSX
1072
+ // literal; `inlineableJsxConsts` — a JSX-shaped ternary/`&&`/`||`/`??`)
1073
+ // is the "returned JSX through a local variable" shape. Return position
1074
+ // deliberately does not resolve identifiers the way JSX-child position
1075
+ // does (see the #547/#1409 inlining above), so the scalar-leaf fallback
1076
+ // would otherwise drop this component with zero files and zero
1077
+ // diagnostics. Scoped to identifiers already proven JSX-holding by
1078
+ // those two maps so an ordinary non-JSX return (`return 42`, `return
1079
+ // someHelperResult`) — including from a PascalCase-but-not-a-component
1080
+ // export the analyzer's syntactic component detector still matches —
1081
+ // stays silent exactly as before.
1082
+ if (
1083
+ ts.isIdentifier(jsxReturn) &&
1084
+ (analyzer.jsxConstants.has(jsxReturn.text) || analyzer.inlineableJsxConsts.has(jsxReturn.text))
1085
+ ) {
1086
+ analyzer.errors.push(createError(
1087
+ ErrorCodes.RETURN_VALUE_NOT_JSX,
1088
+ getSourceLocation(jsxReturn, analyzer.sourceFile, analyzer.filePath),
1089
+ {
1090
+ message:
1091
+ `Component '${analyzer.componentName ?? '(unknown)'}' return value is not recognized ` +
1092
+ `as JSX — return the JSX expression directly instead of binding it to a local variable ` +
1093
+ `first (\`return ${jsxReturn.text}\` after \`const ${jsxReturn.text} = <jsx/>\` is not ` +
1094
+ `resolved at return position).`,
1095
+ },
1096
+ ))
1097
+ }
1098
+ return null
1099
+ }
1070
1100
  return wrapInScopeElement(ir)
1071
1101
  }
1072
1102
 
@@ -3296,7 +3326,10 @@ function resolveCallbackMethodFunctionReferences(
3296
3326
  ...(e.method === 'flat' && e.depthExpr ? { depthExpr: visit(e.depthExpr, bound) } : {}),
3297
3327
  } as ParsedExpr
3298
3328
  case 'object-literal':
3299
- return { ...e, properties: e.properties.map(p => ({ ...p, value: visit(p.value, bound) })) }
3329
+ return {
3330
+ ...e,
3331
+ properties: e.properties.map(p => (p.kind === 'spread' ? { ...p, expr: visit(p.expr, bound) } : { ...p, value: visit(p.value, bound) })),
3332
+ }
3300
3333
  case 'arrow': {
3301
3334
  const inner = e.params.length === 0 ? bound : new Set([...bound, ...e.params])
3302
3335
  return { ...e, body: visit(e.body, inner) }
@@ -6996,6 +7029,100 @@ function getStringValue(node: ts.Expression): string | null {
6996
7029
  // Component Props Processing
6997
7030
  // =============================================================================
6998
7031
 
7032
+ /**
7033
+ * Strip every legal-in-.tsx TRANSPARENT TS wrapper around an expression —
7034
+ * parens, `as`, `satisfies`, and postfix non-null `!` — repeatedly, so a
7035
+ * stack of them (`(x as any)!`) unwraps in one call. None of these change
7036
+ * the RUNTIME value; they are compile-time-only annotations TypeScript
7037
+ * erases, so a caller checking "is this JSX" (or "does this ternary/array
7038
+ * wrap JSX") must see through all of them to avoid false negatives (#2703
7039
+ * Copilot review on #2667: `header={cond ? (<a/> as any) : (<b/> as any)}`
7040
+ * unwrapped only parens, so the `as`-wrapped shape slipped past the naked-
7041
+ * wrapper refusal and still spliced raw JSX into the client bundle).
7042
+ * Angle-bracket type assertions (`<any>x`) are illegal in `.tsx` — the
7043
+ * `<` is always JSX — so there is no fourth wrapper kind to handle here.
7044
+ */
7045
+ function unwrapTransparentTsWrappers(node: ts.Expression): ts.Expression {
7046
+ let n = node
7047
+ while (
7048
+ ts.isParenthesizedExpression(n) ||
7049
+ ts.isAsExpression(n) ||
7050
+ ts.isSatisfiesExpression(n) ||
7051
+ ts.isNonNullExpression(n)
7052
+ ) {
7053
+ n = n.expression
7054
+ }
7055
+ return n
7056
+ }
7057
+
7058
+ /**
7059
+ * Whether `node` (a `ConditionalExpression` or `ArrayLiteralExpression`
7060
+ * already confirmed by the caller) has JSX syntax somewhere inside it —
7061
+ * a ternary arm that is (or recursively resolves to) a JSX element/
7062
+ * fragment, or an array element that is one. Used by #2667's naked-
7063
+ * wrapper refusal: a ternary/array prop initializer with NO JSX inside
7064
+ * (`disabled={cond ? a : b}`, `items={[a, b]}`) is ordinary and must keep
7065
+ * compiling exactly as before — only the JSX-carrying shape is refused.
7066
+ *
7067
+ * Deliberately narrow, mirroring `expressionContainsJsx`'s sibling scope
7068
+ * in `analyzer.ts`-adjacent code: only descends through transparent TS
7069
+ * wrappers (`unwrapTransparentTsWrappers`), `ConditionalExpression` arms,
7070
+ * and `ArrayLiteralExpression` elements (spread elements unwrapped too)
7071
+ * — the two wrapper shapes the issue names, each itself possibly
7072
+ * TS-wrapped (`(cond ? <a/> : <b/>) as any`, `cond ? (<a/> as any) : <b/>`,
7073
+ * #2703). It does not chase JSX through arbitrary call arguments, object
7074
+ * literals, or logical expressions; those are out of this refusal's
7075
+ * scope and stay on the pre-existing (working, non-JSX) expression path.
7076
+ */
7077
+ function expressionWrapsJsx(node: ts.Expression): boolean {
7078
+ const n = unwrapTransparentTsWrappers(node)
7079
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) return true
7080
+ if (ts.isConditionalExpression(n)) {
7081
+ return expressionWrapsJsx(n.whenTrue) || expressionWrapsJsx(n.whenFalse)
7082
+ }
7083
+ if (ts.isArrayLiteralExpression(n)) {
7084
+ return n.elements.some((el) => expressionWrapsJsx(ts.isSpreadElement(el) ? el.expression : el))
7085
+ }
7086
+ return false
7087
+ }
7088
+
7089
+ /**
7090
+ * Refuse a component prop whose initializer is a ternary/array literally
7091
+ * wrapping JSX (#2667) — see `expressionWrapsJsx`'s docstring and the call
7092
+ * site above for why this can't fall through to the plain `expression`
7093
+ * AttrValue path (raw JSX syntax would splice into the emitted client
7094
+ * JS) and why the fragment-wrap escape is NOT offered here (BF021 message
7095
+ * below explains the unsoundness inline; see #2667's tracking issue for
7096
+ * the full door-inventory finding).
7097
+ */
7098
+ function reportNakedJsxWrapperProp(
7099
+ ctx: TransformContext,
7100
+ attr: ts.JsxAttribute,
7101
+ propName: string,
7102
+ jsxExpr: ts.Expression,
7103
+ ): void {
7104
+ const shape = ts.isConditionalExpression(jsxExpr) ? 'a ternary' : 'an array literal'
7105
+ ctx.analyzer.errors.push(
7106
+ createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN, getSourceLocation(attr, ctx.sourceFile, ctx.filePath), {
7107
+ message:
7108
+ `Prop '${propName}' is ${shape} wrapping JSX (${jsxExpr.getText(ctx.sourceFile)}). ` +
7109
+ `This shape is not compiled — only a JSX element/fragment given DIRECTLY as the prop value is.`,
7110
+ suggestion: {
7111
+ message:
7112
+ `Move the conditional/array out of the prop position: compute it in a local ` +
7113
+ `const and pass it as the component's children instead of a named prop ` +
7114
+ `(e.g. const ${propName} = ${jsxExpr.getText(ctx.sourceFile)}; <Comp>{${propName}}</Comp>). ` +
7115
+ `Wrapping the ternary/array in a fragment at the prop position ` +
7116
+ `(${propName}={<>{${jsxExpr.getText(ctx.sourceFile)}}</>}) is NOT a safe escape here: it compiles, ` +
7117
+ `but the child's own reactive prop getter receives the branch's HTML unbranded and re-escapes it as ` +
7118
+ `text on the child's very next reactive run, corrupting the DOM (a narrower gap #2651's door ` +
7119
+ `inventory left open — tracked separately).`,
7120
+ escape: [{ kind: 'rewrite' }],
7121
+ },
7122
+ }),
7123
+ )
7124
+ }
7125
+
6999
7126
  function processComponentProps(
7000
7127
  attributes: ts.JsxAttributes,
7001
7128
  ctx: TransformContext
@@ -7012,14 +7139,14 @@ function processComponentProps(
7012
7139
 
7013
7140
  const name = attr.name.getText(ctx.sourceFile)
7014
7141
 
7015
- // JSX element/fragment as prop value: controls={<select />} or
7016
- // controls={(<div/>)}. Carried as a `jsx-children` AttrValue variant
7017
- // so adapters render the JSX inline rather than passing a string.
7142
+ // JSX element/fragment as prop value: controls={<select />},
7143
+ // controls={(<div/>)}, or controls={<div/> as any} (#2703 the
7144
+ // entity is still directly JSX; `as`/`satisfies`/`!` are type-only
7145
+ // and erased, so this must classify identically to the bare form).
7146
+ // Carried as a `jsx-children` AttrValue variant so adapters render
7147
+ // the JSX inline rather than passing a string.
7018
7148
  if (attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
7019
- let jsxExpr = attr.initializer.expression
7020
- while (ts.isParenthesizedExpression(jsxExpr)) {
7021
- jsxExpr = jsxExpr.expression
7022
- }
7149
+ const jsxExpr = unwrapTransparentTsWrappers(attr.initializer.expression)
7023
7150
  if (ts.isJsxElement(jsxExpr) || ts.isJsxSelfClosingElement(jsxExpr) || ts.isJsxFragment(jsxExpr)) {
7024
7151
  const prevInsideComponentChildren = ctx.insideComponentChildren
7025
7152
  ctx.insideComponentChildren = true
@@ -7034,6 +7161,31 @@ function processComponentProps(
7034
7161
  continue
7035
7162
  }
7036
7163
  }
7164
+
7165
+ // #2667: a ternary or array LITERALLY WRAPPING JSX at this prop
7166
+ // position (`header={cond ? <a/> : <b/>}`, `header={[<a/>, <b/>]}`)
7167
+ // is neither the direct-element shape above nor a plain value
7168
+ // expression — it only classifies as `jsx-children` when the JSX is
7169
+ // hoisted behind a fragment (`header={<>{cond ? <a/> : <b/>}</>}`,
7170
+ // `unwrapHoistedFragment` above). Left undetected, this falls to the
7171
+ // plain `expression` path below, which stringifies the initializer's
7172
+ // SOURCE TEXT — literal JSX syntax spliced into the emitted client
7173
+ // JS (invalid at runtime; the #2651 door inventory's discovery).
7174
+ // Refuse loudly instead of guessing a lowering: the fragment-wrap
7175
+ // escape this diagnostic once considered recommending turns out to
7176
+ // be unsound for the same shape (`isSingleElementJsxChildren`'s
7177
+ // docstring in `ir-to-client-js/collect-elements.ts` — a
7178
+ // conditional-in-fragment reaches `initChild`'s getter UNbranded,
7179
+ // corrupting the child's DOM the moment its own reactive effect
7180
+ // first reads the prop), so only the children-passthrough escape is
7181
+ // offered.
7182
+ if (
7183
+ (ts.isConditionalExpression(jsxExpr) || ts.isArrayLiteralExpression(jsxExpr)) &&
7184
+ expressionWrapsJsx(jsxExpr)
7185
+ ) {
7186
+ reportNakedJsxWrapperProp(ctx, attr, name, jsxExpr)
7187
+ continue
7188
+ }
7037
7189
  }
7038
7190
 
7039
7191
  let value = getAttributeValue(attr, ctx)
@@ -58,6 +58,10 @@ export function matchQueryHrefCall(
58
58
 
59
59
  const triples: QueryHrefTriple[] = []
60
60
  for (const p of obj.properties) {
61
+ // A spread (`{ ...extra, key: v }`, #2696 Step 2) has no static key to
62
+ // build a triple from — fall back to the generic lowering rather than
63
+ // dropping the spread's keys silently.
64
+ if (p.kind === 'spread') return null
61
65
  const v = p.value
62
66
  if (v.kind === 'conditional' && isOmitBranch(v.alternate)) {
63
67
  triples.push({ guard: v.test, key: p.key, value: v.consequent })
@@ -348,7 +348,7 @@ function checkExpr(
348
348
  for (const el of expr.elements) recurse(el)
349
349
  break
350
350
  case 'object-literal':
351
- for (const prop of expr.properties) recurse(prop.value)
351
+ for (const prop of expr.properties) recurse(prop.kind === 'spread' ? prop.expr : prop.value)
352
352
  break
353
353
  case 'array-method':
354
354
  recurse(expr.object)
@@ -647,12 +647,28 @@ function evalNode(node: ts.Expression, ctx: EvalContext): EvalResult {
647
647
  }
648
648
 
649
649
  if (ts.isPropertyAccessExpression(node)) {
650
+ const baseResult = evalNode(node.expression, ctx)
650
651
  // `props.X` / `props?.X` — read of a binding we know nothing about, so
651
652
  // resolve to `undefined`. Chained access (`a.b.c`) collapses the same way
652
653
  // because the base read is already undefined.
653
- const baseResult = evalNode(node.expression, ctx)
654
654
  if (baseResult === undefined) return undefined
655
- return UNRESOLVED
655
+ if (baseResult === UNRESOLVED || baseResult === null || typeof baseResult !== 'object') {
656
+ return UNRESOLVED
657
+ }
658
+ // Own-property reads are only faithful for a plain object. An array (or
659
+ // any other non-plain object) exposes prototype members (`.map`,
660
+ // `.length`) that `hasOwnProperty` would wrongly resolve to `undefined`
661
+ // instead of the real function/value (#2698 review).
662
+ const proto = Object.getPrototypeOf(baseResult)
663
+ if (proto !== Object.prototype && proto !== null) return UNRESOLVED
664
+ // A resolved plain-object base — an object-literal value, e.g. a
665
+ // `.map()` callback param bound to one element (#2696's `t.a`). Missing
666
+ // key → JS `undefined`, mirroring `isElementAccessExpression`'s own
667
+ // missing-key handling above.
668
+ const key = node.name.text
669
+ return Object.prototype.hasOwnProperty.call(baseResult, key)
670
+ ? (baseResult as Record<string, unknown>)[key]
671
+ : undefined
656
672
  }
657
673
 
658
674
  if (ts.isCallExpression(node)) {
@@ -665,6 +681,39 @@ function evalNode(node: ts.Expression, ctx: EvalContext): EvalResult {
665
681
  ) {
666
682
  return ctx.bindings[node.expression.text]
667
683
  }
684
+ // `<array>.map(cb)` — a single-param, expression-bodied arrow over a
685
+ // resolved array receiver; anything else (block body, multi-param,
686
+ // non-array receiver) stays `UNRESOLVED`, same conservative narrowing as
687
+ // every other arm here. A `derived` step with empty `frees` gets no
688
+ // in-template recompute (#2696), so this static value is what production
689
+ // actually reads.
690
+ if (
691
+ ts.isPropertyAccessExpression(node.expression) &&
692
+ node.expression.name.text === 'map' &&
693
+ node.arguments.length === 1
694
+ ) {
695
+ const arrow = node.arguments[0]
696
+ if (
697
+ ts.isArrowFunction(arrow) &&
698
+ arrow.parameters.length === 1 &&
699
+ ts.isIdentifier(arrow.parameters[0].name) &&
700
+ !ts.isBlock(arrow.body)
701
+ ) {
702
+ const recv = evalNode(node.expression.expression, ctx)
703
+ if (Array.isArray(recv)) {
704
+ const paramName = (arrow.parameters[0].name as ts.Identifier).text
705
+ const mapped: unknown[] = []
706
+ for (const item of recv) {
707
+ const localBindings: Record<string, EvalResult> = { ...ctx.bindings, [paramName]: item }
708
+ const v = evalNode(arrow.body as ts.Expression, { ...ctx, bindings: localBindings })
709
+ if (v === UNRESOLVED) return UNRESOLVED
710
+ mapped.push(v === undefined ? null : v)
711
+ }
712
+ return mapped
713
+ }
714
+ }
715
+ return UNRESOLVED
716
+ }
668
717
  // `<array>.join(<sep?>)` — evaluate when the receiver resolves to an array
669
718
  // and the separator (default `,`) is a string. Covers `stateClasses =
670
719
  // [...].join(' ')` (#checkbox). Other array methods stay unresolved.
@@ -36,7 +36,7 @@ import {
36
36
  extractArrowBodyExpression,
37
37
  freeIdentifiers,
38
38
  inlineBinding,
39
- isSupported,
39
+ isSupportedValue,
40
40
  parseExpression,
41
41
  type ParsedExpr,
42
42
  } from './expression-parser.ts'
@@ -81,6 +81,11 @@ export interface SsrSeedPlan {
81
81
  * opaque). The scope check runs over the parsed SOURCE tree, so a shadowed
82
82
  * name (`items.filter((p) => p.ok) && p`, where the trailing `p` is a
83
83
  * different, unbound reference from the callback's own param) is rejected.
84
+ *
85
+ * Uses `isSupportedValue`, not `isSupported`: a signal/memo initializer is an
86
+ * ASSIGNMENT, never a render, so it's checked at VALUE position — this is
87
+ * what admits e.g. `createSignal([{ a: 'p' }].map(t => t.a).join(','))`
88
+ * (an object-literal array-method receiver) as `derived` instead of opaque.
84
89
  */
85
90
  function classify(
86
91
  name: string,
@@ -89,7 +94,7 @@ function classify(
89
94
  parsed: ParsedExpr,
90
95
  available: ReadonlySet<string>,
91
96
  ): SsrSeedStep {
92
- if (!isSupported(parsed).supported) return { kind: 'opaque', name, origin }
97
+ if (!isSupportedValue(parsed).supported) return { kind: 'opaque', name, origin }
93
98
  const frees = freeIdentifiers(parsed)
94
99
  if (frees === null) return { kind: 'opaque', name, origin }
95
100
  for (const free of frees) {
@@ -198,7 +203,19 @@ export function computeSsrSeedPlan(metadata: IRMetadata): SsrSeedPlan {
198
203
  signal.getter,
199
204
  'signal',
200
205
  expr,
201
- resolveThroughLocalConsts(parseExpression(expr), localConsts),
206
+ // Prefer the analyzer's already-parenthesised parse (`analyzer.ts`'s
207
+ // `parseExpression(\`(${signal.initialValue})\`)`, Roadmap A) over
208
+ // re-parsing the bare `expr` string here — re-parsing WITHOUT the
209
+ // wrap misreads a bare object-literal initializer
210
+ // (`createSignal({ ...base, done: true })`) as a block statement
211
+ // (`parseExpression`'s documented block-vs-expression-statement
212
+ // rule), silently opaquing an otherwise-derivable signal (#2696
213
+ // Step 2 follow-up: this only became observable once `object-
214
+ // literal` could classify `derived` at all). Falling back to a
215
+ // parenthesised re-parse (not the bare string) keeps a signal
216
+ // whose shape the analyzer's pass left unparsed (`signal.parsed`
217
+ // undefined) equally safe.
218
+ resolveThroughLocalConsts(signal.parsed ?? parseExpression(`(${expr})`), localConsts),
202
219
  available,
203
220
  ),
204
221
  )
@@ -60,9 +60,23 @@ export function evaluateStaticLiteral(
60
60
  // Shorthand (`{ a }`) and explicit (`{ a: value }`) properties both
61
61
  // carry their resolved tree in `value` (shorthand's is an
62
62
  // `identifier`) — recursing here handles both uniformly: a shorthand
63
- // property only resolves when `bindings` supplies it.
63
+ // property only resolves when `bindings` supplies it. A spread
64
+ // (`{ ...base, a: 1 }`, #2696 Step 2) resolves its source and merges
65
+ // in source order — the same shallow-merge, later-wins, null/undefined-
66
+ // is-a-no-op semantics as the runtime evaluator (`toEvalNode`'s
67
+ // `object-literal` case) and JS itself; a spread source that resolves
68
+ // to anything other than a plain object/null/undefined isn't
69
+ // statically mergeable here, so the whole literal declines.
64
70
  const out: Record<string, unknown> = {}
65
71
  for (const prop of expr.properties) {
72
+ if (prop.kind === 'spread') {
73
+ const resolved = evaluateStaticLiteral(prop.expr, bindings)
74
+ if (!resolved) return null
75
+ if (resolved.value === null || resolved.value === undefined) continue
76
+ if (typeof resolved.value !== 'object' || Array.isArray(resolved.value)) return null
77
+ Object.assign(out, resolved.value)
78
+ continue
79
+ }
66
80
  const resolved = evaluateStaticLiteral(prop.value, bindings)
67
81
  if (!resolved) return null
68
82
  out[prop.key] = resolved.value
@@ -504,6 +504,10 @@ export function matchToLocaleDateStringCall(
504
504
  let tz: string | null = null
505
505
  const probeOptions: Record<string, string> = {}
506
506
  for (const prop of options.properties) {
507
+ // A spread (`{ ...opts, timeZone: 'UTC' }`, #2696 Step 2) isn't a
508
+ // literal-keyed entry this probe can read without evaluating the spread
509
+ // source — decline rather than silently skipping its keys.
510
+ if (prop.kind === 'spread') return null
507
511
  if (prop.value.kind !== 'literal' || prop.value.literalType !== 'string') return null
508
512
  const value = String(prop.value.value)
509
513
  if (prop.key === 'timeZone') {