mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
652 lines
21 KiB
JavaScript
652 lines
21 KiB
JavaScript
import ts from 'typescript-api'
|
|
|
|
// Code React Compiler accepts but runs differently than written. Source hazards are found in the
|
|
// module-level components and hooks the compiler's infer mode picks (by name), compiled or not, so
|
|
// a bailout fix can't expose one; hooks in memo blocks are read from the compiled output.
|
|
|
|
const COMPONENT_NAME = /^[A-Z]/
|
|
const HOOK_NAME = /^use[A-Z0-9]/
|
|
const WRAPPERS = new Set(['memo', 'forwardRef'])
|
|
const OPT_OUT_DIRECTIVES = new Set(['use no memo', 'use no forget'])
|
|
|
|
export const HAZARD_FIXES = {
|
|
'void-read': [
|
|
'React Compiler drops a bare `void x;` read, so it neither invalidates a memo nor forces layout.',
|
|
'Memo invalidator: `useMemo(() => recomputeOn([tick], () => compute()), [tick])` from',
|
|
'@/lib/recompute-on. Layout flush: `forceLayoutReflow(el)` from @/lib/force-layout-reflow.'
|
|
],
|
|
'member-postfix-value': [
|
|
'oxc React Compiler 0.152 compiles a member postfix update used as a value (`a[q.head++]`) as',
|
|
'a prefix update. Read first, then update in its own statement: `const v = a[q.head]; q.head += 1`.'
|
|
],
|
|
'opt-out-directive': [
|
|
'"use no memo" silently opts the function out of React Compiler. Fix the pattern it hides.'
|
|
],
|
|
'discarded-subscription': [
|
|
'A discarded useSyncExternalStore result only forces a re-render; React Compiler still serves',
|
|
'reads after it from cache. Keep the snapshot: `const version = useSyncExternalStore(...)` and',
|
|
'read through `recomputeOn([version], () => read())` from @/lib/recompute-on, or return it.'
|
|
],
|
|
'store-read-in-memo-block': [
|
|
'React Compiler caches this render-time `store.getState()` snapshot on unrelated inputs, so it',
|
|
'goes stale. Read it in the event handler or effect that uses it, or select it with a hook.'
|
|
],
|
|
'virtualizer-in-memo-block': [
|
|
'A TanStack virtualizer is one object mutated in place, so React Compiler serves rows, sizes or',
|
|
'child elements cached on it after every scroll. Add the file to',
|
|
'config/react-compiler-uncompiled-files.txt, owners that pass the virtualizer down included.'
|
|
],
|
|
'live-read-in-memo-block': [
|
|
'React Compiler caches this render-time read of module state (a provider runtime context key or a',
|
|
'structured selection) on its arguments, so it misses the module-level change. Add the file to',
|
|
'config/react-compiler-uncompiled-files.txt, or subscribe to the state so a change re-renders.'
|
|
],
|
|
'hook-in-memo-block': [
|
|
'React Compiler memoizes this hook call inside a cached block, so it runs only when the block',
|
|
'recomputes (a conditional hook). Call the hook in its own top-level `const` and use the variable.'
|
|
]
|
|
}
|
|
|
|
function scriptKind(fileName) {
|
|
return /\.[cm]?[jt]sx$/.test(fileName) ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
|
}
|
|
|
|
export function parseModule(fileName, text) {
|
|
return ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, scriptKind(fileName))
|
|
}
|
|
|
|
function isReactName(name) {
|
|
return COMPONENT_NAME.test(name) || HOOK_NAME.test(name)
|
|
}
|
|
|
|
function calleeName(expression) {
|
|
if (ts.isIdentifier(expression)) {
|
|
return expression.text
|
|
}
|
|
return ts.isPropertyAccessExpression(expression) ? expression.name.text : null
|
|
}
|
|
|
|
function unwrap(expression) {
|
|
let node = expression
|
|
while (
|
|
ts.isParenthesizedExpression(node) ||
|
|
ts.isNonNullExpression(node) ||
|
|
ts.isAsExpression(node) ||
|
|
ts.isSatisfiesExpression(node)
|
|
) {
|
|
node = node.expression
|
|
}
|
|
return node
|
|
}
|
|
|
|
/** Name the compiler infers for a module-scope function: its own, its binding's, or memo()'s. */
|
|
function reactFunctionName(node) {
|
|
if (node.name && ts.isIdentifier(node.name)) {
|
|
return node.name.text
|
|
}
|
|
let parent = node.parent
|
|
while (ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent)) {
|
|
parent = parent.parent
|
|
}
|
|
if (ts.isCallExpression(parent) && WRAPPERS.has(calleeName(parent.expression) ?? '')) {
|
|
parent = parent.parent
|
|
}
|
|
return ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name) ? parent.name.text : null
|
|
}
|
|
|
|
function isFunctionNode(node) {
|
|
return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node)
|
|
}
|
|
|
|
/** Module-scope components and hooks of a parsed module, with their inferred names. */
|
|
export function listReactFunctions(sourceFile) {
|
|
const found = []
|
|
const visit = (node) => {
|
|
if (isFunctionNode(node)) {
|
|
const name = reactFunctionName(node)
|
|
if (name && isReactName(name)) {
|
|
found.push({ name, node })
|
|
}
|
|
return
|
|
}
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
visit(sourceFile)
|
|
return found
|
|
}
|
|
|
|
function lineOf(sourceFile, node) {
|
|
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1
|
|
}
|
|
|
|
function isValueUse(node) {
|
|
const parent = node.parent
|
|
if (ts.isExpressionStatement(parent)) {
|
|
return false
|
|
}
|
|
return !(ts.isForStatement(parent) && parent.incrementor === node)
|
|
}
|
|
|
|
function directiveText(statement) {
|
|
return ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)
|
|
? statement.expression.text
|
|
: null
|
|
}
|
|
|
|
function bodyDirectives(fn) {
|
|
if (!fn.body || !ts.isBlock(fn.body)) {
|
|
return []
|
|
}
|
|
const directives = []
|
|
for (const statement of fn.body.statements) {
|
|
const text = directiveText(statement)
|
|
if (text === null) {
|
|
break
|
|
}
|
|
directives.push({ text, statement })
|
|
}
|
|
return directives
|
|
}
|
|
|
|
/** Hazards in a module's source: `{ kind, fn, line, text }`, one per occurrence. */
|
|
export function findSourceHazards(fileName, text) {
|
|
// Every hazard needs one of these tokens; skipping the parse keeps the full scan fast.
|
|
if (!/\bvoid\b|\+\+|--|use no (?:memo|forget)|useSyncExternalStore/.test(text)) {
|
|
return []
|
|
}
|
|
const sourceFile = parseModule(fileName, text)
|
|
const hazards = []
|
|
const report = (kind, fn, node) =>
|
|
hazards.push({
|
|
kind,
|
|
fn,
|
|
line: lineOf(sourceFile, node),
|
|
text: node.getText(sourceFile).split('\n')[0].slice(0, 100)
|
|
})
|
|
for (const statement of sourceFile.statements) {
|
|
const directive = directiveText(statement)
|
|
if (directive === null) {
|
|
break
|
|
}
|
|
if (OPT_OUT_DIRECTIVES.has(directive)) {
|
|
report('opt-out-directive', '(module)', statement)
|
|
}
|
|
}
|
|
for (const { name, node } of listReactFunctions(sourceFile)) {
|
|
for (const { text: directive, statement } of bodyDirectives(node)) {
|
|
if (OPT_OUT_DIRECTIVES.has(directive)) {
|
|
report('opt-out-directive', name, statement)
|
|
}
|
|
}
|
|
const visit = (child) => {
|
|
if (
|
|
ts.isExpressionStatement(child) &&
|
|
ts.isCallExpression(child.expression) &&
|
|
calleeName(child.expression.expression) === 'useSyncExternalStore'
|
|
) {
|
|
report('discarded-subscription', name, child)
|
|
}
|
|
if (ts.isExpressionStatement(child) && ts.isVoidExpression(child.expression)) {
|
|
const operand = unwrap(child.expression.expression)
|
|
if (
|
|
ts.isIdentifier(operand) ||
|
|
ts.isPropertyAccessExpression(operand) ||
|
|
ts.isElementAccessExpression(operand)
|
|
) {
|
|
report('void-read', name, child)
|
|
}
|
|
}
|
|
if (
|
|
ts.isPostfixUnaryExpression(child) &&
|
|
(ts.isPropertyAccessExpression(unwrap(child.operand)) ||
|
|
ts.isElementAccessExpression(unwrap(child.operand))) &&
|
|
isValueUse(child)
|
|
) {
|
|
report('member-postfix-value', name, child)
|
|
}
|
|
ts.forEachChild(child, visit)
|
|
}
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
return hazards
|
|
}
|
|
|
|
function compilerCacheNames(sourceFile) {
|
|
const runtimeImports = new Set()
|
|
for (const statement of sourceFile.statements) {
|
|
if (
|
|
ts.isImportDeclaration(statement) &&
|
|
ts.isStringLiteral(statement.moduleSpecifier) &&
|
|
/^react(?:-compiler-runtime|\/compiler-runtime)$/.test(statement.moduleSpecifier.text)
|
|
) {
|
|
const bindings = statement.importClause?.namedBindings
|
|
for (const element of bindings && ts.isNamedImports(bindings) ? bindings.elements : []) {
|
|
if ((element.propertyName ?? element.name).text === 'c') {
|
|
runtimeImports.add(element.name.text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return runtimeImports
|
|
}
|
|
|
|
function readsCache(expression, caches) {
|
|
let found = false
|
|
const visit = (node) => {
|
|
if (
|
|
ts.isElementAccessExpression(node) &&
|
|
ts.isIdentifier(node.expression) &&
|
|
caches.has(node.expression.text)
|
|
) {
|
|
found = true
|
|
}
|
|
if (!found) {
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
}
|
|
visit(expression)
|
|
return found
|
|
}
|
|
|
|
function isStoreRead(node) {
|
|
return (
|
|
ts.isCallExpression(node) &&
|
|
node.arguments.length === 0 &&
|
|
ts.isPropertyAccessExpression(node.expression) &&
|
|
node.expression.name.text === 'getState'
|
|
)
|
|
}
|
|
|
|
function memoizedCallKind(node, liveReaders) {
|
|
if (isStoreRead(node)) {
|
|
return { kind: 'store-read', name: 'getState' }
|
|
}
|
|
const callee = calleeName(node.expression)
|
|
if (callee && liveReaders.has(callee)) {
|
|
return { kind: 'live-read', name: callee }
|
|
}
|
|
return callee && HOOK_NAME.test(callee) ? { kind: 'hook', name: callee } : null
|
|
}
|
|
|
|
/** Hook calls, `getState()` reads and live module-state reads the compiler placed inside a memo-cache guarded block. */
|
|
export function findMemoizedCalls(fileName, compiledCode, liveReaders = new Set()) {
|
|
if (!compiledCode.includes('compiler-runtime')) {
|
|
return []
|
|
}
|
|
const sourceFile = parseModule(fileName, compiledCode)
|
|
const runtime = compilerCacheNames(sourceFile)
|
|
if (runtime.size === 0) {
|
|
return []
|
|
}
|
|
const found = new Map()
|
|
const caches = new Set()
|
|
const collect = (fn, node) => {
|
|
if (isFunctionNode(node)) {
|
|
return
|
|
}
|
|
const call = ts.isCallExpression(node) ? memoizedCallKind(node, liveReaders) : null
|
|
// Nested guarded blocks are walked once per enclosing block; keying by node dedupes them.
|
|
if (call) {
|
|
found.set(node, { fn, ...call })
|
|
}
|
|
ts.forEachChild(node, (child) => collect(fn, child))
|
|
}
|
|
const visit = (fn, node) => {
|
|
if (
|
|
ts.isVariableDeclaration(node) &&
|
|
ts.isIdentifier(node.name) &&
|
|
node.initializer &&
|
|
ts.isCallExpression(node.initializer) &&
|
|
ts.isIdentifier(node.initializer.expression) &&
|
|
runtime.has(node.initializer.expression.text)
|
|
) {
|
|
caches.add(node.name.text)
|
|
}
|
|
if (ts.isIfStatement(node) && readsCache(node.expression, caches)) {
|
|
collect(fn, node.thenStatement)
|
|
}
|
|
const next = isFunctionNode(node) ? (reactFunctionName(node) ?? fn) : fn
|
|
ts.forEachChild(node, (child) => visit(next, child))
|
|
}
|
|
visit('(module)', sourceFile)
|
|
return [...found.values()]
|
|
}
|
|
|
|
/** Hook calls the compiler placed inside a memo-cache guarded block: `{ fn, hook }`. */
|
|
export function findMemoizedHookCalls(fileName, compiledCode) {
|
|
return findMemoizedCalls(fileName, compiledCode)
|
|
.filter(({ kind }) => kind === 'hook')
|
|
.map(({ fn, name }) => ({ fn, hook: name }))
|
|
}
|
|
|
|
/** `getState()` calls the compiler placed inside a memo-cache guarded block: `{ fn }`. */
|
|
export function findMemoizedStoreReads(fileName, compiledCode) {
|
|
return findMemoizedCalls(fileName, compiledCode)
|
|
.filter(({ kind }) => kind === 'store-read')
|
|
.map(({ fn }) => ({ fn }))
|
|
}
|
|
|
|
/** Source lines of render-time `getState()` calls in `fnName`; a useMemo/callback read is excluded. */
|
|
export function renderStoreReadLines(fileName, text, fnName) {
|
|
return renderCallLines(fileName, text, fnName, isStoreRead)
|
|
}
|
|
|
|
/** Source lines of render-time calls to `callee` in `fnName`; a useMemo/callback call is excluded. */
|
|
export function renderLiveReadLines(fileName, text, fnName, callee) {
|
|
return renderCallLines(
|
|
fileName,
|
|
text,
|
|
fnName,
|
|
(node) => ts.isCallExpression(node) && calleeName(node.expression) === callee
|
|
)
|
|
}
|
|
|
|
function renderCallLines(fileName, text, fnName, matches) {
|
|
const sourceFile = parseModule(fileName, text)
|
|
const lines = new Set()
|
|
for (const { name, node: root } of listReactFunctions(sourceFile)) {
|
|
if (name !== fnName) {
|
|
continue
|
|
}
|
|
const visit = (node) => {
|
|
if (node !== root && isFunctionNode(node)) {
|
|
return
|
|
}
|
|
if (matches(node)) {
|
|
lines.add(lineOf(sourceFile, node))
|
|
}
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
visit(root)
|
|
}
|
|
return [...lines].sort((a, b) => a - b)
|
|
}
|
|
|
|
// Reads of module-level state that changes without a React update; a cache keyed on props keeps the old value.
|
|
export const LIVE_MODULE_READERS = [
|
|
// Both fold in providerRuntimeSessionGeneration (lib/provider-runtime-context.ts).
|
|
'getProviderRuntimeContextKey',
|
|
'getEnvironmentRuntimeContextKey',
|
|
// Both read the selection lifetime registry in structured-agent-session-selection-controller.ts.
|
|
'structuredSessionSelectionAuthority',
|
|
'structuredSessionSelectionController'
|
|
]
|
|
|
|
function moduleFunctionName(node) {
|
|
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
return node.name.text
|
|
}
|
|
const parent = node.parent
|
|
return (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
|
|
ts.isVariableDeclaration(parent) &&
|
|
ts.isIdentifier(parent.name) &&
|
|
ts.isVariableStatement(parent.parent?.parent)
|
|
? parent.name.text
|
|
: null
|
|
}
|
|
|
|
/** Module-scope plain functions whose own body calls a live reader, so their result is live too. */
|
|
export function findLiveReadWrappers(fileName, text, readers) {
|
|
if (![...readers].some((reader) => text.includes(reader))) {
|
|
return []
|
|
}
|
|
const sourceFile = parseModule(fileName, text)
|
|
const wrappers = []
|
|
for (const statement of sourceFile.statements) {
|
|
const candidates = ts.isFunctionDeclaration(statement)
|
|
? [statement]
|
|
: ts.isVariableStatement(statement)
|
|
? statement.declarationList.declarations
|
|
.map((declaration) => declaration.initializer && unwrap(declaration.initializer))
|
|
.filter((init) => init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)))
|
|
: []
|
|
for (const fn of candidates) {
|
|
const name = moduleFunctionName(fn)
|
|
if (!name || isReactName(name) || readers.has(name)) {
|
|
continue
|
|
}
|
|
let calls = false
|
|
const visit = (node) => {
|
|
if (calls || (node !== fn && isFunctionNode(node))) {
|
|
return
|
|
}
|
|
if (ts.isCallExpression(node) && readers.has(calleeName(node.expression) ?? '')) {
|
|
calls = true
|
|
return
|
|
}
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
visit(fn)
|
|
if (calls) {
|
|
wrappers.push(name)
|
|
}
|
|
}
|
|
}
|
|
return wrappers
|
|
}
|
|
|
|
/** Source lines of `hook` calls in `fnName` that sit inside a larger expression. */
|
|
export function hookCallLines(fileName, text, fnName, hook) {
|
|
const sourceFile = parseModule(fileName, text)
|
|
const roots = listReactFunctions(sourceFile)
|
|
.filter(({ name }) => name === fnName)
|
|
.map(({ node }) => node)
|
|
const lines = new Set()
|
|
const visit = (node) => {
|
|
if (
|
|
ts.isCallExpression(node) &&
|
|
calleeName(node.expression) === hook &&
|
|
!ts.isVariableDeclaration(node.parent) &&
|
|
!ts.isExpressionStatement(node.parent)
|
|
) {
|
|
lines.add(lineOf(sourceFile, node))
|
|
}
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
for (const root of roots.length > 0 ? roots : [sourceFile]) {
|
|
visit(root)
|
|
}
|
|
return lines.size > 0 ? [...lines].sort((a, b) => a - b) : ['?']
|
|
}
|
|
|
|
// A TanStack virtualizer is one object mutated in place, so a cache keyed on it never refreshes.
|
|
export const VIRTUALIZER_HOOKS = new Map([
|
|
['useVirtualizer', 'direct'],
|
|
['useWindowVirtualizer', 'direct']
|
|
])
|
|
const VIRTUALIZER_NAME = /^\w*[vV]irtualizer$/
|
|
const VIRTUALIZER_TYPE = /\bVirtualizer\s*</
|
|
// Bound methods: passing one (e.g. as a row ref) reads no virtualizer state.
|
|
const STABLE_VIRTUALIZER_METHODS = new Set(['measureElement', 'scrollToIndex', 'scrollToOffset'])
|
|
|
|
function virtualizerHookShape(expression, hooks) {
|
|
const node = expression && unwrap(expression)
|
|
return node && ts.isCallExpression(node)
|
|
? (hooks.get(calleeName(node.expression) ?? '') ?? null)
|
|
: null
|
|
}
|
|
|
|
/** Hooks returning a virtualizer (`direct`) or an object with a `virtualizer` (`property`). */
|
|
export function findVirtualizerWrappers(fileName, text, hooks = VIRTUALIZER_HOOKS) {
|
|
if (![...hooks.keys()].some((hook) => text.includes(hook))) {
|
|
return []
|
|
}
|
|
const sourceFile = parseModule(fileName, text)
|
|
const wrappers = []
|
|
for (const { name, node } of listReactFunctions(sourceFile)) {
|
|
if (!HOOK_NAME.test(name) || hooks.has(name)) {
|
|
continue
|
|
}
|
|
const bound = new Set()
|
|
let shape = null
|
|
const visit = (child) => {
|
|
if (child !== node && isFunctionNode(child)) {
|
|
return
|
|
}
|
|
if (
|
|
ts.isVariableDeclaration(child) &&
|
|
ts.isIdentifier(child.name) &&
|
|
virtualizerHookShape(child.initializer, hooks) === 'direct'
|
|
) {
|
|
bound.add(child.name.text)
|
|
}
|
|
if (ts.isReturnStatement(child) && child.expression) {
|
|
const value = unwrap(child.expression)
|
|
if (
|
|
(ts.isIdentifier(value) && bound.has(value.text)) ||
|
|
virtualizerHookShape(value, hooks) === 'direct'
|
|
) {
|
|
shape = 'direct'
|
|
} else if (
|
|
ts.isObjectLiteralExpression(value) &&
|
|
value.properties.some(
|
|
(p) => p.name && ts.isIdentifier(p.name) && p.name.text === 'virtualizer'
|
|
)
|
|
) {
|
|
shape ??= 'property'
|
|
}
|
|
}
|
|
ts.forEachChild(child, visit)
|
|
}
|
|
visit(node)
|
|
if (shape) {
|
|
wrappers.push({ name, shape })
|
|
}
|
|
}
|
|
return wrappers
|
|
}
|
|
|
|
/** Prop and parameter names this module types as a Virtualizer. */
|
|
function virtualizerTypedNames(fileName, text) {
|
|
const names = new Set()
|
|
if (!VIRTUALIZER_TYPE.test(text)) {
|
|
return names
|
|
}
|
|
const sourceFile = parseModule(fileName, text)
|
|
const visit = (node) => {
|
|
if (
|
|
(ts.isPropertySignature(node) || ts.isParameter(node)) &&
|
|
node.type &&
|
|
ts.isIdentifier(node.name) &&
|
|
VIRTUALIZER_TYPE.test(node.type.getText(sourceFile))
|
|
) {
|
|
names.add(node.name.text)
|
|
}
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
visit(sourceFile)
|
|
return names
|
|
}
|
|
|
|
/** Cache blocks keyed on a virtualizer that read it or pass it as a JSX prop: `{ fn, text }`. */
|
|
export function findVirtualizerMemoFlows(
|
|
fileName,
|
|
sourceText,
|
|
compiledCode,
|
|
hooks = VIRTUALIZER_HOOKS
|
|
) {
|
|
if (
|
|
!compiledCode.includes('compiler-runtime') ||
|
|
!(
|
|
/[vV]irtualizer/.test(compiledCode) ||
|
|
[...hooks.keys()].some((hook) => compiledCode.includes(hook))
|
|
)
|
|
) {
|
|
return []
|
|
}
|
|
const typed = virtualizerTypedNames(fileName, sourceText)
|
|
const sourceFile = parseModule(fileName, compiledCode)
|
|
const runtime = compilerCacheNames(sourceFile)
|
|
if (runtime.size === 0) {
|
|
return []
|
|
}
|
|
const caches = new Set()
|
|
const bound = new Set()
|
|
const isVirtualizer = (expression) => {
|
|
const node = unwrap(expression)
|
|
if (ts.isIdentifier(node)) {
|
|
const name = node.text.replace(/_\d+$/, '')
|
|
return bound.has(node.text) || VIRTUALIZER_NAME.test(name) || typed.has(name)
|
|
}
|
|
if (ts.isPropertyAccessExpression(node)) {
|
|
const name = node.name.text
|
|
return VIRTUALIZER_NAME.test(name) || typed.has(name)
|
|
}
|
|
return false
|
|
}
|
|
const keyedOnVirtualizer = (condition) => {
|
|
let keyed = false
|
|
const visit = (node) => {
|
|
if (
|
|
ts.isBinaryExpression(node) &&
|
|
node.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken &&
|
|
(isVirtualizer(node.left) || isVirtualizer(node.right))
|
|
) {
|
|
keyed = true
|
|
}
|
|
if (!keyed) {
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
}
|
|
visit(condition)
|
|
return keyed
|
|
}
|
|
const found = new Map()
|
|
// Closures call the live virtualizer when they run; only render-time reads and props go stale.
|
|
const collect = (fn, node) => {
|
|
if (isFunctionNode(node)) {
|
|
return
|
|
}
|
|
const passed =
|
|
ts.isJsxAttribute(node) &&
|
|
node.initializer &&
|
|
ts.isJsxExpression(node.initializer) &&
|
|
node.initializer.expression &&
|
|
isVirtualizer(node.initializer.expression)
|
|
const read =
|
|
ts.isPropertyAccessExpression(node) &&
|
|
isVirtualizer(node.expression) &&
|
|
!(STABLE_VIRTUALIZER_METHODS.has(node.name.text) && !ts.isCallExpression(node.parent))
|
|
if (passed || read) {
|
|
found.set(node, { fn, text: node.getText(sourceFile).split('\n')[0].slice(0, 100) })
|
|
return
|
|
}
|
|
ts.forEachChild(node, (child) => collect(fn, child))
|
|
}
|
|
const visit = (fn, node) => {
|
|
if (ts.isVariableDeclaration(node) && node.initializer) {
|
|
const init = unwrap(node.initializer)
|
|
const shape = virtualizerHookShape(init, hooks)
|
|
if (ts.isIdentifier(node.name)) {
|
|
if (
|
|
ts.isCallExpression(init) &&
|
|
ts.isIdentifier(init.expression) &&
|
|
runtime.has(init.expression.text)
|
|
) {
|
|
caches.add(node.name.text)
|
|
} else if (shape === 'direct' || isVirtualizer(init)) {
|
|
bound.add(node.name.text)
|
|
}
|
|
} else if (ts.isObjectBindingPattern(node.name)) {
|
|
for (const element of node.name.elements) {
|
|
const key = (element.propertyName ?? element.name).getText(sourceFile)
|
|
if (ts.isIdentifier(element.name) && (key === 'virtualizer' || typed.has(key))) {
|
|
bound.add(element.name.text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (
|
|
ts.isIfStatement(node) &&
|
|
readsCache(node.expression, caches) &&
|
|
keyedOnVirtualizer(node.expression)
|
|
) {
|
|
collect(fn, node.thenStatement)
|
|
}
|
|
const next = isFunctionNode(node) ? (reactFunctionName(node) ?? fn) : fn
|
|
ts.forEachChild(node, (child) => visit(next, child))
|
|
}
|
|
visit('(module)', sourceFile)
|
|
return [...found.values()]
|
|
}
|