@barefootjs/jsx 0.31.4 → 0.31.6

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 (62) 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.d.ts +2 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +773 -649
  14. package/dist/ir-to-client-js/client-only-elision.d.ts +11 -5
  15. package/dist/ir-to-client-js/client-only-elision.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
  19. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/reactivity.d.ts +16 -0
  22. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/types.d.ts +16 -0
  24. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/utils.d.ts +15 -0
  26. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  27. package/dist/module-exports.d.ts +64 -0
  28. package/dist/module-exports.d.ts.map +1 -1
  29. package/dist/scope/binding-scope.d.ts +1 -1
  30. package/dist/types.d.ts +112 -0
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +2 -2
  33. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +117 -17
  34. package/src/__tests__/binding-scope-ratchet.test.ts +146 -21
  35. package/src/__tests__/component-type-parameters.test.ts +70 -0
  36. package/src/__tests__/csr-materialize-loop-preamble-shadow.test.ts +10 -1
  37. package/src/__tests__/doc-examples.test.ts +1 -0
  38. package/src/__tests__/free-refs.test.ts +1 -1
  39. package/src/__tests__/mutable-binding-writers.test.ts +133 -0
  40. package/src/__tests__/preamble-conditional-reactivity.test.ts +191 -0
  41. package/src/__tests__/signal-setter-updater-type.test.ts +81 -0
  42. package/src/adapters/jsx-adapter.ts +29 -3
  43. package/src/adapters/loop-bound-names.ts +18 -0
  44. package/src/adapters/test-adapter.ts +4 -1
  45. package/src/augment-inherited-props.ts +13 -1
  46. package/src/compiler.ts +18 -0
  47. package/src/debug.ts +34 -21
  48. package/src/free-refs.ts +14 -5
  49. package/src/index.ts +6 -0
  50. package/src/ir-to-client-js/client-only-elision.ts +11 -5
  51. package/src/ir-to-client-js/collect-elements.ts +17 -2
  52. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
  53. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  54. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +12 -2
  55. package/src/ir-to-client-js/html-template.ts +51 -0
  56. package/src/ir-to-client-js/reactivity.ts +4 -2
  57. package/src/ir-to-client-js/types.ts +16 -0
  58. package/src/ir-to-client-js/utils.ts +15 -0
  59. package/src/jsx-to-ir.ts +117 -1
  60. package/src/module-exports.ts +137 -0
  61. package/src/scope/binding-scope.ts +1 -1
  62. package/src/types.ts +118 -0
@@ -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
@@ -2151,9 +2192,62 @@ export interface CompilerError {
2151
2192
  suggestion?: ErrorSuggestion
2152
2193
  }
2153
2194
 
