@colrealpro/react-luau-doctor 0.18.2 → 0.19.0

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.
@@ -1109,6 +1109,71 @@ function applyMasks(source, ranges) {
1109
1109
  }
1110
1110
  return output;
1111
1111
  }
1112
+ function genericFunctionParameterRanges(source, mask) {
1113
+ const ranges = [];
1114
+ const functionPattern = /\bfunction\b/g;
1115
+ for (const match of source.matchAll(functionPattern)) {
1116
+ const start = match.index;
1117
+ if (!isCode(mask, start, start + match[0].length))
1118
+ continue;
1119
+ let cursor = start + match[0].length;
1120
+ while (cursor < source.length) {
1121
+ const char = source[cursor];
1122
+ if (/\s/.test(char)) {
1123
+ cursor += 1;
1124
+ continue;
1125
+ }
1126
+ if (char === "(")
1127
+ break;
1128
+ if (char === "<") {
1129
+ let depth = 0;
1130
+ const rangeStart = cursor;
1131
+ while (cursor < source.length) {
1132
+ if (isCode(mask, cursor)) {
1133
+ if (source[cursor] === "<")
1134
+ depth += 1;
1135
+ else if (source[cursor] === ">") {
1136
+ depth -= 1;
1137
+ if (depth === 0) {
1138
+ cursor += 1;
1139
+ ranges.push({ start: rangeStart, end: cursor });
1140
+ break;
1141
+ }
1142
+ }
1143
+ }
1144
+ cursor += 1;
1145
+ }
1146
+ break;
1147
+ }
1148
+ if (/[A-Za-z0-9_.:]/.test(char)) {
1149
+ cursor += 1;
1150
+ continue;
1151
+ }
1152
+ break;
1153
+ }
1154
+ }
1155
+ return ranges;
1156
+ }
1157
+ function normalizeGenericTypePackUses(source, mask) {
1158
+ const declarationRanges = genericFunctionParameterRanges(source, mask);
1159
+ const replacements = [];
1160
+ const packPattern = /\b[A-Za-z_][A-Za-z0-9_]*\.\.\./g;
1161
+ for (const match of source.matchAll(packPattern)) {
1162
+ const start = match.index;
1163
+ const end = start + match[0].length;
1164
+ if (!isCode(mask, start, end))
1165
+ continue;
1166
+ if (declarationRanges.some((range) => start >= range.start && end <= range.end))
1167
+ continue;
1168
+ const name = match[0].slice(0, -3);
1169
+ replacements.push({ start, end, value: `...${name}` });
1170
+ }
1171
+ let output = source;
1172
+ for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
1173
+ output = `${output.slice(0, replacement.start)}${replacement.value}${output.slice(replacement.end)}`;
1174
+ }
1175
+ return output;
1176
+ }
1112
1177
  function normalizeIntegerLiteralSuffixes(source, mask) {
1113
1178
  const suffixes = [];
1114
1179
  const integerPattern = /\b(?:0[xX][0-9A-Fa-f_]+|0[bB][01_]+|[0-9][0-9_]*)i\b/g;
@@ -1148,13 +1213,14 @@ function parserCompatibleSource(source) {
1148
1213
  const mask = codeMask(source);
1149
1214
  const keywordCompatible = replaceContextualKeywords(source, mask);
1150
1215
  const literalCompatible = normalizeIntegerLiteralSuffixes(keywordCompatible, mask);
1216
+ const packCompatible = normalizeGenericTypePackUses(literalCompatible, mask);
1151
1217
  const ranges = [
1152
1218
  ...maskTypeAliases(source, mask),
1153
1219
  ...maskTypeLevelBlocks(source, mask),
1154
1220
  ...attributeRanges(source, mask),
1155
1221
  ...explicitTypeArgumentRanges(source, mask)
1156
1222
  ];
1157
- const masked = applyMasks(literalCompatible, ranges);
1223
+ const masked = applyMasks(packCompatible, ranges);
1158
1224
  return masked.replace(/^(\s*)type(?=\s*(?:\+=|-=|\*=|\/=|%=|\^=|\.\.=|=(?!=)))/gm, "$1_typ");
1159
1225
  }
1160
1226
 
@@ -1367,6 +1433,8 @@ function hasRobloxReceiver(path3, call, context) {
1367
1433
  }
1368
1434
  var runServiceCallCache = new WeakMap;
1369
1435
  var runServiceExpressionCache = new WeakMap;
1436
+ var highFrequencyRobloxCallCache = new WeakMap;
1437
+ var highFrequencyRobloxExpressionCache = new WeakMap;
1370
1438
  function cachedNodeBoolean(cache, context, node, compute) {
1371
1439
  let entries = cache.get(context);
1372
1440
  if (!entries) {
@@ -1431,6 +1499,33 @@ function isHighFrequencyRunServiceCall(call, context) {
1431
1499
  return Boolean(bind && rootIsRunService(bind[1], call, context));
1432
1500
  });
1433
1501
  }
1502
+ var HIGH_FREQUENCY_ROBLOX_EVENTS = new Set([
1503
+ "InputChanged",
1504
+ "TouchMoved",
1505
+ "Move"
1506
+ ]);
1507
+ function isHighFrequencyRobloxExpression(node, context) {
1508
+ return cachedNodeBoolean(highFrequencyRobloxExpressionCache, context, node, () => {
1509
+ if (isHighFrequencyRunServiceExpression(node, context))
1510
+ return true;
1511
+ const normalized = node.text.replace(/\s+/g, "");
1512
+ const match = normalized.match(/^(.+)\.(InputChanged|TouchMoved|Move)$/);
1513
+ if (!match || !HIGH_FREQUENCY_ROBLOX_EVENTS.has(match[2]))
1514
+ return false;
1515
+ return hasRobloxReceiver(match[1], node, context);
1516
+ });
1517
+ }
1518
+ function isHighFrequencyRobloxCall(call, context) {
1519
+ return cachedNodeBoolean(highFrequencyRobloxCallCache, context, call, () => {
1520
+ if (isHighFrequencyRunServiceCall(call, context))
1521
+ return true;
1522
+ const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
1523
+ const event = normalized.match(/^(.+)\.(InputChanged|TouchMoved|Move):Connect$/);
1524
+ if (!event || !HIGH_FREQUENCY_ROBLOX_EVENTS.has(event[2]))
1525
+ return false;
1526
+ return hasRobloxReceiver(event[1], call, context);
1527
+ });
1528
+ }
1434
1529
  function sourceHasHighFrequencyRunService(source) {
1435
1530
  if (/game\s*:\s*GetService\s*\(\s*["']RunService["']\s*\)\s*(?:\.\s*(?:RenderStepped|Heartbeat|Stepped|PreRender|PreSimulation|PostSimulation)\s*:\s*Connect|:\s*(?:BindToRenderStep|BindToSimulation))/.test(source)) {
1436
1531
  return true;
@@ -1447,6 +1542,36 @@ function sourceHasHighFrequencyRunService(source) {
1447
1542
  }
1448
1543
  return false;
1449
1544
  }
1545
+ function sourceHasHighFrequencyRobloxEvent(source) {
1546
+ if (sourceHasHighFrequencyRunService(source))
1547
+ return true;
1548
+ if (/game\s*:\s*GetService\s*\(\s*["']UserInputService["']\s*\)\s*\.\s*(?:InputChanged|TouchMoved)\s*:\s*Connect/.test(source)) {
1549
+ return true;
1550
+ }
1551
+ const serviceAliases = new Set;
1552
+ for (const match of source.matchAll(/\blocal\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*game\s*:\s*GetService\s*\(\s*["']UserInputService["']\s*\)/g)) {
1553
+ serviceAliases.add(match[1]);
1554
+ }
1555
+ for (const alias of serviceAliases) {
1556
+ const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1557
+ if (new RegExp(`\\b${escaped}\\s*\\.\\s*(?:InputChanged|TouchMoved)\\s*:\\s*Connect`).test(source)) {
1558
+ return true;
1559
+ }
1560
+ }
1561
+ const playersAliases = new Set;
1562
+ for (const match of source.matchAll(/\blocal\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*game\s*:\s*GetService\s*\(\s*["']Players["']\s*\)/g)) {
1563
+ playersAliases.add(match[1]);
1564
+ }
1565
+ for (const players of playersAliases) {
1566
+ const escapedPlayers = players.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1567
+ for (const match of source.matchAll(new RegExp(`\\blocal\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*${escapedPlayers}\\s*\\.\\s*LocalPlayer\\s*:\\s*GetMouse\\s*\\(\\s*\\)`, "g"))) {
1568
+ const mouse = match[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1569
+ if (new RegExp(`\\b${mouse}\\s*\\.\\s*Move\\s*:\\s*Connect`).test(source))
1570
+ return true;
1571
+ }
1572
+ }
1573
+ return false;
1574
+ }
1450
1575
  function knownYieldReason(path3, call, context) {
1451
1576
  const normalized = path3.replace(/\s+/g, "");
1452
1577
  if (normalized === "task.wait")
@@ -3993,7 +4118,7 @@ var rerenderHighFrequencyState = {
3993
4118
  seen.add(key);
3994
4119
  const renderCost = componentRenderCost(context, component);
3995
4120
  diagnostics.push({
3996
- node: callNameNode(call),
4121
+ node: callNameNode(binding.call),
3997
4122
  severity: "warning",
3998
4123
  message: `${binding.valueName} has a state setter called from a per-frame callback and may cause frequent rerenders when its value changes.`,
3999
4124
  help: renderCost.length > 0 ? `This render also performs ${renderCost.join(" and ")}. Isolate or throttle the high-frequency state, or use Bindings for host-property-only updates.` : "Isolate or throttle the high-frequency state. Use React.useBinding when the value only drives host Instance properties."
@@ -4270,7 +4395,9 @@ var preferUseRefForMutableCell = {
4270
4395
  };
4271
4396
 
4272
4397
  // src/rules/prefer-binding-over-state.ts
4273
- var EXTERNAL_CALLBACK = /(?:^|[.:])(?:Connect|Once|Subscribe|Observe|Listen|Watch)$/i;
4398
+ var EXTERNAL_CALLBACK = /(?:^|[.:])(?:Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)$/i;
4399
+ var PERSISTENT_MIRROR_CALLBACK = /(?:^|[.:])(?:Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)$/i;
4400
+ var LIKELY_CONTINUOUS_CALLBACK = /(?:^|[.:])onStep$/i;
4274
4401
  var NON_BINDABLE_HOST_FIELDS = new Set(["ref", "key", "children"]);
4275
4402
  function replaceWithinNode(container, target, replacement) {
4276
4403
  const start = target.startIndex - container.startIndex;
@@ -4393,13 +4520,64 @@ var TRANSPARENT_PARENTS = new Set([
4393
4520
  "string_interpolation",
4394
4521
  "expression_list",
4395
4522
  "assignment_statement",
4396
- "arguments",
4397
- "function_call",
4398
4523
  "dot_index_expression",
4399
4524
  "method_index_expression",
4400
4525
  "table_constructor",
4401
4526
  "field"
4402
4527
  ]);
4528
+ var PURE_BINDING_DERIVATION_CALL = /^(?:tostring|tonumber|type|typeof|math\.[A-Za-z_][A-Za-z0-9_]*|string\.[A-Za-z_][A-Za-z0-9_]*|utf8\.[A-Za-z_][A-Za-z0-9_]*|bit32\.[A-Za-z_][A-Za-z0-9_]*|(?:UDim|UDim2|Vector2|Vector3|Color3|CFrame|Rect|NumberRange|NumberSequence|ColorSequence|BrickColor|Font)\.[A-Za-z_][A-Za-z0-9_]*)$/;
4529
+ var HOST_FEEDBACK_PROPERTIES = new Set([
4530
+ "AbsoluteSize",
4531
+ "AbsolutePosition",
4532
+ "AbsoluteRotation",
4533
+ "AbsoluteContentSize",
4534
+ "AbsoluteCanvasSize",
4535
+ "AbsoluteCellSize",
4536
+ "AbsoluteCellCount",
4537
+ "TextBounds",
4538
+ "CanvasPosition"
4539
+ ]);
4540
+ var LIKELY_CONTINUOUS_PROPERTIES = new Set([
4541
+ "CFrame",
4542
+ "WorldCFrame",
4543
+ "Position",
4544
+ "WorldPosition",
4545
+ "Rotation",
4546
+ "Orientation",
4547
+ "CanvasPosition",
4548
+ "PlaybackLoudness",
4549
+ "TimePosition"
4550
+ ]);
4551
+ var SEMANTIC_SNAPSHOT_EXPRESSION = /\b(?:getState|getSnapshot|snapshot|selector|select|table\s*\.\s*(?:clone|freeze|move))\b|(?:\.|\[\s*["'])State(?:\b|["']\s*\])/i;
4552
+ function isBindingMapSafeCall(call, context) {
4553
+ if (call.type !== "function_call")
4554
+ return false;
4555
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "").replace(/\s+/g, "");
4556
+ return PURE_BINDING_DERIVATION_CALL.test(path4);
4557
+ }
4558
+ function isTransparentParent(parent, context) {
4559
+ if (parent.type === "function_call")
4560
+ return isBindingMapSafeCall(parent, context);
4561
+ if (parent.type === "arguments") {
4562
+ return parent.parent?.type === "function_call" && isBindingMapSafeCall(parent.parent, context);
4563
+ }
4564
+ return TRANSPARENT_PARENTS.has(parent.type);
4565
+ }
4566
+ function stringLiteralValue(node) {
4567
+ if (!node)
4568
+ return null;
4569
+ return node.text.trim().match(/^["']([^"']+)["']$/)?.[1] ?? null;
4570
+ }
4571
+ function observedPropertyForCall(summary, call, context) {
4572
+ if (summary.observedPropertyName)
4573
+ return summary.observedPropertyName;
4574
+ if (summary.observedPropertyParameterIndex === undefined)
4575
+ return null;
4576
+ return stringLiteralValue(context.callArguments(call)[summary.observedPropertyParameterIndex]);
4577
+ }
4578
+ function isStrongHostFeedbackProperty(property) {
4579
+ return Boolean(property && HOST_FEEDBACK_PROPERTIES.has(property));
4580
+ }
4403
4581
  function isInside(node, ancestor) {
4404
4582
  let current = node;
4405
4583
  while (current) {
@@ -4483,13 +4661,75 @@ function isTransparentUsage(node, context, compatibleComponentProps) {
4483
4661
  }
4484
4662
  if (parent.type === "function_definition" || parent.type === "function_declaration")
4485
4663
  return false;
4486
- if (!TRANSPARENT_PARENTS.has(parent.type))
4664
+ if (!isTransparentParent(parent, context))
4487
4665
  return false;
4488
4666
  current = parent;
4489
4667
  }
4490
4668
  return false;
4491
4669
  }
4492
- function derivedLocalDeclarationForRead(node) {
4670
+ function isDirectBindingValueUsage(node, context, compatibleComponentProps) {
4671
+ let current = node;
4672
+ while (current) {
4673
+ const parent = current.parent;
4674
+ if (!parent)
4675
+ return false;
4676
+ if (parent.type === "parenthesized_expression" || parent.type === "cast_expression" || parent.type === "type_cast_expression") {
4677
+ current = parent;
4678
+ continue;
4679
+ }
4680
+ if (parent.type !== "field")
4681
+ return false;
4682
+ const value = fieldValue(parent);
4683
+ if (!value || !sameNode(value, current))
4684
+ return false;
4685
+ const property = fieldName(parent);
4686
+ if (!property || NON_BINDABLE_HOST_FIELDS.has(property))
4687
+ return false;
4688
+ const table = parent.parent;
4689
+ const args = table?.parent;
4690
+ const call = args?.parent;
4691
+ if (table?.type !== "table_constructor" || args?.type !== "arguments" || !call || !isCreateElementCall2(call, context)) {
4692
+ return false;
4693
+ }
4694
+ if (!sameNode(context.callArguments(call)[1], table))
4695
+ return false;
4696
+ if (isHostCreateElementCall(call, context))
4697
+ return true;
4698
+ const component = context.callArguments(call)[0]?.text.trim() ?? "";
4699
+ return compatibleComponentProps.get(component)?.has(property) ?? false;
4700
+ }
4701
+ return false;
4702
+ }
4703
+ function allReadsDirectlyBindingSubstitutable(valueName, owner, declaration, context, compatibleComponentProps) {
4704
+ if (!owner.body)
4705
+ return false;
4706
+ let reads = 0;
4707
+ for (const node of context.walk(owner.body)) {
4708
+ if (!isReadNode(node, valueName, owner, declaration))
4709
+ continue;
4710
+ reads += 1;
4711
+ if (!isDirectBindingValueUsage(node, context, compatibleComponentProps))
4712
+ return false;
4713
+ }
4714
+ return reads > 0;
4715
+ }
4716
+ function derivedLocalDeclarationForRead(node, context) {
4717
+ let current = node;
4718
+ while (current) {
4719
+ const parent = current.parent;
4720
+ if (!parent)
4721
+ return null;
4722
+ if (parent.type === "variable_declaration")
4723
+ return parent;
4724
+ if (parent.type === "function_definition" || parent.type === "function_declaration")
4725
+ return null;
4726
+ if (!isTransparentParent(parent, context))
4727
+ return null;
4728
+ current = parent;
4729
+ }
4730
+ return null;
4731
+ }
4732
+ function opaqueDerivedLocalDeclarationForRead(node, context) {
4493
4733
  let current = node;
4494
4734
  while (current) {
4495
4735
  const parent = current.parent;
@@ -4499,7 +4739,14 @@ function derivedLocalDeclarationForRead(node) {
4499
4739
  return parent;
4500
4740
  if (parent.type === "function_definition" || parent.type === "function_declaration")
4501
4741
  return null;
4502
- if (!TRANSPARENT_PARENTS.has(parent.type))
4742
+ if (parent.type === "if_statement" || parent.type === "elseif_clause" || parent.type === "while_statement" || parent.type === "repeat_statement" || parent.type === "for_statement" || parent.type === "for_in_statement" || parent.type === "return_statement")
4743
+ return null;
4744
+ if (parent.type === "function_call") {
4745
+ const path4 = context.resolveCallPath(context.getCallPath(parent) ?? "");
4746
+ if (/^React\.use(?:Memo|Callback|Effect|LayoutEffect)$/.test(path4))
4747
+ return null;
4748
+ }
4749
+ if (!TRANSPARENT_PARENTS.has(parent.type) && parent.type !== "arguments" && parent.type !== "function_call")
4503
4750
  return null;
4504
4751
  current = parent;
4505
4752
  }
@@ -4519,7 +4766,7 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
4519
4766
  reads += 1;
4520
4767
  if (isTransparentUsage(node, context, compatibleComponentProps))
4521
4768
  continue;
4522
- const derivedDeclaration = derivedLocalDeclarationForRead(node);
4769
+ const derivedDeclaration = derivedLocalDeclarationForRead(node, context);
4523
4770
  const derivedNames = derivedDeclaration ? declarationNames2(derivedDeclaration) : [];
4524
4771
  if (derivedDeclaration && derivedNames.length === 1) {
4525
4772
  if (stateReadsAreBindingCompatible(derivedNames[0], owner, derivedDeclaration, context, compatibleComponentProps, new Set(visited)))
@@ -4529,12 +4776,46 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
4529
4776
  }
4530
4777
  return reads > 0;
4531
4778
  }
4779
+ function propertyChangedSignalProperty(text) {
4780
+ return text.match(/GetPropertyChangedSignal\s*\(\s*["']([^"']+)["']\s*\)/)?.[1] ?? null;
4781
+ }
4782
+ function reactChangePropertyForCallback(node) {
4783
+ let current = node.parent;
4784
+ while (current && current.type !== "function_definition" && current.type !== "function_declaration") {
4785
+ if (current.type === "field") {
4786
+ return current.text.match(/React\s*\.\s*Change\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)/)?.[1] ?? null;
4787
+ }
4788
+ current = current.parent;
4789
+ }
4790
+ return null;
4791
+ }
4792
+ function evidenceForExternalSource(highFrequency, hostFeedbackProperty) {
4793
+ const hostFeedbackProperties = new Set;
4794
+ if (hostFeedbackProperty)
4795
+ hostFeedbackProperties.add(hostFeedbackProperty);
4796
+ return {
4797
+ highFrequency,
4798
+ external: true,
4799
+ hostFeedbackProperties,
4800
+ genericExternal: !highFrequency && !hostFeedbackProperty
4801
+ };
4802
+ }
4803
+ function mergeSourceEvidence(target, source) {
4804
+ target.highFrequency ||= source.highFrequency;
4805
+ target.external ||= source.external;
4806
+ target.genericExternal ||= source.genericExternal;
4807
+ for (const property of source.hostFeedbackProperties)
4808
+ target.hostFeedbackProperties.add(property);
4809
+ }
4532
4810
  function callbackSource(call, context) {
4533
4811
  const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
4534
- const highFrequency = isHighFrequencyRunServiceCall(call, context);
4812
+ const highFrequency = isHighFrequencyRobloxCall(call, context) || LIKELY_CONTINUOUS_CALLBACK.test(normalized);
4813
+ const hostFeedbackProperty = propertyChangedSignalProperty(call.text);
4535
4814
  const final = normalized.split(/[.:]/).at(-1) ?? normalized;
4536
- const external = highFrequency || EXTERNAL_CALLBACK.test(normalized) || EXTERNAL_CALLBACK.test(final);
4537
- return { highFrequency, external };
4815
+ const external = highFrequency || Boolean(hostFeedbackProperty) || EXTERNAL_CALLBACK.test(normalized) || EXTERNAL_CALLBACK.test(final);
4816
+ if (!external)
4817
+ return null;
4818
+ return evidenceForExternalSource(highFrequency, hostFeedbackProperty);
4538
4819
  }
4539
4820
  function importedCallbackSource(call, callbackArgument, importedCallbacks, context) {
4540
4821
  const path4 = context.getCallPath(call) ?? "";
@@ -4545,12 +4826,14 @@ function importedCallbackSource(call, callbackArgument, importedCallbacks, conte
4545
4826
  const callbackIndex = args.findIndex((arg) => sameNode(arg, callbackArgument));
4546
4827
  if (callbackIndex < 0 || !summary.callbackParameterIndexes.includes(callbackIndex))
4547
4828
  return null;
4548
- return {
4549
- highFrequency: summary.highFrequency || args.some((arg) => isHighFrequencyRunServiceExpression(arg, context)),
4550
- external: true
4551
- };
4829
+ const highFrequency = summary.highFrequency || args.some((arg) => isHighFrequencyRobloxExpression(arg, context));
4830
+ const hostFeedbackProperty = args.map((arg) => propertyChangedSignalProperty(arg.text)).find((property) => Boolean(property));
4831
+ return evidenceForExternalSource(highFrequency, hostFeedbackProperty);
4552
4832
  }
4553
4833
  function directCallbackSource(node, importedCallbacks, context) {
4834
+ const reactChangeProperty = reactChangePropertyForCallback(node);
4835
+ if (reactChangeProperty)
4836
+ return evidenceForExternalSource(false, reactChangeProperty);
4554
4837
  const parent = node.parent;
4555
4838
  if (parent?.type !== "arguments" || parent.parent?.type !== "function_call")
4556
4839
  return null;
@@ -4558,14 +4841,17 @@ function directCallbackSource(node, importedCallbacks, context) {
4558
4841
  const imported = importedCallbackSource(call, node, importedCallbacks, context);
4559
4842
  if (imported)
4560
4843
  return imported;
4561
- const source = callbackSource(call, context);
4562
- return source.external ? source : null;
4844
+ return callbackSource(call, context);
4563
4845
  }
4564
4846
  function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
4565
4847
  if (!fn.name || !owner.body)
4566
4848
  return null;
4567
- let foundExternal = false;
4568
- let foundHighFrequency = false;
4849
+ const result = {
4850
+ highFrequency: false,
4851
+ external: false,
4852
+ hostFeedbackProperties: new Set,
4853
+ genericExternal: false
4854
+ };
4569
4855
  for (const call of context.walk(owner.body)) {
4570
4856
  if (call.type !== "function_call")
4571
4857
  continue;
@@ -4573,14 +4859,12 @@ function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
4573
4859
  const callbackArg = args.find((arg) => arg.type === "identifier" && arg.text === fn.name);
4574
4860
  if (!callbackArg)
4575
4861
  continue;
4576
- const imported = importedCallbackSource(call, callbackArg, importedCallbacks, context);
4577
- const source = imported ?? callbackSource(call, context);
4578
- if (!source.external)
4862
+ const source = importedCallbackSource(call, callbackArg, importedCallbacks, context) ?? callbackSource(call, context);
4863
+ if (!source)
4579
4864
  continue;
4580
- foundExternal = true;
4581
- foundHighFrequency ||= source.highFrequency;
4865
+ mergeSourceEvidence(result, source);
4582
4866
  }
4583
- return foundExternal ? { highFrequency: foundHighFrequency, external: true } : null;
4867
+ return result.external ? result : null;
4584
4868
  }
4585
4869
  function setterCallbackSource(call, binding, importedCallbacks, context) {
4586
4870
  const owner = binding.owner;
@@ -4594,25 +4878,178 @@ function setterCallbackSource(call, binding, importedCallbacks, context) {
4594
4878
  return direct;
4595
4879
  return namedFunctionCallbackSource(nearest, owner, importedCallbacks, context);
4596
4880
  }
4597
- function bindingHasExternalOrHighFrequencyUpdates(binding, importedCallbacks, context) {
4881
+ function mirrorConfidenceRank(confidence) {
4882
+ if (confidence === "strong")
4883
+ return 2;
4884
+ if (confidence === "possible")
4885
+ return 1;
4886
+ return 0;
4887
+ }
4888
+ function mergeMirrorConfidence(a, b) {
4889
+ return mirrorConfidenceRank(b) > mirrorConfidenceRank(a) ? b : a;
4890
+ }
4891
+ function isLiteralStateTransition(node) {
4892
+ if (!node)
4893
+ return true;
4894
+ const text = node.text.trim();
4895
+ return /^(?:true|false|nil|[-+]?\d+(?:\.\d+)?|["'][^"']*["'])$/.test(text);
4896
+ }
4897
+ function isSemanticSnapshotExpression(node) {
4898
+ return Boolean(node && SEMANTIC_SNAPSHOT_EXPRESSION.test(node.text));
4899
+ }
4900
+ function expressionReferencesCallbackParameter(expression, callback, context) {
4901
+ if (callback.parameters.length === 0)
4902
+ return false;
4903
+ const parameters = new Set(callback.parameters);
4904
+ return [...context.walk(expression)].some((node) => node.type === "identifier" && parameters.has(node.text));
4905
+ }
4906
+ var PURE_EXPRESSION_ROOTS = new Set([
4907
+ "math",
4908
+ "string",
4909
+ "utf8",
4910
+ "bit32",
4911
+ "UDim",
4912
+ "UDim2",
4913
+ "Vector2",
4914
+ "Vector3",
4915
+ "Color3",
4916
+ "CFrame",
4917
+ "Rect",
4918
+ "NumberRange",
4919
+ "NumberSequence",
4920
+ "ColorSequence",
4921
+ "BrickColor",
4922
+ "Font",
4923
+ "Enum",
4924
+ "tostring",
4925
+ "tonumber",
4926
+ "type",
4927
+ "typeof"
4928
+ ]);
4929
+ function callbackLocalNamesBefore(callback, before, context) {
4930
+ const names = new Set;
4931
+ if (!callback.body)
4932
+ return names;
4933
+ for (const node of context.walk(callback.body)) {
4934
+ if (node.startIndex >= before.startIndex)
4935
+ continue;
4936
+ if (context.nearestFunction(node) !== callback || node.type !== "variable_declaration")
4937
+ continue;
4938
+ for (const name of declarationNames2(node))
4939
+ names.add(name);
4940
+ }
4941
+ return names;
4942
+ }
4943
+ function expressionHasExternalInput(expression, callback, setterCall, context) {
4944
+ const callbackLocals = callbackLocalNamesBefore(callback, setterCall, context);
4945
+ const callbackParameters = new Set(callback.parameters);
4946
+ for (const node of context.walk(expression)) {
4947
+ if (node.type !== "identifier" || isIdentifierPropertyName2(node))
4948
+ continue;
4949
+ const name = node.text;
4950
+ if (callbackParameters.has(name))
4951
+ return true;
4952
+ if (callbackLocals.has(name))
4953
+ continue;
4954
+ if (context.model.stateValues.has(name))
4955
+ continue;
4956
+ if (PURE_EXPRESSION_ROOTS.has(name))
4957
+ continue;
4958
+ return true;
4959
+ }
4960
+ return false;
4961
+ }
4962
+ function setterHasDistinctValuesInCallback(call, callback, context) {
4963
+ if (!callback.body)
4964
+ return false;
4965
+ const setterPath = context.getCallPath(call);
4966
+ const distinctValues = new Set;
4967
+ for (const sibling of context.walk(callback.body)) {
4968
+ if (sibling.type !== "function_call" || context.nearestFunction(sibling) !== callback)
4969
+ continue;
4970
+ if (context.getCallPath(sibling) !== setterPath)
4971
+ continue;
4972
+ const argument = context.callArguments(sibling)[0];
4973
+ if (argument)
4974
+ distinctValues.add(argument.text.trim());
4975
+ }
4976
+ return distinctValues.size > 1;
4977
+ }
4978
+ function setterCallMirrorConfidence(call, callback, source, context) {
4979
+ const value = context.callArguments(call)[0];
4980
+ if (!value || value.type === "function_definition")
4981
+ return "none";
4982
+ if (isLiteralStateTransition(value)) {
4983
+ return source.highFrequency && setterHasDistinctValuesInCallback(call, callback, context) ? "strong" : "none";
4984
+ }
4985
+ if (isSemanticSnapshotExpression(value))
4986
+ return "none";
4987
+ if (expressionReferencesCallbackParameter(value, callback, context))
4988
+ return "strong";
4989
+ if (source.hostFeedbackProperties.size > 0) {
4990
+ const text = value.text;
4991
+ if ([...source.hostFeedbackProperties].some((property) => new RegExp(`(?:\\.|["'])${property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(text))) {
4992
+ return "strong";
4993
+ }
4994
+ }
4995
+ if (!expressionHasExternalInput(value, callback, call, context)) {
4996
+ return source.highFrequency && setterHasDistinctValuesInCallback(call, callback, context) ? "strong" : "none";
4997
+ }
4998
+ if (source.highFrequency)
4999
+ return "strong";
5000
+ return source.genericExternal ? "possible" : "strong";
5001
+ }
5002
+ function mergeBindingUpdateEvidence(target, source, mirrorConfidence) {
5003
+ if (mirrorConfidence === "none")
5004
+ return;
5005
+ mergeSourceEvidence(target, source);
5006
+ target.mirrorConfidence = mergeMirrorConfidence(target.mirrorConfidence, mirrorConfidence);
5007
+ }
5008
+ function bindingUpdateEvidence(binding, importedCallbacks, context) {
4598
5009
  const owner = binding.owner;
4599
5010
  if (!owner?.body)
4600
5011
  return null;
4601
- let highFrequency = false;
4602
- let external = false;
5012
+ const result = {
5013
+ highFrequency: false,
5014
+ external: false,
5015
+ hostFeedbackProperties: new Set,
5016
+ genericExternal: false,
5017
+ mirrorConfidence: "none"
5018
+ };
4603
5019
  for (const node of context.walk(owner.body)) {
4604
5020
  if (node.type !== "function_call")
4605
5021
  continue;
4606
5022
  const path4 = context.getCallPath(node);
4607
- if (path4 !== binding.setterName)
5023
+ if (path4 === binding.setterName) {
5024
+ const source2 = setterCallbackSource(node, binding, importedCallbacks, context);
5025
+ const callback = context.nearestFunction(node);
5026
+ if (!source2 || !callback || callback === owner)
5027
+ continue;
5028
+ mergeBindingUpdateEvidence(result, source2, setterCallMirrorConfidence(node, callback, source2, context));
4608
5029
  continue;
4609
- const source = setterCallbackSource(node, binding, importedCallbacks, context);
5030
+ }
5031
+ const args = context.callArguments(node);
5032
+ const setterArg = args.find((arg) => arg.type === "identifier" && arg.text === binding.setterName);
5033
+ if (!setterArg)
5034
+ continue;
5035
+ const normalized = (context.getCallPath(node) ?? "").replace(/\s+/g, "");
5036
+ const imported = importedCallbackSource(node, setterArg, importedCallbacks, context);
5037
+ const source = imported ?? (PERSISTENT_MIRROR_CALLBACK.test(normalized) ? callbackSource(node, context) : null);
4610
5038
  if (!source)
4611
5039
  continue;
4612
- highFrequency ||= source.highFrequency;
4613
- external ||= source.external;
5040
+ mergeBindingUpdateEvidence(result, source, "strong");
4614
5041
  }
4615
- return highFrequency || external ? { highFrequency, external } : null;
5042
+ return result.external && result.mirrorConfidence !== "none" ? result : null;
5043
+ }
5044
+ function hasStrongHostFeedbackEvidence(source) {
5045
+ if (source.genericExternal || source.hostFeedbackProperties.size === 0)
5046
+ return false;
5047
+ return [...source.hostFeedbackProperties].every((property) => isStrongHostFeedbackProperty(property));
5048
+ }
5049
+ function hasLikelyContinuousPropertyEvidence(source) {
5050
+ if (source.hostFeedbackProperties.size === 0)
5051
+ return false;
5052
+ return [...source.hostFeedbackProperties].every((property) => LIKELY_CONTINUOUS_PROPERTIES.has(property));
4616
5053
  }
4617
5054
  function returnedHookValues(binding, context) {
4618
5055
  const owner = binding.owner;
@@ -4690,14 +5127,8 @@ function callDefinitelyUsesBindingMode(summary, call, context) {
4690
5127
  return false;
4691
5128
  return truthy === summary.bindingWhenTruthy;
4692
5129
  }
4693
- function shouldRecommendImportedHook(summary, call, localName, owner, declaration, context, compatibleComponentProps) {
4694
- if (!summary.external || callDefinitelyUsesBindingMode(summary, call, context))
4695
- return false;
4696
- if (!hasAnyRead(localName, owner, declaration, context))
4697
- return false;
4698
- if (summary.sourceKind === "measurement")
4699
- return true;
4700
- return stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps);
5130
+ function hasExplicitBindingMode(summary) {
5131
+ return summary.bindingModeParameterIndex !== undefined && summary.bindingWhenTruthy !== undefined;
4701
5132
  }
4702
5133
  var LIKELY_VISUAL_PROP = /(?:position|size|rotation|transparency|opacity|color|image|text|anchor|visible|enabled|layout|scale|offset|zindex|canvas|viewport|cframe|width|height|radius|stroke|padding|spacing|angle|alpha|progress)/i;
4703
5134
  function createElementPropUsage(node, context) {
@@ -4726,7 +5157,7 @@ function createElementPropUsage(node, context) {
4726
5157
  }
4727
5158
  if (parent.type === "function_definition" || parent.type === "function_declaration")
4728
5159
  return null;
4729
- if (!TRANSPARENT_PARENTS.has(parent.type))
5160
+ if (!isTransparentParent(parent, context))
4730
5161
  return null;
4731
5162
  current = parent;
4732
5163
  }
@@ -4758,17 +5189,17 @@ function isEffectUsage(node, context) {
4758
5189
  }
4759
5190
  return false;
4760
5191
  }
4761
- function isStructuralUsage(node) {
5192
+ function isStructuralUsage(node, context) {
4762
5193
  let current = node;
4763
5194
  while (current) {
4764
5195
  const parent = current.parent;
4765
5196
  if (!parent)
4766
5197
  return false;
4767
- if (parent.type === "if_statement" || parent.type === "elseif_clause" || parent.type === "while_statement" || parent.type === "repeat_statement" || parent.type === "return_statement")
5198
+ if (parent.type === "if_statement" || parent.type === "elseif_clause" || parent.type === "while_statement" || parent.type === "repeat_statement" || parent.type === "for_statement" || parent.type === "for_in_statement" || parent.type === "return_statement")
4768
5199
  return true;
4769
5200
  if (parent.type === "function_definition" || parent.type === "function_declaration")
4770
5201
  return false;
4771
- if (!TRANSPARENT_PARENTS.has(parent.type))
5202
+ if (!isTransparentParent(parent, context))
4772
5203
  return false;
4773
5204
  current = parent;
4774
5205
  }
@@ -4781,11 +5212,11 @@ function classifyBindingUsage(node, context, compatibleComponentProps) {
4781
5212
  return "custom-component";
4782
5213
  if (isEffectUsage(node, context))
4783
5214
  return "effect";
4784
- if (isStructuralUsage(node))
5215
+ if (isStructuralUsage(node, context))
4785
5216
  return "structural";
4786
5217
  return "unknown";
4787
5218
  }
4788
- function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps) {
5219
+ function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps, visited = new Set) {
4789
5220
  const summary = {
4790
5221
  reads: 0,
4791
5222
  compatible: 0,
@@ -4797,9 +5228,45 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
4797
5228
  };
4798
5229
  if (!owner.body)
4799
5230
  return summary;
5231
+ const visitKey = `${declaration.startIndex}:${valueName}`;
5232
+ if (visited.has(visitKey))
5233
+ return summary;
5234
+ visited.add(visitKey);
4800
5235
  for (const node of context.walk(owner.body)) {
4801
5236
  if (!isReadNode(node, valueName, owner, declaration))
4802
5237
  continue;
5238
+ if (!isTransparentUsage(node, context, compatibleComponentProps)) {
5239
+ const derivedDeclaration = derivedLocalDeclarationForRead(node, context);
5240
+ const derivedNames = derivedDeclaration ? declarationNames2(derivedDeclaration) : [];
5241
+ if (derivedDeclaration && derivedNames.length === 1) {
5242
+ const derived = bindingUsageSummary(derivedNames[0], owner, derivedDeclaration, context, compatibleComponentProps, new Set(visited));
5243
+ if (derived.reads > 0) {
5244
+ summary.reads += derived.reads;
5245
+ summary.compatible += derived.compatible;
5246
+ summary.customComponent += derived.customComponent;
5247
+ summary.visualCustomComponent += derived.visualCustomComponent;
5248
+ summary.effect += derived.effect;
5249
+ summary.structural += derived.structural;
5250
+ summary.unknown += derived.unknown;
5251
+ continue;
5252
+ }
5253
+ }
5254
+ const opaqueDeclaration = opaqueDerivedLocalDeclarationForRead(node, context);
5255
+ const opaqueNames = opaqueDeclaration ? declarationNames2(opaqueDeclaration) : [];
5256
+ if (opaqueDeclaration && opaqueNames.length === 1) {
5257
+ const derived = bindingUsageSummary(opaqueNames[0], owner, opaqueDeclaration, context, compatibleComponentProps, new Set(visited));
5258
+ if (derived.reads > 0) {
5259
+ summary.reads += derived.reads + 1;
5260
+ summary.compatible += derived.compatible;
5261
+ summary.customComponent += derived.customComponent;
5262
+ summary.visualCustomComponent += derived.visualCustomComponent;
5263
+ summary.effect += derived.effect;
5264
+ summary.structural += derived.structural;
5265
+ summary.unknown += derived.unknown + 1;
5266
+ continue;
5267
+ }
5268
+ }
5269
+ }
4803
5270
  summary.reads += 1;
4804
5271
  const customComponentUsage = createElementPropUsage(node, context);
4805
5272
  const kind = classifyBindingUsage(node, context, compatibleComponentProps);
@@ -4822,11 +5289,94 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
4822
5289
  function hasCandidatePresentationEvidence(summary) {
4823
5290
  return summary.compatible > 0 || summary.visualCustomComponent > 0;
4824
5291
  }
5292
+ function isPresentationOnly(summary) {
5293
+ return summary.reads > 0 && summary.compatible === summary.reads;
5294
+ }
5295
+ function stateBindingForSetter(setterName, context) {
5296
+ const valueName = context.model.stateSetters.get(setterName);
5297
+ if (!valueName)
5298
+ return null;
5299
+ return context.model.stateBindings.find((binding) => binding.setterName === setterName && binding.valueName === valueName) ?? null;
5300
+ }
5301
+ function effectOnlyDerivesPresentation(valueName, owner, declaration, context, compatibleComponentProps) {
5302
+ if (!owner.body)
5303
+ return false;
5304
+ let foundRelevantEffect = false;
5305
+ for (const call of context.walk(owner.body)) {
5306
+ if (call.type !== "function_call" || context.nearestFunction(call) !== owner)
5307
+ continue;
5308
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
5309
+ if (path4 !== "React.useEffect" && path4 !== "React.useLayoutEffect")
5310
+ continue;
5311
+ const callbackNode = context.callArguments(call)[0];
5312
+ if (callbackNode?.type !== "function_definition")
5313
+ continue;
5314
+ const callback = context.model.functionByNode.get(callbackNode.id);
5315
+ if (!callback?.body)
5316
+ continue;
5317
+ const usesValue = [...context.walk(callback.body)].some((node) => context.nearestFunction(node) === callback && isReadNode(node, valueName, owner, declaration));
5318
+ if (!usesValue)
5319
+ continue;
5320
+ foundRelevantEffect = true;
5321
+ let presentationSetter = false;
5322
+ for (const nested of context.walk(callback.body)) {
5323
+ if (nested.type !== "function_call" || context.nearestFunction(nested) !== callback)
5324
+ continue;
5325
+ if (isBindingMapSafeCall(nested, context))
5326
+ continue;
5327
+ const nestedPath = context.resolveCallPath(context.getCallPath(nested) ?? "");
5328
+ if (nestedPath === "debug.profilebegin" || nestedPath === "debug.profileend")
5329
+ continue;
5330
+ if (context.model.bindingSetters.has(nestedPath)) {
5331
+ presentationSetter = true;
5332
+ continue;
5333
+ }
5334
+ const target = stateBindingForSetter(nestedPath, context);
5335
+ if (target?.owner && stateReadsAreBindingCompatible(target.valueName, target.owner, target.declaration, context, compatibleComponentProps)) {
5336
+ presentationSetter = true;
5337
+ continue;
5338
+ }
5339
+ return false;
5340
+ }
5341
+ if (!presentationSetter)
5342
+ return false;
5343
+ }
5344
+ return foundRelevantEffect;
5345
+ }
5346
+ function importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) {
5347
+ if (!summary.external || callDefinitelyUsesBindingMode(summary, call, context))
5348
+ return null;
5349
+ const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
5350
+ if (usage.reads === 0)
5351
+ return null;
5352
+ const presentationOnly = isPresentationOnly(usage);
5353
+ const explicitBindingMode = hasExplicitBindingMode(summary);
5354
+ const observedProperty = observedPropertyForCall(summary, call, context);
5355
+ const strongHostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
5356
+ const likelyContinuousProperty = summary.sourceKind === "instance-property" && Boolean(observedProperty && LIKELY_CONTINUOUS_PROPERTIES.has(observedProperty));
5357
+ const mirrorsExternalValue = summary.mirrorConfidence !== "none";
5358
+ if (presentationOnly && (summary.highFrequency || likelyContinuousProperty))
5359
+ return "warning";
5360
+ if (presentationOnly && strongHostFeedback && explicitBindingMode)
5361
+ return "warning";
5362
+ if (presentationOnly && (explicitBindingMode || strongHostFeedback || mirrorsExternalValue))
5363
+ return "candidate";
5364
+ if (strongHostFeedback && usage.effect > 0 && effectOnlyDerivesPresentation(localName, owner, declaration, context, compatibleComponentProps)) {
5365
+ return "candidate";
5366
+ }
5367
+ if (!hasCandidatePresentationEvidence(usage))
5368
+ return null;
5369
+ if (summary.highFrequency || likelyContinuousProperty)
5370
+ return "candidate";
5371
+ if (strongHostFeedback && explicitBindingMode && usage.effect === 0)
5372
+ return "candidate";
5373
+ return null;
5374
+ }
4825
5375
  var preferBindingOverState = {
4826
5376
  id: "react-luau/prefer-binding-over-state",
4827
5377
  category: "Performance",
4828
5378
  severity: "warning",
4829
- description: "Prefer React.useBinding when externally updated values do not need React reconciliation on every change.",
5379
+ description: "Prefer React.useBinding when proven external presentation streams do not need React reconciliation.",
4830
5380
  run(context) {
4831
5381
  const diagnostics = [];
4832
5382
  const seen = new Set;
@@ -4843,75 +5393,72 @@ var preferBindingOverState = {
4843
5393
  const owner = context.containingComponent(call) ?? context.nearestFunction(call);
4844
5394
  if (!declaration || !localName || !owner)
4845
5395
  continue;
4846
- if (!shouldRecommendImportedHook(summary, call, localName, owner, declaration, context, compatibleComponentProps))
5396
+ if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "warning")
4847
5397
  continue;
4848
5398
  const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
4849
5399
  if (seen.has(key))
4850
5400
  continue;
4851
5401
  seen.add(key);
4852
- const hasBindingMode = summary.bindingModeParameterIndex !== undefined && summary.bindingWhenTruthy !== undefined;
4853
- const message = summary.sourceKind === "measurement" ? hasBindingMode ? `${path4} is using its React state mode for an externally changing GUI measurement, so ${localName} rerenders this consumer when the measurement changes.` : `${path4} returns React state for an externally changing GUI measurement, so ${localName} rerenders this consumer when the measurement changes.` : summary.sourceKind === "derived-external-state" ? `${path4} derives React state from another externally updated reactive value, so ${localName} adds another rerendering state layer.` : `${path4} returns state updated${summary.highFrequency ? " from a high-frequency source" : " from an external callback"}, and ${localName} only flows into bindable host properties.`;
4854
- const bindingModeInstruction = hasBindingMode ? `Use the hook's Binding-returning mode${summary.bindingModeParameterName ? ` by passing ${summary.bindingWhenTruthy ? "true" : "false"} for ${summary.bindingModeParameterName}` : ""}. ` : "Prefer a Binding-returning measurement hook. ";
4855
- const help = summary.sourceKind === "measurement" ? `${bindingModeInstruction}Use Binding:map for derived visual values or a binding-aware effect for imperative reactions; keep React state only when the measurement truly needs to rebuild component structure.` : "Prefer a Binding-returning hook API so host properties can update without rerendering the consumer.";
5402
+ const hasBindingMode = hasExplicitBindingMode(summary);
5403
+ const observedProperty = observedPropertyForCall(summary, call, context);
5404
+ const hostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
5405
+ const likelyContinuousProperty = summary.sourceKind === "instance-property" && Boolean(observedProperty && LIKELY_CONTINUOUS_PROPERTIES.has(observedProperty));
5406
+ const message = hostFeedback ? `${path4} mirrors ${observedProperty} through React state even though every analyzed use is presentation-only, so the measurement rerenders this consumer without needing reconciliation.` : likelyContinuousProperty ? `${path4} mirrors continuously mutable ${observedProperty} through React state, and ${localName} only feeds presentation updates that do not need reconciliation.` : `${path4} returns state updated from a high-frequency source, and ${localName} only feeds presentation updates that do not need React reconciliation.`;
5407
+ const help = hasBindingMode ? `Use the hook's Binding-returning mode${summary.bindingModeParameterName ? ` by passing ${summary.bindingWhenTruthy ? "true" : "false"} for ${summary.bindingModeParameterName}` : ""}. Binding:map can keep pure visual derivations outside the render cycle.` : "Prefer a Binding-returning hook API so continuous presentation updates can reach host properties without rerendering the consumer.";
4856
5408
  diagnostics.push({
4857
5409
  node: callNameNode(call),
4858
5410
  message,
4859
5411
  help,
4860
- fixPreview: hasBindingMode ? bindingModeFixPreview(summary, call, context) : undefined
4861
- });
4862
- }
4863
- for (const call of context.findCalls()) {
4864
- const path4 = context.getCallPath(call) ?? "";
4865
- const member = path4.split(/[.:]/).at(-1) ?? path4;
4866
- const bindingAlternative = context.project.bindingApiAlternatives.get(member);
4867
- if (!bindingAlternative)
4868
- continue;
4869
- const declaration = declarationForCall2(call);
4870
- const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
4871
- const owner = context.containingComponent(call) ?? context.nearestFunction(call);
4872
- if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
4873
- continue;
4874
- if (!stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps))
4875
- continue;
4876
- const field = selectorFieldName(call, context);
4877
- const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
4878
- if (seen.has(key))
4879
- continue;
4880
- seen.add(key);
4881
- diagnostics.push({
4882
- node: callNameNode(call),
4883
- message: `${path4} subscribes ${localName} through React state${field ? ` for ${field}` : ""}, but ${bindingAlternative} is available and every use of the value only updates bindable host properties.`,
4884
- help: `Use ${bindingAlternative} so the host property can update without rerendering the component.`,
4885
- fixPreview: callMemberFixPreview(call, bindingAlternative)
5412
+ fixPreview: hasBindingMode && allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? bindingModeFixPreview(summary, call, context) : undefined
4886
5413
  });
4887
5414
  }
4888
5415
  for (const binding of context.model.stateBindings) {
4889
5416
  const owner = binding.owner;
4890
5417
  if (!owner || !owner.isComponent && !owner.isHook)
4891
5418
  continue;
4892
- const updateKind = bindingHasExternalOrHighFrequencyUpdates(binding, importedCallbacks, context);
4893
- if (!updateKind)
5419
+ const update = bindingUpdateEvidence(binding, importedCallbacks, context);
5420
+ if (!update)
4894
5421
  continue;
4895
5422
  const localCompatible = stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
4896
5423
  const hookCompatible = owner.isHook ? hookCallSitesAreBindingCompatible(binding, context, compatibleComponentProps) : false;
4897
- if (owner.isHook ? !hookCompatible : !localCompatible)
5424
+ const strongHostFeedback = hasStrongHostFeedbackEvidence(update);
5425
+ const likelyContinuousProperty = hasLikelyContinuousPropertyEvidence(update);
5426
+ const shouldWarn = owner.isHook ? hookCompatible && (update.highFrequency || likelyContinuousProperty) : localCompatible && (update.highFrequency || strongHostFeedback || likelyContinuousProperty);
5427
+ if (!shouldWarn)
4898
5428
  continue;
4899
5429
  const key = `${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
4900
5430
  if (seen.has(key))
4901
5431
  continue;
4902
5432
  seen.add(key);
4903
5433
  if (owner.isHook && hookCompatible) {
5434
+ const properties = likelyContinuousProperty ? [...update.hostFeedbackProperties].sort().join(", ") : "";
5435
+ diagnostics.push({
5436
+ node: callNameNode(binding.call),
5437
+ message: likelyContinuousProperty && !update.highFrequency ? `Hook state ${binding.valueName} mirrors continuously mutable ${properties} and is only consumed by presentation properties at analyzed call sites.` : `Hook state ${binding.valueName} is updated from a high-frequency source and is only consumed by presentation properties at analyzed call sites.`,
5438
+ help: "Consider returning a Binding, or adding a Binding-returning variant of this hook, so continuous presentation updates do not rerender every consumer."
5439
+ });
5440
+ } else if (strongHostFeedback && !update.highFrequency) {
5441
+ const properties = [...update.hostFeedbackProperties].sort().join(", ");
4904
5442
  diagnostics.push({
4905
5443
  node: callNameNode(binding.call),
4906
- message: `Hook state ${binding.valueName} is updated${updateKind.highFrequency ? " from a high-frequency source" : " from an external callback"} and is only consumed by host properties at analyzed call sites.`,
4907
- help: "Consider returning a Binding, or adding a Binding-returning variant of this hook, so consumers can update host properties without rerendering."
5444
+ message: `State ${binding.valueName} mirrors ${properties} and only feeds presentation updates, so React reconciliation is acting as an unnecessary host-property feedback step.`,
5445
+ help: "Prefer React.useBinding for this host feedback path. Use Binding:map for pure visual derivations and keep React state only when a change needs to affect component structure or React effects.",
5446
+ fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
5447
+ });
5448
+ } else if (likelyContinuousProperty && !update.highFrequency) {
5449
+ const properties = [...update.hostFeedbackProperties].sort().join(", ");
5450
+ diagnostics.push({
5451
+ node: callNameNode(binding.call),
5452
+ message: `State ${binding.valueName} mirrors continuously mutable ${properties} and only feeds presentation updates that do not need React reconciliation.`,
5453
+ help: "Prefer React.useBinding so external visual property changes can reach presentation without routing every update through component state.",
5454
+ fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
4908
5455
  });
4909
5456
  } else {
4910
5457
  diagnostics.push({
4911
5458
  node: callNameNode(binding.call),
4912
- message: `State ${binding.valueName} is updated${updateKind.highFrequency ? " from a high-frequency callback" : " from an external callback"} and is read by the component render path.`,
4913
- help: "Prefer React.useBinding when the update is visual and does not need React to rebuild component structure.",
4914
- fixPreview: useBindingFixPreview(binding.call)
5459
+ message: `State ${binding.valueName} is updated from a high-frequency callback and only feeds presentation updates that do not need React reconciliation.`,
5460
+ help: "Prefer React.useBinding so continuous visual updates can reach host properties without rerendering the component.",
5461
+ fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
4915
5462
  });
4916
5463
  }
4917
5464
  }
@@ -4932,27 +5479,49 @@ var preferBindingOverStateCandidate = {
4932
5479
  for (const call of context.findCalls()) {
4933
5480
  const path4 = context.getCallPath(call) ?? "";
4934
5481
  const summary = importedHooks.get(path4);
4935
- if (!summary || !summary.external || callDefinitelyUsesBindingMode(summary, call, context))
5482
+ if (!summary)
4936
5483
  continue;
4937
5484
  const declaration = declarationForCall2(call);
4938
5485
  const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
4939
5486
  const owner = context.containingComponent(call) ?? context.nearestFunction(call);
4940
- if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
5487
+ if (!declaration || !localName || !owner)
4941
5488
  continue;
4942
- if (shouldRecommendImportedHook(summary, call, localName, owner, declaration, context, compatibleComponentProps))
5489
+ if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "candidate")
4943
5490
  continue;
4944
5491
  const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
4945
- const hasPresentationEvidence = hasCandidatePresentationEvidence(usage) || summary.sourceKind === "derived-external-state" && usage.effect > 0;
4946
- if (!summary.highFrequency && !hasPresentationEvidence)
4947
- continue;
5492
+ const presentationOnly = isPresentationOnly(usage);
5493
+ const observedProperty = observedPropertyForCall(summary, call, context);
5494
+ const strongHostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
5495
+ const hasBindingMode = hasExplicitBindingMode(summary);
5496
+ const mirrorsExternalValue = summary.mirrorConfidence !== "none";
5497
+ const visualEffectBridge = strongHostFeedback && usage.effect > 0 && effectOnlyDerivesPresentation(localName, owner, declaration, context, compatibleComponentProps);
4948
5498
  const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
4949
5499
  if (seen.has(key))
4950
5500
  continue;
4951
5501
  seen.add(key);
5502
+ let message;
5503
+ let help;
5504
+ if (presentationOnly && hasBindingMode) {
5505
+ message = `${path4} has a Binding-returning mode and ${localName} is only used for presentation, but Doctor cannot prove the updates are frequent enough to make the Binding version clearly preferable.`;
5506
+ help = "Consider the Binding mode when this value changes often enough that avoiding reconciliation matters; ordinary React state may be clearer for infrequent semantic updates.";
5507
+ } else if (presentationOnly && strongHostFeedback) {
5508
+ message = `${path4} mirrors ${observedProperty} through React state only for presentation, but the hook does not expose a proven low-friction Binding mode.`;
5509
+ help = "Consider a Binding-returning variant if this host feedback path is performance-sensitive. Keep state when the simpler API is more valuable than avoiding occasional rerenders.";
5510
+ } else if (presentationOnly && mirrorsExternalValue) {
5511
+ message = `${path4} mirrors an external reactive value through React state, and ${localName} is only used for presentation.`;
5512
+ help = "Consider a Binding-returning hook API if this source changes often enough that rerendering is unnecessary. Doctor cannot prove the source's update pressure, so ordinary state may still be the clearer choice.";
5513
+ } else if (visualEffectBridge) {
5514
+ message = `${path4} mirrors ${observedProperty} through React state, and the dependent React effect only derives presentation state or Bindings from ${localName}.`;
5515
+ help = hasBindingMode ? "Consider the hook's Binding mode and move the visual derivation into Binding:map/joinBindings so the measurement does not need an intermediate React rerender." : "Consider a Binding-returning hook variant if this visual feedback path is performance-sensitive; the current effect appears to use React state only as an intermediate presentation step.";
5516
+ } else {
5517
+ message = `${path4} updates ${localName} through React state and some consumers are presentation-only, but other consumers may still require React reconciliation.`;
5518
+ help = "Inspect whether the presentation branch can move to a Binding while structural or effect-driven consumers keep React state. Avoid replacing the value wholesale when React still needs to react to it.";
5519
+ }
4952
5520
  diagnostics.push({
4953
5521
  node: callNameNode(call),
4954
- message: `${path4} updates ${localName} through React state when outside data changes. Some uses of ${localName} may need a rerender, so switching it entirely to a Binding could change behavior.`,
4955
- help: "Inspect whether the value can become a Binding, use Binding:map for visual derivation, or split visual updates from the smaller part of the value that truly needs React state."
5522
+ message,
5523
+ help,
5524
+ fixPreview: presentationOnly && hasBindingMode && allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? bindingModeFixPreview(summary, call, context) : undefined
4956
5525
  });
4957
5526
  }
4958
5527
  for (const call of context.findCalls()) {
@@ -4966,39 +5535,80 @@ var preferBindingOverStateCandidate = {
4966
5535
  const owner = context.containingComponent(call) ?? context.nearestFunction(call);
4967
5536
  if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
4968
5537
  continue;
4969
- if (stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps))
5538
+ const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
5539
+ const presentationOnly = isPresentationOnly(usage);
5540
+ if (!presentationOnly)
4970
5541
  continue;
5542
+ const field = selectorFieldName(call, context);
4971
5543
  const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
4972
5544
  if (seen.has(key))
4973
5545
  continue;
4974
5546
  seen.add(key);
4975
5547
  diagnostics.push({
4976
5548
  node: callNameNode(call),
4977
- message: `${path4} uses React state even though ${bindingAlternative} is available. Some uses of ${localName} may need a rerender, so replacing it entirely with the Binding API could change behavior.`,
4978
- help: `Inspect whether ${bindingAlternative}, Binding:map, or splitting visual and structural consumers would avoid unnecessary rerenders.`,
4979
- fixPreview: callMemberFixPreview(call, bindingAlternative)
5549
+ message: `${path4} subscribes ${localName} through React state${field ? ` for ${field}` : ""}, while ${bindingAlternative} is also available and every analyzed consumer is presentation-only.`,
5550
+ help: `Consider ${bindingAlternative} when this value updates frequently enough that avoiding reconciliation matters; state remains reasonable for ordinary semantic store updates.`,
5551
+ fixPreview: allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? callMemberFixPreview(call, bindingAlternative) : undefined
4980
5552
  });
4981
5553
  }
4982
5554
  for (const binding of context.model.stateBindings) {
4983
5555
  const owner = binding.owner;
4984
- if (!owner || !owner.isComponent || !owner.body)
5556
+ if (!owner || !owner.isComponent && !owner.isHook || !owner.body)
4985
5557
  continue;
4986
- const updateKind = bindingHasExternalOrHighFrequencyUpdates(binding, importedCallbacks, context);
4987
- if (!updateKind)
5558
+ const update = bindingUpdateEvidence(binding, importedCallbacks, context);
5559
+ if (!update)
4988
5560
  continue;
4989
- if (stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps))
5561
+ const localCompatible = stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
5562
+ const hookCompatible = owner.isHook ? hookCallSitesAreBindingCompatible(binding, context, compatibleComponentProps) : false;
5563
+ const strongHostFeedback = hasStrongHostFeedbackEvidence(update);
5564
+ const likelyContinuousProperty = hasLikelyContinuousPropertyEvidence(update);
5565
+ const mirrorsExternalValue = update.mirrorConfidence !== "none";
5566
+ if (owner.isHook) {
5567
+ if (hookCompatible && (update.highFrequency || likelyContinuousProperty))
5568
+ continue;
5569
+ if (!(hookCompatible && (strongHostFeedback || mirrorsExternalValue)))
5570
+ continue;
5571
+ const key2 = `hook-state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
5572
+ if (seen.has(key2))
5573
+ continue;
5574
+ seen.add(key2);
5575
+ const measurement = strongHostFeedback ? "a presentation measurement" : "an external reactive value";
5576
+ diagnostics.push({
5577
+ node: callNameNode(binding.call),
5578
+ message: `Hook state ${binding.valueName} mirrors ${measurement} and is only consumed by presentation properties at analyzed call sites, but converting it requires changing the hook API.`,
5579
+ help: "Consider adding a Binding-returning variant when this reactive path updates often enough that avoiding reconciliation matters; keeping the state API is reasonable when ordinary React state is simpler."
5580
+ });
4990
5581
  continue;
5582
+ }
4991
5583
  const usage = bindingUsageSummary(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
4992
5584
  if (!hasCandidatePresentationEvidence(usage))
4993
5585
  continue;
5586
+ if (localCompatible && (update.highFrequency || strongHostFeedback || likelyContinuousProperty))
5587
+ continue;
5588
+ if (localCompatible && mirrorsExternalValue) {
5589
+ const key2 = `state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
5590
+ if (seen.has(key2))
5591
+ continue;
5592
+ seen.add(key2);
5593
+ diagnostics.push({
5594
+ node: callNameNode(binding.call),
5595
+ message: `State ${binding.valueName} mirrors an external reactive value and only feeds presentation updates, but Doctor cannot prove the source changes frequently enough for Binding to be clearly preferable.`,
5596
+ help: "Consider React.useBinding when this source is a stream of presentation values rather than semantic React state. Keep useState when the external event represents an infrequent UI state transition."
5597
+ });
5598
+ continue;
5599
+ }
5600
+ const mixedHighFrequency = update.highFrequency || likelyContinuousProperty;
5601
+ const mixedHostFeedback = strongHostFeedback && usage.structural > 0 && usage.effect === 0;
5602
+ if (!mixedHighFrequency && !mixedHostFeedback)
5603
+ continue;
4994
5604
  const key = `state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
4995
5605
  if (seen.has(key))
4996
5606
  continue;
4997
5607
  seen.add(key);
4998
5608
  diagnostics.push({
4999
5609
  node: callNameNode(binding.call),
5000
- message: `State ${binding.valueName} is updated${updateKind.highFrequency ? " from a high-frequency source" : " from an external callback"} and appears at least partly presentation-oriented, but some consumers may still require React state.`,
5001
- help: "Inspect whether visual consumers can move to React.useBinding while structural consumers keep a smaller state value. Splitting the responsibilities can remove rerenders without changing component behavior."
5610
+ message: `State ${binding.valueName} has presentation-oriented consumers, but other uses still appear to need React reconciliation.`,
5611
+ help: "Inspect whether the presentation branch can move to React.useBinding while structural consumers keep a smaller semantic state value. Splitting the responsibilities can remove hot-path rerenders without forcing Binding semantics onto the whole value."
5002
5612
  });
5003
5613
  }
5004
5614
  return diagnostics;
@@ -6357,6 +6967,16 @@ function nodeLocation(node) {
6357
6967
  endColumn: node.endPosition.column + 1
6358
6968
  };
6359
6969
  }
6970
+ function sameLocation(left, right) {
6971
+ return left.line === right.line && left.column === right.column && left.endLine === right.endLine && left.endColumn === right.endColumn;
6972
+ }
6973
+ function removeSupersededDiagnostics(diagnostics) {
6974
+ return diagnostics.filter((diagnostic) => {
6975
+ if (diagnostic.rule !== "react-luau/rerender-high-frequency-state")
6976
+ return true;
6977
+ return !diagnostics.some((candidate) => candidate.rule === "react-luau/prefer-binding-over-state" && sameLocation(candidate.location, diagnostic.location) && SEVERITY_RANK[candidate.severity] >= SEVERITY_RANK[diagnostic.severity]);
6978
+ });
6979
+ }
6360
6980
  function toDiagnostic(file, ruleId, category, severity, input) {
6361
6981
  const node = input.node;
6362
6982
  const highlights = input.highlights?.map(nodeLocation);
@@ -6414,11 +7034,12 @@ async function analyzeReactFile(input, tree) {
6414
7034
  if (rule.id === "react-luau/parse-error" && findings.length > 0)
6415
7035
  break;
6416
7036
  }
7037
+ const filteredDiagnostics = removeSupersededDiagnostics(diagnostics);
6417
7038
  return {
6418
7039
  relativePath: input.relativePath,
6419
7040
  isReactFile: model.isReactFile,
6420
7041
  scanned: true,
6421
- diagnostics
7042
+ diagnostics: filteredDiagnostics
6422
7043
  };
6423
7044
  }
6424
7045
 
@@ -7389,5 +8010,5 @@ parentPort.on("message", async (request) => {
7389
8010
  }
7390
8011
  });
7391
8012
 
7392
- //# debugId=D3DA65AABFE680C664756E2164756E21
8013
+ //# debugId=07C5601A473D8F7164756E2164756E21
7393
8014
  //# sourceMappingURL=scan-worker.js.map