@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.
@@ -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];
@@ -2980,6 +2986,242 @@ var effectNeedsCleanup = {
2980
2986
  }
2981
2987
  };
2982
2988
 
2989
+ // src/api-reference.ts
2990
+ var functions = new Map;
2991
+ function register(namespace, members, reference) {
2992
+ for (const member of members)
2993
+ functions.set(`${namespace}.${member}`, reference);
2994
+ }
2995
+ for (const type of [
2996
+ "Instance",
2997
+ "Random",
2998
+ "RaycastParams",
2999
+ "OverlapParams",
3000
+ "CatalogSearchParams",
3001
+ "SharedTable"
3002
+ ]) {
3003
+ register(type, ["new"], {
3004
+ returnType: type,
3005
+ allocation: "new",
3006
+ equality: "reference"
3007
+ });
3008
+ }
3009
+ register("table", ["clone", "create", "pack"], {
3010
+ returnType: "table",
3011
+ allocation: "new",
3012
+ equality: "reference"
3013
+ });
3014
+ register("SharedTable", ["clone", "cloneAndFreeze"], {
3015
+ returnType: "SharedTable",
3016
+ allocation: "new",
3017
+ equality: "reference"
3018
+ });
3019
+ register("string", ["split"], {
3020
+ returnType: "table",
3021
+ allocation: "new",
3022
+ equality: "reference"
3023
+ });
3024
+ register("buffer", ["create", "fromstring"], {
3025
+ returnType: "buffer",
3026
+ allocation: "new",
3027
+ equality: "reference"
3028
+ });
3029
+ register("coroutine", ["create"], {
3030
+ returnType: "thread",
3031
+ allocation: "new",
3032
+ equality: "reference"
3033
+ });
3034
+ register("coroutine", ["wrap"], {
3035
+ returnType: "function",
3036
+ allocation: "new",
3037
+ equality: "reference"
3038
+ });
3039
+ register("table", ["freeze"], {
3040
+ returnType: "table",
3041
+ allocation: "argument",
3042
+ equality: "reference",
3043
+ argumentIndex: 0
3044
+ });
3045
+ for (const [type, members] of Object.entries({
3046
+ Color3: ["new", "fromRGB", "fromHSV", "fromHex"],
3047
+ ColorSequence: ["new"],
3048
+ ColorSequenceKeypoint: ["new"],
3049
+ NumberSequence: ["new"],
3050
+ NumberSequenceKeypoint: ["new"],
3051
+ NumberRange: ["new"],
3052
+ UDim: ["new"],
3053
+ UDim2: ["new", "fromScale", "fromOffset"],
3054
+ Vector2: ["new"],
3055
+ Vector3: ["new", "fromNormalId", "fromAxis"],
3056
+ CFrame: [
3057
+ "new",
3058
+ "Angles",
3059
+ "fromEulerAnglesXYZ",
3060
+ "fromEulerAnglesYXZ",
3061
+ "fromOrientation",
3062
+ "fromAxisAngle",
3063
+ "fromMatrix",
3064
+ "lookAt",
3065
+ "lookAlong"
3066
+ ],
3067
+ Rect: ["new"],
3068
+ BrickColor: ["new", "palette"]
3069
+ })) {
3070
+ register(type, members, {
3071
+ returnType: type,
3072
+ allocation: "new",
3073
+ equality: "value"
3074
+ });
3075
+ }
3076
+ var instanceMethods = new Map([
3077
+ ...["GetChildren", "GetDescendants", "GetAttributes", "GetTags"].map((member) => [
3078
+ member,
3079
+ { returnType: "table", allocation: "new", equality: "reference" }
3080
+ ]),
3081
+ [
3082
+ "Clone",
3083
+ { returnType: "Instance", allocation: "new", equality: "reference" }
3084
+ ]
3085
+ ]);
3086
+ function apiFunctionReference(path) {
3087
+ return functions.get(path) ?? null;
3088
+ }
3089
+ function globalPath(context, expression, seen = new Set) {
3090
+ const node = unwrapExpression(expression);
3091
+ if (seen.has(node.id))
3092
+ return null;
3093
+ seen.add(node.id);
3094
+ if (node.type === "identifier") {
3095
+ if (isUnboundIdentifier(context, node))
3096
+ return node.text;
3097
+ const binding = localValueBinding(context, node);
3098
+ return binding?.returnIndex === 0 ? globalPath(context, binding.value, seen) : null;
3099
+ }
3100
+ if (node.type !== "dot_index_expression")
3101
+ return null;
3102
+ const namespace = node.childForFieldName("table");
3103
+ const member = node.childForFieldName("field");
3104
+ const path = namespace && globalPath(context, namespace, seen);
3105
+ return path && member ? `${path}.${member.text}` : null;
3106
+ }
3107
+ function isInstanceReceiver(context, expression, seen = new Set) {
3108
+ const node = unwrapExpression(expression);
3109
+ if (seen.has(node.id))
3110
+ return false;
3111
+ seen.add(node.id);
3112
+ if (node.type === "identifier") {
3113
+ if (isUnboundIdentifier(context, node))
3114
+ return ["game", "workspace", "script"].includes(node.text);
3115
+ const binding = localValueBinding(context, node);
3116
+ return binding?.returnIndex === 0 && isInstanceReceiver(context, binding.value, seen);
3117
+ }
3118
+ if (node.type === "dot_index_expression") {
3119
+ const receiver = node.childForFieldName("table");
3120
+ return Boolean(receiver && isInstanceReceiver(context, receiver, seen));
3121
+ }
3122
+ if (node.type !== "function_call")
3123
+ return false;
3124
+ const name = node.childForFieldName("name");
3125
+ if (!name)
3126
+ return false;
3127
+ if (globalPath(context, name) === "Instance.new")
3128
+ return true;
3129
+ if (name.type !== "method_index_expression")
3130
+ return false;
3131
+ const receiver = name.childForFieldName("table");
3132
+ const member = name.childForFieldName("method")?.text;
3133
+ return Boolean(receiver && member && [
3134
+ "GetService",
3135
+ "WaitForChild",
3136
+ "FindFirstChild",
3137
+ "FindFirstAncestor",
3138
+ "Clone"
3139
+ ].includes(member) && isInstanceReceiver(context, receiver, seen));
3140
+ }
3141
+ function apiReferenceForCall(context, call) {
3142
+ const name = call.childForFieldName("name");
3143
+ if (!name)
3144
+ return null;
3145
+ const path = globalPath(context, name);
3146
+ if (path)
3147
+ return apiFunctionReference(path);
3148
+ if (name.type !== "method_index_expression")
3149
+ return null;
3150
+ const receiver = name.childForFieldName("table");
3151
+ const member = name.childForFieldName("method")?.text;
3152
+ return receiver && member && isInstanceReceiver(context, receiver) ? instanceMethods.get(member) ?? null : null;
3153
+ }
3154
+
3155
+ // src/ast/value-identity.ts
3156
+ function freshValueKind(context, expression, lifetime, seen = new Set, returnIndex = 0, lifetimes = new Set([lifetime])) {
3157
+ if (!expression)
3158
+ return null;
3159
+ const node = unwrapExpression(expression);
3160
+ if (seen.has(node.id) || seen.size >= 64)
3161
+ return null;
3162
+ const visited = new Set(seen).add(node.id);
3163
+ const analyze = (value, owner = lifetime, index = 0) => freshValueKind(context, value, owner, visited, index, new Set(lifetimes).add(owner));
3164
+ if (node.type === "identifier") {
3165
+ const binding = resolveLocalValue(context, node);
3166
+ const owner = binding && context.nearestFunction(binding.value);
3167
+ if (!binding || !owner || !lifetimes.has(owner))
3168
+ return null;
3169
+ return analyze(binding.value, lifetime, binding.returnIndex);
3170
+ }
3171
+ if (returnIndex === 0) {
3172
+ if (node.type === "table_constructor")
3173
+ return "table";
3174
+ if (node.type === "function_definition" || node.type === "function_declaration")
3175
+ return "function";
3176
+ if (node.type === "binary_expression") {
3177
+ const operator = node.children.find((child) => ["and", "or"].includes(child.type))?.type;
3178
+ if (operator === "and")
3179
+ return analyze(node.childForFieldName("right"));
3180
+ if (operator === "or")
3181
+ return analyze(node.childForFieldName("left")) ?? analyze(node.childForFieldName("right"));
3182
+ }
3183
+ if (node.type === "if_expression") {
3184
+ for (const branch of [
3185
+ node,
3186
+ ...node.namedChildren.filter((child) => ["elseif_clause", "else_clause"].includes(child.type))
3187
+ ]) {
3188
+ const kind = analyze(branch.childForFieldName("consequence"));
3189
+ if (kind)
3190
+ return kind;
3191
+ }
3192
+ }
3193
+ }
3194
+ if (node.type !== "function_call")
3195
+ return null;
3196
+ const api = apiReferenceForCall(context, node);
3197
+ if (api && returnIndex === 0) {
3198
+ if (api.allocation === "argument")
3199
+ return analyze(context.callArguments(node)[api.argumentIndex ?? 0] ?? null);
3200
+ if (api.equality !== "reference")
3201
+ return null;
3202
+ return api.returnType === "table" || api.returnType === "function" ? api.returnType : "object";
3203
+ }
3204
+ const name = node.childForFieldName("name");
3205
+ const factory = name && resolveLocalFunction(context, name);
3206
+ if (!factory?.body)
3207
+ return null;
3208
+ for (const statement of context.walk(factory.body)) {
3209
+ if (statement.type !== "return_statement" || context.nearestFunction(statement) !== factory)
3210
+ continue;
3211
+ const values = statement.namedChildren.find((child) => child.type === "expression_list")?.namedChildren ?? [];
3212
+ const value = values[returnIndex] ?? values.at(-1);
3213
+ if (!value)
3214
+ continue;
3215
+ const index = Math.max(0, returnIndex - values.length + 1);
3216
+ if (index > 0 && value.type !== "function_call")
3217
+ continue;
3218
+ const kind = analyze(value, factory, index);
3219
+ if (kind)
3220
+ return kind;
3221
+ }
3222
+ return null;
3223
+ }
3224
+
2983
3225
  // src/rules/effect-state-rules.ts
