@barefootjs/jsx 0.25.0 → 0.26.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 (41) hide show
  1. package/dist/adapters/dangerous-inner-html.d.ts +52 -24
  2. package/dist/adapters/dangerous-inner-html.d.ts.map +1 -1
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/index.js +248 -110
  5. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +15 -1
  7. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +9 -6
  9. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +11 -5
  11. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +27 -1
  13. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/reactivity.d.ts +24 -2
  16. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/types.d.ts +13 -0
  18. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  19. package/dist/to-locale-date-lowering.d.ts +31 -10
  20. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  21. package/dist/types.d.ts +8 -4
  22. package/dist/types.d.ts.map +1 -1
  23. package/package.json +2 -2
  24. package/src/__tests__/dangerous-inner-html-resolver.test.ts +27 -7
  25. package/src/__tests__/nested-loop-conditional.test.ts +133 -0
  26. package/src/__tests__/profile-nested-binding-ids.test.ts +5 -2
  27. package/src/__tests__/reactive-factory-cross-file.test.ts +252 -1
  28. package/src/__tests__/to-locale-date-lowering.test.ts +27 -2
  29. package/src/adapters/dangerous-inner-html.ts +101 -48
  30. package/src/analyzer.ts +222 -58
  31. package/src/ir-to-client-js/collect-elements.ts +28 -2
  32. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +56 -2
  33. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +30 -54
  34. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +11 -5
  35. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +78 -4
  36. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +3 -25
  37. package/src/ir-to-client-js/reactivity.ts +61 -7
  38. package/src/ir-to-client-js/types.ts +13 -0
  39. package/src/rich-type-refusal.ts +3 -2
  40. package/src/to-locale-date-lowering.ts +50 -13
  41. package/src/types.ts +8 -4
package/src/analyzer.ts CHANGED
@@ -2542,6 +2542,59 @@ export function extractFreeIdentifiersFromNode(node: ts.Node): Set<string> {
2542
2542
  return ids
2543
2543
  }
2544
2544
 
2545
+ /**
2546
+ * Identifiers referenced in TYPE position anywhere under `node` (#2350) —
2547
+ * the mirror image of `extractFreeIdentifiersFromNode`, which explicitly
2548
+ * skips type nodes since they carry no runtime reference. Walks the whole
2549
+ * tree without stopping at type nodes; every `TypeReferenceNode`'s root
2550
+ * name is collected, which by plain `forEachChild` recursion also reaches
2551
+ * nested references (generic type arguments, array/union/tuple members,
2552
+ * function-type params/returns) without needing to special-case each shape.
2553
+ * Also collects `TypeQueryNode` (`typeof Foo`) root names — `Foo` there is
2554
+ * a VALUE reference in type position, so `moduleCaptureCheck`'s fallback to
2555
+ * `moduleBindings.imported`/`local` (added for the plain-value-import case,
2556
+ * Copilot review, PR #2351) resolves it the same way (Copilot review, PR
2557
+ * #2351).
2558
+ * A function-like node's own type parameters (`<T>`) are tracked and
2559
+ * excluded within its body/signature, same idea as the value walker's
2560
+ * arrow-parameter tracking — otherwise a generic named the same as a
2561
+ * module-scope type (`function useThing<SavedList>(x: SavedList)`) would
2562
+ * be misclassified as a reference to the import (Copilot review, PR
2563
+ * #2351). Doesn't exclude body-local type/interface declarations that
2564
+ * shadow a module-scope name — rare enough here that, per this file's
2565
+ * accepted-limitation policy, a redundant-but-harmless re-provisioned
2566
+ * import is an acceptable outcome (never a silent dangling reference).
2567
+ */
2568
+ function extractFreeTypeIdentifiersFromNode(node: ts.Node): Set<string> {
2569
+ const ids = new Set<string>()
2570
+ const boundTypeParams = new Set<string>()
2571
+ function rootName(name: ts.EntityName): ts.Identifier {
2572
+ return ts.isQualifiedName(name) ? rootName(name.left) : name
2573
+ }
2574
+ function visit(n: ts.Node): void {
2575
+ if (ts.isTypeReferenceNode(n)) {
2576
+ const name = rootName(n.typeName).text
2577
+ if (!boundTypeParams.has(name)) ids.add(name)
2578
+ // Keep descending — nested references (`Promise<SavedList>`,
2579
+ // `Record<string, SavedList>`) live in this same node's type arguments.
2580
+ }
2581
+ if (ts.isTypeQueryNode(n)) {
2582
+ const name = rootName(n.exprName).text
2583
+ if (!boundTypeParams.has(name)) ids.add(name)
2584
+ }
2585
+ if (ts.isFunctionLike(n) && n.typeParameters && n.typeParameters.length > 0) {
2586
+ const names = n.typeParameters.map((p) => p.name.text)
2587
+ for (const p of names) boundTypeParams.add(p)
2588
+ ts.forEachChild(n, visit)
2589
+ for (const p of names) boundTypeParams.delete(p)
2590
+ return
2591
+ }
2592
+ ts.forEachChild(n, visit)
2593
+ }
2594
+ visit(node)
2595
+ return ids
2596
+ }
2597
+
2545
2598
  /**
2546
2599
  * Check if a const initializer expression contains JSX at a non-root
2547
2600
  * position — ternary with JSX on either side, logical-AND / OR /
@@ -3965,32 +4018,40 @@ function toComponentRelativeSpecifier(resolvedAbs: string, componentFilePath: st
3965
4018
  }
3966
4019
 
3967
4020
  /**
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.
4021
+ * localName -> identity of what the entry file's own top-level named
4022
+ * imports bind, for the satisfied-import dedupe check (#2332, type-only
4023
+ * imports included #2350): if the component file already imports the exact
4024
+ * binding a factory needs to re-provision, injecting it again would be a
4025
+ * duplicate declaration rather than a shadow, so that case is skipped
4026
+ * instead of injected. `targetKey` is the resolved absolute path for
4027
+ * relative sources (or `unresolved:<source>` when probing fails) and the
4028
+ * raw specifier for bare sources. `isTypeOnly` matters at the call site: an
4029
+ * existing type-only import satisfies a factory's type-only need but NOT a
4030
+ * value need (it has no runtime binding) — treating it as satisfying both
4031
+ * would silently skip re-provisioning a value the inlined body actually
4032
+ * calls, the same dangling-reference failure #2341 BUG-2 already covers.
3975
4033
  */
