@colrealpro/react-luau-doctor 0.18.3 → 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.
@@ -1433,6 +1433,8 @@ function hasRobloxReceiver(path3, call, context) {
1433
1433
  }
1434
1434
  var runServiceCallCache = new WeakMap;
1435
1435
  var runServiceExpressionCache = new WeakMap;
1436
+ var highFrequencyRobloxCallCache = new WeakMap;
1437
+ var highFrequencyRobloxExpressionCache = new WeakMap;
1436
1438
  function cachedNodeBoolean(cache, context, node, compute) {
1437
1439
  let entries = cache.get(context);
1438
1440
  if (!entries) {
@@ -1497,6 +1499,33 @@ function isHighFrequencyRunServiceCall(call, context) {
1497
1499
  return Boolean(bind && rootIsRunService(bind[1], call, context));
1498
1500
  });
1499
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
+ }
1500
1529
  function sourceHasHighFrequencyRunService(source) {
1501
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)) {
1502
1531
  return true;
@@ -1513,6 +1542,36 @@ function sourceHasHighFrequencyRunService(source) {
1513
1542
  }
1514
1543
  return false;
1515
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
+ }
1516
1575
  function knownYieldReason(path3, call, context) {
1517
1576
  const normalized = path3.replace(/\s+/g, "");
1518
1577
  if (normalized === "task.wait")
@@ -4059,7 +4118,7 @@ var rerenderHighFrequencyState = {
4059
4118
  seen.add(key);
4060
4119
  const renderCost = componentRenderCost(context, component);
4061
4120
  diagnostics.push({
4062
- node: callNameNode(call),
4121
+ node: callNameNode(binding.call),
4063
4122
  severity: "warning",
4064
4123
  message: `${binding.valueName} has a state setter called from a per-frame callback and may cause frequent rerenders when its value changes.`,
4065
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."
@@ -4336,7 +4395,9 @@ var preferUseRefForMutableCell = {
4336
4395
  };
4337
4396
 
4338
4397
  // src/rules/prefer-binding-over-state.ts
4339
- 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;
4340
4401
  var NON_BINDABLE_HOST_FIELDS = new Set(["ref", "key", "children"]);
4341
4402
  function replaceWithinNode(container, target, replacement) {
4342
4403
  const start = target.startIndex - container.startIndex;
@@ -4459,13 +4520,64 @@ var TRANSPARENT_PARENTS = new Set([
4459
4520
  "string_interpolation",
4460
4521
  "expression_list",
4461
4522
  "assignment_statement",
4462
- "arguments",
4463
- "function_call",
4464
4523
  "dot_index_expression",
4465
4524
  "method_index_expression",
4466
4525
  "table_constructor",
4467
4526
  "field"
4468
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
+ }
4469
4581
  function isInside(node, ancestor) {
4470
4582
  let current = node;
4471
4583
  while (current) {
@@ -4549,13 +4661,75 @@ function isTransparentUsage(node, context, compatibleComponentProps) {
4549
4661
  }
4550
4662
  if (parent.type === "function_definition" || parent.type === "function_declaration")
4551
4663
  return false;
4552
- if (!TRANSPARENT_PARENTS.has(parent.type))
4664
+ if (!isTransparentParent(parent, context))
4553
4665
  return false;
4554
4666
  current = parent;
4555
4667
  }
4556
4668
  return false;
4557
4669
  }
4558
- 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) {
4559
4733
  let current = node;
4560
4734
  while (current) {
4561
4735
  const parent = current.parent;
@@ -4565,7 +4739,14 @@ function derivedLocalDeclarationForRead(node) {
4565
4739
  return parent;
4566
4740
  if (parent.type === "function_definition" || parent.type === "function_declaration")
4567
4741
  return null;
4568
- 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")
4569
4750
  return null;
4570
4751
  current = parent;
4571
4752
  }
@@ -4585,7 +4766,7 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
4585
4766
  reads += 1;
4586
4767
  if (isTransparentUsage(node, context, compatibleComponentProps))
4587
4768
  continue;
4588
- const derivedDeclaration = derivedLocalDeclarationForRead(node);
4769
+ const derivedDeclaration = derivedLocalDeclarationForRead(node, context);
4589
4770
  const derivedNames = derivedDeclaration ? declarationNames2(derivedDeclaration) : [];
4590
4771
  if (derivedDeclaration && derivedNames.length === 1) {
4591
4772
  if (stateReadsAreBindingCompatible(derivedNames[0], owner, derivedDeclaration, context, compatibleComponentProps, new Set(visited)))
@@ -4595,12 +4776,46 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
4595
4776
  }
4596
4777
  return reads > 0;
4597
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
+ }
4598
4810
  function callbackSource(call, context) {
4599
4811
  const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
4600
- const highFrequency = isHighFrequencyRunServiceCall(call, context);
4812
+ const highFrequency = isHighFrequencyRobloxCall(call, context) || LIKELY_CONTINUOUS_CALLBACK.test(normalized);
4813
+ const hostFeedbackProperty = propertyChangedSignalProperty(call.text);
4601
4814
  const final = normalized.split(/[.:]/).at(-1) ?? normalized;
4602
- const external = highFrequency || EXTERNAL_CALLBACK.test(normalized) || EXTERNAL_CALLBACK.test(final);
4603
- 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);
4604
4819
  }
4605
4820
  function importedCallbackSource(call, callbackArgument, importedCallbacks, context) {
4606
4821
  const path4 = context.getCallPath(call) ?? "";
@@ -4611,12 +4826,14 @@ function importedCallbackSource(call, callbackArgument, importedCallbacks, conte
4611
4826
  const callbackIndex = args.findIndex((arg) => sameNode(arg, callbackArgument));
4612
4827
  if (callbackIndex < 0 || !summary.callbackParameterIndexes.includes(callbackIndex))
4613
4828
  return null;
4614
- return {
4615
- highFrequency: summary.highFrequency || args.some((arg) => isHighFrequencyRunServiceExpression(arg, context)),
4616
- external: true
4617
- };
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);
4618
4832
  }
4619
4833
  function directCallbackSource(node, importedCallbacks, context) {
4834
+ const reactChangeProperty = reactChangePropertyForCallback(node);
4835
+ if (reactChangeProperty)
4836
+ return evidenceForExternalSource(false, reactChangeProperty);
4620
4837
  const parent = node.parent;
4621
4838
  if (parent?.type !== "arguments" || parent.parent?.type !== "function_call")
4622
4839
  return null;
@@ -4624,14 +4841,17 @@ function directCallbackSource(node, importedCallbacks, context) {
4624
4841
  const imported = importedCallbackSource(call, node, importedCallbacks, context);
4625
4842
  if (imported)
4626
4843
  return imported;
4627
- const source = callbackSource(call, context);
4628
- return source.external ? source : null;
4844
+ return callbackSource(call, context);
4629
4845
  }
4630
4846
  function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
4631
4847
  if (!fn.name || !owner.body)
4632
4848
  return null;
4633
- let foundExternal = false;
4634
- let foundHighFrequency = false;
4849
+ const result = {
4850
+ highFrequency: false,
4851
+ external: false,
4852
+ hostFeedbackProperties: new Set,
4853
+ genericExternal: false
4854
+ };
4635
4855
  for (const call of context.walk(owner.body)) {
4636
4856
  if (call.type !== "function_call")
4637
4857
  continue;
@@ -4639,14 +4859,12 @@ function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
4639
4859
  const callbackArg = args.find((arg) => arg.type === "identifier" && arg.text === fn.name);
4640
4860
  if (!callbackArg)
4641
4861
  continue;
4642
- const imported = importedCallbackSource(call, callbackArg, importedCallbacks, context);
4643
- const source = imported ?? callbackSource(call, context);
4644
- if (!source.external)
4862
+ const source = importedCallbackSource(call, callbackArg, importedCallbacks, context) ?? callbackSource(call, context);
4863
+ if (!source)
4645
4864
  continue;
4646
- foundExternal = true;
4647
- foundHighFrequency ||= source.highFrequency;
4865
+ mergeSourceEvidence(result, source);
4648
4866
  }
4649
- return foundExternal ? { highFrequency: foundHighFrequency, external: true } : null;
4867
+ return result.external ? result : null;
4650
4868
  }
4651
4869
  function setterCallbackSource(call, binding, importedCallbacks, context) {
4652
4870
  const owner = binding.owner;
@@ -4660,25 +4878,178 @@ function setterCallbackSource(call, binding, importedCallbacks, context) {
4660
4878
  return direct;
4661
4879
  return namedFunctionCallbackSource(nearest, owner, importedCallbacks, context);
4662
4880
  }
4663
- 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) {
4664
5009
  const owner = binding.owner;
4665
5010
  if (!owner?.body)
4666
5011
  return null;
4667
- let highFrequency = false;
4668
- let external = false;
5012
+ const result = {
5013
+ highFrequency: false,
5014
+ external: false,
5015
+ hostFeedbackProperties: new Set,
5016
+ genericExternal: false,
5017
+ mirrorConfidence: "none"
5018
+ };
4669
5019
  for (const node of context.walk(owner.body)) {
4670
5020
  if (node.type !== "function_call")
4671
5021
  continue;
4672
5022
  const path4 = context.getCallPath(node);
4673
- 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));
4674
5029
  continue;
4675
- 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);
4676
5038
  if (!source)
4677
5039
  continue;
4678
- highFrequency ||= source.highFrequency;
4679
- external ||= source.external;
5040
+ mergeBindingUpdateEvidence(result, source, "strong");
4680
5041
  }
4681
- 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));
4682
5053
  }
