mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
* chore(quality): ratchet lint and Zustand performance * fix(ci): stabilize React peer lock snapshot * fix(ci): isolate PR diff and React Doctor CLI
245 lines
6.3 KiB
JavaScript
245 lines
6.3 KiB
JavaScript
const ALLOCATING_METHODS = new Set([
|
|
'filter',
|
|
'flat',
|
|
'flatMap',
|
|
'map',
|
|
'toReversed',
|
|
'toSorted',
|
|
'toSpliced',
|
|
'with'
|
|
])
|
|
|
|
function identifierName(node) {
|
|
return node?.type === 'Identifier' ? node.name : null
|
|
}
|
|
|
|
function propertyName(node) {
|
|
if (node?.type !== 'MemberExpression') {
|
|
return null
|
|
}
|
|
if (!node.computed) {
|
|
return identifierName(node.property)
|
|
}
|
|
return node.property?.type === 'Literal' && typeof node.property.value === 'string'
|
|
? node.property.value
|
|
: null
|
|
}
|
|
|
|
function returnedExpressions(selector) {
|
|
if (selector?.type !== 'ArrowFunctionExpression' && selector?.type !== 'FunctionExpression') {
|
|
return []
|
|
}
|
|
if (selector.body.type !== 'BlockStatement') {
|
|
return [selector.body]
|
|
}
|
|
const expressions = []
|
|
const visit = (node) => {
|
|
if (!node || typeof node !== 'object') {
|
|
return
|
|
}
|
|
if (
|
|
node !== selector.body &&
|
|
['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression'].includes(node.type)
|
|
) {
|
|
return
|
|
}
|
|
if (node.type === 'ReturnStatement') {
|
|
if (node.argument) {
|
|
expressions.push(node.argument)
|
|
}
|
|
return
|
|
}
|
|
for (const [key, child] of Object.entries(node)) {
|
|
if (key === 'parent') {
|
|
continue
|
|
}
|
|
if (Array.isArray(child)) {
|
|
child.forEach(visit)
|
|
} else {
|
|
visit(child)
|
|
}
|
|
}
|
|
}
|
|
visit(selector.body)
|
|
return expressions
|
|
}
|
|
|
|
function unwrapShallowSelector(selector, shallowHooks) {
|
|
if (
|
|
selector?.type === 'CallExpression' &&
|
|
selector.callee.type === 'Identifier' &&
|
|
shallowHooks.has(selector.callee.name)
|
|
) {
|
|
return { selector: selector.arguments[0], shallow: true }
|
|
}
|
|
return { selector, shallow: false }
|
|
}
|
|
|
|
function isIdentitySelector(selector) {
|
|
if (selector?.type !== 'ArrowFunctionExpression' && selector?.type !== 'FunctionExpression') {
|
|
return false
|
|
}
|
|
const parameter = selector.params[0]
|
|
if (parameter?.type !== 'Identifier') {
|
|
return false
|
|
}
|
|
return returnedExpressions(selector).some(
|
|
(expression) => expression.type === 'Identifier' && expression.name === parameter.name
|
|
)
|
|
}
|
|
|
|
function isAllocatingExpression(expression) {
|
|
if (expression?.type === 'ConditionalExpression') {
|
|
return (
|
|
isAllocatingExpression(expression.consequent) || isAllocatingExpression(expression.alternate)
|
|
)
|
|
}
|
|
if (expression?.type === 'LogicalExpression') {
|
|
return isAllocatingExpression(expression.left) || isAllocatingExpression(expression.right)
|
|
}
|
|
if (
|
|
expression?.type === 'ArrayExpression' ||
|
|
expression?.type === 'ObjectExpression' ||
|
|
expression?.type === 'NewExpression'
|
|
) {
|
|
return true
|
|
}
|
|
if (expression?.type !== 'CallExpression') {
|
|
return false
|
|
}
|
|
const method = propertyName(expression.callee)
|
|
if (method && ALLOCATING_METHODS.has(method)) {
|
|
return true
|
|
}
|
|
const callee = expression.callee
|
|
return (
|
|
callee.type === 'MemberExpression' &&
|
|
identifierName(callee.object) === 'Object' &&
|
|
['assign', 'create', 'entries', 'fromEntries', 'keys', 'values'].includes(propertyName(callee))
|
|
)
|
|
}
|
|
|
|
function importedLocalName(specifier, importedName) {
|
|
if (specifier.type !== 'ImportSpecifier' || identifierName(specifier.imported) !== importedName) {
|
|
return null
|
|
}
|
|
return identifierName(specifier.local)
|
|
}
|
|
|
|
function createRuleState() {
|
|
return {
|
|
appStoreHooks: new Set(),
|
|
shallowHooks: new Set()
|
|
}
|
|
}
|
|
|
|
function recordImports(node, state) {
|
|
if (node.source?.value === 'zustand/react/shallow') {
|
|
for (const specifier of node.specifiers) {
|
|
const localName = importedLocalName(specifier, 'useShallow')
|
|
if (localName) {
|
|
state.shallowHooks.add(localName)
|
|
}
|
|
}
|
|
}
|
|
for (const specifier of node.specifiers) {
|
|
const localName = importedLocalName(specifier, 'useAppStore')
|
|
if (localName) {
|
|
state.appStoreHooks.add(localName)
|
|
}
|
|
}
|
|
}
|
|
|
|
function isAppStoreCall(node, state) {
|
|
return (
|
|
node.callee.type === 'Identifier' &&
|
|
state.appStoreHooks.has(node.callee.name) &&
|
|
node.optional !== true
|
|
)
|
|
}
|
|
|
|
function requireSelectorRule() {
|
|
const state = createRuleState()
|
|
return {
|
|
ImportDeclaration(node) {
|
|
recordImports(node, state)
|
|
},
|
|
CallExpression(node) {
|
|
if (isAppStoreCall(node, state) && node.arguments.length === 0) {
|
|
this.report({
|
|
node,
|
|
message:
|
|
'Pass a selector to useAppStore so the component does not rerender for every store write.'
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function noIdentitySelectorRule() {
|
|
const state = createRuleState()
|
|
return {
|
|
ImportDeclaration(node) {
|
|
recordImports(node, state)
|
|
},
|
|
CallExpression(node) {
|
|
if (!isAppStoreCall(node, state)) {
|
|
return
|
|
}
|
|
const { selector } = unwrapShallowSelector(node.arguments[0], state.shallowHooks)
|
|
if (isIdentitySelector(selector)) {
|
|
this.report({
|
|
node: selector,
|
|
message:
|
|
'Select the smallest required fields instead of subscribing to the entire app store.'
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function noFreshSelectorResultRule() {
|
|
const state = createRuleState()
|
|
return {
|
|
ImportDeclaration(node) {
|
|
recordImports(node, state)
|
|
},
|
|
CallExpression(node) {
|
|
if (!isAppStoreCall(node, state)) {
|
|
return
|
|
}
|
|
const { selector, shallow } = unwrapShallowSelector(node.arguments[0], state.shallowHooks)
|
|
if (shallow) {
|
|
return
|
|
}
|
|
const freshResult = returnedExpressions(selector).find(isAllocatingExpression)
|
|
if (freshResult) {
|
|
this.report({
|
|
node: freshResult,
|
|
message:
|
|
'This selector returns a fresh reference on every store write; select a stable field, cache the result, or use useShallow.'
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function bindContext(createVisitors) {
|
|
return (context) => {
|
|
const visitors = createVisitors()
|
|
for (const [nodeType, visit] of Object.entries(visitors)) {
|
|
visitors[nodeType] = visit.bind(context)
|
|
}
|
|
return visitors
|
|
}
|
|
}
|
|
|
|
export default {
|
|
meta: { name: 'app-store-performance' },
|
|
rules: {
|
|
'require-selector': { create: bindContext(requireSelectorRule) },
|
|
'no-identity-selector': { create: bindContext(noIdentitySelectorRule) },
|
|
'no-fresh-selector-result': { create: bindContext(noFreshSelectorResultRule) }
|
|
}
|
|
}
|