@kudzujs/core 0.8.14 → 0.8.15
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/README.md +34 -6
- package/RELEASES.md +32 -0
- package/framework/README.md +18 -2
- package/framework/build.mjs +98 -2868
- package/framework/compiler/animation-frame-pass.mjs +103 -0
- package/framework/compiler/ast-helpers.mjs +181 -0
- package/framework/compiler/browser-signal-passes.mjs +182 -0
- package/framework/compiler/custom-hook-timer-pass.mjs +126 -0
- package/framework/compiler/effect-codegen.mjs +884 -0
- package/framework/compiler/handler-codegen.mjs +296 -0
- package/framework/compiler/normalization-pipeline.mjs +9 -0
- package/framework/compiler/react-migration-pass.mjs +338 -0
- package/framework/compiler/render-control-pass.mjs +96 -0
- package/framework/compiler/router-pass.mjs +245 -0
- package/framework/compiler/worker-compiler.mjs +163 -0
- package/framework/dev-server.mjs +244 -0
- package/package.json +1 -1
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import ts from "typescript"
|
|
2
|
+
import { bindingNames, importDeclarationNames, isFunctionLike, isLocalConst, isReferenceIdentifier, isShadowedByParameter, isShadowedIdentifier, loopDeclaresName, nearestFunction, nearestFunctionLike, referenceIdentifiers, sourceNodeError, statementDeclaresName, unwrapExpression } from "./ast-helpers.mjs"
|
|
3
|
+
|
|
4
|
+
export function createReactMigrationPass({ cloneAst, isArrayFromCall, jsxTagName, renderedCollectionSource }) {
|
|
5
|
+
function normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections = new Set()) {
|
|
6
|
+
const supported = new Set(["createContext", "useContext", "useEffect", "useId", "useReducer", "useRef", "useState"])
|
|
7
|
+
const erased = new Set(["forwardRef", "memo", "useCallback", "useMemo"])
|
|
8
|
+
const aliases = new Map()
|
|
9
|
+
const reactObjects = new Set()
|
|
10
|
+
for (const statement of sourceFile.statements) {
|
|
11
|
+
if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== "react") continue
|
|
12
|
+
if (statement.importClause?.name) reactObjects.add(statement.importClause.name.text)
|
|
13
|
+
const bindings = statement.importClause?.namedBindings
|
|
14
|
+
if (bindings && ts.isNamespaceImport(bindings)) reactObjects.add(bindings.name.text)
|
|
15
|
+
if (bindings && ts.isNamedImports(bindings)) for (const entry of bindings.elements) {
|
|
16
|
+
const imported = (entry.propertyName ?? entry.name).text
|
|
17
|
+
if (!entry.isTypeOnly && (supported.has(imported) || erased.has(imported))) aliases.set(entry.name.text, imported)
|
|
18
|
+
else if (!entry.isTypeOnly && /^use[A-Z]/.test(imported)) throw sourceNodeError(entry, sourceFile, `React ${imported} is not supported by Kudzu migration input`)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (!aliases.size && !reactObjects.size) return sourceFile
|
|
22
|
+
|
|
23
|
+
const migrationCallName = call => {
|
|
24
|
+
if (ts.isIdentifier(call.expression) && aliases.has(call.expression.text) && !isShadowedIdentifier(call.expression, sourceFile)) return aliases.get(call.expression.text)
|
|
25
|
+
if (ts.isPropertyAccessExpression(call.expression) && ts.isIdentifier(call.expression.expression) && reactObjects.has(call.expression.expression.text) && !isShadowedIdentifier(call.expression.expression, sourceFile)) return call.expression.name.text
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
const ownerStateNames = owner => {
|
|
29
|
+
const names = new Set()
|
|
30
|
+
const collect = node => {
|
|
31
|
+
if (node !== owner && isFunctionLike(node)) return
|
|
32
|
+
if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ["useReducer", "useState"].includes(migrationCallName(node.initializer))) {
|
|
33
|
+
const state = node.name.elements[0]
|
|
34
|
+
if (state && ts.isBindingElement(state) && ts.isIdentifier(state.name)) names.add(state.name.text)
|
|
35
|
+
}
|
|
36
|
+
ts.forEachChild(node, collect)
|
|
37
|
+
}
|
|
38
|
+
collect(owner)
|
|
39
|
+
return names
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const validate = node => {
|
|
43
|
+
if (ts.isTypeNode(node)) return
|
|
44
|
+
if (ts.isIdentifier(node) && aliases.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile) && !(ts.isCallExpression(node.parent) && node.parent.expression === node)) throw sourceNodeError(node, sourceFile, `Aliased React ${aliases.get(node.text)} must be called directly`)
|
|
45
|
+
if (ts.isIdentifier(node) && reactObjects.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile) && !(ts.isPropertyAccessExpression(node.parent) && node.parent.expression === node)) throw sourceNodeError(node, sourceFile, "React default or namespace imports may only be used for direct supported members or React.Fragment")
|
|
46
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && reactObjects.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
|
|
47
|
+
const name = node.name.text
|
|
48
|
+
if (name !== "Fragment" && !(ts.isCallExpression(node.parent) && node.parent.expression === node && (supported.has(name) || erased.has(name)))) throw sourceNodeError(node, sourceFile, `React.${name} is not supported; use a directly supported hook call or React.Fragment`)
|
|
49
|
+
}
|
|
50
|
+
ts.forEachChild(node, validate)
|
|
51
|
+
}
|
|
52
|
+
validate(sourceFile)
|
|
53
|
+
|
|
54
|
+
const memoLocals = new Map()
|
|
55
|
+
const collectMemoLocals = node => {
|
|
56
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isCallExpression(node.initializer) && migrationCallName(node.initializer) === "useMemo") {
|
|
57
|
+
if (!isLocalConst(node)) throw sourceNodeError(node, sourceFile, "React useMemo() local values must use const declarations")
|
|
58
|
+
const callback = node.initializer.arguments[0]
|
|
59
|
+
if (callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))) {
|
|
60
|
+
const expression = lowerReactMemoCollectionExpression(reactMemoExpression(callback), factory)
|
|
61
|
+
const owner = nearestFunction(node)
|
|
62
|
+
if (owner && expression) {
|
|
63
|
+
const entries = memoLocals.get(owner) ?? new Map()
|
|
64
|
+
if (entries.has(node.name.text)) throw sourceNodeError(node.name, sourceFile, `React useMemo() local ${JSON.stringify(node.name.text)} must be unique within its component`)
|
|
65
|
+
entries.set(node.name.text, { declaration: node, expression })
|
|
66
|
+
memoLocals.set(owner, entries)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
ts.forEachChild(node, collectMemoLocals)
|
|
71
|
+
}
|
|
72
|
+
collectMemoLocals(sourceFile)
|
|
73
|
+
const memoLocalIsShadowed = (node, owner, entry) => {
|
|
74
|
+
if (isShadowedByParameter(node, owner)) return true
|
|
75
|
+
const declarationStatement = entry.declaration.parent?.parent
|
|
76
|
+
for (let current = node.parent; current && current !== owner; current = current.parent) {
|
|
77
|
+
if (ts.isFunctionExpression(current) && current.name?.text === node.text) return true
|
|
78
|
+
if (ts.isBlock(current) && current.statements.some(statement => statement !== declarationStatement && statementDeclaresName(statement, node.text))) return true
|
|
79
|
+
if (ts.isCaseBlock(current) && current.clauses.some(clause => clause.statements.some(statement => statement !== declarationStatement && statementDeclaresName(statement, node.text)))) return true
|
|
80
|
+
if (ts.isCatchClause(current) && current.variableDeclaration && bindingNames(current.variableDeclaration.name).includes(node.text)) return true
|
|
81
|
+
if ((ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current)) && loopDeclaresName(current, node.text)) return true
|
|
82
|
+
}
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
for (const [owner, entries] of memoLocals) for (const [name, entry] of entries) {
|
|
86
|
+
const visit = node => {
|
|
87
|
+
if (ts.isIdentifier(node) && node.text === name && isReferenceIdentifier(node) && nearestFunctionLike(node) !== owner && !memoLocalIsShadowed(node, owner, entry)) throw sourceNodeError(node, sourceFile, `React useMemo() local ${JSON.stringify(name)} cannot be captured by a nested function`)
|
|
88
|
+
ts.forEachChild(node, visit)
|
|
89
|
+
}
|
|
90
|
+
visit(owner.body)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const required = new Set()
|
|
94
|
+
const imported = new Set()
|
|
95
|
+
const visitor = node => {
|
|
96
|
+
if (ts.isVariableStatement(node)) {
|
|
97
|
+
const entries = memoLocals.get(nearestFunction(node))
|
|
98
|
+
if (entries) {
|
|
99
|
+
for (const declaration of node.declarationList.declarations) if (ts.isIdentifier(declaration.name) && entries.has(declaration.name.text) && declaration.initializer) ts.visitNode(declaration.initializer, visitor)
|
|
100
|
+
const declarations = node.declarationList.declarations.filter(declaration => !ts.isIdentifier(declaration.name) || !entries.has(declaration.name.text))
|
|
101
|
+
if (!declarations.length) return undefined
|
|
102
|
+
if (declarations.length !== node.declarationList.declarations.length) return factory.updateVariableStatement(node, node.modifiers, factory.updateVariableDeclarationList(node.declarationList, declarations.map(declaration => ts.visitEachChild(declaration, visitor, context))))
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (ts.isIdentifier(node) && isReferenceIdentifier(node)) {
|
|
106
|
+
const owner = nearestFunctionLike(node)
|
|
107
|
+
const entry = memoLocals.get(owner)?.get(node.text)
|
|
108
|
+
if (entry && !memoLocalIsShadowed(node, owner, entry)) return ts.visitNode(cloneAst(entry.expression, factory, context), visitor)
|
|
109
|
+
}
|
|
110
|
+
if (ts.isCallExpression(node)) {
|
|
111
|
+
const name = migrationCallName(node)
|
|
112
|
+
if (name === "forwardRef") return ts.visitNode(lowerReactForwardRef(node, sourceFile, factory), visitor)
|
|
113
|
+
if (name === "memo") {
|
|
114
|
+
if (node.arguments.length !== 1 || !(ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0]) || ts.isIdentifier(node.arguments[0]))) throw sourceNodeError(node, sourceFile, "React memo() requires exactly one function component or component identifier")
|
|
115
|
+
if (ts.isIdentifier(node.arguments[0]) && isShadowedIdentifier(node.arguments[0], sourceFile)) throw sourceNodeError(node.arguments[0], sourceFile, "React memo() component identifiers must resolve to an unshadowed same-file top-level function")
|
|
116
|
+
const component = ts.isIdentifier(node.arguments[0]) ? reactMemoComponentExpression(node.arguments[0], sourceFile, factory, context) : node.arguments[0]
|
|
117
|
+
if (!component) throw sourceNodeError(node.arguments[0], sourceFile, "React memo() identifiers must name a same-file top-level function component")
|
|
118
|
+
return ts.visitNode(component, visitor)
|
|
119
|
+
}
|
|
120
|
+
if (name === "useCallback") {
|
|
121
|
+
if (node.arguments.length !== 2 || !ts.isArrayLiteralExpression(node.arguments[1]) || !(ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0]))) throw sourceNodeError(node, sourceFile, "React useCallback() requires an inline function and a literal dependency array")
|
|
122
|
+
const dependency = node.arguments[1].elements.find(entry => !isReactCallbackDependency(entry))
|
|
123
|
+
if (dependency) throw sourceNodeError(dependency, sourceFile, "React useCallback() dependencies must be identifiers or primitive literals")
|
|
124
|
+
const dependencies = new Set(node.arguments[1].elements.map(unwrapExpression).filter(ts.isIdentifier).map(entry => entry.text))
|
|
125
|
+
const owner = nearestFunction(node)
|
|
126
|
+
const stale = owner && [...ownerStateNames(owner)].find(state => referenceIdentifiers(node.arguments[0], state).length && !dependencies.has(state))
|
|
127
|
+
if (stale) throw sourceNodeError(node.arguments[1], sourceFile, `React useCallback() must list captured state ${JSON.stringify(stale)} as a dependency`)
|
|
128
|
+
return ts.visitNode(node.arguments[0], visitor)
|
|
129
|
+
}
|
|
130
|
+
if (name === "useMemo") {
|
|
131
|
+
if (node.arguments.length !== 2 || !ts.isArrayLiteralExpression(node.arguments[1]) || !(ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0]))) throw sourceNodeError(node, sourceFile, "React useMemo() requires an inline function and a literal dependency array")
|
|
132
|
+
const callback = node.arguments[0]
|
|
133
|
+
if (callback.parameters.length || callback.asteriskToken || callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) throw sourceNodeError(callback, sourceFile, "React useMemo() callback must be synchronous and parameterless")
|
|
134
|
+
const dependency = node.arguments[1].elements.find(entry => !isReactCallbackDependency(entry))
|
|
135
|
+
if (dependency) throw sourceNodeError(dependency, sourceFile, "React useMemo() dependencies must be identifiers or primitive literals")
|
|
136
|
+
const expression = lowerReactMemoCollectionExpression(reactMemoExpression(callback), factory)
|
|
137
|
+
const dependencies = new Set(node.arguments[1].elements.map(unwrapExpression).filter(ts.isIdentifier).map(entry => entry.text))
|
|
138
|
+
const owner = nearestFunction(node)
|
|
139
|
+
const states = owner ? ownerStateNames(owner) : new Set()
|
|
140
|
+
const collection = expression && reactMemoCollection(expression, states, importedCollections, sourceFile)
|
|
141
|
+
if (!expression || !collection && !isPureReactMemoExpression(expression)) throw sourceNodeError(callback.body, sourceFile, "React useMemo() callback must return one pure expression or analyzable collection pipeline")
|
|
142
|
+
if (!collection) {
|
|
143
|
+
const unsupported = [...reactMemoReferenceNames(expression)].find(reference => !states.has(reference))
|
|
144
|
+
if (unsupported) throw sourceNodeError(expression, sourceFile, `React useMemo() pure expressions may only reference direct local state; found ${JSON.stringify(unsupported)}`)
|
|
145
|
+
}
|
|
146
|
+
const collectionDependencies = collection ? new Set([...collection.selectorStates, ...(collection.static ? [] : [collection.state.text])]) : undefined
|
|
147
|
+
const stale = collection
|
|
148
|
+
? [...collectionDependencies].find(state => !dependencies.has(state))
|
|
149
|
+
: [...states].find(state => referenceIdentifiers(expression, state).length && !dependencies.has(state))
|
|
150
|
+
if (stale) throw sourceNodeError(node.arguments[1], sourceFile, `React useMemo() must list captured state ${JSON.stringify(stale)} as a dependency`)
|
|
151
|
+
return ts.visitNode(expression, visitor)
|
|
152
|
+
}
|
|
153
|
+
if (name && supported.has(name)) {
|
|
154
|
+
required.add(name)
|
|
155
|
+
return factory.updateCallExpression(node, factory.createIdentifier(name), node.typeArguments, ts.visitNodes(node.arguments, visitor))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (ts.isImportDeclaration(node) && !node.importClause?.isTypeOnly && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "react") {
|
|
159
|
+
const clause = node.importClause
|
|
160
|
+
if (!clause) return node
|
|
161
|
+
let bindings = clause.namedBindings
|
|
162
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
163
|
+
const entries = []
|
|
164
|
+
for (const entry of bindings.elements) {
|
|
165
|
+
const name = (entry.propertyName ?? entry.name).text
|
|
166
|
+
if (entry.isTypeOnly) continue
|
|
167
|
+
if (!entry.isTypeOnly && erased.has(name)) continue
|
|
168
|
+
if (!entry.isTypeOnly && supported.has(name)) {
|
|
169
|
+
if (imported.has(name)) continue
|
|
170
|
+
imported.add(name)
|
|
171
|
+
required.add(name)
|
|
172
|
+
entries.push(factory.createImportSpecifier(false, undefined, factory.createIdentifier(name)))
|
|
173
|
+
} else {
|
|
174
|
+
entries.push(entry)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
bindings = entries.length ? factory.updateNamedImports(bindings, entries) : undefined
|
|
178
|
+
}
|
|
179
|
+
if (!clause.name && !bindings) return undefined
|
|
180
|
+
return factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(clause, clause.isTypeOnly, clause.name, bindings), node.moduleSpecifier, node.attributes)
|
|
181
|
+
}
|
|
182
|
+
return ts.visitEachChild(node, visitor, context)
|
|
183
|
+
}
|
|
184
|
+
let normalized = ts.visitNode(sourceFile, visitor)
|
|
185
|
+
const missing = [...required].filter(name => !imported.has(name)).sort()
|
|
186
|
+
if (!missing.length) return normalized
|
|
187
|
+
for (const name of missing) {
|
|
188
|
+
const collision = sourceFile.statements.some(statement => statementDeclaresName(statement, name) || ts.isImportDeclaration(statement) && importDeclarationNames(statement).includes(name) && statement.moduleSpecifier.text !== "react")
|
|
189
|
+
if (collision) throw sourceNodeError(sourceFile, sourceFile, `React.${name} cannot be normalized because ${JSON.stringify(name)} is already declared`)
|
|
190
|
+
}
|
|
191
|
+
const declaration = factory.createImportDeclaration(undefined, factory.createImportClause(false, undefined, factory.createNamedImports(missing.map(name => factory.createImportSpecifier(false, undefined, factory.createIdentifier(name))))), factory.createStringLiteral("react"))
|
|
192
|
+
const statements = [...normalized.statements]
|
|
193
|
+
const lastImport = statements.findLastIndex(statement => ts.isImportDeclaration(statement))
|
|
194
|
+
statements.splice(lastImport + 1, 0, declaration)
|
|
195
|
+
normalized = factory.updateSourceFile(normalized, statements)
|
|
196
|
+
return normalized
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function lowerReactForwardRef(call, sourceFile, factory) {
|
|
200
|
+
const declaration = call.parent
|
|
201
|
+
const statement = declaration?.parent?.parent
|
|
202
|
+
if (!ts.isVariableDeclaration(declaration) || declaration.initializer !== call || !ts.isIdentifier(declaration.name) || !statement || !ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || statement.parent !== sourceFile) {
|
|
203
|
+
throw sourceNodeError(call, sourceFile, "React forwardRef() must directly initialize one top-level const component")
|
|
204
|
+
}
|
|
205
|
+
if (call.arguments.length !== 1 || !ts.isArrowFunction(call.arguments[0]) && !ts.isFunctionExpression(call.arguments[0])) throw sourceNodeError(call, sourceFile, "React forwardRef() requires exactly one inline render function")
|
|
206
|
+
const callback = call.arguments[0]
|
|
207
|
+
if (callback.asteriskToken || callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) throw sourceNodeError(callback, sourceFile, "React forwardRef() render function must be synchronous and cannot be a generator")
|
|
208
|
+
if (callback.parameters.length !== 2) throw sourceNodeError(callback, sourceFile, "React forwardRef() render function must declare exactly (props, ref)")
|
|
209
|
+
const [props, ref] = callback.parameters
|
|
210
|
+
if (props.dotDotDotToken || props.initializer || !ts.isIdentifier(props.name) && !ts.isObjectBindingPattern(props.name)) throw sourceNodeError(props, sourceFile, "React forwardRef() props must use one identifier or a flat object binding")
|
|
211
|
+
if (ref.dotDotDotToken || ref.initializer || !ts.isIdentifier(ref.name)) throw sourceNodeError(ref, sourceFile, "React forwardRef() ref parameter must be one identifier")
|
|
212
|
+
|
|
213
|
+
let elements
|
|
214
|
+
if (ts.isIdentifier(props.name)) {
|
|
215
|
+
elements = [
|
|
216
|
+
factory.createBindingElement(undefined, factory.createIdentifier("ref"), factory.createIdentifier(ref.name.text)),
|
|
217
|
+
factory.createBindingElement(factory.createToken(ts.SyntaxKind.DotDotDotToken), undefined, factory.createIdentifier(props.name.text))
|
|
218
|
+
]
|
|
219
|
+
} else {
|
|
220
|
+
for (const element of props.name.elements) {
|
|
221
|
+
const property = (element.propertyName ?? element.name)
|
|
222
|
+
if (!ts.isIdentifier(element.name) || property.text === "ref") throw sourceNodeError(element, sourceFile, property.text === "ref" ? "React forwardRef() props must not declare ref; Kudzu supplies ref through the second parameter" : "React forwardRef() props must use one identifier or a flat object binding")
|
|
223
|
+
}
|
|
224
|
+
const rest = props.name.elements.findIndex(element => Boolean(element.dotDotDotToken))
|
|
225
|
+
elements = [...props.name.elements]
|
|
226
|
+
elements.splice(rest < 0 ? elements.length : rest, 0, factory.createBindingElement(undefined, factory.createIdentifier("ref"), factory.createIdentifier(ref.name.text)))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const last = ts.isBlock(callback.body) ? callback.body.statements.at(-1) : undefined
|
|
230
|
+
let returnCount = 0
|
|
231
|
+
const countReturns = node => {
|
|
232
|
+
if (node !== callback.body && isFunctionLike(node)) return
|
|
233
|
+
if (ts.isReturnStatement(node)) returnCount++
|
|
234
|
+
ts.forEachChild(node, countReturns)
|
|
235
|
+
}
|
|
236
|
+
countReturns(callback.body)
|
|
237
|
+
const returned = ts.isBlock(callback.body)
|
|
238
|
+
? last && ts.isReturnStatement(last) ? last.expression : undefined
|
|
239
|
+
: callback.body
|
|
240
|
+
const root = returned && unwrapExpression(returned)
|
|
241
|
+
const tag = root && jsxTagName(root)
|
|
242
|
+
if ((ts.isBlock(callback.body) && returnCount !== 1) || !root || !ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root) || !ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) throw sourceNodeError(callback.body, sourceFile, "React forwardRef() render function must directly return one intrinsic JSX element")
|
|
243
|
+
const attributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
|
|
244
|
+
const forwarded = attributes.properties.filter(attribute => ts.isJsxAttribute(attribute) && attribute.name.text === "ref" && ts.isJsxExpression(attribute.initializer) && ts.isIdentifier(attribute.initializer.expression) && attribute.initializer.expression.text === ref.name.text)
|
|
245
|
+
if (forwarded.length !== 1 || referenceIdentifiers(callback.body, ref.name.text).length !== 1) throw sourceNodeError(ref, sourceFile, "React forwardRef() ref must be forwarded exactly once as ref={ref} on the direct intrinsic root")
|
|
246
|
+
|
|
247
|
+
const parameter = factory.updateParameterDeclaration(props, props.modifiers, undefined, factory.createObjectBindingPattern(elements), props.questionToken, props.type, undefined)
|
|
248
|
+
return ts.isArrowFunction(callback)
|
|
249
|
+
? factory.updateArrowFunction(callback, callback.modifiers, callback.typeParameters, [parameter], callback.type, callback.equalsGreaterThanToken, callback.body)
|
|
250
|
+
: factory.updateFunctionExpression(callback, callback.modifiers, undefined, callback.name, callback.typeParameters, [parameter], callback.type, callback.body)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function validateUseIdSyntax(sourceFile) {
|
|
254
|
+
const imported = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && ["@kudzujs/core", "react"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useId"))
|
|
255
|
+
if (!imported) return
|
|
256
|
+
const visit = node => {
|
|
257
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useId" && !isShadowedIdentifier(node.expression, sourceFile)) {
|
|
258
|
+
if (node.arguments.length) throw sourceNodeError(node, sourceFile, "useId() does not accept arguments")
|
|
259
|
+
const declaration = node.parent
|
|
260
|
+
const statement = declaration?.parent?.parent
|
|
261
|
+
const owner = nearestFunction(node)
|
|
262
|
+
if (!ts.isVariableDeclaration(declaration) || declaration.initializer !== node || !ts.isIdentifier(declaration.name) || !statement || !ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || !owner || !ts.isBlock(owner.body) || statement.parent !== owner.body) {
|
|
263
|
+
throw sourceNodeError(node, sourceFile, "useId() must be assigned to one top-level const identifier in a component")
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
ts.forEachChild(node, visit)
|
|
267
|
+
}
|
|
268
|
+
visit(sourceFile)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function isReactCallbackDependency(node) {
|
|
272
|
+
node = unwrapExpression(node)
|
|
273
|
+
return ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node) || node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword || node.kind === ts.SyntaxKind.NullKeyword
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function lowerReactMemoCollectionExpression(expression, factory) {
|
|
277
|
+
if (!expression) return undefined
|
|
278
|
+
const visit = node => {
|
|
279
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && node.arguments.length === 1) {
|
|
280
|
+
return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("Array"), "from"), undefined, [visit(node.expression.expression), node.arguments[0]])
|
|
281
|
+
}
|
|
282
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ["filter", "flatMap", "slice", "toSorted"].includes(node.expression.name.text)) {
|
|
283
|
+
return factory.updateCallExpression(node, factory.updatePropertyAccessExpression(node.expression, visit(node.expression.expression), node.expression.name), node.typeArguments, node.arguments)
|
|
284
|
+
}
|
|
285
|
+
if (isArrayFromCall(node)) return factory.updateCallExpression(node, node.expression, node.typeArguments, [visit(node.arguments[0]), ...node.arguments.slice(1)])
|
|
286
|
+
return node
|
|
287
|
+
}
|
|
288
|
+
return visit(expression)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function reactMemoCollection(expression, states, importedCollections, sourceFile) {
|
|
292
|
+
const setters = new Map([...states].map(state => [state, state]))
|
|
293
|
+
const fail = (node, message) => { throw sourceNodeError(node, sourceFile, message) }
|
|
294
|
+
return renderedCollectionSource(expression, setters, undefined, fail, new Set(), importedCollections, states)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function reactMemoComponentExpression(identifier, sourceFile, factory, context) {
|
|
298
|
+
for (const statement of sourceFile.statements) {
|
|
299
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === identifier.text && statement.body) {
|
|
300
|
+
const clone = cloneAst(statement, factory, context)
|
|
301
|
+
return factory.createFunctionExpression(clone.modifiers?.filter(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword), clone.asteriskToken, clone.name, clone.typeParameters, clone.parameters, clone.type, clone.body)
|
|
302
|
+
}
|
|
303
|
+
if (!ts.isVariableStatement(statement)) continue
|
|
304
|
+
const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === identifier.text)
|
|
305
|
+
if (declaration?.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return cloneAst(declaration.initializer, factory, context)
|
|
306
|
+
}
|
|
307
|
+
return undefined
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isPureReactMemoExpression(node) {
|
|
311
|
+
node = unwrapExpression(node)
|
|
312
|
+
if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword || node.kind === ts.SyntaxKind.NullKeyword) return true
|
|
313
|
+
if (ts.isParenthesizedExpression(node)) return isPureReactMemoExpression(node.expression)
|
|
314
|
+
if (ts.isPrefixUnaryExpression(node)) return ![ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(node.operator) && isPureReactMemoExpression(node.operand)
|
|
315
|
+
if (ts.isBinaryExpression(node)) return node.operatorToken.kind < ts.SyntaxKind.FirstAssignment && isPureReactMemoExpression(node.left) && isPureReactMemoExpression(node.right)
|
|
316
|
+
if (ts.isConditionalExpression(node)) return isPureReactMemoExpression(node.condition) && isPureReactMemoExpression(node.whenTrue) && isPureReactMemoExpression(node.whenFalse)
|
|
317
|
+
if (ts.isTemplateExpression(node)) return node.templateSpans.every(span => isPureReactMemoExpression(span.expression))
|
|
318
|
+
return false
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function reactMemoReferenceNames(root) {
|
|
322
|
+
const names = new Set()
|
|
323
|
+
const visit = node => {
|
|
324
|
+
if (ts.isIdentifier(node) && isReferenceIdentifier(node)) names.add(node.text)
|
|
325
|
+
ts.forEachChild(node, visit)
|
|
326
|
+
}
|
|
327
|
+
visit(root)
|
|
328
|
+
return names
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { normalizeReactMigrationSyntax, validateUseIdSyntax }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function reactMemoExpression(callback) {
|
|
335
|
+
if (!ts.isBlock(callback.body)) return callback.body
|
|
336
|
+
if (callback.body.statements.length !== 1 || !ts.isReturnStatement(callback.body.statements[0])) return undefined
|
|
337
|
+
return callback.body.statements[0].expression
|
|
338
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import ts from "typescript"
|
|
2
|
+
import { isFunctionLike } from "./ast-helpers.mjs"
|
|
3
|
+
|
|
4
|
+
export function normalizeRenderControlFlow(sourceFile, factory, context) {
|
|
5
|
+
const normalizeStatements = statements => {
|
|
6
|
+
const nested = statements.map(statement => ts.visitEachChild(statement, visitNested, context))
|
|
7
|
+
const assigned = []
|
|
8
|
+
for (let index = 0; index < nested.length; index++) {
|
|
9
|
+
const statement = nested[index]
|
|
10
|
+
const next = nested[index + 1]
|
|
11
|
+
const declaration = singleUninitializedLet(statement)
|
|
12
|
+
const assignment = declaration && next && assignmentConditional(next, declaration.name.text, factory)
|
|
13
|
+
if (declaration && assignment) {
|
|
14
|
+
const updated = factory.updateVariableDeclaration(declaration, declaration.name, declaration.exclamationToken, declaration.type, assignment)
|
|
15
|
+
const list = factory.createVariableDeclarationList([updated], ts.NodeFlags.Const)
|
|
16
|
+
assigned.push(factory.updateVariableStatement(statement, statement.modifiers, list))
|
|
17
|
+
index++
|
|
18
|
+
} else {
|
|
19
|
+
assigned.push(statement)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!assigned.length) return assigned
|
|
24
|
+
const finalIf = returnConditional(assigned.at(-1), factory)
|
|
25
|
+
if (finalIf) return [...assigned.slice(0, -1), factory.createReturnStatement(finalIf)]
|
|
26
|
+
if (!ts.isReturnStatement(assigned.at(-1)) || !assigned.at(-1).expression) return assigned
|
|
27
|
+
let expression = assigned.at(-1).expression
|
|
28
|
+
let start = assigned.length - 1
|
|
29
|
+
while (start > 0) {
|
|
30
|
+
const previous = assigned[start - 1]
|
|
31
|
+
if (!ts.isIfStatement(previous) || previous.elseStatement) break
|
|
32
|
+
const truthy = returnOnlyExpression(previous.thenStatement)
|
|
33
|
+
if (!truthy) break
|
|
34
|
+
expression = factory.createConditionalExpression(previous.expression, factory.createToken(ts.SyntaxKind.QuestionToken), truthy, factory.createToken(ts.SyntaxKind.ColonToken), expression)
|
|
35
|
+
start--
|
|
36
|
+
}
|
|
37
|
+
return start === assigned.length - 1 ? assigned : [...assigned.slice(0, start), factory.createReturnStatement(expression)]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const visitNested = node => {
|
|
41
|
+
if (ts.isBlock(node)) return factory.updateBlock(node, normalizeStatements([...node.statements]))
|
|
42
|
+
if (isFunctionLike(node) && ts.isBlock(node.body)) {
|
|
43
|
+
if (!isRenderFunction(node)) return node
|
|
44
|
+
const body = factory.updateBlock(node.body, normalizeStatements([...node.body.statements]))
|
|
45
|
+
if (ts.isFunctionDeclaration(node)) return factory.updateFunctionDeclaration(node, node.modifiers, node.asteriskToken, node.name, node.typeParameters, node.parameters, node.type, body)
|
|
46
|
+
if (ts.isFunctionExpression(node)) return factory.updateFunctionExpression(node, node.modifiers, node.asteriskToken, node.name, node.typeParameters, node.parameters, node.type, body)
|
|
47
|
+
if (ts.isArrowFunction(node)) return factory.updateArrowFunction(node, node.modifiers, node.typeParameters, node.parameters, node.type, node.equalsGreaterThanToken, body)
|
|
48
|
+
}
|
|
49
|
+
return ts.visitEachChild(node, visitNested, context)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return ts.visitEachChild(sourceFile, visitNested, context)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isRenderFunction(node) {
|
|
56
|
+
if (ts.isFunctionDeclaration(node)) return node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword) || Boolean(node.name && /^[A-Z]/.test(node.name.text))
|
|
57
|
+
const declaration = node.parent
|
|
58
|
+
return ts.isVariableDeclaration(declaration) && ts.isIdentifier(declaration.name) && /^[A-Z]/.test(declaration.name.text)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function singleUninitializedLet(statement) {
|
|
62
|
+
if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Let) === 0 || statement.declarationList.declarations.length !== 1) return undefined
|
|
63
|
+
const declaration = statement.declarationList.declarations[0]
|
|
64
|
+
return ts.isIdentifier(declaration.name) && !declaration.initializer ? declaration : undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function assignmentConditional(statement, name, factory) {
|
|
68
|
+
if (!ts.isIfStatement(statement) || !statement.elseStatement) return undefined
|
|
69
|
+
const truthy = assignmentOnlyExpression(statement.thenStatement, name)
|
|
70
|
+
const falsy = ts.isIfStatement(statement.elseStatement)
|
|
71
|
+
? assignmentConditional(statement.elseStatement, name, factory)
|
|
72
|
+
: assignmentOnlyExpression(statement.elseStatement, name)
|
|
73
|
+
if (!truthy || !falsy) return undefined
|
|
74
|
+
return factory.createConditionalExpression(statement.expression, factory.createToken(ts.SyntaxKind.QuestionToken), truthy, factory.createToken(ts.SyntaxKind.ColonToken), falsy)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assignmentOnlyExpression(statement, name) {
|
|
78
|
+
const candidate = ts.isBlock(statement) && statement.statements.length === 1 ? statement.statements[0] : statement
|
|
79
|
+
if (!ts.isExpressionStatement(candidate) || !ts.isBinaryExpression(candidate.expression) || candidate.expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken || !ts.isIdentifier(candidate.expression.left) || candidate.expression.left.text !== name) return undefined
|
|
80
|
+
return candidate.expression.right
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function returnConditional(statement, factory) {
|
|
84
|
+
if (!ts.isIfStatement(statement) || !statement.elseStatement) return undefined
|
|
85
|
+
const truthy = returnOnlyExpression(statement.thenStatement)
|
|
86
|
+
const falsy = ts.isIfStatement(statement.elseStatement)
|
|
87
|
+
? returnConditional(statement.elseStatement, factory)
|
|
88
|
+
: returnOnlyExpression(statement.elseStatement)
|
|
89
|
+
if (!truthy || !falsy) return undefined
|
|
90
|
+
return factory.createConditionalExpression(statement.expression, factory.createToken(ts.SyntaxKind.QuestionToken), truthy, factory.createToken(ts.SyntaxKind.ColonToken), falsy)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function returnOnlyExpression(statement) {
|
|
94
|
+
const candidate = ts.isBlock(statement) && statement.statements.length === 1 ? statement.statements[0] : statement
|
|
95
|
+
return ts.isReturnStatement(candidate) && candidate.expression ? candidate.expression : undefined
|
|
96
|
+
}
|