@barefootjs/jsx 0.33.1 → 0.33.3

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 (65) hide show
  1. package/dist/analyzer.d.ts +17 -0
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/compiler.d.ts +21 -5
  4. package/dist/compiler.d.ts.map +1 -1
  5. package/dist/expression-parser.d.ts +14 -0
  6. package/dist/expression-parser.d.ts.map +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +817 -457
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/html-template.d.ts +7 -7
  14. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/imports.d.ts +60 -2
  16. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/prop-handling.d.ts +30 -0
  19. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/reactivity.d.ts +5 -0
  21. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/rewrite-props-object.d.ts +36 -8
  23. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/utils.d.ts +26 -2
  25. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  26. package/dist/jsx-to-ir.d.ts.map +1 -1
  27. package/dist/props-binding.d.ts +35 -0
  28. package/dist/props-binding.d.ts.map +1 -1
  29. package/dist/types.d.ts +51 -0
  30. package/dist/types.d.ts.map +1 -1
  31. package/package.json +2 -2
  32. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +165 -98
  33. package/src/__tests__/binding-scope-ratchet.test.ts +5 -1
  34. package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
  35. package/src/__tests__/client-js-generation.test.ts +11 -0
  36. package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
  37. package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
  38. package/src/__tests__/issue-2723-prop-alias-reactivity.test.ts +124 -0
  39. package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
  40. package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
  41. package/src/__tests__/merge-template-imports.test.ts +41 -1
  42. package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
  43. package/src/__tests__/rewrite-props-object.test.ts +41 -4
  44. package/src/__tests__/root-key-relay.test.ts +170 -0
  45. package/src/__tests__/signal-getter-not-called.test.ts +149 -0
  46. package/src/__tests__/state-only-file-default-import.test.ts +47 -0
  47. package/src/analyzer.ts +36 -0
  48. package/src/compiler.ts +94 -104
  49. package/src/expression-parser.ts +26 -0
  50. package/src/index.ts +2 -2
  51. package/src/ir-to-client-js/collect-elements.ts +45 -30
  52. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
  53. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
  54. package/src/ir-to-client-js/emit-registration.ts +26 -7
  55. package/src/ir-to-client-js/generate-init.ts +1 -1
  56. package/src/ir-to-client-js/html-template.ts +130 -20
  57. package/src/ir-to-client-js/imports.ts +178 -5
  58. package/src/ir-to-client-js/index.ts +11 -4
  59. package/src/ir-to-client-js/prop-handling.ts +83 -0
  60. package/src/ir-to-client-js/reactivity.ts +59 -0
  61. package/src/ir-to-client-js/rewrite-props-object.ts +50 -10
  62. package/src/ir-to-client-js/utils.ts +30 -2
  63. package/src/jsx-to-ir.ts +523 -49
  64. package/src/props-binding.ts +51 -0
  65. package/src/types.ts +48 -0
@@ -0,0 +1,47 @@
1
+ /**
2
+ * #2767 follow-up: the state-only-file client-JS path (a `.tsx` with no
3
+ * JSX return but an exported `/* @client *\/` module signal) used to filter
4
+ * OUT every default- or namespace-imported specifier when deciding which
5
+ * external imports to preserve (`s => !s.isDefault && !s.isNamespace`,
6
+ * `compiler.ts`'s single-component early return) — not just render them
7
+ * wrong, but drop them entirely. A signal initializer that references a
8
+ * default- or namespace-imported helper compiled with zero diagnostics
9
+ * into client JS that throws `ReferenceError` in the browser, since the
10
+ * import never made it into the bundle at all.
11
+ */
12
+ import { describe, test, expect } from 'bun:test'
13
+ import { compileJSX } from '../compiler'
14
+ import { TestAdapter } from '../adapters/test-adapter'
15
+
16
+ const adapter = new TestAdapter()
17
+
18
+ describe('state-only file: default/namespace imports feeding a @client signal', () => {
19
+ test('preserves a default-imported helper referenced by the signal initializer', () => {
20
+ const source = `'use client'
21
+ import defaults from './defaults.json' with { type: 'json' }
22
+ import { createSignal } from '@barefootjs/client'
23
+ /* @client */
24
+ export const [count, setCount] = createSignal(defaults.start)
25
+ `
26
+ const result = compileJSX(source, 'store.tsx', { adapter })
27
+ expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
28
+ const clientJs = result.files.find(f => f.type === 'clientJs')
29
+ expect(clientJs).toBeDefined()
30
+ expect(clientJs!.content).toContain("import defaults from './defaults.json'")
31
+ expect(clientJs!.content).not.toContain('import { defaults }')
32
+ })
33
+
34
+ test('preserves a namespace-imported helper referenced by the signal initializer', () => {
35
+ const source = `'use client'
36
+ import * as util from './util'
37
+ import { createSignal } from '@barefootjs/client'
38
+ /* @client */
39
+ export const [count, setCount] = createSignal(util.base())
40
+ `
41
+ const result = compileJSX(source, 'store2.tsx', { adapter })
42
+ expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
43
+ const clientJs = result.files.find(f => f.type === 'clientJs')
44
+ expect(clientJs).toBeDefined()
45
+ expect(clientJs!.content).toContain("import * as util from './util'")
46
+ })
47
+ })
package/src/analyzer.ts CHANGED
@@ -4237,6 +4237,42 @@ export function listComponentFunctions(
4237
4237
  ts.ScriptKind.TSX
4238
4238
  )
