@barefootjs/jsx 0.23.0 → 0.24.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.
package/src/analyzer.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import ts from 'typescript'
10
- import type { ImportSpecifier, TypeInfo, ParamInfo, ReactiveFactoryInfo, DeclinedReactiveFactory, SourceLocation } from './types.ts'
10
+ import type { ImportSpecifier, TypeInfo, ParamInfo, ReactiveFactoryInfo, DeclinedReactiveFactory, RequiredFactoryImport, SourceLocation } from './types.ts'
11
11
  import { parseExpression, parseBlockBodyTolerant, foldBlockToExpr } from './expression-parser.ts'
12
12
  import { rewriteBarePropRefs } from './prop-rewrite.ts'
13
13
  import { incrementCounter } from './instrumentation.ts'
@@ -23,7 +23,7 @@ import {
23
23
  isArrowComponentFunction,
24
24
  collectReactiveGetterNames,
25
25
  } from './analyzer-context.ts'
26
- import { createError, createWarning, ErrorCodes } from './errors.ts'
26
+ import { createError, createWarning, ErrorCodes, type ErrorCode } from './errors.ts'
27
27
  import { baseTypeName } from './rich-type-evidence.ts'
28
28
  import { CATALOGUED_RICH_TYPE_NAMES } from './date-lowering.ts'
29
29
  import path from 'node:path'
@@ -3945,6 +3945,108 @@ function prescanReactiveFactoriesInSource(
3945
3945
  return result
3946
3946
  }
3947
3947
 
