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.js CHANGED
@@ -176,7 +176,8 @@ const SUBSCRIPTION_METHOD_NAMES = new Set([
176
176
  "listen",
177
177
  "sub"
178
178
  ]);
179
- const UNSUBSCRIPTION_METHOD_NAMES = new Set([
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
- ...UNSUBSCRIPTION_METHOD_NAMES,
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 = (filename) => {
420
- if (!filename) return false;
421
- const lastSlash = Math.max(filename.lastIndexOf("/"), filename.lastIndexOf("\\"));
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
- if (!isNodeOfType(node, "CallExpression")) return false;
4240
- if (!isNodeOfType(node.callee, "MemberExpression")) return false;
4241
- if (!isNodeOfType(node.callee.property, "Identifier")) return false;
4242
- return SUBSCRIPTION_METHOD_NAMES.has(node.callee.property.name);
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 isReleaseLikeCall = (callNode, knownBoundReleaseNames) => {
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
- const callee = callNode.callee;
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 (knownBoundReleaseNames.has(callee.name)) return true;
4304
+ if (knownCleanupFunctionNames.has(callee.name)) return true;
4253
4305
  return false;
4254
4306
  }
4255
- if (isNodeOfType(callee, "MemberExpression") && isNodeOfType(callee.property, "Identifier")) return UNSUBSCRIPTION_METHOD_NAMES.has(callee.property.name);
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 containsReleaseLikeCall = (node, knownBoundReleaseNames) => {
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 (isReleaseLikeCall(child, knownBoundReleaseNames)) {
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 isCleanupReturn = (returnedValue, knownBoundReleaseNames) => {
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
- if (isNodeOfType(returnedValue, "Identifier")) return knownBoundReleaseNames.has(returnedValue.name);
4272
- if (isSubscribeLikeCallExpression(returnedValue)) return true;
4273
- if (isNodeOfType(returnedValue, "ArrowFunctionExpression") || isNodeOfType(returnedValue, "FunctionExpression")) return containsReleaseLikeCall(returnedValue, knownBoundReleaseNames);
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 collectReleasableBindingNames = (effectCallback) => {
4305
- const releasableNames = /* @__PURE__ */ new Set();
4306
- if (!isNodeOfType(effectCallback, "ArrowFunctionExpression") && !isNodeOfType(effectCallback, "FunctionExpression")) return releasableNames;
4307
- if (!isNodeOfType(effectCallback.body, "BlockStatement")) return releasableNames;
4308
- for (const statement of effectCallback.body.body ?? []) {
4309
- if (!isNodeOfType(statement, "VariableDeclaration")) continue;
4310
- for (const declarator of statement.declarations ?? []) {
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
- releasableNames.add(declarator.id.name);
4316
- continue;
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
- return releasableNames;
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 isSubscribeLikeCallExpression(callback.body);
4326
- const knownBoundReleaseNames = collectReleasableBindingNames(callback);
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, knownBoundReleaseNames)) didFindCleanupReturn = true;
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: "List every value the hook callback captures in its dependency array.",
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 = (filename) => {
11774
- const segments = filename.split("/");
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 === "React" || namespaceName === "react" || namespaceName.startsWith("_react") || namespaceName.startsWith("_React")) && !isImportedFromModule(namespaceIdentifier, namespaceName, "react")) return;
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
- boundUnsubscribeName: isNodeOfType(declarator.id, "Identifier") ? declarator.id.name : null
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
- boundUnsubscribeName: null
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, boundUnsubscribeName) => {
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
- if (boundUnsubscribeName) knownBoundReleaseNames.add(boundUnsubscribeName);
22220
- return isCleanupReturn(lastStatement.argument, knownBoundReleaseNames);
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.boundUnsubscribeName)) continue;
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 isAutoGenerated = isAutoGeneratedSvgFile(context.getFilename?.());
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
- if (!isNodeOfType(singleAssignment, "AssignmentExpression")) return;
23784
- if (!isNodeOfType(singleAssignment.left, "MemberExpression")) return;
23785
- if (!isNodeOfType(singleAssignment.left.property, "Identifier")) return;
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
- return (isNodeOfType(parent.name, "JSXIdentifier") ? parent.name.name : null) === "renderItem";
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 renderItem creates a fresh closure per row — hoist with useCallback at list scope and pass the row id as a primitive prop`
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 VIRTUALIZED_LIST_NAMES = new Set([
23885
- "FlatList",
23886
- "FlashList",
23887
- "LegendList",
23888
- "SectionList",
23889
- "VirtualizedList"
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 || !VIRTUALIZED_LIST_NAMES.has(elementName)) return;
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
- if (!isNodeOfType(expression, "CallExpression")) continue;
23906
- if (!isNodeOfType(expression.callee, "MemberExpression")) continue;
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={items.${methodName}(...)}> allocates a fresh array per render — wrap in useMemo at list scope so the data reference stays stable across parent renders`
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/plugin/constants/react-native.ts
23948
- const REACT_NATIVE_TEXT_COMPONENTS = new Set([
23949
- "Text",
23950
- "TextInput",
23951
- "Typography",
23952
- "Paragraph",
23953
- "Span",
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 LEGACY_EXPO_PACKAGE_REPLACEMENTS = new Map([["expo-av", "expo-audio for audio and expo-video for video"], ["expo-permissions", "the permissions API in each module (e.g. Camera.requestPermissionsAsync())"]]);
23992
- const REACT_NATIVE_LIST_COMPONENTS = new Set([
23993
- "FlatList",
23994
- "SectionList",
23995
- "VirtualizedList",
23996
- "FlashList"
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 LEGACY_SHADOW_STYLE_PROPERTIES = new Set([
23999
- "shadowColor",
24000
- "shadowOffset",
24001
- "shadowOpacity",
24002
- "shadowRadius",
24003
- "elevation"
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) => ({ ImportDeclaration(node) {
24014
- if (node.source?.value !== "react-native") return;
24015
- for (const specifier of node.specifiers ?? []) {
24016
- if (!isNodeOfType(specifier, "ImportSpecifier")) continue;
24017
- const importedName = getImportedName(specifier);
24018
- if (!importedName) continue;
24019
- const replacement = DEPRECATED_RN_MODULE_REPLACEMENTS.get(importedName);
24020
- if (!replacement) continue;
24021
- context.report({
24022
- node: specifier,
24023
- message: `"${importedName}" was removed from react-native — use ${replacement} instead`
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
- let renderItemDepth = 0;
24087
- const isRenderItemAttribute = (parent) => {
24088
- if (!isNodeOfType(parent, "JSXAttribute")) return false;
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 false;
24096
- return isRenderItemAttribute(expressionContainer.parent);
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
- if (isRenderItemFunction(node)) renderItemDepth++;
24569
+ const renderPropName = resolveRenderPropName(node);
24570
+ if (renderPropName) renderPropStack.push(renderPropName);
24100
24571
  };
24101
24572
  const exit = (node) => {
24102
- if (isRenderItemFunction(node)) renderItemDepth = Math.max(0, renderItemDepth - 1);
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 (renderItemDepth === 0) return;
24581
+ if (renderPropStack.length === 0) return;
24111
24582
  if (!isNodeOfType(node.value, "JSXExpressionContainer")) return;
24112
- if (!isNodeOfType(node.value.expression, "ObjectExpression")) return;
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 object literal on "${propName}" inside renderItem — allocates a fresh reference per row and breaks memo() on the row component. Hoist outside renderItem or pass primitives`
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") || node.name.name !== "style") return;
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 Set(["@react-navigation/stack", "@react-navigation/drawer"]);
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" || !NON_NATIVE_NAVIGATOR_PACKAGES.has(source)) return;
24200
- const replacement = source.replace("@react-navigation/", "@react-navigation/native-");
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) => ({ JSXAttribute(node) {
24378
- if (!isNodeOfType(node.name, "JSXIdentifier")) return;
24379
- if (node.name.name !== "onScroll") return;
24380
- if (!isNodeOfType(node.value, "JSXExpressionContainer")) return;
24381
- const expression = node.value.expression;
24382
- if (!isNodeOfType(expression, "ArrowFunctionExpression") && !isNodeOfType(expression, "FunctionExpression")) return;
24383
- let setStateCallNode = null;
24384
- walkAst(expression.body, (child) => {
24385
- if (setStateCallNode) return;
24386
- if (isNodeOfType(child, "CallExpression") && isNodeOfType(child.callee, "Identifier") && /^set[A-Z]/.test(child.callee.name)) setStateCallNode = child;
24387
- });
24388
- if (setStateCallNode) context.report({
24389
- node: setStateCallNode,
24390
- message: "setState in onScroll triggers re-renders on every scroll event — use a Reanimated shared value (useAnimatedScrollHandler) or a ref to track scroll position"
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 || !SCROLLVIEW_NAMES.has(elementName)) return;
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 (isNodeOfType(expression, "CallExpression") && isNodeOfType(expression.callee, "MemberExpression") && isNodeOfType(expression.callee.property, "Identifier") && expression.callee.property.name === "map") {
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: "<SafeAreaView> wrapping <ScrollView> — set `contentInsetAdjustmentBehavior=\"automatic\"` on the ScrollView and drop the SafeAreaView wrapper for native safe-area handling"
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) => ({ ImportDeclaration(node) {
24474
- if (node.source?.value !== "react-native") return;
24475
- for (const specifier of node.specifiers ?? []) {
24476
- if (!isNodeOfType(specifier, "ImportSpecifier")) continue;
24477
- if (getImportedName(specifier) !== "Image") continue;
24478
- context.report({
24479
- node: specifier,
24480
- message: "Importing Image from react-native prefer expo-image for caching, placeholders, and progressive loading (drop-in API)"
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
- if (node.source?.value !== "react-native") return;
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
- if (getImportedName(specifier) !== "Animated") continue;
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: "Animated from react-native runs animations on the JS thread — use react-native-reanimated for performant UI-thread animations"
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(["onPressIn", "onPressOut"]);
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
- if (resolveJsxElementName(node) !== "Pressable") return;
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: `<Pressable> ${attr.name.name} mutates a Reanimated shared value — use a Gesture.Tap() inside <GestureDetector> for press animations that stay on the UI thread`
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 !== "FlatList" && elementName !== "FlashList") return;
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 (!isNodeOfType(expression, "ObjectExpression")) return;
24672
- const match = findLegacyShadowProperty(expression);
24673
- if (!match) return;
24674
- context.report({
24675
- node: match.node,
24676
- 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+`
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
- JSXOpeningElement(node) {
29959
+ Program(node) {
29325
29960
  const filename = context.getFilename?.() ?? "";
29326
29961
  if (!TANSTACK_ROOT_ROUTE_FILE_PATTERN.test(filename)) return;
29327
- if (isNodeOfType(node.name, "JSXIdentifier") && node.name.name === "HeadContent") hasHeadContentElement = true;
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
- "Program:exit"(programNode) {
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
- if (!hasHeadContentElement) context.report({
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 filename = context.getFilename?.();
33079
- if (!filename) return true;
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;