4683
5054
  function returnedHookValues(binding, context) {
4684
5055
  const owner = binding.owner;
@@ -4756,14 +5127,8 @@ function callDefinitelyUsesBindingMode(summary, call, context) {
4756
5127
  return false;
4757
5128
  return truthy === summary.bindingWhenTruthy;
4758
5129
  }
4759
- function shouldRecommendImportedHook(summary, call, localName, owner, declaration, context, compatibleComponentProps) {
4760
- if (!summary.external || callDefinitelyUsesBindingMode(summary, call, context))
4761
- return false;
4762
- if (!hasAnyRead(localName, owner, declaration, context))
4763
- return false;
4764
- if (summary.sourceKind === "measurement")
4765
- return true;
4766
- return stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps);
5130
+ function hasExplicitBindingMode(summary) {
5131
+ return summary.bindingModeParameterIndex !== undefined && summary.bindingWhenTruthy !== undefined;
4767
5132
  }
4768
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;
4769
5134
  function createElementPropUsage(node, context) {
@@ -4792,7 +5157,7 @@ function createElementPropUsage(node, context) {
4792
5157
  }
4793
5158
  if (parent.type === "function_definition" || parent.type === "function_declaration")
4794
5159
  return null;
4795
- if (!TRANSPARENT_PARENTS.has(parent.type))
5160
+ if (!isTransparentParent(parent, context))
4796
5161
  return null;
4797
5162
  current = parent;
4798
5163
  }
@@ -4824,17 +5189,17 @@ function isEffectUsage(node, context) {
4824
5189
  }
4825
5190
  return false;
4826
5191
  }
