@barefootjs/jsx 0.31.4 → 0.31.5

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 (54) hide show
  1. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  2. package/dist/adapters/loop-bound-names.d.ts +18 -0
  3. package/dist/adapters/loop-bound-names.d.ts.map +1 -1
  4. package/dist/adapters/test-adapter.d.ts.map +1 -1
  5. package/dist/augment-inherited-props.d.ts +12 -2
  6. package/dist/augment-inherited-props.d.ts.map +1 -1
  7. package/dist/compiler.d.ts.map +1 -1
  8. package/dist/debug.d.ts.map +1 -1
  9. package/dist/free-refs.d.ts +11 -2
  10. package/dist/free-refs.d.ts.map +1 -1
  11. package/dist/index.js +765 -649
  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-reactive-effects.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
  15. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/reactivity.d.ts +16 -0
  18. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/types.d.ts +16 -0
  20. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/utils.d.ts +15 -0
  22. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  23. package/dist/module-exports.d.ts +64 -0
  24. package/dist/module-exports.d.ts.map +1 -1
  25. package/dist/scope/binding-scope.d.ts +1 -1
  26. package/dist/types.d.ts +41 -0
  27. package/dist/types.d.ts.map +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/binding-scope-ratchet.test.ts +146 -21
  30. package/src/__tests__/component-type-parameters.test.ts +70 -0
  31. package/src/__tests__/csr-materialize-loop-preamble-shadow.test.ts +10 -1
  32. package/src/__tests__/free-refs.test.ts +1 -1
  33. package/src/__tests__/mutable-binding-writers.test.ts +133 -0
  34. package/src/__tests__/preamble-conditional-reactivity.test.ts +191 -0
  35. package/src/__tests__/signal-setter-updater-type.test.ts +81 -0
  36. package/src/adapters/jsx-adapter.ts +29 -3
  37. package/src/adapters/loop-bound-names.ts +18 -0
  38. package/src/adapters/test-adapter.ts +4 -1
  39. package/src/augment-inherited-props.ts +13 -1
  40. package/src/compiler.ts +18 -0
  41. package/src/debug.ts +34 -21
  42. package/src/free-refs.ts +14 -5
  43. package/src/ir-to-client-js/collect-elements.ts +17 -2
  44. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
  45. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  46. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +12 -2
  47. package/src/ir-to-client-js/html-template.ts +5 -0
  48. package/src/ir-to-client-js/reactivity.ts +4 -2
  49. package/src/ir-to-client-js/types.ts +16 -0
  50. package/src/ir-to-client-js/utils.ts +15 -0
  51. package/src/jsx-to-ir.ts +117 -1
  52. package/src/module-exports.ts +137 -0
  53. package/src/scope/binding-scope.ts +1 -1
  54. package/src/types.ts +41 -0
package/src/jsx-to-ir.ts CHANGED
@@ -845,7 +845,7 @@ function makeBindingEnv(ctx: TransformContext): BindingEnvironment {
845
845
  // mutated (cached on the immutable `BindingScope`) — a stable
846
846
  // snapshot even if `ctx.scope` is later reassigned by an enclosing
847
847
  // visitor frame, which swaps the instance rather than mutating it.
848
- loopParams: boundNames,
848
+ loopValueBoundNames: boundNames,
849
849
  checker: a.checker,
850
850
  }
851
851
  ctx._bindingEnv = env
