@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/CHANGELOG.md +7 -0
- package/dist/cli.js +511 -142
- package/dist/cli.js.map +10 -8
- package/dist/config.schema.json +3 -3
- package/dist/scan-worker.js +510 -141
- package/dist/scan-worker.js.map +10 -8
- package/package.json +1 -1
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.
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
4795
|
-
const
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
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
|
-
|
|
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
|
|
5025
|
+
description: "Warn when props created during render can defeat shallow React.memo comparisons",
|
|
4820
5026
|
guidance: {
|
|
4821
|
-
explanation: "
|
|
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 (
|
|
5040
|
+
if (!args[0] || memoizedComponentKind(context, args[0]) !== "shallow")
|
|
4838
5041
|
continue;
|
|
4839
|
-
const
|
|
4840
|
-
|
|
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) =>
|
|
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}
|
|
4852
|
-
summary: `${childName} gets ${unstable.length}
|
|
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
|
|
5059
|
+
summary: `${childName} gets a ${unstable[index]} prop created during render that can prevent memoization from skipping it`
|
|
4856
5060
|
})),
|
|
4857
|
-
help: "Fresh
|
|
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
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
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 (
|
|
7049
|
-
|
|
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
|
-
|
|
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
|
|
7257
|
-
|
|
7258
|
-
|
|
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
|
|
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
|
-
|
|
7267
|
-
|
|
7268
|
-
|
|
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
|
-
|
|
7271
|
-
|
|
7272
|
-
const root = simpleConditionRoot(controlFlow);
|
|
7273
|
-
if (!root)
|
|
7543
|
+
const conditions = guardConditions(controlFlow, node);
|
|
7544
|
+
if (conditions.length === 0)
|
|
7274
7545
|
return false;
|
|
7275
|
-
|
|
7276
|
-
|
|
7277
|
-
|
|
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) =>
|
|
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
|
-
|
|
7582
|
+
const isStatement = controlFlow.type === "if_statement";
|
|
7583
|
+
if (!isStatement && controlFlow.type !== "if_expression")
|
|
7301
7584
|
return false;
|
|
7302
|
-
const
|
|
7303
|
-
const
|
|
7304
|
-
const
|
|
7305
|
-
if (!
|
|
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
|
|
7308
|
-
const
|
|
7309
|
-
|
|
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 (!
|
|
7610
|
+
if (!sequences.every((sequence) => sequence && sequence.length === first.length && sequence.every((path, index) => path === first[index])))
|
|
7312
7611
|
return false;
|
|
7313
|
-
return
|
|
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) =>
|
|
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 (
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
7679
|
-
summary: loopLike ? `${path} may run a different number of times between renders
|
|
7680
|
-
explanation: loopLike ? `This ${kind} may change how many times ${path} is called
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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=
|
|
15926
|
+
//# debugId=3EFE794093E582F464756E2164756E21
|
|
15558
15927
|
//# sourceMappingURL=cli.js.map
|