4827
- function isStructuralUsage(node) {
5192
+ function isStructuralUsage(node, context) {
4828
5193
  let current = node;
4829
5194
  while (current) {
4830
5195
  const parent = current.parent;
4831
5196
  if (!parent)
4832
5197
  return false;
4833
- 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")
4834
5199
  return true;
4835
5200
  if (parent.type === "function_definition" || parent.type === "function_declaration")
4836
5201
  return false;
4837
- if (!TRANSPARENT_PARENTS.has(parent.type))
5202
+ if (!isTransparentParent(parent, context))
4838
5203
  return false;
4839
5204
  current = parent;
4840
5205
  }
@@ -4847,11 +5212,11 @@ function classifyBindingUsage(node, context, compatibleComponentProps) {
4847
5212
  return "custom-component";
4848
5213
  if (isEffectUsage(node, context))
4849
5214
  return "effect";
4850
- if (isStructuralUsage(node))
5215
+ if (isStructuralUsage(node, context))
4851
5216
  return "structural";
4852
5217
  return "unknown";
4853
5218
  }
4854
- function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps) {
5219
+ function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps, visited = new Set) {
4855
5220
  const summary = {
4856
5221
  reads: 0,
4857
5222
  compatible: 0,
@@ -4863,9 +5228,45 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
4863
5228
  };
4864
5229
  if (!owner.body)
4865
5230
  return summary;
5231
+ const visitKey = `${declaration.startIndex}:${valueName}`;
5232
+ if (visited.has(visitKey))
5233
+ return summary;
5234
+ visited.add(visitKey);
4866
5235
  for (const node of context.walk(owner.body)) {
4867
5236
  if (!isReadNode(node, valueName, owner, declaration))
4868
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
+ }
4869
5270
  summary.reads += 1;
4870
5271
  const customComponentUsage = createElementPropUsage(node, context);
4871
5272
  const kind = classifyBindingUsage(node, context, compatibleComponentProps);
@@ -4888,11 +5289,94 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
4888
5289
  function hasCandidatePresentationEvidence(summary) {
4889
5290
  return summary.compatible > 0 || summary.visualCustomComponent > 0;
4890
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
+ }
4891
5375
  var preferBindingOverState = {
4892
5376
  id: "react-luau/prefer-binding-over-state",
4893
5377
  category: "Performance",
4894
5378
  severity: "warning",
4895
- 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.",
4896
5380
  run(context) {
4897
5381
  const diagnostics = [];
4898
5382
  const seen = new Set;
@@ -4909,75 +5393,72 @@ var preferBindingOverState = {
4909
5393
  const owner = context.containingComponent(call) ?? context.nearestFunction(call);
4910
5394
  if (!declaration || !localName || !owner)
4911
5395
  continue;
4912
- if (!shouldRecommendImportedHook(summary, call, localName, owner, declaration, context, compatibleComponentProps))
5396
+ if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "warning")
4913
5397
  continue;
4914
5398
  const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
4915
5399
  if (seen.has(key))
4916
5400
  continue;
4917
5401
  seen.add(key);
4918
- const hasBindingMode = summary.bindingModeParameterIndex !== undefined && summary.bindingWhenTruthy !== undefined;
4919
- 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.`;
4920
- 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. ";
4921
- 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.";
4922
5408
  diagnostics.push({
4923
5409
  node: callNameNode(call),
4924
5410
  message,
4925
5411
  help,
4926
- fixPreview: hasBindingMode ? bindingModeFixPreview(summary, call, context) : undefined
4927
- });
4928
- }
4929
- for (const call of context.findCalls()) {
4930
- const path4 = context.getCallPath(call) ?? "";
4931
- const member = path4.split(/[.:]/).at(-1) ?? path4;
4932
- const bindingAlternative = context.project.bindingApiAlternatives.get(member);
4933
- if (!bindingAlternative)
4934
- continue;
4935
- const declaration = declarationForCall2(call);
4936
- const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
4937
- const owner = context.containingComponent(call) ?? context.nearestFunction(call);
4938
- if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
4939
- continue;
4940
- if (!stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps))
4941
- continue;
4942
- const field = selectorFieldName(call, context);
4943
- const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
4944
- if (seen.has(key))
4945
- continue;
4946
- seen.add(key);
4947
- diagnostics.push({
4948
- node: callNameNode(call),
4949
- 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.`,
4950
- help: `Use ${bindingAlternative} so the host property can update without rerendering the component.`,
4951
- fixPreview: callMemberFixPreview(call, bindingAlternative)
5412
+ fixPreview: hasBindingMode && allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? bindingModeFixPreview(summary, call, context) : undefined
4952
5413
  });
4953
5414
  }
4954
5415
  for (const binding of context.model.stateBindings) {
4955
5416
  const owner = binding.owner;
4956
5417
  if (!owner || !owner.isComponent && !owner.isHook)
4957
5418
  continue;
4958
- const updateKind = bindingHasExternalOrHighFrequencyUpdates(binding, importedCallbacks, context);
4959
- if (!updateKind)
5419
+ const update = bindingUpdateEvidence(binding, importedCallbacks, context);
5420
+ if (!update)
4960
5421
  continue;
4961
5422
  const localCompatible = stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
4962
5423
  const hookCompatible = owner.isHook ? hookCallSitesAreBindingCompatible(binding, context, compatibleComponentProps) : false;
4963
- 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)
4964
5428
  continue;
4965
5429
  const key = `${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
4966
5430
  if (seen.has(key))
4967
5431
  continue;
4968
5432
  seen.add(key);
4969
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(", ");
5442
+ diagnostics.push({
5443
+ node: callNameNode(binding.call),
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(", ");
4970
5450
  diagnostics.push({
4971
5451
  node: callNameNode(binding.call),
4972
- 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.`,
4973
- help: "Consider returning a Binding, or adding a Binding-returning variant of this hook, so consumers can update host properties without rerendering."
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
4974
5455
  });