4239
4239
 
4240
+ return listComponentFunctionsFromSourceFile(sourceFile)
4241
+ }
4242
+
4243
+ /**
4244
+ * One parse, two structural facts about a component file — the component
4245
+ * names it exports (today's `listComponentFunctions` result) and the
4246
+ * PascalCase JSX tags it instantiates (`collectJsxComponentTags`'s
4247
+ * out-edges). Used by `@barefootjs/vite`'s `discoverComponents` to build the
4248
+ * component-instantiation graph that decides which SERVER files also need a
4249
+ * client bundle because they transitively own a `'use client'` descendant
4250
+ * (issue #2767) — a property no single-file compile can answer, since it
4251
+ * depends on the whole discovered corpus.
4252
+ */
4253
+ export interface ComponentFileScan {
4254
+ /** Component names this file exports (same result as `listComponentFunctions`). */
4255
+ exports: string[]
4256
+ /** PascalCase JSX tag identifiers this file references. */
4257
+ referencedComponents: string[]
4258
+ }
4259
+
4260
+ export function scanComponentFile(source: string, filePath: string): ComponentFileScan {
4261
+ const sourceFile = ts.createSourceFile(
4262
+ filePath,
4263
+ source,
4264
+ ts.ScriptTarget.Latest,
4265
+ true,
4266
+ ts.ScriptKind.TSX
4267
+ )
4268
+
4269
+ return {
4270
+ exports: listComponentFunctionsFromSourceFile(sourceFile),
4271
+ referencedComponents: [...collectJsxComponentTags(sourceFile)],
4272
+ }
4273
+ }
4274
+
4275
+ function listComponentFunctionsFromSourceFile(sourceFile: ts.SourceFile): string[] {
4240
4276
  const componentNames: string[] = []
4241
4277
 
4242
4278
  // 'use client' directive detection (controls whether multi-return JSX
package/src/compiler.ts CHANGED
@@ -19,7 +19,7 @@ import { stripClientBuiltinImports } from './builtins.ts'
19
19
  import { generateClientJs, generateClientJsWithSourceMap, analyzeClientNeeds } from './ir-to-client-js/index.ts'
20
20
  import { decideClientOnlyElision } from './ir-to-client-js/client-only-elision.ts'
21
21
  import { emitModuleLevelDeclarations } from './ir-to-client-js/emit-module-level.ts'
22
- import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode, makeValueUsageTest } from './ir-to-client-js/imports.ts'
22
+ import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode, makeValueUsageTest, renderUsedImportLines, mergeCompiledClientJsImports } from './ir-to-client-js/imports.ts'
23
23
  import { setActiveComponentScope, computeFileScope } from './ir-to-client-js/component-scope.ts'
24
24
  import { generateModuleExports, collectInlineExportedNames } from './module-exports.ts'
25
25
  import { applyCssLayerPrefix, applyCssLayerPrefixToFile } from './css-layer-prefixer.ts'