3948
+ /**
3949
+ * #2332 — portable component-relative import specifier for a resolved
3950
+ * absolute helper-import target. Same `'.'`/`'./'` prefix convention as the
3951
+ * CLI's buildRelativeImportRewriter (packages/cli/src/lib/build.ts); strips
3952
+ * the resolved extension to match the codebase's extensionless-import
3953
+ * style. Unlike `buildRelativeImportRewriter`, this normalizes
3954
+ * `path.relative`'s separators to POSIX (`/`) — a backslash-separated
3955
+ * specifier is not valid ESM syntax, so on win32 `path.relative`'s native
3956
+ * output would inject a broken import rather than merely an unconventional
3957
+ * one (Copilot review, PR #2338).
3958
+ */
3959
+ function toComponentRelativeSpecifier(resolvedAbs: string, componentFilePath: string): string {
3960
+ let rel = path.relative(path.dirname(componentFilePath), resolvedAbs).split(path.sep).join('/')
3961
+ rel = rel.replace(/\.(tsx|ts|jsx|js)$/, '')
3962
+ if (rel === '') rel = '.'
3963
+ if (!rel.startsWith('.')) rel = './' + rel
3964
+ return rel
3965
+ }
3966
+
3967
+ /**
3968
+ * localName -> identity of what the entry file's own top-level named value
3969
+ * imports bind, for the satisfied-import dedupe check (#2332): if the
3970
+ * component file already imports the exact binding a factory needs to
3971
+ * re-provision, injecting it again would be a duplicate declaration rather
3972
+ * than a shadow, so that case is skipped instead of injected. `targetKey` is
3973
+ * the resolved absolute path for relative sources (or `unresolved:<source>`
3974
+ * when probing fails) and the raw specifier for bare sources.
3975
+ */
3976
+ function buildEntryImportIndex(
3977
+ sf: ts.SourceFile,
3978
+ filePath: string
3979
+ ): Map<string, { targetKey: string; exportedName: string }> {
3980
+ const index = new Map<string, { targetKey: string; exportedName: string }>()
3981
+ for (const stmt of sf.statements) {
3982
+ if (!ts.isImportDeclaration(stmt)) continue
3983
+ if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
3984
+ if (stmt.importClause?.isTypeOnly) continue
3985
+ const src = stmt.moduleSpecifier.text
3986
+ const targetKey = src.startsWith('./') || src.startsWith('../')
3987
+ ? (resolveRelativeImportToFile(src, filePath) ?? 'unresolved:' + src)
3988
+ : src
3989
+ const namedBindings = stmt.importClause?.namedBindings
3990
+ if (namedBindings && ts.isNamedImports(namedBindings)) {
3991
+ for (const el of namedBindings.elements) {
3992
+ if (el.isTypeOnly) continue
3993
+ index.set(el.name.text, { targetKey, exportedName: (el.propertyName ?? el.name).text })
3994
+ }
3995
+ }
3996
+ }
3997
+ return index
3998
+ }
3999
+
4000
+ /**
4001
+ * Every value-binding name anywhere in the entry file (imports, variable
4002
+ * declarations at any depth, function/class/enum names, function
4003
+ * parameters) — used by the #2332 re-provisioned-import collision check
4004
+ * (BF113). Deliberately over-broad: the inlined factory body lands INSIDE a
4005
+ * component function, where any nested binding (params, destructures,
4006
+ * locals) would silently shadow a top-level import injected by this round.
4007
+ * A scan limited to top-level bindings would miss that, so any hit anywhere
4008
+ * in the file declines re-provisioning with a loud BF113 rather than
4009
+ * risking a silent shadow — matching `moduleCaptureCheck`'s stated failure-
4010
+ * direction philosophy (loud build error over silent runtime break).
4011
+ */
4012
+ function collectEntryBindingNames(sf: ts.SourceFile): Set<string> {
4013
+ const names = new Set<string>()
4014
+ function visit(node: ts.Node): void {
4015
+ if (ts.isImportDeclaration(node) && node.importClause) {
4016
+ if (node.importClause.name) names.add(node.importClause.name.text)
4017
+ const namedBindings = node.importClause.namedBindings
4018
+ if (namedBindings && ts.isNamedImports(namedBindings)) {
4019
+ // Type-only specifiers still occupy the identifier at the TS level.
4020
+ for (const el of namedBindings.elements) names.add(el.name.text)
4021
+ }
4022
+ if (namedBindings && ts.isNamespaceImport(namedBindings)) {
4023
+ names.add(namedBindings.name.text)
4024
+ }
4025
+ }
4026
+ if (ts.isVariableDeclaration(node)) {
4027
+ const out: string[] = []
4028
+ addBindingNames(node.name, out)
4029
+ for (const n of out) names.add(n)
4030
+ }
4031
+ if (
4032
+ (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isEnumDeclaration(node)) &&
4033
+ node.name
4034
+ ) {
4035
+ names.add(node.name.text)
4036
+ }
4037
+ if (ts.isFunctionLike(node)) {
4038
+ for (const p of node.parameters) {
4039
+ const out: string[] = []
4040
+ addBindingNames(p.name, out)
4041
+ for (const n of out) names.add(n)
4042
+ }
4043
+ }
4044
+ ts.forEachChild(node, visit)
4045
+ }
4046
+ visit(sf)
4047
+ return names
4048
+ }
4049
+
3948
4050
  /**
3949
4051
  * Cross-file half of the factory prescan (#2325 round 2): resolve factories
3950
4052
  * defined in a relative-imported helper file so `const { count } =
@@ -4020,6 +4122,13 @@ function prescanImportedReactiveFactories(
4020
4122
  }
4021
4123
  if (importsToCheck.length === 0) return
4022
4124
 
4125
+ // #2332 — computed once per entry file, shared across all helper files.
4126
+ const entryBindingNames = collectEntryBindingNames(entrySourceFile)
4127
+ const entryImportIndex = buildEntryImportIndex(entrySourceFile, filePath)
4128
+ // localName -> planned injection identity; a later factory requiring the
4129
+ // same name from a DIFFERENT (targetKey, exportedName) is a collision.
4130
+ const plannedInjections = new Map<string, { targetKey: string; exportedName: string }>()
4131
+
4023
4132
  for (const { src, specs } of importsToCheck) {
4024
4133
  const resolved = resolveRelativeImportToFile(src, filePath)
4025
4134
  // Unresolvable — left alone here; the name-heuristic BF110 branch in
@@ -4112,17 +4221,80 @@ function prescanImportedReactiveFactories(
4112
4221
  result.declined.set(spec.local, det.declined)
4113
4222
  break
4114
4223
  case 'factory': {
4115
- const offending = moduleCaptureCheck(fn, det.info, moduleBindings, fn.name!.text)
4116
- if (offending.length > 0) {
4224
+ const capture = moduleCaptureCheck(fn, det.info, moduleBindings, fn.name!.text)
4225
+ if (capture.captured.length > 0) {
4117
4226
  result.declined.set(spec.local, {
4118
4227
  code: 'BF112',
4119
- detail: `'${offending.join("', '")}'`,
4228
+ detail: `'${capture.captured.join("', '")}'`,
4120
4229
  loc: det.info.loc,
4121
4230
  })
4122
- } else {
4123
- det.info.sourceFilePath = resolved
4124
- result.factories.set(spec.local, det.info)
4231
+ break
4232
+ }
4233
+ // #2332 — re-provision the helper file's own named value imports
4234
+ // that the factory body references, instead of declining. Each
4235
+ // ref resolves to a component-relative specifier (or passes
4236
+ // through unchanged for bare/npm specifiers); a ref already
4237
+ // satisfied by an identical top-level import in the component
4238
+ // file is dropped rather than injected (would redeclare it). A
4239
+ // ref whose local name collides with a DIFFERENT existing/planned
4240
+ // binding declines with BF113 — `pending` is only merged into
4241
+ // `plannedInjections` on full factory success (§3.4), so a
4242
+ // factory that declines mid-loop reserves nothing.
4243
+ const required: RequiredFactoryImport[] = []
4244
+ const pending: Array<[string, { targetKey: string; exportedName: string }]> = []
4245
+ let declinedEntry: DeclinedReactiveFactory | null = null
4246
+ for (const ref of capture.importedRefs) {
4247
+ let specifier: string
4248
+ let targetKey: string
4249
+ if (ref.source.startsWith('./') || ref.source.startsWith('../')) {
4250
+ // Resolve from the HELPER file's directory (`resolved` is its
4251
+ // absolute path). Unresolvable → same posture as a local
4252
+ // capture: nothing importable to re-provision (BF112).
4253
+ const abs = resolveRelativeImportToFile(ref.source, resolved)
4254
+ if (!abs) {
4255
+ declinedEntry = {
4256
+ code: 'BF112',
4257
+ detail: `'${ref.localName}' (import '${ref.source}' did not resolve from the helper file)`,
4258
+ loc: det.info.loc,
4259
+ }
4260
+ break
4261
+ }
4262
+ specifier = toComponentRelativeSpecifier(abs, filePath)
4263
+ targetKey = abs
4264
+ } else {
4265
+ specifier = ref.source // bare/npm specifier — unchanged (#2332 test 2)
4266
+ targetKey = ref.source
4267
+ }
4268
+ // Already satisfied by an identical top-level import in the
4269
+ // component file — injecting again would redeclare the binding.
4270
+ const existing = entryImportIndex.get(ref.localName)
4271
+ if (existing && existing.targetKey === targetKey && existing.exportedName === ref.exportedName) {
4272
+ continue
4273
+ }
4274
+ const planned = plannedInjections.get(ref.localName)
4275
+ const collides =
4276
+ (existing !== undefined) ||
4277
+ (planned !== undefined && (planned.targetKey !== targetKey || planned.exportedName !== ref.exportedName)) ||
4278
+ (planned === undefined && entryBindingNames.has(ref.localName))
4279
+ if (collides) {
4280
+ declinedEntry = {
4281
+ code: 'BF113',
4282
+ detail: `'${ref.localName}' from '${specifier}'`,
4283
+ loc: det.info.loc,
4284
+ }
4285
+ break
4286
+ }
4287
+ pending.push([ref.localName, { targetKey, exportedName: ref.exportedName }])
4288
+ required.push({ localName: ref.localName, exportedName: ref.exportedName, specifier })
4289
+ }
4290
+ if (declinedEntry) {
4291
+ result.declined.set(spec.local, declinedEntry)
4292
+ break
4125
4293
  }
4294
+ for (const [name, id] of pending) plannedInjections.set(name, id)
4295
+ det.info.sourceFilePath = resolved
4296
+ if (required.length > 0) det.info.requiredImports = required
4297
+ result.factories.set(spec.local, det.info)
4126
4298
  break
4127
4299
  }
4128
4300
  }
@@ -4131,34 +4303,56 @@ function prescanImportedReactiveFactories(
4131
4303
  }
4132
4304
 
4133
4305
  /**
4134
- * Value bindings at the helper file's module scope that an inlined factory
4135
- * body must not capture (#2325 §4h / BF112): top-level const/let/var,
4136
- * function/class/enum names, and value import specifiers — EXCEPT imports
4137
- * from '@barefootjs/client' / '@barefootjs/client/runtime'. Those are
4138
- * re-provisioned from usage by the client-JS emitter regardless of where
4139
- * the call that used them textually came from (`resolveFinalImports` /
4140
- * `detectUsedImports` regex-scan the *generated* code, not the consumer's
4141
- * source imports see #2325 spec C1), so an inlined body calling
4142
- * `createSignal` is never a capture even though the helper file itself
4143
- * imports it. Type-only imports/declarations carry no runtime binding and
4144
- * are excluded.
4306
+ * Value bindings at the helper file's module scope, split by whether they
4307
+ * have a re-importable module of their own (#2332).
4308
+ *
4309
+ * `local` bindings — top-level const/let/var, function/class/enum names,
4310
+ * plus default-import and namespace-import names have no module a
4311
+ * component file could re-import them from, so an inlined factory body
4312
+ * referencing one unconditionally declines with BF112 (#2325 §4h): moving
4313
+ * the body into the component file would leave a dangling reference.
4314
+ *
4315
+ * `imported` bindings the helper file's own named value imports — CAN be
4316
+ * re-provisioned: the component file can import the same binding under the
4317
+ * same specifier (#2332). These are collected here (keyed by the helper
4318
+ * file's local name) but are NOT captures; `moduleCaptureCheck` below
4319
+ * reports them separately from `local` hits so the caller can decide
4320
+ * whether to re-import rather than unconditionally decline.
4321
+ *
4322
+ * EXCEPT in both cases: imports from '@barefootjs/client' /
4323
+ * '@barefootjs/client/runtime'. Those are re-provisioned from usage by the
4324
+ * client-JS emitter regardless of where the call that used them textually
4325
+ * came from (`resolveFinalImports` / `detectUsedImports` regex-scan the
4326
+ * *generated* code, not the consumer's source imports — see #2325 spec C1),
4327
+ * so an inlined body calling `createSignal` is never a capture even though
4328
+ * the helper file itself imports it. Type-only imports/declarations carry
4329
+ * no runtime binding and are excluded.
4145
4330
  */