4975
5456
  } else {
4976
5457
  diagnostics.push({
4977
5458
  node: callNameNode(binding.call),
4978
- 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.`,
4979
- help: "Prefer React.useBinding when the update is visual and does not need React to rebuild component structure.",
4980
- 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
4981
5462
  });
4982
5463
  }
4983
5464
  }
@@ -4998,27 +5479,49 @@ var preferBindingOverStateCandidate = {
4998
5479
  for (const call of context.findCalls()) {
4999
5480
  const path4 = context.getCallPath(call) ?? "";
5000
5481
  const summary = importedHooks.get(path4);
5001
- if (!summary || !summary.external || callDefinitelyUsesBindingMode(summary, call, context))
5482
+ if (!summary)
5002
5483
  continue;
5003
5484
  const declaration = declarationForCall2(call);
5004
5485
  const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
5005
5486
  const owner = context.containingComponent(call) ?? context.nearestFunction(call);
5006
- if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
5487
+ if (!declaration || !localName || !owner)
5007
5488
  continue;
5008
- if (shouldRecommendImportedHook(summary, call, localName, owner, declaration, context, compatibleComponentProps))
5489
+ if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "candidate")
5009
5490
  continue;
5010
5491
  const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
5011
- const hasPresentationEvidence = hasCandidatePresentationEvidence(usage) || summary.sourceKind === "derived-external-state" && usage.effect > 0;
5012
- if (!summary.highFrequency && !hasPresentationEvidence)
5013
- 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);
5014
5498
  const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
5015
5499
  if (seen.has(key))
5016
5500
  continue;
5017
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
+ }
5018
5520
  diagnostics.push({
5019
5521
  node: callNameNode(call),
5020
- 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.`,
5021
- 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
5022
5525
  });
5023
5526
  }
