@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/CHANGELOG.md +14 -0
- package/dist/cli.js +952 -135
- package/dist/cli.js.map +11 -11
- package/dist/scan-worker.js +662 -107
- package/dist/scan-worker.js.map +6 -6
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,7 @@ var package_default = {
|
|
|
11
11
|
publishConfig: {
|
|
12
12
|
access: "public"
|
|
13
13
|
},
|
|
14
|
-
version: "0.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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|
|
|
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 (!
|
|
5744
|
+
if (!isTransparentParent(parent, context))
|
|
5585
5745
|
return false;
|
|
5586
5746
|
current = parent;
|
|
5587
5747
|
}
|
|
5588
5748
|
return false;
|
|
5589
5749
|
}
|
|
5590
|
-
function
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5666
|
-
|
|
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
|
|
5675
|
-
|
|
5676
|
-
if (!source.external)
|
|
5942
|
+
const source = importedCallbackSource(call, callbackArg, importedCallbacks, context) ?? callbackSource(call, context);
|
|
5943
|
+
if (!source)
|
|
5677
5944
|
continue;
|
|
5678
|
-
|
|
5679
|
-
foundHighFrequency ||= source.highFrequency;
|
|
5945
|
+
mergeSourceEvidence(result, source);
|
|
5680
5946
|
}
|
|
5681
|
-
return
|
|
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
|
|
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
|
-
|
|
5700
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
5711
|
-
external ||= source.external;
|
|
6120
|
+
mergeBindingUpdateEvidence(result, source, "strong");
|
|
5712
6121
|
}
|
|
5713
|
-
return
|
|
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
|
|
5792
|
-
|
|
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 (!
|
|
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 (!
|
|
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
|
|
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 (
|
|
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
|
|
5951
|
-
const
|
|
5952
|
-
const
|
|
5953
|
-
const
|
|
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
|
|
5991
|
-
if (!
|
|
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
|
-
|
|
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
|
|
6005
|
-
help: "Consider returning a Binding, or adding a Binding-returning variant of this hook, so
|
|
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
|
|
6011
|
-
help: "Prefer React.useBinding
|
|
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
|
|
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
|
|
6567
|
+
if (!declaration || !localName || !owner)
|
|
6039
6568
|
continue;
|
|
6040
|
-
if (
|
|
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
|
|
6044
|
-
|
|
6045
|
-
|
|
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
|
|
6053
|
-
help
|
|
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
|
-
|
|
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}
|
|
6076
|
-
help: `
|
|
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
|
|
6085
|
-
if (!
|
|
6638
|
+
const update = bindingUpdateEvidence(binding, importedCallbacks, context);
|
|
6639
|
+
if (!update)
|
|
6086
6640
|
continue;
|
|
6087
|
-
|
|
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}
|
|
6099
|
-
help: "Inspect whether
|
|
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:
|
|
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 =
|
|
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
|
|
8043
|
-
const
|
|
8044
|
-
const
|
|
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:
|
|
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) =>
|
|
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(
|
|
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
|
-
|
|
11067
|
-
|
|
11068
|
-
local
|
|
11069
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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=
|
|
13638
|
+
//# debugId=569DB065E837196F64756E2164756E21
|
|
12822
13639
|
//# sourceMappingURL=cli.js.map
|