@barefootjs/jsx 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/adapters/env-signal.d.ts +40 -0
  2. package/dist/adapters/env-signal.d.ts.map +1 -0
  3. package/dist/adapters/parsed-expr-emitter.d.ts +2 -1
  4. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/augment-inherited-props.d.ts +42 -1
  7. package/dist/augment-inherited-props.d.ts.map +1 -1
  8. package/dist/builtins.d.ts +33 -0
  9. package/dist/builtins.d.ts.map +1 -0
  10. package/dist/compiler.d.ts.map +1 -1
  11. package/dist/errors.d.ts +1 -0
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/expression-parser.d.ts +48 -1
  14. package/dist/expression-parser.d.ts.map +1 -1
  15. package/dist/index.d.ts +5 -4
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +411 -26
  18. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  19. package/dist/jsx-to-ir.d.ts.map +1 -1
  20. package/dist/profiler.d.ts +37 -0
  21. package/dist/profiler.d.ts.map +1 -1
  22. package/dist/ssr-defaults.d.ts.map +1 -1
  23. package/dist/types.d.ts +16 -0
  24. package/dist/types.d.ts.map +1 -1
  25. package/package.json +2 -2
  26. package/src/__tests__/compiler-stress-1244.test.ts +4 -2
  27. package/src/__tests__/expression-parser.test.ts +92 -1
  28. package/src/__tests__/ir-async.test.ts +8 -0
  29. package/src/__tests__/ir-builtin-import-scope.test.ts +188 -0
  30. package/src/__tests__/ir-region.test.ts +86 -0
  31. package/src/__tests__/profiler.test.ts +69 -0
  32. package/src/__tests__/ssr-defaults.test.ts +25 -0
  33. package/src/adapters/env-signal.ts +75 -0
  34. package/src/adapters/parsed-expr-emitter.ts +11 -0
  35. package/src/analyzer.ts +9 -0
  36. package/src/augment-inherited-props.ts +170 -2
  37. package/src/builtins.ts +63 -0
  38. package/src/compiler.ts +6 -2
  39. package/src/errors.ts +10 -0
  40. package/src/expression-parser.ts +156 -2
  41. package/src/index.ts +5 -2
  42. package/src/ir-to-client-js/imports.ts +5 -0
  43. package/src/jsx-to-ir.ts +189 -8
  44. package/src/profiler.ts +63 -0
  45. package/src/ssr-defaults.ts +55 -17
  46. package/src/types.ts +16 -0