4146
- function collectHelperModuleValueBindings(sf: ts.SourceFile): Set<string> {
4147
- const names = new Set<string>()
4331
+ interface HelperModuleBindings {
4332
+ /** Declared directly in the helper file — unconditional BF112 capture. */
4333
+ local: Set<string>
4334
+ /** Named value-import specifiers, keyed by helper-file local name —
4335
+ * re-provisionable into the component file (#2332). */
4336
+ imported: Map<string, { source: string; exportedName: string }>
4337
+ }
4338
+
4339
+ function collectHelperModuleValueBindings(sf: ts.SourceFile): HelperModuleBindings {
4340
+ const local = new Set<string>()
4341
+ const imported = new Map<string, { source: string; exportedName: string }>()
4148
4342
  for (const stmt of sf.statements) {
4149
4343
  if (ts.isVariableStatement(stmt)) {
4150
4344
  const out: string[] = []
4151
4345
  for (const decl of stmt.declarationList.declarations) {
4152
4346
  addBindingNames(decl.name, out)
4153
4347
  }
4154
- for (const n of out) names.add(n)
4348
+ for (const n of out) local.add(n)
4155
4349
  continue
4156
4350
  }
4157
4351
  if (
4158
4352
  (ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)) &&
4159
4353
  stmt.name
4160
4354
  ) {
4161
- names.add(stmt.name.text)
4355
+ local.add(stmt.name.text)
4162
4356
  continue
4163
4357
  }
4164
4358
  if (ts.isImportDeclaration(stmt)) {
@@ -4166,42 +4360,62 @@ function collectHelperModuleValueBindings(sf: ts.SourceFile): Set<string> {
4166
4360
  if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
4167
4361
  const src = stmt.moduleSpecifier.text
4168
4362
  if (src === '@barefootjs/client' || src === '@barefootjs/client/runtime') continue
4169
- if (stmt.importClause?.name) names.add(stmt.importClause.name.text)
4363
+ // Default/namespace imports stay hard BF112 (#2332 scope decision):
4364
+ // no single named export to re-provision under one local name.
4365
+ if (stmt.importClause?.name) local.add(stmt.importClause.name.text)
4170
4366
  const namedBindings = stmt.importClause?.namedBindings
4171
4367
  if (namedBindings && ts.isNamedImports(namedBindings)) {
4172
4368
  for (const el of namedBindings.elements) {
4173
4369
  if (el.isTypeOnly) continue
4174
- names.add(el.name.text)
4370
+ imported.set(el.name.text, { source: src, exportedName: (el.propertyName ?? el.name).text })
4175
4371
  }
4176
4372
  }
4177
4373
  if (namedBindings && ts.isNamespaceImport(namedBindings)) {
4178
- names.add(namedBindings.name.text)
4374
+ local.add(namedBindings.name.text)
4179
4375
  }
4180
4376
  }
4181
4377
  }
4182
- return names
4378
+ return { local, imported }
4379
+ }
4380
+
4381
+ /**
4382
+ * Categorized free-identifier references of a reactive-factory body into
4383
+ * its own module scope (#2332): `captured` are unconditional BF112 hits
4384
+ * (helper-local bindings — the body would dangle if inlined verbatim);
4385
+ * `importedRefs` are references to the helper file's own named value
4386
+ * imports, which the caller may re-provision into the component file
4387
+ * instead of declining (§3.4 in the #2332 spec).
4388
+ */
4389
+ interface ModuleCaptureResult {
4390
+ /** Free refs resolving to helper-local bindings (BF112), sorted. */
4391
+ captured: string[]
4392
+ /** Free refs resolving to the helper's own named value imports, sorted by localName. */
4393
+ importedRefs: Array<{ localName: string; source: string; exportedName: string }>
4183
4394
  }
4184
4395
 
4185
4396
  /**
4186
4397
  * Free identifiers of a reactive-factory body that resolve to bindings at
4187
- * its own module scope (#2325 §4h / BF112) — references the inlined body
4188
- * would silently lose once spliced into the component file. Returns the
4189
- * offending names sorted, for stable diagnostic text.
4398
+ * its own module scope (#2325 §4h / BF112, #2332) — references the inlined
4399
+ * body would silently lose once spliced into the component file, unless
4400
+ * re-provisioned as an import. Returns `captured` (unconditional BF112) and
4401
+ * `importedRefs` (re-provisionable) separately, each sorted for stable
4402
+ * diagnostic/injection text.
4190
4403
  *
4191
4404
  * Known accepted limitation: `extractFreeIdentifiersFromNode` only scope-
4192
4405
  * tracks arrow-function parameters, not nested `function` declarations'
4193
4406
  * parameters or nested-block declarations — a body-nested binding that
4194
4407
  * happens to shadow a helper-module binding could false-positive into
4195
- * BF112. Acceptable: the failure direction is a loud build error on a rare
4196
- * shape, never a silent runtime break.
4408
+ * BF112 or `importedRefs`. Acceptable: the failure direction is always a
4409
+ * loud build error (BF112/BF113) or a redundant-but-harmless injected
4410
+ * import, never a silent dangling reference.
4197
4411
  */
4198
4412
  function moduleCaptureCheck(
4199
4413
  fn: ts.FunctionDeclaration,
4200
4414
  info: ReactiveFactoryInfo,
4201
- moduleBindings: Set<string>,
4415
+ moduleBindings: HelperModuleBindings,
4202
4416
  selfName: string
4203
- ): string[] {
4204
- if (!fn.body) return []
4417
+ ): ModuleCaptureResult {
4418
+ if (!fn.body) return { captured: [], importedRefs: [] }
4205
4419
  const free = extractFreeIdentifiersFromNode(fn.body)
4206
4420
  const exclude = new Set<string>(info.params)
4207
4421
  for (const b of info.localBindings) exclude.add(b)
@@ -4209,12 +4423,20 @@ function moduleCaptureCheck(
4209
4423
  for (const p of REACTIVE_PRIMITIVES) exclude.add(p)
4210
4424
  exclude.add(selfName)
4211
4425
 
4212
- const offending: string[] = []
4426
+ const captured: string[] = []
4427
+ const importedRefs: ModuleCaptureResult['importedRefs'] = []
4213
4428
  for (const id of free) {
4214
4429
  if (exclude.has(id)) continue
4215
- if (moduleBindings.has(id)) offending.push(id)
4430
+ if (moduleBindings.local.has(id)) {
4431
+ captured.push(id)
4432
+ continue
4433
+ }
4434
+ const imp = moduleBindings.imported.get(id)
4435
+ if (imp) importedRefs.push({ localName: id, source: imp.source, exportedName: imp.exportedName })
4216
4436
  }
4217
- return offending.sort()
4437
+ captured.sort()
4438
+ importedRefs.sort((a, b) => (a.localName < b.localName ? -1 : 1))
4439
+ return { captured, importedRefs }
4218
4440
  }
4219
4441
 
4220
4442
  /**
@@ -4393,6 +4615,12 @@ function rewriteFactoryCallsInSource(
4393
4615
  type Edit = { start: number; end: number; replacement: string }
4394
4616
  const edits: Edit[] = []
4395
4617
  let callSiteIndex = 0
4618
+ // #2332 — factories actually inlined in this walk (not merely present in
4619
+ // `prescan.factories`; `maybeRewriteDecl` bails on arity mismatch/omitted
4620
+ // elements/rename destructures without inlining), so their
4621
+ // `requiredImports` can be re-provisioned without adding a dead import
4622
+ // for a factory that was never actually spliced in.
4623
+ const inlinedFactories = new Set<ReactiveFactoryInfo>()
4396
4624
 
4397
4625
  function visitStmt(node: ts.Node, inComponent: boolean): void {
4398
4626
  if (ts.isVariableStatement(node) && inComponent) {
@@ -4544,13 +4772,51 @@ function rewriteFactoryCallsInSource(
4544
4772
  end: stmt.getEnd(),
4545
4773
  replacement: body,
4546
4774
  })
4775
+ inlinedFactories.add(factory)
4547
4776
  }
4548
4777
 
4549
4778
  visitStmt(sourceFile, false)
4550
4779
 
4551
4780
  if (edits.length === 0) return source
4552
4781
 
4553
- // Apply edits from bottom to top so earlier offsets stay valid.
4782
+ // #2332 one deduped import statement per specifier for every inlined
4783
+ // cross-file factory's re-provisioned imports. Injected as a zero-width
4784
+ // edit so the ordinary bottom-to-top splice below applies it; the result
4785
+ // is indistinguishable from a hand-written import for every downstream
4786
+ // consumer (ctx.imports → SSR templateImports AND client
4787
+ // collectExternalImports both parse this same rewritten string).
4788
+ const importsBySpecifier = new Map<string, Map<string, string>>() // specifier -> localName -> exportedName
4789
+ for (const f of inlinedFactories) {
4790
+ for (const r of f.requiredImports ?? []) {
4791
+ let names = importsBySpecifier.get(r.specifier)
4792
+ if (!names) { names = new Map(); importsBySpecifier.set(r.specifier, names) }
4793
+ names.set(r.localName, r.exportedName) // same-key duplicates are identical by prescan construction
4794
+ }
4795
+ }
4796
+ if (importsBySpecifier.size > 0) {
4797
+ // Sort specifiers and, within each, named-import entries by local name —
4798
+ // `importsBySpecifier`/`inlinedFactories` iterate in incidental AST-
4799
+ // traversal/insertion order, which would otherwise make this generated
4800
+ // text order-unstable across unrelated refactors (Copilot review, PR
4801
+ // #2338).
4802
+ const lines = [...importsBySpecifier]
4803
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
4804
+ .map(([spec, names]) => {
4805
+ const specifiers = [...names]
4806
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
4807
+ .map(([local, exported]) => (exported === local ? local : `${exported} as ${local}`))
4808
+ return `import { ${specifiers.join(', ')} } from '${spec}'`
4809
+ })
4810
+ const at = factoryImportInsertionOffset(sourceFile)
4811
+ edits.push({ start: at, end: at, replacement: at === 0 ? lines.join('\n') + '\n' : '\n' + lines.join('\n') })
4812
+ }
4813
+
4814
+ // Apply edits from bottom to top so earlier offsets stay valid. A factory
4815
+ // with `requiredImports` is by definition imported, so the entry file has
4816
+ // at least one import statement and `at` above lands strictly inside the
4817
+ // import prologue — every call-site edit's start is strictly greater
4818
+ // (separated at minimum by the statement break after the last import), so
4819
+ // starts never tie and no sort tiebreak is needed.
4554
4820
  edits.sort((a, b) => b.start - a.start)
4555
4821
  let out = source
4556
4822
  for (const e of edits) {
@@ -4559,6 +4825,24 @@ function rewriteFactoryCallsInSource(
4559
4825
  return out
4560
4826
  }
4561
4827
 
4828
+ /**
4829
+ * Offset just after the last top-level import (else after the 'use client'
4830
+ * directive, else 0) in the prescan source file — where re-provisioned
4831
+ * factory imports are injected (#2332).
4832
+ */
4833
+ function factoryImportInsertionOffset(sf: ts.SourceFile): number {
4834
+ let lastImportEnd = -1
4835
+ let directiveEnd = -1
4836
+ for (const stmt of sf.statements) {
4837
+ if (ts.isImportDeclaration(stmt)) { lastImportEnd = stmt.getEnd(); continue }
4838
+ if (directiveEnd === -1 && ts.isExpressionStatement(stmt) &&
4839
+ ts.isStringLiteral(stmt.expression) && stmt.expression.text === 'use client') {
4840
+ directiveEnd = stmt.getEnd()
4841
+ }
4842
+ }
4843
+ return lastImportEnd >= 0 ? lastImportEnd : directiveEnd >= 0 ? directiveEnd : 0
4844
+ }
4845
+
4562
4846
  function isPascalCaseComponentFn(node: ts.Node): boolean {
4563
4847
  if (ts.isFunctionDeclaration(node) && node.name) {
4564
4848
  return /^[A-Z]/.test(node.name.text)
@@ -4580,11 +4864,12 @@ function escapeRegex(s: string): string {
4580
4864
  /**
4581
4865
  * Build the diagnostic for a call site whose callee was recognised but
4582
4866
  * declined for inlining (#2325 — cross-file factories that rename their
4583
- * return properties, or capture their own module scope). BF112's wording is
4584
- * specific to module-scope capture; every other declined reason (currently
4585
- * only BF111 non-shorthand return properties) shares BF111's generic
4586
- * "cannot be inlined: <detail>" phrasing, matching the wording used for the
4587
- * tuple call-site path.
4867
+ * return properties or capture their own module scope; #2332 a
4868
+ * re-provisioned helper import collides with an existing binding). BF112
4869
+ * and BF113's wording is specific to their respective failure; every other
4870
+ * declined reason (currently only BF111 non-shorthand return properties)
4871
+ * shares BF111's generic "cannot be inlined: <detail>" phrasing, matching
4872
+ * the wording used for the tuple call-site path.
4588
4873
  */
4589
4874
  function declinedFactoryMessage(callee: string, d: DeclinedReactiveFactory): string {
4590
4875
  if (d.code === 'BF112') {
@@ -4594,9 +4879,26 @@ function declinedFactoryMessage(callee: string, d: DeclinedReactiveFactory): str
4594
4879
  `pass them as factory arguments, or inline the factory here.`
4595
4880
  )
4596
4881
  }
4882
+ if (d.code === 'BF113') {
4883
+ return (
4884
+ `Reactive factory '${callee}' cannot be inlined: it needs ${d.detail} ` +
4885
+ `imported into this file, but that name is already bound here to something ` +
4886
+ `else. Rename the conflicting binding in this file, or alias the import in ` +
4887
+ `the factory's own file (import { x as y }).`
4888
+ )
4889
+ }
4597
4890
  return `Reactive factory '${callee}' cannot be inlined: ${d.detail}.`
4598
4891
  }
4599
4892
 
4893
+ /** Diagnostic code for a declined reactive-factory call site (#2325 / #2332). */
4894
+ function declinedFactoryErrorCode(code: DeclinedReactiveFactory['code']): ErrorCode {
4895
+ switch (code) {
4896
+ case 'BF112': return ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
4897
+ case 'BF113': return ErrorCodes.REACTIVE_FACTORY_IMPORT_COLLISION
4898
+ default: return ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED
4899
+ }
4900
+ }
4901
+
4600
4902
  /**
4601
4903
  * Scan a compiled component context for destructures (tuple or object)
4602
4904
  * whose callee is neither `createSignal` / `createMemo` nor an inlinable
@@ -4636,9 +4938,7 @@ export function validateReactiveFactoryCalls(ctx: AnalyzerContext): void {
4636
4938
  const declinedEntry = ctx.declinedReactiveFactories.get(callee)
4637
4939
  if (declinedEntry) {
4638
4940
  ctx.errors.push(createError(
4639
- declinedEntry.code === 'BF112'
4640
- ? ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
4641
- : ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED,
4941
+ declinedFactoryErrorCode(declinedEntry.code),
4642
4942
  loc,
4643
4943
  { severity: 'error', message: declinedFactoryMessage(callee, declinedEntry) }
4644
4944
  ))
@@ -4696,8 +4996,8 @@ export function validateReactiveFactoryCalls(ctx: AnalyzerContext): void {
4696
4996
  * covers the previously-silent object-destructure failure modes: an
4697
4997
  * unrecognised callee, a tuple factory destructured as an object, a rename/
4698
4998
  * default/rest destructure of a shorthand-only factory, an unknown
4699
- * property, a declined (BF111/BF112) factory, or an uninspectable import
4700
- * that looks reactive-factory-shaped by name.
4999
+ * property, a declined (BF111/BF112/BF113) factory, or an uninspectable
5000
+ * import that looks reactive-factory-shaped by name.
4701
5001
  */
4702
5002
  function validateObjectFactoryDestructure(
4703
5003
  ctx: AnalyzerContext,
@@ -4759,9 +5059,7 @@ function validateObjectFactoryDestructure(
4759
5059
  const declinedEntry = ctx.declinedReactiveFactories.get(callee)
4760
5060
  if (declinedEntry) {
4761
5061
  ctx.errors.push(createError(
4762
- declinedEntry.code === 'BF112'
4763
- ? ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
4764
- : ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED,
5062
+ declinedFactoryErrorCode(declinedEntry.code),
4765
5063
  loc,
4766
5064
  { severity: 'error', message: declinedFactoryMessage(callee, declinedEntry) }
4767
5065
  ))
package/src/errors.ts CHANGED
@@ -89,6 +89,7 @@ export const ErrorCodes = {
89
89
  UNRECOGNIZED_REACTIVE_FACTORY: 'BF110',
90
90
  REACTIVE_FACTORY_RENAME_UNSUPPORTED: 'BF111',
91
91
  REACTIVE_FACTORY_MODULE_CAPTURE: 'BF112',
92
+ REACTIVE_FACTORY_IMPORT_COLLISION: 'BF113',
92
93
  } as const
93
94
 
94
95
  export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]
@@ -178,6 +179,11 @@ const errorMessages: Record<ErrorCode, string> = {
178
179
  'body cannot be inlined into the component file. Move those helpers into the ' +
179
180
  'component file, pass them to the factory as parameters, or define the factory ' +
180
181
  'in the component file.',
182
+
183
+ [ErrorCodes.REACTIVE_FACTORY_IMPORT_COLLISION]:
184
+ 'Inlining an imported reactive factory requires re-importing one of its helper ' +
185
+ 'imports into this file, but that name is already bound here to something else. ' +
186
+ "Rename the conflicting binding in this file, or alias the import in the factory's own file.",
181
187
  }
182
188
 
183
189
  // =============================================================================
@@ -21,11 +21,15 @@ import type { LoweringNode, LoweringPlugin } from './lowering-registry.ts'
21
21
  import { formatDateLocalNames } from './adapters/env-signal.ts'
22
22
 
23
23
  const UTC_LITERAL: ParsedExpr = { kind: 'literal', value: 'UTC', literalType: 'string' }
24
+ const EMPTY_NAMES: ParsedExpr = { kind: 'array-literal', elements: [], raw: '[]' } as ParsedExpr
24
25
 
25
26
  /**
26
- * Recognise `formatDate(date, pattern[, timeZone])` against the component's
27
- * local import bindings, or decline (null): a non-identifier callee, a name
28
- * not bound to the `@barefootjs/client` import, or an arity outside 2–3.
27
+ * Recognise `formatDate(date, pattern[, timeZone[, names]])` against the
28
+ * component's local import bindings, or decline (null): a non-identifier
29
+ * callee, a name not bound to the `@barefootjs/client` import, or an arity
30
+ * outside 2–4. The canonical helper arity is 4 (#2334): omitted `timeZone` /
31
+ * `names` normalize to the `'UTC'` literal and the empty table the client
32
+ * function defaults to, so backend helpers stay fixed-arity.
29
33
  */
30
34
  export function matchFormatDateCall(
31
35
  callee: ParsedExpr,
@@ -33,11 +37,11 @@ export function matchFormatDateCall(
33
37
  locals: ReadonlySet<string>,
34
38
  ): LoweringNode | null {
35
39
  if (callee.kind !== 'identifier' || !locals.has(callee.name)) return null
36
- if (args.length < 2 || args.length > 3) return null
40
+ if (args.length < 2 || args.length > 4) return null
37
41
  return {
38
42
  kind: 'helper-call',
39
43
  helper: 'format_date',
40
- args: [args[0], args[1], args[2] ?? UTC_LITERAL],
44
+ args: [args[0], args[1], args[2] ?? UTC_LITERAL, args[3] ?? EMPTY_NAMES],
41
45
  }
42
46
  }
43
47
 
@@ -11,7 +11,7 @@ import type { ClientJsContext } from './types.ts'
11
11
  import { toHtmlAttrName, varSlotId, PROPS_PARAM } from './utils.ts'
12
12
  import { createTemplateAwareStringProtector } from './html-template.ts'
13
13
  import { datePlugin, DATE_METHODS } from '../date-lowering.ts'
14
- import { toLocaleDatePlugin } from '../to-locale-date-lowering.ts'
14
+ import { toLocaleDatePlugin, foldedArgToClientJs } from '../to-locale-date-lowering.ts'
15
15
  import { tsNodeToParsedExpr } from '../expression-parser.ts'
16
16
  import type { LoweringMatcher } from '../lowering-registry.ts'
17
17
 
@@ -181,8 +181,17 @@ function lowerToLocaleCallsInReactiveExpr(expr: string, matcher: LoweringMatcher
181
181
  call.arguments.map((a) => tsNodeToParsedExpr(a)),
182
182
  )
183
183
  if (!node || node.kind !== 'helper-call' || node.helper !== 'format_date') continue
184
- const [, patternArg, tzArg] = node.args
185
- if (patternArg?.kind !== 'literal' || tzArg?.kind !== 'literal') continue
184
+ const [, patternArg, tzArg, namesArg] = node.args
185
+ if (!patternArg || tzArg?.kind !== 'literal') continue
186
+ const localeText = call.arguments[0].getText(sourceFile)
187
+ const patternJs = foldedArgToClientJs(patternArg, localeText)
188
+ if (patternJs === null) continue
189
+ // The names table (#2334) — omitted when empty, same as the static path.
190
+ let namesJs: string | null = null
191
+ if (namesArg && !(namesArg.kind === 'array-literal' && namesArg.elements.length === 0)) {
192
+ namesJs = foldedArgToClientJs(namesArg, localeText)
193
+ if (namesJs === null) continue
194
+ }
186
195
  const receiverText = propAccess.expression.getText(sourceFile)
187
196
  const matchText = call.getText(sourceFile)
188
197
  // The call text contains string literals (placeholders in the protected
@@ -191,7 +200,8 @@ function lowerToLocaleCallsInReactiveExpr(expr: string, matcher: LoweringMatcher
191
200
  result = replaceProtectedCall(
192
201
  result,
193
202
  matchText,
194
- () => `formatDate(${receiverText}, ${JSON.stringify(patternArg.value)}, ${JSON.stringify(tzArg.value)})`,
203
+ () =>
204
+ `formatDate(${receiverText}, ${patternJs}, ${JSON.stringify(tzArg.value)}${namesJs !== null ? `, ${namesJs}` : ''})`,
195
205
  )
196
206
  }
197
207
  return restore(result)