2195
+ /**
2196
+ * The kind of escape available from a refusal — the way a user gets their
2197
+ * legitimate, in-subset JSX to compile on an adapter that cannot host it
2198
+ * (#2613).
2199
+ *
2200
+ * Lives here, next to `ErrorSuggestion`, because both halves of the
2201
+ * "loud-or-escapable" contract must speak the SAME enum: the diagnostic
2202
+ * CLAIMS kinds (`ErrorSuggestion.escape`) and a conformance fixture
2203
+ * DEMONSTRATES them (`JSXFixture.escapes`, which re-exports this type —
2204
+ * `@barefootjs/adapter-tests` depends on this package, not the reverse).
2205
+ * `escape-coverage.test.ts` then checks claims are a subset of what the
2206
+ * twins prove, which is only meaningful while the two share one union.
2207
+ */
2208
+ export type EscapeKind = 'client-directive' | 'prop-precompute' | 'rewrite'
2209
+
2210
+ /**
2211
+ * What an escape costs at SSR. `'none'` — full server render, the result
2212
+ * is present in the server HTML. `'client-render'` — the region is EMPTY
2213
+ * in server HTML until hydration.
2214
+ *
2215
+ * Output-equivalence is explicitly NOT the bar for an escape (#2613): a
2216
+ * `/* @client *\/` region is definitionally not equivalent, and that is a
2217
+ * legitimate trade the user chooses. Making the cost typed and visible is
2218
+ * the honest substitute for pretending it doesn't exist — every renderer
2219
+ * that surfaces an escape surfaces its cost from THIS map, so the trade
2220
+ * can never be quietly dropped on the way to a user.
2221
+ */
2222
+ export type EscapeSsrCost = 'none' | 'client-render'
2223
+
2224
+ export const ESCAPE_SSR_COST: Record<EscapeKind, EscapeSsrCost> = {
2225
+ // `/* @client */` — compiles and hydrates correctly, renders nothing at SSR.
2226
+ 'client-directive': 'client-render',
2227
+ // The refused computation moves to an already-computed prop.
2228
+ 'prop-precompute': 'none',
2229
+ // The source is restructured into an equivalent in-subset shape.
2230
+ rewrite: 'none',
2231
+ }
2232
+
2154
2233
  export interface ErrorSuggestion {
2155
2234
  message: string
2156
2235
  replacement?: string
2236
+ /**
2237
+ * The escape kinds this diagnostic CLAIMS are available, structured
2238
+ * (#2613 increment 3). Additive and one-way: `message` stays
2239
+ * authoritative for humans — several sites have genuinely good
2240
+ * site-specific prose no enum should flatten — while this field is
2241
+ * authoritative for machines (`bf compat`'s legend, the docs matrix,
2242
+ * claim verification). New and edited refusal sites populate it; older
2243
+ * sites migrate opportunistically, so ABSENT means "not yet declared",
2244
+ * never "no escape exists".
2245
+ *
2246
+ * Order carries the recommendation: list a `ssrCost: 'none'` escape
2247
+ * before a `'client-render'` one, matching the prose rule that a
2248
+ * full-SSR way out is offered first.
2249
+ */
2250
+ escape?: ReadonlyArray<{ kind: EscapeKind }>
2157
2251
  }
2158
2252
 
2159
2253
  /**
@@ -2170,6 +2264,30 @@ export interface ConformancePin {
2170
2264
  severity: 'error' | 'warning'
2171
2265
  /** Tracking issue URL (known-limitation label) for this refusal, when one exists. */
2172
2266
  issue?: string
2267
+ /**
2268
+ * Present when THIS adapter has no verified escape yet for this
2269
+ * refusal — the per-adapter half of #2613's "loud-or-escapable" floor
2270
+ * (`packages/compat/src/__tests__/escape-coverage.test.ts`). `issue` is
2271
+ * the tracking pointer for closing the gap (fall back to
2272
+ * https://github.com/piconic-ai/barefootjs/issues/2613 itself when no
2273
+ * more specific issue exists yet).
2274
+ *
2275
+ * Declared here, next to the refusal it qualifies, so an adapter's own
2276
+ * package is the sole place that states what it knows about its own
2277
+ * refusal — no central cross-adapter ledger to keep in sync (that was
2278
+ * the architectural defect increment 1 shipped with: a `packages/compat`
2279
+ * test hardcoding every adapter's id, which made adapters non-additive).
2280
+ *
2281
+ * Absent means the adapter believes an escape is owed here — either
2282
+ * already satisfied (the refused fixture's `escapes` twin compiles
2283
+ * clean, unpinned, non-divergent, and not CSR-skipped on THIS adapter)
2284
+ * or a pending gap the floor test won't let merge silently.
2285
+ *
2286
+ * Shrink-only, same discipline `KNOWN_HOLES` established: once a
2287
+ * working twin exists here, a lingering `unescapable` becomes a STALE
2288
+ * declaration and the floor test fails loudly on it, naming this pin.
2289
+ */
2290
+ unescapable?: { issue: string }
2173
2291
  }
2174
2292
  /** Keyed by shared-fixture id (`JSXFixture.id`). */
2175
2293
  export type ConformancePins = Record<string, ReadonlyArray<ConformancePin>>