@@ -164,7 +164,31 @@ export function augmentInheritedPropAccesses(ir: ComponentIR): void {
164
164
  if (!node) return
165
165
  const el = node as unknown as IRElement
166
166
  for (const attr of el.attrs ?? []) {
167
- const v = attr.value as { kind?: string; expr?: string; presenceOrUndefined?: boolean }
167
+ const v = attr.value as {
168
+ kind?: string
169
+ expr?: string
170
+ presenceOrUndefined?: boolean
171
+ parts?: Array<
172
+ | { type: 'string'; value: string }
173
+ | { type: 'ternary'; condition: string; whenTrue: string; whenFalse: string }
174
+ | { type: 'lookup'; key: string }
175
+ >
176
+ }
177
+ // Template-literal attr values (`className={\`… \${props.className ??
178
+ // ''}\`}`) carry their `props.X` reads inside the parts structure,
179
+ // not a flat expr string (#1896 — TabsContent's template referenced
180
+ // `.ClassName` while the Props struct never declared it). Scan every
181
+ // textual field of every part shape.
182
+ if (v?.parts) {
183
+ for (const part of v.parts) {
184
+ if (part.type === 'string') scan(part.value)
185
+ else if (part.type === 'ternary') {
186
+ scan(part.condition)
187
+ scan(part.whenTrue)
188
+ scan(part.whenFalse)
189
+ } else if (part.type === 'lookup') scan(part.key)
190
+ }
191
+ }
168
192
  if (v?.kind === 'expression' && typeof v.expr === 'string') {
169
193
  scan(v.expr)
170
194
  const expr = v.expr.trim()
@@ -186,6 +210,20 @@ export function augmentInheritedPropAccesses(ir: ComponentIR): void {
186
210
  const c = child as { element?: IRNode }
187
211
  walk((c.element ?? child) as IRNode)
188
212
  }
213
+ // Conditional / if-statement nodes keep their subtrees in branch
214
+ // fields, not `children` (#1896 — DialogTrigger's asChild
215
+ // if-statement hid the button branch's `id={props.id}` from this
216
+ // scan, so the template referenced `.ID` without a Props field).
217
+ const branchy = node as unknown as {
218
+ whenTrue?: IRNode
219
+ whenFalse?: IRNode
220
+ consequent?: IRNode
221
+ alternate?: IRNode
222
+ }
223
+ walk(branchy.whenTrue)
224
+ walk(branchy.whenFalse)
225
+ walk(branchy.consequent)
226
+ walk(branchy.alternate)
189
227
  }
190
228
  walk(ir.root)
191
229
 
@@ -206,6 +244,134 @@ export function augmentInheritedPropAccesses(ir: ComponentIR): void {
206
244
  }
207
245
  }
208
246
 
247
+ /**
248
+ * Parse a const initializer to its static string value: a string literal,
249
+ * a no-substitution template literal, or a `[<string literals>].join(sep)`
250
+ * call. Returns `null` for anything else. The TS parser resolves escapes
251
+ * and quoting exactly as JS would, matching the value the Hono reference
252
+ * inlines at runtime. Shared by the Go, Mojo, and Xslate adapters.
253
+ */
254
+ export function parseStaticStringConst(source: string): string | null {
255
+ const sf = ts.createSourceFile(
256
+ '__const.ts', `const __x = (${source});`, ts.ScriptTarget.Latest, /*setParentNodes*/ false,
257
+ )
258
+ const stmt = sf.statements[0]
259
+ if (!stmt || !ts.isVariableStatement(stmt)) return null
260
+ let init = stmt.declarationList.declarations[0]?.initializer
261
+ while (init && ts.isParenthesizedExpression(init)) init = init.expression
262
+ if (!init) return null
263
+ if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
264
+ return init.text
265
+ }
266
+ return evalStringArrayJoin(source)
267
+ }
268
+
269
+ /**
270
+ * Statically evaluate a template literal whose every interpolation is a
271
+ * bare identifier present in `resolved` to its flat string, else `null`.
272
+ * Companion to `parseStaticStringConst` for COMPOSED module consts
273
+ * (#1896 / #1897 — radio-group's
274
+ * `itemClasses = \`\${itemBaseClasses} \${itemFocusClasses} …\``).
275
+ */
276
+ export function evalTemplateOfStringConsts(
277
+ source: string,
278
+ resolved: ReadonlyMap<string, string>,
279
+ ): string | null {
280
+ const sf = ts.createSourceFile(
281
+ '__const.ts', `const __x = (${source});`, ts.ScriptTarget.Latest, /*setParentNodes*/ false,
282
+ )
283
+ const stmt = sf.statements[0]
284
+ if (!stmt || !ts.isVariableStatement(stmt)) return null
285
+ let init = stmt.declarationList.declarations[0]?.initializer
286
+ while (init && ts.isParenthesizedExpression(init)) init = init.expression
287
+ if (!init || !ts.isTemplateExpression(init)) return null
288
+ let out = init.head.text
289
+ for (const span of init.templateSpans) {
290
+ if (!ts.isIdentifier(span.expression)) return null
291
+ const value = resolved.get(span.expression.text)
292
+ if (value === undefined) return null
293
+ out += value + span.literal.text
294
+ }
295
+ return out
296
+ }
297
+
298
+ /**
299
+ * Build the module string-const map from the IR's localConstants —
300
+ * the SINGLE SOURCE OF TRUTH for all three SSR template adapters.
301
+ * A const qualifies when module-scope and statically resolvable as:
302
+ * - a pure string / no-substitution-template literal,
303
+ * - `[<string literals>].join(sep)`,
304
+ * - a template literal COMPOSED of other qualifying module consts
305
+ * (resolved to a fixed point, so composition order doesn't matter).
306
+ */
307
+ export function collectModuleStringConsts(
308
+ constants: IRMetadata['localConstants'] | undefined,
309
+ ): Map<string, string> {
310
+ const map = new Map<string, string>()
311
+ const candidates = (constants ?? []).filter(
312
+ c => c.isModule && c.value !== undefined,
313
+ )
314
+ let progressed = true
315
+ while (progressed) {
316
+ progressed = false
317
+ for (const c of candidates) {
318
+ if (map.has(c.name)) continue
319
+ const literal =
320
+ parseStaticStringConst(c.value!) ??
321
+ evalTemplateOfStringConsts(c.value!, map)
322
+ if (literal !== null) {
323
+ map.set(c.name, literal)
324
+ progressed = true
325
+ }
326
+ }
327
+ }
328
+ return map
329
+ }
330
+
331
+ /**
332
+ * Resolve `IDENT.key` / `IDENT['key']` where `IDENT` is a module-scope
333
+ * object-literal const and the key/value are static literals — a fully
334
+ * compile-time lookup (the icon registry's `strokePaths['chevron-down']`,
335
+ * pagination's `variantClasses.ghost`; #1896 / #1897). Returns the
336
+ * looked-up scalar, or `null` for any other shape so callers fall back
337
+ * to their generic lowering. Shared by all three SSR template adapters;
338
+ * the prop-KEYED variant of the pattern lives in `parseRecordIndexAccess`.
339
+ */
340
+ export function lookupStaticRecordLiteral(
341
+ objectName: string,
342
+ key: string,
343
+ constants: IRMetadata['localConstants'] | undefined,
344
+ ): { kind: 'string' | 'number'; text: string } | null {
345
+ const constInfo = (constants ?? []).find(c => c.name === objectName && c.isModule)
346
+ if (constInfo?.value === undefined) return null
347
+ const sf = ts.createSourceFile(
348
+ '__rec.ts', `(${constInfo.value})`, ts.ScriptTarget.Latest, /*setParentNodes*/ true,
349
+ )
350
+ if (sf.statements.length !== 1) return null
351
+ const stmt = sf.statements[0]
352
+ if (!ts.isExpressionStatement(stmt)) return null
353
+ let parsed: ts.Expression = stmt.expression
354
+ while (ts.isParenthesizedExpression(parsed)) parsed = parsed.expression
355
+ if (!ts.isObjectLiteralExpression(parsed)) return null
356
+ for (const prop of parsed.properties) {
357
+ if (!ts.isPropertyAssignment(prop)) continue
358
+ const name = prop.name
359
+ const propKey =
360
+ ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)
361
+ ? name.text
362
+ : null
363
+ if (propKey !== key) continue
364
+ let v: ts.Expression = prop.initializer
365
+ while (ts.isParenthesizedExpression(v)) v = v.expression
366
+ if (ts.isNumericLiteral(v)) return { kind: 'number', text: v.text }
367
+ if (ts.isStringLiteral(v) || ts.isNoSubstitutionTemplateLiteral(v)) {
368
+ return { kind: 'string', text: v.text }
369
+ }
370
+ return null
371
+ }
372
+ return null
373
+ }
374
+
209
375
  /**
210
376
  * Statically evaluate `[<string literals>].join(<sep?>)` (e.g. a module-scope
211
377
  * `const stateClasses = ['…', …].join(' ')`) to its joined string, so SSR
@@ -213,7 +379,9 @@ export function augmentInheritedPropAccesses(ir: ComponentIR): void {
213
379
  * instead of referencing a binding that doesn't exist server-side. Default
214
380
  * separator `,` matches JS `Array.prototype.join`. Returns `null` for any
215
381
  * other shape (non-`.join` call, non-array receiver, non-string-literal element
216
- * or separator). Shared by the Mojo + Xslate adapters; Go keeps a private copy.
382
+ * or separator). Shared by the Go, Mojo, and Xslate adapters (all three
383
+ * resolve module consts through `collectModuleStringConsts` above, which
384
+ * folds these joins during its fixed-point pass).
217
385
  */