2984
3226
  var HOOKS_WITH_DEPS = new Set([
2985
3227
  "React.useEffect",
@@ -3043,23 +3285,6 @@ function hasExternalSubscription(node, context) {
3043
3285
  }
3044
3286
  return false;
3045
3287
  }
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
3288
  function setterCalledOutsideCallback(context, owner, callback, setterName) {
3064
3289
  if (!owner.body)
3065
3290
  return false;
@@ -3099,20 +3324,6 @@ function guaranteedRepeatedStateChange(argument, stateName, context) {
3099
3324
  }
3100
3325
  return false;
3101
3326
  }
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
3327
  var noDerivedStateEffect = {
3117
3328
  id: "react-luau/no-derived-state-effect",
3118
3329
  category: "Effects",
@@ -3224,7 +3435,7 @@ var noEffectWithFreshDeps = {
3224
3435
  id: "react-luau/no-effect-with-fresh-deps",
3225
3436
  category: "Hooks",
3226
3437
  severity: "error",
3227
- description: "Dependency tables should not contain tables or functions recreated on every render.",
3438
+ description: "Dependency tables should not contain reference values recreated during render",
3228
3439
  run(context) {
3229
3440
  const diagnostics = [];
3230
3441
  for (const call of context.findCalls()) {
@@ -3236,14 +3447,14 @@ var noEffectWithFreshDeps = {
3236
3447
  if (!owner || !parts)
3237
3448
  continue;
3238
3449
  for (const dependency of dependencyExpressions(parts.deps)) {
3239
- const kind = freshDependencyKind(dependency, owner);
3450
+ const kind = freshValueKind(context, dependency, owner);
3240
3451
  if (!kind)
3241
3452
  continue;
3242
3453
  const label = dependency.type === "identifier" ? ` ${dependency.text}` : "";
3243
3454
  diagnostics.push({
3244
3455
  node: dependency,
3245
- message: `${path} depends on${label || " an inline value"} that is a new ${kind} on every render.`,
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."
3456
+ message: `${path} depends on${label || " an inline value"} that can be a new ${kind} when the component renders`,
3457
+ 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
3458
  });
3248
3459
  }
3249
3460
  }
@@ -5350,40 +5561,32 @@ function componentRenderCost(context, owner) {
5350
5561
  parts.push(`${discovery} Instance discovery call${discovery === 1 ? "" : "s"}`);
5351
5562
  return parts;
5352
5563
  }
5353
- function memoizedImports(context) {
5354
- const result = new Map;
5355
- for (const node of context.walk(context.root)) {
5356
- if (node.type !== "variable_declaration")
5357
- continue;
5358
- const match = node.text.match(/^\s*local\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)\s*$/s);
5359
- if (!match)
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);
5564
+ function memoizedComponentKind(context, expression) {
5565
+ const binding = resolveLocalValue(context, expression);
5566
+ if (!binding || binding.returnIndex !== 0 || binding.value.type !== "function_call")
5567
+ return null;
5568
+ const call = binding.value;
5569
+ if (reactApiPath(context, call) === "React.memo") {
5570
+ return context.callArguments(call).length > 1 ? "custom" : "shallow";
5365
5571
  }
5366
- return result;
5572
+ const target = context.callArguments(call)[0];
5573
+ if (context.getCallPath(call) !== "require" || !target)
5574
+ return null;
5575
+ return resolveModuleReference(normalizeRequireTarget(target.text), context.project.memoizedModules) ?? null;
5367
5576
  }
5368
5577
  function fieldLabel(field) {
5369
5578
  return fieldName(field) ?? field.childForFieldName("name")?.text ?? "prop";
5370
5579
  }
5371
- function isInlineFreshValue(node) {
5372
- return node?.type === "table_constructor" || node?.type === "function_definition";
5373
- }
5374
5580
  var rerenderUnstableMemoProps = {
5375
5581
  id: "react-luau/rerender-unstable-memo-props",
5376
5582
  category: "Performance",
5377
5583
  severity: "warning",
5378
- description: "Warn when fresh table or function props defeat shallow React.memo comparisons.",
5584
+ description: "Warn when props created during render can defeat shallow React.memo comparisons",
5379
5585
  guidance: {
5380
- explanation: "A new function or table has a new identity, so a shallow memo comparison cannot skip this child.",
5381
- help: "Stabilize props that change each render if this child is expensive, move construction into the child, or remove ineffective memoization."
5586
+ explanation: "New tables, functions, and reference objects compare unequal even when their contents match",
5587
+ help: "Stabilize props that change each render if this child is expensive, move construction into the child, or remove ineffective memoization"
5382
5588
  },
5383
5589
  run(context) {
5384
- const imports = memoizedImports(context);
5385
- if (imports.size === 0)
5386
- return [];
5387
5590
  const diagnostics = [];
5388
5591
  for (const call of context.findCalls()) {
5389
5592
  if (context.resolveCallPath(context.getCallPath(call) ?? "") !== "React.createElement")
@@ -5393,12 +5596,13 @@ var rerenderUnstableMemoProps = {
5393
5596
  continue;
5394
5597
  const args = context.callArguments(call);
5395
5598
  const childName = args[0]?.text.trim() ?? "";
5396
- if (imports.get(childName) !== "shallow")
5599
+ if (!args[0] || memoizedComponentKind(context, args[0]) !== "shallow")
5397
5600
  continue;
5398
- const props = args[1];
5399
- if (!props || props.type !== "table_constructor")
5601
+ const propsBinding = args[1] && resolveLocalValue(context, args[1]);
5602
+ const props = propsBinding?.returnIndex === 0 ? propsBinding.value : null;
5603
+ if (!props || props.type !== "table_constructor" || context.nearestFunction(props) !== component)
5400
5604
  continue;
5401
- const unstableFields = props.namedChildren.filter((node) => node.type === "field").filter((field) => isInlineFreshValue(fieldValue(field)));
5605
+ const unstableFields = props.namedChildren.filter((node) => node.type === "field").filter((field) => freshValueKind(context, fieldValue(field), component));
5402
5606
  if (unstableFields.length === 0)
5403
5607
  continue;
5404
5608
  const unstable = unstableFields.map(fieldLabel);
@@ -5407,13 +5611,13 @@ var rerenderUnstableMemoProps = {
5407
5611
  node: highlights[0],
5408
5612
  highlights,
5409
5613
  severity: "warning",
5410
- message: `Memoized ${childName} receives fresh ${unstable.join(", ")} prop${unstable.length === 1 ? "" : "s"} on every parent render.`,
5411
- summary: `${childName} gets ${unstable.length} fresh ${unstable.length === 1 ? "prop" : "props"} each render. Memoization cannot skip it.`,
5614
+ message: `Memoized ${childName} can receive fresh ${unstable.join(", ")} prop${unstable.length === 1 ? "" : "s"} when its parent renders`,
5615
+ summary: `${childName} gets ${unstable.length} ${unstable.length === 1 ? "prop" : "props"} created during render that can prevent memoization from skipping it`,
5412
5616
  editorRanges: highlights.map((node, index) => ({
5413
5617
  node,
5414
- summary: `${childName} gets a fresh ${unstable[index]} prop each render. Memoization cannot skip it.`
5618
+ summary: `${childName} gets a ${unstable[index]} prop created during render that can prevent memoization from skipping it`
5415
5619
  })),
5416
- help: "Fresh table or function identity prevents the shallow memo comparison from skipping this child. Stabilize the prop only when the parent rerenders frequently, move construction into the child, or remove ineffective memoization."
5620
+ 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
5621
  });
5418
5622
  }
5419
5623
  return diagnostics;
@@ -7587,30 +7791,32 @@ function currentConditionalHookMode(context) {
7587
7791
  }
7588
7792
  return null;
7589
7793
  }
7590
- function controlledParameterIndex(controlFlow, fn, summary) {
7794
+ function controlledParameterIndex(controlFlow, fn, summary, node) {
7591
7795
  if (controlFlow.type !== "if_statement")
7592
7796
  return null;
7593
- const header = (controlFlow.text.split(/\bthen\b/s, 1)[0] ?? "").trim();
7594
- for (const [conditionName, index] of Object.entries(summary.conditionVariables)) {
7595
- const escaped = conditionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7596
- const patterns = [
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
- }
7797
+ const conditions = guardConditions(controlFlow, node);
7798
+ if (conditions.length === 0)
7799
+ return null;
7800
+ const variables = { ...summary.conditionVariables };
7605
7801
  for (const index of summary.parameterIndexes) {
7606
7802
  const name = fn.parameters[index];
7607
- if (!name)
7608
- continue;
7609
- const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7610
- if (new RegExp(`^if\\s+(?:not\\s+)?${escaped}\\s*$`).test(header))
7611
- return index;
7803
+ if (name)
7804
+ variables[name] ??= index;
7612
7805
  }
7613
- return null;
7806
+ const indexes = conditions.map((condition) => {
7807
+ for (const [name, index] of Object.entries(variables)) {
7808
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7809
+ const patterns = [
7810
+ new RegExp(`^(?:not\\s+)?${escaped}\\s*$`),
7811
+ new RegExp(`^${escaped}\\s*(?:==|~=)\\s*(?:true|false|nil)\\s*$`),
7812
+ new RegExp(`^(?:true|false|nil)\\s*(?:==|~=)\\s*${escaped}\\s*$`)
7813
+ ];
7814
+ if (patterns.some((pattern) => pattern.test(condition.text.trim())))
7815
+ return index;
7816
+ }
7817
+ return null;
7818
+ });
7819
+ return indexes.every((index) => index !== null) ? indexes[0] : null;
7614
7820
  }
7615
7821
  function dynamicHookModeFixPreview(hookName, argumentText) {
7616
7822
  return {
@@ -7812,28 +8018,104 @@ function staticModuleTables(context) {
7812
8018
  }
7813
8019
  return candidates;
7814
8020
  }
7815
- function simpleConditionRoot(controlFlow) {
7816
- if (controlFlow.type !== "if_statement" && controlFlow.type !== "if_expression")
7817
- return null;
8021
+ function containsNode(container, node) {
8022
+ return node.startIndex >= container.startIndex && node.endIndex <= container.endIndex;
8023
+ }
8024
+ function branchContaining(controlFlow, node) {
8025
+ return controlFlow.namedChildren.find((child) => containsNode(child, node)) ?? null;
8026
+ }
8027
+ function guardConditions(controlFlow, node) {
7818
8028
  const condition = controlFlow.namedChildren[0];
7819
8029
  if (!condition)
7820
- return null;
8030
+ return [];
8031
+ if (controlFlow.type !== "if_statement" && controlFlow.type !== "if_expression")
8032
+ return [condition];
8033
+ if (containsNode(condition, node))
8034
+ return [];
8035
+ const conditions = [condition];
8036
+ for (const clause of controlFlow.namedChildren.slice(1)) {
8037
+ if (clause.startIndex > node.startIndex)
8038
+ break;
8039
+ if (clause.type !== "elseif_statement" && clause.type !== "elseif_clause")
8040
+ continue;
8041
+ const clauseCondition = clause.namedChildren[0];
8042
+ if (clauseCondition && !containsNode(clauseCondition, node))
8043
+ conditions.push(clauseCondition);
8044
+ }
8045
+ return conditions;
8046
+ }
8047
+ function conditionallyContains(controlFlow, node) {
8048
+ if (controlFlow.type === "if_statement" || controlFlow.type === "if_expression")
8049
+ return guardConditions(controlFlow, node).length > 0;
8050
+ if (controlFlow.type === "for_statement")
8051
+ return controlFlow.namedChildren.some((child) => child.type === "block" && containsNode(child, node));
8052
+ if (CONDITIONAL_TYPES.has(controlFlow.type))
8053
+ return true;
8054
+ if (controlFlow.type !== "binary_expression" || !controlFlow.children.some((child) => child.type === "and" || child.type === "or"))
8055
+ return false;
8056
+ const right = controlFlow.childForFieldName("right") ?? controlFlow.namedChildren[1];
8057
+ return Boolean(right && containsNode(right, node));
8058
+ }
8059
+ function simpleConditionRoot(condition) {
7821
8060
  const text = condition.text.trim();
7822
8061
  const match = text.match(/^(?:not\s+)?([A-Za-z_][A-Za-z0-9_]*)(?:\s*(?:==|~=)\s*(?:true|false|nil))?$/);
7823
8062
  return match?.[1] ?? null;
7824
8063
  }
7825
- function conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants) {
7826
- const condition = controlFlow.namedChildren[0];
7827
- if (!condition)
8064
+ var unstableLocalNamesCache = new WeakMap;
8065
+ function unstableLocalNames(fn, context) {
8066
+ const cached = unstableLocalNamesCache.get(fn);
8067
+ if (cached)
8068
+ return cached;
8069
+ const declared = new Set;
8070
+ const unstable = new Set;
8071
+ for (const node of context.walk(fn.node)) {
8072
+ if (node.type === "variable_declaration" && context.nearestFunction(node) === fn) {
8073
+ for (const name of declarationNames2(node)) {
8074
+ if (declared.has(name))
8075
+ unstable.add(name);
8076
+ declared.add(name);
8077
+ }
8078
+ }
8079
+ if (node.type !== "assignment_statement" || node.parent?.type === "variable_declaration")
8080
+ continue;
8081
+ const variables = node.namedChildren.find((child) => child.type === "variable_list");
8082
+ for (const target of variables?.namedChildren ?? []) {
8083
+ if (target.type === "identifier")
8084
+ unstable.add(target.text);
8085
+ }
8086
+ }
8087
+ unstableLocalNamesCache.set(fn, unstable);
8088
+ return unstable;
8089
+ }
8090
+ function moduleNameIsUnshadowed(name, node, fn, context) {
8091
+ let owner = fn;
8092
+ while (owner) {
8093
+ if (owner.parameters.includes(name) || isBindingShadowedBetween(node, owner, name))
8094
+ return false;
8095
+ owner = context.nearestFunction(owner.node);
8096
+ }
8097
+ return true;
8098
+ }
8099
+ function conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants, node = controlFlow) {
8100
+ if (!["if_statement", "if_expression", "binary_expression"].includes(controlFlow.type))
7828
8101
  return false;
7829
- if (["true", "false", "nil", "number", "string"].includes(condition.type))
7830
- return true;
7831
- const root = simpleConditionRoot(controlFlow);
7832
- if (!root)
8102
+ const conditions = guardConditions(controlFlow, node);
8103
+ if (conditions.length === 0)
7833
8104
  return false;
7834
- if (moduleInvariants.has(root))
7835
- return true;
7836
- return Boolean(context.model.stableVariablesByFunction.get(nodeKey(fn.node))?.has(root));
8105
+ return conditions.every((condition) => {
8106
+ if (["true", "false", "nil", "number", "string"].includes(condition.type))
8107
+ return true;
8108
+ const root = simpleConditionRoot(condition);
8109
+ if (!root)
8110
+ return false;
8111
+ if (moduleInvariants.has(root) && moduleNameIsUnshadowed(root, condition, fn, context))
8112
+ return true;
8113
+ if (isNameShadowedBetween(condition, fn, root))
8114
+ return false;
8115
+ if (unstableLocalNames(fn, context).has(root))
8116
+ return false;
8117
+ return Boolean(context.model.stableVariablesByFunction.get(nodeKey(fn.node))?.has(root));
8118
+ });
7837
8119
  }
7838
8120
  function branchHookSequence(branch, fn, context) {
7839
8121
  const hooks = [];
@@ -7848,28 +8130,45 @@ function branchHookSequence(branch, fn, context) {
7848
8130
  const path = context.resolveCallPath(rawPath);
7849
8131
  if (!isHookPath(path))
7850
8132
  continue;
7851
- const nestedControl = findAncestorBetween(node, branch, (ancestor) => CONDITIONAL_TYPES.has(ancestor.type));
7852
- if (nestedControl)
8133
+ const nestedControl = node.id === branch.id ? null : findAncestorBetween(node, branch, (ancestor) => conditionallyContains(ancestor, node));
8134
+ if (nestedControl || conditionallyContains(branch, node))
7853
8135
  return null;
7854
8136
  hooks.push(path);
7855
8137
  }
7856
8138
  return hooks;
7857
8139
  }
7858
8140
  function ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context) {
7859
- if (controlFlow.type !== "if_statement")
8141
+ const isStatement = controlFlow.type === "if_statement";
8142
+ if (!isStatement && controlFlow.type !== "if_expression")
7860
8143
  return false;
7861
- const thenBlock = controlFlow.namedChildren.find((child) => child.type === "block");
7862
- const elseStatement = controlFlow.namedChildren.find((child) => child.type === "else_statement");
7863
- const elseBlock = elseStatement?.namedChildren.find((child) => child.type === "block");
7864
- if (!thenBlock || !elseBlock)
8144
+ const firstBranch = isStatement ? controlFlow.namedChildren.find((child) => child.type === "block") : controlFlow.namedChildren[1];
8145
+ const elseClause = controlFlow.namedChildren.find((child) => child.type === "else_statement" || child.type === "else_clause");
8146
+ const lastBranch = isStatement ? elseClause?.namedChildren.find((child) => child.type === "block") : elseClause?.namedChildren[0];
8147
+ if (!firstBranch || !lastBranch)
7865
8148
  return false;
7866
- const thenHooks = branchHookSequence(thenBlock, fn, context);
7867
- const elseHooks = branchHookSequence(elseBlock, fn, context);
7868
- if (!thenHooks || !elseHooks || thenHooks.length !== elseHooks.length)
8149
+ const branches = [firstBranch];
8150
+ for (const clause of controlFlow.namedChildren) {
8151
+ if (clause.type !== "elseif_statement" && clause.type !== "elseif_clause")
8152
+ continue;
8153
+ const condition = clause.namedChildren[0];
8154
+ if (!condition)
8155
+ return false;
8156
+ const conditionHooks = branchHookSequence(condition, fn, context);
8157
+ if (!conditionHooks || conditionHooks.length > 0)
8158
+ return false;
8159
+ const branch = isStatement ? clause.namedChildren.find((child) => child.type === "block") : clause.namedChildren[1];
8160
+ if (!branch)
8161
+ return false;
8162
+ branches.push(branch);
8163
+ }
8164
+ branches.push(lastBranch);
8165
+ const sequences = branches.map((branch) => branchHookSequence(branch, fn, context));
8166
+ const first = sequences[0];
8167
+ if (!first)
7869
8168
  return false;
7870
- if (!thenHooks.every((path, index) => path === elseHooks[index]))
8169
+ if (!sequences.every((sequence) => sequence && sequence.length === first.length && sequence.every((path, index) => path === first[index])))
7871
8170
  return false;
7872
- return thenHooks.every((path) => path.startsWith("React."));
8171
+ return first.every((path) => path.startsWith("React."));
7873
8172
  }
7874
8173
  function stateModeStability(name, owner, context) {
7875
8174
  const binding = context.model.stateBindings.find((candidate) => candidate.owner === owner && candidate.valueName === name);
@@ -7974,7 +8273,21 @@ function controlledAccessPaths(summary, parameterIndex) {
7974
8273
  paths.add("");
7975
8274
  return [...paths];
7976
8275
  }
7977
- function loopIterationIsProvablyStable(loop, imports, owner, stableShapes, moduleTables) {
8276
+ function loopIterationIsProvablyStable(loop, imports, owner, stableShapes, moduleTables, context, moduleInvariants) {
8277
+ for (const node of context.walk(loop)) {
8278
+ if (!["break_statement", "continue_statement", "return_statement"].includes(node.type))
8279
+ continue;
8280
+ if (context.nearestFunction(node) !== owner)
8281
+ continue;
8282
+ if (node.type !== "return_statement") {
8283
+ const targetLoop = findAncestorBetween(node, owner.node, (ancestor) => ["for_statement", "while_statement", "repeat_statement"].includes(ancestor.type));
8284
+ if (targetLoop?.id !== loop.id)
8285
+ continue;
8286
+ }
8287
+ const dynamicGuard = findAncestorBetween(node, loop, (ancestor) => conditionallyContains(ancestor, node) && !conditionIsProvablyInvariant(ancestor, owner, context, moduleInvariants, node));
8288
+ if (dynamicGuard)
8289
+ return false;
8290
+ }
7978
8291
  const numeric = loop.namedChildren.find((child) => child.type === "for_numeric_clause");
7979
8292
  if (numeric) {
7980
8293
  const text = numeric.text;
@@ -8047,23 +8360,46 @@ function reachableReturnsBefore(call, fn, context, cache) {
8047
8360
  returns = [...context.walk(fn.body)].filter((node) => node.type === "return_statement" && !isNestedInsideFunction(node, fn.node) && !impossibleNilGuardReturn(node, fn, parameterTypes));
8048
8361
  cache.set(key, returns);
8049
8362
  }
8050
- return returns.filter((node) => node.endIndex <= call.startIndex);
8363
+ return returns.filter((node) => {
8364
+ if (node.endIndex > call.startIndex)
8365
+ return false;
8366
+ return !findAncestorBetween(node, fn.node, (ancestor) => {
8367
+ if (ancestor.type !== "if_statement")
8368
+ return false;
8369
+ const returnBranch = branchContaining(ancestor, node);
8370
+ const callBranch = branchContaining(ancestor, call);
8371
+ return Boolean(returnBranch && callBranch && returnBranch.id !== callBranch.id);
8372
+ });
8373
+ });
8051
8374
  }
8052
8375
  function returnIsControlledByStableTopologyMode(node, fn, summary, context, moduleInvariants) {
8053
8376
  let current = node.parent;
8377
+ let sawStableGuard = false;
8054
8378
  while (current && current !== fn.node) {
8055
- if (current.type === "if_statement" || current.type === "if_expression") {
8056
- if (summary && controlledParameterIndex(current, fn, summary) !== null)
8057
- return true;
8058
- if (conditionIsProvablyInvariant(current, fn, context, moduleInvariants))
8059
- return true;
8379
+ if (["for_statement", "while_statement", "repeat_statement"].includes(current.type))
8060
8380
  return false;
8381
+ if (current.type === "if_statement" || current.type === "if_expression") {
8382
+ if (summary && controlledParameterIndex(current, fn, summary, node) !== null)
8383
+ sawStableGuard = true;
8384
+ else if (conditionIsProvablyInvariant(current, fn, context, moduleInvariants, node))
8385
+ sawStableGuard = true;
8386
+ else
8387
+ return false;
8061
8388
  }
8062
8389
  current = current.parent;
8063
8390
  }
8064
- return false;
8391
+ return sawStableGuard;
8065
8392
  }
8066
8393
  function conditionalFixPreview(kind, hookName) {
8394
+ if (kind === "short-circuit expression") {
8395
+ return {
8396
+ kind: "pattern",
8397
+ before: `local result = enabled and renderChild(${hookName}())`,
8398
+ after: `local value = ${hookName}()
8399
+ local result = enabled and renderChild(value)`,
8400
+ note: "Call the hook before the short-circuit expression, then branch on its result"
8401
+ };
8402
+ }
8067
8403
  if (hookName === "React.useEffect" && kind === "if statement") {
8068
8404
  return {
8069
8405
  kind: "pattern",
@@ -8142,6 +8478,34 @@ end`,
8142
8478
  note: "Call hooks before any reachable early return so every render executes the same hook sequence."
8143
8479
  };
8144
8480
  }
8481
+ function callbackFunctions(context) {
8482
+ const callbacks = new Set;
8483
+ const addCallback = (expression) => {
8484
+ const fn = resolveLocalFunction(context, expression);
8485
+ if (fn)
8486
+ callbacks.add(fn.node.id);
8487
+ };
8488
+ for (const call of context.findCalls()) {
8489
+ const path = reactApiPath(context, call);
8490
+ const args = context.callArguments(call);
8491
+ for (const [index, argument] of args.entries()) {
8492
+ if (index === 0 && ["React.memo", "React.forwardRef", "React.createElement"].includes(path ?? ""))
8493
+ continue;
8494
+ if (path?.startsWith("React.use") || path === "React.memo" || unwrapExpression(argument).type === "function_definition")
8495
+ addCallback(argument);
8496
+ }
8497
+ if (path !== "React.createElement" || args[0]?.type !== "string" || args[1]?.type !== "table_constructor")
8498
+ continue;
8499
+ for (const field of args[1].namedChildren) {
8500
+ if (field.type !== "field")
8501
+ continue;
8502
+ const value = field.namedChildren.at(-1);
8503
+ if (value)
8504
+ addCallback(value);
8505
+ }
8506
+ }
8507
+ return callbacks;
8508
+ }
8145
8509
  var rulesOfHooks = {
8146
8510
  id: "react-luau/rules-of-hooks",
8147
8511
  category: "Hooks",
@@ -8162,6 +8526,7 @@ var rulesOfHooks = {
8162
8526
  const modeImports = conditionalHookModeImports(context);
8163
8527
  const ignoredHookImports = nonReactHookImports(context);
8164
8528
  const currentModeSummary = currentConditionalHookMode(context);
8529
+ const callbacks = callbackFunctions(context);
8165
8530
  const reachableReturns = new Map;
8166
8531
  for (const call of context.findCalls()) {
8167
8532
  const rawPath = context.getCallPath(call);
@@ -8173,11 +8538,11 @@ var rulesOfHooks = {
8173
8538
  if (!isHookPath(path))
8174
8539
  continue;
8175
8540
  const fn = context.nearestFunction(call);
8176
- if (!fn || !fn.isComponent && !fn.isHook) {
8541
+ if (!fn || !fn.isComponent && !fn.isHook || callbacks.has(fn.node.id)) {
8177
8542
  diagnostics.push({
8178
8543
  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.",
8544
+ message: `Hook ${path} is called outside a React component or custom hook`,
8545
+ help: "Move the hook into a component or a custom hook whose name starts with use",
8181
8546
  fixPreview: {
8182
8547
  kind: "pattern",
8183
8548
  before: `local value = ${path}()
@@ -8189,7 +8554,7 @@ end`,
8189
8554
  local value = ${path}()
8190
8555
  return value
8191
8556
  end`,
8192
- note: "Hooks need a React-owned component or custom-hook call stack."
8557
+ note: "Hooks need a React-owned component or custom-hook call stack"
8193
8558
  }
8194
8559
  });
8195
8560
  continue;
@@ -8220,24 +8585,28 @@ end`,
8220
8585
  });
8221
8586
  }
8222
8587
  }
8223
- const controlFlow = findAncestorBetween(call, fn.node, (node) => CONDITIONAL_TYPES.has(node.type));
8588
+ const controlFlow = findAncestorBetween(call, fn.node, (node) => {
8589
+ if (!conditionallyContains(node, call))
8590
+ return false;
8591
+ if (currentModeSummary && controlledParameterIndex(node, fn, currentModeSummary, call) !== null)
8592
+ return false;
8593
+ if (conditionIsProvablyInvariant(node, fn, context, moduleInvariants, call))
8594
+ return false;
8595
+ if (ifBranchesHaveEquivalentBuiltInTopology(node, fn, context))
8596
+ return false;
8597
+ if (node.type === "for_statement" && loopIterationIsProvablyStable(node, staticImports, fn, stableShapes, moduleTables, context, moduleInvariants))
8598
+ return false;
8599
+ return true;
8600
+ });
8224
8601
  if (controlFlow) {
8225
- if (currentModeSummary && controlledParameterIndex(controlFlow, fn, currentModeSummary) !== null)
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("_", " ");
8602
+ const kind = controlFlow.type === "binary_expression" ? "short-circuit expression" : controlFlow.type.replaceAll("_", " ");
8234
8603
  const loopLike = controlFlow.type === "for_statement" || controlFlow.type === "while_statement" || controlFlow.type === "repeat_statement";
8235
8604
  diagnostics.push({
8236
8605
  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.` : `Hook ${path} is called inside ${kind}, so the hook may be skipped on some renders.`,
8238
- summary: loopLike ? `${path} may run a different number of times between renders.` : `${path} may be skipped on some renders.`,
8239
- explanation: loopLike ? `This ${kind} may change how many times ${path} is called.` : `This ${kind} can skip ${path} on some renders.`,
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." : "Call the hook unconditionally at the top level, then branch on the returned value.",
8606
+ 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`,
8607
+ summary: loopLike ? `${path} may run a different number of times between renders` : `${path} may be skipped on some renders`,
8608
+ explanation: loopLike ? `This ${kind} may change how many times ${path} is called` : `This ${kind} can skip ${path} on some renders`,
8609
+ 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
8610
  fixPreview: conditionalFixPreview(kind, path)
8242
8611
  });
8243
8612
  continue;
@@ -8610,7 +8979,7 @@ var unstableContextValue = {
8610
8979
  id: "react-luau/unstable-context-value",
8611
8980
  category: "Performance",
8612
8981
  severity: "warning",
8613
- description: "Avoid recreating context value tables on every provider render when identity matters.",
8982
+ description: "Avoid recreating context reference values during render when identity matters",
8614
8983
  run(context) {
8615
8984
  const diagnostics = [];
8616
8985
  for (const call of context.findCalls()) {
@@ -8625,13 +8994,13 @@ var unstableContextValue = {
8625
8994
  const props = args[1];
8626
8995
  if (!/\.Provider$/.test(element) || !props || props.type !== "table_constructor")
8627
8996
  continue;
8628
- const valueField = props.namedChildren.find((field) => field.type === "field" && fieldName(field) === "value" && fieldValue(field)?.type === "table_constructor");
8997
+ const valueField = props.namedChildren.find((field) => field.type === "field" && fieldName(field) === "value" && freshValueKind(context, fieldValue(field), component));
8629
8998
  if (!valueField)
8630
8999
  continue;
8631
9000
  diagnostics.push({
8632
9001
  node: fieldNameNode(valueField),
8633
- message: "Context Provider receives a new inline value table on every render.",
8634
- help: "Memoize the provider value from its real dependencies when consumer updates depend on object identity."
9002
+ message: "Context Provider can receive a new reference value when the component renders",
9003
+ help: "Memoize the provider value from its real dependencies when consumer updates depend on object identity"
8635
9004
  });
8636
9005
  }
8637
9006
  return diagnostics;
@@ -9979,5 +10348,5 @@ parentPort.on("message", async (request) => {
9979
10348
  }
9980
10349
  });
9981
10350
 
9982
- //# debugId=420377AA4E8B7A3664756E2164756E21
10351
+ //# debugId=AB0DC00E72A2325A64756E2164756E21
9983
10352
  //# sourceMappingURL=scan-worker.js.map