@@ -5021,6 +5021,13 @@ function transformMapCall(
5021
5021
  // the row effect re-runs the preamble ahead of the write.
5022
5022
  if (preamble && !isStaticArray) {
5023
5023
  markPreambleAttrSlots(children, new Set(preamble.declaredNames), ctx)
5024
+ // #2596 follow-up to the two passes above — the CONDITION-position twin.
5025
+ // See `markPreambleConditionalReactivity`'s docstring for why this one is
5026
+ // gated on `reactiveNames` (genuinely signal-derived) rather than the
5027
+ // blanket `declaredNames` the region/attr passes use.
5028
+ if (preamble.reactiveNames && preamble.reactiveNames.length > 0) {
5029
+ markPreambleConditionalReactivity(children, new Set(preamble.reactiveNames), ctx)
5030
+ }
5024
5031
  }
5025
5032
 
5026
5033
  // Collect nested components for both static and dynamic arrays.
@@ -5457,6 +5464,67 @@ function markPreambleAttrSlots(
5457
5464
  visit(nodes)
5458
5465
  }
5459
5466
 
5467
+ /**
5468
+ * Grant the IR `reactive` flag (and a slot id) to a loop-body CONDITIONAL
5469
+ * whose condition bare-references a preamble local that is itself reactive
5470
+ * (#2596) — `reactiveNames`, computed by `computePreambleReactiveNames`.
5471
+ *
5472
+ * Root cause this closes: a plain (non-preamble) condition's `reactive` flag
5473
+ * comes from `isReactiveExpression`/`isReactiveOrigin` run against the
5474
+ * condition's OWN text at `transformConditional`/`transformLogicalAnd`/the
5475
+ * if-else-chain builder — none of which know about an enclosing loop's
5476
+ * `.map()` callback preamble (that's assembled and only becomes available
5477
+ * on `IRLoop` after `children` is already built, same ordering constraint
5478
+ * `collectPreambleRegions`/`markPreambleAttrSlots` work around). A condition
5479
+ * that's just a bare reference to a preamble local (`label ? <A/> : <B/>`)
5480
+ * therefore falls through every one of those classifiers with `reactive:
5481
+ * false` even when `label`'s initializer reads a signal — the classifiers
5482
+ * only ever see the token `label`, never its declaration.
5483
+ *
5484
+ * Deliberately narrower than `collectPreambleRegions`/`markPreambleAttrSlots`:
5485
+ * those two mark ANY reference to `declaredNames` because their region-patch
5486
+ * effect re-runs the WHOLE preamble unconditionally on row update, so a
5487
+ * merely item-derived local (`const label = item.title`) still ends up
5488
+ * correctly tracked by ordinary signal auto-tracking inside that effect —
5489
+ * marking it reactive there costs nothing extra. A conditional is different:
5490
+ * `reactive: true` here means Phase 2 wraps it in an `insert()` that swaps
5491
+ * whole DOM subtrees, which is real cost and, for an item-only local, pure
5492
+ * waste — the row already renders fresh whenever it's (re)constructed. So
5493
+ * this only fires for `reactiveNames`, the subset PROVEN to depend on an
5494
+ * actual external signal/memo/prop.
5495
+ *
5496
+ * Mirrors the sibling passes' traversal shape (conditional arms included).
5497
+ */
5498
+ function markPreambleConditionalReactivity(
5499
+ nodes: IRNode[],
5500
+ reactiveNames: ReadonlySet<string>,
5501
+ ctx: TransformContext,
5502
+ ): void {
5503
+ if (reactiveNames.size === 0) return
5504
+ const visit = (list: IRNode[]): void => {
5505
+ for (const node of list) {
5506
+ switch (node.type) {
5507
+ case 'element':
5508
+ case 'fragment':
5509
+ visit(node.children)
5510
+ break
5511
+ case 'conditional': {
5512
+ if (!node.reactive) {
5513
+ const refs = extractFreeIdentifiersFromText(node.condition)
5514
+ if ([...refs].some((r) => reactiveNames.has(r))) {
5515
+ node.reactive = true
5516
+ if (!node.slotId) node.slotId = generateSlotId(ctx)
5517
+ }
5518
+ }
5519
+ visit([node.whenTrue, ...(node.whenFalse ? [node.whenFalse] : [])])
5520
+ break
5521
+ }
5522
+ }
5523
+ }
5524
+ }
5525
+ visit(nodes)
5526
+ }
5527
+
5460
5528
  /**
5461
5529
  * Expression-bearing source text of an attribute value, for the
5462
5530
  * free-identifier scan. Only used as a FALLBACK — an attribute normally
@@ -5503,6 +5571,52 @@ function collectPreambleDeclaredNames(stmt: ts.Statement, out: Set<string>): voi
5503
5571
  }
5504
5572
  }
5505
5573
 
5574
+ /**
5575
+ * Which {@link MapCallbackPreamble.declaredNames} are themselves reactive —
5576
+ * their OWN initializer reads a signal / memo / reactive prop, directly or
5577
+ * transitively through an earlier declaration in the same preamble (#2596).
5578
+ *
5579
+ * Reuses the same Phase-1 classifier (`isReactiveExpression`) already applied
5580
+ * to every non-preamble condition/attribute — this doesn't invent a second
5581
+ * reactivity heuristic, it runs the existing one over one more declaration
5582
+ * list. `ctx.patterns.constants` (the component-level constant chain
5583
+ * `isSignalOrMemoReference`/`isPropsReference` already walk) doesn't reach
5584
+ * these names — they're scoped to the `.map()` callback body, never added to
5585
+ * `ctx.patterns` — so without this pass a preamble local reading a signal is
5586
+ * invisible to the classifier.
5587
+ *
5588
+ * Value-declaration statements only (`const`/`let x = expr`, including
5589
+ * destructuring targets, which mark every bound name reactive together since
5590
+ * they share one initializer). A non-declaration preamble statement
5591
+ * (assignment, loop, side-effecting call) contributes no reactive names —
5592
+ * consistent with `neutralPreambleDeclarations`'s all-or-nothing DSL gate
5593
+ * treating anything past that as un-lowerable, though this walk (client/JS-
5594
+ * runtime SSR only) doesn't require the same all-or-nothing: it simply skips
5595
+ * what it can't classify.
5596
+ */
5597
+ function computePreambleReactiveNames(
5598
+ statements: readonly ts.Statement[],
5599
+ ctx: TransformContext,
5600
+ ): Set<string> {
5601
+ const reactiveNames = new Set<string>()
5602
+ for (const stmt of statements) {
5603
+ if (!ts.isVariableStatement(stmt)) continue
5604
+ for (const decl of stmt.declarationList.declarations) {
5605
+ if (!decl.initializer) continue
5606
+ const boundNames = new Set<string>()
5607
+ collectBindingNames(decl.name, boundNames)
5608
+ const initText = ctx.getJS(decl.initializer)
5609
+ const initFreeRefs = extractFreeIdentifiersFromNode(decl.initializer)
5610
+ const readsEarlierReactive = [...initFreeRefs].some((r) => reactiveNames.has(r))
5611
+ const isReactive = readsEarlierReactive || isReactiveExpression(initText, ctx, decl.initializer)
5612
+ if (isReactive) {
5613
+ for (const n of boundNames) reactiveNames.add(n)
5614
+ }
5615
+ }
5616
+ }
5617
+ return reactiveNames
5618
+ }
5619
+
5506
5620
  /**
5507
5621
  * Build a {@link MapCallbackPreamble} from value-only (JSX-free) pre-return
5508
5622
  * statements — the Stage-2 fold preamble and the plain block-body preamble.
@@ -5526,6 +5640,7 @@ function preambleFromValueStatements(
5526
5640
  typedParts.push(raw0.endsWith(';') ? raw0 : raw0 + ';')
5527
5641
  segments.push(tjs !== js ? { kind: 'js', text: js, templateText: tjs } : { kind: 'js', text: js })
5528
5642
  }
5643
+ const reactiveNames = computePreambleReactiveNames(statements, ctx)
5529
5644
  return {
5530
5645
  segments: trimPreambleSegments(segments),
5531
5646
  ssrText: tsxSourceText(typedParts.join(' ')),
@@ -5533,6 +5648,7 @@ function preambleFromValueStatements(
5533
5648
  // Value-only preambles accumulate no JSX, so no child needs the array join.
5534
5649
  builderNames: [],
5535
5650
  declarations: neutralPreambleDeclarations(statements, ctx) ?? undefined,
5651
+ reactiveNames: reactiveNames.size > 0 ? [...reactiveNames] : undefined,
5536
5652
  }
5537
5653
  }
5538
5654
 
@@ -5,6 +5,7 @@
5
5
  * This is a compiler-layer concern, not adapter-specific.
6
6
  */
7
7
 
8
+ import ts from 'typescript'
8
9
  import type { ComponentIR, ParamInfo } from './types.ts'
9
10
  import { identifierPattern } from './identifier-pattern.ts'
10
11
 
@@ -161,6 +162,142 @@ export function findReachableNames(
161
162
  return reachable
162
163
  }
163
164
 
165
+ /**
166
+ * Which of `candidates` does `bodyText` ASSIGN to?
167
+ *
168
+ * Reachability above answers "is this declaration referenced?", which is
169
+ * the right question for pruning SSR-irrelevant code. It is the wrong
170
+ * question for a MUTABLE binding: a surviving `let` whose only writer got
171
+ * pruned is left declared-and-read but never assigned, and TypeScript's
172
+ * control-flow analysis then narrows it to `never` at every guarded use
173
+ * (#2598). `closeOverWritersOfMutableBindings` uses this to restore the
174
+ * missing half of that pair.
175
+ *
176
+ * Recognizes the forms that actually write a local binding:
177
+ * `x = …`, `x += …` (and every other compound operator), `x++`, `--x`
178
+ * Destructuring assignment (`[x] = …`, `({ x } = …)`) is deliberately NOT
179
+ * recognized: it never appears in the ref/handler shapes this exists for,
180
+ * and a wrong guess here over-retains rather than fails loudly, so leaving
181
+ * it out keeps the retained set honest. If one shows up, it will present
182
+ * as this same `never` narrowing and can be added with a fixture.
183
+ *
184
+ * Parsed with the TS AST, not matched as text: `identifierPattern` (used
185
+ * for reference detection above) cannot tell a write from a read, and a
186
+ * regex for `name\s*=` would match `name == x`, a `name=` inside a string
187
+ * or JSX attribute, and a property write `obj.name = x` that assigns
188
+ * nothing of the sort.
189
+ */
190
+ export function findAssignedNames(
191
+ bodyText: string,
192
+ candidates: ReadonlySet<string>,
193
+ ): Set<string> {
194
+ const assigned = new Set<string>()
195
+ if (candidates.size === 0) return assigned
196
+
197
+ const sf = ts.createSourceFile(
198
+ 'bf-assignment-scan.tsx',
199
+ bodyText,
200
+ ts.ScriptTarget.Latest,
201
+ /* setParentNodes */ false,
202
+ ts.ScriptKind.TSX,
203
+ )
204
+
205
+ // A bare Identifier on the left of an assignment — `obj.x = …` is a
206
+ // PropertyAccessExpression and writes through the binding rather than to
207
+ // it, so it does not count.
208
+ const record = (target: ts.Node): void => {
209
+ if (ts.isIdentifier(target) && candidates.has(target.text)) {
210
+ assigned.add(target.text)
211
+ }
212
+ }
213
+
214
+ const visit = (node: ts.Node): void => {
215
+ if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) {
216
+ record(node.left)
217
+ } else if (
218
+ (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
219
+ (node.operator === ts.SyntaxKind.PlusPlusToken || node.operator === ts.SyntaxKind.MinusMinusToken)
220
+ ) {
221
+ record(node.operand)
222
+ }
223
+ ts.forEachChild(node, visit)
224
+ }
225
+
226
+ ts.forEachChild(sf, visit)
227
+ return assigned
228
+ }
229
+
230
+ /**
231
+ * `findReachableNames`, plus the invariant it cannot express on its own:
232
+ * **a mutable binding that survives keeps the declarations that write it.**
233
+ *
234
+ * Reachability is seeded from the RENDERED JSX, which has already had the
235
+ * client-only attributes stripped — `ref={setRef}` leaves no `setRef`
236
+ * behind, and `onClick={handleClick}` is rendered as `onClick={() => {}}`.
237
+ * That is deliberate: code reachable only from a handler is client-only
238
+ * and should not be emitted into an SSR template.
239
+ *
240
+ * It goes wrong when a `let` outlives its writer. The binding survives
241
+ * because some OTHER surviving declaration reads it, while its only
242
+ * assignment lived in a pruned handler — so the emitted template declares
243
+ * it, reads it, and never assigns it. TypeScript's control-flow analysis
244
+ * concludes it is permanently `null`, narrows every guarded use to `never`,
245
+ * and each member access on it fails:
246
+ *
247
+ * let highlightEl: HTMLElement | null = null // writer was pruned
248
+ * const syncScroll = () => {
249
+ * if (highlightEl && textareaEl) {
250
+ * highlightEl.scrollTop = textareaEl.scrollTop // TS2339 on `never`
251
+ * }
252
+ * }
253
+ *
254
+ * Pulling the writers back in restores the source's shape for exactly the
255
+ * bindings that survived — nothing else. The retained writer is dead code
256
+ * at SSR (it only ever runs from a hydrated event), which is the same
257
+ * harmless-unused-declaration trade `generateModuleScopeDeclarations`
258
+ * already makes deliberately.
259
+ *
260
+ * Iterates to a fixpoint because a newly retained writer can read further
261
+ * declarations, and can itself write another mutable binding. Bounded by
262
+ * the declaration count: each round either adds a name or stops.
263
+ */
264
+ export function closeOverWritersOfMutableBindings(
265
+ primaryRefs: string,
266
+ declarations: { name: string; body: string }[],
267
+ mutableNames: ReadonlySet<string>,
268
+ ): Set<string> {
269
+ let reachable = findReachableNames(primaryRefs, declarations)
270
+ if (mutableNames.size === 0) return reachable
271
+
272
+ let seedText = primaryRefs
273
+ for (let round = 0; round <= declarations.length; round++) {
274
+ const survivingMutables = new Set(
275
+ [...reachable].filter(name => mutableNames.has(name)),
276
+ )
277
+ if (survivingMutables.size === 0) return reachable
278
+
279
+ const added = declarations
280
+ .filter(d => !reachable.has(d.name))
281
+ .filter(d => findAssignedNames(d.body, survivingMutables).size > 0)
282
+ .map(d => d.name)
283
+ if (added.length === 0) return reachable
284
+
285
+ // Re-seed by NAME rather than merging sets directly, so each retained
286
+ // writer's own transitive dependencies come along through the same
287
+ // traversal instead of a second, divergent one.
288
+ seedText += '\n' + added.join('\n')
289
+ reachable = findReachableNames(seedText, declarations)
290
+ }
291
+ return reachable
292
+ }
293
+
294
+ function isAssignmentOperator(kind: ts.SyntaxKind): boolean {
295
+ return (
296
+ kind >= ts.SyntaxKind.FirstAssignment &&
297
+ kind <= ts.SyntaxKind.LastAssignment
298
+ )
299
+ }
300
+
164
301
  /**
165
302
  * Extract parameter names from a function expression string.
166
303
  * Handles: arrow functions, single-param arrows, function expressions.
@@ -201,7 +201,7 @@ export class BindingScope {
201
201
  * qualifies, including a preamble local shadowing a module const.
202
202
  * These call `isBound` / `boundNames()`.
203
203
  * - REACTIVITY / SLOT-ID CLASSIFIERS (`referencesLoopParam`,
204
- * `hasReactiveAttributes`, and the `BindingEnvironment.loopParams`
204
+ * `hasReactiveAttributes`, and the `BindingEnvironment.loopValueBoundNames`
205
205
  * feed built from `makeBindingEnv`, all in `jsx-to-ir.ts`) ask
206
206
  * "does this expression read a value that changes per row and so
207
207
  * needs its own patchable slot" — a preamble local already gets
package/src/types.ts CHANGED
@@ -901,6 +901,28 @@ export interface MapCallbackPreamble {
901
901
  * keeps the plain interpolation it always had.
902
902
  */
903
903
  builderNames: string[]
904
+ /**
905
+ * The subset of {@link declaredNames} whose OWN initializer is itself
906
+ * reactive — reads a signal / memo / reactive prop, directly or
907
+ * transitively through an earlier preamble declaration (#2596). `undefined`
908
+ * (never an empty array) when the preamble contributes no such name, or
909
+ * when it isn't a value-only declaration sequence this analysis covers
910
+ * (see `preambleFromValueStatements`'s caller — a JSX-building preamble
911
+ * doesn't compute this and leaves it unset).
912
+ *
913
+ * Used to decide whether a loop-body CONDITIONAL whose condition
914
+ * bare-references a preamble local should carry the IR `reactive` flag
915
+ * (`markPreambleConditionalReactivity`, jsx-to-ir.ts). Deliberately NOT the
916
+ * same test `collectPreambleRegions`/`markPreambleAttrSlots` use for
917
+ * text/attr positions — those mark ANY reference to `declaredNames`
918
+ * reactive because their region-patch effect re-runs the whole preamble
919
+ * unconditionally on row update, and ordinary signal auto-tracking inside
920
+ * that effect picks up genuine dependencies regardless of the IR flag. A
921
+ * conditional instead swaps whole DOM subtrees via `insert()`, so it stays
922
+ * unwrapped unless the local it reads is proven to depend on an actual
923
+ * external signal — a bare `item.title`-derived local needs no such wrap.
924
+ */
925
+ reactiveNames?: string[]
904
926
  }