218
386
  export function evalStringArrayJoin(source: string): string | null {
219
387
  const sf = ts.createSourceFile(
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Compiler built-in JSX tags that are import-scoped to `@barefootjs/client`
3
+ * and compiled away (no runtime value survives in emitted output).
4
+ *
5
+ * `<Async>` and `<Region>` are recognised **structurally** — by their
6
+ * `@barefootjs/client` import in `ir.metadata.imports`, never by a bare
7
+ * capitalized tag-name match — so a user's own `<Async>` / `<Region>`
8
+ * component (imported from elsewhere or declared locally) does not collide
9
+ * with the built-in. The import is elided on emit (both `templateImports`
10
+ * and the client-JS DOM imports) so it never lingers as a phantom runtime
11
+ * import.
12
+ *
13
+ * Runtime stubs + types ship from `@barefootjs/client` (see
14
+ * `packages/client/src/builtins.ts`). See piconic-ai/barefootjs#1915.
15
+ */
16
+
17
+ import type { ImportInfo } from './types.ts'
18
+
19
+ /** Package that the built-in tags must be imported from to be recognised. */
20
+ export const CLIENT_BUILTIN_SOURCE = '@barefootjs/client'
21
+
22
+ export type ClientBuiltinTag = 'Async' | 'Region'
23
+
24
+ /** The recognised built-in tag (export) names. */
25
+ export const CLIENT_BUILTIN_TAGS: readonly ClientBuiltinTag[] = ['Async', 'Region']
26
+
27
+ /** True when `name` is one of the compile-away built-in export names. */
28
+ export function isClientBuiltinName(name: string): name is ClientBuiltinTag {
29
+ return name === 'Async' || name === 'Region'
30
+ }
31
+
32
+ /**
33
+ * Elide the compile-away built-ins from an import list for emission (#1915).
34
+ * `<Async>` / `<Region>` are lowered into the template, so their
35
+ * `@barefootjs/client` import must not survive as a phantom runtime import in
36
+ * either the SSR template or the client JS bundle. Drops the `Async` / `Region`
37
+ * specifiers from `@barefootjs/client` imports, and drops the whole import
38
+ * statement when it has no remaining specifiers.
39
+ */
40
+ export function stripClientBuiltinImports(imports: ImportInfo[]): ImportInfo[] {
41
+ const result: ImportInfo[] = []
42
+ for (const imp of imports) {
43
+ // Only a *value* named import of the built-ins can become a phantom runtime
44
+ // import. Leave everything else untouched: imports from other sources;
45
+ // `import type { Async }` (erased by TS — never a runtime import, and may be
46
+ // needed to type-check emitted templates); and side-effect imports (no
47
+ // specifiers, deliberate). See #1915 review.
48
+ if (imp.source !== CLIENT_BUILTIN_SOURCE || imp.isTypeOnly || imp.specifiers.length === 0) {
49
+ result.push(imp)
50
+ continue
51
+ }
52
+ const kept = imp.specifiers.filter(
53
+ // Keep per-specifier type-only built-ins (`import { type Async }`) — they
54
+ // are erased by TS and never a runtime phantom.
55
+ spec => spec.isDefault || spec.isNamespace || spec.isTypeOnly || !isClientBuiltinName(spec.name),
56
+ )
57
+ // Drop the import entirely when every specifier was a built-in; otherwise
58
+ // re-emit without them.
59
+ if (kept.length === 0) continue
60
+ result.push(kept.length === imp.specifiers.length ? imp : { ...imp, specifiers: kept })
61
+ }
62
+ return result
63
+ }
package/src/compiler.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  import type { TemplateAdapter } from './adapters/interface.ts'
15
15
  import { analyzeComponent, listComponentFunctions, createProgramForFile, needsTypeBasedDetection } from './analyzer.ts'
16
16
  import { jsxToIR } from './jsx-to-ir.ts'
17
+ import { stripClientBuiltinImports } from './builtins.ts'
17
18
  import { generateClientJs, generateClientJsWithSourceMap, analyzeClientNeeds } from './ir-to-client-js/index.ts'
18
19
  import { emitModuleLevelDeclarations } from './ir-to-client-js/emit-module-level.ts'
19
20
  import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode } from './ir-to-client-js/imports.ts'
@@ -502,8 +503,11 @@ export function buildMetadata(
502
503
  // re-emission. Adapters that re-emit imports (Hono, test) call
503
504
  // `rewriteImportsForTemplate` themselves to apply client-shim rewrite or
504
505
  // strip behaviour; adapters whose templates never carry imports (Go,
505
- // Mojo) only consult this list for diagnostics like BF103.
506
- templateImports: ctx.imports,
506
+ // Mojo) only consult this list for diagnostics like BF103. The
507
+ // compile-away built-ins (`<Async>` / `<Region>`) are stripped here so
508
+ // their `@barefootjs/client` import never reaches any adapter's template
509
+ // as a phantom (#1915).
510
+ templateImports: stripClientBuiltinImports(ctx.imports),
507
511
  namedExports: ctx.namedExports,
508
512
  localFunctions: ctx.localFunctions,
509
513
  localConstants: ctx.localConstants,
package/src/errors.ts CHANGED
@@ -47,6 +47,11 @@ export const ErrorCodes = {
47
47
  // Import errors (BF050-BF059)
48
48
  SHARED_PROGRAM_REQUIRED: 'BF050',
49
49
  WRONG_PACKAGE_IMPORT: 'BF051',
50
+ // A bare `<Async>` / `<Region>` tag was used without importing it from
51
+ // `@barefootjs/client`. The built-ins are recognised import-scoped (#1915),
52
+ // so an unimported tag with the built-in name is either a forgotten import
53
+ // or an undeclared component — fail loud with the import to add.
54
+ BUILTIN_REQUIRES_IMPORT: 'BF054',
50
55
 
51
56
  // Init statement errors (BF052)
52
57
  UNDECLARED_INIT_STATEMENT_REFERENCE: 'BF052',
@@ -135,6 +140,11 @@ const errorMessages: Record<ErrorCode, string> = {
135
140
  [ErrorCodes.WRONG_PACKAGE_IMPORT]:
136
141
  'Import from wrong package.',
137
142
 
143
+ [ErrorCodes.BUILTIN_REQUIRES_IMPORT]:
144
+ "Built-in <Async> / <Region> must be imported from '@barefootjs/client'. " +
145
+ 'The compiler recognises these tags by their import (not by tag name), ' +
146
+ 'so an unimported tag with this name is treated as an undeclared component.',
147
+
138
148
  [ErrorCodes.UNDECLARED_INIT_STATEMENT_REFERENCE]:
139
149
  'Init statement references an undeclared identifier. Declare it at module scope, inside the component, or import it — otherwise ESM strict mode throws ReferenceError at runtime.',
140
150
 
@@ -17,6 +17,14 @@ export type ParsedExpr =
17
17
  | { kind: 'literal'; value: string | number | boolean | null; literalType: 'string' | 'number' | 'boolean' | 'null' }
18
18
  | { kind: 'call'; callee: ParsedExpr; args: ParsedExpr[] }
19
19
  | { kind: 'member'; object: ParsedExpr; property: string; computed: boolean }
20
+ // Element access with a NON-literal index (`selected()[index]`,
21
+ // `rows[i + 1]`). A literal-index access (`arr[0]`, `obj['key']`)
22
+ // stays a `member` (computed) since the key is statically known and
23
+ // folds into the same property path. The variable case can't, so the
24
+ // index travels as its own `ParsedExpr` for the adapter to lower
25
+ // (array `->[$i]` vs hash `->{$k}` in Perl, `[index]` in JS). #1897
26
+ // (data-table's per-row `selected()[index]`).
27
+ | { kind: 'index-access'; object: ParsedExpr; index: ParsedExpr }
20
28
  | { kind: 'binary'; op: string; left: ParsedExpr; right: ParsedExpr }
21
29
  | { kind: 'unary'; op: string; argument: ParsedExpr }
22
30
  | { kind: 'conditional'; test: ParsedExpr; consequent: ParsedExpr; alternate: ParsedExpr }
@@ -46,6 +54,7 @@ export type ParsedExpr =
46
54
  | 'toLowerCase'
47
55
  | 'toUpperCase'
48
56
  | 'trim'
57
+ | 'toFixed'
49
58
  | 'split'
50
59
  | 'startsWith'
51
60
  | 'endsWith'
@@ -476,6 +485,106 @@ export function extractArrowBodyExpression(source: string): string | null {
476
485
  return expr.body.getText(sf).trim()
477
486
  }
478
487
 
488
+ /**
489
+ * One member of a context-provider object-literal value
490
+ * (`<Ctx.Provider value={{ open: () => …, onOpenChange: … }}>`), classified
491
+ * for SSR lowering:
492
+ *
493
+ * - `getter` — a ZERO-parameter arrow with an expression body. At SSR time
494
+ * the provider value is fixed for the render, so the getter is equivalent
495
+ * to its body's value snapshot (`open: () => props.open ?? false` reads as
496
+ * `props.open ?? false`). Arrows with parameters are NOT getters — their
497
+ * body references the parameter, which has no SSR value.
498
+ * - `function` — any other function shape (parameterised / block-bodied
499
+ * arrow, function expression, or a `??` / `||` chain with a function
500
+ * operand). These are behavior, not data: SSR never invokes them, so
501
+ * adapters lower them to their nil value (`undef` / `nil`).
502
+ * - `expression` — everything else; lowers through the adapter's normal
503
+ * expression pipeline (so signal getters, props, memo calls keep their
504
+ * existing SSR seeding semantics).
505
+ */
506
+ export type ProviderObjectMember =
507
+ | { name: string; kind: 'getter'; body: string }
508
+ | { name: string; kind: 'function' }
509
+ | { name: string; kind: 'expression'; expr: string }
510
+
511
+ /**
512
+ * Structurally parse a `<Ctx.Provider value={{ … }}>` object literal into
513
+ * per-member SSR lowering classifications (see `ProviderObjectMember`).
514
+ *
515
+ * Returns `null` when the source is not a plain object literal, or when it
516
+ * contains a shape with no per-member story (spread entry, computed key,
517
+ * get/set accessor) — callers fall back to their existing whole-expression
518
+ * path (typically a BF101 refusal). Shorthand members (`{ search }`) yield
519
+ * an `expression` member with the identifier as the expression; method
520
+ * members (`{ open() {…} }`) classify as `function` like block-bodied
521
+ * arrows.
522
+ */
523
+ export function parseProviderObjectLiteral(source: string): ProviderObjectMember[] | null {
524
+ const sf = ts.createSourceFile(
525
+ '__provider__.ts',
526
+ `const __x = (${source});`,
527
+ ts.ScriptTarget.Latest,
528
+ /* setParentNodes */ true,
529
+ )
530
+ const stmt = sf.statements[0]
531
+ if (!stmt || !ts.isVariableStatement(stmt)) return null
532
+ let init = stmt.declarationList.declarations[0]?.initializer
533
+ while (init && ts.isParenthesizedExpression(init)) init = init.expression
534
+ if (!init || !ts.isObjectLiteralExpression(init)) return null
535
+
536
+ const isFunctionShaped = (e: ts.Expression): boolean => {
537
+ let v: ts.Expression = e
538
+ while (ts.isParenthesizedExpression(v)) v = v.expression
539
+ if (ts.isArrowFunction(v) || ts.isFunctionExpression(v)) return true
540
+ // `props.onX ?? (() => {})` — a fallback chain with a function operand
541
+ // is function-typed regardless of which side wins at runtime.
542
+ if (
543
+ ts.isBinaryExpression(v) &&
544
+ (v.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken ||
545
+ v.operatorToken.kind === ts.SyntaxKind.BarBarToken)
546
+ ) {
547
+ return isFunctionShaped(v.left) || isFunctionShaped(v.right)
548
+ }
549
+ return false
550
+ }
551
+
552
+ const members: ProviderObjectMember[] = []
553
+ for (const prop of init.properties) {
554
+ if (ts.isShorthandPropertyAssignment(prop)) {
555
+ members.push({ name: prop.name.text, kind: 'expression', expr: prop.name.text })
556
+ continue
557
+ }
558
+ if (ts.isMethodDeclaration(prop)) {
559
+ // `{ open() {…} }` — function-shaped behavior, same as a
560
+ // block-bodied arrow member.
561
+ const name = ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)
562
+ ? prop.name.text
563
+ : null
564
+ if (name === null) return null // computed key
565
+ members.push({ name, kind: 'function' })
566
+ continue
567
+ }
568
+ if (!ts.isPropertyAssignment(prop)) return null // spread / accessor
569
+ const name = ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)
570
+ ? prop.name.text
571
+ : null
572
+ if (name === null) return null // computed key
573
+ let v: ts.Expression = prop.initializer
574
+ while (ts.isParenthesizedExpression(v)) v = v.expression
575
+ if (ts.isArrowFunction(v) && v.parameters.length === 0 && !ts.isBlock(v.body)) {
576
+ members.push({ name, kind: 'getter', body: v.body.getText(sf).trim() })
577
+ continue
578
+ }
579
+ if (isFunctionShaped(v)) {
580
+ members.push({ name, kind: 'function' })
581
+ continue
582
+ }
583
+ members.push({ name, kind: 'expression', expr: v.getText(sf).trim() })
584
+ }
585
+ return members
586
+ }
587
+
479
588
  /**
480
589
  * A single entry of a JSX `style={{ … }}` object, lowered for SSR. The key is
481
590
  * already CSS-cased (`backgroundColor` → `background-color`); the value is
@@ -757,6 +866,14 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
757
866
  if (callee.property === 'trim') {
758
867
  return { kind: 'array-method', method: 'trim', object: callee.object, args }
759
868
  }
869
+ // `.toFixed(digits?)` — Number → fixed-decimal string. The digit
870
+ // count (default 0) travels as the single arg; all adapters route
871
+ // through a `to_fixed` runtime helper (Perl) / `fmt.Sprintf` (Go)
872
+ // so JS's rounding + zero-padding semantics match. #1897
873
+ // (data-table's `payment.amount.toFixed(2)`).
874
+ if (callee.property === 'toFixed') {
875
+ return { kind: 'array-method', method: 'toFixed', object: callee.object, args }
876
+ }
760
877
  // `.split()` / `.split(sep)` / `.split(sep, limit)` — string →
761
878
  // array, full JS arity. `.split()` (no separator) returns the
762
879
  // whole string as a single element; `.split(sep)` splits on the
@@ -1028,6 +1145,13 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1028
1145
  if (ts.isElementAccessExpression(node)) {
1029
1146
  const object = convertNode(node.expression, raw)
1030
1147
  const argNode = node.argumentExpression
1148
+ // `argumentExpression` is non-optional in the TS types but CAN be
1149
+ // undefined on an AST recovered from incomplete source (`arr[`). Guard
1150
+ // so a half-typed expression surfaces a recoverable BF101 instead of
1151
+ // throwing inside `ts.isNumericLiteral(undefined)`.
1152
+ if (!argNode) {
1153
+ return { kind: 'unsupported', raw, reason: 'Element access with no index expression' }
1154
+ }
1031
1155
  // For simple number/string access, store as property
1032
1156
  if (ts.isNumericLiteral(argNode)) {
1033
1157
  return { kind: 'member', object, property: argNode.text, computed: true }
@@ -1035,8 +1159,13 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1035
1159
  if (ts.isStringLiteral(argNode)) {
1036
1160
  return { kind: 'member', object, property: argNode.text, computed: true }
1037
1161
  }
1038
- // Complex computed access
1039
- return { kind: 'unsupported', raw, reason: 'Complex computed property access' }
1162
+ // Variable / expression index (`selected()[index]`, `rows[i + 1]`):
1163
+ // carry the index as its own ParsedExpr so the adapter can lower it
1164
+ // (the literal forms above fold into a static property path; this
1165
+ // one can't). #1897 (data-table).
1166
+ const index = convertNode(argNode, raw)
1167
+ if (index.kind === 'unsupported') return index
1168
+ return { kind: 'index-access', object, index }
1040
1169
  }
1041
1170
 
1042
1171
  // Binary expression: a === b, count > 0, a + b
@@ -1988,6 +2117,8 @@ function findImpureDefaultNode(expr: ParsedExpr): string | null {
1988
2117
  return null
1989
2118
  case 'member':
1990
2119
  return findImpureDefaultNode(expr.object)
2120
+ case 'index-access':
2121
+ return findImpureDefaultNode(expr.object) ?? findImpureDefaultNode(expr.index)
1991
2122
  case 'unary':
1992
2123
  return findImpureDefaultNode(expr.argument)
1993
2124
  case 'binary':
@@ -2217,6 +2348,10 @@ function collectIdentifiers(expr: ParsedExpr, out: Set<string>): void {
2217
2348
  case 'member':
2218
2349
  collectIdentifiers(expr.object, out)
2219
2350
  return
2351
+ case 'index-access':
2352
+ collectIdentifiers(expr.object, out)
2353
+ collectIdentifiers(expr.index, out)
2354
+ return
2220
2355
  case 'binary':
2221
2356
  case 'logical':
2222
2357
  collectIdentifiers(expr.left, out)
@@ -2311,6 +2446,8 @@ function substituteDestructuredFields(
2311
2446
  }
2312
2447
  }
2313
2448
  return { kind: 'member', object: walk(e.object), property: e.property, computed: e.computed }
2449
+ case 'index-access':
2450
+ return { kind: 'index-access', object: walk(e.object), index: walk(e.index) }
2314
2451
  case 'binary':
2315
2452
  return { kind: 'binary', op: e.op, left: walk(e.left), right: walk(e.right) }
2316
2453
  case 'logical':
@@ -2559,6 +2696,17 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2559
2696
  return { supported: true, level: 'L2' }
2560
2697
  }
2561
2698
 
2699
+ case 'index-access': {
2700
+ // `arr[index]` — supported when both the receiver and the index
2701
+ // expression are themselves supported (the index is typically a
2702
+ // loop variable or arithmetic over one). #1897 (data-table).
2703
+ const objSupport = checkSupport(expr.object)
2704
+ if (!objSupport.supported) return objSupport
2705
+ const indexSupport = checkSupport(expr.index)
2706
+ if (!indexSupport.supported) return indexSupport
2707
+ return { supported: true, level: 'L2' }
2708
+ }
2709
+
2562
2710
  case 'binary': {
2563
2711
  const leftSupport = checkSupport(expr.left)
2564
2712
  if (!leftSupport.supported) return leftSupport
@@ -2640,6 +2788,8 @@ export function containsHigherOrder(expr: ParsedExpr): boolean {
2640
2788
  return expr.args.some(containsHigherOrder) || containsHigherOrder(expr.callee)
2641
2789
  case 'member':
2642
2790
  return containsHigherOrder(expr.object)
2791
+ case 'index-access':
2792
+ return containsHigherOrder(expr.object) || containsHigherOrder(expr.index)
2643
2793
  case 'binary':
2644
2794
  return containsHigherOrder(expr.left) || containsHigherOrder(expr.right)
2645
2795
  case 'unary':
@@ -2811,6 +2961,8 @@ export function exprToString(expr: ParsedExpr): string {
2811
2961
  return `${exprToString(expr.callee)}(${expr.args.map(exprToString).join(', ')})`
2812
2962
  case 'member':
2813
2963
  return `${exprToString(expr.object)}.${expr.property}`
2964
+ case 'index-access':
2965
+ return `${exprToString(expr.object)}[${exprToString(expr.index)}]`
2814
2966
  case 'binary':
2815
2967
  return `${exprToString(expr.left)} ${expr.op} ${exprToString(expr.right)}`
2816
2968
  case 'unary':
@@ -2892,6 +3044,8 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
2892
3044
  : JSON.stringify(expr.property)
2893
3045
  return `${obj}[${key}]`
2894
3046
  }
3047
+ case 'index-access':
3048
+ return `${stringifyParsedExpr(expr.object)}[${stringifyParsedExpr(expr.index)}]`
2895
3049
  case 'binary':
2896
3050
  return `${stringifyParsedExpr(expr.left)} ${expr.op} ${stringifyParsedExpr(expr.right)}`
2897
3051
  case 'unary':
package/src/index.ts CHANGED
@@ -76,6 +76,7 @@ export type { JsxAdapterConfig } from './adapters/jsx-adapter.ts'
76
76
  export { rewriteImportsForTemplate } from './adapters/template-imports.ts'
77
77
  export { emitParsedExpr } from './adapters/parsed-expr-emitter.ts'
78
78
  export type { ParsedExprEmitter, HigherOrderMethod, ArrayMethod, SortMethod, LiteralType } from './adapters/parsed-expr-emitter.ts'
79
+ export { importsSearchParams, searchParamsLocalNames, matchSearchParamsMethodCall } from './adapters/env-signal.ts'
79
80
  export { emitIRNode } from './adapters/ir-node-emitter.ts'
80
81
  export type { IRNodeEmitter, EmitIRNode } from './adapters/ir-node-emitter.ts'
81
82
  export { emitAttrValue } from './adapters/attr-value-emitter.ts'
@@ -247,7 +248,7 @@ export {
247
248
  export { ErrorCodes, createError, formatError, generateCodeFrame } from './errors.ts'
248
249
 
249
250
  // Expression Parser
250
- export { parseExpression, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries } from './expression-parser.ts'
251
+ export { parseExpression, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, parseProviderObjectLiteral, type ProviderObjectMember } from './expression-parser.ts'
251
252
  export type { StyleObjectEntry } from './expression-parser.ts'
252
253
  export type { ParsedExpr, ParsedStatement, SortComparator, SortKey, ReduceOp, FlatDepth, FlatMapOp, FlatMapLeaf, SupportLevel, SupportResult, TemplatePart } from './expression-parser.ts'
253
254
  export { buildLoopChainExpr } from './loop-chain.ts'
@@ -285,6 +286,7 @@ export type { WrapReason } from './ir-to-client-js/reactivity.ts'
285
286
  // Reactive performance profiler (#1690). Static half (SR5 budget, SR6 diff) +
286
287
  // dynamic half (SR2/SR4 join, SR7 report, v1 analyses).
287
288
  export {
289
+ PROFILE_SCHEMA_VERSION,
288
290
  buildStaticBudget,
289
291
  formatStaticBudget,
290
292
  diffStaticBudget,
@@ -306,6 +308,7 @@ export type {
306
308
  StaticBudget,
307
309
  StaticBudgetOptions,
308
310
  FanOutEntry,
311
+ BudgetHandler,
309
312
  BudgetDiff,
310
313
  FanOutChange,
311
314
  ProfileReport,
@@ -352,7 +355,7 @@ export type {
352
355
  export { BOOLEAN_ATTRS, isBooleanAttr } from './html-constants.ts'
353
356
 
354
357
  // Shared props-object-pattern helpers for the Go / Mojo template adapters
355
- export { augmentInheritedPropAccesses, parseRecordIndexAccess, evalStringArrayJoin, collectContextConsumers } from './augment-inherited-props.ts'
358
+ export { augmentInheritedPropAccesses, parseRecordIndexAccess, evalStringArrayJoin, collectModuleStringConsts, lookupStaticRecordLiteral, collectContextConsumers } from './augment-inherited-props.ts'
356
359
  export type { RecordIndexAccess, RecordIndexEntry, ContextConsumer } from './augment-inherited-props.ts'
357
360
 
358
361
  // HTML element attribute types
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ComponentIR, IRNode } from '../types.ts'
6
+ import { isClientBuiltinName } from '../builtins.ts'
6
7
 
7
8
  // All exports from @barefootjs/client/runtime that may be used in generated code
8
9
  export const RUNTIME_IMPORT_CANDIDATES = [
@@ -62,6 +63,10 @@ export function collectUserDomImports(ir: ComponentIR): string[] {
62
63
  if (runtimeSources.has(imp.source) && !imp.isTypeOnly) {
63
64
  for (const spec of imp.specifiers) {
64
65
  if (!spec.isDefault && !spec.isNamespace) {
66
+ // Compile-away built-ins (`<Async>` / `<Region>`) are lowered into
67
+ // the template — never emit their import into the client bundle,
68
+ // where it would be a phantom runtime import (#1915).
69
+ if (isClientBuiltinName(spec.name)) continue
65
70
  userImports.push(spec.alias ? `${spec.name} as ${spec.alias}` : spec.name)
66
71
  }
67
72
  }