5024
5527
  for (const call of context.findCalls()) {
@@ -5032,39 +5535,80 @@ var preferBindingOverStateCandidate = {
5032
5535
  const owner = context.containingComponent(call) ?? context.nearestFunction(call);
5033
5536
  if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
5034
5537
  continue;
5035
- if (stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps))
5538
+ const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
5539
+ const presentationOnly = isPresentationOnly(usage);
5540
+ if (!presentationOnly)
5036
5541
  continue;
5542
+ const field = selectorFieldName(call, context);
5037
5543
  const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
5038
5544
  if (seen.has(key))
5039
5545
  continue;
5040
5546
  seen.add(key);
5041
5547
  diagnostics.push({
5042
5548
  node: callNameNode(call),
5043
- 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.`,
5044
- help: `Inspect whether ${bindingAlternative}, Binding:map, or splitting visual and structural consumers would avoid unnecessary rerenders.`,
5045
- 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
5046
5552
  });
5047
5553
  }
5048
5554
  for (const binding of context.model.stateBindings) {
5049
5555
  const owner = binding.owner;
5050
- if (!owner || !owner.isComponent || !owner.body)
5556
+ if (!owner || !owner.isComponent && !owner.isHook || !owner.body)
5051
5557
  continue;
5052
- const updateKind = bindingHasExternalOrHighFrequencyUpdates(binding, importedCallbacks, context);
5053
- if (!updateKind)
5558
+ const update = bindingUpdateEvidence(binding, importedCallbacks, context);
5559
+ if (!update)
5054
5560
  continue;
5055
- 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
+ });
5056
5581
  continue;
5582
+ }
5057
5583
  const usage = bindingUsageSummary(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
5058
5584
  if (!hasCandidatePresentationEvidence(usage))
5059
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;
5060
5604
  const key = `state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
5061
5605
  if (seen.has(key))
5062
5606
  continue;
5063
5607
  seen.add(key);
5064
5608
  diagnostics.push({
5065
5609
  node: callNameNode(binding.call),
5066
- 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.`,
5067
- 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."
5068
5612
  });
5069
5613
  }
5070
5614
  return diagnostics;
@@ -6423,6 +6967,16 @@ function nodeLocation(node) {
6423
6967
  endColumn: node.endPosition.column + 1
6424
6968
  };
6425
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
+ }
6426
6980
  function toDiagnostic(file, ruleId, category, severity, input) {
6427
6981
  const node = input.node;
6428
6982
  const highlights = input.highlights?.map(nodeLocation);
@@ -6480,11 +7034,12 @@ async function analyzeReactFile(input, tree) {
6480
7034
  if (rule.id === "react-luau/parse-error" && findings.length > 0)
6481
7035
  break;
6482
7036
  }
7037
+ const filteredDiagnostics = removeSupersededDiagnostics(diagnostics);
6483
7038
  return {
6484
7039
  relativePath: input.relativePath,
6485
7040
  isReactFile: model.isReactFile,
6486
7041
  scanned: true,
6487
- diagnostics
7042
+ diagnostics: filteredDiagnostics
6488
7043
  };
6489
7044
  }
6490
7045
 
@@ -7455,5 +8010,5 @@ parentPort.on("message", async (request) => {
7455
8010
  }
7456
8011
  });
7457
8012
 
7458
- //# debugId=CD648BE1625A701464756E2164756E21
8013
+ //# debugId=07C5601A473D8F7164756E2164756E21
7459
8014
  //# sourceMappingURL=scan-worker.js.map