@kudzujs/core 0.8.13 → 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 +62 -0
- package/framework/README.md +22 -2
- package/framework/build.mjs +123 -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,103 @@
|
|
|
1
|
+
import ts from "typescript"
|
|
2
|
+
import { effectReturns, importDeclarationNames, isShadowedIdentifier, nearestFunction, referenceIdentifiers, sourceNodeError, statementDeclaresName, unwrapExpression } from "./ast-helpers.mjs"
|
|
3
|
+
|
|
4
|
+
export function normalizeEffectAnimationFrameRefs(sourceFile, factory, context) {
|
|
5
|
+
const frameCall = (node, name) => ts.isCallExpression(node) && (
|
|
6
|
+
ts.isIdentifier(node.expression) && node.expression.text === name ||
|
|
7
|
+
ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === "window" && node.expression.name.text === name
|
|
8
|
+
)
|
|
9
|
+
const unshadowedFrameCall = (node, owner) => {
|
|
10
|
+
const name = ts.isIdentifier(node.expression) ? node.expression : ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) ? node.expression.expression : undefined
|
|
11
|
+
return name && !isShadowedIdentifier(name, owner) && !sourceFile.statements.some(statement => statementDeclaresName(statement, name.text) || ts.isImportDeclaration(statement) && importDeclarationNames(statement).includes(name.text))
|
|
12
|
+
}
|
|
13
|
+
const currentAccess = (node, name) => ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === name && node.name.text === "current"
|
|
14
|
+
const inside = (node, root) => {
|
|
15
|
+
for (let current = node; current; current = current.parent) if (current === root) return true
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
const directOrGuarded = (statement, body, name, negated) => {
|
|
19
|
+
if (statement.parent === body) return true
|
|
20
|
+
let branch = statement
|
|
21
|
+
if (ts.isBlock(statement.parent) && statement.parent.statements.length === 1) branch = statement.parent
|
|
22
|
+
const conditional = branch.parent
|
|
23
|
+
if (!ts.isIfStatement(conditional) || conditional.thenStatement !== branch || conditional.parent !== body || conditional.elseStatement) return false
|
|
24
|
+
let condition = unwrapExpression(conditional.expression)
|
|
25
|
+
const isNegated = ts.isPrefixUnaryExpression(condition) && condition.operator === ts.SyntaxKind.ExclamationToken
|
|
26
|
+
if (isNegated) condition = unwrapExpression(condition.operand)
|
|
27
|
+
return isNegated === negated && currentAccess(condition, name)
|
|
28
|
+
}
|
|
29
|
+
const hasUseRefImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && !statement.importClause?.isTypeOnly && ts.isStringLiteral(statement.moduleSpecifier) && ["react", "@kudzujs/core"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useRef"))
|
|
30
|
+
const hasUseEffectImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && !statement.importClause?.isTypeOnly && ts.isStringLiteral(statement.moduleSpecifier) && ["react", "@kudzujs/core"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useEffect"))
|
|
31
|
+
const replacements = new Set()
|
|
32
|
+
const inspect = node => {
|
|
33
|
+
if (!hasUseRefImport || !ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name) || !node.initializer || !ts.isCallExpression(node.initializer) || !ts.isIdentifier(node.initializer.expression) || node.initializer.expression.text !== "useRef" || isShadowedIdentifier(node.initializer.expression, sourceFile) || node.initializer.arguments.length !== 1 || !ts.isNumericLiteral(node.initializer.arguments[0]) || Number(node.initializer.arguments[0].text) !== 0) {
|
|
34
|
+
ts.forEachChild(node, inspect)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
const owner = nearestFunction(node)
|
|
38
|
+
if (!owner?.body || !ts.isBlock(owner.body)) return
|
|
39
|
+
const accesses = []
|
|
40
|
+
const collect = current => {
|
|
41
|
+
if (currentAccess(current, node.name.text) && !isShadowedIdentifier(current.expression, owner.body)) accesses.push(current)
|
|
42
|
+
ts.forEachChild(current, collect)
|
|
43
|
+
}
|
|
44
|
+
collect(owner.body)
|
|
45
|
+
const frameAssignments = accesses.filter(access => ts.isBinaryExpression(access.parent) && unwrapExpression(access.parent.left) === access && access.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && frameCall(unwrapExpression(access.parent.right), "requestAnimationFrame") && unshadowedFrameCall(unwrapExpression(access.parent.right), owner))
|
|
46
|
+
if (!frameAssignments.length) return
|
|
47
|
+
const invalidReference = referenceIdentifiers(owner.body, node.name.text).find(reference => !ts.isPropertyAccessExpression(reference.parent) || reference.parent.expression !== reference || reference.parent.name.text !== "current")
|
|
48
|
+
if (invalidReference) throw sourceNodeError(invalidReference, sourceFile, "Animation frame refs may only use direct .current reads and assignments")
|
|
49
|
+
const statement = node.parent?.parent
|
|
50
|
+
const topLevelOwner = owner.parent === sourceFile || ts.isVariableDeclaration(owner.parent) && owner.parent.parent?.parent?.parent === sourceFile
|
|
51
|
+
if (!topLevelOwner || !ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const) || statement.declarationList.declarations.length !== 1 || statement.parent !== owner.body) throw sourceNodeError(node, sourceFile, "Animation frame refs must be one top-level component const")
|
|
52
|
+
if (frameAssignments.length !== 1) throw sourceNodeError(node, sourceFile, "Animation frame refs require one direct ref.current = requestAnimationFrame(callback) assignment")
|
|
53
|
+
const frame = unwrapExpression(frameAssignments[0].parent.right)
|
|
54
|
+
const frameCallback = frame.arguments.length === 1 && ts.isIdentifier(unwrapExpression(frame.arguments[0])) ? unwrapExpression(frame.arguments[0]) : undefined
|
|
55
|
+
const effectCalls = owner.body.statements.flatMap(statement => hasUseEffectImport && ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression) && ts.isIdentifier(statement.expression.expression) && statement.expression.expression.text === "useEffect" && !isShadowedIdentifier(statement.expression.expression, sourceFile) ? [statement.expression] : [])
|
|
56
|
+
const effects = effectCalls.filter(effect => effect.arguments[0] && inside(frameAssignments[0], effect.arguments[0]))
|
|
57
|
+
if (effects.length !== 1) throw sourceNodeError(node, sourceFile, "Animation frame refs must belong to one inline component effect")
|
|
58
|
+
const effect = effects[0]
|
|
59
|
+
const callback = effect.arguments[0]
|
|
60
|
+
if (!(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) || !ts.isBlock(callback.body)) throw sourceNodeError(callback, sourceFile, "Animation frame refs require one inline block-bodied effect")
|
|
61
|
+
if (accesses.some(access => !inside(access, callback))) throw sourceNodeError(node, sourceFile, "Animation frame refs may only be used inside their owning effect")
|
|
62
|
+
const callbacks = new Map()
|
|
63
|
+
for (const statement of callback.body.statements) {
|
|
64
|
+
if (ts.isVariableStatement(statement)) for (const declaration of statement.declarationList.declarations) {
|
|
65
|
+
if (ts.isIdentifier(declaration.name) && declaration.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) callbacks.set(declaration.name.text, declaration.initializer)
|
|
66
|
+
}
|
|
67
|
+
if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) callbacks.set(statement.name.text, statement)
|
|
68
|
+
}
|
|
69
|
+
const frameOwner = nearestFunction(frameAssignments[0])
|
|
70
|
+
const shadowedCallback = frameCallback && isShadowedIdentifier(frameCallback, callback.body)
|
|
71
|
+
const update = frameCallback && !shadowedCallback && callbacks.get(frameCallback.text)
|
|
72
|
+
if (!update) throw sourceNodeError(frame, sourceFile, "Animation frame refs require a direct local callback")
|
|
73
|
+
const frameStatement = frameAssignments[0].parent.parent
|
|
74
|
+
if (!frameOwner || ![...callbacks.values()].includes(frameOwner) || !ts.isBlock(frameOwner.body) || !ts.isExpressionStatement(frameStatement) || !directOrGuarded(frameStatement, frameOwner.body, node.name.text, true)) throw sourceNodeError(frameAssignments[0], sourceFile, "Animation frame requests must be assigned directly inside one local scheduler")
|
|
75
|
+
const resetAssignments = accesses.filter(access => ts.isBinaryExpression(access.parent) && unwrapExpression(access.parent.left) === access && access.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isNumericLiteral(unwrapExpression(access.parent.right)) && Number(unwrapExpression(access.parent.right).text) === 0)
|
|
76
|
+
const resetStatement = resetAssignments[0]?.parent.parent
|
|
77
|
+
if (resetAssignments.length !== 1 || nearestFunction(resetAssignments[0]) !== update || !ts.isBlock(update.body) || !ts.isExpressionStatement(resetStatement) || resetStatement.parent !== update.body) throw sourceNodeError(update, sourceFile, "Animation frame callbacks must directly reset their ref to 0")
|
|
78
|
+
const writes = accesses.filter(access => ts.isBinaryExpression(access.parent) && unwrapExpression(access.parent.left) === access && access.parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && access.parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment)
|
|
79
|
+
if (writes.length !== 2) throw sourceNodeError(node, sourceFile, "Animation frame refs may only be assigned by their request and reset operations")
|
|
80
|
+
const returns = effectReturns(callback)
|
|
81
|
+
const cancellations = []
|
|
82
|
+
const collectCancellations = current => {
|
|
83
|
+
const argument = current.arguments?.[0] && unwrapExpression(current.arguments[0])
|
|
84
|
+
if (frameCall(current, "cancelAnimationFrame") && unshadowedFrameCall(current, owner) && current.arguments.length === 1 && currentAccess(argument, node.name.text) && !isShadowedIdentifier(argument.expression, owner.body)) cancellations.push(current)
|
|
85
|
+
ts.forEachChild(current, collectCancellations)
|
|
86
|
+
}
|
|
87
|
+
collectCancellations(callback.body)
|
|
88
|
+
const cancellation = cancellations[0]
|
|
89
|
+
const cleanup = cancellation && returns.cleanups.find(candidate => nearestFunction(cancellation) === candidate)
|
|
90
|
+
const cancellationStatement = cancellation?.parent
|
|
91
|
+
if (cancellations.length !== 1 || !cleanup || !ts.isBlock(cleanup.body) || !ts.isExpressionStatement(cancellationStatement) || !directOrGuarded(cancellationStatement, cleanup.body, node.name.text, false)) throw sourceNodeError(node, sourceFile, "Animation frame refs require direct cancellation in effect cleanup")
|
|
92
|
+
replacements.add(node)
|
|
93
|
+
}
|
|
94
|
+
inspect(sourceFile)
|
|
95
|
+
if (!replacements.size) return sourceFile
|
|
96
|
+
const visitor = node => {
|
|
97
|
+
if (replacements.has(node)) return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, factory.createObjectLiteralExpression([
|
|
98
|
+
factory.createPropertyAssignment("current", factory.createNumericLiteral(0))
|
|
99
|
+
]))
|
|
100
|
+
return ts.visitEachChild(node, visitor, context)
|
|
101
|
+
}
|
|
102
|
+
return ts.visitNode(sourceFile, visitor)
|
|
103
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import ts from "typescript"
|
|
2
|
+
|
|
3
|
+
export function importDeclarationNames(statement) {
|
|
4
|
+
const names = []
|
|
5
|
+
if (statement.importClause?.name) names.push(statement.importClause.name.text)
|
|
6
|
+
const bindings = statement.importClause?.namedBindings
|
|
7
|
+
if (bindings && ts.isNamespaceImport(bindings)) names.push(bindings.name.text)
|
|
8
|
+
if (bindings && ts.isNamedImports(bindings)) for (const entry of bindings.elements) names.push(entry.name.text)
|
|
9
|
+
return names
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function referenceIdentifiers(root, name) {
|
|
13
|
+
const references = []
|
|
14
|
+
const visit = node => {
|
|
15
|
+
if (ts.isIdentifier(node) && node.text === name && isReferenceIdentifier(node) && !isShadowedIdentifier(node, root)) references.push(node)
|
|
16
|
+
ts.forEachChild(node, visit)
|
|
17
|
+
}
|
|
18
|
+
visit(root)
|
|
19
|
+
return references
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isFunctionLike(node) {
|
|
23
|
+
return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node) || ts.isConstructorDeclaration(node)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function containsJsx(root) {
|
|
27
|
+
let found = false
|
|
28
|
+
const visit = node => {
|
|
29
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) found = true
|
|
30
|
+
if (!found) ts.forEachChild(node, visit)
|
|
31
|
+
}
|
|
32
|
+
visit(root)
|
|
33
|
+
return found
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function referencesIdentifier(root, name) {
|
|
37
|
+
let found = false
|
|
38
|
+
const visit = node => {
|
|
39
|
+
if (ts.isIdentifier(node) && node.text === name && isReferenceIdentifier(node)) found = true
|
|
40
|
+
if (!found) ts.forEachChild(node, visit)
|
|
41
|
+
}
|
|
42
|
+
visit(root)
|
|
43
|
+
return found
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function unwrapExpression(node) {
|
|
47
|
+
return ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node) ? unwrapExpression(node.expression) : node
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isLocalConst(node) {
|
|
51
|
+
const list = node.parent
|
|
52
|
+
const statement = list?.parent
|
|
53
|
+
return ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 && ts.isVariableStatement(statement)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isUnshadowedGlobal(identifier, sourceFile) {
|
|
57
|
+
if (isShadowedIdentifier(identifier, sourceFile)) return false
|
|
58
|
+
return !sourceFile.statements.some(statement => {
|
|
59
|
+
if (statementDeclaresName(statement, identifier.text)) return true
|
|
60
|
+
if (!ts.isImportDeclaration(statement) || !statement.importClause) return false
|
|
61
|
+
const clause = statement.importClause
|
|
62
|
+
if (clause.name?.text === identifier.text) return true
|
|
63
|
+
if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) return clause.namedBindings.name.text === identifier.text
|
|
64
|
+
return clause.namedBindings && ts.isNamedImports(clause.namedBindings) ? clause.namedBindings.elements.some(entry => entry.name.text === identifier.text) : false
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function bindingNames(name) {
|
|
69
|
+
if (ts.isIdentifier(name)) return [name.text]
|
|
70
|
+
return name.elements.flatMap(element => ts.isBindingElement(element) ? bindingNames(element.name) : [])
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isReferenceIdentifier(node) {
|
|
74
|
+
const parent = node.parent
|
|
75
|
+
if (!parent) return true
|
|
76
|
+
if ((ts.isPropertyAccessExpression(parent) && parent.name === node) ||
|
|
77
|
+
(ts.isPropertyAssignment(parent) && parent.name === node) ||
|
|
78
|
+
(ts.isMethodDeclaration(parent) && parent.name === node) ||
|
|
79
|
+
((ts.isGetAccessorDeclaration(parent) || ts.isSetAccessorDeclaration(parent)) && parent.name === node) ||
|
|
80
|
+
(ts.isVariableDeclaration(parent) && parent.name === node) ||
|
|
81
|
+
(ts.isParameter(parent) && parent.name === node) ||
|
|
82
|
+
(ts.isFunctionDeclaration(parent) && parent.name === node) ||
|
|
83
|
+
(ts.isJsxAttribute(parent) && parent.name === node) ||
|
|
84
|
+
(ts.isBindingElement(parent) && (parent.name === node || parent.propertyName === node)) ||
|
|
85
|
+
ts.isImportSpecifier(parent) || ts.isImportClause(parent)) return false
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function nearestFunction(node) {
|
|
90
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
91
|
+
if (ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current) || ts.isArrowFunction(current)) return current
|
|
92
|
+
}
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function nearestFunctionLike(node) {
|
|
97
|
+
for (let current = node.parent; current; current = current.parent) if (isFunctionLike(current)) return current
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isShadowedByParameter(node, scopeRoot) {
|
|
102
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
103
|
+
if (isFunctionLike(current) && current.parameters.some(parameter => bindingNames(parameter.name).includes(node.text))) return true
|
|
104
|
+
if (current === scopeRoot) break
|
|
105
|
+
}
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function isShadowedIdentifier(node, scopeRoot) {
|
|
110
|
+
if (isShadowedByParameter(node, scopeRoot)) return true
|
|
111
|
+
if (node === scopeRoot) return false
|
|
112
|
+
if (isFunctionLike(scopeRoot) && scopeRoot.name?.text === node.text) return true
|
|
113
|
+
if (isFunctionLike(scopeRoot) && functionVarDeclaresName(scopeRoot, node.text)) return true
|
|
114
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
115
|
+
if (current === scopeRoot) break
|
|
116
|
+
if (ts.isFunctionExpression(current) && current.name?.text === node.text) return true
|
|
117
|
+
if (ts.isBlock(current) && current.statements.some(statement => statementDeclaresName(statement, node.text))) return true
|
|
118
|
+
if (ts.isCaseBlock(current) && current.clauses.some(clause => clause.statements.some(statement => statementDeclaresName(statement, node.text)))) return true
|
|
119
|
+
if (ts.isCatchClause(current) && current.variableDeclaration && bindingNames(current.variableDeclaration.name).includes(node.text)) return true
|
|
120
|
+
if ((ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current)) && loopDeclaresName(current, node.text)) return true
|
|
121
|
+
if (isFunctionLike(current) && functionVarDeclaresName(current, node.text)) return true
|
|
122
|
+
}
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function statementDeclaresName(statement, name) {
|
|
127
|
+
if (ts.isVariableStatement(statement)) return statement.declarationList.declarations.some(declaration => bindingNames(declaration.name).includes(name))
|
|
128
|
+
if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isImportEqualsDeclaration(statement)) return statement.name?.text === name
|
|
129
|
+
if ((ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement)) && !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DeclareKeyword)) return ts.isIdentifier(statement.name) && statement.name.text === name
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function loopDeclaresName(loop, name) {
|
|
134
|
+
const declaration = ts.isForStatement(loop) ? loop.initializer : loop.initializer
|
|
135
|
+
return declaration && ts.isVariableDeclarationList(declaration) && declaration.declarations.some(entry => bindingNames(entry.name).includes(name))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function functionVarDeclaresName(fn, name) {
|
|
139
|
+
let found = false
|
|
140
|
+
const visit = node => {
|
|
141
|
+
if (found || node !== fn.body && isFunctionLike(node)) return
|
|
142
|
+
if (ts.isVariableDeclarationList(node) && (node.flags & ts.NodeFlags.BlockScoped) === 0 && node.declarations.some(entry => bindingNames(entry.name).includes(name))) found = true
|
|
143
|
+
if (!found) ts.forEachChild(node, visit)
|
|
144
|
+
}
|
|
145
|
+
if (fn.body) visit(fn.body)
|
|
146
|
+
return found
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function sourceNodeError(node, fallbackSource, message) {
|
|
150
|
+
const original = ts.getOriginalNode(node)
|
|
151
|
+
const sourceFile = original.getSourceFile?.()?.fileName ? original.getSourceFile() : fallbackSource
|
|
152
|
+
const position = sourceFile.getLineAndCharacterOfPosition(original.getStart(sourceFile))
|
|
153
|
+
return new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function sourceLocation(node, fallbackSource) {
|
|
157
|
+
const original = ts.getOriginalNode(node)
|
|
158
|
+
const sourceFile = original.getSourceFile?.()?.fileName ? original.getSourceFile() : fallbackSource
|
|
159
|
+
const position = sourceFile.getLineAndCharacterOfPosition(original.getStart(sourceFile))
|
|
160
|
+
return `${sourceFile.fileName}:${position.line + 1}:${position.character + 1}`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function effectReturns(callback) {
|
|
164
|
+
let cleanup = false
|
|
165
|
+
let invalid
|
|
166
|
+
const cleanups = []
|
|
167
|
+
const visit = node => {
|
|
168
|
+
if (invalid || node !== callback.body && isFunctionLike(node)) return
|
|
169
|
+
if (ts.isReturnStatement(node) && node.expression) {
|
|
170
|
+
const expression = unwrapExpression(node.expression)
|
|
171
|
+
if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) {
|
|
172
|
+
cleanup = true
|
|
173
|
+
cleanups.push(expression)
|
|
174
|
+
}
|
|
175
|
+
else invalid = node
|
|
176
|
+
}
|
|
177
|
+
if (!invalid) ts.forEachChild(node, visit)
|
|
178
|
+
}
|
|
179
|
+
visit(callback.body)
|
|
180
|
+
return { cleanup, cleanups, invalid }
|
|
181
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import ts from "typescript"
|
|
2
|
+
import { bindingNames, containsJsx, functionVarDeclaresName, importDeclarationNames, isShadowedIdentifier, isUnshadowedGlobal, nearestFunction, referenceIdentifiers, sourceNodeError, statementDeclaresName, unwrapExpression } from "./ast-helpers.mjs"
|
|
3
|
+
|
|
4
|
+
export function normalizeMediaQueryExternalStores(sourceFile, factory, context) {
|
|
5
|
+
const imports = sourceFile.statements.filter(statement => ts.isImportDeclaration(statement) && !statement.importClause?.isTypeOnly && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "react" && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings))
|
|
6
|
+
const externalStoreImport = imports.flatMap(statement => statement.importClause.namedBindings.elements.map(entry => ({ entry, statement }))).find(({ entry }) => !entry.isTypeOnly && !entry.propertyName && entry.name.text === "useSyncExternalStore")
|
|
7
|
+
if (!externalStoreImport) return sourceFile
|
|
8
|
+
const returnedExpression = callback => {
|
|
9
|
+
if (!(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) || callback.parameters.length) return undefined
|
|
10
|
+
if (!ts.isBlock(callback.body)) return unwrapExpression(callback.body)
|
|
11
|
+
if (callback.body.statements.length !== 1 || !ts.isReturnStatement(callback.body.statements[0]) || !callback.body.statements[0].expression) return undefined
|
|
12
|
+
return unwrapExpression(callback.body.statements[0].expression)
|
|
13
|
+
}
|
|
14
|
+
const matchMediaQuery = expression => {
|
|
15
|
+
expression = unwrapExpression(expression)
|
|
16
|
+
if (!ts.isCallExpression(expression) || expression.arguments.length !== 1 || !ts.isStringLiteral(unwrapExpression(expression.arguments[0])) || !ts.isPropertyAccessExpression(expression.expression) || !ts.isIdentifier(expression.expression.expression) || expression.expression.expression.text !== "window" || expression.expression.name.text !== "matchMedia" || !isUnshadowedGlobal(expression.expression.expression, sourceFile)) return undefined
|
|
17
|
+
return unwrapExpression(expression.arguments[0]).text
|
|
18
|
+
}
|
|
19
|
+
const mediaListener = (statement, method, media, callback) => ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression) && statement.expression.arguments.length === 2 && ts.isPropertyAccessExpression(statement.expression.expression) && ts.isIdentifier(statement.expression.expression.expression) && statement.expression.expression.expression.text === media && statement.expression.expression.name.text === method && ts.isStringLiteral(unwrapExpression(statement.expression.arguments[0])) && unwrapExpression(statement.expression.arguments[0]).text === "change" && ts.isIdentifier(unwrapExpression(statement.expression.arguments[1])) && unwrapExpression(statement.expression.arguments[1]).text === callback
|
|
20
|
+
const candidates = new Map()
|
|
21
|
+
let index = 0
|
|
22
|
+
const inspect = node => {
|
|
23
|
+
if (!ts.isVariableStatement(node) || !(node.declarationList.flags & ts.NodeFlags.Const) || node.declarationList.declarations.length !== 1) {
|
|
24
|
+
ts.forEachChild(node, inspect)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
const declaration = node.declarationList.declarations[0]
|
|
28
|
+
const call = declaration.initializer && unwrapExpression(declaration.initializer)
|
|
29
|
+
if (!ts.isIdentifier(declaration.name) || !call || !ts.isCallExpression(call) || !ts.isIdentifier(call.expression) || call.expression.text !== "useSyncExternalStore" || isShadowedIdentifier(call.expression, sourceFile)) {
|
|
30
|
+
ts.forEachChild(node, inspect)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
if (call.arguments.length !== 3) throw sourceNodeError(call, sourceFile, "Media query useSyncExternalStore() requires subscribe, browser snapshot, and false server snapshot callbacks")
|
|
34
|
+
const [subscribe, snapshot, serverSnapshot] = call.arguments.map(unwrapExpression)
|
|
35
|
+
if (!(ts.isArrowFunction(subscribe) || ts.isFunctionExpression(subscribe)) || subscribe.parameters.length !== 1 || !ts.isIdentifier(subscribe.parameters[0].name) || !ts.isBlock(subscribe.body)) throw sourceNodeError(subscribe, sourceFile, "Media query subscriptions require one inline callback parameter and block body")
|
|
36
|
+
if (subscribe.body.statements.length !== 3) throw sourceNodeError(subscribe, sourceFile, "Media query subscriptions must add and remove one matching change listener")
|
|
37
|
+
const callback = subscribe.parameters[0].name.text
|
|
38
|
+
const [mediaStatement, addStatement, returnStatement] = subscribe.body.statements
|
|
39
|
+
const mediaDeclaration = ts.isVariableStatement(mediaStatement) && (mediaStatement.declarationList.flags & ts.NodeFlags.Const) && mediaStatement.declarationList.declarations.length === 1 ? mediaStatement.declarationList.declarations[0] : undefined
|
|
40
|
+
const media = mediaDeclaration && ts.isIdentifier(mediaDeclaration.name) ? mediaDeclaration.name.text : undefined
|
|
41
|
+
const query = mediaDeclaration?.initializer && matchMediaQuery(mediaDeclaration.initializer)
|
|
42
|
+
const cleanup = ts.isReturnStatement(returnStatement) && returnStatement.expression ? unwrapExpression(returnStatement.expression) : undefined
|
|
43
|
+
const cleanupStatement = cleanup && (ts.isArrowFunction(cleanup) || ts.isFunctionExpression(cleanup)) && !cleanup.parameters.length
|
|
44
|
+
? ts.isBlock(cleanup.body) ? cleanup.body.statements.length === 1 ? cleanup.body.statements[0] : undefined : factory.createExpressionStatement(cleanup.body)
|
|
45
|
+
: undefined
|
|
46
|
+
if (!media || !query || !mediaListener(addStatement, "addEventListener", media, callback) || !cleanupStatement || !mediaListener(cleanupStatement, "removeEventListener", media, callback)) throw sourceNodeError(subscribe, sourceFile, "Media query subscriptions must add and remove one matching change listener")
|
|
47
|
+
const snapshotValue = returnedExpression(snapshot)
|
|
48
|
+
const snapshotQuery = snapshotValue && ts.isPropertyAccessExpression(snapshotValue) && snapshotValue.name.text === "matches" ? matchMediaQuery(snapshotValue.expression) : undefined
|
|
49
|
+
const serverValue = returnedExpression(serverSnapshot)
|
|
50
|
+
if (snapshotQuery !== query || !serverValue || serverValue.kind !== ts.SyntaxKind.FalseKeyword) throw sourceNodeError(call, sourceFile, "Media query external stores require matching static snapshots and a false server fallback")
|
|
51
|
+
const owner = nearestFunction(node)
|
|
52
|
+
const topLevelOwner = owner && (owner.parent === sourceFile || ts.isVariableDeclaration(owner.parent) && owner.parent.parent?.parent?.parent === sourceFile)
|
|
53
|
+
if (!topLevelOwner || !owner.body || !ts.isBlock(owner.body) || node.parent !== owner.body) throw sourceNodeError(declaration, sourceFile, "Media query external stores must initialize one top-level component const")
|
|
54
|
+
for (const name of ["useEffect", "useState"]) if (owner.parameters.some(parameter => bindingNames(parameter.name).includes(name)) || functionVarDeclaresName(owner, name) || owner.body.statements.some(statement => statementDeclaresName(statement, name))) throw sourceNodeError(declaration, sourceFile, `Media query external stores conflict with component-local ${name}`)
|
|
55
|
+
let setter = `__kSetMediaQuery${index++}`
|
|
56
|
+
while (sourceFile.text.includes(setter)) setter = `__kSetMediaQuery${index++}`
|
|
57
|
+
candidates.set(node, { declaration, query, setter })
|
|
58
|
+
}
|
|
59
|
+
inspect(sourceFile)
|
|
60
|
+
const references = referenceIdentifiers(sourceFile, "useSyncExternalStore")
|
|
61
|
+
if (references.length !== candidates.size) throw sourceNodeError(references.find(reference => ![...candidates.values()].some(candidate => insideNode(reference, candidate.declaration.initializer))) ?? externalStoreImport.entry, sourceFile, "useSyncExternalStore is supported only for direct static media query declarations")
|
|
62
|
+
const directHooks = new Set(imports.flatMap(statement => statement.importClause.namedBindings.elements.filter(entry => !entry.propertyName).map(entry => entry.name.text)))
|
|
63
|
+
const missingHooks = ["useEffect", "useState"].filter(name => !directHooks.has(name))
|
|
64
|
+
for (const name of missingHooks) {
|
|
65
|
+
const collision = sourceFile.statements.some(statement => statementDeclaresName(statement, name) || ts.isImportDeclaration(statement) && importDeclarationNames(statement).includes(name))
|
|
66
|
+
if (collision) throw sourceNodeError([...candidates.values()][0].declaration, sourceFile, `Media query external stores conflict with local ${name}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const visitor = node => {
|
|
70
|
+
const candidate = ts.isVariableStatement(node) ? candidates.get(node) : undefined
|
|
71
|
+
if (candidate) {
|
|
72
|
+
const state = factory.createVariableStatement(node.modifiers, factory.createVariableDeclarationList([
|
|
73
|
+
factory.createVariableDeclaration(factory.createArrayBindingPattern([
|
|
74
|
+
factory.createBindingElement(undefined, undefined, candidate.declaration.name),
|
|
75
|
+
factory.createBindingElement(undefined, undefined, candidate.setter)
|
|
76
|
+
]), undefined, undefined, factory.createCallExpression(factory.createIdentifier("useState"), undefined, [factory.createFalse()]))
|
|
77
|
+
], ts.NodeFlags.Const))
|
|
78
|
+
const media = factory.createIdentifier("media")
|
|
79
|
+
const update = factory.createIdentifier("update")
|
|
80
|
+
const mediaCall = factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("window"), "matchMedia"), undefined, [factory.createStringLiteral(candidate.query)])
|
|
81
|
+
const updateCallback = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createCallExpression(factory.createIdentifier(candidate.setter), undefined, [factory.createPropertyAccessExpression(media, "matches")]))
|
|
82
|
+
const listener = method => factory.createCallExpression(factory.createPropertyAccessExpression(media, method), undefined, [factory.createStringLiteral("change"), update])
|
|
83
|
+
const cleanup = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), listener("removeEventListener"))
|
|
84
|
+
const setup = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createBlock([
|
|
85
|
+
factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(media, undefined, undefined, mediaCall)], ts.NodeFlags.Const)),
|
|
86
|
+
factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(update, undefined, undefined, updateCallback)], ts.NodeFlags.Const)),
|
|
87
|
+
factory.createExpressionStatement(factory.createCallExpression(update, undefined, [])),
|
|
88
|
+
factory.createExpressionStatement(listener("addEventListener")),
|
|
89
|
+
factory.createReturnStatement(cleanup)
|
|
90
|
+
], true))
|
|
91
|
+
const effectCall = factory.createCallExpression(factory.createIdentifier("useEffect"), undefined, [setup, factory.createArrayLiteralExpression()])
|
|
92
|
+
ts.setOriginalNode(effectCall, candidate.declaration.initializer)
|
|
93
|
+
ts.setTextRange(effectCall, candidate.declaration.initializer)
|
|
94
|
+
return [state, factory.createExpressionStatement(effectCall)]
|
|
95
|
+
}
|
|
96
|
+
if (node === externalStoreImport.statement) {
|
|
97
|
+
const clause = node.importClause
|
|
98
|
+
const bindings = clause.namedBindings
|
|
99
|
+
const elements = bindings.elements.filter(entry => entry !== externalStoreImport.entry)
|
|
100
|
+
for (const name of missingHooks) elements.push(factory.createImportSpecifier(false, undefined, factory.createIdentifier(name)))
|
|
101
|
+
return factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(clause, false, clause.name, factory.updateNamedImports(bindings, elements)), node.moduleSpecifier, node.attributes)
|
|
102
|
+
}
|
|
103
|
+
return ts.visitEachChild(node, visitor, context)
|
|
104
|
+
}
|
|
105
|
+
return ts.visitNode(sourceFile, visitor)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function insideNode(node, root) {
|
|
109
|
+
for (let current = node; current; current = current.parent) if (current === root) return true
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function normalizeNavigatorCapabilityConditions(sourceFile, factory, context) {
|
|
114
|
+
const candidates = new Map()
|
|
115
|
+
let index = 0
|
|
116
|
+
const inspect = node => {
|
|
117
|
+
if (!ts.isVariableStatement(node) || !(node.declarationList.flags & ts.NodeFlags.Const) || node.declarationList.declarations.length !== 1) {
|
|
118
|
+
ts.forEachChild(node, inspect)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
const declaration = node.declarationList.declarations[0]
|
|
122
|
+
const value = declaration.initializer && unwrapExpression(declaration.initializer)
|
|
123
|
+
if (!ts.isIdentifier(declaration.name) || !value || !ts.isBinaryExpression(value) || value.operatorToken.kind !== ts.SyntaxKind.InKeyword || !ts.isStringLiteral(unwrapExpression(value.left)) || !ts.isIdentifier(unwrapExpression(value.right)) || unwrapExpression(value.right).text !== "navigator" || !isUnshadowedGlobal(unwrapExpression(value.right), sourceFile)) {
|
|
124
|
+
ts.forEachChild(node, inspect)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
const owner = nearestFunction(node)
|
|
128
|
+
const topLevelOwner = owner && (owner.parent === sourceFile || ts.isVariableDeclaration(owner.parent) && owner.parent.parent?.parent?.parent === sourceFile)
|
|
129
|
+
if (!topLevelOwner || !owner.body || !ts.isBlock(owner.body) || node.parent !== owner.body) throw sourceNodeError(declaration, sourceFile, "Navigator capability conditions must be top-level component const declarations")
|
|
130
|
+
const references = referenceIdentifiers(owner.body, declaration.name.text)
|
|
131
|
+
const condition = references.length === 1 ? references[0] : undefined
|
|
132
|
+
const structural = condition && ts.isBinaryExpression(condition.parent) && condition.parent.left === condition && condition.parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && containsJsx(condition.parent.right) && ts.isJsxExpression(condition.parent.parent) && condition.parent.parent.expression === condition.parent
|
|
133
|
+
if (!structural) throw sourceNodeError(declaration, sourceFile, "Navigator capability values may only control one direct JSX && branch")
|
|
134
|
+
for (const name of ["useEffect", "useState"]) if (owner.parameters.some(parameter => bindingNames(parameter.name).includes(name)) || functionVarDeclaresName(owner, name) || owner.body.statements.some(statement => statementDeclaresName(statement, name))) throw sourceNodeError(declaration, sourceFile, `Navigator capability conditions conflict with component-local ${name}`)
|
|
135
|
+
let setter = `__kSetNavigatorCapability${index++}`
|
|
136
|
+
while (sourceFile.text.includes(setter)) setter = `__kSetNavigatorCapability${index++}`
|
|
137
|
+
candidates.set(node, { declaration, property: unwrapExpression(value.left).text, setter })
|
|
138
|
+
}
|
|
139
|
+
inspect(sourceFile)
|
|
140
|
+
if (!candidates.size) return sourceFile
|
|
141
|
+
|
|
142
|
+
const visitor = node => {
|
|
143
|
+
const candidate = ts.isVariableStatement(node) ? candidates.get(node) : undefined
|
|
144
|
+
if (candidate) {
|
|
145
|
+
const state = factory.createVariableStatement(node.modifiers, factory.createVariableDeclarationList([
|
|
146
|
+
factory.createVariableDeclaration(factory.createArrayBindingPattern([
|
|
147
|
+
factory.createBindingElement(undefined, undefined, candidate.declaration.name),
|
|
148
|
+
factory.createBindingElement(undefined, undefined, candidate.setter)
|
|
149
|
+
]), undefined, undefined, factory.createCallExpression(factory.createIdentifier("useState"), undefined, [factory.createFalse()]))
|
|
150
|
+
], ts.NodeFlags.Const))
|
|
151
|
+
const capability = factory.createBinaryExpression(factory.createStringLiteral(candidate.property), factory.createToken(ts.SyntaxKind.InKeyword), factory.createIdentifier("navigator"))
|
|
152
|
+
const setup = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createBlock([
|
|
153
|
+
factory.createExpressionStatement(factory.createCallExpression(factory.createIdentifier(candidate.setter), undefined, [capability]))
|
|
154
|
+
], true))
|
|
155
|
+
const effectCall = factory.createCallExpression(factory.createIdentifier("useEffect"), undefined, [setup, factory.createArrayLiteralExpression()])
|
|
156
|
+
ts.setOriginalNode(effectCall, candidate.declaration.initializer)
|
|
157
|
+
ts.setTextRange(effectCall, candidate.declaration.initializer)
|
|
158
|
+
const effect = factory.createExpressionStatement(effectCall)
|
|
159
|
+
return [state, effect]
|
|
160
|
+
}
|
|
161
|
+
return ts.visitEachChild(node, visitor, context)
|
|
162
|
+
}
|
|
163
|
+
let normalized = ts.visitNode(sourceFile, visitor)
|
|
164
|
+
const hookImports = normalized.statements.filter(statement => ts.isImportDeclaration(statement) && !statement.importClause?.isTypeOnly && ts.isStringLiteral(statement.moduleSpecifier) && ["react", "@kudzujs/core"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings))
|
|
165
|
+
const hookImport = hookImports[0]
|
|
166
|
+
const bindings = hookImport?.importClause.namedBindings
|
|
167
|
+
const imported = new Set(hookImports.flatMap(statement => statement.importClause.namedBindings.elements.map(entry => entry.name.text)))
|
|
168
|
+
const missing = ["useEffect", "useState"].filter(name => !imported.has(name))
|
|
169
|
+
if (!missing.length) return normalized
|
|
170
|
+
for (const name of missing) if (normalized.statements.some(statement => !hookImports.includes(statement) && (statementDeclaresName(statement, name) || ts.isImportDeclaration(statement) && importDeclarationNames(statement).includes(name)))) throw sourceNodeError([...candidates.values()][0].declaration, sourceFile, `Navigator capability conditions conflict with local ${name}`)
|
|
171
|
+
if (hookImport && bindings && ts.isNamedImports(bindings)) {
|
|
172
|
+
const statements = normalized.statements.map(statement => statement === hookImport ? factory.updateImportDeclaration(statement, statement.modifiers, factory.updateImportClause(statement.importClause, false, statement.importClause.name, factory.updateNamedImports(bindings, [
|
|
173
|
+
...bindings.elements,
|
|
174
|
+
...missing.map(name => factory.createImportSpecifier(false, undefined, factory.createIdentifier(name)))
|
|
175
|
+
])), statement.moduleSpecifier, statement.attributes) : statement)
|
|
176
|
+
return factory.updateSourceFile(normalized, statements)
|
|
177
|
+
}
|
|
178
|
+
const declaration = factory.createImportDeclaration(undefined, factory.createImportClause(false, undefined, factory.createNamedImports(missing.map(name => factory.createImportSpecifier(false, undefined, factory.createIdentifier(name))))), factory.createStringLiteral("@kudzujs/core"))
|
|
179
|
+
const statements = [...normalized.statements]
|
|
180
|
+
statements.splice(statements.findLastIndex(statement => ts.isImportDeclaration(statement)) + 1, 0, declaration)
|
|
181
|
+
return factory.updateSourceFile(normalized, statements)
|
|
182
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import ts from "typescript"
|
|
3
|
+
import { effectReturns, nearestFunction, referencesIdentifier, sourceNodeError, unwrapExpression } from "./ast-helpers.mjs"
|
|
4
|
+
|
|
5
|
+
const customHookTimerStatePrefix = "__kTimerState_"
|
|
6
|
+
const customHookTimerSetterPrefix = "__kSetTimerState_"
|
|
7
|
+
|
|
8
|
+
export function normalizeCustomHookTimerRefs(sourceFile, factory, context) {
|
|
9
|
+
const timerCall = (node, name) => ts.isCallExpression(node) && (
|
|
10
|
+
ts.isIdentifier(node.expression) && node.expression.text === name ||
|
|
11
|
+
ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === "window" && node.expression.name.text === name
|
|
12
|
+
)
|
|
13
|
+
const currentAccess = (node, name) => ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === name && node.name.text === "current"
|
|
14
|
+
const clearStatement = (node, name) => {
|
|
15
|
+
if (!ts.isIfStatement(node) || node.elseStatement || !currentAccess(unwrapExpression(node.expression), name)) return undefined
|
|
16
|
+
const statement = ts.isBlock(node.thenStatement) && node.thenStatement.statements.length === 1 ? node.thenStatement.statements[0] : node.thenStatement
|
|
17
|
+
if (!ts.isExpressionStatement(statement) || !timerCall(statement.expression, "clearTimeout") || statement.expression.arguments.length !== 1 || !currentAccess(unwrapExpression(statement.expression.arguments[0]), name)) return undefined
|
|
18
|
+
return { condition: unwrapExpression(node.expression), argument: unwrapExpression(statement.expression.arguments[0]) }
|
|
19
|
+
}
|
|
20
|
+
const analyze = (hook, hookName) => {
|
|
21
|
+
if (!/^use[A-Z]/.test(hookName) || hook.parameters.length || !hook.body || !ts.isBlock(hook.body)) return undefined
|
|
22
|
+
const returnedStatement = hook.body.statements.at(-1)
|
|
23
|
+
const returned = returnedStatement && ts.isReturnStatement(returnedStatement) && returnedStatement.expression ? unwrapExpression(returnedStatement.expression) : undefined
|
|
24
|
+
if (!returned || !ts.isObjectLiteralExpression(returned)) return undefined
|
|
25
|
+
const returnedNames = new Set(returned.properties.filter(ts.isShorthandPropertyAssignment).map(property => property.name.text))
|
|
26
|
+
const callbacks = new Map()
|
|
27
|
+
const refs = []
|
|
28
|
+
for (const statement of hook.body.statements) {
|
|
29
|
+
if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const)) continue
|
|
30
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
31
|
+
if (ts.isIdentifier(declaration.name) && declaration.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) callbacks.set(declaration.name.text, declaration.initializer)
|
|
32
|
+
if (ts.isIdentifier(declaration.name) && declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useRef" && declaration.initializer.arguments.length === 1 && declaration.initializer.arguments[0].kind === ts.SyntaxKind.NullKeyword) refs.push({ declaration, name: declaration.name.text })
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const candidates = []
|
|
36
|
+
for (const ref of refs) {
|
|
37
|
+
const assignments = []
|
|
38
|
+
const accesses = []
|
|
39
|
+
const clearStatements = []
|
|
40
|
+
const collect = node => {
|
|
41
|
+
if (currentAccess(node, ref.name)) accesses.push(node)
|
|
42
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && currentAccess(unwrapExpression(node.left), ref.name)) assignments.push(node)
|
|
43
|
+
const clear = clearStatement(node, ref.name)
|
|
44
|
+
if (clear) clearStatements.push({ node, ...clear })
|
|
45
|
+
ts.forEachChild(node, collect)
|
|
46
|
+
}
|
|
47
|
+
collect(hook.body)
|
|
48
|
+
if (assignments.some(assignment => timerCall(unwrapExpression(assignment.right), "setTimeout"))) candidates.push({ ...ref, assignments, accesses, clearStatements })
|
|
49
|
+
}
|
|
50
|
+
if (!candidates.length) return undefined
|
|
51
|
+
if (candidates.length !== 1) throw sourceNodeError(hook, sourceFile, "Relative custom hooks may own only one private timeout ref")
|
|
52
|
+
const timer = candidates[0]
|
|
53
|
+
if (timer.assignments.length !== 1) throw sourceNodeError(timer.declaration, sourceFile, "Private timeout refs require one direct timer.current = setTimeout(...) assignment")
|
|
54
|
+
const assignment = timer.assignments[0]
|
|
55
|
+
const timeout = unwrapExpression(assignment.right)
|
|
56
|
+
const timeoutCallback = timeout.arguments[0]
|
|
57
|
+
const delay = timeout.arguments[1]
|
|
58
|
+
if (!timerCall(timeout, "setTimeout") || timeout.arguments.length !== 2 || !timeoutCallback || !(ts.isArrowFunction(timeoutCallback) || ts.isFunctionExpression(timeoutCallback)) || timeoutCallback.parameters.length || !delay || !ts.isNumericLiteral(unwrapExpression(delay))) throw sourceNodeError(assignment, sourceFile, "Private timeout refs require setTimeout() with one zero-argument callback and a numeric literal delay")
|
|
59
|
+
const callback = nearestFunction(assignment)
|
|
60
|
+
const callbackName = [...callbacks].find(([, value]) => value === callback)?.[0]
|
|
61
|
+
if (!callbackName || !returnedNames.has(callbackName) || !ts.isBlock(callback.body) || !ts.isExpressionStatement(assignment.parent) || assignment.parent.parent !== callback.body) throw sourceNodeError(assignment, sourceFile, "Private timeout refs must be assigned directly inside one returned custom-hook callback")
|
|
62
|
+
const callbackClear = timer.clearStatements.find(entry => nearestFunction(entry.node) === callback)
|
|
63
|
+
if (!callbackClear || callbackClear.node.parent !== callback.body || callback.body.statements.indexOf(callbackClear.node) >= callback.body.statements.indexOf(assignment.parent)) throw sourceNodeError(callback, sourceFile, "Private timeout callbacks must directly clear the previous timer before assigning its replacement")
|
|
64
|
+
const effectCalls = hook.body.statements.flatMap(statement => {
|
|
65
|
+
if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression) || !ts.isIdentifier(statement.expression.expression) || statement.expression.expression.text !== "useEffect") return []
|
|
66
|
+
return [statement.expression]
|
|
67
|
+
})
|
|
68
|
+
let cleanupClear
|
|
69
|
+
for (const effect of effectCalls) {
|
|
70
|
+
const [setup, dependencies] = effect.arguments
|
|
71
|
+
if (!(ts.isArrowFunction(setup) || ts.isFunctionExpression(setup)) || !ts.isArrayLiteralExpression(dependencies) || dependencies.elements.length) continue
|
|
72
|
+
const returns = effectReturns(setup)
|
|
73
|
+
if (returns.cleanups.length !== 1) continue
|
|
74
|
+
const cleanup = returns.cleanups[0]
|
|
75
|
+
const entry = timer.clearStatements.find(candidate => nearestFunction(candidate.node) === cleanup)
|
|
76
|
+
if (entry && ts.isBlock(cleanup.body) && cleanup.body.statements.length === 1 && cleanup.body.statements[0] === entry.node) cleanupClear = entry
|
|
77
|
+
}
|
|
78
|
+
if (!cleanupClear) throw sourceNodeError(timer.declaration, sourceFile, "Private timeout refs require one empty-dependency effect that directly clears the timer on cleanup")
|
|
79
|
+
const accepted = new Set([assignment.left, callbackClear.condition, callbackClear.argument, cleanupClear.condition, cleanupClear.argument].map(unwrapExpression))
|
|
80
|
+
const unsupported = timer.accesses.find(access => !accepted.has(access))
|
|
81
|
+
if (unsupported) throw sourceNodeError(unsupported, sourceFile, "Private timeout refs may only be read by their direct replacement and cleanup guards")
|
|
82
|
+
const identity = createHash("sha256").update(`${sourceFile.fileName}:${hook.pos}:${timer.name}`).digest("hex").slice(0, 10)
|
|
83
|
+
const stateName = `${customHookTimerStatePrefix}${identity}`
|
|
84
|
+
const setterName = `${customHookTimerSetterPrefix}${identity}`
|
|
85
|
+
if (referencesIdentifier(hook.body, stateName) || referencesIdentifier(hook.body, setterName)) throw sourceNodeError(timer.declaration, sourceFile, "Private timeout ref conflicts with compiler-owned bindings")
|
|
86
|
+
return { assignment, declaration: timer.declaration, refName: timer.name, returned, stateName, setterName }
|
|
87
|
+
}
|
|
88
|
+
const timerStates = new Set()
|
|
89
|
+
const transform = (hook, hookName) => {
|
|
90
|
+
const timer = analyze(hook, hookName)
|
|
91
|
+
if (!timer) return undefined
|
|
92
|
+
timerStates.add(timer.stateName)
|
|
93
|
+
const timerVisitor = current => {
|
|
94
|
+
if (current === timer.declaration) {
|
|
95
|
+
const binding = factory.createArrayBindingPattern([
|
|
96
|
+
factory.createBindingElement(undefined, undefined, timer.stateName),
|
|
97
|
+
factory.createBindingElement(undefined, undefined, timer.setterName)
|
|
98
|
+
])
|
|
99
|
+
const initializer = factory.updateCallExpression(current.initializer, factory.createIdentifier("useState"), current.initializer.typeArguments, current.initializer.arguments)
|
|
100
|
+
return factory.updateVariableDeclaration(current, binding, current.exclamationToken, undefined, initializer)
|
|
101
|
+
}
|
|
102
|
+
if (current === timer.assignment) return factory.createCallExpression(factory.createIdentifier(timer.setterName), undefined, [ts.visitNode(current.right, timerVisitor)])
|
|
103
|
+
if (currentAccess(current, timer.refName)) return factory.createIdentifier(timer.stateName)
|
|
104
|
+
if (current === timer.returned) return factory.updateObjectLiteralExpression(current, [
|
|
105
|
+
...current.properties,
|
|
106
|
+
factory.createShorthandPropertyAssignment(timer.stateName),
|
|
107
|
+
factory.createShorthandPropertyAssignment(timer.setterName)
|
|
108
|
+
])
|
|
109
|
+
return ts.visitEachChild(current, timerVisitor, context)
|
|
110
|
+
}
|
|
111
|
+
return ts.visitEachChild(hook, timerVisitor, context)
|
|
112
|
+
}
|
|
113
|
+
const visitor = node => {
|
|
114
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
115
|
+
const hookName = node.name?.text ?? (node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword) ? "useDefault" : "")
|
|
116
|
+
const transformed = transform(node, hookName)
|
|
117
|
+
if (transformed) return transformed
|
|
118
|
+
}
|
|
119
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))) {
|
|
120
|
+
const transformed = transform(node.initializer, node.name.text)
|
|
121
|
+
if (transformed) return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, transformed)
|
|
122
|
+
}
|
|
123
|
+
return ts.visitEachChild(node, visitor, context)
|
|
124
|
+
}
|
|
125
|
+
return { sourceFile: ts.visitNode(sourceFile, visitor), timerStates }
|
|
126
|
+
}
|