3976
4034
  function buildEntryImportIndex(
3977
4035
  sf: ts.SourceFile,
3978
4036
  filePath: string
3979
- ): Map<string, { targetKey: string; exportedName: string }> {
3980
- const index = new Map<string, { targetKey: string; exportedName: string }>()
4037
+ ): Map<string, { targetKey: string; exportedName: string; isTypeOnly: boolean }> {
4038
+ const index = new Map<string, { targetKey: string; exportedName: string; isTypeOnly: boolean }>()
3981
4039
  for (const stmt of sf.statements) {
3982
4040
  if (!ts.isImportDeclaration(stmt)) continue
3983
4041
  if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
3984
- if (stmt.importClause?.isTypeOnly) continue
3985
4042
  const src = stmt.moduleSpecifier.text
3986
4043
  const targetKey = src.startsWith('./') || src.startsWith('../')
3987
4044
  ? (resolveRelativeImportToFile(src, filePath) ?? 'unresolved:' + src)
3988
4045
  : src
4046
+ const wholeTypeOnly = stmt.importClause?.isTypeOnly === true
3989
4047
  const namedBindings = stmt.importClause?.namedBindings
3990
4048
  if (namedBindings && ts.isNamedImports(namedBindings)) {
3991
4049
  for (const el of namedBindings.elements) {
3992
- if (el.isTypeOnly) continue
3993
- index.set(el.name.text, { targetKey, exportedName: (el.propertyName ?? el.name).text })
4050
+ index.set(el.name.text, {
4051
+ targetKey,
4052
+ exportedName: (el.propertyName ?? el.name).text,
4053
+ isTypeOnly: wholeTypeOnly || el.isTypeOnly,
4054
+ })
3994
4055
  }
3995
4056
  }
3996
4057
  }
@@ -4034,6 +4095,9 @@ function collectEntryBindingNames(sf: ts.SourceFile): Set<string> {
4034
4095
  ) {
4035
4096
  names.add(node.name.text)
4036
4097
  }
4098
+ if ((ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) && node.name) {
4099
+ names.add(node.name.text)
4100
+ }
4037
4101
  if (ts.isFunctionLike(node)) {
4038
4102
  for (const p of node.parameters) {
4039
4103
  const out: string[] = []
@@ -4165,6 +4229,10 @@ function prescanImportedReactiveFactories(
4165
4229
  const entryImportIndex = buildEntryImportIndex(entrySourceFile, filePath)
4166
4230
  // localName -> planned injection identity; a later factory requiring the
4167
4231
  // same name from a DIFFERENT (targetKey, exportedName) is a collision.
4232
+ // Deliberately isTypeOnly-agnostic: a value need and a type need for the
4233
+ // SAME (targetKey, exportedName) aren't a real collision (the eventual
4234
+ // value import satisfies both) — the final line-generation step below
4235
+ // drops the redundant type-only line rather than declining here.
4168
4236
  const plannedInjections = new Map<string, { targetKey: string; exportedName: string }>()
4169
4237
 
4170
4238
  // #2341 BUG-2 — one read+parse per helper file per entry file, memoized
@@ -4346,7 +4414,9 @@ function prescanImportedReactiveFactories(
4346
4414
  break
4347
4415
  }
4348
4416
  // #2332 — re-provision the helper file's own named value imports
4349
- // that the factory body references, instead of declining. Each
4417
+ // that the factory body references, instead of declining (type-
4418
+ // only refs included #2350 — same treatment, tagged `isTypeOnly`
4419
+ // so the line-generator below emits `import type { ... }`). Each
4350
4420
  // ref resolves to a component-relative specifier (or passes
4351
4421
  // through unchanged for bare/npm specifiers); a ref already
4352
4422
  // satisfied by an identical top-level import in the component
@@ -4358,7 +4428,11 @@ function prescanImportedReactiveFactories(
4358
4428
  const required: RequiredFactoryImport[] = []
4359
4429
  const pending: Array<[string, { targetKey: string; exportedName: string }]> = []
4360
4430
  let declinedEntry: DeclinedReactiveFactory | null = null
4361
- for (const ref of capture.importedRefs) {
4431
+ const allRefs = [
4432
+ ...capture.importedRefs.map((r) => ({ ...r, isTypeOnly: false })),
4433
+ ...capture.importedTypeRefs.map((r) => ({ ...r, isTypeOnly: true })),
4434
+ ]
4435
+ for (const ref of allRefs) {
4362
4436
  let specifier: string
4363
4437
  let targetKey: string
4364
4438
  if (ref.source.startsWith('./') || ref.source.startsWith('../')) {
@@ -4384,8 +4458,13 @@ function prescanImportedReactiveFactories(
4384
4458
  }
4385
4459
  // Already satisfied by an identical top-level import in the
4386
4460
  // component file — injecting again would redeclare the binding.
4461
+ // A type-only need is satisfiable by ANY matching import (value
4462
+ // or type — a value import brings its type into scope too); a
4463
+ // value need can only be satisfied by an existing value import,
4464
+ // since a type-only import has no runtime binding (#2350).
4387
4465
  const existing = entryImportIndex.get(ref.localName)
4388
- if (existing && existing.targetKey === targetKey && existing.exportedName === ref.exportedName) {
4466
+ if (existing && existing.targetKey === targetKey && existing.exportedName === ref.exportedName &&
4467
+ (ref.isTypeOnly || !existing.isTypeOnly)) {
4389
4468
  continue
4390
4469
  }
4391
4470
  const planned = plannedInjections.get(ref.localName)
@@ -4402,7 +4481,7 @@ function prescanImportedReactiveFactories(
4402
4481
  break
4403
4482
  }
4404
4483
  pending.push([ref.localName, { targetKey, exportedName: ref.exportedName }])
4405
- required.push({ localName: ref.localName, exportedName: ref.exportedName, specifier })
4484
+ required.push({ localName: ref.localName, exportedName: ref.exportedName, specifier, isTypeOnly: ref.isTypeOnly || undefined })
4406
4485
  }
4407
4486
  if (declinedEntry) {
4408
4487
  result.declined.set(spec.local, declinedEntry)
@@ -4445,8 +4524,17 @@ function prescanImportedReactiveFactories(
4445
4524
  * came from (`resolveFinalImports` / `detectUsedImports` regex-scan the
4446
4525
  * *generated* code, not the consumer's source imports — see #2325 spec C1),
4447
4526
  * so an inlined body calling `createSignal` is never a capture even though
4448
- * the helper file itself imports it. Type-only imports/declarations carry
4449
- * no runtime binding and are excluded.
4527
+ * the helper file itself imports it.
4528
+ *
4529
+ * `localTypes`/`importedTypes` are the type-position mirror of `local`/
4530
+ * `imported` (#2350): a factory body's return-type annotations, generic
4531
+ * type arguments, and local variable type annotations reference names too,
4532
+ * and those need the exact same capture-or-re-provision treatment — a type
4533
+ * declared directly in the helper file can't be re-imported (BF112,
4534
+ * folded into `local`'s capture handling since the failure mode is
4535
+ * identical), but a type the helper file itself imports (`import type {
4536
+ * X }` or the per-specifier `import { type X }`) can be re-provisioned as
4537
+ * `import type { X } from '<specifier>'` in the component file.
4450
4538
  */
4451
4539
  interface HelperModuleBindings {
4452
4540
  /** Declared directly in the helper file — unconditional BF112 capture. */
@@ -4454,10 +4542,18 @@ interface HelperModuleBindings {
4454
4542
  /** Named value-import specifiers, keyed by helper-file local name —
4455
4543
  * re-provisionable into the component file (#2332). */
4456
4544
  imported: Map<string, { source: string; exportedName: string }>
4545
+ /** Type/interface declared directly in the helper file — unconditional
4546
+ * BF112 capture, same as `local` (#2350). */
4547
+ localTypes: Set<string>
4548
+ /** The helper file's own type-only named imports, keyed by local name —
4549
+ * re-provisionable as `import type { X } from '<specifier>'` (#2350). */
4550
+ importedTypes: Map<string, { source: string; exportedName: string }>
4457
4551
  }
4458
4552
 
4459
4553
  function collectHelperModuleValueBindings(sf: ts.SourceFile): HelperModuleBindings {
4460
4554
  const local = new Set<string>()
4555
+ const localTypes = new Set<string>()
4556
+ const importedTypes = new Map<string, { source: string; exportedName: string }>()
4461
4557
  const imported = new Map<string, { source: string; exportedName: string }>()
4462
4558
  for (const stmt of sf.statements) {
4463
4559
  if (ts.isVariableStatement(stmt)) {
@@ -4475,35 +4571,43 @@ function collectHelperModuleValueBindings(sf: ts.SourceFile): HelperModuleBindin
4475
4571
  local.add(stmt.name.text)
4476
4572
  continue
4477
4573
  }
4574
+ if ((ts.isTypeAliasDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)) && stmt.name) {
4575
+ localTypes.add(stmt.name.text)
4576
+ continue
4577
+ }
4478
4578
  if (ts.isImportDeclaration(stmt)) {
4479
- if (stmt.importClause?.isTypeOnly) continue
4480
4579
  if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
4481
4580
  const src = stmt.moduleSpecifier.text
4482
4581
  if (src === '@barefootjs/client' || src === '@barefootjs/client/runtime') continue
4582
+ const wholeTypeOnly = stmt.importClause?.isTypeOnly === true
4483
4583
  // Default/namespace imports stay hard BF112 (#2332 scope decision):
4484
- // no single named export to re-provision under one local name.
4485
- if (stmt.importClause?.name) local.add(stmt.importClause.name.text)
4584
+ // no single named export to re-provision under one local name. A
4585
+ // type-only default/namespace import has the same problem, so it
4586
+ // goes to `localTypes` rather than `importedTypes`.
4587
+ if (stmt.importClause?.name) (wholeTypeOnly ? localTypes : local).add(stmt.importClause.name.text)
4486
4588
  const namedBindings = stmt.importClause?.namedBindings
4487
4589
  if (namedBindings && ts.isNamedImports(namedBindings)) {
4488
4590
  for (const el of namedBindings.elements) {
4489
- if (el.isTypeOnly) continue
4490
- imported.set(el.name.text, { source: src, exportedName: (el.propertyName ?? el.name).text })
4591
+ const entry = { source: src, exportedName: (el.propertyName ?? el.name).text }
4592
+ if (wholeTypeOnly || el.isTypeOnly) importedTypes.set(el.name.text, entry)
4593
+ else imported.set(el.name.text, entry)
4491
4594
  }
4492
4595
  }
4493
4596
  if (namedBindings && ts.isNamespaceImport(namedBindings)) {
4494
- local.add(namedBindings.name.text)
4597
+ (wholeTypeOnly ? localTypes : local).add(namedBindings.name.text)
4495
4598
  }
4496
4599
  }
4497
4600
  }
4498
- return { local, imported }
4601
+ return { local, imported, localTypes, importedTypes }
4499
4602
  }
4500
4603
 
4501
4604
  /**
4502
4605
  * Categorized free-identifier references of a reactive-factory body into
4503
- * its own module scope (#2332): `captured` are unconditional BF112 hits
4504
- * (helper-local bindings — the body would dangle if inlined verbatim);
4505
- * `importedRefs` are references to the helper file's own named value
4506
- * imports, which the caller may re-provision into the component file
4606
+ * its own module scope (#2332, type positions added #2350): `captured` are
4607
+ * unconditional BF112 hits (helper-local bindings — the body would dangle
4608
+ * if inlined verbatim, value or type alike); `importedRefs`/
4609
+ * `importedTypeRefs` are references to the helper file's own named value/
4610
+ * type imports, which the caller may re-provision into the component file
4507
4611
  * instead of declining (§3.4 in the #2332 spec).
4508
4612
  */
4509
4613
  interface ModuleCaptureResult {
@@ -4511,23 +4615,27 @@ interface ModuleCaptureResult {
4511
4615
  captured: string[]
4512
4616
  /** Free refs resolving to the helper's own named value imports, sorted by localName. */
4513
4617
  importedRefs: Array<{ localName: string; source: string; exportedName: string }>
4618
+ /** Free type-position refs resolving to the helper's own named type imports, sorted by localName. */
4619
+ importedTypeRefs: Array<{ localName: string; source: string; exportedName: string }>
4514
4620
  }
4515
4621
 
4516
4622
  /**
4517
4623
  * Free identifiers of a reactive-factory body that resolve to bindings at
4518
- * its own module scope (#2325 §4h / BF112, #2332) references the inlined
4519
- * body would silently lose once spliced into the component file, unless
4520
- * re-provisioned as an import. Returns `captured` (unconditional BF112) and
4521
- * `importedRefs` (re-provisionable) separately, each sorted for stable
4522
- * diagnostic/injection text.
4624
+ * its own module scope (#2325 §4h / BF112, #2332, type positions #2350)
4625
+ * references the inlined body would silently lose once spliced into the
4626
+ * component file, unless re-provisioned as an import. Returns `captured`
4627
+ * (unconditional BF112) and `importedRefs`/`importedTypeRefs`
4628
+ * (re-provisionable) separately, each sorted for stable diagnostic/
4629
+ * injection text.
4523
4630
  *
4524
4631
  * Known accepted limitation: `extractFreeIdentifiersFromNode` only scope-
4525
4632
  * tracks arrow-function parameters, not nested `function` declarations'
4526
4633
  * parameters or nested-block declarations — a body-nested binding that
4527
4634
  * happens to shadow a helper-module binding could false-positive into
4528
- * BF112 or `importedRefs`. Acceptable: the failure direction is always a
4529
- * loud build error (BF112/BF113) or a redundant-but-harmless injected
4530
- * import, never a silent dangling reference.
4635
+ * BF112 or `importedRefs`/`importedTypeRefs`. Acceptable: the failure
4636
+ * direction is always a loud build error (BF112/BF113) or a
4637
+ * redundant-but-harmless injected import, never a silent dangling
4638
+ * reference.
4531
4639
  */
4532
4640
  function moduleCaptureCheck(
4533
4641
  fn: ts.FunctionDeclaration,
@@ -4535,16 +4643,24 @@ function moduleCaptureCheck(
4535
4643
  moduleBindings: HelperModuleBindings,
4536
4644
  selfName: string
4537
4645
  ): ModuleCaptureResult {
4538
- if (!fn.body) return { captured: [], importedRefs: [] }
4646
+ if (!fn.body) return { captured: [], importedRefs: [], importedTypeRefs: [] }
4539
4647
  const free = extractFreeIdentifiersFromNode(fn.body)
4648
+ const freeTypes = extractFreeTypeIdentifiersFromNode(fn.body)
4540
4649
  const exclude = new Set<string>(info.params)
4541
4650
  for (const b of info.localBindings) exclude.add(b)
4542
4651
  for (const r of info.returnTupleIdentifiers) exclude.add(r)
4543
4652
  for (const p of REACTIVE_PRIMITIVES) exclude.add(p)
4544
4653
  exclude.add(selfName)
4654
+ // extractFreeTypeIdentifiersFromNode only tracks type parameters declared
4655
+ // BY nodes it walks — it never sees `fn` itself (only `fn.body`), so the
4656
+ // factory's own `<T>` list needs excluding here instead (Copilot review,
4657
+ // PR #2351: `function useThing<Item>(...)` shadowing a module-scope
4658
+ // `Item` type import).
4659
+ if (fn.typeParameters) for (const p of fn.typeParameters) exclude.add(p.name.text)
4545
4660
 
4546
4661
  const captured: string[] = []
4547
4662
  const importedRefs: ModuleCaptureResult['importedRefs'] = []
4663
+ const importedTypeRefs: ModuleCaptureResult['importedTypeRefs'] = []
4548
4664
  for (const id of free) {
4549
4665
  if (exclude.has(id)) continue
4550
4666
  if (moduleBindings.local.has(id)) {
@@ -4554,9 +4670,29 @@ function moduleCaptureCheck(
4554
4670
  const imp = moduleBindings.imported.get(id)
4555
4671
  if (imp) importedRefs.push({ localName: id, source: imp.source, exportedName: imp.exportedName })
4556
4672
  }
4673
+ for (const id of freeTypes) {
4674
+ if (exclude.has(id)) continue
4675
+ if (free.has(id)) continue // already resolved above — same name, value position wins
4676
+ if (moduleBindings.localTypes.has(id) || moduleBindings.local.has(id)) {
4677
+ captured.push(id)
4678
+ continue
4679
+ }
4680
+ const typeImp = moduleBindings.importedTypes.get(id)
4681
+ if (typeImp) {
4682
+ importedTypeRefs.push({ localName: id, source: typeImp.source, exportedName: typeImp.exportedName })
4683
+ continue
4684
+ }
4685
+ // A name used ONLY in type position can still resolve to the helper's
4686
+ // own VALUE import (e.g. a class referenced purely as `(): Foo`) —
4687
+ // re-provision it as a normal value import, which brings the type into
4688
+ // scope too, instead of missing it entirely (Copilot review, PR #2351).
4689
+ const valueImp = moduleBindings.imported.get(id)
4690
+ if (valueImp) importedRefs.push({ localName: id, source: valueImp.source, exportedName: valueImp.exportedName })
4691
+ }
4557
4692
  captured.sort()
4558
4693
  importedRefs.sort((a, b) => (a.localName < b.localName ? -1 : 1))
4559
- return { captured, importedRefs }
4694
+ importedTypeRefs.sort((a, b) => (a.localName < b.localName ? -1 : 1))
4695
+ return { captured, importedRefs, importedTypeRefs }
4560
4696
  }
4561
4697
 
4562
4698
  /**
@@ -5084,33 +5220,61 @@ function rewriteFactoryCallsInSource(
5084
5220
  if (edits.length === 0) return source
5085
5221
 
5086
5222
  // #2332 — one deduped import statement per specifier for every inlined
5087
- // cross-file factory's re-provisioned imports. Injected as a zero-width
5088
- // edit so the ordinary bottom-to-top splice below applies it; the result
5089
- // is indistinguishable from a hand-written import for every downstream
5223
+ // cross-file factory's re-provisioned imports (type-only refs #2350, kept
5224
+ // on separate `import type { ... }` lines since a value and a type
5225
+ // import can't share one specifier list). Injected as a zero-width edit
5226
+ // so the ordinary bottom-to-top splice below applies it; the result is
5227
+ // indistinguishable from a hand-written import for every downstream
5090
5228
  // consumer (ctx.imports → SSR templateImports AND client
5091
5229
  // collectExternalImports both parse this same rewritten string).
5092
5230
  const importsBySpecifier = new Map<string, Map<string, string>>() // specifier -> localName -> exportedName
5231
+ const typeImportsBySpecifier = new Map<string, Map<string, string>>() // specifier -> localName -> exportedName
5093
5232
  for (const f of inlinedFactories) {
5094
5233
  for (const r of f.requiredImports ?? []) {
5095
- let names = importsBySpecifier.get(r.specifier)
5096
- if (!names) { names = new Map(); importsBySpecifier.set(r.specifier, names) }
5234
+ const bySpecifier = r.isTypeOnly ? typeImportsBySpecifier : importsBySpecifier
5235
+ let names = bySpecifier.get(r.specifier)
5236
+ if (!names) { names = new Map(); bySpecifier.set(r.specifier, names) }
5097
5237
  names.set(r.localName, r.exportedName) // same-key duplicates are identical by prescan construction
5098
5238
  }
5099
5239
  }
5100
- if (importsBySpecifier.size > 0) {
5101
- // Sort specifiers and, within each, named-import entries by local name —
5102
- // `importsBySpecifier`/`inlinedFactories` iterate in incidental AST-
5103
- // traversal/insertion order, which would otherwise make this generated
5104
- // text order-unstable across unrelated refactors (Copilot review, PR
5105
- // #2338).
5106
- const lines = [...importsBySpecifier]
5107
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
5108
- .map(([spec, names]) => {
5109
- const specifiers = [...names]
5110
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
5111
- .map(([local, exported]) => (exported === local ? local : `${exported} as ${local}`))
5112
- return `import { ${specifiers.join(', ')} } from '${spec}'`
5113
- })
5240
+ // Two different factories can independently need the same (specifier,
5241
+ // localName) one only in value position, one only in type position
5242
+ // (#2350). The value import already brings the type into scope, so drop
5243
+ // the now-redundant type-only line rather than emit both (which TypeScript
5244
+ // would reject as a duplicate identifier).
5245
+ for (const [spec, typeNames] of typeImportsBySpecifier) {
5246
+ const valueNames = importsBySpecifier.get(spec)
5247
+ if (!valueNames) continue
5248
+ for (const local of [...typeNames.keys()]) {
5249
+ if (valueNames.has(local)) typeNames.delete(local)
5250
+ }
5251
+ if (typeNames.size === 0) typeImportsBySpecifier.delete(spec)
5252
+ }
5253
+ if (importsBySpecifier.size > 0 || typeImportsBySpecifier.size > 0) {
5254
+ // One sorted pass over the UNION of specifiers (value + type-only), not
5255
+ // two separately-sorted lists — the latter would leave the value/
5256
+ // type-only halves each internally sorted but not globally sorted
5257
+ // against each other (a type-only import from 'a' could land after a
5258
+ // value import from 'b'), an unstable-looking order across unrelated
5259
+ // refactors (Copilot review, PR #2351). Within each specifier, named-
5260
+ // import entries are sorted by local name too — `importsBySpecifier`/
5261
+ // `inlinedFactories` iterate in incidental AST-traversal/insertion
5262
+ // order (Copilot review, PR #2338).
5263
+ const buildLine = (names: Map<string, string>, keyword: string, spec: string) => {
5264
+ const specifiers = [...names]
5265
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
5266
+ .map(([local, exported]) => (exported === local ? local : `${exported} as ${local}`))
5267
+ return `import ${keyword}{ ${specifiers.join(', ')} } from '${spec}'`
5268
+ }
5269
+ const allSpecifiers = new Set([...importsBySpecifier.keys(), ...typeImportsBySpecifier.keys()])
5270
+ const lines = [...allSpecifiers].sort().flatMap((spec) => {
5271
+ const out: string[] = []
5272
+ const valueNames = importsBySpecifier.get(spec)
5273
+ if (valueNames) out.push(buildLine(valueNames, '', spec))
5274
+ const typeNames = typeImportsBySpecifier.get(spec)
5275
+ if (typeNames) out.push(buildLine(typeNames, 'type ', spec))
5276
+ return out
5277
+ })
5114
5278
  const at = factoryImportInsertionOffset(sourceFile)
5115
5279
  edits.push({ start: at, end: at, replacement: at === 0 ? lines.join('\n') + '\n' : '\n' + lines.join('\n') })
5116
5280
  }
@@ -1246,8 +1246,13 @@ export function collectLoopChildBindings(
1246
1246
  const bindings = emptyLoopChildBindings()
1247
1247
  for (const child of children) {
1248
1248
  bindings.events.push(...collectLoopChildEventsWithNesting(child))
1249
- bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, loopParam, loopParamBindings))
1250
- bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, loopParam, loopParamBindings))
1249
+ // stopAtReactiveConditionals=true (#2347): this function always also
1250
+ // collects nested reactive conditionals below via
1251
+ // `collectLoopChildConditionals`, which gives each its own insert() +
1252
+ // arm-scoped attrs/texts (`LoopChildBranchSummary.reactiveAttrs` /
1253
+ // `.reactiveTexts`) — descending into them here too would double-bind.
1254
+ bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, loopParam, loopParamBindings, true))
1255
+ bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, loopParam, loopParamBindings, true))
1251
1256
  bindings.refs.push(...collectLoopChildRefs(child))
1252
1257
  bindings.conditionals.push(...collectLoopChildConditionals(child, ctx, siblingOffsets, loopParam, loopParamBindings))
1253
1258
  }
@@ -1342,5 +1347,26 @@ function summarizeLoopChildBranch(
1342
1347
  innerLoops: inner.length > 0 ? inner : undefined,
1343
1348
  conditionals: collectLoopChildConditionals(node, ctx, siblingOffsets, loopParam, loopParamBindings),
1344
1349
  events: collectConditionalBranchEvents(node),
1350
+ // Loop-param-aware — reuses the flat loop-item collectors scoped to just
1351
+ // this branch's subtree. Both already stop descending into any further
1352
+ // nested reactive conditional (own insert()/arm), so calling them here
1353
+ // on the branch root yields exactly this branch's direct bindings
1354
+ // without re-collecting what a nested arm already owns (#2347).
1355
+ reactiveAttrs: collectLoopChildReactiveAttrs(node, ctx, loopParam, loopParamBindings, true),
1356
+ // Skip when the branch's ENTIRE content is a single bare `expression`
1357
+ // (no wrapping element) — e.g. a hoisted `renderNode={(n) => <Pill/>}`
1358
+ // callback (#1211/#1213). That value is already fully re-evaluated and
1359
+ // spliced via `__bfSlot` whenever `insert()` (re-)mounts this branch;
1360
+ // an *additional* nested createEffect for the same expression re-calls
1361
+ // it and creates a second, independent live element (a JSX-callback
1362
+ // result isn't idempotent to re-invoke the way a plain signal read is),
1363
+ // and the loop-child arm's `$t()`-based anchor lookup — designed for
1364
+ // text nodes — doesn't cleanly displace an already-mounted Element,
1365
+ // so the second instance lands beside the first instead of replacing
1366
+ // it. A text *nested inside* a static wrapper element in the branch
1367
+ // (the element-descent case) is unaffected and still collected below.
1368
+ reactiveTexts: node.type === 'expression'
1369
+ ? []
1370
+ : collectLoopChildReactiveTexts(node, ctx, loopParam, loopParamBindings, true),
1345
1371
  }
1346
1372
  }
@@ -11,6 +11,8 @@ import type {
11
11
  LoopChildBranchSummary,
12
12
  LoopChildConditional,
13
13
  LoopChildEvent,
14
+ LoopChildReactiveAttr,
15
+ LoopChildReactiveText,
14
16
  NestedLoop,
15
17
  } from '../../types.ts'
16
18
  import type {
@@ -20,9 +22,10 @@ import type {
20
22
  IRProp,
21
23
  LoopParamBinding,
22
24
  } from '../../../types.ts'
23
- import { AttrValueOf } from '../../../types.ts'
25
+ import { AttrValueOf, pickAttrMeta } from '../../../types.ts'
24
26
  import { quotePropName, wrapLoopParamAsAccessor, attrValueToString } from '../../utils.ts'
25
27
  import { addCondAttrToTemplate, irChildrenToJsExpr } from '../../html-template.ts'
28
+ import type { ReactiveAttrSlot } from './reactive-effects.ts'
26
29
 
27
30
  /**
28
31
  * Apply a string-level expression rewriter (loop-param-accessor wrap, prop
@@ -381,6 +384,56 @@ export function buildLoopChildConditionalsPlan(
381
384
  return plans
382
385
  }
383
386
 
387
+ /**
388
+ * Group a branch's reactive attrs by child slot (one qsa per slot),
389
+ * pre-wrapping each expression via the supplied loop-param wrap closure.
390
+ * Shared by every arm builder — outer conditional arms and recursively
391
+ * nested ones alike — so an attr binds inside whichever arm directly owns
392
+ * its element, never a stale outer scope (#2347).
393
+ */
394
+ export function buildArmAttrsPlan(
395
+ attrs: readonly LoopChildReactiveAttr[] | undefined,
396
+ wrap: (expr: string) => string,
397
+ ): readonly ReactiveAttrSlot[] {
398
+ if (!attrs || attrs.length === 0) return []
399
+ const bySlot = new Map<string, LoopChildReactiveAttr[]>()
400
+ for (const attr of attrs) {
401
+ let bucket = bySlot.get(attr.childSlotId)
402
+ if (!bucket) {
403
+ bucket = []
404
+ bySlot.set(attr.childSlotId, bucket)
405
+ }
406
+ bucket.push(attr)
407
+ }
408
+ const slots: ReactiveAttrSlot[] = []
409
+ for (const [slotId, slotAttrs] of bySlot) {
410
+ slots.push({
411
+ slotId,
412
+ attrs: slotAttrs.map(attr => ({
413
+ attrName: attr.attrName,
414
+ wrappedExpression: wrap(attr.expression),
415
+ meta: pickAttrMeta(attr),
416
+ })),
417
+ })
418
+ }
419
+ return slots
420
+ }
421
+
422
+ /**
423
+ * Pre-wrap a branch's reactive text interpolations via the supplied
424
+ * loop-param wrap closure. Shared by every arm builder (#2347).
425
+ */
426
+ export function buildArmTextsPlan(
427
+ texts: readonly LoopChildReactiveText[] | undefined,
428
+ wrap: (expr: string) => string,
429
+ ): readonly import('./loop-child-arm.ts').LoopChildArmText[] {
430
+ if (!texts || texts.length === 0) return []
431
+ return texts.map(text => ({
432
+ slotId: text.slotId,
433
+ wrappedExpression: wrap(text.expression),
434
+ }))
435
+ }
436
+
384
437
  interface BuildLoopChildArmArgs {
385
438
  branch: LoopChildBranchSummary
386
439
  wrap: (expr: string) => string
@@ -413,6 +466,7 @@ function buildLoopChildArmPlan(args: BuildLoopChildArmArgs): LoopChildArmPlan {
413
466
  loopParam,
414
467
  loopParamBindings,
415
468
  }),
416
- texts: [],
469
+ attrs: buildArmAttrsPlan(branch.reactiveAttrs, wrap),
470
+ texts: buildArmTextsPlan(branch.reactiveTexts, wrap),
417
471
  }
418
472
  }