@colrealpro/react-luau-doctor 0.20.0 → 0.20.1

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/cli.js CHANGED
@@ -11,7 +11,7 @@ var package_default = {
11
11
  publishConfig: {
12
12
  access: "public"
13
13
  },
14
- version: "0.20.0",
14
+ version: "0.20.1",
15
15
  description: "Static analysis for React-Luau hooks, effects, rendering, and performance.",
16
16
  license: "MIT",
17
17
  type: "module",
@@ -121,6 +121,12 @@ function sameNode(left, right) {
121
121
  // src/ast/local-values.ts
122
122
  var valueCache = new WeakMap;
123
123
  var apiCache = new WeakMap;
124
+ function isUnboundIdentifier(context, node) {
125
+ return node.type === "identifier" && visibleValue(context, node) === undefined;
126
+ }
127
+ function localValueBinding(context, node) {
128
+ return node.type === "identifier" ? visibleValue(context, node) : null;
129
+ }
124
130
  function unwrapExpression(node) {
125
131
  while (["parenthesized_expression", "cast_expression"].includes(node.type) && node.namedChildren[0]) {
126
132
  node = node.namedChildren[0];
@@ -2421,6 +2427,242 @@ var effectNeedsCleanup = {
2421
2427
  }
2422
2428
  };
2423
2429
 
2430
+ // src/api-reference.ts
2431
+ var functions = new Map;
2432
+ function register(namespace, members, reference) {
2433
+ for (const member of members)
2434
+ functions.set(`${namespace}.${member}`, reference);
2435
+ }
2436
+ for (const type of [
2437
+ "Instance",
2438
+ "Random",
2439
+ "RaycastParams",
2440
+ "OverlapParams",
2441
+ "CatalogSearchParams",
2442
+ "SharedTable"
2443
+ ]) {
2444
+ register(type, ["new"], {
2445
+ returnType: type,
2446
+ allocation: "new",
2447
+ equality: "reference"
2448
+ });
2449
+ }
2450
+ register("table", ["clone", "create", "pack"], {
2451
+ returnType: "table",
2452
+ allocation: "new",
2453
+ equality: "reference"
2454
+ });
2455
+ register("SharedTable", ["clone", "cloneAndFreeze"], {
2456
+ returnType: "SharedTable",
2457
+ allocation: "new",
2458
+ equality: "reference"
2459
+ });
2460
+ register("string", ["split"], {
2461
+ returnType: "table",
2462
+ allocation: "new",
2463
+ equality: "reference"
2464
+ });
2465
+ register("buffer", ["create", "fromstring"], {
2466
+ returnType: "buffer",
2467
+ allocation: "new",
2468
+ equality: "reference"
2469
+ });
2470
+ register("coroutine", ["create"], {
2471
+ returnType: "thread",
2472
+ allocation: "new",
2473
+ equality: "reference"
2474
+ });
2475
+ register("coroutine", ["wrap"], {
2476
+ returnType: "function",
2477
+ allocation: "new",
2478
+ equality: "reference"
2479
+ });
2480
+ register("table", ["freeze"], {
2481
+ returnType: "table",
2482
+ allocation: "argument",
2483
+ equality: "reference",
2484
+ argumentIndex: 0
2485
+ });
2486
+ for (const [type, members] of Object.entries({
2487
+ Color3: ["new", "fromRGB", "fromHSV", "fromHex"],
2488
+ ColorSequence: ["new"],
2489
+ ColorSequenceKeypoint: ["new"],
2490
+ NumberSequence: ["new"],
2491
+ NumberSequenceKeypoint: ["new"],
2492
+ NumberRange: ["new"],
2493
+ UDim: ["new"],
2494
+ UDim2: ["new", "fromScale", "fromOffset"],
2495
+ Vector2: ["new"],
2496
+ Vector3: ["new", "fromNormalId", "fromAxis"],
2497
+ CFrame: [
2498
+ "new",
2499
+ "Angles",
2500
+ "fromEulerAnglesXYZ",
2501
+ "fromEulerAnglesYXZ",
2502
+ "fromOrientation",
2503
+ "fromAxisAngle",
2504
+ "fromMatrix",
2505
+ "lookAt",
2506
+ "lookAlong"
2507
+ ],
2508
+ Rect: ["new"],
2509
+ BrickColor: ["new", "palette"]
2510
+ })) {
2511
+ register(type, members, {
2512
+ returnType: type,
2513
+ allocation: "new",
2514
+ equality: "value"
2515
+ });
2516
+ }
2517
+ var instanceMethods = new Map([
2518
+ ...["GetChildren", "GetDescendants", "GetAttributes", "GetTags"].map((member) => [
2519
+ member,
2520
+ { returnType: "table", allocation: "new", equality: "reference" }
2521
+ ]),
2522
+ [
2523
+ "Clone",
2524
+ { returnType: "Instance", allocation: "new", equality: "reference" }
2525
+ ]
2526
+ ]);
2527
+ function apiFunctionReference(path) {
2528
+ return functions.get(path) ?? null;
2529
+ }
2530
+ function globalPath(context, expression, seen = new Set) {
2531
+ const node = unwrapExpression(expression);
2532
+ if (seen.has(node.id))
2533
+ return null;
2534
+ seen.add(node.id);
2535
+ if (node.type === "identifier") {
2536
+ if (isUnboundIdentifier(context, node))
2537
+ return node.text;
2538
+ const binding = localValueBinding(context, node);
2539
+ return binding?.returnIndex === 0 ? globalPath(context, binding.value, seen) : null;
2540
+ }
2541
+ if (node.type !== "dot_index_expression")
2542
+ return null;
2543
+ const namespace = node.childForFieldName("table");
2544
+ const member = node.childForFieldName("field");
2545
+ const path = namespace && globalPath(context, namespace, seen);
2546
+ return path && member ? `${path}.${member.text}` : null;
2547
+ }
2548
+ function isInstanceReceiver(context, expression, seen = new Set) {
2549
+ const node = unwrapExpression(expression);
2550
+ if (seen.has(node.id))
2551
+ return false;
2552
+ seen.add(node.id);
2553
+ if (node.type === "identifier") {
2554
+ if (isUnboundIdentifier(context, node))
2555
+ return ["game", "workspace", "script"].includes(node.text);
2556
+ const binding = localValueBinding(context, node);
2557
+ return binding?.returnIndex === 0 && isInstanceReceiver(context, binding.value, seen);
2558
+ }
2559
+ if (node.type === "dot_index_expression") {
2560
+ const receiver = node.childForFieldName("table");
2561
+ return Boolean(receiver && isInstanceReceiver(context, receiver, seen));
2562
+ }
2563
+ if (node.type !== "function_call")
2564
+ return false;
2565
+ const name = node.childForFieldName("name");
2566
+ if (!name)
2567
+ return false;
2568
+ if (globalPath(context, name) === "Instance.new")
2569
+ return true;
2570
+ if (name.type !== "method_index_expression")
2571
+ return false;
2572
+ const receiver = name.childForFieldName("table");
2573
+ const member = name.childForFieldName("method")?.text;
2574
+ return Boolean(receiver && member && [
2575
+ "GetService",
2576
+ "WaitForChild",
2577
+ "FindFirstChild",
2578
+ "FindFirstAncestor",
2579
+ "Clone"
2580
+ ].includes(member) && isInstanceReceiver(context, receiver, seen));
2581
+ }
2582
+ function apiReferenceForCall(context, call) {
2583
+ const name = call.childForFieldName("name");
2584
+ if (!name)
2585
+ return null;
2586
+ const path = globalPath(context, name);
2587
+ if (path)
2588
+ return apiFunctionReference(path);
2589
+ if (name.type !== "method_index_expression")
2590
+ return null;
2591
+ const receiver = name.childForFieldName("table");
2592
+ const member = name.childForFieldName("method")?.text;
2593
+ return receiver && member && isInstanceReceiver(context, receiver) ? instanceMethods.get(member) ?? null : null;
2594
+ }
2595
+
2596
+ // src/ast/value-identity.ts
2597
+ function freshValueKind(context, expression, lifetime, seen = new Set, returnIndex = 0, lifetimes = new Set([lifetime])) {
2598
+ if (!expression)
2599
+ return null;
2600
+ const node = unwrapExpression(expression);
2601
+ if (seen.has(node.id) || seen.size >= 64)
2602
+ return null;
2603
+ const visited = new Set(seen).add(node.id);
2604
+ const analyze = (value, owner = lifetime, index = 0) => freshValueKind(context, value, owner, visited, index, new Set(lifetimes).add(owner));
2605
+ if (node.type === "identifier") {
2606
+ const binding = resolveLocalValue(context, node);
2607
+ const owner = binding && context.nearestFunction(binding.value);
2608
+ if (!binding || !owner || !lifetimes.has(owner))
2609
+ return null;
2610
+ return analyze(binding.value, lifetime, binding.returnIndex);
2611
+ }
2612
+ if (returnIndex === 0) {
2613
+ if (node.type === "table_constructor")
2614
+ return "table";
2615
+ if (node.type === "function_definition" || node.type === "function_declaration")
2616
+ return "function";
2617
+ if (node.type === "binary_expression") {
2618
+ const operator = node.children.find((child) => ["and", "or"].includes(child.type))?.type;
2619
+ if (operator === "and")
2620
+ return analyze(node.childForFieldName("right"));
2621
+ if (operator === "or")
2622
+ return analyze(node.childForFieldName("left")) ?? analyze(node.childForFieldName("right"));
2623
+ }
2624
+ if (node.type === "if_expression") {
2625
+ for (const branch of [
2626
+ node,
2627
+ ...node.namedChildren.filter((child) => ["elseif_clause", "else_clause"].includes(child.type))
2628
+ ]) {
2629
+ const kind = analyze(branch.childForFieldName("consequence"));
2630
+ if (kind)
2631
+ return kind;
2632
+ }
2633
+ }
2634
+ }
2635
+ if (node.type !== "function_call")
2636
+ return null;
2637
+ const api = apiReferenceForCall(context, node);
2638
+ if (api && returnIndex === 0) {
2639
+ if (api.allocation === "argument")
2640
+ return analyze(context.callArguments(node)[api.argumentIndex ?? 0] ?? null);
2641
+ if (api.equality !== "reference")
2642
+ return null;
2643
+ return api.returnType === "table" || api.returnType === "function" ? api.returnType : "object";
2644
+ }
2645
+ const name = node.childForFieldName("name");
2646
+ const factory = name && resolveLocalFunction(context, name);
2647
+ if (!factory?.body)
2648
+ return null;
2649
+ for (const statement of context.walk(factory.body)) {
2650
+ if (statement.type !== "return_statement" || context.nearestFunction(statement) !== factory)
2651
+ continue;
2652
+ const values = statement.namedChildren.find((child) => child.type === "expression_list")?.namedChildren ?? [];
2653
+ const value = values[returnIndex] ?? values.at(-1);
2654
+ if (!value)
2655
+ continue;
2656
+ const index = Math.max(0, returnIndex - values.length + 1);
2657
+ if (index > 0 && value.type !== "function_call")
2658
+ continue;
2659
+ const kind = analyze(value, factory, index);
2660
+ if (kind)
2661
+ return kind;
2662
+ }
2663
+ return null;
2664
+ }
2665
+
2424
2666
  // src/rules/effect-state-rules.ts
2425
2667
  var HOOKS_WITH_DEPS = new Set([
2426
2668
  "React.useEffect",
@@ -2484,23 +2726,6 @@ function hasExternalSubscription(node, context) {
2484
2726
  }
2485
2727
  return false;
2486
2728
  }
2487
- function directLocalInitializer(owner, name) {
2488
- if (!owner.body)
2489
- return null;
2490
- for (const statement of owner.body.namedChildren) {
2491
- if (statement.type !== "variable_declaration")
2492
- continue;
2493
- const assignment = statement.namedChildren.find((child) => child.type === "assignment_statement");
2494
- const variableList = assignment?.namedChildren.find((child) => child.type === "variable_list");
2495
- const expressionList = assignment?.namedChildren.find((child) => child.type === "expression_list");
2496
- const names = variableList?.namedChildren.filter((child) => child.type === "identifier") ?? [];
2497
- const expressions = expressionList?.namedChildren ?? [];
2498
- const index = names.findIndex((child) => child.text === name);
2499
- if (index >= 0)
2500
- return expressions[index] ?? expressions[0] ?? null;
2501
- }
2502
- return null;
2503
- }
2504
2729
  function setterCalledOutsideCallback(context, owner, callback, setterName) {
2505
2730
  if (!owner.body)
2506
2731
  return false;
@@ -2540,20 +2765,6 @@ function guaranteedRepeatedStateChange(argument, stateName, context) {
2540
2765
  }
2541
2766
  return false;
2542
2767
  }
2543
- function freshDependencyKind(node, owner) {
2544
- if (node.type === "table_constructor")
2545
- return "table";
2546
- if (node.type === "function_definition")
2547
- return "function";
2548
- if (node.type === "identifier") {
2549
- const initializer = directLocalInitializer(owner, node.text);
2550
- if (initializer?.type === "table_constructor")
2551
- return "table";
2552
- if (initializer?.type === "function_definition")
2553
- return "function";
2554
- }
2555
- return null;
2556
- }
2557
2768
  var noDerivedStateEffect = {
2558
2769
  id: "react-luau/no-derived-state-effect",
2559
2770
  category: "Effects",
@@ -2665,7 +2876,7 @@ var noEffectWithFreshDeps = {
2665
2876
  id: "react-luau/no-effect-with-fresh-deps",
2666
2877
  category: "Hooks",
2667
2878
  severity: "error",
2668
- description: "Dependency tables should not contain tables or functions recreated on every render.",
2879
+ description: "Dependency tables should not contain reference values recreated during render",
2669
2880
  run(context) {
2670
2881
  const diagnostics = [];
2671
2882
  for (const call of context.findCalls()) {
@@ -2677,14 +2888,14 @@ var noEffectWithFreshDeps = {
2677
2888
  if (!owner || !parts)
2678
2889
  continue;
2679
2890
  for (const dependency of dependencyExpressions(parts.deps)) {
2680
- const kind = freshDependencyKind(dependency, owner);
2891
+ const kind = freshValueKind(context, dependency, owner);
2681
2892
  if (!kind)
2682
2893
  continue;
2683
2894
  const label = dependency.type === "identifier" ? ` ${dependency.text}` : "";
2684
2895
  diagnostics.push({
2685
2896
  node: dependency,
2686
- message: `${path} depends on${label || " an inline value"} that is a new ${kind} on every render.`,
2687
- help: "Move the value inside the hook callback and depend on its simple inputs, or intentionally stabilize the value with useMemo/useCallback when identity is part of the contract."
2897
+ message: `${path} depends on${label || " an inline value"} that can be a new ${kind} when the component renders`,
2898
+ help: "Move the value inside the hook callback and depend on its simple inputs, or intentionally stabilize the value with useMemo/useCallback when identity is part of the contract"
2688
2899
  });
2689
2900
  }
2690
2901
  }
@@ -4791,40 +5002,32 @@ function componentRenderCost(context, owner) {
4791
5002
  parts.push(`${discovery} Instance discovery call${discovery === 1 ? "" : "s"}`);
4792
5003
  return parts;
4793
5004
  }
4794
- function memoizedImports(context) {
4795
- const result = new Map;
4796
- for (const node of context.walk(context.root)) {
4797
- if (node.type !== "variable_declaration")
4798
- continue;
4799
- const match = node.text.match(/^\s*local\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)\s*$/s);
4800
- if (!match)
4801
- continue;
4802
- const localName = match[1];
4803
- const memoKind = resolveModuleReference(normalizeRequireTarget(match[2]), context.project.memoizedModules);
4804
- if (memoKind)
4805
- result.set(localName, memoKind);
5005
+ function memoizedComponentKind(context, expression) {
5006
+ const binding = resolveLocalValue(context, expression);
5007
+ if (!binding || binding.returnIndex !== 0 || binding.value.type !== "function_call")
5008
+ return null;
5009
+ const call = binding.value;
5010
+ if (reactApiPath(context, call) === "React.memo") {
5011
+ return context.callArguments(call).length > 1 ? "custom" : "shallow";
4806
5012
  }
4807
- return result;
5013
+ const target = context.callArguments(call)[0];
5014
+ if (context.getCallPath(call) !== "require" || !target)
5015
+ return null;
5016
+ return resolveModuleReference(normalizeRequireTarget(target.text), context.project.memoizedModules) ?? null;
4808
5017
  }
4809
5018
  function fieldLabel(field) {
4810
5019
  return fieldName(field) ?? field.childForFieldName("name")?.text ?? "prop";
4811
5020
  }
4812
- function isInlineFreshValue(node) {
4813
- return node?.type === "table_constructor" || node?.type === "function_definition";
4814
- }
4815
5021
  var rerenderUnstableMemoProps = {
4816
5022
  id: "react-luau/rerender-unstable-memo-props",
4817
5023
  category: "Performance",
4818
5024
  severity: "warning",
4819
- description: "Warn when fresh table or function props defeat shallow React.memo comparisons.",
5025
+ description: "Warn when props created during render can defeat shallow React.memo comparisons",
4820
5026
  guidance: {
4821
- explanation: "A new function or table has a new identity, so a shallow memo comparison cannot skip this child.",
4822
- help: "Stabilize props that change each render if this child is expensive, move construction into the child, or remove ineffective memoization."
5027
+ explanation: "New tables, functions, and reference objects compare unequal even when their contents match",
5028
+ help: "Stabilize props that change each render if this child is expensive, move construction into the child, or remove ineffective memoization"
4823
5029
  },
4824
5030
  run(context) {
4825
- const imports = memoizedImports(context);
4826
- if (imports.size === 0)
4827
- return [];
4828
5031
  const diagnostics = [];
4829
5032
  for (const call of context.findCalls()) {
4830
5033
  if (context.resolveCallPath(context.getCallPath(call) ?? "") !== "React.createElement")
@@ -4834,12 +5037,13 @@ var rerenderUnstableMemoProps = {
4834
5037
  continue;
4835
5038
  const args = context.callArguments(call);
4836
5039
  const childName = args[0]?.text.trim() ?? "";
4837
- if (imports.get(childName) !== "shallow")
5040
+ if (!args[0] || memoizedComponentKind(context, args[0]) !== "shallow")
4838
5041
  continue;
4839
- const props = args[1];
4840
- if (!props || props.type !== "table_constructor")
5042
+ const propsBinding = args[1] && resolveLocalValue(context, args[1]);
5043
+ const props = propsBinding?.returnIndex === 0 ? propsBinding.value : null;
5044
+ if (!props || props.type !== "table_constructor" || context.nearestFunction(props) !== component)
4841
5045
  continue;
4842
- const unstableFields = props.namedChildren.filter((node) => node.type === "field").filter((field) => isInlineFreshValue(fieldValue(field)));
5046
+ const unstableFields = props.namedChildren.filter((node) => node.type === "field").filter((field) => freshValueKind(context, fieldValue(field), component));
4843
5047
  if (unstableFields.length === 0)
4844
5048
  continue;
4845
5049
  const unstable = unstableFields.map(fieldLabel);
@@ -4848,13 +5052,13 @@ var rerenderUnstableMemoProps = {
4848
5052
  node: highlights[0],
4849
5053
  highlights,
4850
5054
  severity: "warning",
4851
- message: `Memoized ${childName} receives fresh ${unstable.join(", ")} prop${unstable.length === 1 ? "" : "s"} on every parent render.`,
4852
- summary: `${childName} gets ${unstable.length} fresh ${unstable.length === 1 ? "prop" : "props"} each render. Memoization cannot skip it.`,
5055
+ message: `Memoized ${childName} can receive fresh ${unstable.join(", ")} prop${unstable.length === 1 ? "" : "s"} when its parent renders`,
5056
+ summary: `${childName} gets ${unstable.length} ${unstable.length === 1 ? "prop" : "props"} created during render that can prevent memoization from skipping it`,
4853
5057
  editorRanges: highlights.map((node, index) => ({
4854
5058
  node,
4855
- summary: `${childName} gets a fresh ${unstable[index]} prop each render. Memoization cannot skip it.`
5059
+ summary: `${childName} gets a ${unstable[index]} prop created during render that can prevent memoization from skipping it`
4856
5060
  })),
4857
- help: "Fresh table or function identity prevents the shallow memo comparison from skipping this child. Stabilize the prop only when the parent rerenders frequently, move construction into the child, or remove ineffective memoization."
5061
+ help: "Fresh reference identity can prevent the shallow memo comparison from skipping this child. Stabilize the prop when the parent rerenders frequently, move construction into the child, or remove ineffective memoization"
4858
5062
  });
4859
5063
  }
4860
5064
  return diagnostics;
@@ -7028,30 +7232,32 @@ function currentConditionalHookMode(context) {
7028
7232
  }
7029
7233
  return null;
7030
7234
  }
7031
- function controlledParameterIndex(controlFlow, fn, summary) {
7235
+ function controlledParameterIndex(controlFlow, fn, summary, node) {
7032
7236
  if (controlFlow.type !== "if_statement")
7033
7237
  return null;
7034
- const header = (controlFlow.text.split(/\bthen\b/s, 1)[0] ?? "").trim();
7035
- for (const [conditionName, index] of Object.entries(summary.conditionVariables)) {
7036
- const escaped = conditionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7037
- const patterns = [
7038
- new RegExp(`^if\\s+${escaped}\\s*$`),
7039
- new RegExp(`^if\\s+not\\s+${escaped}\\s*$`),
7040
- new RegExp(`^if\\s+${escaped}\\s*(?:==|~=)\\s*(?:true|false|nil)\\s*$`),
7041
- new RegExp(`^if\\s+(?:true|false|nil)\\s*(?:==|~=)\\s*${escaped}\\s*$`)
7042
- ];
7043
- if (patterns.some((pattern) => pattern.test(header)))
7044
- return index;
7045
- }
7238
+ const conditions = guardConditions(controlFlow, node);
7239
+ if (conditions.length === 0)
7240
+ return null;
7241
+ const variables = { ...summary.conditionVariables };
7046
7242
  for (const index of summary.parameterIndexes) {
7047
7243
  const name = fn.parameters[index];
7048
- if (!name)
7049
- continue;
7050
- const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7051
- if (new RegExp(`^if\\s+(?:not\\s+)?${escaped}\\s*$`).test(header))
7052
- return index;
7244
+ if (name)
7245
+ variables[name] ??= index;
7053
7246
  }
7054
- return null;
7247
+ const indexes = conditions.map((condition) => {
7248
+ for (const [name, index] of Object.entries(variables)) {
7249
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7250
+ const patterns = [
7251
+ new RegExp(`^(?:not\\s+)?${escaped}\\s*$`),
7252
+ new RegExp(`^${escaped}\\s*(?:==|~=)\\s*(?:true|false|nil)\\s*$`),
7253
+ new RegExp(`^(?:true|false|nil)\\s*(?:==|~=)\\s*${escaped}\\s*$`)
7254
+ ];
7255
+ if (patterns.some((pattern) => pattern.test(condition.text.trim())))
7256
+ return index;
7257
+ }
7258
+ return null;
7259
+ });
7260
+ return indexes.every((index) => index !== null) ? indexes[0] : null;
7055
7261
  }
7056
7262
  function dynamicHookModeFixPreview(hookName, argumentText) {
7057
7263
  return {
@@ -7253,28 +7459,104 @@ function staticModuleTables(context) {
7253
7459
  }
7254
7460
  return candidates;
7255
7461
  }
7256
- function simpleConditionRoot(controlFlow) {
7257
- if (controlFlow.type !== "if_statement" && controlFlow.type !== "if_expression")
7258
- return null;
7462
+ function containsNode(container, node) {
7463
+ return node.startIndex >= container.startIndex && node.endIndex <= container.endIndex;
7464
+ }
7465
+ function branchContaining(controlFlow, node) {
7466
+ return controlFlow.namedChildren.find((child) => containsNode(child, node)) ?? null;
7467
+ }
7468
+ function guardConditions(controlFlow, node) {
7259
7469
  const condition = controlFlow.namedChildren[0];
7260
7470
  if (!condition)
7261
- return null;
7471
+ return [];
7472
+ if (controlFlow.type !== "if_statement" && controlFlow.type !== "if_expression")
7473
+ return [condition];
7474
+ if (containsNode(condition, node))
7475
+ return [];
7476
+ const conditions = [condition];
7477
+ for (const clause of controlFlow.namedChildren.slice(1)) {
7478
+ if (clause.startIndex > node.startIndex)
7479
+ break;
7480
+ if (clause.type !== "elseif_statement" && clause.type !== "elseif_clause")
7481
+ continue;
7482
+ const clauseCondition = clause.namedChildren[0];
7483
+ if (clauseCondition && !containsNode(clauseCondition, node))
7484
+ conditions.push(clauseCondition);
7485
+ }
7486
+ return conditions;
7487
+ }
7488
+ function conditionallyContains(controlFlow, node) {
7489
+ if (controlFlow.type === "if_statement" || controlFlow.type === "if_expression")
7490
+ return guardConditions(controlFlow, node).length > 0;
7491
+ if (controlFlow.type === "for_statement")
7492
+ return controlFlow.namedChildren.some((child) => child.type === "block" && containsNode(child, node));
7493
+ if (CONDITIONAL_TYPES.has(controlFlow.type))
7494
+ return true;
7495
+ if (controlFlow.type !== "binary_expression" || !controlFlow.children.some((child) => child.type === "and" || child.type === "or"))
7496
+ return false;
7497
+ const right = controlFlow.childForFieldName("right") ?? controlFlow.namedChildren[1];
7498
+ return Boolean(right && containsNode(right, node));
7499
+ }
7500
+ function simpleConditionRoot(condition) {
7262
7501
  const text = condition.text.trim();
7263
7502
  const match = text.match(/^(?:not\s+)?([A-Za-z_][A-Za-z0-9_]*)(?:\s*(?:==|~=)\s*(?:true|false|nil))?$/);
7264
7503
  return match?.[1] ?? null;
7265
7504
  }
7266
- function conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants) {
7267
- const condition = controlFlow.namedChildren[0];
7268
- if (!condition)
7505
+ var unstableLocalNamesCache = new WeakMap;
7506
+ function unstableLocalNames(fn, context) {
7507
+ const cached = unstableLocalNamesCache.get(fn);
7508
+ if (cached)
7509
+ return cached;
7510
+ const declared = new Set;
7511
+ const unstable = new Set;
7512
+ for (const node of context.walk(fn.node)) {
7513
+ if (node.type === "variable_declaration" && context.nearestFunction(node) === fn) {
7514
+ for (const name of declarationNames(node)) {
7515
+ if (declared.has(name))
7516
+ unstable.add(name);
7517
+ declared.add(name);
7518
+ }
7519
+ }
7520
+ if (node.type !== "assignment_statement" || node.parent?.type === "variable_declaration")
7521
+ continue;
7522
+ const variables = node.namedChildren.find((child) => child.type === "variable_list");
7523
+ for (const target of variables?.namedChildren ?? []) {
7524
+ if (target.type === "identifier")
7525
+ unstable.add(target.text);
7526
+ }
7527
+ }
7528
+ unstableLocalNamesCache.set(fn, unstable);
7529
+ return unstable;
7530
+ }
7531
+ function moduleNameIsUnshadowed(name, node, fn, context) {
7532
+ let owner = fn;
7533
+ while (owner) {
7534
+ if (owner.parameters.includes(name) || isBindingShadowedBetween(node, owner, name))
7535
+ return false;
7536
+ owner = context.nearestFunction(owner.node);
7537
+ }
7538
+ return true;
7539
+ }
7540
+ function conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants, node = controlFlow) {
7541
+ if (!["if_statement", "if_expression", "binary_expression"].includes(controlFlow.type))
7269
7542
  return false;
7270
- if (["true", "false", "nil", "number", "string"].includes(condition.type))
7271
- return true;
7272
- const root = simpleConditionRoot(controlFlow);
7273
- if (!root)
7543
+ const conditions = guardConditions(controlFlow, node);
7544
+ if (conditions.length === 0)
7274
7545
  return false;
7275
- if (moduleInvariants.has(root))
7276
- return true;
7277
- return Boolean(context.model.stableVariablesByFunction.get(nodeKey(fn.node))?.has(root));
7546
+ return conditions.every((condition) => {
7547
+ if (["true", "false", "nil", "number", "string"].includes(condition.type))
7548
+ return true;
7549
+ const root = simpleConditionRoot(condition);
7550
+ if (!root)
7551
+ return false;
7552
+ if (moduleInvariants.has(root) && moduleNameIsUnshadowed(root, condition, fn, context))
7553
+ return true;
7554
+ if (isNameShadowedBetween(condition, fn, root))
7555
+ return false;
7556
+ if (unstableLocalNames(fn, context).has(root))
7557
+ return false;
7558
+ return Boolean(context.model.stableVariablesByFunction.get(nodeKey(fn.node))?.has(root));
7559
+ });
7278
7560
  }
7279
7561
  function branchHookSequence(branch, fn, context) {
7280
7562
  const hooks = [];
@@ -7289,28 +7571,45 @@ function branchHookSequence(branch, fn, context) {
7289
7571
  const path = context.resolveCallPath(rawPath);
7290
7572
  if (!isHookPath(path))
7291
7573
  continue;
7292
- const nestedControl = findAncestorBetween(node, branch, (ancestor) => CONDITIONAL_TYPES.has(ancestor.type));
7293
- if (nestedControl)
7574
+ const nestedControl = node.id === branch.id ? null : findAncestorBetween(node, branch, (ancestor) => conditionallyContains(ancestor, node));
7575
+ if (nestedControl || conditionallyContains(branch, node))
7294
7576
  return null;
7295
7577
  hooks.push(path);
7296
7578
  }
7297
7579
  return hooks;
7298
7580
  }
7299
7581
  function ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context) {
7300
- if (controlFlow.type !== "if_statement")
7582
+ const isStatement = controlFlow.type === "if_statement";
7583
+ if (!isStatement && controlFlow.type !== "if_expression")
7301
7584
  return false;
7302
- const thenBlock = controlFlow.namedChildren.find((child) => child.type === "block");
7303
- const elseStatement = controlFlow.namedChildren.find((child) => child.type === "else_statement");
7304
- const elseBlock = elseStatement?.namedChildren.find((child) => child.type === "block");
7305
- if (!thenBlock || !elseBlock)
7585
+ const firstBranch = isStatement ? controlFlow.namedChildren.find((child) => child.type === "block") : controlFlow.namedChildren[1];
7586
+ const elseClause = controlFlow.namedChildren.find((child) => child.type === "else_statement" || child.type === "else_clause");
7587
+ const lastBranch = isStatement ? elseClause?.namedChildren.find((child) => child.type === "block") : elseClause?.namedChildren[0];
7588
+ if (!firstBranch || !lastBranch)
7306
7589
  return false;
7307
- const thenHooks = branchHookSequence(thenBlock, fn, context);
7308
- const elseHooks = branchHookSequence(elseBlock, fn, context);
7309
- if (!thenHooks || !elseHooks || thenHooks.length !== elseHooks.length)
7590
+ const branches = [firstBranch];
7591
+ for (const clause of controlFlow.namedChildren) {
7592
+ if (clause.type !== "elseif_statement" && clause.type !== "elseif_clause")
7593
+ continue;
7594
+ const condition = clause.namedChildren[0];
7595
+ if (!condition)
7596
+ return false;
7597
+ const conditionHooks = branchHookSequence(condition, fn, context);
7598
+ if (!conditionHooks || conditionHooks.length > 0)
7599
+ return false;
7600
+ const branch = isStatement ? clause.namedChildren.find((child) => child.type === "block") : clause.namedChildren[1];
7601
+ if (!branch)
7602
+ return false;
7603
+ branches.push(branch);
7604
+ }
7605
+ branches.push(lastBranch);
7606
+ const sequences = branches.map((branch) => branchHookSequence(branch, fn, context));
7607
+ const first = sequences[0];
7608
+ if (!first)
7310
7609
  return false;
7311
- if (!thenHooks.every((path, index) => path === elseHooks[index]))
7610
+ if (!sequences.every((sequence) => sequence && sequence.length === first.length && sequence.every((path, index) => path === first[index])))
7312
7611
  return false;
7313
- return thenHooks.every((path) => path.startsWith("React."));
7612
+ return first.every((path) => path.startsWith("React."));
7314
7613
  }
7315
7614
  function stateModeStability(name, owner, context) {
7316
7615
  const binding = context.model.stateBindings.find((candidate) => candidate.owner === owner && candidate.valueName === name);
@@ -7415,7 +7714,21 @@ function controlledAccessPaths(summary, parameterIndex) {
7415
7714
  paths.add("");
7416
7715
  return [...paths];
7417
7716
  }
7418
- function loopIterationIsProvablyStable(loop, imports, owner, stableShapes, moduleTables) {
7717
+ function loopIterationIsProvablyStable(loop, imports, owner, stableShapes, moduleTables, context, moduleInvariants) {
7718
+ for (const node of context.walk(loop)) {
7719
+ if (!["break_statement", "continue_statement", "return_statement"].includes(node.type))
7720
+ continue;
7721
+ if (context.nearestFunction(node) !== owner)
7722
+ continue;
7723
+ if (node.type !== "return_statement") {
7724
+ const targetLoop = findAncestorBetween(node, owner.node, (ancestor) => ["for_statement", "while_statement", "repeat_statement"].includes(ancestor.type));
7725
+ if (targetLoop?.id !== loop.id)
7726
+ continue;
7727
+ }
7728
+ const dynamicGuard = findAncestorBetween(node, loop, (ancestor) => conditionallyContains(ancestor, node) && !conditionIsProvablyInvariant(ancestor, owner, context, moduleInvariants, node));
7729
+ if (dynamicGuard)
7730
+ return false;
7731
+ }
7419
7732
  const numeric = loop.namedChildren.find((child) => child.type === "for_numeric_clause");
7420
7733
  if (numeric) {
7421
7734
  const text = numeric.text;
@@ -7488,23 +7801,46 @@ function reachableReturnsBefore(call, fn, context, cache) {
7488
7801
  returns = [...context.walk(fn.body)].filter((node) => node.type === "return_statement" && !isNestedInsideFunction(node, fn.node) && !impossibleNilGuardReturn(node, fn, parameterTypes));
7489
7802
  cache.set(key, returns);
7490
7803
  }
7491
- return returns.filter((node) => node.endIndex <= call.startIndex);
7804
+ return returns.filter((node) => {
7805
+ if (node.endIndex > call.startIndex)
7806
+ return false;
7807
+ return !findAncestorBetween(node, fn.node, (ancestor) => {
7808
+ if (ancestor.type !== "if_statement")
7809
+ return false;
7810
+ const returnBranch = branchContaining(ancestor, node);
7811
+ const callBranch = branchContaining(ancestor, call);
7812
+ return Boolean(returnBranch && callBranch && returnBranch.id !== callBranch.id);
7813
+ });
7814
+ });
7492
7815
  }
7493
7816
  function returnIsControlledByStableTopologyMode(node, fn, summary, context, moduleInvariants) {
7494
7817
  let current = node.parent;
7818
+ let sawStableGuard = false;
7495
7819
  while (current && current !== fn.node) {
7496
- if (current.type === "if_statement" || current.type === "if_expression") {
7497
- if (summary && controlledParameterIndex(current, fn, summary) !== null)
7498
- return true;
7499
- if (conditionIsProvablyInvariant(current, fn, context, moduleInvariants))
7500
- return true;
7820
+ if (["for_statement", "while_statement", "repeat_statement"].includes(current.type))
7501
7821
  return false;
7822
+ if (current.type === "if_statement" || current.type === "if_expression") {
7823
+ if (summary && controlledParameterIndex(current, fn, summary, node) !== null)
7824
+ sawStableGuard = true;
7825
+ else if (conditionIsProvablyInvariant(current, fn, context, moduleInvariants, node))
7826
+ sawStableGuard = true;
7827
+ else
7828
+ return false;
7502
7829
  }
7503
7830
  current = current.parent;
7504
7831
  }
7505
- return false;
7832
+ return sawStableGuard;
7506
7833
  }
7507
7834
  function conditionalFixPreview(kind, hookName) {
7835
+ if (kind === "short-circuit expression") {
7836
+ return {
7837
+ kind: "pattern",
7838
+ before: `local result = enabled and renderChild(${hookName}())`,
7839
+ after: `local value = ${hookName}()
7840
+ local result = enabled and renderChild(value)`,
7841
+ note: "Call the hook before the short-circuit expression, then branch on its result"
7842
+ };
7843
+ }
7508
7844
  if (hookName === "React.useEffect" && kind === "if statement") {
7509
7845
  return {
7510
7846
  kind: "pattern",
@@ -7583,6 +7919,34 @@ end`,
7583
7919
  note: "Call hooks before any reachable early return so every render executes the same hook sequence."
7584
7920
  };
7585
7921
  }
7922
+ function callbackFunctions(context) {
7923
+ const callbacks = new Set;
7924
+ const addCallback = (expression) => {
7925
+ const fn = resolveLocalFunction(context, expression);
7926
+ if (fn)
7927
+ callbacks.add(fn.node.id);
7928
+ };
7929
+ for (const call of context.findCalls()) {
7930
+ const path = reactApiPath(context, call);
7931
+ const args = context.callArguments(call);
7932
+ for (const [index, argument] of args.entries()) {
7933
+ if (index === 0 && ["React.memo", "React.forwardRef", "React.createElement"].includes(path ?? ""))
7934
+ continue;
7935
+ if (path?.startsWith("React.use") || path === "React.memo" || unwrapExpression(argument).type === "function_definition")
7936
+ addCallback(argument);
7937
+ }
7938
+ if (path !== "React.createElement" || args[0]?.type !== "string" || args[1]?.type !== "table_constructor")
7939
+ continue;
7940
+ for (const field of args[1].namedChildren) {
7941
+ if (field.type !== "field")
7942
+ continue;
7943
+ const value = field.namedChildren.at(-1);
7944
+ if (value)
7945
+ addCallback(value);
7946
+ }
7947
+ }
7948
+ return callbacks;
7949
+ }
7586
7950
  var rulesOfHooks = {
7587
7951
  id: "react-luau/rules-of-hooks",
7588
7952
  category: "Hooks",
@@ -7603,6 +7967,7 @@ var rulesOfHooks = {
7603
7967
  const modeImports = conditionalHookModeImports(context);
7604
7968
  const ignoredHookImports = nonReactHookImports(context);
7605
7969
  const currentModeSummary = currentConditionalHookMode(context);
7970
+ const callbacks = callbackFunctions(context);
7606
7971
  const reachableReturns = new Map;
7607
7972
  for (const call of context.findCalls()) {
7608
7973
  const rawPath = context.getCallPath(call);
@@ -7614,11 +7979,11 @@ var rulesOfHooks = {
7614
7979
  if (!isHookPath(path))
7615
7980
  continue;
7616
7981
  const fn = context.nearestFunction(call);
7617
- if (!fn || !fn.isComponent && !fn.isHook) {
7982
+ if (!fn || !fn.isComponent && !fn.isHook || callbacks.has(fn.node.id)) {
7618
7983
  diagnostics.push({
7619
7984
  node: callNameNode(call),
7620
- message: `Hook ${path} is called outside a React component or custom hook.`,
7621
- help: "Move the hook into a component or a custom hook whose name starts with use.",
7985
+ message: `Hook ${path} is called outside a React component or custom hook`,
7986
+ help: "Move the hook into a component or a custom hook whose name starts with use",
7622
7987
  fixPreview: {
7623
7988
  kind: "pattern",
7624
7989
  before: `local value = ${path}()
@@ -7630,7 +7995,7 @@ end`,
7630
7995
  local value = ${path}()
7631
7996
  return value
7632
7997
  end`,
7633
- note: "Hooks need a React-owned component or custom-hook call stack."
7998
+ note: "Hooks need a React-owned component or custom-hook call stack"
7634
7999
  }
7635
8000
  });
7636
8001
  continue;
@@ -7661,24 +8026,28 @@ end`,
7661
8026
  });
7662
8027
  }
7663
8028
  }
7664
- const controlFlow = findAncestorBetween(call, fn.node, (node) => CONDITIONAL_TYPES.has(node.type));
8029
+ const controlFlow = findAncestorBetween(call, fn.node, (node) => {
8030
+ if (!conditionallyContains(node, call))
8031
+ return false;
8032
+ if (currentModeSummary && controlledParameterIndex(node, fn, currentModeSummary, call) !== null)
8033
+ return false;
8034
+ if (conditionIsProvablyInvariant(node, fn, context, moduleInvariants, call))
8035
+ return false;
8036
+ if (ifBranchesHaveEquivalentBuiltInTopology(node, fn, context))
8037
+ return false;
8038
+ if (node.type === "for_statement" && loopIterationIsProvablyStable(node, staticImports, fn, stableShapes, moduleTables, context, moduleInvariants))
8039
+ return false;
8040
+ return true;
8041
+ });
7665
8042
  if (controlFlow) {
7666
- if (currentModeSummary && controlledParameterIndex(controlFlow, fn, currentModeSummary) !== null)
7667
- continue;
7668
- if (conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants))
7669
- continue;
7670
- if (ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context))
7671
- continue;
7672
- if (controlFlow.type === "for_statement" && loopIterationIsProvablyStable(controlFlow, staticImports, fn, stableShapes, moduleTables))
7673
- continue;
7674
- const kind = controlFlow.type.replaceAll("_", " ");
8043
+ const kind = controlFlow.type === "binary_expression" ? "short-circuit expression" : controlFlow.type.replaceAll("_", " ");
7675
8044
  const loopLike = controlFlow.type === "for_statement" || controlFlow.type === "while_statement" || controlFlow.type === "repeat_statement";
7676
8045
  diagnostics.push({
7677
8046
  node: callNameNode(call),
7678
- message: loopLike ? `Hook ${path} is called inside ${kind} whose size or iteration order may change between renders. If that happens, React will see a different hook sequence.` : `Hook ${path} is called inside ${kind}, so the hook may be skipped on some renders.`,
7679
- summary: loopLike ? `${path} may run a different number of times between renders.` : `${path} may be skipped on some renders.`,
7680
- explanation: loopLike ? `This ${kind} may change how many times ${path} is called.` : `This ${kind} can skip ${path} on some renders.`,
7681
- help: loopLike ? "Keep hook count independent of runtime collection size or loop iterations. A common fix is to render a child component per item and call the hook inside that child." : "Call the hook unconditionally at the top level, then branch on the returned value.",
8047
+ message: loopLike ? `Hook ${path} is called inside ${kind} whose size or iteration order may change between renders. If that happens, React will see a different hook sequence` : `Hook ${path} is called inside ${kind}, so the hook may be skipped on some renders`,
8048
+ summary: loopLike ? `${path} may run a different number of times between renders` : `${path} may be skipped on some renders`,
8049
+ explanation: loopLike ? `This ${kind} may change how many times ${path} is called` : `This ${kind} can skip ${path} on some renders`,
8050
+ help: loopLike ? "Keep hook count independent of runtime collection size or loop iterations. A common fix is to render a child component per item and call the hook inside that child" : "Call the hook unconditionally at the top level, then branch on the returned value",
7682
8051
  fixPreview: conditionalFixPreview(kind, path)
7683
8052
  });
7684
8053
  continue;
@@ -8051,7 +8420,7 @@ var unstableContextValue = {
8051
8420
  id: "react-luau/unstable-context-value",
8052
8421
  category: "Performance",
8053
8422
  severity: "warning",
8054
- description: "Avoid recreating context value tables on every provider render when identity matters.",
8423
+ description: "Avoid recreating context reference values during render when identity matters",
8055
8424
  run(context) {
8056
8425
  const diagnostics = [];
8057
8426
  for (const call of context.findCalls()) {
@@ -8066,13 +8435,13 @@ var unstableContextValue = {
8066
8435
  const props = args[1];
8067
8436
  if (!/\.Provider$/.test(element) || !props || props.type !== "table_constructor")
8068
8437
  continue;
8069
- const valueField = props.namedChildren.find((field) => field.type === "field" && fieldName(field) === "value" && fieldValue(field)?.type === "table_constructor");
8438
+ const valueField = props.namedChildren.find((field) => field.type === "field" && fieldName(field) === "value" && freshValueKind(context, fieldValue(field), component));
8070
8439
  if (!valueField)
8071
8440
  continue;
8072
8441
  diagnostics.push({
8073
8442
  node: fieldNameNode(valueField),
8074
- message: "Context Provider receives a new inline value table on every render.",
8075
- help: "Memoize the provider value from its real dependencies when consumer updates depend on object identity."
8443
+ message: "Context Provider can receive a new reference value when the component renders",
8444
+ help: "Memoize the provider value from its real dependencies when consumer updates depend on object identity"
8076
8445
  });
8077
8446
  }
8078
8447
  return diagnostics;
@@ -15554,5 +15923,5 @@ ${renderUpdateNotice(update.current, update.latest, colorized)}
15554
15923
  }
15555
15924
  main();
15556
15925
 
15557
- //# debugId=9D947B614FF21B3A64756E2164756E21
15926
+ //# debugId=3EFE794093E582F464756E2164756E21
15558
15927
  //# sourceMappingURL=cli.js.map