@colrealpro/react-luau-doctor 0.20.0 → 0.20.2
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 +18 -0
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/dist/cli.js +1742 -592
- package/dist/cli.js.map +20 -16
- package/dist/config.schema.json +4 -4
- package/dist/scan-worker.js +529 -149
- package/dist/scan-worker.js.map +12 -10
- package/package.json +66 -66
package/dist/scan-worker.js
CHANGED
|
@@ -737,6 +737,12 @@ import path2 from "path";
|
|
|
737
737
|
// src/ast/local-values.ts
|
|
738
738
|
var valueCache = new WeakMap;
|
|
739
739
|
var apiCache = new WeakMap;
|
|
740
|
+
function isUnboundIdentifier(context, node) {
|
|
741
|
+
return node.type === "identifier" && visibleValue(context, node) === undefined;
|
|
742
|
+
}
|
|
743
|
+
function localValueBinding(context, node) {
|
|
744
|
+
return node.type === "identifier" ? visibleValue(context, node) : null;
|
|
745
|
+
}
|
|
740
746
|
function unwrapExpression(node) {
|
|
741
747
|
while (["parenthesized_expression", "cast_expression"].includes(node.type) && node.namedChildren[0]) {
|
|
742
748
|
node = node.namedChildren[0];
|
|
@@ -1162,7 +1168,13 @@ function isLazyRefInitialization(context, call) {
|
|
|
1162
1168
|
}
|
|
1163
1169
|
return false;
|
|
1164
1170
|
}
|
|
1165
|
-
|
|
1171
|
+
var hookInitializerArguments = new Map([
|
|
1172
|
+
["React.useRef", 0],
|
|
1173
|
+
["React.useState", 0],
|
|
1174
|
+
["React.useBinding", 0],
|
|
1175
|
+
["React.useReducer", 1]
|
|
1176
|
+
]);
|
|
1177
|
+
function isHookInitializer(context, call) {
|
|
1166
1178
|
let node = call;
|
|
1167
1179
|
while (node.parent) {
|
|
1168
1180
|
const parent = node.parent;
|
|
@@ -1170,8 +1182,11 @@ function isRefInitializer(context, call) {
|
|
|
1170
1182
|
return false;
|
|
1171
1183
|
if (parent.type === "arguments") {
|
|
1172
1184
|
const hook = parent.parent;
|
|
1173
|
-
if (hook?.type === "function_call"
|
|
1174
|
-
|
|
1185
|
+
if (hook?.type === "function_call") {
|
|
1186
|
+
const argumentIndex = hookInitializerArguments.get(reactApiPath(context, hook) ?? "");
|
|
1187
|
+
if (argumentIndex !== undefined && context.callArguments(hook)[argumentIndex]?.id === node.id)
|
|
1188
|
+
return true;
|
|
1189
|
+
}
|
|
1175
1190
|
}
|
|
1176
1191
|
node = parent;
|
|
1177
1192
|
}
|
|
@@ -1205,18 +1220,18 @@ var noBindingGetValueInRender = {
|
|
|
1205
1220
|
id: "react-luau/no-binding-getvalue-in-render",
|
|
1206
1221
|
category: "Correctness",
|
|
1207
1222
|
severity: "warning",
|
|
1208
|
-
description: "Do not read binding snapshots during render except
|
|
1223
|
+
description: "Do not read binding snapshots during render except in hook initialization arguments",
|
|
1209
1224
|
guidance: {
|
|
1210
1225
|
explanation: "getValue returns a snapshot without subscribing \u2014 later binding updates do not rerender the component or update props built from that snapshot",
|
|
1211
1226
|
help: "Pass the binding directly to the prop or use binding:map(function(value) return ... end)",
|
|
1212
|
-
caveat: "
|
|
1227
|
+
caveat: "Snapshots in the first argument to React.useRef, React.useState, or React.useBinding, or the second argument to React.useReducer, are allowed because those arguments are used only for initialization"
|
|
1213
1228
|
},
|
|
1214
1229
|
run(context) {
|
|
1215
1230
|
return context.findCalls().flatMap((call) => {
|
|
1216
1231
|
const owner = context.nearestFunction(call);
|
|
1217
1232
|
const name = call.childForFieldName("name");
|
|
1218
1233
|
const receiver = name?.namedChildren[0];
|
|
1219
|
-
if (!owner || !owner.isComponent && !owner.isHook || name?.type !== "method_index_expression" || name.namedChildren.at(-1)?.text !== "getValue" || !receiver || !isBinding(context, receiver) ||
|
|
1234
|
+
if (!owner || !owner.isComponent && !owner.isHook || name?.type !== "method_index_expression" || name.namedChildren.at(-1)?.text !== "getValue" || !receiver || !isBinding(context, receiver) || isHookInitializer(context, call))
|
|
1220
1235
|
return [];
|
|
1221
1236
|
return [
|
|
1222
1237
|
{
|
|
@@ -2980,6 +2995,242 @@ var effectNeedsCleanup = {
|
|
|
2980
2995
|
}
|
|
2981
2996
|
};
|
|
2982
2997
|
|
|
2998
|
+
// src/api-reference.ts
|
|
2999
|
+
var functions = new Map;
|
|
3000
|
+
function register(namespace, members, reference) {
|
|
3001
|
+
for (const member of members)
|
|
3002
|
+
functions.set(`${namespace}.${member}`, reference);
|
|
3003
|
+
}
|
|
3004
|
+
for (const type of [
|
|
3005
|
+
"Instance",
|
|
3006
|
+
"Random",
|
|
3007
|
+
"RaycastParams",
|
|
3008
|
+
"OverlapParams",
|
|
3009
|
+
"CatalogSearchParams",
|
|
3010
|
+
"SharedTable"
|
|
3011
|
+
]) {
|
|
3012
|
+
register(type, ["new"], {
|
|
3013
|
+
returnType: type,
|
|
3014
|
+
allocation: "new",
|
|
3015
|
+
equality: "reference"
|
|
3016
|
+
});
|
|
3017
|
+
}
|
|
3018
|
+
register("table", ["clone", "create", "pack"], {
|
|
3019
|
+
returnType: "table",
|
|
3020
|
+
allocation: "new",
|
|
3021
|
+
equality: "reference"
|
|
3022
|
+
});
|
|
3023
|
+
register("SharedTable", ["clone", "cloneAndFreeze"], {
|
|
3024
|
+
returnType: "SharedTable",
|
|
3025
|
+
allocation: "new",
|
|
3026
|
+
equality: "reference"
|
|
3027
|
+
});
|
|
3028
|
+
register("string", ["split"], {
|
|
3029
|
+
returnType: "table",
|
|
3030
|
+
allocation: "new",
|
|
3031
|
+
equality: "reference"
|
|
3032
|
+
});
|
|
3033
|
+
register("buffer", ["create", "fromstring"], {
|
|
3034
|
+
returnType: "buffer",
|
|
3035
|
+
allocation: "new",
|
|
3036
|
+
equality: "reference"
|
|
3037
|
+
});
|
|
3038
|
+
register("coroutine", ["create"], {
|
|
3039
|
+
returnType: "thread",
|
|
3040
|
+
allocation: "new",
|
|
3041
|
+
equality: "reference"
|
|
3042
|
+
});
|
|
3043
|
+
register("coroutine", ["wrap"], {
|
|
3044
|
+
returnType: "function",
|
|
3045
|
+
allocation: "new",
|
|
3046
|
+
equality: "reference"
|
|
3047
|
+
});
|
|
3048
|
+
register("table", ["freeze"], {
|
|
3049
|
+
returnType: "table",
|
|
3050
|
+
allocation: "argument",
|
|
3051
|
+
equality: "reference",
|
|
3052
|
+
argumentIndex: 0
|
|
3053
|
+
});
|
|
3054
|
+
for (const [type, members] of Object.entries({
|
|
3055
|
+
Color3: ["new", "fromRGB", "fromHSV", "fromHex"],
|
|
3056
|
+
ColorSequence: ["new"],
|
|
3057
|
+
ColorSequenceKeypoint: ["new"],
|
|
3058
|
+
NumberSequence: ["new"],
|
|
3059
|
+
NumberSequenceKeypoint: ["new"],
|
|
3060
|
+
NumberRange: ["new"],
|
|
3061
|
+
UDim: ["new"],
|
|
3062
|
+
UDim2: ["new", "fromScale", "fromOffset"],
|
|
3063
|
+
Vector2: ["new"],
|
|
3064
|
+
Vector3: ["new", "fromNormalId", "fromAxis"],
|
|
3065
|
+
CFrame: [
|
|
3066
|
+
"new",
|
|
3067
|
+
"Angles",
|
|
3068
|
+
"fromEulerAnglesXYZ",
|
|
3069
|
+
"fromEulerAnglesYXZ",
|
|
3070
|
+
"fromOrientation",
|
|
3071
|
+
"fromAxisAngle",
|
|
3072
|
+
"fromMatrix",
|
|
3073
|
+
"lookAt",
|
|
3074
|
+
"lookAlong"
|
|
3075
|
+
],
|
|
3076
|
+
Rect: ["new"],
|
|
3077
|
+
BrickColor: ["new", "palette"]
|
|
3078
|
+
})) {
|
|
3079
|
+
register(type, members, {
|
|
3080
|
+
returnType: type,
|
|
3081
|
+
allocation: "new",
|
|
3082
|
+
equality: "value"
|
|
3083
|
+
});
|
|
3084
|
+
}
|
|
3085
|
+
var instanceMethods = new Map([
|
|
3086
|
+
...["GetChildren", "GetDescendants", "GetAttributes", "GetTags"].map((member) => [
|
|
3087
|
+
member,
|
|
3088
|
+
{ returnType: "table", allocation: "new", equality: "reference" }
|
|
3089
|
+
]),
|
|
3090
|
+
[
|
|
3091
|
+
"Clone",
|
|
3092
|
+
{ returnType: "Instance", allocation: "new", equality: "reference" }
|
|
3093
|
+
]
|
|
3094
|
+
]);
|
|
3095
|
+
function apiFunctionReference(path) {
|
|
3096
|
+
return functions.get(path) ?? null;
|
|
3097
|
+
}
|
|
3098
|
+
function globalPath(context, expression, seen = new Set) {
|
|
3099
|
+
const node = unwrapExpression(expression);
|
|
3100
|
+
if (seen.has(node.id))
|
|
3101
|
+
return null;
|
|
3102
|
+
seen.add(node.id);
|
|
3103
|
+
if (node.type === "identifier") {
|
|
3104
|
+
if (isUnboundIdentifier(context, node))
|
|
3105
|
+
return node.text;
|
|
3106
|
+
const binding = localValueBinding(context, node);
|
|
3107
|
+
return binding?.returnIndex === 0 ? globalPath(context, binding.value, seen) : null;
|
|
3108
|
+
}
|
|
3109
|
+
if (node.type !== "dot_index_expression")
|
|
3110
|
+
return null;
|
|
3111
|
+
const namespace = node.childForFieldName("table");
|
|
3112
|
+
const member = node.childForFieldName("field");
|
|
3113
|
+
const path = namespace && globalPath(context, namespace, seen);
|
|
3114
|
+
return path && member ? `${path}.${member.text}` : null;
|
|
3115
|
+
}
|
|
3116
|
+
function isInstanceReceiver(context, expression, seen = new Set) {
|
|
3117
|
+
const node = unwrapExpression(expression);
|
|
3118
|
+
if (seen.has(node.id))
|
|
3119
|
+
return false;
|
|
3120
|
+
seen.add(node.id);
|
|
3121
|
+
if (node.type === "identifier") {
|
|
3122
|
+
if (isUnboundIdentifier(context, node))
|
|
3123
|
+
return ["game", "workspace", "script"].includes(node.text);
|
|
3124
|
+
const binding = localValueBinding(context, node);
|
|
3125
|
+
return binding?.returnIndex === 0 && isInstanceReceiver(context, binding.value, seen);
|
|
3126
|
+
}
|
|
3127
|
+
if (node.type === "dot_index_expression") {
|
|
3128
|
+
const receiver = node.childForFieldName("table");
|
|
3129
|
+
return Boolean(receiver && isInstanceReceiver(context, receiver, seen));
|
|
3130
|
+
}
|
|
3131
|
+
if (node.type !== "function_call")
|
|
3132
|
+
return false;
|
|
3133
|
+
const name = node.childForFieldName("name");
|
|
3134
|
+
if (!name)
|
|
3135
|
+
return false;
|
|
3136
|
+
if (globalPath(context, name) === "Instance.new")
|
|
3137
|
+
return true;
|
|
3138
|
+
if (name.type !== "method_index_expression")
|
|
3139
|
+
return false;
|
|
3140
|
+
const receiver = name.childForFieldName("table");
|
|
3141
|
+
const member = name.childForFieldName("method")?.text;
|
|
3142
|
+
return Boolean(receiver && member && [
|
|
3143
|
+
"GetService",
|
|
3144
|
+
"WaitForChild",
|
|
3145
|
+
"FindFirstChild",
|
|
3146
|
+
"FindFirstAncestor",
|
|
3147
|
+
"Clone"
|
|
3148
|
+
].includes(member) && isInstanceReceiver(context, receiver, seen));
|
|
3149
|
+
}
|
|
3150
|
+
function apiReferenceForCall(context, call) {
|
|
3151
|
+
const name = call.childForFieldName("name");
|
|
3152
|
+
if (!name)
|
|
3153
|
+
return null;
|
|
3154
|
+
const path = globalPath(context, name);
|
|
3155
|
+
if (path)
|
|
3156
|
+
return apiFunctionReference(path);
|
|
3157
|
+
if (name.type !== "method_index_expression")
|
|
3158
|
+
return null;
|
|
3159
|
+
const receiver = name.childForFieldName("table");
|
|
3160
|
+
const member = name.childForFieldName("method")?.text;
|
|
3161
|
+
return receiver && member && isInstanceReceiver(context, receiver) ? instanceMethods.get(member) ?? null : null;
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
// src/ast/value-identity.ts
|
|
3165
|
+
function freshValueKind(context, expression, lifetime, seen = new Set, returnIndex = 0, lifetimes = new Set([lifetime])) {
|
|
3166
|
+
if (!expression)
|
|
3167
|
+
return null;
|
|
3168
|
+
const node = unwrapExpression(expression);
|
|
3169
|
+
if (seen.has(node.id) || seen.size >= 64)
|
|
3170
|
+
return null;
|
|
3171
|
+
const visited = new Set(seen).add(node.id);
|
|
3172
|
+
const analyze = (value, owner = lifetime, index = 0) => freshValueKind(context, value, owner, visited, index, new Set(lifetimes).add(owner));
|
|
3173
|
+
if (node.type === "identifier") {
|
|
3174
|
+
const binding = resolveLocalValue(context, node);
|
|
3175
|
+
const owner = binding && context.nearestFunction(binding.value);
|
|
3176
|
+
if (!binding || !owner || !lifetimes.has(owner))
|
|
3177
|
+
return null;
|
|
3178
|
+
return analyze(binding.value, lifetime, binding.returnIndex);
|
|
3179
|
+
}
|
|
3180
|
+
if (returnIndex === 0) {
|
|
3181
|
+
if (node.type === "table_constructor")
|
|
3182
|
+
return "table";
|
|
3183
|
+
if (node.type === "function_definition" || node.type === "function_declaration")
|
|
3184
|
+
return "function";
|
|
3185
|
+
if (node.type === "binary_expression") {
|
|
3186
|
+
const operator = node.children.find((child) => ["and", "or"].includes(child.type))?.type;
|
|
3187
|
+
if (operator === "and")
|
|
3188
|
+
return analyze(node.childForFieldName("right"));
|
|
3189
|
+
if (operator === "or")
|
|
3190
|
+
return analyze(node.childForFieldName("left")) ?? analyze(node.childForFieldName("right"));
|
|
3191
|
+
}
|
|
3192
|
+
if (node.type === "if_expression") {
|
|
3193
|
+
for (const branch of [
|
|
3194
|
+
node,
|
|
3195
|
+
...node.namedChildren.filter((child) => ["elseif_clause", "else_clause"].includes(child.type))
|
|
3196
|
+
]) {
|
|
3197
|
+
const kind = analyze(branch.childForFieldName("consequence"));
|
|
3198
|
+
if (kind)
|
|
3199
|
+
return kind;
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
if (node.type !== "function_call")
|
|
3204
|
+
return null;
|
|
3205
|
+
const api = apiReferenceForCall(context, node);
|
|
3206
|
+
if (api && returnIndex === 0) {
|
|
3207
|
+
if (api.allocation === "argument")
|
|
3208
|
+
return analyze(context.callArguments(node)[api.argumentIndex ?? 0] ?? null);
|
|
3209
|
+
if (api.equality !== "reference")
|
|
3210
|
+
return null;
|
|
3211
|
+
return api.returnType === "table" || api.returnType === "function" ? api.returnType : "object";
|
|
3212
|
+
}
|
|
3213
|
+
const name = node.childForFieldName("name");
|
|
3214
|
+
const factory = name && resolveLocalFunction(context, name);
|
|
3215
|
+
if (!factory?.body)
|
|
3216
|
+
return null;
|
|
3217
|
+
for (const statement of context.walk(factory.body)) {
|
|
3218
|
+
if (statement.type !== "return_statement" || context.nearestFunction(statement) !== factory)
|
|
3219
|
+
continue;
|
|
3220
|
+
const values = statement.namedChildren.find((child) => child.type === "expression_list")?.namedChildren ?? [];
|
|
3221
|
+
const value = values[returnIndex] ?? values.at(-1);
|
|
3222
|
+
if (!value)
|
|
3223
|
+
continue;
|
|
3224
|
+
const index = Math.max(0, returnIndex - values.length + 1);
|
|
3225
|
+
if (index > 0 && value.type !== "function_call")
|
|
3226
|
+
continue;
|
|
3227
|
+
const kind = analyze(value, factory, index);
|
|
3228
|
+
if (kind)
|
|
3229
|
+
return kind;
|
|
3230
|
+
}
|
|
3231
|
+
return null;
|
|
3232
|
+
}
|
|
3233
|
+
|
|
2983
3234
|
// src/rules/effect-state-rules.ts
|
|
2984
3235
|
var HOOKS_WITH_DEPS = new Set([
|
|
2985
3236
|
"React.useEffect",
|
|
@@ -3043,23 +3294,6 @@ function hasExternalSubscription(node, context) {
|
|
|
3043
3294
|
}
|
|
3044
3295
|
return false;
|
|
3045
3296
|
}
|
|
3046
|
-
function directLocalInitializer(owner, name) {
|
|
3047
|
-
if (!owner.body)
|
|
3048
|
-
return null;
|
|
3049
|
-
for (const statement of owner.body.namedChildren) {
|
|
3050
|
-
if (statement.type !== "variable_declaration")
|
|
3051
|
-
continue;
|
|
3052
|
-
const assignment = statement.namedChildren.find((child) => child.type === "assignment_statement");
|
|
3053
|
-
const variableList = assignment?.namedChildren.find((child) => child.type === "variable_list");
|
|
3054
|
-
const expressionList = assignment?.namedChildren.find((child) => child.type === "expression_list");
|
|
3055
|
-
const names = variableList?.namedChildren.filter((child) => child.type === "identifier") ?? [];
|
|
3056
|
-
const expressions = expressionList?.namedChildren ?? [];
|
|
3057
|
-
const index = names.findIndex((child) => child.text === name);
|
|
3058
|
-
if (index >= 0)
|
|
3059
|
-
return expressions[index] ?? expressions[0] ?? null;
|
|
3060
|
-
}
|
|
3061
|
-
return null;
|
|
3062
|
-
}
|
|
3063
3297
|
function setterCalledOutsideCallback(context, owner, callback, setterName) {
|
|
3064
3298
|
if (!owner.body)
|
|
3065
3299
|
return false;
|
|
@@ -3099,20 +3333,6 @@ function guaranteedRepeatedStateChange(argument, stateName, context) {
|
|
|
3099
3333
|
}
|
|
3100
3334
|
return false;
|
|
3101
3335
|
}
|
|
3102
|
-
function freshDependencyKind(node, owner) {
|
|
3103
|
-
if (node.type === "table_constructor")
|
|
3104
|
-
return "table";
|
|
3105
|
-
if (node.type === "function_definition")
|
|
3106
|
-
return "function";
|
|
3107
|
-
if (node.type === "identifier") {
|
|
3108
|
-
const initializer = directLocalInitializer(owner, node.text);
|
|
3109
|
-
if (initializer?.type === "table_constructor")
|
|
3110
|
-
return "table";
|
|
3111
|
-
if (initializer?.type === "function_definition")
|
|
3112
|
-
return "function";
|
|
3113
|
-
}
|
|
3114
|
-
return null;
|
|
3115
|
-
}
|
|
3116
3336
|
var noDerivedStateEffect = {
|
|
3117
3337
|
id: "react-luau/no-derived-state-effect",
|
|
3118
3338
|
category: "Effects",
|
|
@@ -3224,7 +3444,7 @@ var noEffectWithFreshDeps = {
|
|
|
3224
3444
|
id: "react-luau/no-effect-with-fresh-deps",
|
|
3225
3445
|
category: "Hooks",
|
|
3226
3446
|
severity: "error",
|
|
3227
|
-
description: "Dependency tables should not contain
|
|
3447
|
+
description: "Dependency tables should not contain reference values recreated during render",
|
|
3228
3448
|
run(context) {
|
|
3229
3449
|
const diagnostics = [];
|
|
3230
3450
|
for (const call of context.findCalls()) {
|
|
@@ -3236,14 +3456,14 @@ var noEffectWithFreshDeps = {
|
|
|
3236
3456
|
if (!owner || !parts)
|
|
3237
3457
|
continue;
|
|
3238
3458
|
for (const dependency of dependencyExpressions(parts.deps)) {
|
|
3239
|
-
const kind =
|
|
3459
|
+
const kind = freshValueKind(context, dependency, owner);
|
|
3240
3460
|
if (!kind)
|
|
3241
3461
|
continue;
|
|
3242
3462
|
const label = dependency.type === "identifier" ? ` ${dependency.text}` : "";
|
|
3243
3463
|
diagnostics.push({
|
|
3244
3464
|
node: dependency,
|
|
3245
|
-
message: `${path} depends on${label || " an inline value"} that
|
|
3246
|
-
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
|
|
3465
|
+
message: `${path} depends on${label || " an inline value"} that can be a new ${kind} when the component renders`,
|
|
3466
|
+
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"
|
|
3247
3467
|
});
|
|
3248
3468
|
}
|
|
3249
3469
|
}
|
|
@@ -5350,40 +5570,32 @@ function componentRenderCost(context, owner) {
|
|
|
5350
5570
|
parts.push(`${discovery} Instance discovery call${discovery === 1 ? "" : "s"}`);
|
|
5351
5571
|
return parts;
|
|
5352
5572
|
}
|
|
5353
|
-
function
|
|
5354
|
-
const
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
continue;
|
|
5361
|
-
const localName = match[1];
|
|
5362
|
-
const memoKind = resolveModuleReference(normalizeRequireTarget(match[2]), context.project.memoizedModules);
|
|
5363
|
-
if (memoKind)
|
|
5364
|
-
result.set(localName, memoKind);
|
|
5573
|
+
function memoizedComponentKind(context, expression) {
|
|
5574
|
+
const binding = resolveLocalValue(context, expression);
|
|
5575
|
+
if (!binding || binding.returnIndex !== 0 || binding.value.type !== "function_call")
|
|
5576
|
+
return null;
|
|
5577
|
+
const call = binding.value;
|
|
5578
|
+
if (reactApiPath(context, call) === "React.memo") {
|
|
5579
|
+
return context.callArguments(call).length > 1 ? "custom" : "shallow";
|
|
5365
5580
|
}
|
|
5366
|
-
|
|
5581
|
+
const target = context.callArguments(call)[0];
|
|
5582
|
+
if (context.getCallPath(call) !== "require" || !target)
|
|
5583
|
+
return null;
|
|
5584
|
+
return resolveModuleReference(normalizeRequireTarget(target.text), context.project.memoizedModules) ?? null;
|
|
5367
5585
|
}
|
|
5368
5586
|
function fieldLabel(field) {
|
|
5369
5587
|
return fieldName(field) ?? field.childForFieldName("name")?.text ?? "prop";
|
|
5370
5588
|
}
|
|
5371
|
-
function isInlineFreshValue(node) {
|
|
5372
|
-
return node?.type === "table_constructor" || node?.type === "function_definition";
|
|
5373
|
-
}
|
|
5374
5589
|
var rerenderUnstableMemoProps = {
|
|
5375
5590
|
id: "react-luau/rerender-unstable-memo-props",
|
|
5376
5591
|
category: "Performance",
|
|
5377
5592
|
severity: "warning",
|
|
5378
|
-
description: "Warn when
|
|
5593
|
+
description: "Warn when props created during render can defeat shallow React.memo comparisons",
|
|
5379
5594
|
guidance: {
|
|
5380
|
-
explanation: "
|
|
5381
|
-
help: "Stabilize props that change each render if this child is expensive, move construction into the child, or remove ineffective memoization
|
|
5595
|
+
explanation: "New tables, functions, and reference objects compare unequal even when their contents match",
|
|
5596
|
+
help: "Stabilize props that change each render if this child is expensive, move construction into the child, or remove ineffective memoization"
|
|
5382
5597
|
},
|
|
5383
5598
|
run(context) {
|
|
5384
|
-
const imports = memoizedImports(context);
|
|
5385
|
-
if (imports.size === 0)
|
|
5386
|
-
return [];
|
|
5387
5599
|
const diagnostics = [];
|
|
5388
5600
|
for (const call of context.findCalls()) {
|
|
5389
5601
|
if (context.resolveCallPath(context.getCallPath(call) ?? "") !== "React.createElement")
|
|
@@ -5393,12 +5605,13 @@ var rerenderUnstableMemoProps = {
|
|
|
5393
5605
|
continue;
|
|
5394
5606
|
const args = context.callArguments(call);
|
|
5395
5607
|
const childName = args[0]?.text.trim() ?? "";
|
|
5396
|
-
if (
|
|
5608
|
+
if (!args[0] || memoizedComponentKind(context, args[0]) !== "shallow")
|
|
5397
5609
|
continue;
|
|
5398
|
-
const
|
|
5399
|
-
|
|
5610
|
+
const propsBinding = args[1] && resolveLocalValue(context, args[1]);
|
|
5611
|
+
const props = propsBinding?.returnIndex === 0 ? propsBinding.value : null;
|
|
5612
|
+
if (!props || props.type !== "table_constructor" || context.nearestFunction(props) !== component)
|
|
5400
5613
|
continue;
|
|
5401
|
-
const unstableFields = props.namedChildren.filter((node) => node.type === "field").filter((field) =>
|
|
5614
|
+
const unstableFields = props.namedChildren.filter((node) => node.type === "field").filter((field) => freshValueKind(context, fieldValue(field), component));
|
|
5402
5615
|
if (unstableFields.length === 0)
|
|
5403
5616
|
continue;
|
|
5404
5617
|
const unstable = unstableFields.map(fieldLabel);
|
|
@@ -5407,13 +5620,13 @@ var rerenderUnstableMemoProps = {
|
|
|
5407
5620
|
node: highlights[0],
|
|
5408
5621
|
highlights,
|
|
5409
5622
|
severity: "warning",
|
|
5410
|
-
message: `Memoized ${childName}
|
|
5411
|
-
summary: `${childName} gets ${unstable.length}
|
|
5623
|
+
message: `Memoized ${childName} can receive fresh ${unstable.join(", ")} prop${unstable.length === 1 ? "" : "s"} when its parent renders`,
|
|
5624
|
+
summary: `${childName} gets ${unstable.length} ${unstable.length === 1 ? "prop" : "props"} created during render that can prevent memoization from skipping it`,
|
|
5412
5625
|
editorRanges: highlights.map((node, index) => ({
|
|
5413
5626
|
node,
|
|
5414
|
-
summary: `${childName} gets a
|
|
5627
|
+
summary: `${childName} gets a ${unstable[index]} prop created during render that can prevent memoization from skipping it`
|
|
5415
5628
|
})),
|
|
5416
|
-
help: "Fresh
|
|
5629
|
+
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"
|
|
5417
5630
|
});
|
|
5418
5631
|
}
|
|
5419
5632
|
return diagnostics;
|
|
@@ -7587,30 +7800,32 @@ function currentConditionalHookMode(context) {
|
|
|
7587
7800
|
}
|
|
7588
7801
|
return null;
|
|
7589
7802
|
}
|
|
7590
|
-
function controlledParameterIndex(controlFlow, fn, summary) {
|
|
7803
|
+
function controlledParameterIndex(controlFlow, fn, summary, node) {
|
|
7591
7804
|
if (controlFlow.type !== "if_statement")
|
|
7592
7805
|
return null;
|
|
7593
|
-
const
|
|
7594
|
-
|
|
7595
|
-
|
|
7596
|
-
|
|
7597
|
-
new RegExp(`^if\\s+${escaped}\\s*$`),
|
|
7598
|
-
new RegExp(`^if\\s+not\\s+${escaped}\\s*$`),
|
|
7599
|
-
new RegExp(`^if\\s+${escaped}\\s*(?:==|~=)\\s*(?:true|false|nil)\\s*$`),
|
|
7600
|
-
new RegExp(`^if\\s+(?:true|false|nil)\\s*(?:==|~=)\\s*${escaped}\\s*$`)
|
|
7601
|
-
];
|
|
7602
|
-
if (patterns.some((pattern) => pattern.test(header)))
|
|
7603
|
-
return index;
|
|
7604
|
-
}
|
|
7806
|
+
const conditions = guardConditions(controlFlow, node);
|
|
7807
|
+
if (conditions.length === 0)
|
|
7808
|
+
return null;
|
|
7809
|
+
const variables = { ...summary.conditionVariables };
|
|
7605
7810
|
for (const index of summary.parameterIndexes) {
|
|
7606
7811
|
const name = fn.parameters[index];
|
|
7607
|
-
if (
|
|
7608
|
-
|
|
7609
|
-
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7610
|
-
if (new RegExp(`^if\\s+(?:not\\s+)?${escaped}\\s*$`).test(header))
|
|
7611
|
-
return index;
|
|
7812
|
+
if (name)
|
|
7813
|
+
variables[name] ??= index;
|
|
7612
7814
|
}
|
|
7613
|
-
|
|
7815
|
+
const indexes = conditions.map((condition) => {
|
|
7816
|
+
for (const [name, index] of Object.entries(variables)) {
|
|
7817
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7818
|
+
const patterns = [
|
|
7819
|
+
new RegExp(`^(?:not\\s+)?${escaped}\\s*$`),
|
|
7820
|
+
new RegExp(`^${escaped}\\s*(?:==|~=)\\s*(?:true|false|nil)\\s*$`),
|
|
7821
|
+
new RegExp(`^(?:true|false|nil)\\s*(?:==|~=)\\s*${escaped}\\s*$`)
|
|
7822
|
+
];
|
|
7823
|
+
if (patterns.some((pattern) => pattern.test(condition.text.trim())))
|
|
7824
|
+
return index;
|
|
7825
|
+
}
|
|
7826
|
+
return null;
|
|
7827
|
+
});
|
|
7828
|
+
return indexes.every((index) => index !== null) ? indexes[0] : null;
|
|
7614
7829
|
}
|
|
7615
7830
|
function dynamicHookModeFixPreview(hookName, argumentText) {
|
|
7616
7831
|
return {
|
|
@@ -7812,28 +8027,104 @@ function staticModuleTables(context) {
|
|
|
7812
8027
|
}
|
|
7813
8028
|
return candidates;
|
|
7814
8029
|
}
|
|
7815
|
-
function
|
|
7816
|
-
|
|
7817
|
-
|
|
8030
|
+
function containsNode(container, node) {
|
|
8031
|
+
return node.startIndex >= container.startIndex && node.endIndex <= container.endIndex;
|
|
8032
|
+
}
|
|
8033
|
+
function branchContaining(controlFlow, node) {
|
|
8034
|
+
return controlFlow.namedChildren.find((child) => containsNode(child, node)) ?? null;
|
|
8035
|
+
}
|
|
8036
|
+
function guardConditions(controlFlow, node) {
|
|
7818
8037
|
const condition = controlFlow.namedChildren[0];
|
|
7819
8038
|
if (!condition)
|
|
7820
|
-
return
|
|
8039
|
+
return [];
|
|
8040
|
+
if (controlFlow.type !== "if_statement" && controlFlow.type !== "if_expression")
|
|
8041
|
+
return [condition];
|
|
8042
|
+
if (containsNode(condition, node))
|
|
8043
|
+
return [];
|
|
8044
|
+
const conditions = [condition];
|
|
8045
|
+
for (const clause of controlFlow.namedChildren.slice(1)) {
|
|
8046
|
+
if (clause.startIndex > node.startIndex)
|
|
8047
|
+
break;
|
|
8048
|
+
if (clause.type !== "elseif_statement" && clause.type !== "elseif_clause")
|
|
8049
|
+
continue;
|
|
8050
|
+
const clauseCondition = clause.namedChildren[0];
|
|
8051
|
+
if (clauseCondition && !containsNode(clauseCondition, node))
|
|
8052
|
+
conditions.push(clauseCondition);
|
|
8053
|
+
}
|
|
8054
|
+
return conditions;
|
|
8055
|
+
}
|
|
8056
|
+
function conditionallyContains(controlFlow, node) {
|
|
8057
|
+
if (controlFlow.type === "if_statement" || controlFlow.type === "if_expression")
|
|
8058
|
+
return guardConditions(controlFlow, node).length > 0;
|
|
8059
|
+
if (controlFlow.type === "for_statement")
|
|
8060
|
+
return controlFlow.namedChildren.some((child) => child.type === "block" && containsNode(child, node));
|
|
8061
|
+
if (CONDITIONAL_TYPES.has(controlFlow.type))
|
|
8062
|
+
return true;
|
|
8063
|
+
if (controlFlow.type !== "binary_expression" || !controlFlow.children.some((child) => child.type === "and" || child.type === "or"))
|
|
8064
|
+
return false;
|
|
8065
|
+
const right = controlFlow.childForFieldName("right") ?? controlFlow.namedChildren[1];
|
|
8066
|
+
return Boolean(right && containsNode(right, node));
|
|
8067
|
+
}
|
|
8068
|
+
function simpleConditionRoot(condition) {
|
|
7821
8069
|
const text = condition.text.trim();
|
|
7822
8070
|
const match = text.match(/^(?:not\s+)?([A-Za-z_][A-Za-z0-9_]*)(?:\s*(?:==|~=)\s*(?:true|false|nil))?$/);
|
|
7823
8071
|
return match?.[1] ?? null;
|
|
7824
8072
|
}
|
|
7825
|
-
|
|
7826
|
-
|
|
7827
|
-
|
|
8073
|
+
var unstableLocalNamesCache = new WeakMap;
|
|
8074
|
+
function unstableLocalNames(fn, context) {
|
|
8075
|
+
const cached = unstableLocalNamesCache.get(fn);
|
|
8076
|
+
if (cached)
|
|
8077
|
+
return cached;
|
|
8078
|
+
const declared = new Set;
|
|
8079
|
+
const unstable = new Set;
|
|
8080
|
+
for (const node of context.walk(fn.node)) {
|
|
8081
|
+
if (node.type === "variable_declaration" && context.nearestFunction(node) === fn) {
|
|
8082
|
+
for (const name of declarationNames2(node)) {
|
|
8083
|
+
if (declared.has(name))
|
|
8084
|
+
unstable.add(name);
|
|
8085
|
+
declared.add(name);
|
|
8086
|
+
}
|
|
8087
|
+
}
|
|
8088
|
+
if (node.type !== "assignment_statement" || node.parent?.type === "variable_declaration")
|
|
8089
|
+
continue;
|
|
8090
|
+
const variables = node.namedChildren.find((child) => child.type === "variable_list");
|
|
8091
|
+
for (const target of variables?.namedChildren ?? []) {
|
|
8092
|
+
if (target.type === "identifier")
|
|
8093
|
+
unstable.add(target.text);
|
|
8094
|
+
}
|
|
8095
|
+
}
|
|
8096
|
+
unstableLocalNamesCache.set(fn, unstable);
|
|
8097
|
+
return unstable;
|
|
8098
|
+
}
|
|
8099
|
+
function moduleNameIsUnshadowed(name, node, fn, context) {
|
|
8100
|
+
let owner = fn;
|
|
8101
|
+
while (owner) {
|
|
8102
|
+
if (owner.parameters.includes(name) || isBindingShadowedBetween(node, owner, name))
|
|
8103
|
+
return false;
|
|
8104
|
+
owner = context.nearestFunction(owner.node);
|
|
8105
|
+
}
|
|
8106
|
+
return true;
|
|
8107
|
+
}
|
|
8108
|
+
function conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants, node = controlFlow) {
|
|
8109
|
+
if (!["if_statement", "if_expression", "binary_expression"].includes(controlFlow.type))
|
|
7828
8110
|
return false;
|
|
7829
|
-
|
|
7830
|
-
|
|
7831
|
-
const root = simpleConditionRoot(controlFlow);
|
|
7832
|
-
if (!root)
|
|
8111
|
+
const conditions = guardConditions(controlFlow, node);
|
|
8112
|
+
if (conditions.length === 0)
|
|
7833
8113
|
return false;
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
|
|
8114
|
+
return conditions.every((condition) => {
|
|
8115
|
+
if (["true", "false", "nil", "number", "string"].includes(condition.type))
|
|
8116
|
+
return true;
|
|
8117
|
+
const root = simpleConditionRoot(condition);
|
|
8118
|
+
if (!root)
|
|
8119
|
+
return false;
|
|
8120
|
+
if (moduleInvariants.has(root) && moduleNameIsUnshadowed(root, condition, fn, context))
|
|
8121
|
+
return true;
|
|
8122
|
+
if (isNameShadowedBetween(condition, fn, root))
|
|
8123
|
+
return false;
|
|
8124
|
+
if (unstableLocalNames(fn, context).has(root))
|
|
8125
|
+
return false;
|
|
8126
|
+
return Boolean(context.model.stableVariablesByFunction.get(nodeKey(fn.node))?.has(root));
|
|
8127
|
+
});
|
|
7837
8128
|
}
|
|
7838
8129
|
function branchHookSequence(branch, fn, context) {
|
|
7839
8130
|
const hooks = [];
|
|
@@ -7848,28 +8139,45 @@ function branchHookSequence(branch, fn, context) {
|
|
|
7848
8139
|
const path = context.resolveCallPath(rawPath);
|
|
7849
8140
|
if (!isHookPath(path))
|
|
7850
8141
|
continue;
|
|
7851
|
-
const nestedControl = findAncestorBetween(node, branch, (ancestor) =>
|
|
7852
|
-
if (nestedControl)
|
|
8142
|
+
const nestedControl = node.id === branch.id ? null : findAncestorBetween(node, branch, (ancestor) => conditionallyContains(ancestor, node));
|
|
8143
|
+
if (nestedControl || conditionallyContains(branch, node))
|
|
7853
8144
|
return null;
|
|
7854
8145
|
hooks.push(path);
|
|
7855
8146
|
}
|
|
7856
8147
|
return hooks;
|
|
7857
8148
|
}
|
|
7858
8149
|
function ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context) {
|
|
7859
|
-
|
|
8150
|
+
const isStatement = controlFlow.type === "if_statement";
|
|
8151
|
+
if (!isStatement && controlFlow.type !== "if_expression")
|
|
7860
8152
|
return false;
|
|
7861
|
-
const
|
|
7862
|
-
const
|
|
7863
|
-
const
|
|
7864
|
-
if (!
|
|
8153
|
+
const firstBranch = isStatement ? controlFlow.namedChildren.find((child) => child.type === "block") : controlFlow.namedChildren[1];
|
|
8154
|
+
const elseClause = controlFlow.namedChildren.find((child) => child.type === "else_statement" || child.type === "else_clause");
|
|
8155
|
+
const lastBranch = isStatement ? elseClause?.namedChildren.find((child) => child.type === "block") : elseClause?.namedChildren[0];
|
|
8156
|
+
if (!firstBranch || !lastBranch)
|
|
7865
8157
|
return false;
|
|
7866
|
-
const
|
|
7867
|
-
const
|
|
7868
|
-
|
|
8158
|
+
const branches = [firstBranch];
|
|
8159
|
+
for (const clause of controlFlow.namedChildren) {
|
|
8160
|
+
if (clause.type !== "elseif_statement" && clause.type !== "elseif_clause")
|
|
8161
|
+
continue;
|
|
8162
|
+
const condition = clause.namedChildren[0];
|
|
8163
|
+
if (!condition)
|
|
8164
|
+
return false;
|
|
8165
|
+
const conditionHooks = branchHookSequence(condition, fn, context);
|
|
8166
|
+
if (!conditionHooks || conditionHooks.length > 0)
|
|
8167
|
+
return false;
|
|
8168
|
+
const branch = isStatement ? clause.namedChildren.find((child) => child.type === "block") : clause.namedChildren[1];
|
|
8169
|
+
if (!branch)
|
|
8170
|
+
return false;
|
|
8171
|
+
branches.push(branch);
|
|
8172
|
+
}
|
|
8173
|
+
branches.push(lastBranch);
|
|
8174
|
+
const sequences = branches.map((branch) => branchHookSequence(branch, fn, context));
|
|
8175
|
+
const first = sequences[0];
|
|
8176
|
+
if (!first)
|
|
7869
8177
|
return false;
|
|
7870
|
-
if (!
|
|
8178
|
+
if (!sequences.every((sequence) => sequence && sequence.length === first.length && sequence.every((path, index) => path === first[index])))
|
|
7871
8179
|
return false;
|
|
7872
|
-
return
|
|
8180
|
+
return first.every((path) => path.startsWith("React."));
|
|
7873
8181
|
}
|
|
7874
8182
|
function stateModeStability(name, owner, context) {
|
|
7875
8183
|
const binding = context.model.stateBindings.find((candidate) => candidate.owner === owner && candidate.valueName === name);
|
|
@@ -7974,7 +8282,21 @@ function controlledAccessPaths(summary, parameterIndex) {
|
|
|
7974
8282
|
paths.add("");
|
|
7975
8283
|
return [...paths];
|
|
7976
8284
|
}
|
|
7977
|
-
function loopIterationIsProvablyStable(loop, imports, owner, stableShapes, moduleTables) {
|
|
8285
|
+
function loopIterationIsProvablyStable(loop, imports, owner, stableShapes, moduleTables, context, moduleInvariants) {
|
|
8286
|
+
for (const node of context.walk(loop)) {
|
|
8287
|
+
if (!["break_statement", "continue_statement", "return_statement"].includes(node.type))
|
|
8288
|
+
continue;
|
|
8289
|
+
if (context.nearestFunction(node) !== owner)
|
|
8290
|
+
continue;
|
|
8291
|
+
if (node.type !== "return_statement") {
|
|
8292
|
+
const targetLoop = findAncestorBetween(node, owner.node, (ancestor) => ["for_statement", "while_statement", "repeat_statement"].includes(ancestor.type));
|
|
8293
|
+
if (targetLoop?.id !== loop.id)
|
|
8294
|
+
continue;
|
|
8295
|
+
}
|
|
8296
|
+
const dynamicGuard = findAncestorBetween(node, loop, (ancestor) => conditionallyContains(ancestor, node) && !conditionIsProvablyInvariant(ancestor, owner, context, moduleInvariants, node));
|
|
8297
|
+
if (dynamicGuard)
|
|
8298
|
+
return false;
|
|
8299
|
+
}
|
|
7978
8300
|
const numeric = loop.namedChildren.find((child) => child.type === "for_numeric_clause");
|
|
7979
8301
|
if (numeric) {
|
|
7980
8302
|
const text = numeric.text;
|
|
@@ -8047,23 +8369,46 @@ function reachableReturnsBefore(call, fn, context, cache) {
|
|
|
8047
8369
|
returns = [...context.walk(fn.body)].filter((node) => node.type === "return_statement" && !isNestedInsideFunction(node, fn.node) && !impossibleNilGuardReturn(node, fn, parameterTypes));
|
|
8048
8370
|
cache.set(key, returns);
|
|
8049
8371
|
}
|
|
8050
|
-
return returns.filter((node) =>
|
|
8372
|
+
return returns.filter((node) => {
|
|
8373
|
+
if (node.endIndex > call.startIndex)
|
|
8374
|
+
return false;
|
|
8375
|
+
return !findAncestorBetween(node, fn.node, (ancestor) => {
|
|
8376
|
+
if (ancestor.type !== "if_statement")
|
|
8377
|
+
return false;
|
|
8378
|
+
const returnBranch = branchContaining(ancestor, node);
|
|
8379
|
+
const callBranch = branchContaining(ancestor, call);
|
|
8380
|
+
return Boolean(returnBranch && callBranch && returnBranch.id !== callBranch.id);
|
|
8381
|
+
});
|
|
8382
|
+
});
|
|
8051
8383
|
}
|
|
8052
8384
|
function returnIsControlledByStableTopologyMode(node, fn, summary, context, moduleInvariants) {
|
|
8053
8385
|
let current = node.parent;
|
|
8386
|
+
let sawStableGuard = false;
|
|
8054
8387
|
while (current && current !== fn.node) {
|
|
8055
|
-
if (
|
|
8056
|
-
if (summary && controlledParameterIndex(current, fn, summary) !== null)
|
|
8057
|
-
return true;
|
|
8058
|
-
if (conditionIsProvablyInvariant(current, fn, context, moduleInvariants))
|
|
8059
|
-
return true;
|
|
8388
|
+
if (["for_statement", "while_statement", "repeat_statement"].includes(current.type))
|
|
8060
8389
|
return false;
|
|
8390
|
+
if (current.type === "if_statement" || current.type === "if_expression") {
|
|
8391
|
+
if (summary && controlledParameterIndex(current, fn, summary, node) !== null)
|
|
8392
|
+
sawStableGuard = true;
|
|
8393
|
+
else if (conditionIsProvablyInvariant(current, fn, context, moduleInvariants, node))
|
|
8394
|
+
sawStableGuard = true;
|
|
8395
|
+
else
|
|
8396
|
+
return false;
|
|
8061
8397
|
}
|
|
8062
8398
|
current = current.parent;
|
|
8063
8399
|
}
|
|
8064
|
-
return
|
|
8400
|
+
return sawStableGuard;
|
|
8065
8401
|
}
|
|
8066
8402
|
function conditionalFixPreview(kind, hookName) {
|
|
8403
|
+
if (kind === "short-circuit expression") {
|
|
8404
|
+
return {
|
|
8405
|
+
kind: "pattern",
|
|
8406
|
+
before: `local result = enabled and renderChild(${hookName}())`,
|
|
8407
|
+
after: `local value = ${hookName}()
|
|
8408
|
+
local result = enabled and renderChild(value)`,
|
|
8409
|
+
note: "Call the hook before the short-circuit expression, then branch on its result"
|
|
8410
|
+
};
|
|
8411
|
+
}
|
|
8067
8412
|
if (hookName === "React.useEffect" && kind === "if statement") {
|
|
8068
8413
|
return {
|
|
8069
8414
|
kind: "pattern",
|
|
@@ -8142,6 +8487,34 @@ end`,
|
|
|
8142
8487
|
note: "Call hooks before any reachable early return so every render executes the same hook sequence."
|
|
8143
8488
|
};
|
|
8144
8489
|
}
|
|
8490
|
+
function callbackFunctions(context) {
|
|
8491
|
+
const callbacks = new Set;
|
|
8492
|
+
const addCallback = (expression) => {
|
|
8493
|
+
const fn = resolveLocalFunction(context, expression);
|
|
8494
|
+
if (fn)
|
|
8495
|
+
callbacks.add(fn.node.id);
|
|
8496
|
+
};
|
|
8497
|
+
for (const call of context.findCalls()) {
|
|
8498
|
+
const path = reactApiPath(context, call);
|
|
8499
|
+
const args = context.callArguments(call);
|
|
8500
|
+
for (const [index, argument] of args.entries()) {
|
|
8501
|
+
if (index === 0 && ["React.memo", "React.forwardRef", "React.createElement"].includes(path ?? ""))
|
|
8502
|
+
continue;
|
|
8503
|
+
if (path?.startsWith("React.use") || path === "React.memo" || unwrapExpression(argument).type === "function_definition")
|
|
8504
|
+
addCallback(argument);
|
|
8505
|
+
}
|
|
8506
|
+
if (path !== "React.createElement" || args[0]?.type !== "string" || args[1]?.type !== "table_constructor")
|
|
8507
|
+
continue;
|
|
8508
|
+
for (const field of args[1].namedChildren) {
|
|
8509
|
+
if (field.type !== "field")
|
|
8510
|
+
continue;
|
|
8511
|
+
const value = field.namedChildren.at(-1);
|
|
8512
|
+
if (value)
|
|
8513
|
+
addCallback(value);
|
|
8514
|
+
}
|
|
8515
|
+
}
|
|
8516
|
+
return callbacks;
|
|
8517
|
+
}
|
|
8145
8518
|
var rulesOfHooks = {
|
|
8146
8519
|
id: "react-luau/rules-of-hooks",
|
|
8147
8520
|
category: "Hooks",
|
|
@@ -8162,6 +8535,7 @@ var rulesOfHooks = {
|
|
|
8162
8535
|
const modeImports = conditionalHookModeImports(context);
|
|
8163
8536
|
const ignoredHookImports = nonReactHookImports(context);
|
|
8164
8537
|
const currentModeSummary = currentConditionalHookMode(context);
|
|
8538
|
+
const callbacks = callbackFunctions(context);
|
|
8165
8539
|
const reachableReturns = new Map;
|
|
8166
8540
|
for (const call of context.findCalls()) {
|
|
8167
8541
|
const rawPath = context.getCallPath(call);
|
|
@@ -8173,11 +8547,11 @@ var rulesOfHooks = {
|
|
|
8173
8547
|
if (!isHookPath(path))
|
|
8174
8548
|
continue;
|
|
8175
8549
|
const fn = context.nearestFunction(call);
|
|
8176
|
-
if (!fn || !fn.isComponent && !fn.isHook) {
|
|
8550
|
+
if (!fn || !fn.isComponent && !fn.isHook || callbacks.has(fn.node.id)) {
|
|
8177
8551
|
diagnostics.push({
|
|
8178
8552
|
node: callNameNode(call),
|
|
8179
|
-
message: `Hook ${path} is called outside a React component or custom hook
|
|
8180
|
-
help: "Move the hook into a component or a custom hook whose name starts with use
|
|
8553
|
+
message: `Hook ${path} is called outside a React component or custom hook`,
|
|
8554
|
+
help: "Move the hook into a component or a custom hook whose name starts with use",
|
|
8181
8555
|
fixPreview: {
|
|
8182
8556
|
kind: "pattern",
|
|
8183
8557
|
before: `local value = ${path}()
|
|
@@ -8189,7 +8563,7 @@ end`,
|
|
|
8189
8563
|
local value = ${path}()
|
|
8190
8564
|
return value
|
|
8191
8565
|
end`,
|
|
8192
|
-
note: "Hooks need a React-owned component or custom-hook call stack
|
|
8566
|
+
note: "Hooks need a React-owned component or custom-hook call stack"
|
|
8193
8567
|
}
|
|
8194
8568
|
});
|
|
8195
8569
|
continue;
|
|
@@ -8220,24 +8594,28 @@ end`,
|
|
|
8220
8594
|
});
|
|
8221
8595
|
}
|
|
8222
8596
|
}
|
|
8223
|
-
const controlFlow = findAncestorBetween(call, fn.node, (node) =>
|
|
8597
|
+
const controlFlow = findAncestorBetween(call, fn.node, (node) => {
|
|
8598
|
+
if (!conditionallyContains(node, call))
|
|
8599
|
+
return false;
|
|
8600
|
+
if (currentModeSummary && controlledParameterIndex(node, fn, currentModeSummary, call) !== null)
|
|
8601
|
+
return false;
|
|
8602
|
+
if (conditionIsProvablyInvariant(node, fn, context, moduleInvariants, call))
|
|
8603
|
+
return false;
|
|
8604
|
+
if (ifBranchesHaveEquivalentBuiltInTopology(node, fn, context))
|
|
8605
|
+
return false;
|
|
8606
|
+
if (node.type === "for_statement" && loopIterationIsProvablyStable(node, staticImports, fn, stableShapes, moduleTables, context, moduleInvariants))
|
|
8607
|
+
return false;
|
|
8608
|
+
return true;
|
|
8609
|
+
});
|
|
8224
8610
|
if (controlFlow) {
|
|
8225
|
-
|
|
8226
|
-
continue;
|
|
8227
|
-
if (conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants))
|
|
8228
|
-
continue;
|
|
8229
|
-
if (ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context))
|
|
8230
|
-
continue;
|
|
8231
|
-
if (controlFlow.type === "for_statement" && loopIterationIsProvablyStable(controlFlow, staticImports, fn, stableShapes, moduleTables))
|
|
8232
|
-
continue;
|
|
8233
|
-
const kind = controlFlow.type.replaceAll("_", " ");
|
|
8611
|
+
const kind = controlFlow.type === "binary_expression" ? "short-circuit expression" : controlFlow.type.replaceAll("_", " ");
|
|
8234
8612
|
const loopLike = controlFlow.type === "for_statement" || controlFlow.type === "while_statement" || controlFlow.type === "repeat_statement";
|
|
8235
8613
|
diagnostics.push({
|
|
8236
8614
|
node: callNameNode(call),
|
|
8237
|
-
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
|
|
8238
|
-
summary: loopLike ? `${path} may run a different number of times between renders
|
|
8239
|
-
explanation: loopLike ? `This ${kind} may change how many times ${path} is called
|
|
8240
|
-
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
|
|
8615
|
+
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`,
|
|
8616
|
+
summary: loopLike ? `${path} may run a different number of times between renders` : `${path} may be skipped on some renders`,
|
|
8617
|
+
explanation: loopLike ? `This ${kind} may change how many times ${path} is called` : `This ${kind} can skip ${path} on some renders`,
|
|
8618
|
+
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",
|
|
8241
8619
|
fixPreview: conditionalFixPreview(kind, path)
|
|
8242
8620
|
});
|
|
8243
8621
|
continue;
|
|
@@ -8610,7 +8988,7 @@ var unstableContextValue = {
|
|
|
8610
8988
|
id: "react-luau/unstable-context-value",
|
|
8611
8989
|
category: "Performance",
|
|
8612
8990
|
severity: "warning",
|
|
8613
|
-
description: "Avoid recreating context
|
|
8991
|
+
description: "Avoid recreating context reference values during render when identity matters",
|
|
8614
8992
|
run(context) {
|
|
8615
8993
|
const diagnostics = [];
|
|
8616
8994
|
for (const call of context.findCalls()) {
|
|
@@ -8625,13 +9003,13 @@ var unstableContextValue = {
|
|
|
8625
9003
|
const props = args[1];
|
|
8626
9004
|
if (!/\.Provider$/.test(element) || !props || props.type !== "table_constructor")
|
|
8627
9005
|
continue;
|
|
8628
|
-
const valueField = props.namedChildren.find((field) => field.type === "field" && fieldName(field) === "value" && fieldValue(field)
|
|
9006
|
+
const valueField = props.namedChildren.find((field) => field.type === "field" && fieldName(field) === "value" && freshValueKind(context, fieldValue(field), component));
|
|
8629
9007
|
if (!valueField)
|
|
8630
9008
|
continue;
|
|
8631
9009
|
diagnostics.push({
|
|
8632
9010
|
node: fieldNameNode(valueField),
|
|
8633
|
-
message: "Context Provider
|
|
8634
|
-
help: "Memoize the provider value from its real dependencies when consumer updates depend on object identity
|
|
9011
|
+
message: "Context Provider can receive a new reference value when the component renders",
|
|
9012
|
+
help: "Memoize the provider value from its real dependencies when consumer updates depend on object identity"
|
|
8635
9013
|
});
|
|
8636
9014
|
}
|
|
8637
9015
|
return diagnostics;
|
|
@@ -8960,6 +9338,7 @@ async function analyzeReactFile(input, tree, selectedRules = rules) {
|
|
|
8960
9338
|
project: input.project
|
|
8961
9339
|
};
|
|
8962
9340
|
const context = createRuleContext(file);
|
|
9341
|
+
const syntaxFindings = parsedTree.rootNode.hasError ? parseErrors.run(context) : [];
|
|
8963
9342
|
const categorySet = input.categories && input.categories.length > 0 ? new Set(input.categories) : null;
|
|
8964
9343
|
const isSuppressed = input.respectInlineDisables ? createInlineSuppressionChecker(input.source) : () => false;
|
|
8965
9344
|
const diagnostics = [];
|
|
@@ -8971,7 +9350,7 @@ async function analyzeReactFile(input, tree, selectedRules = rules) {
|
|
|
8971
9350
|
const severity = effectiveSeverity(rule.severity, rule.id, input.config);
|
|
8972
9351
|
if (!severity)
|
|
8973
9352
|
continue;
|
|
8974
|
-
const findings = rule.run(context);
|
|
9353
|
+
const findings = rule.id === parseErrors.id ? syntaxFindings : rule.run(context);
|
|
8975
9354
|
for (const finding of findings) {
|
|
8976
9355
|
const configuredSeverity = input.config.rules?.[rule.id];
|
|
8977
9356
|
const diagnostic = toDiagnostic(file, rule.id, rule.category, severity, {
|
|
@@ -8992,7 +9371,8 @@ async function analyzeReactFile(input, tree, selectedRules = rules) {
|
|
|
8992
9371
|
relativePath: input.relativePath,
|
|
8993
9372
|
isReactFile: model.isReactFile,
|
|
8994
9373
|
scanned: true,
|
|
8995
|
-
diagnostics: filteredDiagnostics
|
|
9374
|
+
diagnostics: filteredDiagnostics,
|
|
9375
|
+
unverified: syntaxFindings.length > 0
|
|
8996
9376
|
};
|
|
8997
9377
|
} finally {
|
|
8998
9378
|
if (!tree)
|
|
@@ -9979,5 +10359,5 @@ parentPort.on("message", async (request) => {
|
|
|
9979
10359
|
}
|
|
9980
10360
|
});
|
|
9981
10361
|
|
|
9982
|
-
//# debugId=
|
|
10362
|
+
//# debugId=3E81A30ECFF40BE164756E2164756E21
|
|
9983
10363
|
//# sourceMappingURL=scan-worker.js.map
|