@@ -43,11 +43,27 @@ export interface CompileOptionsWithAdapter extends CompileOptions {
43
43
  * conflict-free block.
44
44
  *
45
45
  * Named value/type imports from the same source are folded into their first
46
- * occurrence (preserving line order and first-seen symbol order); every
47
- * other import form (side-effect, default, namespace) is kept in place and
48
- * de-duplicated by exact line. This ensures a symbol is never imported
49
- * twice across sibling components a redeclaration that Bun tolerates but
50
- * stricter ESM parsers (the Deno runtime that renders SSR templates) reject.
46
+ * occurrence (preserving line order and first-seen symbol order); a default
47
+ * or namespace import is likewise folded by source rather than deduplicated
48
+ * by exact line every sibling component that shares a module-scope import
49
+ * declaration compiles it independently, so the same default binding can
50
+ * legally arrive as `import cfg from 'x'` from one component and `import
51
+ * cfg, { helper } from 'x'` from another once only the SECOND one also uses
52
+ * a named specifier from the same statement. Folding by source (default
53
+ * name from the first occurrence that has one, named specifiers unioned)
54
+ * collapses both into one `import cfg, { helper } from 'x'` line; the two
55
+ * exact-line-deduplicated strings would otherwise both survive and
56
+ * redeclare `cfg` (#2767 follow-up — this shape was unreachable before a
57
+ * default/namespace-importing server component could become a real client
58
+ * bundle at all). A namespace specifier can't combine with named ones on
59
+ * one line, so it's folded to its own line, keyed by (source, local name)
60
+ * — see `renderUsedImportLines`'s docstring for the same split rendering
61
+ * rule `collectExternalImports` uses.
62
+ *
63
+ * Every other import form (side-effect) is kept in place and de-duplicated
64
+ * by exact line. This ensures a symbol is never imported twice across
65
+ * sibling components — a redeclaration that Bun tolerates but stricter ESM
66
+ * parsers (the Deno runtime that renders SSR templates) reject.
51
67
  *
52
68
  * For a single-component file the output is identical to the input order;
53
69
  * only repeated sibling imports collapse.
@@ -62,38 +78,70 @@ export interface CompileOptionsWithAdapter extends CompileOptions {
62
78
  export function mergeTemplateImports(lines: string[]): string {
63
79
  const result: string[] = []
64
80
  const valueIdx = new Map<string, number>()
81
+ const valueDefault = new Map<string, string>()
65
82
  const valueNames = new Map<string, Set<string>>()
66
83
  const typeIdx = new Map<string, number>()
67
84
  const typeNames = new Map<string, Set<string>>()
68
85
  const seenOther = new Set<string>()
69
86
 
70
- const fold = (
71
- src: string,
72
- rawNames: string,
73
- idx: Map<string, number>,
74
- names: Map<string, Set<string>>,
75
- render: (src: string, names: Set<string>) => string,
76
- ) => {
77
- if (!idx.has(src)) {
78
- idx.set(src, result.length)
79
- names.set(src, new Set())
87
+ const foldType = (src: string, rawNames: string) => {
88
+ if (!typeIdx.has(src)) {
89
+ typeIdx.set(src, result.length)
90
+ typeNames.set(src, new Set())
80
91
  result.push('')
81
92
  }
82
- const set = names.get(src)!
93
+ const set = typeNames.get(src)!
83
94
  for (const n of rawNames.split(',').map(s => s.trim()).filter(Boolean)) set.add(n)
84
- result[idx.get(src)!] = render(src, set)
95
+ result[typeIdx.get(src)!] = `import type { ${[...set].join(', ')} } from '${src}'`
96
+ }
97
+
98
+ const foldValue = (src: string, defaultName: string | null, rawNames: string | null) => {
99
+ if (!valueIdx.has(src)) {
100
+ valueIdx.set(src, result.length)
101
+ valueNames.set(src, new Set())
102
+ result.push('')
103
+ }
104
+ if (defaultName && !valueDefault.has(src)) valueDefault.set(src, defaultName)
105
+ if (rawNames) {
106
+ const set = valueNames.get(src)!
107
+ for (const n of rawNames.split(',').map(s => s.trim()).filter(Boolean)) set.add(n)
108
+ }
109
+ result[valueIdx.get(src)!] = renderUsedImportLines(
110
+ src,
111
+ valueDefault.get(src) ?? null,
112
+ null,
113
+ [...valueNames.get(src)!],
114
+ ).join('\n')
85
115
  }
86
116
 
87
117
  for (const raw of lines) {
88
118
  const line = raw.trim()
89
119
  if (!line) continue
90
120
  const typeMatch = line.match(/^import\s+type\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
91
- const valueMatch = line.match(/^import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
92
- if (valueMatch) {
93
- fold(valueMatch[2], valueMatch[1], valueIdx, valueNames, (s, n) => `import { ${[...n].join(', ')} } from '${s}'`)
94
- } else if (typeMatch) {
95
- fold(typeMatch[2], typeMatch[1], typeIdx, typeNames, (s, n) => `import type { ${[...n].join(', ')} } from '${s}'`)
121
+ const namedMatch = line.match(/^import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
122
+ const defaultNamedMatch = line.match(/^import\s+([A-Za-z_$][\w$]*)\s*,\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
123
+ const defaultOnlyMatch = line.match(/^import\s+([A-Za-z_$][\w$]*)\s*from\s*['"]([^'"]+)['"]\s*;?$/)
124
+
125
+ if (typeMatch) {
126
+ foldType(typeMatch[2], typeMatch[1])
127
+ } else if (namedMatch) {
128
+ foldValue(namedMatch[2], null, namedMatch[1])
129
+ } else if (defaultNamedMatch) {
130
+ foldValue(defaultNamedMatch[3], defaultNamedMatch[1], defaultNamedMatch[2])
131
+ } else if (defaultOnlyMatch) {
132
+ foldValue(defaultOnlyMatch[2], defaultOnlyMatch[1], null)
96
133
  } else if (!seenOther.has(line)) {
134
+ // Covers namespace imports (`import * as X from 'src'`) and
135
+ // side-effect imports alike — deduplicated by exact line, same as
136
+ // before. A namespace import can't combine with named specifiers on
137
+ // one line (see `renderUsedImportLines`), so two components that
138
+ // both import the SAME namespace binding from the SAME source
139
+ // always emit byte-identical lines and collapse here; two DIFFERENT
140
+ // local namespace names for the same source (unusual — would require
141
+ // sibling components to alias the same module-scope `import * as`
142
+ // declaration differently, which isn't possible for one shared
143
+ // declaration) are kept as separate lines rather than silently
144
+ // merged.
97
145
  seenOther.add(line)
98
146
  result.push(line)
99
147
  }
@@ -446,30 +494,9 @@ function compileMultipleComponents(
446
494
  }
447
495
  const clientJsOutputs = allOutputs.map(o => o.clientJs).filter(Boolean) as string[]
448
496
  if (clientJsOutputs.length > 0) {
449
- const importsBySource = new Map<string, Set<string>>()
450
- const otherImports: string[] = []
451
- const allCode: string[] = []
452
- for (const js of clientJsOutputs) {
453
- for (const line of js.split('\n')) {
454
- if (line.startsWith('import ')) {
455
- const match = line.match(/^import \{ ([^}]+) \} from ['"]([^'"]+)['"]$/)
456
- if (match) {
457
- const source = match[2]
458
- if (!importsBySource.has(source)) importsBySource.set(source, new Set())
459
- for (const n of match[1].split(',').map(n => n.trim())) importsBySource.get(source)!.add(n)
460
- } else if (!otherImports.includes(line)) {
461
- otherImports.push(line)
462
- }
463
- }
464
- }
465
- allCode.push(js.replace(/^import .+\n/gm, '').trim())
466
- }
467
- const mergedClientImports = [...importsBySource].map(([src, names]) =>
468
- `import { ${[...names].sort().join(', ')} } from '${src}'`
469
- )
470
497
  files.push({
471
498
  path: filePath.replace(/\.tsx?$/, '.client.js'),
472
- content: [...mergedClientImports, ...otherImports, '', ...allCode.filter(Boolean)].join('\n'),
499
+ content: mergeCompiledClientJsImports(clientJsOutputs),
473
500
  type: 'clientJs',
474
501
  })
475
502
  }
@@ -557,63 +584,14 @@ function compileMultipleComponents(
557
584
  })
558
585
  }
559
586
 
560
- // Combine client JS if any
587
+ // Combine client JS if any — see `mergeCompiledClientJsImports`'s
588
+ // docstring for why this AST-based merge (not a text/regex line scan)
589
+ // is required here specifically (#2767 follow-up).
561
590
  const clientJsOutputs = allOutputs.map(o => o.clientJs).filter(Boolean) as string[]
562
591
  if (clientJsOutputs.length > 0) {
563
- // Separate imports from code and merge imports by source
564
- const importsBySource = new Map<string, Set<string>>()
565
- const otherImports: string[] = []
566
- const allCode: string[] = []
567
-
568
- for (const js of clientJsOutputs) {
569
- const lines = js.split('\n')
570
- const codeLines: string[] = []
571
-
572
- for (const line of lines) {
573
- if (line.startsWith('import ')) {
574
- // Parse named imports: import { a, b } from 'source'
575
- const match = line.match(/^import \{ ([^}]+) \} from ['"]([^'"]+)['"]$/)
576
- if (match) {
577
- const names = match[1].split(',').map(n => n.trim())
578
- const source = match[2]
579
- if (!importsBySource.has(source)) {
580
- importsBySource.set(source, new Set())
581
- }
582
- const set = importsBySource.get(source)!
583
- for (const name of names) {
584
- set.add(name)
585
- }
586
- } else {
587
- // Other import styles (default, namespace, etc.)
588
- if (!otherImports.includes(line)) {
589
- otherImports.push(line)
590
- }
591
- }
592
- } else {
593
- codeLines.push(line)
594
- }
595
- }
596
-
597
- allCode.push(codeLines.join('\n').trim())
598
- }
599
-
600
- // Generate merged imports
601
- const mergedImports: string[] = []
602
- for (const [source, names] of importsBySource) {
603
- const sortedNames = [...names].sort()
604
- mergedImports.push(`import { ${sortedNames.join(', ')} } from '${source}'`)
605
- }
606
-
607
- const combinedClientJs = [
608
- ...mergedImports,
609
- ...otherImports,
610
- '',
611
- ...allCode.filter(Boolean),
612
- ].join('\n')
613
-
614
592
  files.push({
615
593
  path: filePath.replace(/\.tsx?$/, '.client.js'),
616
- content: combinedClientJs,
594
+ content: mergeCompiledClientJsImports(clientJsOutputs),
617
595
  type: 'clientJs',
618
596
  })
619
597
  }
@@ -735,7 +713,9 @@ export function compileJSX(
735
713
 
736
714
  // Preserve non-runtime user imports whose specifiers are referenced
737
715
  // in the generated body (e.g. an initializer that calls an imported
738
- // helper: `createSignal(defaultValue())`).
716
+ // helper: `createSignal(defaultValue())`). A default- or namespace-
717
+ // imported helper needs its own import syntax, not named braces —
718
+ // see `renderUsedImportLines`'s docstring.
739
719
  const externalImportLines: string[] = []
740
720
  const isUsedAsValue = makeValueUsageTest(body)
741
721
  for (const imp of ctx.imports) {
@@ -745,12 +725,22 @@ export function compileJSX(
745
725
  externalImportLines.push(`import '${imp.source}'`)
746
726
  continue
747
727
  }
748
- const used = imp.specifiers
749
- .filter(s => !s.isDefault && !s.isNamespace && !s.isTypeOnly && isUsedAsValue(s.alias || s.name))
750
- .map(s => s.alias ? `${s.name} as ${s.alias}` : s.name)
751
- if (used.length > 0) {
752
- externalImportLines.push(`import { ${used.join(', ')} } from '${imp.source}'`)
728
+ const usedNamed: string[] = []
729
+ let usedDefault: string | null = null
730
+ let usedNamespace: string | null = null
731
+ for (const s of imp.specifiers) {
732
+ if (s.isTypeOnly) continue
733
+ const localName = s.alias || s.name
734
+ if (!isUsedAsValue(localName)) continue
735
+ if (s.isDefault) {
736
+ usedDefault = localName
737
+ } else if (s.isNamespace) {
738
+ usedNamespace = localName
739
+ } else {
740
+ usedNamed.push(s.alias ? `${s.name} as ${s.alias}` : s.name)
741
+ }
753
742
  }
743
+ externalImportLines.push(...renderUsedImportLines(imp.source, usedDefault, usedNamespace, usedNamed))
754
744
  }
755
745
 
756
746
  const allImports = [runtimeImportLine, ...externalImportLines].filter(Boolean).join('\n')
@@ -317,6 +317,32 @@ export type SortComparator = {
317
317
  keys: SortKey[]
318
318
  }
319
319
 
320
+ /**
321
+ * Runtime registries of the sort-comparator catalogue's finite dimensions —
322
+ * the denominators for the coverage ledger's sort floor
323
+ * (`packages/adapter-tests/src/__tests__/coverage-map.test.ts`). This closes
324
+ * the comparator half of the change-time coupling rule
325
+ * (`spec/subset-conformance.md`) mechanically: the exhaustiveness pins below
326
+ * make widening {@link SortKey} without listing the new member here a compile
327
+ * error, and the floor test then makes shipping a listed member with no
328
+ * covering fixture a test failure — same drift defence `PARSED_EXPR_KINDS`
329
+ * and `ARRAY_METHOD_NAMES` provide for their halves.
330
+ */
331
+ export const SORT_KEY_TYPES = ['numeric', 'string', 'auto'] as const satisfies ReadonlyArray<SortKey['type']>
332
+ type MissingFromSortKeyTypes = Exclude<SortKey['type'], (typeof SORT_KEY_TYPES)[number]>
333
+ const _sortKeyTypeRegistryIsExhaustive: MissingFromSortKeyTypes extends never ? true : never = true
334
+ void _sortKeyTypeRegistryIsExhaustive
335
+
336
+ export const SORT_KEY_TARGETS = ['self', 'field'] as const satisfies ReadonlyArray<SortKey['key']['kind']>
337
+ type MissingFromSortKeyTargets = Exclude<SortKey['key']['kind'], (typeof SORT_KEY_TARGETS)[number]>
338
+ const _sortKeyTargetRegistryIsExhaustive: MissingFromSortKeyTargets extends never ? true : never = true
339
+ void _sortKeyTargetRegistryIsExhaustive
340
+
341
+ export const SORT_KEY_DIRECTIONS = ['asc', 'desc'] as const satisfies ReadonlyArray<SortKey['direction']>
342
+ type MissingFromSortKeyDirections = Exclude<SortKey['direction'], (typeof SORT_KEY_DIRECTIONS)[number]>
343
+ const _sortKeyDirectionRegistryIsExhaustive: MissingFromSortKeyDirections extends never ? true : never = true
344
+ void _sortKeyDirectionRegistryIsExhaustive
345
+
320
346
  /**
321
347
  * Flatten depth for `.flat(depth?)` (#1448 Tier C). A finite non-negative
322
348
  * integer flattens that many levels (`.flat()` defaults to `1`; a `0` or
package/src/index.ts CHANGED
@@ -66,7 +66,7 @@ export type {
66
66
  } from './types.ts'
67
67
 
68
68
  // Analyzer
69
- export { analyzeComponent, listComponentFunctions, listComponentFunctions as listExportedComponents, createProgramForFile, needsTypeBasedDetection, REACTIVE_PRIMITIVES, BROWSER_ONLY_CLIENT_APIS, type AnalyzerContext } from './analyzer.ts'
69
+ export { analyzeComponent, listComponentFunctions, listComponentFunctions as listExportedComponents, scanComponentFile, createProgramForFile, needsTypeBasedDetection, REACTIVE_PRIMITIVES, BROWSER_ONLY_CLIENT_APIS, type AnalyzerContext, type ComponentFileScan } from './analyzer.ts'
70
70
  export { createProgramForCorpus, type SharedProgramOptions } from './shared-program.ts'
71
71
 
72
72
  // JSX to IR transformer
@@ -213,7 +213,7 @@ export { isValueReferenceIdentifier, collectValueReferencedNames } from './value
213
213
  // Expression Parser
214
214
  export { parseExpression, tsNodeToParsedExpr, asCallbackMethodCall, CALLBACK_METHODS, sortComparatorFromArrow, serializeParsedExpr, freeVarsInBody, freeIdentifiers, materializeGetterCalls, isSupported, isSupportedValue, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, parseBlockBodyTolerant, foldBlockToExpr, predicateTernaryToLogical, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, hasUnsafeStyleValue, parseProviderObjectLiteral, type ProviderObjectMember, type FoldBlockOptions } from './expression-parser.ts'
215
215
  export type { StyleObjectEntry } from './expression-parser.ts'
216
- export { PARSED_EXPR_KINDS, ARRAY_METHOD_NAMES } from './expression-parser.ts'
216
+ export { PARSED_EXPR_KINDS, ARRAY_METHOD_NAMES, SORT_KEY_TYPES, SORT_KEY_TARGETS, SORT_KEY_DIRECTIONS } from './expression-parser.ts'
217
217
  export type { ParsedExpr, ObjectLiteralProperty, ParsedStatement, SortComparator, SortKey, FlatDepth, SupportLevel, SupportResult, TemplatePart } from './expression-parser.ts'
218
218
  export { buildLoopChainExpr } from './loop-chain.ts'
219
219
  export type { LoopChainInputs } from './loop-chain.ts'
@@ -8,11 +8,12 @@ import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM } from '
8
8
  import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings, buildLoopRowScope, anyNameIn } from './reactivity.ts'
9
9
  import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate, computeSkeletonSlotPaths, renderFlatMapClientBody, renderFlatMapProjectionClientBody, flatMapCallbackHasKeyedLeaf, type SkeletonSlotPaths } from './html-template.ts'
10
10
  import { detectRootNamespaceWrapTag } from './control-flow/stringify/template-parse.ts'
11
- import { expandDynamicPropValue, expandConstantForReactivity } from './prop-handling.ts'
11
+ import { expandDynamicPropValue, expandConstantForReactivity, resolveRestSpreadOrigin, resolveRestSpreadNames } from './prop-handling.ts'
12
12
  import { extractFreeIdentifiersFromText } from './csr-substitute.ts'
13
13
  import { walkIR, stopAt } from './walker.ts'
14
14
  import { buildLoopChainExpr } from '../loop-chain.ts'
15
15
  import { identifierPattern } from '../identifier-pattern.ts'
16
+ import { classifyDOMProp } from '@barefootjs/shared'
16
17
 
17
18
  /** Expressions that render nothing (0 DOM nodes) — `&&` / `?:` empty branches. */
18
19
  const EMPTY_RENDER_EXPRS = new Set(['null', 'undefined', 'false', "''", '""', '``'])
@@ -497,23 +498,15 @@ function isSingleElementJsxChildren(nodes: IRNode[]): boolean {
497
498
  return nodes.length === 1 && nodes[0].type === 'element'
498
499
  }
499
500
 
500
- /** Build rest spread names from context (rest/props spreads handled by applyRestAttrs, not spreadAttrs). */
501
- function buildRestSpreadNames(ctx: ClientJsContext): Set<string> {
502
- const names = new Set<string>()
503
- if (ctx.restPropsName) names.add(ctx.restPropsName)
504
- if (ctx.propsObjectName) names.add(ctx.propsObjectName)
505
- return names
506
- }
507
-
508
501
  /** Build propsExpr for a child component from its IR props. */
509
502
  function buildComponentPropsExpr(props: IRProp[], ctx: ClientJsContext): string {
510
- const restName = ctx.restPropsName
511
- const propsObjName = ctx.propsObjectName
512
503
  const knownSpreadProp = props.find(p => {
513
504
  if (p.name !== '...' && !p.name.startsWith('...')) return false
514
505
  if (p.value.kind !== 'spread' && p.value.kind !== 'expression') return false
515
506
  const expr = p.value.kind === 'spread' ? p.value.expr : p.value.expr
516
- return expr === restName || expr === propsObjName
507
+ // #2723: resolve through any `const x__alias = x` hop onto the
508
+ // rest/props binding — see `resolveRestSpreadOrigin`'s docstring.
509
+ return resolveRestSpreadOrigin(ctx, expr) !== null
517
510
  })
518
511
  const spreadSource = knownSpreadProp ? PROPS_PARAM : null
519
512
 
@@ -589,11 +582,32 @@ function collectReactiveChildProps(node: IRComponent, ctx: ClientJsContext): voi
589
582
  for (const prop of node.props) {
590
583
  if (prop.name === '...' || prop.name.startsWith('...')) continue
591
584
  if (prop.value.kind === 'jsx-children') continue
592
- const isEventHandler =
593
- prop.name.startsWith('on') &&
594
- prop.name.length > 2 &&
595
- prop.name[2] === prop.name[2].toUpperCase()
596
- if (isEventHandler) continue
585
+ // `classifyDOMProp` is the single source of truth for "how does this prop
586
+ // reach the DOM?" (`packages/shared/src/dom-prop.ts`). `applyRestAttrs`
587
+ // reads it to build its own attribute set — everything whose kind is NOT
588
+ // `ref` / `event` / `skip`. This mirror is the compile-time twin of that
589
+ // loop, so it takes the same three exclusions from the same classifier
590
+ // rather than re-deciding them by hand (#2749: the hand-rolled `on[A-Z]`
591
+ // check covered events but not `ref`, so a `ref` on a child-component
592
+ // call site was mirrored as `setAttribute('ref', String(<fn source>))` —
593
+ // an attribute SSR never emits, failing the SSR-vs-hydrated snapshot and
594
+ // shadowing the real binding `initChild` was already passing correctly).
595
+ //
596
+ // Two small widenings come with reading the shared classifier, both
597
+ // intended. `skip` also covers a `children` prop reaching here as an
598
+ // expression rather than as the `jsx-children` variant handled above —
599
+ // `setAttribute('children', …)` was never a sensible mirror. And the
600
+ // classifier's event test requires an actual A-Z at index 2, where the
601
+ // old `name[2] === name[2].toUpperCase()` also matched non-letters
602
+ // (`on-foo`, `on1x`), which are not DOM events and now mirror normally.
603
+ //
604
+ // Why not also exclude `innerHTML` here: `dangerouslySetInnerHTML` as a
605
+ // reactive CHILD-COMPONENT prop is a separate, unmeasured shape — the
606
+ // element-level path is what the `dangerous-inner-html*` fixtures cover.
607
+ // Narrowing this mirror to the three kinds `applyRestAttrs` excludes keeps
608
+ // the change to the classification `ref` was already missing.
609
+ const domKind = classifyDOMProp(prop.name).kind
610
+ if (domKind === 'ref' || domKind === 'event' || domKind === 'skip') continue
597
611
  // Only `expression` / `template` variants drive reactive prop forwarding.
598
612
  if (prop.value.kind !== 'expression' && prop.value.kind !== 'template') continue
599
613
  const valueExpr = attrValueToString(prop.value)!
@@ -762,7 +776,7 @@ export function collectElements(
762
776
  // drops the parent's slot suffix automatically. Each iteration
763
777
  // owns a distinct scope identified by `data-key`, mirroring the
764
778
  // SSR template's renderChild emit.
765
- staticItemTemplate = irToHtmlTemplate(l.children[0], buildRestSpreadNames(ctx), 0, undefined, undefined)
779
+ staticItemTemplate = irToHtmlTemplate(l.children[0], resolveRestSpreadNames(ctx), 0, undefined, undefined)
766
780
  }
767
781
  } else if (l.children[0] && !projectionInner) {
768
782
  // Pass loopParams so expressions are wrapped at generation time,
@@ -771,8 +785,8 @@ export function collectElements(
771
785
  // in the emitted template literal are rewritten to `__bfItem()[1].color`.
772
786
  const loopParamSpec = [{ param: l.param, bindings: l.paramBindings }]
773
787
  template = useElementReconciliation
774
- ? irToPlaceholderTemplate(l.children[0], buildRestSpreadNames(ctx), 0, loopParamSpec)
775
- : irToHtmlTemplate(l.children[0], buildRestSpreadNames(ctx), 0, loopParamSpec)
788
+ ? irToPlaceholderTemplate(l.children[0], resolveRestSpreadNames(ctx), 0, loopParamSpec)
789
+ : irToHtmlTemplate(l.children[0], resolveRestSpreadNames(ctx), 0, loopParamSpec)
776
790
  // Static-array loops emit a `forEach((param, idx) => ...)` whose body
777
791
  // references the destructured param directly — `__bfItem()` is not in
778
792
  // scope there. Build a second template that skips the loop-param
@@ -788,8 +802,8 @@ export function collectElements(
788
802
  // markers, so SSR's parent-anchored shape and CSR's random-id
789
803
  // shape both resolve through the same lookup.
790
804
  staticItemTemplate = useElementReconciliation
791
- ? irToPlaceholderTemplate(l.children[0], buildRestSpreadNames(ctx), 0)
792
- : irToHtmlTemplate(l.children[0], buildRestSpreadNames(ctx), 0)
805
+ ? irToPlaceholderTemplate(l.children[0], resolveRestSpreadNames(ctx), 0)
806
+ : irToHtmlTemplate(l.children[0], resolveRestSpreadNames(ctx), 0)
793
807
  } else if (!useElementReconciliation && !l.bodyIsMultiRoot && !l.bodyIsItemConditional) {
794
808
  // Hoisted shared-template fast path (perf): only for the plain
795
809
  // `mapArray` shape — single-root, dynamic array, no element
@@ -855,13 +869,13 @@ export function collectElements(
855
869
  flatMapClient: projectionInner
856
870
  ? {
857
871
  params: l.index ? `(${l.param}, ${l.index})` : `(${l.param})`,
858
- body: renderFlatMapProjectionClientBody(projectionInner, buildRestSpreadNames(ctx)),
872
+ body: renderFlatMapProjectionClientBody(projectionInner, resolveRestSpreadNames(ctx)),
859
873
  keyed: projectionInner.key !== null,
860
874
  }
861
875
  : l.flatMapCallback
862
876
  ? {
863
877
  params: l.flatMapCallback.params,
864
- body: renderFlatMapClientBody(l.flatMapCallback, buildRestSpreadNames(ctx)),
878
+ body: renderFlatMapClientBody(l.flatMapCallback, resolveRestSpreadNames(ctx)),
865
879
  keyed: flatMapCallbackHasKeyedLeaf(l.flatMapCallback),
866
880
  }
867
881
  : undefined,
@@ -964,9 +978,10 @@ function collectFromElement(element: IRElement, ctx: ClientJsContext, insideCond
964
978
  // Always use PROPS_PARAM as the source since the init function parameter is PROPS_PARAM.
965
979
  if (attr.name === '...' && attr.value) {
966
980
  const spreadVal = attrValueToString(attr.value) ?? ''
967
- const elemRestName = ctx.restPropsName
968
- const elemPropsObjName = ctx.propsObjectName
969
- if (spreadVal && (spreadVal === elemRestName || spreadVal === elemPropsObjName)) {
981
+ // #2723: resolve through any `const x__alias = x` hop onto the
982
+ // rest/props binding — see `resolveRestSpreadOrigin`'s docstring.
983
+ const spreadOrigin = spreadVal ? resolveRestSpreadOrigin(ctx, spreadVal) : null
984
+ if (spreadOrigin !== null) {
970
985
  // `applyRestAttrs(_el, _p, exclude)` is handed the FULL props
971
986
  // object (`PROPS_PARAM`), not a computed JS rest binding, and the
972
987
  // runtime filters by SOURCE KEY (`source[key]`). So `exclude` must
@@ -985,7 +1000,7 @@ function collectFromElement(element: IRElement, ctx: ClientJsContext, insideCond
985
1000
  // caller-keyed (#2524 CSR half) — so the exclude list must use
986
1001
  // the caller-facing key too, not the local binding name.
987
1002
  const consumedKeys =
988
- spreadVal === elemRestName ? ctx.propsParams.map(p => p.sourceName ?? p.name) : []
1003
+ spreadOrigin === 'rest' ? ctx.propsParams.map(p => p.sourceName ?? p.name) : []
989
1004
  const staticAttrKeys = element.attrs
990
1005
  .filter(a => a.name !== '...')
991
1006
  .map(a => a.name)
@@ -1135,7 +1150,7 @@ function collectBranchLoops(
1135
1150
  siblingOffsets: Map<IRLoop, IRNode[]>,
1136
1151
  ): BranchLoop[] {
1137
1152
  const loops: BranchLoop[] = []
1138
- const restNames = ctx ? buildRestSpreadNames(ctx) : undefined
1153
+ const restNames = ctx ? resolveRestSpreadNames(ctx) : undefined
1139
1154
 
1140
1155
  walkIR<string | null>(node, null, {
1141
1156
  // Don't recurse into nested conditionals / if-statements.
@@ -1258,7 +1273,7 @@ function buildConditionalMetadata(
1258
1273
  ctx: ClientJsContext,
1259
1274
  siblingOffsets: Map<IRLoop, IRNode[]>,
1260
1275
  ): ConditionalElement {
1261
- const restNames = buildRestSpreadNames(ctx)
1276
+ const restNames = resolveRestSpreadNames(ctx)
1262
1277
  // Use loopDepth=-1 so the first loop encountered inside the branch emits
1263
1278
  // data-key (depth 0) for its items, matching the mapArray item template
1264
1279
  // and event dispatcher convention. Matches irToComponentTemplate/generateCsrTemplate.
@@ -31,7 +31,7 @@
31
31
  * <indent>}) }
32
32
  */
33
33
 
34
- import { keyAttrName, profileBindingId, varSlotId } from '../../utils.ts'
34
+ import { keyAttrName, mapArrayKeyArgs, profileBindingId, varSlotId } from '../../utils.ts'
35
35
  import { emitComponentAndEventSetup } from '../shared.ts'
36
36
  import { emitAttrUpdate } from '../../emit-reactive.ts'
37
37
  import { emitMultiRootTemplateCloneLines, namespaceWrapForTemplate } from './template-parse.ts'
@@ -157,7 +157,11 @@ function emitReactive(lines: string[], inner: InnerLoopPlan, indent: string, pc:
157
157
  bodyIsMultiRoot: emit.bodyIsMultiRoot,
158
158
  })
159
159
  lines.push(`${indent} return __innerEl${uid}`)
160
- lines.push(`${indent}}, '${inner.markerId}'${profileBindingId(pc, inner.slotId)}) }`)
160
+ // #2753 Shape B: the runtime's own fallback stamp (a row whose renderItem
161
+ // didn't already set a key attribute — see `map-array.ts`) has no depth
162
+ // concept, so a nested keyed loop must tell it which name to check/write
163
+ // instead of the default `data-key`.
164
+ lines.push(`${indent}}, '${inner.markerId}'${mapArrayKeyArgs(profileBindingId(pc, inner.slotId), !!emit.wrappedKey, inner.keyDepth)}) }`)
161
165
  }
162
166
 
163
167
  function emitStatic(lines: string[], inner: InnerLoopPlan, indent: string, pc: string | undefined): void {
@@ -11,7 +11,7 @@
11
11
  * every nesting depth.
12
12
  */
13
13
 
14
- import { varSlotId, DATA_BF_PH, keyAttrName, profileBindingId } from '../../utils.ts'
14
+ import { varSlotId, DATA_BF_PH, keyAttrName, mapArrayKeyArgs, profileBindingId } from '../../utils.ts'
15
15
  import { emitComponentAndEventSetup } from '../shared.ts'
16
16
  import { emitAttrUpdate } from '../../emit-reactive.ts'
17
17
  import { namespaceWrapForTemplate } from './template-parse.ts'
@@ -180,7 +180,10 @@ export function stringifyBranchInnerLoops(
180
180
  stringifyLoopChildConditionals(lines, inner.nestedConditionals, `${indent} `, pc)
181
181
  }
182
182
  lines.push(`${indent} return __bel${uid}`)
183
- lines.push(`${indent}}, '${inner.markerId}'${profileBindingId(pc, inner.slotId)}) }`)
183
+ // #2753 Shape B: see the identical comment in `inner-loop.ts`
184
+ // `keyDepth` is always 1 for a branch-arm inner loop, so this only ever
185
+ // widens the trailing args when the loop is also keyed.
186
+ lines.push(`${indent}}, '${inner.markerId}'${mapArrayKeyArgs(profileBindingId(pc, inner.slotId), !!inner.wrappedKey, inner.keyDepth)}) }`)
184
187
  }
185
188
  }
186
189