@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.
package/dist/cli.js CHANGED
@@ -11,7 +11,7 @@ var package_default = {
11
11
  publishConfig: {
12
12
  access: "public"
13
13
  },
14
- version: "0.18.3",
14
+ version: "0.19.0",
15
15
  description: "Static analysis for React-Luau hooks, effects, rendering, and performance.",
16
16
  license: "MIT",
17
17
  type: "module",
@@ -22,6 +22,7 @@ var package_default = {
22
22
  "dist",
23
23
  "vendor",
24
24
  "README.md",
25
+ "CHANGELOG.md",
25
26
  "LICENSE",
26
27
  "THIRD_PARTY_NOTICES.md"
27
28
  ],
@@ -864,7 +865,7 @@ import crypto from "crypto";
864
865
  import fs4 from "fs";
865
866
  import os from "os";
866
867
  import path4 from "path";
867
- var CACHE_SCHEMA_VERSION = 1;
868
+ import { fileURLToPath } from "url";
868
869
  var MAX_CACHED_REPORTS = 8;
869
870
  function normalizeRelative(value) {
870
871
  return value.split(path4.sep).join("/");
@@ -872,6 +873,54 @@ function normalizeRelative(value) {
872
873
  function hashText(value) {
873
874
  return crypto.createHash("sha256").update(value).digest("hex");
874
875
  }
876
+ var analyzerHash = null;
877
+ function hashAnalyzerFile(hash, root, filename) {
878
+ if (!fs4.existsSync(filename))
879
+ return;
880
+ hash.update(normalizeRelative(path4.relative(root, filename)));
881
+ hash.update("\x00");
882
+ hash.update(fs4.readFileSync(filename));
883
+ hash.update("\x00");
884
+ }
885
+ function sourceFiles(directory) {
886
+ if (!fs4.existsSync(directory))
887
+ return [];
888
+ const files = [];
889
+ for (const entry of fs4.readdirSync(directory, { withFileTypes: true })) {
890
+ const filename = path4.join(directory, entry.name);
891
+ if (entry.isDirectory())
892
+ files.push(...sourceFiles(filename));
893
+ else if (entry.isFile() && entry.name.endsWith(".ts"))
894
+ files.push(filename);
895
+ }
896
+ return files;
897
+ }
898
+ function currentAnalyzerHash() {
899
+ if (analyzerHash)
900
+ return analyzerHash;
901
+ const hash = crypto.createHash("sha256");
902
+ let packageRoot = path4.resolve(path4.dirname(fileURLToPath(import.meta.url)), "..");
903
+ let bundledCli = null;
904
+ try {
905
+ const entrypoint = process.argv[1] ? fs4.realpathSync(process.argv[1]) : null;
906
+ if (entrypoint && path4.basename(entrypoint) === "cli.js" && path4.basename(path4.dirname(entrypoint)) === "dist") {
907
+ bundledCli = entrypoint;
908
+ packageRoot = path4.dirname(path4.dirname(entrypoint));
909
+ }
910
+ } catch {}
911
+ if (bundledCli) {
912
+ hashAnalyzerFile(hash, packageRoot, bundledCli);
913
+ hashAnalyzerFile(hash, packageRoot, path4.join(path4.dirname(bundledCli), "scan-worker.js"));
914
+ } else {
915
+ for (const filename of sourceFiles(path4.join(packageRoot, "src")).sort()) {
916
+ hashAnalyzerFile(hash, packageRoot, filename);
917
+ }
918
+ }
919
+ hashAnalyzerFile(hash, packageRoot, path4.join(packageRoot, "package.json"));
920
+ hashAnalyzerFile(hash, packageRoot, path4.join(packageRoot, "vendor", "tree-sitter-luau.wasm"));
921
+ analyzerHash = hash.digest("hex");
922
+ return analyzerHash;
923
+ }
875
924
  function stableValue(value) {
876
925
  if (Array.isArray(value))
877
926
  return value.map(stableValue);
@@ -901,7 +950,7 @@ function projectCacheFilename(root) {
901
950
  function readPayload(filename, root) {
902
951
  try {
903
952
  const parsed = JSON.parse(fs4.readFileSync(filename, "utf8"));
904
- if (parsed.schemaVersion !== CACHE_SCHEMA_VERSION || parsed.analyzerVersion !== package_default.version || path4.resolve(parsed.root) !== path4.resolve(root) || !parsed.files || typeof parsed.fingerprint !== "string")
953
+ if (parsed.analyzerHash !== currentAnalyzerHash() || path4.resolve(parsed.root) !== path4.resolve(root) || !parsed.files || typeof parsed.fingerprint !== "string")
905
954
  return null;
906
955
  return parsed;
907
956
  } catch {
@@ -1121,8 +1170,7 @@ function saveProjectCache(session, project, effectModules = session.previous?.ef
1121
1170
  reports[reportKey] = { ...report, lastUsedAt: Date.now() };
1122
1171
  const prunedReports = Object.fromEntries(Object.entries(reports).sort(([, left], [, right]) => right.lastUsedAt - left.lastUsedAt).slice(0, MAX_CACHED_REPORTS));
1123
1172
  writePayload(session.filename, {
1124
- schemaVersion: CACHE_SCHEMA_VERSION,
1125
- analyzerVersion: package_default.version,
1173
+ analyzerHash: currentAnalyzerHash(),
1126
1174
  root: path4.resolve(session.root),
1127
1175
  fingerprint: session.fingerprint,
1128
1176
  files: session.files,
@@ -2465,6 +2513,8 @@ function hasRobloxReceiver(path6, call, context) {
2465
2513
  }
2466
2514
  var runServiceCallCache = new WeakMap;
2467
2515
  var runServiceExpressionCache = new WeakMap;
2516
+ var highFrequencyRobloxCallCache = new WeakMap;
2517
+ var highFrequencyRobloxExpressionCache = new WeakMap;
2468
2518
  function cachedNodeBoolean(cache, context, node, compute) {
2469
2519
  let entries = cache.get(context);
2470
2520
  if (!entries) {
@@ -2529,6 +2579,33 @@ function isHighFrequencyRunServiceCall(call, context) {
2529
2579
  return Boolean(bind && rootIsRunService(bind[1], call, context));
2530
2580
  });
2531
2581
  }
2582
+ var HIGH_FREQUENCY_ROBLOX_EVENTS = new Set([
2583
+ "InputChanged",
2584
+ "TouchMoved",
2585
+ "Move"
2586
+ ]);
2587
+ function isHighFrequencyRobloxExpression(node, context) {
2588
+ return cachedNodeBoolean(highFrequencyRobloxExpressionCache, context, node, () => {
2589
+ if (isHighFrequencyRunServiceExpression(node, context))
2590
+ return true;
2591
+ const normalized = node.text.replace(/\s+/g, "");
2592
+ const match = normalized.match(/^(.+)\.(InputChanged|TouchMoved|Move)$/);
2593
+ if (!match || !HIGH_FREQUENCY_ROBLOX_EVENTS.has(match[2]))
2594
+ return false;
2595
+ return hasRobloxReceiver(match[1], node, context);
2596
+ });
2597
+ }
2598
+ function isHighFrequencyRobloxCall(call, context) {
2599
+ return cachedNodeBoolean(highFrequencyRobloxCallCache, context, call, () => {
2600
+ if (isHighFrequencyRunServiceCall(call, context))
2601
+ return true;
2602
+ const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
2603
+ const event = normalized.match(/^(.+)\.(InputChanged|TouchMoved|Move):Connect$/);
2604
+ if (!event || !HIGH_FREQUENCY_ROBLOX_EVENTS.has(event[2]))
2605
+ return false;
2606
+ return hasRobloxReceiver(event[1], call, context);
2607
+ });
2608
+ }
2532
2609
  function sourceHasHighFrequencyRunService(source) {
2533
2610
  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)) {
2534
2611
  return true;
@@ -2545,6 +2622,36 @@ function sourceHasHighFrequencyRunService(source) {
2545
2622
  }
2546
2623
  return false;
2547
2624
  }
2625
+ function sourceHasHighFrequencyRobloxEvent(source) {
2626
+ if (sourceHasHighFrequencyRunService(source))
2627
+ return true;
2628
+ if (/game\s*:\s*GetService\s*\(\s*["']UserInputService["']\s*\)\s*\.\s*(?:InputChanged|TouchMoved)\s*:\s*Connect/.test(source)) {
2629
+ return true;
2630
+ }
2631
+ const serviceAliases = new Set;
2632
+ 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)) {
2633
+ serviceAliases.add(match[1]);
2634
+ }
2635
+ for (const alias of serviceAliases) {
2636
+ const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2637
+ if (new RegExp(`\\b${escaped}\\s*\\.\\s*(?:InputChanged|TouchMoved)\\s*:\\s*Connect`).test(source)) {
2638
+ return true;
2639
+ }
2640
+ }
2641
+ const playersAliases = new Set;
2642
+ 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)) {
2643
+ playersAliases.add(match[1]);
2644
+ }
2645
+ for (const players of playersAliases) {
2646
+ const escapedPlayers = players.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2647
+ 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"))) {
2648
+ const mouse = match[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2649
+ if (new RegExp(`\\b${mouse}\\s*\\.\\s*Move\\s*:\\s*Connect`).test(source))
2650
+ return true;
2651
+ }
2652
+ }
2653
+ return false;
2654
+ }
2548
2655
  function knownYieldReason(path6, call, context) {
2549
2656
  const normalized = path6.replace(/\s+/g, "");
2550
2657
  if (normalized === "task.wait")
@@ -5091,7 +5198,7 @@ var rerenderHighFrequencyState = {
5091
5198
  seen.add(key);
5092
5199
  const renderCost = componentRenderCost(context, component);
5093
5200
  diagnostics.push({
5094
- node: callNameNode(call),
5201
+ node: callNameNode(binding.call),
5095
5202
  severity: "warning",
5096
5203
  message: `${binding.valueName} has a state setter called from a per-frame callback and may cause frequent rerenders when its value changes.`,
5097
5204
  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."
@@ -5368,7 +5475,9 @@ var preferUseRefForMutableCell = {
5368
5475
  };
5369
5476
 
5370
5477
  // src/rules/prefer-binding-over-state.ts
5371
- var EXTERNAL_CALLBACK = /(?:^|[.:])(?:Connect|Once|Subscribe|Observe|Listen|Watch)$/i;
5478
+ var EXTERNAL_CALLBACK = /(?:^|[.:])(?:Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)$/i;
5479
+ var PERSISTENT_MIRROR_CALLBACK = /(?:^|[.:])(?:Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)$/i;
5480
+ var LIKELY_CONTINUOUS_CALLBACK = /(?:^|[.:])onStep$/i;
5372
5481
  var NON_BINDABLE_HOST_FIELDS = new Set(["ref", "key", "children"]);
5373
5482
  function replaceWithinNode(container, target, replacement) {
5374
5483
  const start = target.startIndex - container.startIndex;
@@ -5491,13 +5600,64 @@ var TRANSPARENT_PARENTS = new Set([
5491
5600
  "string_interpolation",
5492
5601
  "expression_list",
5493
5602
  "assignment_statement",
5494
- "arguments",
5495
- "function_call",
5496
5603
  "dot_index_expression",
5497
5604
  "method_index_expression",
5498
5605
  "table_constructor",
5499
5606
  "field"
5500
5607
  ]);
5608
+ 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_]*)$/;
5609
+ var HOST_FEEDBACK_PROPERTIES = new Set([
5610
+ "AbsoluteSize",
5611
+ "AbsolutePosition",
5612
+ "AbsoluteRotation",
5613
+ "AbsoluteContentSize",
5614
+ "AbsoluteCanvasSize",
5615
+ "AbsoluteCellSize",
5616
+ "AbsoluteCellCount",
5617
+ "TextBounds",
5618
+ "CanvasPosition"
5619
+ ]);
5620
+ var LIKELY_CONTINUOUS_PROPERTIES = new Set([
5621
+ "CFrame",
5622
+ "WorldCFrame",
5623
+ "Position",
5624
+ "WorldPosition",
5625
+ "Rotation",
5626
+ "Orientation",
5627
+ "CanvasPosition",
5628
+ "PlaybackLoudness",
5629
+ "TimePosition"
5630
+ ]);
5631
+ var SEMANTIC_SNAPSHOT_EXPRESSION = /\b(?:getState|getSnapshot|snapshot|selector|select|table\s*\.\s*(?:clone|freeze|move))\b|(?:\.|\[\s*["'])State(?:\b|["']\s*\])/i;
5632
+ function isBindingMapSafeCall(call, context) {
5633
+ if (call.type !== "function_call")
5634
+ return false;
5635
+ const path7 = context.resolveCallPath(context.getCallPath(call) ?? "").replace(/\s+/g, "");
5636
+ return PURE_BINDING_DERIVATION_CALL.test(path7);
5637
+ }
5638
+ function isTransparentParent(parent, context) {
5639
+ if (parent.type === "function_call")
5640
+ return isBindingMapSafeCall(parent, context);
5641
+ if (parent.type === "arguments") {
5642
+ return parent.parent?.type === "function_call" && isBindingMapSafeCall(parent.parent, context);
5643
+ }
5644
+ return TRANSPARENT_PARENTS.has(parent.type);
5645
+ }
5646
+ function stringLiteralValue(node) {
5647
+ if (!node)
5648
+ return null;
5649
+ return node.text.trim().match(/^["']([^"']+)["']$/)?.[1] ?? null;
5650
+ }
5651
+ function observedPropertyForCall(summary, call, context) {
5652
+ if (summary.observedPropertyName)
5653
+ return summary.observedPropertyName;
5654
+ if (summary.observedPropertyParameterIndex === undefined)
5655
+ return null;
5656
+ return stringLiteralValue(context.callArguments(call)[summary.observedPropertyParameterIndex]);
5657
+ }
5658
+ function isStrongHostFeedbackProperty(property) {
5659
+ return Boolean(property && HOST_FEEDBACK_PROPERTIES.has(property));
5660
+ }
5501
5661
  function isInside2(node, ancestor) {
5502
5662
  let current = node;
5503
5663
  while (current) {
@@ -5581,13 +5741,75 @@ function isTransparentUsage(node, context, compatibleComponentProps) {
5581
5741
  }
5582
5742
  if (parent.type === "function_definition" || parent.type === "function_declaration")
5583
5743
  return false;
5584
- if (!TRANSPARENT_PARENTS.has(parent.type))
5744
+ if (!isTransparentParent(parent, context))
5585
5745
  return false;
5586
5746
  current = parent;
5587
5747
  }
5588
5748
  return false;
5589
5749
  }
5590
- function derivedLocalDeclarationForRead(node) {
5750
+ function isDirectBindingValueUsage(node, context, compatibleComponentProps) {
5751
+ let current = node;
5752
+ while (current) {
5753
+ const parent = current.parent;
5754
+ if (!parent)
5755
+ return false;
5756
+ if (parent.type === "parenthesized_expression" || parent.type === "cast_expression" || parent.type === "type_cast_expression") {
5757
+ current = parent;
5758
+ continue;
5759
+ }
5760
+ if (parent.type !== "field")
5761
+ return false;
5762
+ const value = fieldValue(parent);
5763
+ if (!value || !sameNode(value, current))
5764
+ return false;
5765
+ const property = fieldName(parent);
5766
+ if (!property || NON_BINDABLE_HOST_FIELDS.has(property))
5767
+ return false;
5768
+ const table = parent.parent;
5769
+ const args = table?.parent;
5770
+ const call = args?.parent;
5771
+ if (table?.type !== "table_constructor" || args?.type !== "arguments" || !call || !isCreateElementCall2(call, context)) {
5772
+ return false;
5773
+ }
5774
+ if (!sameNode(context.callArguments(call)[1], table))
5775
+ return false;
5776
+ if (isHostCreateElementCall(call, context))
5777
+ return true;
5778
+ const component = context.callArguments(call)[0]?.text.trim() ?? "";
5779
+ return compatibleComponentProps.get(component)?.has(property) ?? false;
5780
+ }
5781
+ return false;
5782
+ }
5783
+ function allReadsDirectlyBindingSubstitutable(valueName, owner, declaration, context, compatibleComponentProps) {
5784
+ if (!owner.body)
5785
+ return false;
5786
+ let reads = 0;
5787
+ for (const node of context.walk(owner.body)) {
5788
+ if (!isReadNode(node, valueName, owner, declaration))
5789
+ continue;
5790
+ reads += 1;
5791
+ if (!isDirectBindingValueUsage(node, context, compatibleComponentProps))
5792
+ return false;
5793
+ }
5794
+ return reads > 0;
5795
+ }
5796
+ function derivedLocalDeclarationForRead(node, context) {
5797
+ let current = node;
5798
+ while (current) {
5799
+ const parent = current.parent;
5800
+ if (!parent)
5801
+ return null;
5802
+ if (parent.type === "variable_declaration")
5803
+ return parent;
5804
+ if (parent.type === "function_definition" || parent.type === "function_declaration")
5805
+ return null;
5806
+ if (!isTransparentParent(parent, context))
5807
+ return null;
5808
+ current = parent;
5809
+ }
5810
+ return null;
5811
+ }
5812
+ function opaqueDerivedLocalDeclarationForRead(node, context) {
5591
5813
  let current = node;
5592
5814
  while (current) {
5593
5815
  const parent = current.parent;
@@ -5597,7 +5819,14 @@ function derivedLocalDeclarationForRead(node) {
5597
5819
  return parent;
5598
5820
  if (parent.type === "function_definition" || parent.type === "function_declaration")
5599
5821
  return null;
5600
- if (!TRANSPARENT_PARENTS.has(parent.type))
5822
+ 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")
5823
+ return null;
5824
+ if (parent.type === "function_call") {
5825
+ const path7 = context.resolveCallPath(context.getCallPath(parent) ?? "");
5826
+ if (/^React\.use(?:Memo|Callback|Effect|LayoutEffect)$/.test(path7))
5827
+ return null;
5828
+ }
5829
+ if (!TRANSPARENT_PARENTS.has(parent.type) && parent.type !== "arguments" && parent.type !== "function_call")
5601
5830
  return null;
5602
5831
  current = parent;
5603
5832
  }
@@ -5617,7 +5846,7 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
5617
5846
  reads += 1;
5618
5847
  if (isTransparentUsage(node, context, compatibleComponentProps))
5619
5848
  continue;
5620
- const derivedDeclaration = derivedLocalDeclarationForRead(node);
5849
+ const derivedDeclaration = derivedLocalDeclarationForRead(node, context);
5621
5850
  const derivedNames = derivedDeclaration ? declarationNames2(derivedDeclaration) : [];
5622
5851
  if (derivedDeclaration && derivedNames.length === 1) {
5623
5852
  if (stateReadsAreBindingCompatible(derivedNames[0], owner, derivedDeclaration, context, compatibleComponentProps, new Set(visited)))
@@ -5627,12 +5856,46 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
5627
5856
  }
5628
5857
  return reads > 0;
5629
5858
  }
5859
+ function propertyChangedSignalProperty(text) {
5860
+ return text.match(/GetPropertyChangedSignal\s*\(\s*["']([^"']+)["']\s*\)/)?.[1] ?? null;
5861
+ }
5862
+ function reactChangePropertyForCallback(node) {
5863
+ let current = node.parent;
5864
+ while (current && current.type !== "function_definition" && current.type !== "function_declaration") {
5865
+ if (current.type === "field") {
5866
+ return current.text.match(/React\s*\.\s*Change\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)/)?.[1] ?? null;
5867
+ }
5868
+ current = current.parent;
5869
+ }
5870
+ return null;
5871
+ }
5872
+ function evidenceForExternalSource(highFrequency, hostFeedbackProperty) {
5873
+ const hostFeedbackProperties = new Set;
5874
+ if (hostFeedbackProperty)
5875
+ hostFeedbackProperties.add(hostFeedbackProperty);
5876
+ return {
5877
+ highFrequency,
5878
+ external: true,
5879
+ hostFeedbackProperties,
5880
+ genericExternal: !highFrequency && !hostFeedbackProperty
5881
+ };
5882
+ }
5883
+ function mergeSourceEvidence(target, source) {
5884
+ target.highFrequency ||= source.highFrequency;
5885
+ target.external ||= source.external;
5886
+ target.genericExternal ||= source.genericExternal;
5887
+ for (const property of source.hostFeedbackProperties)
5888
+ target.hostFeedbackProperties.add(property);
5889
+ }
5630
5890
  function callbackSource(call, context) {
5631
5891
  const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
5632
- const highFrequency = isHighFrequencyRunServiceCall(call, context);
5892
+ const highFrequency = isHighFrequencyRobloxCall(call, context) || LIKELY_CONTINUOUS_CALLBACK.test(normalized);
5893
+ const hostFeedbackProperty = propertyChangedSignalProperty(call.text);
5633
5894
  const final = normalized.split(/[.:]/).at(-1) ?? normalized;
5634
- const external = highFrequency || EXTERNAL_CALLBACK.test(normalized) || EXTERNAL_CALLBACK.test(final);
5635
- return { highFrequency, external };
5895
+ const external = highFrequency || Boolean(hostFeedbackProperty) || EXTERNAL_CALLBACK.test(normalized) || EXTERNAL_CALLBACK.test(final);
5896
+ if (!external)
5897
+ return null;
5898
+ return evidenceForExternalSource(highFrequency, hostFeedbackProperty);
5636
5899
  }
5637
5900
  function importedCallbackSource(call, callbackArgument, importedCallbacks, context) {
5638
5901
  const path7 = context.getCallPath(call) ?? "";
@@ -5643,12 +5906,14 @@ function importedCallbackSource(call, callbackArgument, importedCallbacks, conte
5643
5906
  const callbackIndex = args.findIndex((arg) => sameNode(arg, callbackArgument));
5644
5907
  if (callbackIndex < 0 || !summary.callbackParameterIndexes.includes(callbackIndex))
5645
5908
  return null;
5646
- return {
5647
- highFrequency: summary.highFrequency || args.some((arg) => isHighFrequencyRunServiceExpression(arg, context)),
5648
- external: true
5649
- };
5909
+ const highFrequency = summary.highFrequency || args.some((arg) => isHighFrequencyRobloxExpression(arg, context));
5910
+ const hostFeedbackProperty = args.map((arg) => propertyChangedSignalProperty(arg.text)).find((property) => Boolean(property));
5911
+ return evidenceForExternalSource(highFrequency, hostFeedbackProperty);
5650
5912
  }
5651
5913
  function directCallbackSource(node, importedCallbacks, context) {
5914
+ const reactChangeProperty = reactChangePropertyForCallback(node);
5915
+ if (reactChangeProperty)
5916
+ return evidenceForExternalSource(false, reactChangeProperty);
5652
5917
  const parent = node.parent;
5653
5918
  if (parent?.type !== "arguments" || parent.parent?.type !== "function_call")
5654
5919
  return null;
@@ -5656,14 +5921,17 @@ function directCallbackSource(node, importedCallbacks, context) {
5656
5921
  const imported = importedCallbackSource(call, node, importedCallbacks, context);
5657
5922
  if (imported)
5658
5923
  return imported;
5659
- const source = callbackSource(call, context);
5660
- return source.external ? source : null;
5924
+ return callbackSource(call, context);
5661
5925
  }
5662
5926
  function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
5663
5927
  if (!fn.name || !owner.body)
5664
5928
  return null;
5665
- let foundExternal = false;
5666
- let foundHighFrequency = false;
5929
+ const result = {
5930
+ highFrequency: false,
5931
+ external: false,
5932
+ hostFeedbackProperties: new Set,
5933
+ genericExternal: false
5934
+ };
5667
5935
  for (const call of context.walk(owner.body)) {
5668
5936
  if (call.type !== "function_call")
5669
5937
  continue;
@@ -5671,14 +5939,12 @@ function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
5671
5939
  const callbackArg = args.find((arg) => arg.type === "identifier" && arg.text === fn.name);
5672
5940
  if (!callbackArg)
5673
5941
  continue;
5674
- const imported = importedCallbackSource(call, callbackArg, importedCallbacks, context);
5675
- const source = imported ?? callbackSource(call, context);
5676
- if (!source.external)
5942
+ const source = importedCallbackSource(call, callbackArg, importedCallbacks, context) ?? callbackSource(call, context);
5943
+ if (!source)
5677
5944
  continue;
5678
- foundExternal = true;
5679
- foundHighFrequency ||= source.highFrequency;
5945
+ mergeSourceEvidence(result, source);
5680
5946
  }
5681
- return foundExternal ? { highFrequency: foundHighFrequency, external: true } : null;
5947
+ return result.external ? result : null;
5682
5948
  }
5683
5949
  function setterCallbackSource(call, binding, importedCallbacks, context) {
5684
5950
  const owner = binding.owner;
@@ -5692,25 +5958,178 @@ function setterCallbackSource(call, binding, importedCallbacks, context) {
5692
5958
  return direct;
5693
5959
  return namedFunctionCallbackSource(nearest, owner, importedCallbacks, context);
5694
5960
  }
5695
- function bindingHasExternalOrHighFrequencyUpdates(binding, importedCallbacks, context) {
5961
+ function mirrorConfidenceRank(confidence) {
5962
+ if (confidence === "strong")
5963
+ return 2;
5964
+ if (confidence === "possible")
5965
+ return 1;
5966
+ return 0;
5967
+ }
5968
+ function mergeMirrorConfidence(a, b) {
5969
+ return mirrorConfidenceRank(b) > mirrorConfidenceRank(a) ? b : a;
5970
+ }
5971
+ function isLiteralStateTransition(node) {
5972
+ if (!node)
5973
+ return true;
5974
+ const text = node.text.trim();
5975
+ return /^(?:true|false|nil|[-+]?\d+(?:\.\d+)?|["'][^"']*["'])$/.test(text);
5976
+ }
5977
+ function isSemanticSnapshotExpression(node) {
5978
+ return Boolean(node && SEMANTIC_SNAPSHOT_EXPRESSION.test(node.text));
5979
+ }
5980
+ function expressionReferencesCallbackParameter(expression, callback, context) {
5981
+ if (callback.parameters.length === 0)
5982
+ return false;
5983
+ const parameters = new Set(callback.parameters);
5984
+ return [...context.walk(expression)].some((node) => node.type === "identifier" && parameters.has(node.text));
5985
+ }
5986
+ var PURE_EXPRESSION_ROOTS = new Set([
5987
+ "math",
5988
+ "string",
5989
+ "utf8",
5990
+ "bit32",
5991
+ "UDim",
5992
+ "UDim2",
5993
+ "Vector2",
5994
+ "Vector3",
5995
+ "Color3",
5996
+ "CFrame",
5997
+ "Rect",
5998
+ "NumberRange",
5999
+ "NumberSequence",
6000
+ "ColorSequence",
6001
+ "BrickColor",
6002
+ "Font",
6003
+ "Enum",
6004
+ "tostring",
6005
+ "tonumber",
6006
+ "type",
6007
+ "typeof"
6008
+ ]);
6009
+ function callbackLocalNamesBefore(callback, before, context) {
6010
+ const names = new Set;
6011
+ if (!callback.body)
6012
+ return names;
6013
+ for (const node of context.walk(callback.body)) {
6014
+ if (node.startIndex >= before.startIndex)
6015
+ continue;
6016
+ if (context.nearestFunction(node) !== callback || node.type !== "variable_declaration")
6017
+ continue;
6018
+ for (const name of declarationNames2(node))
6019
+ names.add(name);
6020
+ }
6021
+ return names;
6022
+ }
6023
+ function expressionHasExternalInput(expression, callback, setterCall, context) {
6024
+ const callbackLocals = callbackLocalNamesBefore(callback, setterCall, context);
6025
+ const callbackParameters = new Set(callback.parameters);
6026
+ for (const node of context.walk(expression)) {
6027
+ if (node.type !== "identifier" || isIdentifierPropertyName2(node))
6028
+ continue;
6029
+ const name = node.text;
6030
+ if (callbackParameters.has(name))
6031
+ return true;
6032
+ if (callbackLocals.has(name))
6033
+ continue;
6034
+ if (context.model.stateValues.has(name))
6035
+ continue;
6036
+ if (PURE_EXPRESSION_ROOTS.has(name))
6037
+ continue;
6038
+ return true;
6039
+ }
6040
+ return false;
6041
+ }
6042
+ function setterHasDistinctValuesInCallback(call, callback, context) {
6043
+ if (!callback.body)
6044
+ return false;
6045
+ const setterPath = context.getCallPath(call);
6046
+ const distinctValues = new Set;
6047
+ for (const sibling of context.walk(callback.body)) {
6048
+ if (sibling.type !== "function_call" || context.nearestFunction(sibling) !== callback)
6049
+ continue;
6050
+ if (context.getCallPath(sibling) !== setterPath)
6051
+ continue;
6052
+ const argument = context.callArguments(sibling)[0];
6053
+ if (argument)
6054
+ distinctValues.add(argument.text.trim());
6055
+ }
6056
+ return distinctValues.size > 1;
6057
+ }
6058
+ function setterCallMirrorConfidence(call, callback, source, context) {
6059
+ const value = context.callArguments(call)[0];
6060
+ if (!value || value.type === "function_definition")
6061
+ return "none";
6062
+ if (isLiteralStateTransition(value)) {
6063
+ return source.highFrequency && setterHasDistinctValuesInCallback(call, callback, context) ? "strong" : "none";
6064
+ }
6065
+ if (isSemanticSnapshotExpression(value))
6066
+ return "none";
6067
+ if (expressionReferencesCallbackParameter(value, callback, context))
6068
+ return "strong";
6069
+ if (source.hostFeedbackProperties.size > 0) {
6070
+ const text = value.text;
6071
+ if ([...source.hostFeedbackProperties].some((property) => new RegExp(`(?:\\.|["'])${property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(text))) {
6072
+ return "strong";
6073
+ }
6074
+ }
6075
+ if (!expressionHasExternalInput(value, callback, call, context)) {
6076
+ return source.highFrequency && setterHasDistinctValuesInCallback(call, callback, context) ? "strong" : "none";
6077
+ }
6078
+ if (source.highFrequency)
6079
+ return "strong";
6080
+ return source.genericExternal ? "possible" : "strong";
6081
+ }
6082
+ function mergeBindingUpdateEvidence(target, source, mirrorConfidence) {
6083
+ if (mirrorConfidence === "none")
6084
+ return;
6085
+ mergeSourceEvidence(target, source);
6086
+ target.mirrorConfidence = mergeMirrorConfidence(target.mirrorConfidence, mirrorConfidence);
6087
+ }
6088
+ function bindingUpdateEvidence(binding, importedCallbacks, context) {
5696
6089
  const owner = binding.owner;
5697
6090
  if (!owner?.body)
5698
6091
  return null;
5699
- let highFrequency = false;
5700
- let external = false;
6092
+ const result = {
6093
+ highFrequency: false,
6094
+ external: false,
6095
+ hostFeedbackProperties: new Set,
6096
+ genericExternal: false,
6097
+ mirrorConfidence: "none"
6098
+ };
5701
6099
  for (const node of context.walk(owner.body)) {
5702
6100
  if (node.type !== "function_call")
5703
6101
  continue;
5704
6102
  const path7 = context.getCallPath(node);
5705
- if (path7 !== binding.setterName)
6103
+ if (path7 === binding.setterName) {
6104
+ const source2 = setterCallbackSource(node, binding, importedCallbacks, context);
6105
+ const callback = context.nearestFunction(node);
6106
+ if (!source2 || !callback || callback === owner)
6107
+ continue;
6108
+ mergeBindingUpdateEvidence(result, source2, setterCallMirrorConfidence(node, callback, source2, context));
5706
6109
  continue;
5707
- const source = setterCallbackSource(node, binding, importedCallbacks, context);
6110
+ }
6111
+ const args = context.callArguments(node);
6112
+ const setterArg = args.find((arg) => arg.type === "identifier" && arg.text === binding.setterName);
6113
+ if (!setterArg)
6114
+ continue;
6115
+ const normalized = (context.getCallPath(node) ?? "").replace(/\s+/g, "");
6116
+ const imported = importedCallbackSource(node, setterArg, importedCallbacks, context);
6117
+ const source = imported ?? (PERSISTENT_MIRROR_CALLBACK.test(normalized) ? callbackSource(node, context) : null);
5708
6118
  if (!source)
5709
6119
  continue;
5710
- highFrequency ||= source.highFrequency;
5711
- external ||= source.external;
6120
+ mergeBindingUpdateEvidence(result, source, "strong");
5712
6121
  }
5713
- return highFrequency || external ? { highFrequency, external } : null;
6122
+ return result.external && result.mirrorConfidence !== "none" ? result : null;
6123
+ }
6124
+ function hasStrongHostFeedbackEvidence(source) {
6125
+ if (source.genericExternal || source.hostFeedbackProperties.size === 0)
6126
+ return false;
6127
+ return [...source.hostFeedbackProperties].every((property) => isStrongHostFeedbackProperty(property));
6128
+ }
6129
+ function hasLikelyContinuousPropertyEvidence(source) {
6130
+ if (source.hostFeedbackProperties.size === 0)
6131
+ return false;
6132
+ return [...source.hostFeedbackProperties].every((property) => LIKELY_CONTINUOUS_PROPERTIES.has(property));
5714
6133
  }
5715
6134
  function returnedHookValues(binding, context) {
5716
6135
  const owner = binding.owner;
@@ -5788,14 +6207,8 @@ function callDefinitelyUsesBindingMode(summary, call, context) {
5788
6207
  return false;
5789
6208
  return truthy === summary.bindingWhenTruthy;
5790
6209
  }
5791
- function shouldRecommendImportedHook(summary, call, localName, owner, declaration, context, compatibleComponentProps) {
5792
- if (!summary.external || callDefinitelyUsesBindingMode(summary, call, context))
5793
- return false;
5794
- if (!hasAnyRead(localName, owner, declaration, context))
5795
- return false;
5796
- if (summary.sourceKind === "measurement")
5797
- return true;
5798
- return stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps);
6210
+ function hasExplicitBindingMode(summary) {
6211
+ return summary.bindingModeParameterIndex !== undefined && summary.bindingWhenTruthy !== undefined;
5799
6212
  }
5800
6213
  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;
5801
6214
  function createElementPropUsage(node, context) {
@@ -5824,7 +6237,7 @@ function createElementPropUsage(node, context) {
5824
6237
  }
5825
6238
  if (parent.type === "function_definition" || parent.type === "function_declaration")
5826
6239
  return null;
5827
- if (!TRANSPARENT_PARENTS.has(parent.type))
6240
+ if (!isTransparentParent(parent, context))
5828
6241
  return null;
5829
6242
  current = parent;
5830
6243
  }
@@ -5856,17 +6269,17 @@ function isEffectUsage(node, context) {
5856
6269
  }
5857
6270
  return false;
5858
6271
  }
5859
- function isStructuralUsage(node) {
6272
+ function isStructuralUsage(node, context) {
5860
6273
  let current = node;
5861
6274
  while (current) {
5862
6275
  const parent = current.parent;
5863
6276
  if (!parent)
5864
6277
  return false;
5865
- if (parent.type === "if_statement" || parent.type === "elseif_clause" || parent.type === "while_statement" || parent.type === "repeat_statement" || parent.type === "return_statement")
6278
+ 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")
5866
6279
  return true;
5867
6280
  if (parent.type === "function_definition" || parent.type === "function_declaration")
5868
6281
  return false;
5869
- if (!TRANSPARENT_PARENTS.has(parent.type))
6282
+ if (!isTransparentParent(parent, context))
5870
6283
  return false;
5871
6284
  current = parent;
5872
6285
  }
@@ -5879,11 +6292,11 @@ function classifyBindingUsage(node, context, compatibleComponentProps) {
5879
6292
  return "custom-component";
5880
6293
  if (isEffectUsage(node, context))
5881
6294
  return "effect";
5882
- if (isStructuralUsage(node))
6295
+ if (isStructuralUsage(node, context))
5883
6296
  return "structural";
5884
6297
  return "unknown";
5885
6298
  }
5886
- function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps) {
6299
+ function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps, visited = new Set) {
5887
6300
  const summary = {
5888
6301
  reads: 0,
5889
6302
  compatible: 0,
@@ -5895,9 +6308,45 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
5895
6308
  };
5896
6309
  if (!owner.body)
5897
6310
  return summary;
6311
+ const visitKey = `${declaration.startIndex}:${valueName}`;
6312
+ if (visited.has(visitKey))
6313
+ return summary;
6314
+ visited.add(visitKey);
5898
6315
  for (const node of context.walk(owner.body)) {
5899
6316
  if (!isReadNode(node, valueName, owner, declaration))
5900
6317
  continue;
6318
+ if (!isTransparentUsage(node, context, compatibleComponentProps)) {
6319
+ const derivedDeclaration = derivedLocalDeclarationForRead(node, context);
6320
+ const derivedNames = derivedDeclaration ? declarationNames2(derivedDeclaration) : [];
6321
+ if (derivedDeclaration && derivedNames.length === 1) {
6322
+ const derived = bindingUsageSummary(derivedNames[0], owner, derivedDeclaration, context, compatibleComponentProps, new Set(visited));
6323
+ if (derived.reads > 0) {
6324
+ summary.reads += derived.reads;
6325
+ summary.compatible += derived.compatible;
6326
+ summary.customComponent += derived.customComponent;
6327
+ summary.visualCustomComponent += derived.visualCustomComponent;
6328
+ summary.effect += derived.effect;
6329
+ summary.structural += derived.structural;
6330
+ summary.unknown += derived.unknown;
6331
+ continue;
6332
+ }
6333
+ }
6334
+ const opaqueDeclaration = opaqueDerivedLocalDeclarationForRead(node, context);
6335
+ const opaqueNames = opaqueDeclaration ? declarationNames2(opaqueDeclaration) : [];
6336
+ if (opaqueDeclaration && opaqueNames.length === 1) {
6337
+ const derived = bindingUsageSummary(opaqueNames[0], owner, opaqueDeclaration, context, compatibleComponentProps, new Set(visited));
6338
+ if (derived.reads > 0) {
6339
+ summary.reads += derived.reads + 1;
6340
+ summary.compatible += derived.compatible;
6341
+ summary.customComponent += derived.customComponent;
6342
+ summary.visualCustomComponent += derived.visualCustomComponent;
6343
+ summary.effect += derived.effect;
6344
+ summary.structural += derived.structural;
6345
+ summary.unknown += derived.unknown + 1;
6346
+ continue;
6347
+ }
6348
+ }
6349
+ }
5901
6350
  summary.reads += 1;
5902
6351
  const customComponentUsage = createElementPropUsage(node, context);
5903
6352
  const kind = classifyBindingUsage(node, context, compatibleComponentProps);
@@ -5920,11 +6369,94 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
5920
6369
  function hasCandidatePresentationEvidence(summary) {
5921
6370
  return summary.compatible > 0 || summary.visualCustomComponent > 0;
5922
6371
  }
6372
+ function isPresentationOnly(summary) {
6373
+ return summary.reads > 0 && summary.compatible === summary.reads;
6374
+ }
6375
+ function stateBindingForSetter(setterName, context) {
6376
+ const valueName = context.model.stateSetters.get(setterName);
6377
+ if (!valueName)
6378
+ return null;
6379
+ return context.model.stateBindings.find((binding) => binding.setterName === setterName && binding.valueName === valueName) ?? null;
6380
+ }
6381
+ function effectOnlyDerivesPresentation(valueName, owner, declaration, context, compatibleComponentProps) {
6382
+ if (!owner.body)
6383
+ return false;
6384
+ let foundRelevantEffect = false;
6385
+ for (const call of context.walk(owner.body)) {
6386
+ if (call.type !== "function_call" || context.nearestFunction(call) !== owner)
6387
+ continue;
6388
+ const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
6389
+ if (path7 !== "React.useEffect" && path7 !== "React.useLayoutEffect")
6390
+ continue;
6391
+ const callbackNode = context.callArguments(call)[0];
6392
+ if (callbackNode?.type !== "function_definition")
6393
+ continue;
6394
+ const callback = context.model.functionByNode.get(callbackNode.id);
6395
+ if (!callback?.body)
6396
+ continue;
6397
+ const usesValue = [...context.walk(callback.body)].some((node) => context.nearestFunction(node) === callback && isReadNode(node, valueName, owner, declaration));
6398
+ if (!usesValue)
6399
+ continue;
6400
+ foundRelevantEffect = true;
6401
+ let presentationSetter = false;
6402
+ for (const nested of context.walk(callback.body)) {
6403
+ if (nested.type !== "function_call" || context.nearestFunction(nested) !== callback)
6404
+ continue;
6405
+ if (isBindingMapSafeCall(nested, context))
6406
+ continue;
6407
+ const nestedPath = context.resolveCallPath(context.getCallPath(nested) ?? "");
6408
+ if (nestedPath === "debug.profilebegin" || nestedPath === "debug.profileend")
6409
+ continue;
6410
+ if (context.model.bindingSetters.has(nestedPath)) {
6411
+ presentationSetter = true;
6412
+ continue;
6413
+ }
6414
+ const target = stateBindingForSetter(nestedPath, context);
6415
+ if (target?.owner && stateReadsAreBindingCompatible(target.valueName, target.owner, target.declaration, context, compatibleComponentProps)) {
6416
+ presentationSetter = true;
6417
+ continue;
6418
+ }
6419
+ return false;
6420
+ }
6421
+ if (!presentationSetter)
6422
+ return false;
6423
+ }
6424
+ return foundRelevantEffect;
6425
+ }
6426
+ function importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) {
6427
+ if (!summary.external || callDefinitelyUsesBindingMode(summary, call, context))
6428
+ return null;
6429
+ const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
6430
+ if (usage.reads === 0)
6431
+ return null;
6432
+ const presentationOnly = isPresentationOnly(usage);
6433
+ const explicitBindingMode = hasExplicitBindingMode(summary);
6434
+ const observedProperty = observedPropertyForCall(summary, call, context);
6435
+ const strongHostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
6436
+ const likelyContinuousProperty = summary.sourceKind === "instance-property" && Boolean(observedProperty && LIKELY_CONTINUOUS_PROPERTIES.has(observedProperty));
6437
+ const mirrorsExternalValue = summary.mirrorConfidence !== "none";
6438
+ if (presentationOnly && (summary.highFrequency || likelyContinuousProperty))
6439
+ return "warning";
6440
+ if (presentationOnly && strongHostFeedback && explicitBindingMode)
6441
+ return "warning";
6442
+ if (presentationOnly && (explicitBindingMode || strongHostFeedback || mirrorsExternalValue))
6443
+ return "candidate";
6444
+ if (strongHostFeedback && usage.effect > 0 && effectOnlyDerivesPresentation(localName, owner, declaration, context, compatibleComponentProps)) {
6445
+ return "candidate";
6446
+ }
6447
+ if (!hasCandidatePresentationEvidence(usage))
6448
+ return null;
6449
+ if (summary.highFrequency || likelyContinuousProperty)
6450
+ return "candidate";
6451
+ if (strongHostFeedback && explicitBindingMode && usage.effect === 0)
6452
+ return "candidate";
6453
+ return null;
6454
+ }
5923
6455
  var preferBindingOverState = {
5924
6456
  id: "react-luau/prefer-binding-over-state",
5925
6457
  category: "Performance",
5926
6458
  severity: "warning",
5927
- description: "Prefer React.useBinding when externally updated values do not need React reconciliation on every change.",
6459
+ description: "Prefer React.useBinding when proven external presentation streams do not need React reconciliation.",
5928
6460
  run(context) {
5929
6461
  const diagnostics = [];
5930
6462
  const seen = new Set;
@@ -5941,75 +6473,72 @@ var preferBindingOverState = {
5941
6473
  const owner = context.containingComponent(call) ?? context.nearestFunction(call);
5942
6474
  if (!declaration || !localName || !owner)
5943
6475
  continue;
5944
- if (!shouldRecommendImportedHook(summary, call, localName, owner, declaration, context, compatibleComponentProps))
6476
+ if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "warning")
5945
6477
  continue;
5946
6478
  const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
5947
6479
  if (seen.has(key))
5948
6480
  continue;
5949
6481
  seen.add(key);
5950
- const hasBindingMode = summary.bindingModeParameterIndex !== undefined && summary.bindingWhenTruthy !== undefined;
5951
- const message = summary.sourceKind === "measurement" ? hasBindingMode ? `${path7} is using its React state mode for an externally changing GUI measurement, so ${localName} rerenders this consumer when the measurement changes.` : `${path7} returns React state for an externally changing GUI measurement, so ${localName} rerenders this consumer when the measurement changes.` : summary.sourceKind === "derived-external-state" ? `${path7} derives React state from another externally updated reactive value, so ${localName} adds another rerendering state layer.` : `${path7} returns state updated${summary.highFrequency ? " from a high-frequency source" : " from an external callback"}, and ${localName} only flows into bindable host properties.`;
5952
- 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. ";
5953
- 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.";
6482
+ const hasBindingMode = hasExplicitBindingMode(summary);
6483
+ const observedProperty = observedPropertyForCall(summary, call, context);
6484
+ const hostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
6485
+ const likelyContinuousProperty = summary.sourceKind === "instance-property" && Boolean(observedProperty && LIKELY_CONTINUOUS_PROPERTIES.has(observedProperty));
6486
+ const message = hostFeedback ? `${path7} mirrors ${observedProperty} through React state even though every analyzed use is presentation-only, so the measurement rerenders this consumer without needing reconciliation.` : likelyContinuousProperty ? `${path7} mirrors continuously mutable ${observedProperty} through React state, and ${localName} only feeds presentation updates that do not need reconciliation.` : `${path7} returns state updated from a high-frequency source, and ${localName} only feeds presentation updates that do not need React reconciliation.`;
6487
+ 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.";
5954
6488
  diagnostics.push({
5955
6489
  node: callNameNode(call),
5956
6490
  message,
5957
6491
  help,
5958
- fixPreview: hasBindingMode ? bindingModeFixPreview(summary, call, context) : undefined
5959
- });
5960
- }
5961
- for (const call of context.findCalls()) {
5962
- const path7 = context.getCallPath(call) ?? "";
5963
- const member = path7.split(/[.:]/).at(-1) ?? path7;
5964
- const bindingAlternative = context.project.bindingApiAlternatives.get(member);
5965
- if (!bindingAlternative)
5966
- continue;
5967
- const declaration = declarationForCall2(call);
5968
- const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
5969
- const owner = context.containingComponent(call) ?? context.nearestFunction(call);
5970
- if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
5971
- continue;
5972
- if (!stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps))
5973
- continue;
5974
- const field = selectorFieldName(call, context);
5975
- const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
5976
- if (seen.has(key))
5977
- continue;
5978
- seen.add(key);
5979
- diagnostics.push({
5980
- node: callNameNode(call),
5981
- message: `${path7} subscribes ${localName} through React state${field ? ` for ${field}` : ""}, but ${bindingAlternative} is available and every use of the value only updates bindable host properties.`,
5982
- help: `Use ${bindingAlternative} so the host property can update without rerendering the component.`,
5983
- fixPreview: callMemberFixPreview(call, bindingAlternative)
6492
+ fixPreview: hasBindingMode && allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? bindingModeFixPreview(summary, call, context) : undefined
5984
6493
  });
5985
6494
  }
5986
6495
  for (const binding of context.model.stateBindings) {
5987
6496
  const owner = binding.owner;
5988
6497
  if (!owner || !owner.isComponent && !owner.isHook)
5989
6498
  continue;
5990
- const updateKind = bindingHasExternalOrHighFrequencyUpdates(binding, importedCallbacks, context);
5991
- if (!updateKind)
6499
+ const update = bindingUpdateEvidence(binding, importedCallbacks, context);
6500
+ if (!update)
5992
6501
  continue;
5993
6502
  const localCompatible = stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
5994
6503
  const hookCompatible = owner.isHook ? hookCallSitesAreBindingCompatible(binding, context, compatibleComponentProps) : false;
5995
- if (owner.isHook ? !hookCompatible : !localCompatible)
6504
+ const strongHostFeedback = hasStrongHostFeedbackEvidence(update);
6505
+ const likelyContinuousProperty = hasLikelyContinuousPropertyEvidence(update);
6506
+ const shouldWarn = owner.isHook ? hookCompatible && (update.highFrequency || likelyContinuousProperty) : localCompatible && (update.highFrequency || strongHostFeedback || likelyContinuousProperty);
6507
+ if (!shouldWarn)
5996
6508
  continue;
5997
6509
  const key = `${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
5998
6510
  if (seen.has(key))
5999
6511
  continue;
6000
6512
  seen.add(key);
6001
6513
  if (owner.isHook && hookCompatible) {
6514
+ const properties = likelyContinuousProperty ? [...update.hostFeedbackProperties].sort().join(", ") : "";
6002
6515
  diagnostics.push({
6003
6516
  node: callNameNode(binding.call),
6004
- 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.`,
6005
- help: "Consider returning a Binding, or adding a Binding-returning variant of this hook, so consumers can update host properties without rerendering."
6517
+ 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.`,
6518
+ help: "Consider returning a Binding, or adding a Binding-returning variant of this hook, so continuous presentation updates do not rerender every consumer."
6519
+ });
6520
+ } else if (strongHostFeedback && !update.highFrequency) {
6521
+ const properties = [...update.hostFeedbackProperties].sort().join(", ");
6522
+ diagnostics.push({
6523
+ node: callNameNode(binding.call),
6524
+ message: `State ${binding.valueName} mirrors ${properties} and only feeds presentation updates, so React reconciliation is acting as an unnecessary host-property feedback step.`,
6525
+ 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.",
6526
+ fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
6527
+ });
6528
+ } else if (likelyContinuousProperty && !update.highFrequency) {
6529
+ const properties = [...update.hostFeedbackProperties].sort().join(", ");
6530
+ diagnostics.push({
6531
+ node: callNameNode(binding.call),
6532
+ message: `State ${binding.valueName} mirrors continuously mutable ${properties} and only feeds presentation updates that do not need React reconciliation.`,
6533
+ help: "Prefer React.useBinding so external visual property changes can reach presentation without routing every update through component state.",
6534
+ fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
6006
6535
  });
6007
6536
  } else {
6008
6537
  diagnostics.push({
6009
6538
  node: callNameNode(binding.call),
6010
- 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.`,
6011
- help: "Prefer React.useBinding when the update is visual and does not need React to rebuild component structure.",
6012
- fixPreview: useBindingFixPreview(binding.call)
6539
+ message: `State ${binding.valueName} is updated from a high-frequency callback and only feeds presentation updates that do not need React reconciliation.`,
6540
+ help: "Prefer React.useBinding so continuous visual updates can reach host properties without rerendering the component.",
6541
+ fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
6013
6542
  });
6014
6543
  }
6015
6544
  }
@@ -6030,27 +6559,49 @@ var preferBindingOverStateCandidate = {
6030
6559
  for (const call of context.findCalls()) {
6031
6560
  const path7 = context.getCallPath(call) ?? "";
6032
6561
  const summary = importedHooks.get(path7);
6033
- if (!summary || !summary.external || callDefinitelyUsesBindingMode(summary, call, context))
6562
+ if (!summary)
6034
6563
  continue;
6035
6564
  const declaration = declarationForCall2(call);
6036
6565
  const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
6037
6566
  const owner = context.containingComponent(call) ?? context.nearestFunction(call);
6038
- if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
6567
+ if (!declaration || !localName || !owner)
6039
6568
  continue;
6040
- if (shouldRecommendImportedHook(summary, call, localName, owner, declaration, context, compatibleComponentProps))
6569
+ if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "candidate")
6041
6570
  continue;
6042
6571
  const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
6043
- const hasPresentationEvidence = hasCandidatePresentationEvidence(usage) || summary.sourceKind === "derived-external-state" && usage.effect > 0;
6044
- if (!summary.highFrequency && !hasPresentationEvidence)
6045
- continue;
6572
+ const presentationOnly = isPresentationOnly(usage);
6573
+ const observedProperty = observedPropertyForCall(summary, call, context);
6574
+ const strongHostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
6575
+ const hasBindingMode = hasExplicitBindingMode(summary);
6576
+ const mirrorsExternalValue = summary.mirrorConfidence !== "none";
6577
+ const visualEffectBridge = strongHostFeedback && usage.effect > 0 && effectOnlyDerivesPresentation(localName, owner, declaration, context, compatibleComponentProps);
6046
6578
  const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
6047
6579
  if (seen.has(key))
6048
6580
  continue;
6049
6581
  seen.add(key);
6582
+ let message;
6583
+ let help;
6584
+ if (presentationOnly && hasBindingMode) {
6585
+ message = `${path7} 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.`;
6586
+ 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.";
6587
+ } else if (presentationOnly && strongHostFeedback) {
6588
+ message = `${path7} mirrors ${observedProperty} through React state only for presentation, but the hook does not expose a proven low-friction Binding mode.`;
6589
+ 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.";
6590
+ } else if (presentationOnly && mirrorsExternalValue) {
6591
+ message = `${path7} mirrors an external reactive value through React state, and ${localName} is only used for presentation.`;
6592
+ 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.";
6593
+ } else if (visualEffectBridge) {
6594
+ message = `${path7} mirrors ${observedProperty} through React state, and the dependent React effect only derives presentation state or Bindings from ${localName}.`;
6595
+ 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.";
6596
+ } else {
6597
+ message = `${path7} updates ${localName} through React state and some consumers are presentation-only, but other consumers may still require React reconciliation.`;
6598
+ 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.";
6599
+ }
6050
6600
  diagnostics.push({
6051
6601
  node: callNameNode(call),
6052
- message: `${path7} 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.`,
6053
- 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."
6602
+ message,
6603
+ help,
6604
+ fixPreview: presentationOnly && hasBindingMode && allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? bindingModeFixPreview(summary, call, context) : undefined
6054
6605
  });
6055
6606
  }
6056
6607
  for (const call of context.findCalls()) {
@@ -6064,39 +6615,80 @@ var preferBindingOverStateCandidate = {
6064
6615
  const owner = context.containingComponent(call) ?? context.nearestFunction(call);
6065
6616
  if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
6066
6617
  continue;
6067
- if (stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps))
6618
+ const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
6619
+ const presentationOnly = isPresentationOnly(usage);
6620
+ if (!presentationOnly)
6068
6621
  continue;
6622
+ const field = selectorFieldName(call, context);
6069
6623
  const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
6070
6624
  if (seen.has(key))
6071
6625
  continue;
6072
6626
  seen.add(key);
6073
6627
  diagnostics.push({
6074
6628
  node: callNameNode(call),
6075
- message: `${path7} 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.`,
6076
- help: `Inspect whether ${bindingAlternative}, Binding:map, or splitting visual and structural consumers would avoid unnecessary rerenders.`,
6077
- fixPreview: callMemberFixPreview(call, bindingAlternative)
6629
+ message: `${path7} subscribes ${localName} through React state${field ? ` for ${field}` : ""}, while ${bindingAlternative} is also available and every analyzed consumer is presentation-only.`,
6630
+ help: `Consider ${bindingAlternative} when this value updates frequently enough that avoiding reconciliation matters; state remains reasonable for ordinary semantic store updates.`,
6631
+ fixPreview: allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? callMemberFixPreview(call, bindingAlternative) : undefined
6078
6632
  });
6079
6633
  }
6080
6634
  for (const binding of context.model.stateBindings) {
6081
6635
  const owner = binding.owner;
6082
- if (!owner || !owner.isComponent || !owner.body)
6636
+ if (!owner || !owner.isComponent && !owner.isHook || !owner.body)
6083
6637
  continue;
6084
- const updateKind = bindingHasExternalOrHighFrequencyUpdates(binding, importedCallbacks, context);
6085
- if (!updateKind)
6638
+ const update = bindingUpdateEvidence(binding, importedCallbacks, context);
6639
+ if (!update)
6086
6640
  continue;
6087
- if (stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps))
6641
+ const localCompatible = stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
6642
+ const hookCompatible = owner.isHook ? hookCallSitesAreBindingCompatible(binding, context, compatibleComponentProps) : false;
6643
+ const strongHostFeedback = hasStrongHostFeedbackEvidence(update);
6644
+ const likelyContinuousProperty = hasLikelyContinuousPropertyEvidence(update);
6645
+ const mirrorsExternalValue = update.mirrorConfidence !== "none";
6646
+ if (owner.isHook) {
6647
+ if (hookCompatible && (update.highFrequency || likelyContinuousProperty))
6648
+ continue;
6649
+ if (!(hookCompatible && (strongHostFeedback || mirrorsExternalValue)))
6650
+ continue;
6651
+ const key2 = `hook-state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
6652
+ if (seen.has(key2))
6653
+ continue;
6654
+ seen.add(key2);
6655
+ const measurement = strongHostFeedback ? "a presentation measurement" : "an external reactive value";
6656
+ diagnostics.push({
6657
+ node: callNameNode(binding.call),
6658
+ 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.`,
6659
+ 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."
6660
+ });
6088
6661
  continue;
6662
+ }
6089
6663
  const usage = bindingUsageSummary(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
6090
6664
  if (!hasCandidatePresentationEvidence(usage))
6091
6665
  continue;
6666
+ if (localCompatible && (update.highFrequency || strongHostFeedback || likelyContinuousProperty))
6667
+ continue;
6668
+ if (localCompatible && mirrorsExternalValue) {
6669
+ const key2 = `state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
6670
+ if (seen.has(key2))
6671
+ continue;
6672
+ seen.add(key2);
6673
+ diagnostics.push({
6674
+ node: callNameNode(binding.call),
6675
+ 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.`,
6676
+ 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."
6677
+ });
6678
+ continue;
6679
+ }
6680
+ const mixedHighFrequency = update.highFrequency || likelyContinuousProperty;
6681
+ const mixedHostFeedback = strongHostFeedback && usage.structural > 0 && usage.effect === 0;
6682
+ if (!mixedHighFrequency && !mixedHostFeedback)
6683
+ continue;
6092
6684
  const key = `state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
6093
6685
  if (seen.has(key))
6094
6686
  continue;
6095
6687
  seen.add(key);
6096
6688
  diagnostics.push({
6097
6689
  node: callNameNode(binding.call),
6098
- 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.`,
6099
- 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."
6690
+ message: `State ${binding.valueName} has presentation-oriented consumers, but other uses still appear to need React reconciliation.`,
6691
+ 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."
6100
6692
  });
6101
6693
  }
6102
6694
  return diagnostics;
@@ -7455,6 +8047,16 @@ function nodeLocation(node) {
7455
8047
  endColumn: node.endPosition.column + 1
7456
8048
  };
7457
8049
  }
8050
+ function sameLocation(left, right) {
8051
+ return left.line === right.line && left.column === right.column && left.endLine === right.endLine && left.endColumn === right.endColumn;
8052
+ }
8053
+ function removeSupersededDiagnostics(diagnostics) {
8054
+ return diagnostics.filter((diagnostic) => {
8055
+ if (diagnostic.rule !== "react-luau/rerender-high-frequency-state")
8056
+ return true;
8057
+ 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]);
8058
+ });
8059
+ }
7458
8060
  function toDiagnostic(file, ruleId, category, severity, input) {
7459
8061
  const node = input.node;
7460
8062
  const highlights = input.highlights?.map(nodeLocation);
@@ -7512,11 +8114,12 @@ async function analyzeReactFile(input, tree) {
7512
8114
  if (rule.id === "react-luau/parse-error" && findings.length > 0)
7513
8115
  break;
7514
8116
  }
8117
+ const filteredDiagnostics = removeSupersededDiagnostics(diagnostics);
7515
8118
  return {
7516
8119
  relativePath: input.relativePath,
7517
8120
  isReactFile: model.isReactFile,
7518
8121
  scanned: true,
7519
- diagnostics
8122
+ diagnostics: filteredDiagnostics
7520
8123
  };
7521
8124
  }
7522
8125
 
@@ -7675,7 +8278,34 @@ class AnalysisWorkerPool {
7675
8278
  // src/project-model.ts
7676
8279
  import fs6 from "fs";
7677
8280
  import path7 from "path";
7678
- var EXTERNAL_UPDATE_SOURCE = /(?::|\.)(?:Connect|Once|Subscribe|Observe|Listen|Watch)\s*\(|\b(?:subscribe|observe|listen|watch)[A-Za-z0-9_]*\s*\(|GetPropertyChangedSignal\s*\(|\.Changed\b/i;
8281
+ var EXTERNAL_UPDATE_SOURCE = /(?::|\.)(?:Connect|Once|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)\s*\(|\b(?:subscribe|observe|listen|watch|onStep|onUpdate|onChange|onChanged)[A-Za-z0-9_]*\s*\(|GetPropertyChangedSignal\s*\(|GetAttributeChangedSignal\s*\(|\.Changed\b/i;
8282
+ var SEMANTIC_SNAPSHOT_EXPRESSION2 = /\b(?:getState|getSnapshot|snapshot|selector|select|table\s*\.\s*(?:clone|freeze|move))\b|(?:\.|\[\s*["'])State(?:\b|["']\s*\])/i;
8283
+ var PURE_MIRROR_EXPRESSION_ROOTS = new Set([
8284
+ "math",
8285
+ "string",
8286
+ "utf8",
8287
+ "bit32",
8288
+ "UDim",
8289
+ "UDim2",
8290
+ "Vector2",
8291
+ "Vector3",
8292
+ "Color3",
8293
+ "CFrame",
8294
+ "Rect",
8295
+ "NumberRange",
8296
+ "NumberSequence",
8297
+ "ColorSequence",
8298
+ "BrickColor",
8299
+ "Font",
8300
+ "Enum",
8301
+ "tostring",
8302
+ "tonumber",
8303
+ "type",
8304
+ "typeof",
8305
+ "true",
8306
+ "false",
8307
+ "nil"
8308
+ ]);
7679
8309
  var CALLBACK_PARAMETER_NAME = /^(?:callback|handler|listener|subscriber|observer|effect|fn)$/i;
7680
8310
  function exportedFunctionName(source) {
7681
8311
  return source.match(/\breturn\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/)?.[1] ?? null;
@@ -8011,9 +8641,62 @@ function findExternalCallbackFunction(source) {
8011
8641
  return {
8012
8642
  name,
8013
8643
  callbackParameterIndexes,
8014
- highFrequency: sourceHasHighFrequencyRunService(source)
8644
+ highFrequency: sourceHasHighFrequencyRobloxEvent(source) || sourceHasLikelyContinuousSubscription(source)
8015
8645
  };
8016
8646
  }
8647
+ function sourceHasLikelyContinuousSubscription(source) {
8648
+ return /(?::|\.)onStep\s*\(/i.test(source);
8649
+ }
8650
+ function expressionReferencesName(expression, name) {
8651
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8652
+ return new RegExp(`\\b${escaped}\\b`).test(expression);
8653
+ }
8654
+ function isLiteralStateTransitionExpression(expression) {
8655
+ const text = expression.trim();
8656
+ return /^(?:true|false|nil|[-+]?\d+(?:\.\d+)?|["'][^"']*["'])$/.test(text);
8657
+ }
8658
+ function expressionHasLikelyExternalInput(expression) {
8659
+ const withoutMembers = expression.replace(/\.\s*[A-Za-z_][A-Za-z0-9_]*/g, "");
8660
+ for (const match of withoutMembers.matchAll(/\b[A-Za-z_][A-Za-z0-9_]*\b/g)) {
8661
+ if (!PURE_MIRROR_EXPRESSION_ROOTS.has(match[0]))
8662
+ return true;
8663
+ }
8664
+ return false;
8665
+ }
8666
+ function subscriptionMirrorConfidence(source, setterName) {
8667
+ const escapedSetter = setterName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8668
+ const directSetter = new RegExp(`(?::|\\.)(Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)\\s*\\(\\s*${escapedSetter}\\b`, "i");
8669
+ if (directSetter.test(source))
8670
+ return "strong";
8671
+ const callbackPattern = new RegExp(`(?::|\\.)(Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)\\s*\\(\\s*function\\s*\\(([^)]*)\\)[\\s\\S]{0,2400}?\\b${escapedSetter}\\s*\\(`, "gi");
8672
+ let possible = false;
8673
+ for (const match of source.matchAll(callbackPattern)) {
8674
+ if (match.index === undefined)
8675
+ continue;
8676
+ const callbackParameters = splitTopLevelParameters(match[2] ?? "").map(parameterName).filter((name) => Boolean(name));
8677
+ const setterOffset = match[0].lastIndexOf(setterName);
8678
+ if (setterOffset < 0)
8679
+ continue;
8680
+ const setterStart = match.index + setterOffset;
8681
+ const open = source.indexOf("(", setterStart + setterName.length);
8682
+ const argumentsText = extractCallArguments(source, open);
8683
+ if (argumentsText === null)
8684
+ continue;
8685
+ const setterArgs = splitTopLevelParameters(argumentsText);
8686
+ const expression = setterArgs[0]?.trim() ?? "";
8687
+ if (!expression || /^function\b/.test(expression) || isLiteralStateTransitionExpression(expression))
8688
+ continue;
8689
+ if (SEMANTIC_SNAPSHOT_EXPRESSION2.test(expression))
8690
+ continue;
8691
+ if (callbackParameters.some((name) => expressionReferencesName(expression, name))) {
8692
+ return "strong";
8693
+ }
8694
+ if (!expressionHasLikelyExternalInput(expression))
8695
+ continue;
8696
+ possible = true;
8697
+ }
8698
+ return possible ? "possible" : "none";
8699
+ }
8017
8700
  function findBindingCandidateHook(source) {
8018
8701
  const exported = source.match(/\breturn\s+(use[A-Z0-9_][A-Za-z0-9_]*)\s*$/)?.[1];
8019
8702
  if (!exported)
@@ -8035,18 +8718,35 @@ function findBindingCandidateHook(source) {
8035
8718
  const setterPassedToSubscription = new RegExp(`(?::|\\.)(?:Connect|Once|Subscribe|Observe|Listen|Watch)\\s*\\(\\s*${escapedSetter}\\b`, "i").test(source);
8036
8719
  if (!setterCalled && !setterPassedToSubscription)
8037
8720
  return null;
8038
- const highFrequency = sourceHasHighFrequencyRunService(source);
8721
+ const highFrequency = sourceHasHighFrequencyRobloxEvent(source) || sourceHasLikelyContinuousSubscription(source);
8039
8722
  const external = highFrequency || EXTERNAL_UPDATE_SOURCE.test(source);
8040
8723
  if (!external)
8041
8724
  return null;
8042
- const propertySignal = source.match(/GetPropertyChangedSignal\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/);
8043
- const observedPropertyName = propertySignal?.[1] ?? null;
8044
- const measurement = Boolean(observedPropertyName && new RegExp(`\\[\\s*${observedPropertyName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\]`).test(source));
8725
+ const literalPropertySignal = source.match(/GetPropertyChangedSignal\s*\(\s*["']([^"']+)["']\s*\)/);
8726
+ const dynamicPropertySignal = literalPropertySignal ? null : source.match(/GetPropertyChangedSignal\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/);
8727
+ const literalPropertyName = literalPropertySignal?.[1] ?? null;
8728
+ const dynamicPropertyName = dynamicPropertySignal?.[1] ?? null;
8729
+ const readsObservedProperty = literalPropertyName ? new RegExp(`(?:\\.\\s*${literalPropertyName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b|\\[\\s*["']${literalPropertyName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*\\])`).test(source) : Boolean(dynamicPropertyName && new RegExp(`\\[\\s*${dynamicPropertyName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\]`).test(source));
8730
+ const instanceProperty = Boolean((literalPropertyName || dynamicPropertyName) && readsObservedProperty);
8731
+ const mirrorConfidence = instanceProperty ? "strong" : subscriptionMirrorConfidence(source, setterName);
8732
+ let observedPropertyParameterIndex;
8733
+ if (instanceProperty && dynamicPropertyName) {
8734
+ const parameters = findFunctionParameters(source, exported);
8735
+ if (parameters !== null) {
8736
+ const parameterNames3 = splitTopLevelParameters(parameters).map((parameter) => parameter.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1] ?? "");
8737
+ const index = parameterNames3.indexOf(dynamicPropertyName);
8738
+ if (index >= 0)
8739
+ observedPropertyParameterIndex = index;
8740
+ }
8741
+ }
8045
8742
  return {
8046
8743
  name: exported,
8047
8744
  highFrequency,
8048
8745
  external,
8049
- sourceKind: measurement ? "measurement" : "external-state",
8746
+ sourceKind: instanceProperty ? "instance-property" : "external-state",
8747
+ mirrorConfidence,
8748
+ ...instanceProperty && literalPropertyName ? { observedPropertyName: literalPropertyName } : {},
8749
+ ...observedPropertyParameterIndex !== undefined ? { observedPropertyParameterIndex } : {},
8050
8750
  ...dualMode ? {
8051
8751
  bindingModeParameterIndex: dualMode.bindingModeParameterIndex,
8052
8752
  bindingModeParameterName: dualMode.bindingModeParameterName,
@@ -8084,7 +8784,8 @@ function findDerivedBindingCandidateHook(source, bindingCandidateHooks) {
8084
8784
  name: exported,
8085
8785
  highFrequency: summary.highFrequency,
8086
8786
  external: true,
8087
- sourceKind: "derived-external-state"
8787
+ sourceKind: "derived-external-state",
8788
+ mirrorConfidence: summary.mirrorConfidence
8088
8789
  };
8089
8790
  }
8090
8791
  return null;
@@ -8448,7 +9149,11 @@ function findBindingCompatibleComponentProps(source) {
8448
9149
  }
8449
9150
  const compatible = new Set;
8450
9151
  for (const [property, offsets] of reads) {
8451
- if (offsets.length > 0 && offsets.every((offset) => ranges.some((range) => offset >= range.start && offset < range.end))) {
9152
+ if (offsets.length > 0 && offsets.every((offset) => ranges.some((range) => {
9153
+ if (offset < range.start || offset >= range.end)
9154
+ return false;
9155
+ return !/\bfunction\b/.test(source.slice(range.start, offset));
9156
+ }))) {
8452
9157
  compatible.add(property);
8453
9158
  }
8454
9159
  }
@@ -10771,6 +11476,7 @@ import path12 from "path";
10771
11476
  var UPDATE_CHECK_INTERVAL_MS = 2 * 60 * 60 * 1000;
10772
11477
  var UPDATE_REQUEST_TIMEOUT_MS = 5000;
10773
11478
  var UPDATE_CACHE_FILENAME = "update-check.json";
11479
+ var CHANGELOG_URL = "https://raw.githubusercontent.com/ColRealPro/react-luau-doctor/refs/heads/main/CHANGELOG.md";
10774
11480
  function updateCacheFilename() {
10775
11481
  return path12.join(cacheBaseDirectory(), UPDATE_CACHE_FILENAME);
10776
11482
  }
@@ -10852,6 +11558,9 @@ function updateRegistryUrl() {
10852
11558
  const base = registry.endsWith("/") ? registry : `${registry}/`;
10853
11559
  return new URL(`${encodeURIComponent(package_default.name)}/latest`, base).toString();
10854
11560
  }
11561
+ function changelogUrl() {
11562
+ return process.env.REACT_LUAU_DOCTOR_CHANGELOG_URL ?? CHANGELOG_URL;
11563
+ }
10855
11564
  async function fetchLatestVersion() {
10856
11565
  const controller = new AbortController;
10857
11566
  const timer = setTimeout(() => controller.abort(), UPDATE_REQUEST_TIMEOUT_MS);
@@ -10872,6 +11581,50 @@ async function fetchLatestVersion() {
10872
11581
  clearTimeout(timer);
10873
11582
  }
10874
11583
  }
11584
+ function changelogReleasesBetween(markdown, currentVersion, latestVersion) {
11585
+ const releases = [];
11586
+ let active = null;
11587
+ const flush = () => {
11588
+ if (!active)
11589
+ return;
11590
+ const afterCurrent = compareVersions(active.version, currentVersion);
11591
+ const atOrBeforeLatest = compareVersions(active.version, latestVersion);
11592
+ if (afterCurrent !== null && atOrBeforeLatest !== null && afterCurrent > 0 && atOrBeforeLatest <= 0) {
11593
+ releases.push({ version: active.version, notes: active.lines.join(`
11594
+ `).trim() });
11595
+ }
11596
+ active = null;
11597
+ };
11598
+ for (const line of markdown.split(/\r?\n/)) {
11599
+ if (/^##\s+/.test(line.trim())) {
11600
+ flush();
11601
+ const match = /^##\s+\[?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s+-.*)?\s*$/.exec(line.trim());
11602
+ if (match)
11603
+ active = { version: match[1], lines: [] };
11604
+ continue;
11605
+ }
11606
+ active?.lines.push(line);
11607
+ }
11608
+ flush();
11609
+ releases.sort((left, right) => compareVersions(right.version, left.version) ?? 0);
11610
+ return releases;
11611
+ }
11612
+ async function fetchChangelogReleases(currentVersion, latestVersion) {
11613
+ const controller = new AbortController;
11614
+ const timer = setTimeout(() => controller.abort(), UPDATE_REQUEST_TIMEOUT_MS);
11615
+ timer.unref?.();
11616
+ try {
11617
+ const response = await fetch(changelogUrl(), {
11618
+ headers: { accept: "text/markdown, text/plain;q=0.9, */*;q=0.1" },
11619
+ signal: controller.signal
11620
+ });
11621
+ if (!response.ok)
11622
+ throw new Error(`changelog returned HTTP ${response.status}`);
11623
+ return changelogReleasesBetween(await response.text(), currentVersion, latestVersion);
11624
+ } finally {
11625
+ clearTimeout(timer);
11626
+ }
11627
+ }
10875
11628
  function updateCacheIsStale(now = Date.now()) {
10876
11629
  const cache = readUpdateCache();
10877
11630
  return !cache || now - cache.checkedAt >= UPDATE_CHECK_INTERVAL_MS;
@@ -10927,7 +11680,7 @@ async function checkForUpdatesNow(currentVersion) {
10927
11680
 
10928
11681
  // src/update-install.ts
10929
11682
  import { spawnSync as spawnSync3 } from "child_process";
10930
- import { fileURLToPath } from "url";
11683
+ import { fileURLToPath as fileURLToPath2 } from "url";
10931
11684
  function normalizedPath(filename) {
10932
11685
  return filename.replaceAll("\\", "/");
10933
11686
  }
@@ -10955,7 +11708,7 @@ function updateInstallCommandForPath(filename) {
10955
11708
  return null;
10956
11709
  }
10957
11710
  function currentUpdateInstallCommand() {
10958
- return updateInstallCommandForPath(fileURLToPath(import.meta.url));
11711
+ return updateInstallCommandForPath(fileURLToPath2(import.meta.url));
10959
11712
  }
10960
11713
  function installLatestVersion(command) {
10961
11714
  const result = spawnSync3(command.command, command.args, {
@@ -11063,10 +11816,12 @@ end, {})`,
11063
11816
  },
11064
11817
  "react-luau/prefer-binding-over-state-candidate": {
11065
11818
  before: `local value, setValue = React.useState(0)
11066
- -- value drives both visual and structural work`,
11067
- after: `local visualValue, setVisualValue = React.useBinding(0)
11068
- local structuralValue, setStructuralValue = React.useState(0)`,
11069
- note: "A common fix is to split high-frequency visual updates from the smaller value that truly needs reconciliation."
11819
+ signal:Connect(setValue)
11820
+ -- value is used for presentation`,
11821
+ after: `local value, setValue = React.useBinding(0)
11822
+ signal:Connect(setValue)
11823
+ -- keep separate React state too if structure depends on it`,
11824
+ note: "Use a Binding when this is really an external presentation stream. Keep or split React state when the event represents semantic UI state or needs reconciliation."
11070
11825
  },
11071
11826
  "react-luau/rerender-unstable-memo-props": {
11072
11827
  before: `return React.createElement(MemoizedButton, {
@@ -11498,7 +12253,7 @@ CI commands:
11498
12253
 
11499
12254
  Update commands:
11500
12255
  update Update the global installation to the latest release
11501
- update --check Check npm for a newer release without updating
12256
+ update --check Check npm and show release notes without updating
11502
12257
 
11503
12258
  Rules commands:
11504
12259
  rules list [--category <name>] [--configured] [--json]
@@ -11515,12 +12270,67 @@ Config:
11515
12270
  function automaticUpdateNoticeEnabled(options, machineReadable) {
11516
12271
  return !options.noUpdateCheck && !machineReadable && Boolean(process.stdout.isTTY) && !process.env.CI && process.env.NO_UPDATE_NOTIFIER === undefined && process.env.REACT_LUAU_DOCTOR_NO_UPDATE_CHECK === undefined;
11517
12272
  }
11518
- function renderUpdateNotice(current, latest, colorized) {
12273
+ function renderUpdateHeader(current, latest, colorized) {
11519
12274
  const label = whyPaint(colorized, "Update available:", WHY_ANSI.bold, WHY_ANSI.yellow);
11520
12275
  const oldVersion = whyPaint(colorized, `v${current}`, WHY_ANSI.dim);
11521
12276
  const newVersion = whyPaint(colorized, `v${latest}`, WHY_ANSI.bold);
11522
- return `${label} ${oldVersion} \u2192 ${newVersion}
11523
- Run \`react-luau-doctor update\` to update.`;
12277
+ return `${label} ${oldVersion} \u2192 ${newVersion}`;
12278
+ }
12279
+ function renderUpdateNotice(current, latest, colorized) {
12280
+ return `${renderUpdateHeader(current, latest, colorized)}
12281
+ Run \`react-luau-doctor update --check\` to see what's new.`;
12282
+ }
12283
+ function renderChangelogInline(value, colorized, ...baseCodes) {
12284
+ if (!colorized)
12285
+ return value;
12286
+ const parts = [];
12287
+ let offset = 0;
12288
+ for (const match of value.matchAll(/`([^`\n]+)`/g)) {
12289
+ const index = match.index ?? 0;
12290
+ if (index > offset)
12291
+ parts.push(whyPaint(true, value.slice(offset, index), ...baseCodes));
12292
+ parts.push(whyPaint(true, match[1], WHY_THEME.variable));
12293
+ offset = index + match[0].length;
12294
+ }
12295
+ if (parts.length === 0)
12296
+ return whyPaint(true, value, ...baseCodes);
12297
+ if (offset < value.length)
12298
+ parts.push(whyPaint(true, value.slice(offset), ...baseCodes));
12299
+ return parts.join("");
12300
+ }
12301
+ function renderChangelogNotes(notes, colorized) {
12302
+ return notes.split(/\r?\n/).map((line) => {
12303
+ const heading = /^#{3,6}\s+(.+)$/.exec(line.trim());
12304
+ if (heading)
12305
+ return renderChangelogInline(heading[1], colorized, WHY_ANSI.bold);
12306
+ const bullet = /^(\s*)[-*]\s+(.+)$/.exec(line);
12307
+ if (bullet)
12308
+ return `${bullet[1] || " "}\u2022 ${renderChangelogInline(bullet[2], colorized)}`;
12309
+ return renderChangelogInline(line, colorized);
12310
+ }).join(`
12311
+ `).trim();
12312
+ }
12313
+ function renderUpdateCheck(current, latest, releases, releaseNotesUnavailable, colorized) {
12314
+ const lines2 = [
12315
+ renderUpdateHeader(current, latest, colorized),
12316
+ "",
12317
+ whyPaint(colorized, "What's new", WHY_ANSI.bold)
12318
+ ];
12319
+ if (releaseNotesUnavailable) {
12320
+ lines2.push(" Release notes could not be loaded from GitHub.");
12321
+ } else if (releases.length === 0) {
12322
+ lines2.push(" No changelog entries were found between these versions.");
12323
+ } else {
12324
+ for (const release of releases) {
12325
+ lines2.push("", whyPaint(colorized, `v${release.version}`, WHY_ANSI.bold));
12326
+ const notes = renderChangelogNotes(release.notes, colorized);
12327
+ if (notes)
12328
+ lines2.push(notes);
12329
+ }
12330
+ }
12331
+ lines2.push("", "Run `react-luau-doctor update` to update.");
12332
+ return lines2.join(`
12333
+ `);
11524
12334
  }
11525
12335
  async function runUpdateCommand(argv) {
11526
12336
  if (argv.includes("--help") || argv.includes("-h")) {
@@ -11539,7 +12349,14 @@ async function runUpdateCommand(argv) {
11539
12349
  }
11540
12350
  const colorized = shouldUseColor(false, false);
11541
12351
  if (argv[0] === "--check") {
11542
- process.stdout.write(`${renderUpdateNotice(VERSION2, result.latest, colorized)}
12352
+ let releases = [];
12353
+ let releaseNotesUnavailable = false;
12354
+ try {
12355
+ releases = await fetchChangelogReleases(VERSION2, result.latest);
12356
+ } catch {
12357
+ releaseNotesUnavailable = true;
12358
+ }
12359
+ process.stdout.write(`${renderUpdateCheck(VERSION2, result.latest, releases, releaseNotesUnavailable, colorized)}
11543
12360
  `);
11544
12361
  return;
11545
12362
  }
@@ -12476,14 +13293,14 @@ function renderWhyCodeFrame(filename, diagnostic, colorized) {
12476
13293
  }
12477
13294
  function whySeverityMeaning(severity) {
12478
13295
  if (severity === "error")
12479
- return "This can violate React or Luau correctness rules and should normally be fixed.";
13296
+ return "This can cause incorrect behavior or violate React's rules and should normally be fixed.";
12480
13297
  if (severity === "warning")
12481
13298
  return "This pattern is very likely to be a real issue in the code shown and is normally worth fixing.";
12482
13299
  return "This may be intentional. Doctor found a plausible improvement, but the right choice depends on how this value or pattern is meant to affect rendering.";
12483
13300
  }
12484
13301
  function whyRuleSpecificContext(diagnostic) {
12485
13302
  if (diagnostic.rule === "react-luau/prefer-binding-over-state-candidate") {
12486
- return "A Binding can update visual values without rerendering the component. Here, at least one use may be structural or otherwise depend on a rerender, so replacing all of the state with a Binding is not automatically safe.";
13303
+ return "A Binding can update visual values without rerendering the component. Doctor found an external reactive mirror or presentation branch, but it cannot fully prove the source's update pressure, conversion safety, or that every consumer can leave React state.";
12487
13304
  }
12488
13305
  if (diagnostic.rule === "react-luau/prefer-binding-over-state") {
12489
13306
  return "A Binding can update these values without rerendering the component, and every use of the value only needs that kind of update. React state is therefore doing extra reconciliation work here.";
@@ -12498,7 +13315,7 @@ function whyRuleSpecificContext(diagnostic) {
12498
13315
  }
12499
13316
  function whyRuleCaveat(diagnostic) {
12500
13317
  if (diagnostic.rule === "react-luau/prefer-binding-over-state-candidate") {
12501
- return "Keep React state when the value genuinely changes component structure, conditional children, hook inputs, or other behavior that must go through React reconciliation. Mixed cases often benefit from splitting visual Binding updates from a smaller structural state value.";
13318
+ return "Keep React state when the external event represents semantic UI state, changes component structure, drives hook/effect behavior, or is infrequent enough that a Binding would only make the code harder to follow. Mixed cases can split visual Binding updates from the smaller value that truly needs reconciliation.";
12502
13319
  }
12503
13320
  if (diagnostic.rule === "react-luau/prefer-binding-over-state") {
12504
13321
  return "Keep React state if changing the value must rebuild component structure rather than only update Roblox Instance properties or other Binding-aware consumers.";
@@ -12818,5 +13635,5 @@ ${renderUpdateNotice(update.current, update.latest, colorized)}
12818
13635
  }
12819
13636
  main();
12820
13637
 
12821
- //# debugId=32BB85E7FBDD457564756E2164756E21
13638
+ //# debugId=569DB065E837196F64756E2164756E21
12822
13639
  //# sourceMappingURL=cli.js.map