oxlint-plugin-react-doctor 0.2.8 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +68 -0
- package/dist/index.js +927 -359
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -176,7 +176,8 @@ const SUBSCRIPTION_METHOD_NAMES = new Set([
|
|
|
176
176
|
"listen",
|
|
177
177
|
"sub"
|
|
178
178
|
]);
|
|
179
|
-
const
|
|
179
|
+
const CLEANUP_RETURNING_SUBSCRIPTION_METHOD_NAMES = new Set(["subscribe", "sub"]);
|
|
180
|
+
const GLOBAL_RELEASE_METHOD_NAMES = new Set([
|
|
180
181
|
"unsubscribe",
|
|
181
182
|
"removeEventListener",
|
|
182
183
|
"removeListener",
|
|
@@ -186,8 +187,16 @@ const UNSUBSCRIPTION_METHOD_NAMES = new Set([
|
|
|
186
187
|
"unsub",
|
|
187
188
|
"abort"
|
|
188
189
|
]);
|
|
190
|
+
const BOUND_RESOURCE_RELEASE_METHOD_NAMES = new Set([
|
|
191
|
+
"remove",
|
|
192
|
+
"cleanup",
|
|
193
|
+
"dispose",
|
|
194
|
+
"destroy",
|
|
195
|
+
"stop",
|
|
196
|
+
"teardown"
|
|
197
|
+
]);
|
|
189
198
|
const CLEANUP_LIKE_RELEASE_CALLEE_NAMES = new Set([
|
|
190
|
-
...
|
|
199
|
+
...GLOBAL_RELEASE_METHOD_NAMES,
|
|
191
200
|
"cleanup",
|
|
192
201
|
"dispose",
|
|
193
202
|
"destroy",
|
|
@@ -416,9 +425,10 @@ const sliceBelowSourceRoot = (filename) => {
|
|
|
416
425
|
if (cutAt < 0) return filename;
|
|
417
426
|
return filename.slice(cutAt);
|
|
418
427
|
};
|
|
419
|
-
const isTestlikeFilename = (
|
|
420
|
-
if (!
|
|
421
|
-
const
|
|
428
|
+
const isTestlikeFilename = (rawFilename) => {
|
|
429
|
+
if (!rawFilename) return false;
|
|
430
|
+
const filename = rawFilename.replaceAll("\\", "/");
|
|
431
|
+
const lastSlash = filename.lastIndexOf("/");
|
|
422
432
|
const basename = lastSlash === -1 ? filename : filename.slice(lastSlash + 1);
|
|
423
433
|
if (NON_PRODUCTION_BASENAMES.has(basename.toLowerCase())) return true;
|
|
424
434
|
for (const suffix of NON_PRODUCTION_FILENAME_SUFFIXES) if (basename.includes(suffix)) return true;
|
|
@@ -2882,6 +2892,9 @@ const asyncDeferAwait = defineRule({
|
|
|
2882
2892
|
}
|
|
2883
2893
|
});
|
|
2884
2894
|
//#endregion
|
|
2895
|
+
//#region src/plugin/utils/normalize-filename.ts
|
|
2896
|
+
const normalizeFilename$1 = (filename) => filename.replaceAll("\\", "/");
|
|
2897
|
+
//#endregion
|
|
2885
2898
|
//#region src/plugin/utils/get-callee-identifier-trail.ts
|
|
2886
2899
|
const getCalleeIdentifierTrail = (call) => {
|
|
2887
2900
|
let entry = call;
|
|
@@ -2970,7 +2983,7 @@ const asyncParallel = defineRule({
|
|
|
2970
2983
|
severity: "warn",
|
|
2971
2984
|
recommendation: "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
|
|
2972
2985
|
create: (context) => {
|
|
2973
|
-
const filename = context.getFilename?.() ?? "";
|
|
2986
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
2974
2987
|
const isBrowserTestFile = BROWSER_TEST_FILE_PATTERN.test(filename);
|
|
2975
2988
|
let hasTestLibraryImport = false;
|
|
2976
2989
|
const shouldSkipFile = () => isBrowserTestFile || hasTestLibraryImport;
|
|
@@ -4235,42 +4248,99 @@ const walkInsideStatementBlocks = (node, visitor) => {
|
|
|
4235
4248
|
};
|
|
4236
4249
|
//#endregion
|
|
4237
4250
|
//#region src/plugin/rules/state-and-effects/utils/is-subscribe-like-call-expression.ts
|
|
4251
|
+
const getSubscribeLikeMethodName = (node) => {
|
|
4252
|
+
if (!isNodeOfType(node, "CallExpression")) return null;
|
|
4253
|
+
if (!isNodeOfType(node.callee, "MemberExpression")) return null;
|
|
4254
|
+
if (!isNodeOfType(node.callee.property, "Identifier")) return null;
|
|
4255
|
+
return node.callee.property.name;
|
|
4256
|
+
};
|
|
4238
4257
|
const isSubscribeLikeCallExpression = (node) => {
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4258
|
+
const methodName = getSubscribeLikeMethodName(node);
|
|
4259
|
+
return methodName !== null && SUBSCRIPTION_METHOD_NAMES.has(methodName);
|
|
4260
|
+
};
|
|
4261
|
+
const isCleanupReturningSubscribeLikeCallExpression = (node) => {
|
|
4262
|
+
const methodName = getSubscribeLikeMethodName(node);
|
|
4263
|
+
return methodName !== null && CLEANUP_RETURNING_SUBSCRIPTION_METHOD_NAMES.has(methodName);
|
|
4243
4264
|
};
|
|
4244
4265
|
//#endregion
|
|
4245
4266
|
//#region src/plugin/rules/state-and-effects/utils/is-cleanup-return.ts
|
|
4246
|
-
const
|
|
4267
|
+
const ITERATOR_CALLBACK_METHOD_NAMES = new Set([
|
|
4268
|
+
"each",
|
|
4269
|
+
"every",
|
|
4270
|
+
"filter",
|
|
4271
|
+
"find",
|
|
4272
|
+
"findIndex",
|
|
4273
|
+
"findLast",
|
|
4274
|
+
"findLastIndex",
|
|
4275
|
+
"flatMap",
|
|
4276
|
+
"forEach",
|
|
4277
|
+
"map",
|
|
4278
|
+
"reduce",
|
|
4279
|
+
"reduceRight",
|
|
4280
|
+
"some",
|
|
4281
|
+
"sort",
|
|
4282
|
+
"toSorted"
|
|
4283
|
+
]);
|
|
4284
|
+
const STATIC_ITERATOR_CALLBACK_METHOD_NAMES = new Set([
|
|
4285
|
+
"from",
|
|
4286
|
+
"fromAsync",
|
|
4287
|
+
"groupBy"
|
|
4288
|
+
]);
|
|
4289
|
+
const unwrapChainExpression$2 = (node) => isNodeOfType(node, "ChainExpression") ? node.expression : node;
|
|
4290
|
+
const isNullLiteral = (node) => isNodeOfType(node, "Literal") && node.value === null;
|
|
4291
|
+
const isListenerRemovalViaNullHandler = (callNode) => {
|
|
4292
|
+
if (!isNodeOfType(callNode, "CallExpression")) return false;
|
|
4293
|
+
const callee = unwrapChainExpression$2(callNode.callee);
|
|
4294
|
+
return isNodeOfType(callee, "MemberExpression") && isNodeOfType(callee.property, "Identifier") && callee.property.name === "on" && isNullLiteral(callNode.arguments?.[1]);
|
|
4295
|
+
};
|
|
4296
|
+
const isReleaseLikeCall = (node, knownCleanupFunctionNames, knownBoundSubscriptionNames) => {
|
|
4297
|
+
const callNode = unwrapChainExpression$2(node);
|
|
4247
4298
|
if (!isNodeOfType(callNode, "CallExpression")) return false;
|
|
4248
|
-
|
|
4299
|
+
if (isListenerRemovalViaNullHandler(callNode)) return true;
|
|
4300
|
+
const callee = unwrapChainExpression$2(callNode.callee);
|
|
4249
4301
|
if (isNodeOfType(callee, "Identifier")) {
|
|
4250
4302
|
if (TIMER_CLEANUP_CALLEE_NAMES.has(callee.name)) return true;
|
|
4251
4303
|
if (CLEANUP_LIKE_RELEASE_CALLEE_NAMES.has(callee.name)) return true;
|
|
4252
|
-
if (
|
|
4304
|
+
if (knownCleanupFunctionNames.has(callee.name)) return true;
|
|
4253
4305
|
return false;
|
|
4254
4306
|
}
|
|
4255
|
-
if (isNodeOfType(callee, "MemberExpression") && isNodeOfType(callee.property, "Identifier"))
|
|
4307
|
+
if (isNodeOfType(callee, "MemberExpression") && isNodeOfType(callee.property, "Identifier")) {
|
|
4308
|
+
if (BOUND_RESOURCE_RELEASE_METHOD_NAMES.has(callee.property.name) && isNodeOfType(callee.object, "Identifier") && knownBoundSubscriptionNames.has(callee.object.name)) return true;
|
|
4309
|
+
return GLOBAL_RELEASE_METHOD_NAMES.has(callee.property.name);
|
|
4310
|
+
}
|
|
4256
4311
|
return false;
|
|
4257
4312
|
};
|
|
4258
|
-
const
|
|
4313
|
+
const isStaticIteratorCallbackCallee = (callee) => isNodeOfType(callee, "MemberExpression") && isNodeOfType(callee.object, "Identifier") && isNodeOfType(callee.property, "Identifier") && (callee.object.name === "Array" || callee.object.name === "Map" || callee.object.name === "Object") && STATIC_ITERATOR_CALLBACK_METHOD_NAMES.has(callee.property.name);
|
|
4314
|
+
const isIteratorCallbackArgument = (node) => {
|
|
4315
|
+
const parentNode = node.parent;
|
|
4316
|
+
if (!isNodeOfType(parentNode, "CallExpression")) return false;
|
|
4317
|
+
if (!parentNode.arguments?.some((argument) => argument === node)) return false;
|
|
4318
|
+
const callee = unwrapChainExpression$2(parentNode.callee);
|
|
4319
|
+
if (parentNode.arguments[1] === node && isStaticIteratorCallbackCallee(callee)) return true;
|
|
4320
|
+
return isNodeOfType(callee, "MemberExpression") && isNodeOfType(callee.property, "Identifier") && ITERATOR_CALLBACK_METHOD_NAMES.has(callee.property.name);
|
|
4321
|
+
};
|
|
4322
|
+
const containsReleaseLikeCall = (node, knownCleanupFunctionNames, knownBoundSubscriptionNames) => {
|
|
4259
4323
|
let didFindRelease = false;
|
|
4260
4324
|
walkAst(node, (child) => {
|
|
4261
4325
|
if (didFindRelease) return false;
|
|
4262
|
-
if (
|
|
4326
|
+
if (child !== node && isFunctionLike(child) && !isIteratorCallbackArgument(child)) return false;
|
|
4327
|
+
if (isReleaseLikeCall(child, knownCleanupFunctionNames, knownBoundSubscriptionNames)) {
|
|
4263
4328
|
didFindRelease = true;
|
|
4264
4329
|
return false;
|
|
4265
4330
|
}
|
|
4266
4331
|
});
|
|
4267
4332
|
return didFindRelease;
|
|
4268
4333
|
};
|
|
4269
|
-
const
|
|
4334
|
+
const isCleanupFunctionLike = (node, knownCleanupFunctionNames, knownBoundSubscriptionNames) => {
|
|
4335
|
+
if (!isFunctionLike(node)) return false;
|
|
4336
|
+
return containsReleaseLikeCall(node.body, knownCleanupFunctionNames, knownBoundSubscriptionNames);
|
|
4337
|
+
};
|
|
4338
|
+
const isCleanupReturn = (returnedValue, knownCleanupFunctionNames, knownBoundSubscriptionNames) => {
|
|
4270
4339
|
if (!returnedValue) return false;
|
|
4271
|
-
|
|
4272
|
-
if (
|
|
4273
|
-
if (
|
|
4340
|
+
const unwrappedValue = unwrapChainExpression$2(returnedValue);
|
|
4341
|
+
if (isNodeOfType(unwrappedValue, "Identifier")) return knownCleanupFunctionNames.has(unwrappedValue.name);
|
|
4342
|
+
if (isCleanupReturningSubscribeLikeCallExpression(unwrappedValue)) return true;
|
|
4343
|
+
if (isCleanupFunctionLike(unwrappedValue, knownCleanupFunctionNames, knownBoundSubscriptionNames)) return true;
|
|
4274
4344
|
return false;
|
|
4275
4345
|
};
|
|
4276
4346
|
//#endregion
|
|
@@ -4285,7 +4355,7 @@ const findSubscribeLikeUsages = (callback) => {
|
|
|
4285
4355
|
if (isNodeOfType(lastCallbackStatement, "ReturnStatement") && lastCallbackStatement.argument) cleanupArgument = lastCallbackStatement.argument;
|
|
4286
4356
|
}
|
|
4287
4357
|
walkAst(callback, (child) => {
|
|
4288
|
-
if (child === cleanupArgument) return false;
|
|
4358
|
+
if (child === cleanupArgument && !isSubscribeLikeCallExpression(child)) return false;
|
|
4289
4359
|
if (!isNodeOfType(child, "CallExpression")) return;
|
|
4290
4360
|
if (isNodeOfType(child.callee, "Identifier") && TIMER_CALLEE_NAMES_REQUIRING_CLEANUP.has(child.callee.name)) {
|
|
4291
4361
|
usages.push({
|
|
@@ -4301,34 +4371,56 @@ const findSubscribeLikeUsages = (callback) => {
|
|
|
4301
4371
|
});
|
|
4302
4372
|
return usages;
|
|
4303
4373
|
};
|
|
4304
|
-
const
|
|
4305
|
-
const
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4374
|
+
const collectCleanupBindings = (effectCallback) => {
|
|
4375
|
+
const bindings = {
|
|
4376
|
+
cleanupFunctionNames: /* @__PURE__ */ new Set(),
|
|
4377
|
+
subscriptionNames: /* @__PURE__ */ new Set(),
|
|
4378
|
+
effectScopeVariableNames: /* @__PURE__ */ new Set()
|
|
4379
|
+
};
|
|
4380
|
+
if (!isNodeOfType(effectCallback, "ArrowFunctionExpression") && !isNodeOfType(effectCallback, "FunctionExpression")) return bindings;
|
|
4381
|
+
if (!isNodeOfType(effectCallback.body, "BlockStatement")) return bindings;
|
|
4382
|
+
walkInsideStatementBlocks(effectCallback.body, (child) => {
|
|
4383
|
+
if (!isNodeOfType(child, "VariableDeclaration")) return;
|
|
4384
|
+
for (const declarator of child.declarations ?? []) {
|
|
4311
4385
|
if (!isNodeOfType(declarator.id, "Identifier")) continue;
|
|
4386
|
+
const bindingName = declarator.id.name;
|
|
4387
|
+
bindings.effectScopeVariableNames.add(bindingName);
|
|
4312
4388
|
const init = declarator.init;
|
|
4313
4389
|
if (!init || !isNodeOfType(init, "CallExpression")) continue;
|
|
4314
4390
|
if (isSubscribeLikeCallExpression(init)) {
|
|
4315
|
-
|
|
4316
|
-
|
|
4391
|
+
bindings.subscriptionNames.add(bindingName);
|
|
4392
|
+
if (isCleanupReturningSubscribeLikeCallExpression(init)) bindings.cleanupFunctionNames.add(bindingName);
|
|
4317
4393
|
}
|
|
4318
|
-
if (isNodeOfType(init.callee, "Identifier") && TIMER_CALLEE_NAMES_REQUIRING_CLEANUP.has(init.callee.name)) releasableNames.add(declarator.id.name);
|
|
4319
4394
|
}
|
|
4320
|
-
}
|
|
4321
|
-
|
|
4395
|
+
});
|
|
4396
|
+
walkAst(effectCallback.body, (child) => {
|
|
4397
|
+
if (child !== effectCallback.body && (isNodeOfType(child, "ArrowFunctionExpression") || isNodeOfType(child, "FunctionExpression"))) return false;
|
|
4398
|
+
if (isNodeOfType(child, "FunctionDeclaration") && child.id && isCleanupFunctionLike(child, bindings.cleanupFunctionNames, bindings.subscriptionNames)) {
|
|
4399
|
+
bindings.cleanupFunctionNames.add(child.id.name);
|
|
4400
|
+
return false;
|
|
4401
|
+
}
|
|
4402
|
+
});
|
|
4403
|
+
walkInsideStatementBlocks(effectCallback.body, (child) => {
|
|
4404
|
+
if (!isNodeOfType(child, "VariableDeclaration")) return;
|
|
4405
|
+
for (const declarator of child.declarations ?? []) {
|
|
4406
|
+
if (!isNodeOfType(declarator.id, "Identifier") || !declarator.init) continue;
|
|
4407
|
+
if (isCleanupFunctionLike(declarator.init, bindings.cleanupFunctionNames, bindings.subscriptionNames)) bindings.cleanupFunctionNames.add(declarator.id.name);
|
|
4408
|
+
}
|
|
4409
|
+
});
|
|
4410
|
+
walkAst(effectCallback.body, (child) => {
|
|
4411
|
+
if (isNodeOfType(child, "AssignmentExpression") && isNodeOfType(child.left, "Identifier") && bindings.effectScopeVariableNames.has(child.left.name) && isCleanupFunctionLike(child.right, bindings.cleanupFunctionNames, bindings.subscriptionNames)) bindings.cleanupFunctionNames.add(child.left.name);
|
|
4412
|
+
});
|
|
4413
|
+
return bindings;
|
|
4322
4414
|
};
|
|
4323
4415
|
const effectHasCleanupRelease = (callback) => {
|
|
4324
4416
|
if (!isNodeOfType(callback, "ArrowFunctionExpression") && !isNodeOfType(callback, "FunctionExpression")) return false;
|
|
4325
|
-
if (!isNodeOfType(callback.body, "BlockStatement")) return
|
|
4326
|
-
const
|
|
4417
|
+
if (!isNodeOfType(callback.body, "BlockStatement")) return isCleanupReturningSubscribeLikeCallExpression(callback.body);
|
|
4418
|
+
const cleanupBindings = collectCleanupBindings(callback);
|
|
4327
4419
|
let didFindCleanupReturn = false;
|
|
4328
4420
|
walkInsideStatementBlocks(callback.body, (child) => {
|
|
4329
4421
|
if (didFindCleanupReturn) return;
|
|
4330
4422
|
if (!isNodeOfType(child, "ReturnStatement")) return;
|
|
4331
|
-
if (isCleanupReturn(child.argument,
|
|
4423
|
+
if (isCleanupReturn(child.argument, cleanupBindings.cleanupFunctionNames, cleanupBindings.subscriptionNames)) didFindCleanupReturn = true;
|
|
4332
4424
|
});
|
|
4333
4425
|
return didFindCleanupReturn;
|
|
4334
4426
|
};
|
|
@@ -5493,7 +5585,19 @@ const exhaustiveDeps = defineRule({
|
|
|
5493
5585
|
id: "exhaustive-deps",
|
|
5494
5586
|
severity: "warn",
|
|
5495
5587
|
tags: ["test-noise"],
|
|
5496
|
-
recommendation:
|
|
5588
|
+
recommendation: `Don't blindly add missing dependencies. Read the hook callback first.
|
|
5589
|
+
|
|
5590
|
+
Bad:
|
|
5591
|
+
useEffect(() => {
|
|
5592
|
+
setCount(count + 1);
|
|
5593
|
+
}, [count]);
|
|
5594
|
+
|
|
5595
|
+
Better:
|
|
5596
|
+
useEffect(() => {
|
|
5597
|
+
setCount((currentCount) => currentCount + 1);
|
|
5598
|
+
}, []);
|
|
5599
|
+
|
|
5600
|
+
If the missing value is recreated every render, move it inside the hook or stabilize it before adding it to deps.`,
|
|
5497
5601
|
category: "Correctness",
|
|
5498
5602
|
create: (context) => {
|
|
5499
5603
|
const settings = resolveExhaustiveDepsSettings(context.settings);
|
|
@@ -7711,7 +7815,7 @@ const jsxFilenameExtension = defineRule({
|
|
|
7711
7815
|
const settings = resolveSettings$34(context.settings);
|
|
7712
7816
|
const allowedExtensions = normalizeExtensions(settings.extensions);
|
|
7713
7817
|
const allowedList = [...allowedExtensions].map((extension) => `.${extension}`).join(", ");
|
|
7714
|
-
const filename = context.getFilename ? context.getFilename() : "fixture.tsx";
|
|
7818
|
+
const filename = context.getFilename ? normalizeFilename$1(context.getFilename()) : "fixture.tsx";
|
|
7715
7819
|
const extensionOnly = path.extname(filename).slice(1);
|
|
7716
7820
|
const fileHasAllowedExtension = allowedExtensions.has(extensionOnly);
|
|
7717
7821
|
let didReportMismatch = false;
|
|
@@ -11348,7 +11452,7 @@ const nextjsMissingMetadata = defineRule({
|
|
|
11348
11452
|
severity: "warn",
|
|
11349
11453
|
recommendation: "Add `export const metadata = { title: '...', description: '...' }` or `export async function generateMetadata()`",
|
|
11350
11454
|
create: (context) => ({ Program(programNode) {
|
|
11351
|
-
const filename = context.getFilename?.() ?? "";
|
|
11455
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
11352
11456
|
if (!PAGE_FILE_PATTERN.test(filename)) return;
|
|
11353
11457
|
if (INTERNAL_PAGE_PATH_PATTERN.test(filename)) return;
|
|
11354
11458
|
if (!programNode.body?.some((statement) => {
|
|
@@ -11413,7 +11517,7 @@ const nextjsNoClientFetchForServerData = defineRule({
|
|
|
11413
11517
|
if (!fileHasUseClient || !isHookCall$1(node, EFFECT_HOOK_NAMES$1)) return;
|
|
11414
11518
|
const callback = getEffectCallback(node);
|
|
11415
11519
|
if (!callback || !containsFetchCall(callback)) return;
|
|
11416
|
-
const filename = context.getFilename?.() ?? "";
|
|
11520
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
11417
11521
|
if (PAGE_OR_LAYOUT_FILE_PATTERN.test(filename) || PAGES_DIRECTORY_PATTERN.test(filename)) context.report({
|
|
11418
11522
|
node,
|
|
11419
11523
|
message: "useEffect + fetch in a page/layout — fetch data server-side with a server component instead"
|
|
@@ -11446,7 +11550,7 @@ const nextjsNoClientSideRedirect = defineRule({
|
|
|
11446
11550
|
severity: "warn",
|
|
11447
11551
|
recommendation: "Avoid redirects inside useEffect. Use an event handler, middleware, or server-side redirect (App Router: redirect() from next/navigation; Pages Router: getServerSideProps redirect)",
|
|
11448
11552
|
create: (context) => {
|
|
11449
|
-
const filename = context.getFilename?.() ?? "";
|
|
11553
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
11450
11554
|
const isPagesRouterFile = PAGES_DIRECTORY_PATTERN.test(filename);
|
|
11451
11555
|
return { CallExpression(node) {
|
|
11452
11556
|
if (!isHookCall$1(node, EFFECT_HOOK_NAMES$1)) return;
|
|
@@ -11515,7 +11619,7 @@ const nextjsNoHeadImport = defineRule({
|
|
|
11515
11619
|
recommendation: "Use the Metadata API instead: `export const metadata = { title: '...' }` or `export async function generateMetadata()`",
|
|
11516
11620
|
create: (context) => ({ ImportDeclaration(node) {
|
|
11517
11621
|
if (node.source?.value !== "next/head") return;
|
|
11518
|
-
const filename = context.getFilename?.() ?? "";
|
|
11622
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
11519
11623
|
if (!APP_DIRECTORY_PATTERN.test(filename)) return;
|
|
11520
11624
|
context.report({
|
|
11521
11625
|
node,
|
|
@@ -11532,7 +11636,7 @@ const nextjsNoImgElement = defineRule({
|
|
|
11532
11636
|
severity: "warn",
|
|
11533
11637
|
recommendation: "`import Image from 'next/image'` — provides automatic WebP/AVIF, lazy loading, and responsive srcset",
|
|
11534
11638
|
create: (context) => {
|
|
11535
|
-
const filename = context.getFilename?.() ?? "";
|
|
11639
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
11536
11640
|
const isOgRoute = OG_ROUTE_PATTERN.test(filename);
|
|
11537
11641
|
return { JSXOpeningElement(node) {
|
|
11538
11642
|
if (isOgRoute) return;
|
|
@@ -11770,8 +11874,8 @@ const findSideEffect = (node, options = {}) => {
|
|
|
11770
11874
|
};
|
|
11771
11875
|
//#endregion
|
|
11772
11876
|
//#region src/plugin/rules/nextjs/nextjs-no-side-effect-in-get-handler.ts
|
|
11773
|
-
const extractMutatingRouteSegment = (
|
|
11774
|
-
const segments =
|
|
11877
|
+
const extractMutatingRouteSegment = (rawFilename) => {
|
|
11878
|
+
const segments = rawFilename.replaceAll("\\", "/").split("/");
|
|
11775
11879
|
for (const segment of segments) {
|
|
11776
11880
|
const cleaned = segment.replace(/^\[.*\]$/, "");
|
|
11777
11881
|
if (MUTATING_ROUTE_SEGMENTS.has(cleaned)) return cleaned;
|
|
@@ -11886,7 +11990,7 @@ const nextjsNoSideEffectInGetHandler = defineRule({
|
|
|
11886
11990
|
resolveBinding = buildProgramBindingLookup(node);
|
|
11887
11991
|
},
|
|
11888
11992
|
ExportNamedDeclaration(node) {
|
|
11889
|
-
const filename = context.getFilename?.() ?? "";
|
|
11993
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
11890
11994
|
if (!ROUTE_HANDLER_FILE_PATTERN.test(filename)) return;
|
|
11891
11995
|
if (CRON_ROUTE_PATTERN.test(filename)) return;
|
|
11892
11996
|
if (!isExportedGetHandler(node)) return;
|
|
@@ -13500,7 +13604,7 @@ const noBarrelImport = defineRule({
|
|
|
13500
13604
|
if (didReportForFile) return;
|
|
13501
13605
|
const source = node.source?.value;
|
|
13502
13606
|
if (typeof source !== "string" || !source.startsWith(".")) return;
|
|
13503
|
-
const filename = context.getFilename?.() ?? "";
|
|
13607
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
13504
13608
|
if (!filename) return;
|
|
13505
13609
|
const importRequests = getRuntimeImportRequests(node);
|
|
13506
13610
|
if (importRequests.length === 0) return;
|
|
@@ -13732,6 +13836,14 @@ const isImportedFromModule = (contextNode, localIdentifierName, moduleSource) =>
|
|
|
13732
13836
|
if (!info) return false;
|
|
13733
13837
|
return info.source === moduleSource;
|
|
13734
13838
|
};
|
|
13839
|
+
const getImportedNameFromModule = (contextNode, localIdentifierName, moduleSource) => {
|
|
13840
|
+
const lookup = getImportLookup(contextNode);
|
|
13841
|
+
if (!lookup) return null;
|
|
13842
|
+
const info = lookup.get(localIdentifierName);
|
|
13843
|
+
if (!info) return null;
|
|
13844
|
+
if (info.source !== moduleSource) return null;
|
|
13845
|
+
return info.imported;
|
|
13846
|
+
};
|
|
13735
13847
|
//#endregion
|
|
13736
13848
|
//#region src/plugin/rules/react-builtins/no-clone-element.ts
|
|
13737
13849
|
const MESSAGE$21 = "`React.cloneElement` is uncommon and leads to fragile components.";
|
|
@@ -19226,7 +19338,7 @@ const noSecretsInClientCode = defineRule({
|
|
|
19226
19338
|
severity: "warn",
|
|
19227
19339
|
recommendation: "Move secrets to server-only code. Public client environment variables are bundled into browser code and must not contain secrets",
|
|
19228
19340
|
create: (context) => {
|
|
19229
|
-
const filename = context.getFilename?.() ?? "";
|
|
19341
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
19230
19342
|
const framework = getReactDoctorStringSetting(context.settings, "framework");
|
|
19231
19343
|
const rootDirectory = getReactDoctorStringSetting(context.settings, "rootDirectory");
|
|
19232
19344
|
let shouldUseVariableNameHeuristic = classifySecretFileExposure(filename, {
|
|
@@ -21205,6 +21317,15 @@ const noUnstableNestedComponents = defineRule({
|
|
|
21205
21317
|
}
|
|
21206
21318
|
});
|
|
21207
21319
|
//#endregion
|
|
21320
|
+
//#region src/plugin/utils/is-canonical-react-namespace-name.ts
|
|
21321
|
+
const isCanonicalReactNamespaceName = (namespaceName) => {
|
|
21322
|
+
if (namespaceName === "React") return true;
|
|
21323
|
+
if (namespaceName === "react") return true;
|
|
21324
|
+
if (namespaceName.startsWith("_react")) return true;
|
|
21325
|
+
if (namespaceName.startsWith("_React")) return true;
|
|
21326
|
+
return false;
|
|
21327
|
+
};
|
|
21328
|
+
//#endregion
|
|
21208
21329
|
//#region src/plugin/rules/performance/no-usememo-simple-expression.ts
|
|
21209
21330
|
const isSimpleExpression = (node) => {
|
|
21210
21331
|
if (!node) return false;
|
|
@@ -21237,7 +21358,7 @@ const noUsememoSimpleExpression = defineRule({
|
|
|
21237
21358
|
const namespaceIdentifier = node.callee.object;
|
|
21238
21359
|
if (isNodeOfType(namespaceIdentifier, "Identifier")) {
|
|
21239
21360
|
const namespaceName = namespaceIdentifier.name;
|
|
21240
|
-
if (!(namespaceName
|
|
21361
|
+
if (!isCanonicalReactNamespaceName(namespaceName) && !isImportedFromModule(namespaceIdentifier, namespaceName, "react")) return;
|
|
21241
21362
|
}
|
|
21242
21363
|
}
|
|
21243
21364
|
const callback = node.arguments?.[0];
|
|
@@ -21695,7 +21816,7 @@ const onlyExportComponents = defineRule({
|
|
|
21695
21816
|
allowConstantExport: settings.allowConstantExport
|
|
21696
21817
|
};
|
|
21697
21818
|
return { Program(node) {
|
|
21698
|
-
if (!isFileNameAllowed(context.getFilename ? context.getFilename() : void 0, settings.checkJS)) return;
|
|
21819
|
+
if (!isFileNameAllowed(context.getFilename ? normalizeFilename$1(context.getFilename()) : void 0, settings.checkJS)) return;
|
|
21699
21820
|
const allNodes = collectAllNodes(node);
|
|
21700
21821
|
const exports = [];
|
|
21701
21822
|
let hasReactExport = false;
|
|
@@ -22163,9 +22284,11 @@ const findSubscriptionCall = (effectBodyStatements) => {
|
|
|
22163
22284
|
if (!isNodeOfType(init.callee, "MemberExpression")) continue;
|
|
22164
22285
|
if (!isNodeOfType(init.callee.property, "Identifier")) continue;
|
|
22165
22286
|
if (!SUBSCRIPTION_METHOD_NAMES.has(init.callee.property.name)) continue;
|
|
22287
|
+
const boundSubscriptionName = isNodeOfType(declarator.id, "Identifier") ? declarator.id.name : null;
|
|
22166
22288
|
return {
|
|
22167
22289
|
call: init,
|
|
22168
|
-
|
|
22290
|
+
boundReleaseName: boundSubscriptionName && isCleanupReturningSubscribeLikeCallExpression(init) ? boundSubscriptionName : null,
|
|
22291
|
+
boundSubscriptionName
|
|
22169
22292
|
};
|
|
22170
22293
|
}
|
|
22171
22294
|
if (isNodeOfType(statement, "ExpressionStatement")) {
|
|
@@ -22176,7 +22299,8 @@ const findSubscriptionCall = (effectBodyStatements) => {
|
|
|
22176
22299
|
if (!SUBSCRIPTION_METHOD_NAMES.has(expression.callee.property.name)) continue;
|
|
22177
22300
|
return {
|
|
22178
22301
|
call: expression,
|
|
22179
|
-
|
|
22302
|
+
boundReleaseName: null,
|
|
22303
|
+
boundSubscriptionName: null
|
|
22180
22304
|
};
|
|
22181
22305
|
}
|
|
22182
22306
|
}
|
|
@@ -22212,12 +22336,14 @@ const getSingleSetterCallFromHandler = (handler) => {
|
|
|
22212
22336
|
setterArgument: expression.arguments[0]
|
|
22213
22337
|
};
|
|
22214
22338
|
};
|
|
22215
|
-
const cleanupReleasesSubscription = (effectBodyStatements,
|
|
22339
|
+
const cleanupReleasesSubscription = (effectBodyStatements, boundReleaseName, boundSubscriptionName) => {
|
|
22216
22340
|
const lastStatement = effectBodyStatements[effectBodyStatements.length - 1];
|
|
22217
22341
|
if (!isNodeOfType(lastStatement, "ReturnStatement")) return false;
|
|
22218
22342
|
const knownBoundReleaseNames = /* @__PURE__ */ new Set();
|
|
22219
|
-
|
|
22220
|
-
|
|
22343
|
+
const knownBoundSubscriptionNames = /* @__PURE__ */ new Set();
|
|
22344
|
+
if (boundReleaseName) knownBoundReleaseNames.add(boundReleaseName);
|
|
22345
|
+
if (boundSubscriptionName) knownBoundSubscriptionNames.add(boundSubscriptionName);
|
|
22346
|
+
return isCleanupReturn(lastStatement.argument, knownBoundReleaseNames, knownBoundSubscriptionNames);
|
|
22221
22347
|
};
|
|
22222
22348
|
const preferUseSyncExternalStore = defineRule({
|
|
22223
22349
|
id: "prefer-use-sync-external-store",
|
|
@@ -22262,7 +22388,7 @@ const preferUseSyncExternalStore = defineRule({
|
|
|
22262
22388
|
const useStateInitializer = useStateInitializerByValueName.get(valueName);
|
|
22263
22389
|
if (!useStateInitializer) continue;
|
|
22264
22390
|
if (!areExpressionsStructurallyEqual(useStateInitializer, setterPayload.setterArgument)) continue;
|
|
22265
|
-
if (!cleanupReleasesSubscription(effectBodyStatements, subscription.
|
|
22391
|
+
if (!cleanupReleasesSubscription(effectBodyStatements, subscription.boundReleaseName, subscription.boundSubscriptionName)) continue;
|
|
22266
22392
|
const matchingBinding = useStateBindings.find((binding) => binding.valueName === valueName);
|
|
22267
22393
|
context.report({
|
|
22268
22394
|
node: matchingBinding?.declarator ?? effectCall,
|
|
@@ -22511,6 +22637,13 @@ const HOOK_OBJECTS_WITH_METHODS = new Map([
|
|
|
22511
22637
|
"set"
|
|
22512
22638
|
])]
|
|
22513
22639
|
]);
|
|
22640
|
+
const HOOK_IMPORT_SOURCES_WITH_UNSAFE_METHOD_DESTRUCTURING = new Map([["useNavigation", new Set(["@react-navigation/native", "@react-navigation/core"])]]);
|
|
22641
|
+
const isUnsafeMethodDestructureHookImport = (node, hookSource) => {
|
|
22642
|
+
const moduleSources = HOOK_IMPORT_SOURCES_WITH_UNSAFE_METHOD_DESTRUCTURING.get(hookSource);
|
|
22643
|
+
if (!moduleSources) return false;
|
|
22644
|
+
for (const moduleSource of moduleSources) if (isImportedFromModule(node, hookSource, moduleSource)) return true;
|
|
22645
|
+
return false;
|
|
22646
|
+
};
|
|
22514
22647
|
const buildHookBindingMap = (componentBody) => {
|
|
22515
22648
|
const result = /* @__PURE__ */ new Map();
|
|
22516
22649
|
if (!componentBody || !isNodeOfType(componentBody, "BlockStatement")) return result;
|
|
@@ -22567,6 +22700,7 @@ const reactCompilerDestructureMethod = defineRule({
|
|
|
22567
22700
|
if (!hookSource) return;
|
|
22568
22701
|
const allowedMethods = HOOK_OBJECTS_WITH_METHODS.get(hookSource);
|
|
22569
22702
|
if (!allowedMethods || !allowedMethods.has(methodName)) return;
|
|
22703
|
+
if (isUnsafeMethodDestructureHookImport(node, hookSource)) return;
|
|
22570
22704
|
if (!isNodeOfType(node.parent, "CallExpression") || node.parent.callee !== node) return;
|
|
22571
22705
|
context.report({
|
|
22572
22706
|
node,
|
|
@@ -22577,6 +22711,51 @@ const reactCompilerDestructureMethod = defineRule({
|
|
|
22577
22711
|
}
|
|
22578
22712
|
});
|
|
22579
22713
|
//#endregion
|
|
22714
|
+
//#region src/plugin/rules/architecture/react-compiler-no-manual-memoization.ts
|
|
22715
|
+
const REMOVAL_MESSAGE_BY_REACT_API_NAME = new Map([
|
|
22716
|
+
["useMemo", "Remove `useMemo` — React Compiler auto-memoizes every value in this component. Manual `useMemo` adds noise without improving performance."],
|
|
22717
|
+
["useCallback", "Remove `useCallback` — React Compiler auto-memoizes every function in this component. Manual `useCallback` adds noise without improving performance."],
|
|
22718
|
+
["memo", "Remove `memo()` — React Compiler memoizes component output automatically. Wrapping with `memo` is redundant and obscures the component tree."]
|
|
22719
|
+
]);
|
|
22720
|
+
const resolveReactApiNameForIdentifier = (callee) => {
|
|
22721
|
+
if (!isNodeOfType(callee, "Identifier")) return null;
|
|
22722
|
+
const importedName = getImportedNameFromModule(callee, callee.name, "react");
|
|
22723
|
+
if (importedName && REMOVAL_MESSAGE_BY_REACT_API_NAME.has(importedName)) return importedName;
|
|
22724
|
+
return null;
|
|
22725
|
+
};
|
|
22726
|
+
const resolveReactApiNameForMemberExpression = (callee) => {
|
|
22727
|
+
if (!isNodeOfType(callee, "MemberExpression")) return null;
|
|
22728
|
+
if (callee.computed) return null;
|
|
22729
|
+
const namespaceIdentifier = callee.object;
|
|
22730
|
+
const propertyIdentifier = callee.property;
|
|
22731
|
+
if (!isNodeOfType(namespaceIdentifier, "Identifier")) return null;
|
|
22732
|
+
if (!isNodeOfType(propertyIdentifier, "Identifier")) return null;
|
|
22733
|
+
if (!REMOVAL_MESSAGE_BY_REACT_API_NAME.has(propertyIdentifier.name)) return null;
|
|
22734
|
+
const namespaceName = namespaceIdentifier.name;
|
|
22735
|
+
if (isCanonicalReactNamespaceName(namespaceName)) return propertyIdentifier.name;
|
|
22736
|
+
if (isImportedFromModule(namespaceIdentifier, namespaceName, "react")) return propertyIdentifier.name;
|
|
22737
|
+
return null;
|
|
22738
|
+
};
|
|
22739
|
+
const resolveRemovalMessageForCallee = (callee) => {
|
|
22740
|
+
const apiName = resolveReactApiNameForIdentifier(callee) ?? resolveReactApiNameForMemberExpression(callee);
|
|
22741
|
+
if (!apiName) return null;
|
|
22742
|
+
return REMOVAL_MESSAGE_BY_REACT_API_NAME.get(apiName) ?? null;
|
|
22743
|
+
};
|
|
22744
|
+
const reactCompilerNoManualMemoization = defineRule({
|
|
22745
|
+
id: "react-compiler-no-manual-memoization",
|
|
22746
|
+
severity: "error",
|
|
22747
|
+
requires: ["react-compiler"],
|
|
22748
|
+
recommendation: "Delete the `useMemo` / `useCallback` / `memo` call and use the bare expression or component — React Compiler memoizes it for you.",
|
|
22749
|
+
create: (context) => ({ CallExpression(node) {
|
|
22750
|
+
const removalMessage = resolveRemovalMessageForCallee(node.callee);
|
|
22751
|
+
if (!removalMessage) return;
|
|
22752
|
+
context.report({
|
|
22753
|
+
node,
|
|
22754
|
+
message: removalMessage
|
|
22755
|
+
});
|
|
22756
|
+
} })
|
|
22757
|
+
});
|
|
22758
|
+
//#endregion
|
|
22580
22759
|
//#region src/plugin/utils/has-binding-named.ts
|
|
22581
22760
|
const hasBindingNamed = (root, bindingName) => {
|
|
22582
22761
|
const collected = /* @__PURE__ */ new Set();
|
|
@@ -22685,15 +22864,15 @@ const renderingAnimateSvgWrapper = defineRule({
|
|
|
22685
22864
|
});
|
|
22686
22865
|
//#endregion
|
|
22687
22866
|
//#region src/plugin/rules/correctness/rendering-conditional-render.ts
|
|
22688
|
-
const NUMERIC_NAME_HINTS = [
|
|
22867
|
+
const NUMERIC_NAME_HINTS$1 = [
|
|
22689
22868
|
"count",
|
|
22690
22869
|
"length",
|
|
22691
22870
|
"total",
|
|
22692
22871
|
"size",
|
|
22693
22872
|
"num"
|
|
22694
22873
|
];
|
|
22695
|
-
const isNumericName = (name) => {
|
|
22696
|
-
for (const hint of NUMERIC_NAME_HINTS) {
|
|
22874
|
+
const isNumericName$1 = (name) => {
|
|
22875
|
+
for (const hint of NUMERIC_NAME_HINTS$1) {
|
|
22697
22876
|
if (name === hint) return true;
|
|
22698
22877
|
const camelSuffix = hint.charAt(0).toUpperCase() + hint.slice(1);
|
|
22699
22878
|
if (name.endsWith(camelSuffix)) return true;
|
|
@@ -22712,7 +22891,7 @@ const renderingConditionalRender = defineRule({
|
|
|
22712
22891
|
const left = node.left;
|
|
22713
22892
|
if (!left) return;
|
|
22714
22893
|
const isLengthMemberAccess = isNodeOfType(left, "MemberExpression") && isNodeOfType(left.property, "Identifier") && left.property.name === "length";
|
|
22715
|
-
const isNumericIdentifier = isNodeOfType(left, "Identifier") && isNumericName(left.name);
|
|
22894
|
+
const isNumericIdentifier = isNodeOfType(left, "Identifier") && isNumericName$1(left.name);
|
|
22716
22895
|
if (isLengthMemberAccess || isNumericIdentifier) context.report({
|
|
22717
22896
|
node,
|
|
22718
22897
|
message: "Conditional rendering with a numeric value can render '0' — use `value > 0`, `Boolean(value)`, or a ternary"
|
|
@@ -22907,7 +23086,8 @@ const renderingSvgPrecision = defineRule({
|
|
|
22907
23086
|
category: "Performance",
|
|
22908
23087
|
recommendation: "Truncate path/points/transform decimals to 1–2 digits — sub-pixel precision adds bytes with no visible difference",
|
|
22909
23088
|
create: (context) => {
|
|
22910
|
-
const
|
|
23089
|
+
const filename = context.getFilename?.();
|
|
23090
|
+
const isAutoGenerated = isAutoGeneratedSvgFile(filename ? normalizeFilename$1(filename) : void 0);
|
|
22911
23091
|
return { JSXAttribute(node) {
|
|
22912
23092
|
if (isAutoGenerated) return;
|
|
22913
23093
|
if (!isNodeOfType(node.name, "JSXIdentifier")) return;
|
|
@@ -23713,18 +23893,32 @@ const REANIMATED_LAYOUT_KEYS = new Set([
|
|
|
23713
23893
|
"minHeight",
|
|
23714
23894
|
"maxWidth",
|
|
23715
23895
|
"maxHeight",
|
|
23896
|
+
"margin",
|
|
23716
23897
|
"marginTop",
|
|
23717
23898
|
"marginBottom",
|
|
23718
23899
|
"marginLeft",
|
|
23719
23900
|
"marginRight",
|
|
23901
|
+
"marginHorizontal",
|
|
23902
|
+
"marginVertical",
|
|
23903
|
+
"padding",
|
|
23720
23904
|
"paddingTop",
|
|
23721
23905
|
"paddingBottom",
|
|
23722
23906
|
"paddingLeft",
|
|
23723
23907
|
"paddingRight",
|
|
23908
|
+
"paddingHorizontal",
|
|
23909
|
+
"paddingVertical",
|
|
23724
23910
|
"flex",
|
|
23725
23911
|
"flexBasis",
|
|
23726
23912
|
"flexGrow",
|
|
23727
|
-
"flexShrink"
|
|
23913
|
+
"flexShrink",
|
|
23914
|
+
"borderWidth",
|
|
23915
|
+
"borderTopWidth",
|
|
23916
|
+
"borderBottomWidth",
|
|
23917
|
+
"borderLeftWidth",
|
|
23918
|
+
"borderRightWidth",
|
|
23919
|
+
"fontSize",
|
|
23920
|
+
"lineHeight",
|
|
23921
|
+
"letterSpacing"
|
|
23728
23922
|
]);
|
|
23729
23923
|
const findReturnedObject = (callback) => {
|
|
23730
23924
|
if (!isNodeOfType(callback, "ArrowFunctionExpression") && !isNodeOfType(callback, "FunctionExpression")) return null;
|
|
@@ -23780,10 +23974,9 @@ const rnAnimationReactionAsDerived = defineRule({
|
|
|
23780
23974
|
singleAssignment = onlyStatement.expression;
|
|
23781
23975
|
} else if (body) singleAssignment = body;
|
|
23782
23976
|
if (!singleAssignment) return;
|
|
23783
|
-
|
|
23784
|
-
|
|
23785
|
-
if (!
|
|
23786
|
-
if (singleAssignment.left.property.name !== "value") return;
|
|
23977
|
+
const isValueAssignment = isNodeOfType(singleAssignment, "AssignmentExpression") && isNodeOfType(singleAssignment.left, "MemberExpression") && isNodeOfType(singleAssignment.left.property, "Identifier") && singleAssignment.left.property.name === "value";
|
|
23978
|
+
const isSetCall = isNodeOfType(singleAssignment, "CallExpression") && isNodeOfType(singleAssignment.callee, "MemberExpression") && isNodeOfType(singleAssignment.callee.property, "Identifier") && singleAssignment.callee.property.name === "set" && (singleAssignment.arguments?.length ?? 0) === 1;
|
|
23979
|
+
if (!isValueAssignment && !isSetCall) return;
|
|
23787
23980
|
context.report({
|
|
23788
23981
|
node,
|
|
23789
23982
|
message: "useAnimatedReaction body is a single shared-value assignment — useDerivedValue is shorter and tracks dependencies natively"
|
|
@@ -23793,10 +23986,13 @@ const rnAnimationReactionAsDerived = defineRule({
|
|
|
23793
23986
|
//#endregion
|
|
23794
23987
|
//#region src/plugin/rules/react-native/rn-bottom-sheet-prefer-native.ts
|
|
23795
23988
|
const JS_BOTTOM_SHEET_PACKAGES = new Set([
|
|
23796
|
-
"@gorhom/bottom-sheet",
|
|
23797
23989
|
"react-native-bottom-sheet",
|
|
23798
23990
|
"react-native-modal-bottom-sheet",
|
|
23799
|
-
"react-native-raw-bottom-sheet"
|
|
23991
|
+
"react-native-raw-bottom-sheet",
|
|
23992
|
+
"react-native-modalize",
|
|
23993
|
+
"react-native-actions-sheet",
|
|
23994
|
+
"react-native-bottomsheet-reanimated",
|
|
23995
|
+
"@discord/bottom-sheet"
|
|
23800
23996
|
]);
|
|
23801
23997
|
const rnBottomSheetPreferNative = defineRule({
|
|
23802
23998
|
id: "rn-bottom-sheet-prefer-native",
|
|
@@ -23814,6 +24010,77 @@ const rnBottomSheetPreferNative = defineRule({
|
|
|
23814
24010
|
} })
|
|
23815
24011
|
});
|
|
23816
24012
|
//#endregion
|
|
24013
|
+
//#region src/plugin/constants/react-native.ts
|
|
24014
|
+
const REACT_NATIVE_TEXT_COMPONENTS = new Set([
|
|
24015
|
+
"Text",
|
|
24016
|
+
"TextInput",
|
|
24017
|
+
"Typography",
|
|
24018
|
+
"Paragraph",
|
|
24019
|
+
"Span",
|
|
24020
|
+
"H1",
|
|
24021
|
+
"H2",
|
|
24022
|
+
"H3",
|
|
24023
|
+
"H4",
|
|
24024
|
+
"H5",
|
|
24025
|
+
"H6"
|
|
24026
|
+
]);
|
|
24027
|
+
const REACT_NATIVE_TEXT_COMPONENT_KEYWORDS = new Set([
|
|
24028
|
+
"Text",
|
|
24029
|
+
"Title",
|
|
24030
|
+
"Label",
|
|
24031
|
+
"Heading",
|
|
24032
|
+
"Caption",
|
|
24033
|
+
"Subtitle",
|
|
24034
|
+
"Typography",
|
|
24035
|
+
"Paragraph",
|
|
24036
|
+
"Description",
|
|
24037
|
+
"Body"
|
|
24038
|
+
]);
|
|
24039
|
+
const DEPRECATED_RN_MODULE_REPLACEMENTS = new Map([
|
|
24040
|
+
["AsyncStorage", "@react-native-async-storage/async-storage"],
|
|
24041
|
+
["Picker", "@react-native-picker/picker"],
|
|
24042
|
+
["PickerIOS", "@react-native-picker/picker"],
|
|
24043
|
+
["DatePickerIOS", "@react-native-community/datetimepicker"],
|
|
24044
|
+
["DatePickerAndroid", "@react-native-community/datetimepicker"],
|
|
24045
|
+
["ProgressBarAndroid", "a community alternative"],
|
|
24046
|
+
["ProgressViewIOS", "a community alternative"],
|
|
24047
|
+
["SafeAreaView", "react-native-safe-area-context"],
|
|
24048
|
+
["Slider", "@react-native-community/slider"],
|
|
24049
|
+
["ViewPagerAndroid", "react-native-pager-view"],
|
|
24050
|
+
["WebView", "react-native-webview"],
|
|
24051
|
+
["NetInfo", "@react-native-community/netinfo"],
|
|
24052
|
+
["CameraRoll", "@react-native-camera-roll/camera-roll"],
|
|
24053
|
+
["Clipboard", "@react-native-clipboard/clipboard"],
|
|
24054
|
+
["ImageEditor", "@react-native-community/image-editor"],
|
|
24055
|
+
["MaskedViewIOS", "@react-native-masked-view/masked-view"]
|
|
24056
|
+
]);
|
|
24057
|
+
const LEGACY_EXPO_PACKAGE_REPLACEMENTS = new Map([
|
|
24058
|
+
["expo-av", "expo-audio for audio and expo-video for video"],
|
|
24059
|
+
["expo-permissions", "the permissions API in each module (e.g. Camera.requestPermissionsAsync())"],
|
|
24060
|
+
["expo-app-loading", "expo-splash-screen"],
|
|
24061
|
+
["expo-linear-gradient", "the `backgroundImage` CSS gradient style prop (New Architecture) or expo-linear-gradient's successor"],
|
|
24062
|
+
["react-native-fast-image", "expo-image (drop-in with caching, placeholders, and crossfades)"]
|
|
24063
|
+
]);
|
|
24064
|
+
const REACT_NATIVE_LIST_COMPONENTS = new Set([
|
|
24065
|
+
"FlatList",
|
|
24066
|
+
"SectionList",
|
|
24067
|
+
"VirtualizedList",
|
|
24068
|
+
"FlashList",
|
|
24069
|
+
"LegendList"
|
|
24070
|
+
]);
|
|
24071
|
+
const RENDER_ITEM_PROP_NAMES = new Set([
|
|
24072
|
+
"renderItem",
|
|
24073
|
+
"renderSectionHeader",
|
|
24074
|
+
"renderSectionFooter"
|
|
24075
|
+
]);
|
|
24076
|
+
const LEGACY_SHADOW_STYLE_PROPERTIES = new Set([
|
|
24077
|
+
"shadowColor",
|
|
24078
|
+
"shadowOffset",
|
|
24079
|
+
"shadowOpacity",
|
|
24080
|
+
"shadowRadius",
|
|
24081
|
+
"elevation"
|
|
24082
|
+
]);
|
|
24083
|
+
//#endregion
|
|
23817
24084
|
//#region src/plugin/rules/react-native/rn-list-callback-per-row.ts
|
|
23818
24085
|
const LIST_ROW_PRESS_HANDLER_PROPS = new Set([
|
|
23819
24086
|
"onPress",
|
|
@@ -23838,13 +24105,21 @@ const detectInlineRowHandlers = (renderItemFn) => {
|
|
|
23838
24105
|
};
|
|
23839
24106
|
const isRenderItemJsxAttribute = (parent) => {
|
|
23840
24107
|
if (!isNodeOfType(parent, "JSXAttribute")) return false;
|
|
23841
|
-
|
|
24108
|
+
const attrName = isNodeOfType(parent.name, "JSXIdentifier") ? parent.name.name : null;
|
|
24109
|
+
return attrName ? RENDER_ITEM_PROP_NAMES.has(attrName) : false;
|
|
23842
24110
|
};
|
|
23843
24111
|
const isRenderItemFunction = (node) => {
|
|
23844
24112
|
const parent = node.parent;
|
|
23845
24113
|
if (!isNodeOfType(parent, "JSXExpressionContainer")) return false;
|
|
23846
24114
|
return isRenderItemJsxAttribute(parent.parent);
|
|
23847
24115
|
};
|
|
24116
|
+
const resolveRenderPropName = (node) => {
|
|
24117
|
+
const container = node.parent;
|
|
24118
|
+
if (!isNodeOfType(container, "JSXExpressionContainer")) return "renderItem";
|
|
24119
|
+
const attr = container.parent;
|
|
24120
|
+
if (isNodeOfType(attr, "JSXAttribute") && isNodeOfType(attr.name, "JSXIdentifier") && RENDER_ITEM_PROP_NAMES.has(attr.name.name)) return attr.name.name;
|
|
24121
|
+
return "renderItem";
|
|
24122
|
+
};
|
|
23848
24123
|
const rnListCallbackPerRow = defineRule({
|
|
23849
24124
|
id: "rn-list-callback-per-row",
|
|
23850
24125
|
tags: ["test-noise"],
|
|
@@ -23854,12 +24129,13 @@ const rnListCallbackPerRow = defineRule({
|
|
|
23854
24129
|
create: (context) => {
|
|
23855
24130
|
const inspect = (node) => {
|
|
23856
24131
|
if (!isRenderItemFunction(node)) return;
|
|
24132
|
+
const renderPropName = resolveRenderPropName(node);
|
|
23857
24133
|
const inlineHandlers = detectInlineRowHandlers(node);
|
|
23858
24134
|
for (const handler of inlineHandlers) {
|
|
23859
24135
|
const handlerName = isNodeOfType(handler, "JSXAttribute") && isNodeOfType(handler.name, "JSXIdentifier") ? handler.name.name : "<handler>";
|
|
23860
24136
|
context.report({
|
|
23861
24137
|
node: handler,
|
|
23862
|
-
message: `Inline ${handlerName} arrow inside
|
|
24138
|
+
message: `Inline ${handlerName} arrow inside ${renderPropName} creates a fresh closure per render — hoist with useCallback at list scope`
|
|
23863
24139
|
});
|
|
23864
24140
|
}
|
|
23865
24141
|
};
|
|
@@ -23881,13 +24157,33 @@ const resolveJsxElementName = (openingElement) => {
|
|
|
23881
24157
|
};
|
|
23882
24158
|
//#endregion
|
|
23883
24159
|
//#region src/plugin/rules/react-native/rn-list-data-mapped.ts
|
|
23884
|
-
const
|
|
23885
|
-
"
|
|
23886
|
-
"
|
|
23887
|
-
"
|
|
23888
|
-
"
|
|
23889
|
-
"
|
|
24160
|
+
const FRESH_ARRAY_METHODS = new Set([
|
|
24161
|
+
"map",
|
|
24162
|
+
"filter",
|
|
24163
|
+
"toSorted",
|
|
24164
|
+
"slice",
|
|
24165
|
+
"toReversed",
|
|
24166
|
+
"concat",
|
|
24167
|
+
"flat",
|
|
24168
|
+
"flatMap",
|
|
24169
|
+
"toSpliced"
|
|
23890
24170
|
]);
|
|
24171
|
+
const isFreshArrayExpression = (node) => {
|
|
24172
|
+
if (isNodeOfType(node, "ArrayExpression")) return "[...spread]";
|
|
24173
|
+
if (isNodeOfType(node, "CallExpression")) {
|
|
24174
|
+
const callee = node.callee;
|
|
24175
|
+
if (isNodeOfType(callee, "MemberExpression")) {
|
|
24176
|
+
if (isNodeOfType(callee.property, "Identifier")) {
|
|
24177
|
+
const methodName = callee.property.name;
|
|
24178
|
+
if (FRESH_ARRAY_METHODS.has(methodName)) return `.${methodName}(…)`;
|
|
24179
|
+
if (methodName === "from" && isNodeOfType(callee.object, "Identifier") && callee.object.name === "Array") return "Array.from(…)";
|
|
24180
|
+
}
|
|
24181
|
+
return isFreshArrayExpression(callee.object);
|
|
24182
|
+
}
|
|
24183
|
+
if (isNodeOfType(callee, "Identifier") && callee.name === "Array") return "Array(…)";
|
|
24184
|
+
}
|
|
24185
|
+
return null;
|
|
24186
|
+
};
|
|
23891
24187
|
const rnListDataMapped = defineRule({
|
|
23892
24188
|
id: "rn-list-data-mapped",
|
|
23893
24189
|
tags: ["test-noise"],
|
|
@@ -23896,20 +24192,17 @@ const rnListDataMapped = defineRule({
|
|
|
23896
24192
|
recommendation: "Wrap the projection in `useMemo(() => items.map(...), [items])` so the list's `data` prop has a stable reference across parent renders",
|
|
23897
24193
|
create: (context) => ({ JSXOpeningElement(node) {
|
|
23898
24194
|
const elementName = resolveJsxElementName(node);
|
|
23899
|
-
if (!elementName || !
|
|
24195
|
+
if (!elementName || !REACT_NATIVE_LIST_COMPONENTS.has(elementName)) return;
|
|
23900
24196
|
for (const attr of node.attributes ?? []) {
|
|
23901
24197
|
if (!isNodeOfType(attr, "JSXAttribute")) continue;
|
|
23902
24198
|
if (!isNodeOfType(attr.name, "JSXIdentifier") || attr.name.name !== "data") continue;
|
|
23903
24199
|
if (!isNodeOfType(attr.value, "JSXExpressionContainer")) continue;
|
|
23904
24200
|
const expression = attr.value.expression;
|
|
23905
|
-
|
|
23906
|
-
if (!
|
|
23907
|
-
if (!isNodeOfType(expression.callee.property, "Identifier")) continue;
|
|
23908
|
-
const methodName = expression.callee.property.name;
|
|
23909
|
-
if (methodName !== "map" && methodName !== "filter") continue;
|
|
24201
|
+
const freshArrayDescription = isFreshArrayExpression(expression);
|
|
24202
|
+
if (!freshArrayDescription) continue;
|
|
23910
24203
|
context.report({
|
|
23911
24204
|
node: attr,
|
|
23912
|
-
message: `<${elementName} data={
|
|
24205
|
+
message: `<${elementName} data={…${freshArrayDescription}}> allocates a fresh array per render — wrap in useMemo so the data reference stays stable across parent renders`
|
|
23913
24206
|
});
|
|
23914
24207
|
return;
|
|
23915
24208
|
}
|
|
@@ -23944,86 +24237,177 @@ const rnListRecyclableWithoutTypes = defineRule({
|
|
|
23944
24237
|
} })
|
|
23945
24238
|
});
|
|
23946
24239
|
//#endregion
|
|
23947
|
-
//#region src/
|
|
23948
|
-
const
|
|
23949
|
-
"
|
|
23950
|
-
"
|
|
23951
|
-
"
|
|
23952
|
-
"
|
|
23953
|
-
"
|
|
23954
|
-
"H1",
|
|
23955
|
-
"H2",
|
|
23956
|
-
"H3",
|
|
23957
|
-
"H4",
|
|
23958
|
-
"H5",
|
|
23959
|
-
"H6"
|
|
23960
|
-
]);
|
|
23961
|
-
const REACT_NATIVE_TEXT_COMPONENT_KEYWORDS = new Set([
|
|
23962
|
-
"Text",
|
|
23963
|
-
"Title",
|
|
23964
|
-
"Label",
|
|
23965
|
-
"Heading",
|
|
23966
|
-
"Caption",
|
|
23967
|
-
"Subtitle",
|
|
23968
|
-
"Typography",
|
|
23969
|
-
"Paragraph",
|
|
23970
|
-
"Description",
|
|
23971
|
-
"Body"
|
|
23972
|
-
]);
|
|
23973
|
-
const DEPRECATED_RN_MODULE_REPLACEMENTS = new Map([
|
|
23974
|
-
["AsyncStorage", "@react-native-async-storage/async-storage"],
|
|
23975
|
-
["Picker", "@react-native-picker/picker"],
|
|
23976
|
-
["PickerIOS", "@react-native-picker/picker"],
|
|
23977
|
-
["DatePickerIOS", "@react-native-community/datetimepicker"],
|
|
23978
|
-
["DatePickerAndroid", "@react-native-community/datetimepicker"],
|
|
23979
|
-
["ProgressBarAndroid", "a community alternative"],
|
|
23980
|
-
["ProgressViewIOS", "a community alternative"],
|
|
23981
|
-
["SafeAreaView", "react-native-safe-area-context"],
|
|
23982
|
-
["Slider", "@react-native-community/slider"],
|
|
23983
|
-
["ViewPagerAndroid", "react-native-pager-view"],
|
|
23984
|
-
["WebView", "react-native-webview"],
|
|
23985
|
-
["NetInfo", "@react-native-community/netinfo"],
|
|
23986
|
-
["CameraRoll", "@react-native-camera-roll/camera-roll"],
|
|
23987
|
-
["Clipboard", "@react-native-clipboard/clipboard"],
|
|
23988
|
-
["ImageEditor", "@react-native-community/image-editor"],
|
|
23989
|
-
["MaskedViewIOS", "@react-native-masked-view/masked-view"]
|
|
24240
|
+
//#region src/react-native-dependency-names.ts
|
|
24241
|
+
const EXPO_MANAGED_DEPENDENCY_NAMES = new Set([
|
|
24242
|
+
"expo",
|
|
24243
|
+
"expo-router",
|
|
24244
|
+
"@expo/cli",
|
|
24245
|
+
"@expo/metro-config",
|
|
24246
|
+
"@expo/metro-runtime"
|
|
23990
24247
|
]);
|
|
23991
|
-
const
|
|
23992
|
-
|
|
23993
|
-
"
|
|
23994
|
-
|
|
23995
|
-
"
|
|
23996
|
-
"
|
|
24248
|
+
const REACT_NATIVE_DEPENDENCY_NAMES = new Set([
|
|
24249
|
+
"react-native",
|
|
24250
|
+
"react-native-tvos",
|
|
24251
|
+
...EXPO_MANAGED_DEPENDENCY_NAMES,
|
|
24252
|
+
"react-native-windows",
|
|
24253
|
+
"react-native-macos"
|
|
23997
24254
|
]);
|
|
23998
|
-
const
|
|
23999
|
-
|
|
24000
|
-
|
|
24001
|
-
|
|
24002
|
-
|
|
24003
|
-
|
|
24255
|
+
const REACT_NATIVE_DEPENDENCY_PREFIXES = ["@react-native/", "@react-native-"];
|
|
24256
|
+
const isExpoManagedDependencyName = (dependencyName) => EXPO_MANAGED_DEPENDENCY_NAMES.has(dependencyName);
|
|
24257
|
+
const isReactNativeDependencyName = (dependencyName) => {
|
|
24258
|
+
if (REACT_NATIVE_DEPENDENCY_NAMES.has(dependencyName)) return true;
|
|
24259
|
+
for (const prefix of REACT_NATIVE_DEPENDENCY_PREFIXES) if (dependencyName.startsWith(prefix)) return true;
|
|
24260
|
+
return false;
|
|
24261
|
+
};
|
|
24262
|
+
//#endregion
|
|
24263
|
+
//#region src/plugin/utils/classify-package-platform.ts
|
|
24264
|
+
const WEB_FRAMEWORK_DEPENDENCY_NAMES = new Set([
|
|
24265
|
+
"next",
|
|
24266
|
+
"vite",
|
|
24267
|
+
"react-scripts",
|
|
24268
|
+
"gatsby",
|
|
24269
|
+
"@remix-run/react",
|
|
24270
|
+
"@remix-run/node",
|
|
24271
|
+
"@docusaurus/core",
|
|
24272
|
+
"@docusaurus/preset-classic",
|
|
24273
|
+
"@storybook/react",
|
|
24274
|
+
"@storybook/react-vite",
|
|
24275
|
+
"@storybook/react-webpack5",
|
|
24276
|
+
"@storybook/nextjs",
|
|
24277
|
+
"@storybook/web-components",
|
|
24278
|
+
"storybook",
|
|
24279
|
+
"react-dom",
|
|
24280
|
+
"@vitejs/plugin-react",
|
|
24281
|
+
"@vitejs/plugin-react-swc"
|
|
24004
24282
|
]);
|
|
24283
|
+
const cachedPlatformByPackageDirectory = /* @__PURE__ */ new Map();
|
|
24284
|
+
const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
|
|
24285
|
+
const findNearestPackageDirectory = (filename) => {
|
|
24286
|
+
if (!filename) return null;
|
|
24287
|
+
const fromCache = cachedPackageDirectoryByFilename.get(filename);
|
|
24288
|
+
if (fromCache !== void 0) return fromCache;
|
|
24289
|
+
let currentDirectory = path.dirname(filename);
|
|
24290
|
+
while (true) {
|
|
24291
|
+
const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
|
|
24292
|
+
let hasPackageJson = false;
|
|
24293
|
+
try {
|
|
24294
|
+
hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
|
|
24295
|
+
} catch {
|
|
24296
|
+
hasPackageJson = false;
|
|
24297
|
+
}
|
|
24298
|
+
if (hasPackageJson) {
|
|
24299
|
+
cachedPackageDirectoryByFilename.set(filename, currentDirectory);
|
|
24300
|
+
return currentDirectory;
|
|
24301
|
+
}
|
|
24302
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
24303
|
+
if (parentDirectory === currentDirectory) {
|
|
24304
|
+
cachedPackageDirectoryByFilename.set(filename, null);
|
|
24305
|
+
return null;
|
|
24306
|
+
}
|
|
24307
|
+
currentDirectory = parentDirectory;
|
|
24308
|
+
}
|
|
24309
|
+
};
|
|
24310
|
+
const readPackageJsonSafe = (packageJsonPath) => {
|
|
24311
|
+
let rawContents;
|
|
24312
|
+
try {
|
|
24313
|
+
rawContents = fs.readFileSync(packageJsonPath, "utf-8");
|
|
24314
|
+
} catch {
|
|
24315
|
+
return null;
|
|
24316
|
+
}
|
|
24317
|
+
try {
|
|
24318
|
+
const parsed = JSON.parse(rawContents);
|
|
24319
|
+
if (typeof parsed === "object" && parsed !== null) return parsed;
|
|
24320
|
+
return null;
|
|
24321
|
+
} catch {
|
|
24322
|
+
return null;
|
|
24323
|
+
}
|
|
24324
|
+
};
|
|
24325
|
+
const DEPENDENCY_SECTION_NAMES = [
|
|
24326
|
+
"dependencies",
|
|
24327
|
+
"devDependencies",
|
|
24328
|
+
"peerDependencies",
|
|
24329
|
+
"optionalDependencies"
|
|
24330
|
+
];
|
|
24331
|
+
const iterateDependencyNames = function* (packageJson) {
|
|
24332
|
+
for (const sectionName of DEPENDENCY_SECTION_NAMES) {
|
|
24333
|
+
const section = packageJson[sectionName];
|
|
24334
|
+
if (!section) continue;
|
|
24335
|
+
for (const dependencyName of Object.keys(section)) yield dependencyName;
|
|
24336
|
+
}
|
|
24337
|
+
};
|
|
24338
|
+
const isReactNativeAware = (packageJson) => {
|
|
24339
|
+
if (typeof packageJson["react-native"] === "string") return true;
|
|
24340
|
+
for (const dependencyName of iterateDependencyNames(packageJson)) if (isReactNativeDependencyName(dependencyName)) return true;
|
|
24341
|
+
return false;
|
|
24342
|
+
};
|
|
24343
|
+
const isExpoManaged = (packageJson) => {
|
|
24344
|
+
for (const dependencyName of iterateDependencyNames(packageJson)) if (isExpoManagedDependencyName(dependencyName)) return true;
|
|
24345
|
+
return false;
|
|
24346
|
+
};
|
|
24347
|
+
const isWebFrameworkOnly = (packageJson) => {
|
|
24348
|
+
for (const dependencyName of iterateDependencyNames(packageJson)) if (WEB_FRAMEWORK_DEPENDENCY_NAMES.has(dependencyName)) return true;
|
|
24349
|
+
return false;
|
|
24350
|
+
};
|
|
24351
|
+
const classifyPackagePlatform = (filename) => {
|
|
24352
|
+
const packageDirectory = findNearestPackageDirectory(filename);
|
|
24353
|
+
if (!packageDirectory) return "unknown";
|
|
24354
|
+
const cached = cachedPlatformByPackageDirectory.get(packageDirectory);
|
|
24355
|
+
if (cached !== void 0) return cached;
|
|
24356
|
+
const packageJson = readPackageJsonSafe(path.join(packageDirectory, "package.json"));
|
|
24357
|
+
if (!packageJson) {
|
|
24358
|
+
cachedPlatformByPackageDirectory.set(packageDirectory, "unknown");
|
|
24359
|
+
return "unknown";
|
|
24360
|
+
}
|
|
24361
|
+
let result;
|
|
24362
|
+
if (isExpoManaged(packageJson)) result = "expo";
|
|
24363
|
+
else if (isReactNativeAware(packageJson)) result = "react-native";
|
|
24364
|
+
else if (isWebFrameworkOnly(packageJson)) result = "web";
|
|
24365
|
+
else result = "unknown";
|
|
24366
|
+
cachedPlatformByPackageDirectory.set(packageDirectory, result);
|
|
24367
|
+
return result;
|
|
24368
|
+
};
|
|
24369
|
+
//#endregion
|
|
24370
|
+
//#region src/plugin/utils/is-expo-managed-file.ts
|
|
24371
|
+
const isExpoManagedFileActive = (context) => {
|
|
24372
|
+
const filename = context.getFilename?.() ? normalizeFilename$1(context.getFilename()) : void 0;
|
|
24373
|
+
if (filename) {
|
|
24374
|
+
const packagePlatform = classifyPackagePlatform(filename);
|
|
24375
|
+
if (packagePlatform === "expo") return true;
|
|
24376
|
+
if (packagePlatform === "react-native" || packagePlatform === "web") return false;
|
|
24377
|
+
}
|
|
24378
|
+
return getReactDoctorStringSetting(context.settings, "framework") === "expo";
|
|
24379
|
+
};
|
|
24005
24380
|
//#endregion
|
|
24006
24381
|
//#region src/plugin/rules/react-native/rn-no-deprecated-modules.ts
|
|
24382
|
+
const getExpoAwareReplacement = (importedName, baseReplacement, isExpo) => {
|
|
24383
|
+
if (!isExpo) return baseReplacement;
|
|
24384
|
+
if (importedName === "AsyncStorage") return "expo-sqlite/localStorage (import \"expo-sqlite/localStorage/install\") for simple key-value storage, or expo-secure-store for sensitive data";
|
|
24385
|
+
return baseReplacement;
|
|
24386
|
+
};
|
|
24007
24387
|
const rnNoDeprecatedModules = defineRule({
|
|
24008
24388
|
id: "rn-no-deprecated-modules",
|
|
24009
24389
|
tags: ["test-noise"],
|
|
24010
24390
|
requires: ["react-native"],
|
|
24011
24391
|
severity: "error",
|
|
24012
24392
|
recommendation: "Import from the community package instead — deprecated modules were removed from the react-native core",
|
|
24013
|
-
create: (context) =>
|
|
24014
|
-
|
|
24015
|
-
|
|
24016
|
-
if (
|
|
24017
|
-
const
|
|
24018
|
-
|
|
24019
|
-
|
|
24020
|
-
|
|
24021
|
-
|
|
24022
|
-
|
|
24023
|
-
|
|
24024
|
-
|
|
24025
|
-
|
|
24026
|
-
|
|
24393
|
+
create: (context) => {
|
|
24394
|
+
const isExpo = isExpoManagedFileActive(context);
|
|
24395
|
+
return { ImportDeclaration(node) {
|
|
24396
|
+
if (node.source?.value !== "react-native") return;
|
|
24397
|
+
for (const specifier of node.specifiers ?? []) {
|
|
24398
|
+
if (!isNodeOfType(specifier, "ImportSpecifier")) continue;
|
|
24399
|
+
const importedName = getImportedName(specifier);
|
|
24400
|
+
if (!importedName) continue;
|
|
24401
|
+
const baseReplacement = DEPRECATED_RN_MODULE_REPLACEMENTS.get(importedName);
|
|
24402
|
+
if (!baseReplacement) continue;
|
|
24403
|
+
const replacement = getExpoAwareReplacement(importedName, baseReplacement, isExpo);
|
|
24404
|
+
context.report({
|
|
24405
|
+
node: specifier,
|
|
24406
|
+
message: `"${importedName}" was removed from react-native — use ${replacement} instead`
|
|
24407
|
+
});
|
|
24408
|
+
}
|
|
24409
|
+
} };
|
|
24410
|
+
}
|
|
24027
24411
|
});
|
|
24028
24412
|
//#endregion
|
|
24029
24413
|
//#region src/plugin/rules/react-native/rn-no-dimensions-get.ts
|
|
@@ -24047,6 +24431,99 @@ const rnNoDimensionsGet = defineRule({
|
|
|
24047
24431
|
} })
|
|
24048
24432
|
});
|
|
24049
24433
|
//#endregion
|
|
24434
|
+
//#region src/plugin/rules/react-native/rn-no-falsy-and-render.ts
|
|
24435
|
+
const NUMERIC_NAME_HINTS = [
|
|
24436
|
+
"count",
|
|
24437
|
+
"length",
|
|
24438
|
+
"total",
|
|
24439
|
+
"size",
|
|
24440
|
+
"num",
|
|
24441
|
+
"index",
|
|
24442
|
+
"amount",
|
|
24443
|
+
"quantity",
|
|
24444
|
+
"offset",
|
|
24445
|
+
"width",
|
|
24446
|
+
"height",
|
|
24447
|
+
"duration",
|
|
24448
|
+
"progress",
|
|
24449
|
+
"score",
|
|
24450
|
+
"rank",
|
|
24451
|
+
"level",
|
|
24452
|
+
"step",
|
|
24453
|
+
"max",
|
|
24454
|
+
"min",
|
|
24455
|
+
"sum",
|
|
24456
|
+
"avg",
|
|
24457
|
+
"depth",
|
|
24458
|
+
"balance",
|
|
24459
|
+
"age",
|
|
24460
|
+
"weight",
|
|
24461
|
+
"volume",
|
|
24462
|
+
"distance",
|
|
24463
|
+
"speed",
|
|
24464
|
+
"rate",
|
|
24465
|
+
"ratio",
|
|
24466
|
+
"percent",
|
|
24467
|
+
"percentage"
|
|
24468
|
+
];
|
|
24469
|
+
const BOOLEAN_PREFIXES = [
|
|
24470
|
+
"is",
|
|
24471
|
+
"has",
|
|
24472
|
+
"can",
|
|
24473
|
+
"should",
|
|
24474
|
+
"did",
|
|
24475
|
+
"will",
|
|
24476
|
+
"show",
|
|
24477
|
+
"hide",
|
|
24478
|
+
"enable",
|
|
24479
|
+
"disable"
|
|
24480
|
+
];
|
|
24481
|
+
const isNumericName = (name) => {
|
|
24482
|
+
const lower = name.toLowerCase();
|
|
24483
|
+
for (const prefix of BOOLEAN_PREFIXES) if (lower.startsWith(prefix) && name.length > prefix.length && name[prefix.length] === name[prefix.length].toUpperCase()) return false;
|
|
24484
|
+
for (const hint of NUMERIC_NAME_HINTS) {
|
|
24485
|
+
if (lower === hint) return true;
|
|
24486
|
+
const camelSuffix = hint.charAt(0).toUpperCase() + hint.slice(1);
|
|
24487
|
+
if (name.endsWith(camelSuffix)) return true;
|
|
24488
|
+
if (lower.endsWith(`_${hint}`)) return true;
|
|
24489
|
+
}
|
|
24490
|
+
return false;
|
|
24491
|
+
};
|
|
24492
|
+
const isLikelyNumericExpression = (node) => {
|
|
24493
|
+
if (isNodeOfType(node, "MemberExpression") && isNodeOfType(node.property, "Identifier") && node.property.name === "length") return true;
|
|
24494
|
+
if (isNodeOfType(node, "Identifier") && isNumericName(node.name)) return true;
|
|
24495
|
+
if (isNodeOfType(node, "MemberExpression") && isNodeOfType(node.property, "Identifier") && isNumericName(node.property.name)) return true;
|
|
24496
|
+
return false;
|
|
24497
|
+
};
|
|
24498
|
+
const rnNoFalsyAndRender = defineRule({
|
|
24499
|
+
id: "rn-no-falsy-and-render",
|
|
24500
|
+
requires: ["react-native"],
|
|
24501
|
+
severity: "error",
|
|
24502
|
+
recommendation: "Use `{value > 0 && <X />}`, `{Boolean(value) && <X />}`, or a ternary `{value ? <X /> : null}` — numeric falsy `0` renders as raw text and crashes on RN",
|
|
24503
|
+
create: (context) => {
|
|
24504
|
+
let isDomComponentFile = false;
|
|
24505
|
+
return {
|
|
24506
|
+
Program(programNode) {
|
|
24507
|
+
isDomComponentFile = hasDirective(programNode, "use dom");
|
|
24508
|
+
},
|
|
24509
|
+
LogicalExpression(node) {
|
|
24510
|
+
if (isDomComponentFile) return;
|
|
24511
|
+
if (node.operator !== "&&") return;
|
|
24512
|
+
if (!(isNodeOfType(node.right, "JSXElement") || isNodeOfType(node.right, "JSXFragment"))) return;
|
|
24513
|
+
const parent = node.parent;
|
|
24514
|
+
if (!(isNodeOfType(parent, "JSXExpressionContainer") || isNodeOfType(parent, "LogicalExpression") && parent.operator === "&&")) return;
|
|
24515
|
+
const left = node.left;
|
|
24516
|
+
if (!left) return;
|
|
24517
|
+
if (!isLikelyNumericExpression(left)) return;
|
|
24518
|
+
context.report({
|
|
24519
|
+
node: left,
|
|
24520
|
+
message: "Conditional rendering with a numeric value can render `0` as raw text — on React Native this crashes. Use `value > 0`, `Boolean(value)`, or a ternary"
|
|
24521
|
+
});
|
|
24522
|
+
}
|
|
24523
|
+
};
|
|
24524
|
+
}
|
|
24525
|
+
});
|
|
24526
|
+
//#endregion
|
|
24050
24527
|
//#region src/plugin/rules/react-native/rn-no-inline-flatlist-renderitem.ts
|
|
24051
24528
|
const rnNoInlineFlatlistRenderitem = defineRule({
|
|
24052
24529
|
id: "rn-no-inline-flatlist-renderitem",
|
|
@@ -24071,11 +24548,6 @@ const rnNoInlineFlatlistRenderitem = defineRule({
|
|
|
24071
24548
|
});
|
|
24072
24549
|
//#endregion
|
|
24073
24550
|
//#region src/plugin/rules/react-native/rn-no-inline-object-in-list-item.ts
|
|
24074
|
-
const RENDER_ITEM_PROP_NAMES = new Set([
|
|
24075
|
-
"renderItem",
|
|
24076
|
-
"renderSectionHeader",
|
|
24077
|
-
"renderSectionFooter"
|
|
24078
|
-
]);
|
|
24079
24551
|
const rnNoInlineObjectInListItem = defineRule({
|
|
24080
24552
|
id: "rn-no-inline-object-in-list-item",
|
|
24081
24553
|
tags: ["test-noise"],
|
|
@@ -24083,23 +24555,22 @@ const rnNoInlineObjectInListItem = defineRule({
|
|
|
24083
24555
|
severity: "warn",
|
|
24084
24556
|
recommendation: "Hoist style/object props outside renderItem (StyleSheet.create, useMemo at list scope, or pass primitives) so memo() row components stop bailing",
|
|
24085
24557
|
create: (context) => {
|
|
24086
|
-
|
|
24087
|
-
const
|
|
24088
|
-
if (!isNodeOfType(
|
|
24089
|
-
const attrName = isNodeOfType(parent.name, "JSXIdentifier") ? parent.name.name : null;
|
|
24090
|
-
return attrName ? RENDER_ITEM_PROP_NAMES.has(attrName) : false;
|
|
24091
|
-
};
|
|
24092
|
-
const isRenderItemFunction = (node) => {
|
|
24093
|
-
if (!isNodeOfType(node, "ArrowFunctionExpression") && !isNodeOfType(node, "FunctionExpression")) return false;
|
|
24558
|
+
const renderPropStack = [];
|
|
24559
|
+
const resolveRenderPropName = (node) => {
|
|
24560
|
+
if (!isNodeOfType(node, "ArrowFunctionExpression") && !isNodeOfType(node, "FunctionExpression")) return null;
|
|
24094
24561
|
const expressionContainer = node.parent;
|
|
24095
|
-
if (!isNodeOfType(expressionContainer, "JSXExpressionContainer")) return
|
|
24096
|
-
|
|
24562
|
+
if (!isNodeOfType(expressionContainer, "JSXExpressionContainer")) return null;
|
|
24563
|
+
const attr = expressionContainer.parent;
|
|
24564
|
+
if (!isNodeOfType(attr, "JSXAttribute")) return null;
|
|
24565
|
+
const attrName = isNodeOfType(attr.name, "JSXIdentifier") ? attr.name.name : null;
|
|
24566
|
+
return attrName && RENDER_ITEM_PROP_NAMES.has(attrName) ? attrName : null;
|
|
24097
24567
|
};
|
|
24098
24568
|
const enter = (node) => {
|
|
24099
|
-
|
|
24569
|
+
const renderPropName = resolveRenderPropName(node);
|
|
24570
|
+
if (renderPropName) renderPropStack.push(renderPropName);
|
|
24100
24571
|
};
|
|
24101
24572
|
const exit = (node) => {
|
|
24102
|
-
if (
|
|
24573
|
+
if (resolveRenderPropName(node)) renderPropStack.pop();
|
|
24103
24574
|
};
|
|
24104
24575
|
return {
|
|
24105
24576
|
ArrowFunctionExpression: enter,
|
|
@@ -24107,13 +24578,18 @@ const rnNoInlineObjectInListItem = defineRule({
|
|
|
24107
24578
|
FunctionExpression: enter,
|
|
24108
24579
|
"FunctionExpression:exit": exit,
|
|
24109
24580
|
JSXAttribute(node) {
|
|
24110
|
-
if (
|
|
24581
|
+
if (renderPropStack.length === 0) return;
|
|
24111
24582
|
if (!isNodeOfType(node.value, "JSXExpressionContainer")) return;
|
|
24112
|
-
|
|
24583
|
+
const expression = node.value.expression;
|
|
24584
|
+
const isInlineObject = isNodeOfType(expression, "ObjectExpression");
|
|
24585
|
+
const isInlineArray = isNodeOfType(expression, "ArrayExpression");
|
|
24586
|
+
if (!isInlineObject && !isInlineArray) return;
|
|
24113
24587
|
const propName = isNodeOfType(node.name, "JSXIdentifier") ? node.name.name : "<unknown>";
|
|
24588
|
+
const literalKind = isInlineArray ? "array" : "object";
|
|
24589
|
+
const activeRenderProp = renderPropStack[renderPropStack.length - 1];
|
|
24114
24590
|
context.report({
|
|
24115
24591
|
node,
|
|
24116
|
-
message: `Inline
|
|
24592
|
+
message: `Inline ${literalKind} literal on "${propName}" inside ${activeRenderProp} — allocates a fresh reference per render and breaks memo(). Hoist outside ${activeRenderProp} or pass primitives`
|
|
24117
24593
|
});
|
|
24118
24594
|
}
|
|
24119
24595
|
};
|
|
@@ -24163,7 +24639,9 @@ const rnNoLegacyShadowStyles = defineRule({
|
|
|
24163
24639
|
recommendation: "Use `boxShadow` for cross-platform shadows on the new architecture instead of platform-specific shadow properties",
|
|
24164
24640
|
create: (context) => ({
|
|
24165
24641
|
JSXAttribute(node) {
|
|
24166
|
-
if (!isNodeOfType(node.name, "JSXIdentifier")
|
|
24642
|
+
if (!isNodeOfType(node.name, "JSXIdentifier")) return;
|
|
24643
|
+
const attrName = node.name.name;
|
|
24644
|
+
if (attrName !== "style" && !attrName.endsWith("Style")) return;
|
|
24167
24645
|
if (!isNodeOfType(node.value, "JSXExpressionContainer")) return;
|
|
24168
24646
|
const expression = node.value.expression;
|
|
24169
24647
|
if (isNodeOfType(expression, "ObjectExpression")) reportLegacyShadowProperties(expression, context);
|
|
@@ -24187,7 +24665,11 @@ const rnNoLegacyShadowStyles = defineRule({
|
|
|
24187
24665
|
});
|
|
24188
24666
|
//#endregion
|
|
24189
24667
|
//#region src/plugin/rules/react-native/rn-no-non-native-navigator.ts
|
|
24190
|
-
const NON_NATIVE_NAVIGATOR_PACKAGES = new
|
|
24668
|
+
const NON_NATIVE_NAVIGATOR_PACKAGES = new Map([
|
|
24669
|
+
["@react-navigation/stack", "@react-navigation/native-stack"],
|
|
24670
|
+
["@react-navigation/drawer", "expo-router Drawer (no native equivalent exists for standalone React Navigation)"],
|
|
24671
|
+
["@react-navigation/bottom-tabs", "@react-navigation/native-tabs (v7+) or expo-router NativeTabs"]
|
|
24672
|
+
]);
|
|
24191
24673
|
const rnNoNonNativeNavigator = defineRule({
|
|
24192
24674
|
id: "rn-no-non-native-navigator",
|
|
24193
24675
|
tags: ["test-noise"],
|
|
@@ -24196,8 +24678,9 @@ const rnNoNonNativeNavigator = defineRule({
|
|
|
24196
24678
|
recommendation: "Use `@react-navigation/native-stack` (or `native-tabs` in v7+) for platform-native transitions and gestures",
|
|
24197
24679
|
create: (context) => ({ ImportDeclaration(node) {
|
|
24198
24680
|
const source = node.source?.value;
|
|
24199
|
-
if (typeof source !== "string"
|
|
24200
|
-
const replacement =
|
|
24681
|
+
if (typeof source !== "string") return;
|
|
24682
|
+
const replacement = NON_NATIVE_NAVIGATOR_PACKAGES.get(source);
|
|
24683
|
+
if (!replacement) return;
|
|
24201
24684
|
context.report({
|
|
24202
24685
|
node,
|
|
24203
24686
|
message: `${source} uses a JS-implemented navigator — use ${replacement} for native iOS/Android transitions and gestures`
|
|
@@ -24368,34 +24851,68 @@ const rnNoRawText = defineRule({
|
|
|
24368
24851
|
});
|
|
24369
24852
|
//#endregion
|
|
24370
24853
|
//#region src/plugin/rules/react-native/rn-no-scroll-state.ts
|
|
24854
|
+
const SET_STATE_PATTERN = /^set[A-Z]/;
|
|
24855
|
+
const findSetStateInBody = (body) => {
|
|
24856
|
+
let setStateCallNode = null;
|
|
24857
|
+
walkAst(body, (child) => {
|
|
24858
|
+
if (setStateCallNode) return;
|
|
24859
|
+
if (isNodeOfType(child, "CallExpression") && isNodeOfType(child.callee, "Identifier") && SET_STATE_PATTERN.test(child.callee.name)) setStateCallNode = child;
|
|
24860
|
+
});
|
|
24861
|
+
return setStateCallNode;
|
|
24862
|
+
};
|
|
24371
24863
|
const rnNoScrollState = defineRule({
|
|
24372
24864
|
id: "rn-no-scroll-state",
|
|
24373
24865
|
tags: ["test-noise"],
|
|
24374
24866
|
requires: ["react-native"],
|
|
24375
24867
|
severity: "error",
|
|
24376
24868
|
recommendation: "Track scroll position with a Reanimated shared value (`useAnimatedScrollHandler`) or a ref — `setState` on every scroll event causes re-render storms",
|
|
24377
|
-
create: (context) =>
|
|
24378
|
-
|
|
24379
|
-
|
|
24380
|
-
|
|
24381
|
-
|
|
24382
|
-
|
|
24383
|
-
|
|
24384
|
-
|
|
24385
|
-
|
|
24386
|
-
|
|
24387
|
-
|
|
24388
|
-
|
|
24389
|
-
node
|
|
24390
|
-
|
|
24391
|
-
|
|
24392
|
-
|
|
24869
|
+
create: (context) => {
|
|
24870
|
+
const stateSettersInHandlers = /* @__PURE__ */ new Map();
|
|
24871
|
+
return {
|
|
24872
|
+
VariableDeclarator(node) {
|
|
24873
|
+
if (!isNodeOfType(node.id, "Identifier")) return;
|
|
24874
|
+
const variableName = node.id.name;
|
|
24875
|
+
if (!/scroll/i.test(variableName)) return;
|
|
24876
|
+
const init = node.init;
|
|
24877
|
+
if (!isNodeOfType(init, "ArrowFunctionExpression") && !isNodeOfType(init, "FunctionExpression")) return;
|
|
24878
|
+
const setStateCall = findSetStateInBody(init.body);
|
|
24879
|
+
if (setStateCall) stateSettersInHandlers.set(variableName, setStateCall);
|
|
24880
|
+
},
|
|
24881
|
+
JSXAttribute(node) {
|
|
24882
|
+
if (!isNodeOfType(node.name, "JSXIdentifier")) return;
|
|
24883
|
+
if (node.name.name !== "onScroll") return;
|
|
24884
|
+
if (!isNodeOfType(node.value, "JSXExpressionContainer")) return;
|
|
24885
|
+
const expression = node.value.expression;
|
|
24886
|
+
if (isNodeOfType(expression, "Identifier")) {
|
|
24887
|
+
const tracked = stateSettersInHandlers.get(expression.name);
|
|
24888
|
+
if (tracked) context.report({
|
|
24889
|
+
node: tracked,
|
|
24890
|
+
message: "setState in onScroll handler triggers re-renders on every scroll event — use a Reanimated shared value (useAnimatedScrollHandler) or a ref to track scroll position"
|
|
24891
|
+
});
|
|
24892
|
+
return;
|
|
24893
|
+
}
|
|
24894
|
+
if (!isNodeOfType(expression, "ArrowFunctionExpression") && !isNodeOfType(expression, "FunctionExpression")) return;
|
|
24895
|
+
const setStateCallNode = findSetStateInBody(expression.body);
|
|
24896
|
+
if (setStateCallNode) context.report({
|
|
24897
|
+
node: setStateCallNode,
|
|
24898
|
+
message: "setState in onScroll triggers re-renders on every scroll event — use a Reanimated shared value (useAnimatedScrollHandler) or a ref to track scroll position"
|
|
24899
|
+
});
|
|
24900
|
+
}
|
|
24901
|
+
};
|
|
24902
|
+
}
|
|
24393
24903
|
});
|
|
24394
24904
|
//#endregion
|
|
24395
|
-
//#region src/plugin/rules/react-native/utils/scrollview_names.ts
|
|
24396
|
-
const SCROLLVIEW_NAMES = new Set(["ScrollView"]);
|
|
24397
|
-
//#endregion
|
|
24398
24905
|
//#region src/plugin/rules/react-native/rn-no-scrollview-mapped-list.ts
|
|
24906
|
+
const NON_VIRTUALIZED_SCROLL_CONTAINERS = new Set(["ScrollView"]);
|
|
24907
|
+
const ARRAY_ITERATION_METHODS = new Set(["map", "flatMap"]);
|
|
24908
|
+
const isArrayIterationExpression = (node) => {
|
|
24909
|
+
if (!isNodeOfType(node, "CallExpression")) return false;
|
|
24910
|
+
if (!isNodeOfType(node.callee, "MemberExpression")) return false;
|
|
24911
|
+
if (!isNodeOfType(node.callee.property, "Identifier")) return false;
|
|
24912
|
+
if (ARRAY_ITERATION_METHODS.has(node.callee.property.name)) return true;
|
|
24913
|
+
if (node.callee.property.name === "filter" || node.callee.property.name === "slice" || node.callee.property.name === "sort" || node.callee.property.name === "reverse" || node.callee.property.name === "concat") return isArrayIterationExpression(node.callee.object);
|
|
24914
|
+
return false;
|
|
24915
|
+
};
|
|
24399
24916
|
const rnNoScrollviewMappedList = defineRule({
|
|
24400
24917
|
id: "rn-no-scrollview-mapped-list",
|
|
24401
24918
|
tags: ["test-noise"],
|
|
@@ -24404,11 +24921,11 @@ const rnNoScrollviewMappedList = defineRule({
|
|
|
24404
24921
|
recommendation: "Use FlashList, LegendList, or FlatList — `<ScrollView>{items.map(...)}</ScrollView>` mounts every row in memory",
|
|
24405
24922
|
create: (context) => ({ JSXElement(node) {
|
|
24406
24923
|
const elementName = resolveJsxElementName(node.openingElement);
|
|
24407
|
-
if (!elementName || !
|
|
24924
|
+
if (!elementName || !NON_VIRTUALIZED_SCROLL_CONTAINERS.has(elementName)) return;
|
|
24408
24925
|
for (const child of node.children ?? []) {
|
|
24409
24926
|
if (!isNodeOfType(child, "JSXExpressionContainer")) continue;
|
|
24410
24927
|
const expression = child.expression;
|
|
24411
|
-
if (
|
|
24928
|
+
if (isArrayIterationExpression(expression)) {
|
|
24412
24929
|
context.report({
|
|
24413
24930
|
node: child,
|
|
24414
24931
|
message: `<${elementName}> rendering items.map(...) — use FlashList, LegendList, or FlatList so only visible rows mount`
|
|
@@ -24441,6 +24958,15 @@ const rnNoSingleElementStyleArray = defineRule({
|
|
|
24441
24958
|
} })
|
|
24442
24959
|
});
|
|
24443
24960
|
//#endregion
|
|
24961
|
+
//#region src/plugin/rules/react-native/utils/scrollview_names.ts
|
|
24962
|
+
const SCROLLVIEW_NAMES = new Set([
|
|
24963
|
+
"ScrollView",
|
|
24964
|
+
"FlatList",
|
|
24965
|
+
"SectionList",
|
|
24966
|
+
"VirtualizedList",
|
|
24967
|
+
"KeyboardAwareScrollView"
|
|
24968
|
+
]);
|
|
24969
|
+
//#endregion
|
|
24444
24970
|
//#region src/plugin/rules/react-native/rn-prefer-content-inset-adjustment.ts
|
|
24445
24971
|
const rnPreferContentInsetAdjustment = defineRule({
|
|
24446
24972
|
id: "rn-prefer-content-inset-adjustment",
|
|
@@ -24454,9 +24980,10 @@ const rnPreferContentInsetAdjustment = defineRule({
|
|
|
24454
24980
|
if (!isNodeOfType(child, "JSXElement")) continue;
|
|
24455
24981
|
const childName = resolveJsxElementName(child.openingElement);
|
|
24456
24982
|
if (!childName || !SCROLLVIEW_NAMES.has(childName)) continue;
|
|
24983
|
+
if (childName === "KeyboardAwareScrollView") continue;
|
|
24457
24984
|
context.report({
|
|
24458
24985
|
node,
|
|
24459
|
-
message:
|
|
24986
|
+
message: `<SafeAreaView> wrapping <${childName}> — set \`contentInsetAdjustmentBehavior="automatic"\` on the ${childName} and drop the SafeAreaView wrapper for native safe-area handling`
|
|
24460
24987
|
});
|
|
24461
24988
|
return;
|
|
24462
24989
|
}
|
|
@@ -24464,23 +24991,28 @@ const rnPreferContentInsetAdjustment = defineRule({
|
|
|
24464
24991
|
});
|
|
24465
24992
|
//#endregion
|
|
24466
24993
|
//#region src/plugin/rules/react-native/rn-prefer-expo-image.ts
|
|
24994
|
+
const EMPTY_VISITORS$1 = {};
|
|
24467
24995
|
const rnPreferExpoImage = defineRule({
|
|
24468
24996
|
id: "rn-prefer-expo-image",
|
|
24469
24997
|
tags: ["test-noise"],
|
|
24470
24998
|
requires: ["react-native"],
|
|
24471
24999
|
severity: "warn",
|
|
24472
25000
|
recommendation: "Use `<Image>` from `expo-image` instead of `react-native` — same prop API, plus disk + memory caching, placeholders, and crossfades",
|
|
24473
|
-
create: (context) =>
|
|
24474
|
-
if (
|
|
24475
|
-
|
|
24476
|
-
if (
|
|
24477
|
-
|
|
24478
|
-
|
|
24479
|
-
|
|
24480
|
-
|
|
24481
|
-
|
|
24482
|
-
|
|
24483
|
-
|
|
25001
|
+
create: (context) => {
|
|
25002
|
+
if (!isExpoManagedFileActive(context)) return EMPTY_VISITORS$1;
|
|
25003
|
+
return { ImportDeclaration(node) {
|
|
25004
|
+
if (node.source?.value !== "react-native") return;
|
|
25005
|
+
for (const specifier of node.specifiers ?? []) {
|
|
25006
|
+
if (!isNodeOfType(specifier, "ImportSpecifier")) continue;
|
|
25007
|
+
const importedName = getImportedName(specifier);
|
|
25008
|
+
if (importedName !== "Image" && importedName !== "ImageBackground") continue;
|
|
25009
|
+
context.report({
|
|
25010
|
+
node: specifier,
|
|
25011
|
+
message: `Importing ${importedName} from react-native — prefer expo-image for caching, placeholders, and progressive loading (drop-in API)`
|
|
25012
|
+
});
|
|
25013
|
+
}
|
|
25014
|
+
} };
|
|
25015
|
+
}
|
|
24484
25016
|
});
|
|
24485
25017
|
//#endregion
|
|
24486
25018
|
//#region src/plugin/rules/react-native/rn-prefer-pressable.ts
|
|
@@ -24490,6 +25022,7 @@ const TOUCHABLE_COMPONENTS = new Set([
|
|
|
24490
25022
|
"TouchableWithoutFeedback",
|
|
24491
25023
|
"TouchableNativeFeedback"
|
|
24492
25024
|
]);
|
|
25025
|
+
const TOUCHABLE_SOURCES = new Set(["react-native", "react-native-gesture-handler"]);
|
|
24493
25026
|
const rnPreferPressable = defineRule({
|
|
24494
25027
|
id: "rn-prefer-pressable",
|
|
24495
25028
|
tags: ["test-noise"],
|
|
@@ -24497,7 +25030,8 @@ const rnPreferPressable = defineRule({
|
|
|
24497
25030
|
severity: "warn",
|
|
24498
25031
|
recommendation: "Use `<Pressable>` from react-native (or react-native-gesture-handler) instead of legacy Touchable* components",
|
|
24499
25032
|
create: (context) => ({ ImportDeclaration(node) {
|
|
24500
|
-
|
|
25033
|
+
const source = node.source?.value;
|
|
25034
|
+
if (typeof source !== "string" || !TOUCHABLE_SOURCES.has(source)) return;
|
|
24501
25035
|
for (const specifier of node.specifiers ?? []) {
|
|
24502
25036
|
if (!isNodeOfType(specifier, "ImportSpecifier")) continue;
|
|
24503
25037
|
const importedName = getImportedName(specifier);
|
|
@@ -24511,6 +25045,7 @@ const rnPreferPressable = defineRule({
|
|
|
24511
25045
|
});
|
|
24512
25046
|
//#endregion
|
|
24513
25047
|
//#region src/plugin/rules/react-native/rn-prefer-reanimated.ts
|
|
25048
|
+
const JS_THREAD_ANIMATION_IMPORTS = new Set(["Animated", "LayoutAnimation"]);
|
|
24514
25049
|
const rnPreferReanimated = defineRule({
|
|
24515
25050
|
id: "rn-prefer-reanimated",
|
|
24516
25051
|
tags: ["test-noise"],
|
|
@@ -24521,17 +25056,31 @@ const rnPreferReanimated = defineRule({
|
|
|
24521
25056
|
if (node.source?.value !== "react-native") return;
|
|
24522
25057
|
for (const specifier of node.specifiers ?? []) {
|
|
24523
25058
|
if (!isNodeOfType(specifier, "ImportSpecifier")) continue;
|
|
24524
|
-
|
|
25059
|
+
const importedName = getImportedName(specifier);
|
|
25060
|
+
if (!importedName || !JS_THREAD_ANIMATION_IMPORTS.has(importedName)) continue;
|
|
25061
|
+
const suggestion = importedName === "LayoutAnimation" ? "LayoutAnimation runs animations on the JS thread and causes full layout recalculations — use Reanimated's Layout Animations (entering/exiting/layout props) for UI-thread layout transitions" : "Animated from react-native runs animations on the JS thread — use react-native-reanimated for performant UI-thread animations";
|
|
24525
25062
|
context.report({
|
|
24526
25063
|
node: specifier,
|
|
24527
|
-
message:
|
|
25064
|
+
message: suggestion
|
|
24528
25065
|
});
|
|
24529
25066
|
}
|
|
24530
25067
|
} })
|
|
24531
25068
|
});
|
|
24532
25069
|
//#endregion
|
|
24533
25070
|
//#region src/plugin/rules/react-native/rn-pressable-shared-value-mutation.ts
|
|
24534
|
-
const PRESS_HANDLER_PROP_NAMES = new Set([
|
|
25071
|
+
const PRESS_HANDLER_PROP_NAMES = new Set([
|
|
25072
|
+
"onPress",
|
|
25073
|
+
"onPressIn",
|
|
25074
|
+
"onPressOut",
|
|
25075
|
+
"onLongPress"
|
|
25076
|
+
]);
|
|
25077
|
+
const PRESSABLE_ELEMENT_NAMES = new Set([
|
|
25078
|
+
"Pressable",
|
|
25079
|
+
"TouchableOpacity",
|
|
25080
|
+
"TouchableHighlight",
|
|
25081
|
+
"TouchableWithoutFeedback",
|
|
25082
|
+
"TouchableNativeFeedback"
|
|
25083
|
+
]);
|
|
24535
25084
|
const handlerMutatesIdentifier = (handler, sharedValueBindings) => {
|
|
24536
25085
|
if (!isNodeOfType(handler, "ArrowFunctionExpression") && !isNodeOfType(handler, "FunctionExpression")) return false;
|
|
24537
25086
|
if (sharedValueBindings.size === 0) return false;
|
|
@@ -24578,7 +25127,8 @@ const rnPressableSharedValueMutation = defineRule({
|
|
|
24578
25127
|
trackSharedValueBinding(node);
|
|
24579
25128
|
},
|
|
24580
25129
|
JSXOpeningElement(node) {
|
|
24581
|
-
|
|
25130
|
+
const name = resolveJsxElementName(node);
|
|
25131
|
+
if (!name || !PRESSABLE_ELEMENT_NAMES.has(name)) return;
|
|
24582
25132
|
if (sharedValueBindingsByComponent.length === 0) return;
|
|
24583
25133
|
const activeBindings = /* @__PURE__ */ new Set();
|
|
24584
25134
|
for (const frame of sharedValueBindingsByComponent) for (const binding of frame) activeBindings.add(binding);
|
|
@@ -24593,7 +25143,7 @@ const rnPressableSharedValueMutation = defineRule({
|
|
|
24593
25143
|
if (!handlerMutatesIdentifier(handler, activeBindings)) continue;
|
|
24594
25144
|
context.report({
|
|
24595
25145
|
node: attr,
|
|
24596
|
-
message:
|
|
25146
|
+
message: `<${name}> ${attr.name.name} mutates a Reanimated shared value — use a Gesture.Tap() inside <GestureDetector> for press animations that stay on the UI thread`
|
|
24597
25147
|
});
|
|
24598
25148
|
}
|
|
24599
25149
|
}
|
|
@@ -24611,7 +25161,8 @@ const rnScrollviewDynamicPadding = defineRule({
|
|
|
24611
25161
|
create: (context) => ({ JSXOpeningElement(node) {
|
|
24612
25162
|
const elementName = resolveJsxElementName(node);
|
|
24613
25163
|
if (!elementName) return;
|
|
24614
|
-
if (!SCROLLVIEW_NAMES.has(elementName) && elementName !== "
|
|
25164
|
+
if (!SCROLLVIEW_NAMES.has(elementName) && elementName !== "FlashList") return;
|
|
25165
|
+
if (elementName === "KeyboardAwareScrollView") return;
|
|
24615
25166
|
for (const attr of node.attributes ?? []) {
|
|
24616
25167
|
if (!isNodeOfType(attr, "JSXAttribute")) continue;
|
|
24617
25168
|
if (!isNodeOfType(attr.name, "JSXIdentifier") || attr.name.name !== "contentContainerStyle") continue;
|
|
@@ -24668,13 +25219,23 @@ const rnStylePreferBoxShadow = defineRule({
|
|
|
24668
25219
|
if (attrName !== "style" && !attrName.endsWith("Style")) return;
|
|
24669
25220
|
if (!isNodeOfType(node.value, "JSXExpressionContainer")) return;
|
|
24670
25221
|
const expression = node.value.expression;
|
|
24671
|
-
if (
|
|
24672
|
-
|
|
24673
|
-
|
|
24674
|
-
|
|
24675
|
-
|
|
24676
|
-
|
|
24677
|
-
})
|
|
25222
|
+
if (isNodeOfType(expression, "ObjectExpression")) {
|
|
25223
|
+
const match = findLegacyShadowProperty(expression);
|
|
25224
|
+
if (match) context.report({
|
|
25225
|
+
node: match.node,
|
|
25226
|
+
message: `${match.keyName} is iOS/Android-platform-specific — use the cross-platform CSS \`boxShadow\` string (e.g. \`boxShadow: "0 2px 8px rgba(0,0,0,0.1)"\`) on RN v7+`
|
|
25227
|
+
});
|
|
25228
|
+
} else if (isNodeOfType(expression, "ArrayExpression")) for (const element of expression.elements ?? []) {
|
|
25229
|
+
if (!isNodeOfType(element, "ObjectExpression")) continue;
|
|
25230
|
+
const match = findLegacyShadowProperty(element);
|
|
25231
|
+
if (match) {
|
|
25232
|
+
context.report({
|
|
25233
|
+
node: match.node,
|
|
25234
|
+
message: `${match.keyName} is iOS/Android-platform-specific — use the cross-platform CSS \`boxShadow\` string on RN v7+`
|
|
25235
|
+
});
|
|
25236
|
+
return;
|
|
25237
|
+
}
|
|
25238
|
+
}
|
|
24678
25239
|
},
|
|
24679
25240
|
CallExpression(node) {
|
|
24680
25241
|
if (!isNodeOfType(node.callee, "MemberExpression")) return;
|
|
@@ -28732,7 +29293,7 @@ const serverFetchWithoutRevalidate = defineRule({
|
|
|
28732
29293
|
let isServerSideFile = false;
|
|
28733
29294
|
return {
|
|
28734
29295
|
Program(node) {
|
|
28735
|
-
const filename = context.getFilename?.() ?? "";
|
|
29296
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
28736
29297
|
if (!APP_ROUTER_FILE_PATTERN.test(filename)) {
|
|
28737
29298
|
isServerSideFile = false;
|
|
28738
29299
|
return;
|
|
@@ -28845,7 +29406,7 @@ const serverHoistStaticIo = defineRule({
|
|
|
28845
29406
|
inspectHandlerBody(context, declaration.body, `${handlerName} route handler`, collectIdentifierParams(declaration.params ?? []));
|
|
28846
29407
|
},
|
|
28847
29408
|
ExportDefaultDeclaration(node) {
|
|
28848
|
-
const filename = context.getFilename?.() ?? "";
|
|
29409
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
28849
29410
|
if (!PAGES_ROUTER_API_PATH_PATTERN.test(filename)) return;
|
|
28850
29411
|
const declaration = node.declaration;
|
|
28851
29412
|
if (!declaration || !isNodeOfType(declaration, "FunctionDeclaration") && !isNodeOfType(declaration, "FunctionExpression") && !isNodeOfType(declaration, "ArrowFunctionExpression")) return;
|
|
@@ -29312,6 +29873,47 @@ const tanstackStartLoaderParallelFetch = defineRule({
|
|
|
29312
29873
|
});
|
|
29313
29874
|
//#endregion
|
|
29314
29875
|
//#region src/plugin/rules/tanstack-start/tanstack-start-missing-head-content.ts
|
|
29876
|
+
const TANSTACK_ROUTER_PACKAGE = "@tanstack/react-router";
|
|
29877
|
+
const HEAD_CONTENT_COMPONENT_NAME = "HeadContent";
|
|
29878
|
+
const DOCUMENT_HEAD_ELEMENT_NAME = "head";
|
|
29879
|
+
const getJsxMemberRootName = (node) => {
|
|
29880
|
+
if (isNodeOfType(node.object, "JSXIdentifier")) return node.object.name;
|
|
29881
|
+
if (isNodeOfType(node.object, "JSXMemberExpression")) return getJsxMemberRootName(node.object);
|
|
29882
|
+
return null;
|
|
29883
|
+
};
|
|
29884
|
+
const getJsxMemberPropertyName = (node) => {
|
|
29885
|
+
if (isNodeOfType(node.property, "JSXIdentifier")) return node.property.name;
|
|
29886
|
+
return null;
|
|
29887
|
+
};
|
|
29888
|
+
const getMemberRootName = (node) => {
|
|
29889
|
+
if (isNodeOfType(node.object, "Identifier")) return node.object.name;
|
|
29890
|
+
if (isNodeOfType(node.object, "MemberExpression")) return getMemberRootName(node.object);
|
|
29891
|
+
return null;
|
|
29892
|
+
};
|
|
29893
|
+
const getMemberPropertyName = (node) => {
|
|
29894
|
+
if (isNodeOfType(node.property, "Identifier")) return node.property.name;
|
|
29895
|
+
return null;
|
|
29896
|
+
};
|
|
29897
|
+
const isDocumentHeadElement = (node) => isNodeOfType(node, "JSXElement") && isNodeOfType(node.openingElement.name, "JSXIdentifier") && node.openingElement.name.name === DOCUMENT_HEAD_ELEMENT_NAME;
|
|
29898
|
+
const isInsideDocumentHeadElement = (node) => {
|
|
29899
|
+
let currentNode = node.parent;
|
|
29900
|
+
while (currentNode) {
|
|
29901
|
+
if (isDocumentHeadElement(currentNode)) return true;
|
|
29902
|
+
currentNode = currentNode.parent;
|
|
29903
|
+
}
|
|
29904
|
+
return false;
|
|
29905
|
+
};
|
|
29906
|
+
const isCustomJsxElementName = (node) => {
|
|
29907
|
+
if (isNodeOfType(node, "JSXIdentifier")) {
|
|
29908
|
+
const firstCharacter = node.name.charAt(0);
|
|
29909
|
+
return firstCharacter.toUpperCase() === firstCharacter && firstCharacter.toLowerCase() !== firstCharacter;
|
|
29910
|
+
}
|
|
29911
|
+
if (!isNodeOfType(node, "JSXMemberExpression")) return false;
|
|
29912
|
+
const rootName = getJsxMemberRootName(node);
|
|
29913
|
+
if (!rootName) return false;
|
|
29914
|
+
const firstCharacter = rootName.charAt(0);
|
|
29915
|
+
return firstCharacter.toUpperCase() === firstCharacter && firstCharacter.toLowerCase() !== firstCharacter;
|
|
29916
|
+
};
|
|
29315
29917
|
const tanstackStartMissingHeadContent = defineRule({
|
|
29316
29918
|
id: "tanstack-start-missing-head-content",
|
|
29317
29919
|
tags: ["test-noise"],
|
|
@@ -29320,16 +29922,79 @@ const tanstackStartMissingHeadContent = defineRule({
|
|
|
29320
29922
|
recommendation: "Add `<HeadContent />` inside `<head>` in your __root route — without it, route `head()` meta tags are silently dropped",
|
|
29321
29923
|
create: (context) => {
|
|
29322
29924
|
let hasHeadContentElement = false;
|
|
29925
|
+
let hasDocumentHeadElement = false;
|
|
29926
|
+
let hasCustomHeadChildElement = false;
|
|
29927
|
+
const headContentComponentNames = new Set([HEAD_CONTENT_COMPONENT_NAME]);
|
|
29928
|
+
const tanstackRouterNamespaceNames = /* @__PURE__ */ new Set();
|
|
29929
|
+
const collectImportBindings = (node) => {
|
|
29930
|
+
if (!isNodeOfType(node, "ImportDeclaration")) return;
|
|
29931
|
+
const isTanstackRouterImport = node.source.value === TANSTACK_ROUTER_PACKAGE;
|
|
29932
|
+
const specifiers = node.specifiers ?? [];
|
|
29933
|
+
for (const specifier of specifiers) {
|
|
29934
|
+
if (isTanstackRouterImport && isNodeOfType(specifier, "ImportNamespaceSpecifier")) {
|
|
29935
|
+
tanstackRouterNamespaceNames.add(specifier.local.name);
|
|
29936
|
+
continue;
|
|
29937
|
+
}
|
|
29938
|
+
if (!isNodeOfType(specifier, "ImportSpecifier")) continue;
|
|
29939
|
+
if (!isNodeOfType(specifier.imported, "Identifier") || specifier.imported.name !== HEAD_CONTENT_COMPONENT_NAME) continue;
|
|
29940
|
+
headContentComponentNames.add(specifier.local.name);
|
|
29941
|
+
}
|
|
29942
|
+
};
|
|
29943
|
+
const collectVariableAlias = (node) => {
|
|
29944
|
+
if (!isNodeOfType(node, "VariableDeclarator")) return;
|
|
29945
|
+
if (!isNodeOfType(node.id, "Identifier")) return;
|
|
29946
|
+
const initializer = node.init;
|
|
29947
|
+
if (!initializer) return;
|
|
29948
|
+
if (isNodeOfType(initializer, "Identifier")) {
|
|
29949
|
+
if (headContentComponentNames.has(initializer.name)) headContentComponentNames.add(node.id.name);
|
|
29950
|
+
if (tanstackRouterNamespaceNames.has(initializer.name)) tanstackRouterNamespaceNames.add(node.id.name);
|
|
29951
|
+
return;
|
|
29952
|
+
}
|
|
29953
|
+
if (!isNodeOfType(initializer, "MemberExpression")) return;
|
|
29954
|
+
const rootName = getMemberRootName(initializer);
|
|
29955
|
+
const propertyName = getMemberPropertyName(initializer);
|
|
29956
|
+
if (rootName && tanstackRouterNamespaceNames.has(rootName) && propertyName === HEAD_CONTENT_COMPONENT_NAME) headContentComponentNames.add(node.id.name);
|
|
29957
|
+
};
|
|
29323
29958
|
return {
|
|
29324
|
-
|
|
29959
|
+
Program(node) {
|
|
29325
29960
|
const filename = context.getFilename?.() ?? "";
|
|
29326
29961
|
if (!TANSTACK_ROOT_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
29327
|
-
|
|
29962
|
+
const statements = node.body ?? [];
|
|
29963
|
+
for (const statement of statements) collectImportBindings(statement);
|
|
29964
|
+
for (const statement of statements) {
|
|
29965
|
+
if (!isNodeOfType(statement, "VariableDeclaration")) continue;
|
|
29966
|
+
for (const declaration of statement.declarations ?? []) collectVariableAlias(declaration);
|
|
29967
|
+
}
|
|
29328
29968
|
},
|
|
29329
|
-
|
|
29969
|
+
ImportDeclaration(node) {
|
|
29970
|
+
const filename = context.getFilename?.() ?? "";
|
|
29971
|
+
if (!TANSTACK_ROOT_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
29972
|
+
collectImportBindings(node);
|
|
29973
|
+
},
|
|
29974
|
+
VariableDeclarator(node) {
|
|
29330
29975
|
const filename = context.getFilename?.() ?? "";
|
|
29331
29976
|
if (!TANSTACK_ROOT_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
29332
|
-
|
|
29977
|
+
collectVariableAlias(node);
|
|
29978
|
+
},
|
|
29979
|
+
JSXOpeningElement(node) {
|
|
29980
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
29981
|
+
if (!TANSTACK_ROOT_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
29982
|
+
if (isNodeOfType(node.name, "JSXIdentifier")) {
|
|
29983
|
+
if (node.name.name === DOCUMENT_HEAD_ELEMENT_NAME) hasDocumentHeadElement = true;
|
|
29984
|
+
if (headContentComponentNames.has(node.name.name)) hasHeadContentElement = true;
|
|
29985
|
+
if (isInsideDocumentHeadElement(node) && isCustomJsxElementName(node.name)) hasCustomHeadChildElement = true;
|
|
29986
|
+
return;
|
|
29987
|
+
}
|
|
29988
|
+
if (!isNodeOfType(node.name, "JSXMemberExpression")) return;
|
|
29989
|
+
const rootName = getJsxMemberRootName(node.name);
|
|
29990
|
+
const propertyName = getJsxMemberPropertyName(node.name);
|
|
29991
|
+
if (rootName && tanstackRouterNamespaceNames.has(rootName) && propertyName === HEAD_CONTENT_COMPONENT_NAME) hasHeadContentElement = true;
|
|
29992
|
+
if (isInsideDocumentHeadElement(node) && isCustomJsxElementName(node.name)) hasCustomHeadChildElement = true;
|
|
29993
|
+
},
|
|
29994
|
+
"Program:exit"(programNode) {
|
|
29995
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
29996
|
+
if (!TANSTACK_ROOT_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
29997
|
+
if (hasDocumentHeadElement && !hasHeadContentElement && !hasCustomHeadChildElement) context.report({
|
|
29333
29998
|
node: programNode,
|
|
29334
29999
|
message: "Root route (__root) without <HeadContent /> — route head() meta tags won't render"
|
|
29335
30000
|
});
|
|
@@ -29346,7 +30011,7 @@ const tanstackStartNoAnchorElement = defineRule({
|
|
|
29346
30011
|
severity: "warn",
|
|
29347
30012
|
recommendation: "`import { Link } from '@tanstack/react-router'` — enables type-safe routes, preloading via `preload=\"intent\"`, and client-side navigation",
|
|
29348
30013
|
create: (context) => ({ JSXOpeningElement(node) {
|
|
29349
|
-
const filename = context.getFilename?.() ?? "";
|
|
30014
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
29350
30015
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
29351
30016
|
if (!isNodeOfType(node.name, "JSXIdentifier") || node.name.name !== "a") return;
|
|
29352
30017
|
const hrefAttribute = (node.attributes ?? []).find((attribute) => isNodeOfType(attribute, "JSXAttribute") && isNodeOfType(attribute.name, "JSXIdentifier") && attribute.name.name === "href");
|
|
@@ -29420,7 +30085,7 @@ const tanstackStartNoNavigateInRender = defineRule({
|
|
|
29420
30085
|
const isEventHandlerAttribute = (node) => isNodeOfType(node, "JSXAttribute") && isNodeOfType(node.name, "JSXIdentifier") && typeof node.name.name === "string" && node.name.name.startsWith("on") && UPPERCASE_PATTERN.test(node.name.name.charAt(2));
|
|
29421
30086
|
return {
|
|
29422
30087
|
CallExpression(node) {
|
|
29423
|
-
const filename = context.getFilename?.() ?? "";
|
|
30088
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
29424
30089
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
29425
30090
|
if (isDeferredHookCall(node)) deferredCallbackDepth++;
|
|
29426
30091
|
if (deferredCallbackDepth > 0 || eventHandlerDepth > 0) return;
|
|
@@ -29430,17 +30095,17 @@ const tanstackStartNoNavigateInRender = defineRule({
|
|
|
29430
30095
|
});
|
|
29431
30096
|
},
|
|
29432
30097
|
"CallExpression:exit"(node) {
|
|
29433
|
-
const filename = context.getFilename?.() ?? "";
|
|
30098
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
29434
30099
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
29435
30100
|
if (isDeferredHookCall(node)) deferredCallbackDepth = Math.max(0, deferredCallbackDepth - 1);
|
|
29436
30101
|
},
|
|
29437
30102
|
JSXAttribute(node) {
|
|
29438
|
-
const filename = context.getFilename?.() ?? "";
|
|
30103
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
29439
30104
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
29440
30105
|
if (isEventHandlerAttribute(node)) eventHandlerDepth++;
|
|
29441
30106
|
},
|
|
29442
30107
|
"JSXAttribute:exit"(node) {
|
|
29443
|
-
const filename = context.getFilename?.() ?? "";
|
|
30108
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
29444
30109
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
29445
30110
|
if (isEventHandlerAttribute(node)) eventHandlerDepth = Math.max(0, eventHandlerDepth - 1);
|
|
29446
30111
|
}
|
|
@@ -29521,7 +30186,7 @@ const tanstackStartNoUseEffectFetch = defineRule({
|
|
|
29521
30186
|
severity: "warn",
|
|
29522
30187
|
recommendation: "Fetch data in the route `loader` instead — the router coordinates loading before rendering to avoid waterfalls",
|
|
29523
30188
|
create: (context) => ({ CallExpression(node) {
|
|
29524
|
-
const filename = context.getFilename?.() ?? "";
|
|
30189
|
+
const filename = normalizeFilename$1(context.getFilename?.() ?? "");
|
|
29525
30190
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
29526
30191
|
if (!isHookCall$1(node, EFFECT_HOOK_NAMES$1)) return;
|
|
29527
30192
|
const callback = node.arguments?.[0];
|
|
@@ -32090,6 +32755,17 @@ const reactDoctorRules = [
|
|
|
32090
32755
|
category: "Architecture"
|
|
32091
32756
|
}
|
|
32092
32757
|
},
|
|
32758
|
+
{
|
|
32759
|
+
key: "react-doctor/react-compiler-no-manual-memoization",
|
|
32760
|
+
id: "react-compiler-no-manual-memoization",
|
|
32761
|
+
source: "react-doctor",
|
|
32762
|
+
originallyExternal: false,
|
|
32763
|
+
rule: {
|
|
32764
|
+
...reactCompilerNoManualMemoization,
|
|
32765
|
+
framework: "global",
|
|
32766
|
+
category: "Architecture"
|
|
32767
|
+
}
|
|
32768
|
+
},
|
|
32093
32769
|
{
|
|
32094
32770
|
key: "react-doctor/react-in-jsx-scope",
|
|
32095
32771
|
id: "react-in-jsx-scope",
|
|
@@ -32395,6 +33071,18 @@ const reactDoctorRules = [
|
|
|
32395
33071
|
tags: [...new Set(["react-native", ...rnNoDimensionsGet.tags ?? []])]
|
|
32396
33072
|
}
|
|
32397
33073
|
},
|
|
33074
|
+
{
|
|
33075
|
+
key: "react-doctor/rn-no-falsy-and-render",
|
|
33076
|
+
id: "rn-no-falsy-and-render",
|
|
33077
|
+
source: "react-doctor",
|
|
33078
|
+
originallyExternal: false,
|
|
33079
|
+
rule: {
|
|
33080
|
+
...rnNoFalsyAndRender,
|
|
33081
|
+
framework: "react-native",
|
|
33082
|
+
category: "React Native",
|
|
33083
|
+
tags: [...new Set(["react-native", ...rnNoFalsyAndRender.tags ?? []])]
|
|
33084
|
+
}
|
|
33085
|
+
},
|
|
32398
33086
|
{
|
|
32399
33087
|
key: "react-doctor/rn-no-inline-flatlist-renderitem",
|
|
32400
33088
|
id: "rn-no-inline-flatlist-renderitem",
|
|
@@ -32950,138 +33638,18 @@ const reactDoctorRules = [
|
|
|
32950
33638
|
];
|
|
32951
33639
|
const ruleRegistry = Object.fromEntries(reactDoctorRules.map((rule) => [rule.id, rule.rule]));
|
|
32952
33640
|
//#endregion
|
|
32953
|
-
//#region src/react-native-dependency-names.ts
|
|
32954
|
-
const REACT_NATIVE_DEPENDENCY_NAMES = new Set([
|
|
32955
|
-
"react-native",
|
|
32956
|
-
"react-native-tvos",
|
|
32957
|
-
"expo",
|
|
32958
|
-
"expo-router",
|
|
32959
|
-
"@expo/cli",
|
|
32960
|
-
"@expo/metro-config",
|
|
32961
|
-
"@expo/metro-runtime",
|
|
32962
|
-
"react-native-windows",
|
|
32963
|
-
"react-native-macos"
|
|
32964
|
-
]);
|
|
32965
|
-
const REACT_NATIVE_DEPENDENCY_PREFIXES = ["@react-native/", "@react-native-"];
|
|
32966
|
-
const isReactNativeDependencyName = (dependencyName) => {
|
|
32967
|
-
if (REACT_NATIVE_DEPENDENCY_NAMES.has(dependencyName)) return true;
|
|
32968
|
-
for (const prefix of REACT_NATIVE_DEPENDENCY_PREFIXES) if (dependencyName.startsWith(prefix)) return true;
|
|
32969
|
-
return false;
|
|
32970
|
-
};
|
|
32971
|
-
//#endregion
|
|
32972
|
-
//#region src/plugin/utils/classify-package-platform.ts
|
|
32973
|
-
const WEB_FRAMEWORK_DEPENDENCY_NAMES = new Set([
|
|
32974
|
-
"next",
|
|
32975
|
-
"vite",
|
|
32976
|
-
"react-scripts",
|
|
32977
|
-
"gatsby",
|
|
32978
|
-
"@remix-run/react",
|
|
32979
|
-
"@remix-run/node",
|
|
32980
|
-
"@docusaurus/core",
|
|
32981
|
-
"@docusaurus/preset-classic",
|
|
32982
|
-
"@storybook/react",
|
|
32983
|
-
"@storybook/react-vite",
|
|
32984
|
-
"@storybook/react-webpack5",
|
|
32985
|
-
"@storybook/nextjs",
|
|
32986
|
-
"@storybook/web-components",
|
|
32987
|
-
"storybook",
|
|
32988
|
-
"react-dom",
|
|
32989
|
-
"@vitejs/plugin-react",
|
|
32990
|
-
"@vitejs/plugin-react-swc"
|
|
32991
|
-
]);
|
|
32992
|
-
const cachedPlatformByPackageDirectory = /* @__PURE__ */ new Map();
|
|
32993
|
-
const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
|
|
32994
|
-
const findNearestPackageDirectory = (filename) => {
|
|
32995
|
-
if (!filename) return null;
|
|
32996
|
-
const fromCache = cachedPackageDirectoryByFilename.get(filename);
|
|
32997
|
-
if (fromCache !== void 0) return fromCache;
|
|
32998
|
-
let currentDirectory = path.dirname(filename);
|
|
32999
|
-
while (true) {
|
|
33000
|
-
const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
|
|
33001
|
-
let hasPackageJson = false;
|
|
33002
|
-
try {
|
|
33003
|
-
hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
|
|
33004
|
-
} catch {
|
|
33005
|
-
hasPackageJson = false;
|
|
33006
|
-
}
|
|
33007
|
-
if (hasPackageJson) {
|
|
33008
|
-
cachedPackageDirectoryByFilename.set(filename, currentDirectory);
|
|
33009
|
-
return currentDirectory;
|
|
33010
|
-
}
|
|
33011
|
-
const parentDirectory = path.dirname(currentDirectory);
|
|
33012
|
-
if (parentDirectory === currentDirectory) {
|
|
33013
|
-
cachedPackageDirectoryByFilename.set(filename, null);
|
|
33014
|
-
return null;
|
|
33015
|
-
}
|
|
33016
|
-
currentDirectory = parentDirectory;
|
|
33017
|
-
}
|
|
33018
|
-
};
|
|
33019
|
-
const readPackageJsonSafe = (packageJsonPath) => {
|
|
33020
|
-
let rawContents;
|
|
33021
|
-
try {
|
|
33022
|
-
rawContents = fs.readFileSync(packageJsonPath, "utf-8");
|
|
33023
|
-
} catch {
|
|
33024
|
-
return null;
|
|
33025
|
-
}
|
|
33026
|
-
try {
|
|
33027
|
-
const parsed = JSON.parse(rawContents);
|
|
33028
|
-
if (typeof parsed === "object" && parsed !== null) return parsed;
|
|
33029
|
-
return null;
|
|
33030
|
-
} catch {
|
|
33031
|
-
return null;
|
|
33032
|
-
}
|
|
33033
|
-
};
|
|
33034
|
-
const DEPENDENCY_SECTION_NAMES = [
|
|
33035
|
-
"dependencies",
|
|
33036
|
-
"devDependencies",
|
|
33037
|
-
"peerDependencies",
|
|
33038
|
-
"optionalDependencies"
|
|
33039
|
-
];
|
|
33040
|
-
const iterateDependencyNames = function* (packageJson) {
|
|
33041
|
-
for (const sectionName of DEPENDENCY_SECTION_NAMES) {
|
|
33042
|
-
const section = packageJson[sectionName];
|
|
33043
|
-
if (!section) continue;
|
|
33044
|
-
for (const dependencyName of Object.keys(section)) yield dependencyName;
|
|
33045
|
-
}
|
|
33046
|
-
};
|
|
33047
|
-
const isReactNativeAware = (packageJson) => {
|
|
33048
|
-
if (typeof packageJson["react-native"] === "string") return true;
|
|
33049
|
-
for (const dependencyName of iterateDependencyNames(packageJson)) if (isReactNativeDependencyName(dependencyName)) return true;
|
|
33050
|
-
return false;
|
|
33051
|
-
};
|
|
33052
|
-
const isWebFrameworkOnly = (packageJson) => {
|
|
33053
|
-
for (const dependencyName of iterateDependencyNames(packageJson)) if (WEB_FRAMEWORK_DEPENDENCY_NAMES.has(dependencyName)) return true;
|
|
33054
|
-
return false;
|
|
33055
|
-
};
|
|
33056
|
-
const classifyPackagePlatform = (filename) => {
|
|
33057
|
-
const packageDirectory = findNearestPackageDirectory(filename);
|
|
33058
|
-
if (!packageDirectory) return "unknown";
|
|
33059
|
-
const cached = cachedPlatformByPackageDirectory.get(packageDirectory);
|
|
33060
|
-
if (cached !== void 0) return cached;
|
|
33061
|
-
const packageJson = readPackageJsonSafe(path.join(packageDirectory, "package.json"));
|
|
33062
|
-
if (!packageJson) {
|
|
33063
|
-
cachedPlatformByPackageDirectory.set(packageDirectory, "unknown");
|
|
33064
|
-
return "unknown";
|
|
33065
|
-
}
|
|
33066
|
-
let result;
|
|
33067
|
-
if (isReactNativeAware(packageJson)) result = "react-native";
|
|
33068
|
-
else if (isWebFrameworkOnly(packageJson)) result = "web";
|
|
33069
|
-
else result = "unknown";
|
|
33070
|
-
cachedPlatformByPackageDirectory.set(packageDirectory, result);
|
|
33071
|
-
return result;
|
|
33072
|
-
};
|
|
33073
|
-
//#endregion
|
|
33074
33641
|
//#region src/plugin/utils/is-react-native-file.ts
|
|
33075
33642
|
const WEB_FILE_EXTENSION_PATTERN = /\.web\.[cm]?[jt]sx?$/;
|
|
33076
33643
|
const NATIVE_FILE_EXTENSION_PATTERN = /\.(?:ios|android|native)\.[cm]?[jt]sx?$/;
|
|
33077
33644
|
const isReactNativeFileActive = (context) => {
|
|
33078
|
-
const
|
|
33079
|
-
if (!
|
|
33645
|
+
const rawFilename = context.getFilename?.();
|
|
33646
|
+
if (!rawFilename) return true;
|
|
33647
|
+
const filename = normalizeFilename$1(rawFilename);
|
|
33080
33648
|
if (NATIVE_FILE_EXTENSION_PATTERN.test(filename)) return true;
|
|
33081
33649
|
if (WEB_FILE_EXTENSION_PATTERN.test(filename)) return false;
|
|
33082
33650
|
const packagePlatform = classifyPackagePlatform(filename);
|
|
33083
33651
|
if (packagePlatform === "web") return false;
|
|
33084
|
-
if (packagePlatform === "react-native") return true;
|
|
33652
|
+
if (packagePlatform === "expo" || packagePlatform === "react-native") return true;
|
|
33085
33653
|
const framework = getReactDoctorStringSetting(context.settings, "framework");
|
|
33086
33654
|
if (framework === "react-native" || framework === "expo") return true;
|
|
33087
33655
|
if (framework === "nextjs" || framework === "vite" || framework === "cra" || framework === "remix" || framework === "gatsby" || framework === "tanstack-start") return false;
|