@barefootjs/jsx 0.33.2 → 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.
- package/dist/analyzer.d.ts +17 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/compiler.d.ts +21 -5
- package/dist/compiler.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +707 -431
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +60 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/prop-handling.d.ts +4 -7
- package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +26 -2
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/props-binding.d.ts +35 -0
- package/dist/props-binding.d.ts.map +1 -1
- package/dist/types.d.ts +50 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +145 -97
- package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
- package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
- package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
- package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
- package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
- package/src/__tests__/merge-template-imports.test.ts +41 -1
- package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
- package/src/__tests__/root-key-relay.test.ts +170 -0
- package/src/__tests__/signal-getter-not-called.test.ts +149 -0
- package/src/__tests__/state-only-file-default-import.test.ts +47 -0
- package/src/analyzer.ts +36 -0
- package/src/compiler.ts +94 -104
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/collect-elements.ts +27 -5
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
- package/src/ir-to-client-js/html-template.ts +122 -12
- package/src/ir-to-client-js/imports.ts +178 -5
- package/src/ir-to-client-js/index.ts +5 -0
- package/src/ir-to-client-js/prop-handling.ts +6 -17
- package/src/ir-to-client-js/utils.ts +30 -2
- package/src/jsx-to-ir.ts +480 -52
- package/src/props-binding.ts +51 -0
- package/src/types.ts +47 -13
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);
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
|
71
|
-
src
|
|
72
|
-
|
|
73
|
-
|
|
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 =
|
|
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[
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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:
|
|
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:
|
|
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
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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')
|
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
|
|
@@ -13,6 +13,7 @@ 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', "''", '""', '``'])
|
|
@@ -581,11 +582,32 @@ function collectReactiveChildProps(node: IRComponent, ctx: ClientJsContext): voi
|
|
|
581
582
|
for (const prop of node.props) {
|
|
582
583
|
if (prop.name === '...' || prop.name.startsWith('...')) continue
|
|
583
584
|
if (prop.value.kind === 'jsx-children') continue
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
|
589
611
|
// Only `expression` / `template` variants drive reactive prop forwarding.
|
|
590
612
|
if (prop.value.kind !== 'expression' && prop.value.kind !== 'template') continue
|
|
591
613
|
const valueExpr = attrValueToString(prop.value)!
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* IR → HTML template string generation and validation.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { AttrValue, FlatMapCallback, IRAttribute, IRNode, IRProp, MapCallbackPreamble } from '../types.ts'
|
|
5
|
+
import type { AttrValue, FlatMapCallback, IRAttribute, IRExpression, IRNode, IRProp, MapCallbackPreamble } from '../types.ts'
|
|
6
6
|
import { isBooleanAttr } from '../html-constants.ts'
|
|
7
7
|
import { toHtmlAttrName, attrValueToString, quotePropName, PROPS_PARAM, DATA_BF_PH, keyAttrName, loopStartMarker, loopEndMarker, loopItemMarker, freeIdsFromRefs, setIntersects, wrapExprWithLoopParams } from './utils.ts'
|
|
8
8
|
import type { LoopParamSpec } from './utils.ts'
|
|
@@ -319,6 +319,16 @@ function escapeAttrValueExpr(valExpr: string): string {
|
|
|
319
319
|
* bytes. Bare `${...}` interpolations — `{children}` passthrough and
|
|
320
320
|
* `renderChild(...)` output — are pre-rendered HTML and must NOT be
|
|
321
321
|
* escaped, so this is applied only at the four text-marker emit sites.
|
|
322
|
+
* The no-`slotId` fallthrough (every `case 'expression'` branch's final
|
|
323
|
+
* `return` in this file) is shared by several unrelated shapes besides
|
|
324
|
+
* `{children}` passthrough — an `escapeLeafTextExpressions`-wrapped
|
|
325
|
+
* preamble leaf, `lowerFormControlValueSsr`'s textarea initial value, an
|
|
326
|
+
* inlined constant, a `''`/`undefined` deferred placeholder — every one of
|
|
327
|
+
* which is either already escaped or a literal, and must reach the
|
|
328
|
+
* template untouched. `bareSpliceExpr` below is that branch's single door
|
|
329
|
+
* — the one place the fallthrough's decision is made — and it is what
|
|
330
|
+
* picks the genuine `{children}` reference back out for `markupOrEmpty`'s
|
|
331
|
+
* nullish guard (#2775); see its own docstring.
|
|
322
332
|
* Hono escapes text content with the same set as attribute values
|
|
323
333
|
* (`& " ' < >`), so `escapeText` delegates to the same operation.
|
|
324
334
|
*
|
|
@@ -339,6 +349,73 @@ function escapeTextSlotExpr(innerExpr: string, isMarkup = false): string {
|
|
|
339
349
|
return `${isMarkup ? 'escapeTextOrMarkup' : 'escapeText'}(${innerExpr})`
|
|
340
350
|
}
|
|
341
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Recognizes a JSX child-position expression that is exactly a reference to
|
|
354
|
+
* the reserved `children` prop — bare `children` (destructured) or
|
|
355
|
+
* `<receiver>.children` for any single-identifier receiver (`props.children`,
|
|
356
|
+
* a custom props-param name, a loop-scoped alias closing over props, ...).
|
|
357
|
+
* Checked against `node.expr` — the ORIGINAL source text, never a
|
|
358
|
+
* transformed/wrapped form — so it stays accurate regardless of which
|
|
359
|
+
* builder is asking, and regardless of any earlier pass
|
|
360
|
+
* (`escapeLeafTextExpressions`, `lowerFormControlValueSsr`) that may have
|
|
361
|
+
* wrapped an unrelated leaf.
|
|
362
|
+
*
|
|
363
|
+
* Deliberately LOOSER than `isTransparentFragment` (`jsx-to-ir.ts`), which
|
|
364
|
+
* answers the same underlying question one level up. That function runs on
|
|
365
|
+
* the TS AST and compares the expression text against an EXACT set —
|
|
366
|
+
* `children`, `props.children`, and the analyzer-resolved
|
|
367
|
+
* `${ctx.analyzer.propsObjectName}.children`. This layer works on IR and has
|
|
368
|
+
* no analyzer, so the resolved props name is not reachable here; matching any
|
|
369
|
+
* single-identifier receiver is the available approximation, chosen — not an
|
|
370
|
+
* inherited convention.
|
|
371
|
+
*
|
|
372
|
+
* The looseness costs nothing measurable. An unrelated `.children` member —
|
|
373
|
+
* a tree node's own `children` array, say — does not even arrive here: a
|
|
374
|
+
* reactive member expression is given a `slotId` and takes the escaped
|
|
375
|
+
* text-slot branch above, so it never reaches the bare-splice fallthrough
|
|
376
|
+
* this gates. And were one to arrive, the outcome is still benign: the
|
|
377
|
+
* branch never escaped its value either way, a non-nullish value is
|
|
378
|
+
* returned untouched, and a nullish one rendering `''` instead of the
|
|
379
|
+
* literal `"undefined"` is an improvement in its own right.
|
|
380
|
+
*
|
|
381
|
+
* Both the ORIGINAL source text and the RESOLVED expression are tested,
|
|
382
|
+
* because either one alone misses a shape. `node.expr` is the only form
|
|
383
|
+
* that does not vary between the four builders, so it stays the primary
|
|
384
|
+
* test; but it is the pre-substitution text, which for a
|
|
385
|
+
* destructured-and-renamed children (`const { children: kids } = props`)
|
|
386
|
+
* reads `kids` and matches nothing — while the resolved expression the
|
|
387
|
+
* emitter is about to splice already reads `(_p.children)`. Testing both
|
|
388
|
+
* closes that (#2786) without giving up `node.expr`'s stability.
|
|
389
|
+
*/
|
|
390
|
+
function isChildrenPassthroughExpr(expr: string): boolean {
|
|
391
|
+
return /^([A-Za-z_$][\w$]*\.)?children$/.test(expr.trim())
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The single door for the bare (no-`slotId`) expression splice — the
|
|
396
|
+
* counterpart to `escapeTextSlotExpr` for the branch that must NOT escape.
|
|
397
|
+
* All four `case 'expression'` builders in this file route through here so
|
|
398
|
+
* this decision exists in exactly one place: four copies that agree today
|
|
399
|
+
* are four that can drift apart tomorrow, and this file is where that has
|
|
400
|
+
* already happened (#2753 -> #2762).
|
|
401
|
+
*
|
|
402
|
+
* Only a genuine `{children}` passthrough gets `markupOrEmpty`'s nullish
|
|
403
|
+
* guard (#2775). Everything else this fallthrough hosts — an
|
|
404
|
+
* `escapeLeafTextExpressions`-wrapped preamble leaf,
|
|
405
|
+
* `lowerFormControlValueSsr`'s textarea initial value, an inlined constant,
|
|
406
|
+
* a `''`/`undefined` deferred placeholder — reaches the template exactly as
|
|
407
|
+
* it arrived, already escaped or a literal. Escaping is never correct here:
|
|
408
|
+
* the value is pre-rendered HTML, per `escapeTextSlotExpr`'s docstring.
|
|
409
|
+
*/
|
|
410
|
+
function bareSpliceExpr(node: IRExpression, valueExpr: string): string {
|
|
411
|
+
// Strip the parens the emitter wraps a substituted expression in, so the
|
|
412
|
+
// resolved form is comparable to the bare source text.
|
|
413
|
+
const resolved = valueExpr.trim().replace(/^\(+|\)+$/g, '')
|
|
414
|
+
const isChildren =
|
|
415
|
+
isChildrenPassthroughExpr(node.expr) || isChildrenPassthroughExpr(resolved)
|
|
416
|
+
return !node.joinArrayChild && isChildren ? `markupOrEmpty(${valueExpr})` : valueExpr
|
|
417
|
+
}
|
|
418
|
+
|
|
342
419
|
/**
|
|
343
420
|
* `dangerouslySetInnerHTML={{ __html: E }}` makes the element's content its
|
|
344
421
|
* raw innerHTML — the intentional, React-style escape hatch. Returns the
|
|
@@ -757,13 +834,21 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
|
|
|
757
834
|
switch (node.type) {
|
|
758
835
|
case 'element': {
|
|
759
836
|
// Merge context shared with `irToComponentTemplate` /
|
|
760
|
-
// `generateCsrTemplate`.
|
|
761
|
-
// `
|
|
762
|
-
//
|
|
763
|
-
//
|
|
837
|
+
// `generateCsrTemplate`. Its spread rest-name detector uses
|
|
838
|
+
// `v.expr` directly (no `templateExpr` fallback — those live on
|
|
839
|
+
// the SSR template path).
|
|
840
|
+
//
|
|
841
|
+
// Why not path-local `clientOnly`: this builder emits the row /
|
|
842
|
+
// branch markup that a freshly built row gets, while a row REUSED
|
|
843
|
+
// by hydration carries the SSR adapter's markup instead. So the
|
|
844
|
+
// two representations must agree, and `clientOnly` ("SSR omits it;
|
|
845
|
+
// the effect owns it") is the same statement on both sides. Baking
|
|
846
|
+
// the attribute in here made a rebuilt row carry an attribute an
|
|
847
|
+
// SSR-reused row never has — visible the moment a row-count change
|
|
848
|
+
// makes reused and rebuilt rows coexist in one list (#2756).
|
|
764
849
|
const mergeCtx: MergeContext = {
|
|
765
850
|
isFilteredSpread: (v) => !!restSpreadNames?.has(v.expr),
|
|
766
|
-
honorClientOnly:
|
|
851
|
+
honorClientOnly: true,
|
|
767
852
|
}
|
|
768
853
|
const useMerge = shouldUseSpreadAttrsMerge(node.attrs, mergeCtx)
|
|
769
854
|
const firstMergeableIdx = useMerge
|
|
@@ -780,6 +865,12 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
|
|
|
780
865
|
|
|
781
866
|
const attrParts = node.attrs
|
|
782
867
|
.map((a, idx) => {
|
|
868
|
+
// Deferred to the row's own `createEffect`, which the loop-row
|
|
869
|
+
// reactive-attr collector already registers for every
|
|
870
|
+
// `clientOnly` attr (`collect-elements.ts`). Emitting it here
|
|
871
|
+
// too would be redundant on a rebuilt row and absent on a
|
|
872
|
+
// hydrate-reused one (#2756).
|
|
873
|
+
if (a.clientOnly) return ''
|
|
783
874
|
if (useMerge && isMergeableAttr(a, mergeCtx)) {
|
|
784
875
|
// Only the first mergeable attr emits the merge call; the
|
|
785
876
|
// others are already represented inside the merge object.
|
|
@@ -853,11 +944,19 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
|
|
|
853
944
|
// separate rather than collapsed into a shared helper precisely
|
|
854
945
|
// because their `clientOnly` semantics differ — see that function's
|
|
855
946
|
// own comment (#2617).
|
|
947
|
+
|
|
948
|
+
// Escape only when the IR says so (`escapeInClientTemplate`) — most
|
|
949
|
+
// `${...}` here is already pre-rendered HTML. Never take
|
|
950
|
+
// `templateExpr` wholesale instead: it rebinds to `_p.xxx`, dropping
|
|
951
|
+
// the `?? {}` prop-defaulting guard in this builder's init scope
|
|
952
|
+
// (`client-js-generation.test.ts`).
|
|
953
|
+
const escapeForClient = (e: string): string =>
|
|
954
|
+
node.escapeInClientTemplate ? `escapeText(${e})` : e
|
|
856
955
|
if (node.markerless) {
|
|
857
|
-
const bare = wrapInterpolation(wrapExpr(node.expr))
|
|
956
|
+
const bare = escapeForClient(wrapInterpolation(wrapExpr(node.expr)))
|
|
858
957
|
return `\${${bare}}`
|
|
859
958
|
}
|
|
860
|
-
const inner = wrapInterpolation(wrapExpr(node.expr))
|
|
959
|
+
const inner = escapeForClient(wrapInterpolation(wrapExpr(node.expr)))
|
|
861
960
|
// Stage 3 / D4 — an element-array child ({out}) built by an arbitrary
|
|
862
961
|
// .map() preamble is an array of HTML strings; join it rather than let
|
|
863
962
|
// `${[...]}` `String`-comma-collapse it. Only reached on a JS-runtime
|
|
@@ -876,7 +975,8 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
|
|
|
876
975
|
const slotted = branchSlotsVar || node.joinArrayChild ? valueExpr : escapeTextSlotExpr(valueExpr)
|
|
877
976
|
return `<!--bf:${node.slotId}-->\${${slotted}}<!--/-->`
|
|
878
977
|
}
|
|
879
|
-
|
|
978
|
+
// Bare-splice fallthrough (no `slotId`, not an array-child join).
|
|
979
|
+
return `\${${bareSpliceExpr(node, valueExpr)}}`
|
|
880
980
|
}
|
|
881
981
|
|
|
882
982
|
case 'conditional': {
|
|
@@ -1381,6 +1481,10 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Readonly
|
|
|
1381
1481
|
case 'element': {
|
|
1382
1482
|
const attrParts = node.attrs
|
|
1383
1483
|
.map((a) => {
|
|
1484
|
+
// Same deferral as `irToHtmlTemplate` — this builder is the
|
|
1485
|
+
// composite-row twin of it, so a row it builds must carry the
|
|
1486
|
+
// same attributes a hydration-reused row does (#2756).
|
|
1487
|
+
if (a.clientOnly) return ''
|
|
1384
1488
|
const attrName = a.name === '...'
|
|
1385
1489
|
? '...'
|
|
1386
1490
|
: (a.name === 'key' ? keyAttrName(loopDepth) : toHtmlAttrName(a.name))
|
|
@@ -1416,7 +1520,11 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Readonly
|
|
|
1416
1520
|
if (node.slotId) {
|
|
1417
1521
|
return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped)}}<!--/-->`
|
|
1418
1522
|
}
|
|
1419
|
-
|
|
1523
|
+
// Bare-splice fallthrough (no `slotId`) — this builder's composite-row
|
|
1524
|
+
// twin of `irToHtmlTemplate`'s `escapeForClient`, same "why not
|
|
1525
|
+
// templateExpr" reasoning (#2765).
|
|
1526
|
+
const spliced = bareSpliceExpr(node, value)
|
|
1527
|
+
return `\${${node.escapeInClientTemplate ? `escapeText(${spliced})` : spliced}}`
|
|
1420
1528
|
}
|
|
1421
1529
|
|
|
1422
1530
|
case 'conditional': {
|
|
@@ -1903,7 +2011,8 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
|
|
|
1903
2011
|
const isMarkup = opts.markupSlotIds?.has(node.slotId) ?? false
|
|
1904
2012
|
return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped, isMarkup)}}<!--/-->`
|
|
1905
2013
|
}
|
|
1906
|
-
|
|
2014
|
+
// Bare-splice fallthrough (no `slotId`).
|
|
2015
|
+
return `\${${bareSpliceExpr(node, value)}}`
|
|
1907
2016
|
}
|
|
1908
2017
|
|
|
1909
2018
|
case 'conditional': {
|
|
@@ -2527,7 +2636,8 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
|
|
|
2527
2636
|
const isMarkup = opts.markupSlotIds?.has(node.slotId) ?? false
|
|
2528
2637
|
return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(expr, isMarkup)}}<!--/-->`
|
|
2529
2638
|
}
|
|
2530
|
-
|
|
2639
|
+
// Bare-splice fallthrough (no `slotId`).
|
|
2640
|
+
return `\${${bareSpliceExpr(node, value)}}`
|
|
2531
2641
|
}
|
|
2532
2642
|
|
|
2533
2643
|
case 'conditional': {
|