905
927
 
906
928
  /**
@@ -1940,6 +1962,25 @@ export interface IRMetadata {
1940
1962
  isClientComponent: boolean
1941
1963
  typeDefinitions: TypeDefinition[]
1942
1964
  propsType: TypeInfo | null
1965
+ /**
1966
+ * The component function's own generic type parameter list, verbatim
1967
+ * from source (each `node.getText()`, joined and wrapped in `<...>`),
1968
+ * e.g. `<NodeType extends NodeBase = NodeBase, EdgeType extends
1969
+ * EdgeBase = EdgeBase>`. `null` when the component isn't generic.
1970
+ *
1971
+ * A generic function component's props type (and often its body) keeps
1972
+ * referencing these names verbatim in emitted output (e.g. `props:
1973
+ * FlowComponentProps<NodeType, EdgeType>`, `createFlowStore<NodeType,
1974
+ * EdgeType>(props)`) — without the function's own declaration also
1975
+ * carrying the type parameters, those references are unresolved names
1976
+ * in the emitted `.tsx` (TS2304). Emitters that print a `function
1977
+ * <name>(...)` signature for the component must splice this verbatim
1978
+ * between the name and the parameter list. Optional (rather than
1979
+ * required) so the many hand-built `IRMetadata` test fixtures across
1980
+ * the suite don't need updating for a field that is `null` for the
1981
+ * overwhelming majority of (non-generic) components.
1982
+ */
1983
+ typeParameters?: string | null
1943
1984
  propsParams: ParamInfo[]
1944
1985
  /** Name of the props object parameter (e.g., 'props' in `function Component(props: Props)`) */
1945
1986
  propsObjectName: string | null