@colrealpro/react-luau-doctor 0.18.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/cli.js +1019 -136
- package/dist/cli.js.map +29 -29
- package/dist/scan-worker.js +729 -108
- package/dist/scan-worker.js.map +25 -25
- 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,
|
|
@@ -2141,6 +2189,71 @@ function applyMasks(source, ranges) {
|
|
|
2141
2189
|
}
|
|
2142
2190
|
return output;
|
|
2143
2191
|
}
|
|
2192
|
+
function genericFunctionParameterRanges(source, mask) {
|
|
2193
|
+
const ranges = [];
|
|
2194
|
+
const functionPattern = /\bfunction\b/g;
|
|
2195
|
+
for (const match of source.matchAll(functionPattern)) {
|
|
2196
|
+
const start = match.index;
|
|
2197
|
+
if (!isCode(mask, start, start + match[0].length))
|
|
2198
|
+
continue;
|
|
2199
|
+
let cursor = start + match[0].length;
|
|
2200
|
+
while (cursor < source.length) {
|
|
2201
|
+
const char = source[cursor];
|
|
2202
|
+
if (/\s/.test(char)) {
|
|
2203
|
+
cursor += 1;
|
|
2204
|
+
continue;
|
|
2205
|
+
}
|
|
2206
|
+
if (char === "(")
|
|
2207
|
+
break;
|
|
2208
|
+
if (char === "<") {
|
|
2209
|
+
let depth = 0;
|
|
2210
|
+
const rangeStart = cursor;
|
|
2211
|
+
while (cursor < source.length) {
|
|
2212
|
+
if (isCode(mask, cursor)) {
|
|
2213
|
+
if (source[cursor] === "<")
|
|
2214
|
+
depth += 1;
|
|
2215
|
+
else if (source[cursor] === ">") {
|
|
2216
|
+
depth -= 1;
|
|
2217
|
+
if (depth === 0) {
|
|
2218
|
+
cursor += 1;
|
|
2219
|
+
ranges.push({ start: rangeStart, end: cursor });
|
|
2220
|
+
break;
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
cursor += 1;
|
|
2225
|
+
}
|
|
2226
|
+
break;
|
|
2227
|
+
}
|
|
2228
|
+
if (/[A-Za-z0-9_.:]/.test(char)) {
|
|
2229
|
+
cursor += 1;
|
|
2230
|
+
continue;
|
|
2231
|
+
}
|
|
2232
|
+
break;
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
return ranges;
|
|
2236
|
+
}
|
|
2237
|
+
function normalizeGenericTypePackUses(source, mask) {
|
|
2238
|
+
const declarationRanges = genericFunctionParameterRanges(source, mask);
|
|
2239
|
+
const replacements = [];
|
|
2240
|
+
const packPattern = /\b[A-Za-z_][A-Za-z0-9_]*\.\.\./g;
|
|
2241
|
+
for (const match of source.matchAll(packPattern)) {
|
|
2242
|
+
const start = match.index;
|
|
2243
|
+
const end = start + match[0].length;
|
|
2244
|
+
if (!isCode(mask, start, end))
|
|
2245
|
+
continue;
|
|
2246
|
+
if (declarationRanges.some((range) => start >= range.start && end <= range.end))
|
|
2247
|
+
continue;
|
|
2248
|
+
const name = match[0].slice(0, -3);
|
|
2249
|
+
replacements.push({ start, end, value: `...${name}` });
|
|
2250
|
+
}
|
|
2251
|
+
let output = source;
|
|
2252
|
+
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
|
|
2253
|
+
output = `${output.slice(0, replacement.start)}${replacement.value}${output.slice(replacement.end)}`;
|
|
2254
|
+
}
|
|
2255
|
+
return output;
|
|
2256
|
+
}
|
|
2144
2257
|
function normalizeIntegerLiteralSuffixes(source, mask) {
|
|
2145
2258
|
const suffixes = [];
|
|
2146
2259
|
const integerPattern = /\b(?:0[xX][0-9A-Fa-f_]+|0[bB][01_]+|[0-9][0-9_]*)i\b/g;
|
|
@@ -2180,13 +2293,14 @@ function parserCompatibleSource(source) {
|
|
|
2180
2293
|
const mask = codeMask(source);
|
|
2181
2294
|
const keywordCompatible = replaceContextualKeywords(source, mask);
|
|
2182
2295
|
const literalCompatible = normalizeIntegerLiteralSuffixes(keywordCompatible, mask);
|
|
2296
|
+
const packCompatible = normalizeGenericTypePackUses(literalCompatible, mask);
|
|
2183
2297
|
const ranges = [
|
|
2184
2298
|
...maskTypeAliases(source, mask),
|
|
2185
2299
|
...maskTypeLevelBlocks(source, mask),
|
|
2186
2300
|
...attributeRanges(source, mask),
|
|
2187
2301
|
...explicitTypeArgumentRanges(source, mask)
|
|
2188
2302
|
];
|
|
2189
|
-
const masked = applyMasks(
|
|
2303
|
+
const masked = applyMasks(packCompatible, ranges);
|
|
2190
2304
|
return masked.replace(/^(\s*)type(?=\s*(?:\+=|-=|\*=|\/=|%=|\^=|\.\.=|=(?!=)))/gm, "$1_typ");
|
|
2191
2305
|
}
|
|
2192
2306
|
|
|
@@ -2399,6 +2513,8 @@ function hasRobloxReceiver(path6, call, context) {
|
|
|
2399
2513
|
}
|
|
2400
2514
|
var runServiceCallCache = new WeakMap;
|
|
2401
2515
|
var runServiceExpressionCache = new WeakMap;
|
|
2516
|
+
var highFrequencyRobloxCallCache = new WeakMap;
|
|
2517
|
+
var highFrequencyRobloxExpressionCache = new WeakMap;
|
|
2402
2518
|
function cachedNodeBoolean(cache, context, node, compute) {
|
|
2403
2519
|
let entries = cache.get(context);
|
|
2404
2520
|
if (!entries) {
|
|
@@ -2463,6 +2579,33 @@ function isHighFrequencyRunServiceCall(call, context) {
|
|
|
2463
2579
|
return Boolean(bind && rootIsRunService(bind[1], call, context));
|
|
2464
2580
|
});
|
|
2465
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
|
+
}
|
|
2466
2609
|
function sourceHasHighFrequencyRunService(source) {
|
|
2467
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)) {
|
|
2468
2611
|
return true;
|
|
@@ -2479,6 +2622,36 @@ function sourceHasHighFrequencyRunService(source) {
|
|
|
2479
2622
|
}
|
|
2480
2623
|
return false;
|
|
2481
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
|
+
}
|
|
2482
2655
|
function knownYieldReason(path6, call, context) {
|
|
2483
2656
|
const normalized = path6.replace(/\s+/g, "");
|
|
2484
2657
|
if (normalized === "task.wait")
|
|
@@ -5025,7 +5198,7 @@ var rerenderHighFrequencyState = {
|
|
|
5025
5198
|
seen.add(key);
|
|
5026
5199
|
const renderCost = componentRenderCost(context, component);
|
|
5027
5200
|
diagnostics.push({
|
|
5028
|
-
node: callNameNode(call),
|
|
5201
|
+
node: callNameNode(binding.call),
|
|
5029
5202
|
severity: "warning",
|
|
5030
5203
|
message: `${binding.valueName} has a state setter called from a per-frame callback and may cause frequent rerenders when its value changes.`,
|
|
5031
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."
|
|
@@ -5302,7 +5475,9 @@ var preferUseRefForMutableCell = {
|
|
|
5302
5475
|
};
|
|
5303
5476
|
|
|
5304
5477
|
// src/rules/prefer-binding-over-state.ts
|
|
5305
|
-
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;
|
|
5306
5481
|
var NON_BINDABLE_HOST_FIELDS = new Set(["ref", "key", "children"]);
|
|
5307
5482
|
function replaceWithinNode(container, target, replacement) {
|
|
5308
5483
|
const start = target.startIndex - container.startIndex;
|
|
@@ -5425,13 +5600,64 @@ var TRANSPARENT_PARENTS = new Set([
|
|
|
5425
5600
|
"string_interpolation",
|
|
5426
5601
|
"expression_list",
|
|
5427
5602
|
"assignment_statement",
|
|
5428
|
-
"arguments",
|
|
5429
|
-
"function_call",
|
|
5430
5603
|
"dot_index_expression",
|
|
5431
5604
|
"method_index_expression",
|
|
5432
5605
|
"table_constructor",
|
|
5433
5606
|
"field"
|
|
5434
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
|
+
}
|
|
5435
5661
|
function isInside2(node, ancestor) {
|
|
5436
5662
|
let current = node;
|
|
5437
5663
|
while (current) {
|
|
@@ -5515,13 +5741,59 @@ function isTransparentUsage(node, context, compatibleComponentProps) {
|
|
|
5515
5741
|
}
|
|
5516
5742
|
if (parent.type === "function_definition" || parent.type === "function_declaration")
|
|
5517
5743
|
return false;
|
|
5518
|
-
if (!
|
|
5744
|
+
if (!isTransparentParent(parent, context))
|
|
5519
5745
|
return false;
|
|
5520
5746
|
current = parent;
|
|
5521
5747
|
}
|
|
5522
5748
|
return false;
|
|
5523
5749
|
}
|
|
5524
|
-
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) {
|
|
5525
5797
|
let current = node;
|
|
5526
5798
|
while (current) {
|
|
5527
5799
|
const parent = current.parent;
|
|
@@ -5531,7 +5803,30 @@ function derivedLocalDeclarationForRead(node) {
|
|
|
5531
5803
|
return parent;
|
|
5532
5804
|
if (parent.type === "function_definition" || parent.type === "function_declaration")
|
|
5533
5805
|
return null;
|
|
5534
|
-
if (!
|
|
5806
|
+
if (!isTransparentParent(parent, context))
|
|
5807
|
+
return null;
|
|
5808
|
+
current = parent;
|
|
5809
|
+
}
|
|
5810
|
+
return null;
|
|
5811
|
+
}
|
|
5812
|
+
function opaqueDerivedLocalDeclarationForRead(node, context) {
|
|
5813
|
+
let current = node;
|
|
5814
|
+
while (current) {
|
|
5815
|
+
const parent = current.parent;
|
|
5816
|
+
if (!parent)
|
|
5817
|
+
return null;
|
|
5818
|
+
if (parent.type === "variable_declaration")
|
|
5819
|
+
return parent;
|
|
5820
|
+
if (parent.type === "function_definition" || parent.type === "function_declaration")
|
|
5821
|
+
return null;
|
|
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")
|
|
5535
5830
|
return null;
|
|
5536
5831
|
current = parent;
|
|
5537
5832
|
}
|
|
@@ -5551,7 +5846,7 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
|
|
|
5551
5846
|
reads += 1;
|
|
5552
5847
|
if (isTransparentUsage(node, context, compatibleComponentProps))
|
|
5553
5848
|
continue;
|
|
5554
|
-
const derivedDeclaration = derivedLocalDeclarationForRead(node);
|
|
5849
|
+
const derivedDeclaration = derivedLocalDeclarationForRead(node, context);
|
|
5555
5850
|
const derivedNames = derivedDeclaration ? declarationNames2(derivedDeclaration) : [];
|
|
5556
5851
|
if (derivedDeclaration && derivedNames.length === 1) {
|
|
5557
5852
|
if (stateReadsAreBindingCompatible(derivedNames[0], owner, derivedDeclaration, context, compatibleComponentProps, new Set(visited)))
|
|
@@ -5561,12 +5856,46 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
|
|
|
5561
5856
|
}
|
|
5562
5857
|
return reads > 0;
|
|
5563
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
|
+
}
|
|
5564
5890
|
function callbackSource(call, context) {
|
|
5565
5891
|
const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
|
|
5566
|
-
const highFrequency =
|
|
5892
|
+
const highFrequency = isHighFrequencyRobloxCall(call, context) || LIKELY_CONTINUOUS_CALLBACK.test(normalized);
|
|
5893
|
+
const hostFeedbackProperty = propertyChangedSignalProperty(call.text);
|
|
5567
5894
|
const final = normalized.split(/[.:]/).at(-1) ?? normalized;
|
|
5568
|
-
const external = highFrequency || EXTERNAL_CALLBACK.test(normalized) || EXTERNAL_CALLBACK.test(final);
|
|
5569
|
-
|
|
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);
|
|
5570
5899
|
}
|
|
5571
5900
|
function importedCallbackSource(call, callbackArgument, importedCallbacks, context) {
|
|
5572
5901
|
const path7 = context.getCallPath(call) ?? "";
|
|
@@ -5577,12 +5906,14 @@ function importedCallbackSource(call, callbackArgument, importedCallbacks, conte
|
|
|
5577
5906
|
const callbackIndex = args.findIndex((arg) => sameNode(arg, callbackArgument));
|
|
5578
5907
|
if (callbackIndex < 0 || !summary.callbackParameterIndexes.includes(callbackIndex))
|
|
5579
5908
|
return null;
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
};
|
|
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);
|
|
5584
5912
|
}
|
|
5585
5913
|
function directCallbackSource(node, importedCallbacks, context) {
|
|
5914
|
+
const reactChangeProperty = reactChangePropertyForCallback(node);
|
|
5915
|
+
if (reactChangeProperty)
|
|
5916
|
+
return evidenceForExternalSource(false, reactChangeProperty);
|
|
5586
5917
|
const parent = node.parent;
|
|
5587
5918
|
if (parent?.type !== "arguments" || parent.parent?.type !== "function_call")
|
|
5588
5919
|
return null;
|
|
@@ -5590,14 +5921,17 @@ function directCallbackSource(node, importedCallbacks, context) {
|
|
|
5590
5921
|
const imported = importedCallbackSource(call, node, importedCallbacks, context);
|
|
5591
5922
|
if (imported)
|
|
5592
5923
|
return imported;
|
|
5593
|
-
|
|
5594
|
-
return source.external ? source : null;
|
|
5924
|
+
return callbackSource(call, context);
|
|
5595
5925
|
}
|
|
5596
5926
|
function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
|
|
5597
5927
|
if (!fn.name || !owner.body)
|
|
5598
5928
|
return null;
|
|
5599
|
-
|
|
5600
|
-
|
|
5929
|
+
const result = {
|
|
5930
|
+
highFrequency: false,
|
|
5931
|
+
external: false,
|
|
5932
|
+
hostFeedbackProperties: new Set,
|
|
5933
|
+
genericExternal: false
|
|
5934
|
+
};
|
|
5601
5935
|
for (const call of context.walk(owner.body)) {
|
|
5602
5936
|
if (call.type !== "function_call")
|
|
5603
5937
|
continue;
|
|
@@ -5605,14 +5939,12 @@ function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
|
|
|
5605
5939
|
const callbackArg = args.find((arg) => arg.type === "identifier" && arg.text === fn.name);
|
|
5606
5940
|
if (!callbackArg)
|
|
5607
5941
|
continue;
|
|
5608
|
-
const
|
|
5609
|
-
|
|
5610
|
-
if (!source.external)
|
|
5942
|
+
const source = importedCallbackSource(call, callbackArg, importedCallbacks, context) ?? callbackSource(call, context);
|
|
5943
|
+
if (!source)
|
|
5611
5944
|
continue;
|
|
5612
|
-
|
|
5613
|
-
foundHighFrequency ||= source.highFrequency;
|
|
5945
|
+
mergeSourceEvidence(result, source);
|
|
5614
5946
|
}
|
|
5615
|
-
return
|
|
5947
|
+
return result.external ? result : null;
|
|
5616
5948
|
}
|
|
5617
5949
|
function setterCallbackSource(call, binding, importedCallbacks, context) {
|
|
5618
5950
|
const owner = binding.owner;
|
|
@@ -5626,25 +5958,178 @@ function setterCallbackSource(call, binding, importedCallbacks, context) {
|
|
|
5626
5958
|
return direct;
|
|
5627
5959
|
return namedFunctionCallbackSource(nearest, owner, importedCallbacks, context);
|
|
5628
5960
|
}
|
|
5629
|
-
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) {
|
|
5630
6089
|
const owner = binding.owner;
|
|
5631
6090
|
if (!owner?.body)
|
|
5632
6091
|
return null;
|
|
5633
|
-
|
|
5634
|
-
|
|
6092
|
+
const result = {
|
|
6093
|
+
highFrequency: false,
|
|
6094
|
+
external: false,
|
|
6095
|
+
hostFeedbackProperties: new Set,
|
|
6096
|
+
genericExternal: false,
|
|
6097
|
+
mirrorConfidence: "none"
|
|
6098
|
+
};
|
|
5635
6099
|
for (const node of context.walk(owner.body)) {
|
|
5636
6100
|
if (node.type !== "function_call")
|
|
5637
6101
|
continue;
|
|
5638
6102
|
const path7 = context.getCallPath(node);
|
|
5639
|
-
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));
|
|
5640
6109
|
continue;
|
|
5641
|
-
|
|
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);
|
|
5642
6118
|
if (!source)
|
|
5643
6119
|
continue;
|
|
5644
|
-
|
|
5645
|
-
external ||= source.external;
|
|
6120
|
+
mergeBindingUpdateEvidence(result, source, "strong");
|
|
5646
6121
|
}
|
|
5647
|
-
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));
|
|
5648
6133
|
}
|
|
5649
6134
|
function returnedHookValues(binding, context) {
|
|
5650
6135
|
const owner = binding.owner;
|
|
@@ -5722,14 +6207,8 @@ function callDefinitelyUsesBindingMode(summary, call, context) {
|
|
|
5722
6207
|
return false;
|
|
5723
6208
|
return truthy === summary.bindingWhenTruthy;
|
|
5724
6209
|
}
|
|
5725
|
-
function
|
|
5726
|
-
|
|
5727
|
-
return false;
|
|
5728
|
-
if (!hasAnyRead(localName, owner, declaration, context))
|
|
5729
|
-
return false;
|
|
5730
|
-
if (summary.sourceKind === "measurement")
|
|
5731
|
-
return true;
|
|
5732
|
-
return stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps);
|
|
6210
|
+
function hasExplicitBindingMode(summary) {
|
|
6211
|
+
return summary.bindingModeParameterIndex !== undefined && summary.bindingWhenTruthy !== undefined;
|
|
5733
6212
|
}
|
|
5734
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;
|
|
5735
6214
|
function createElementPropUsage(node, context) {
|
|
@@ -5758,7 +6237,7 @@ function createElementPropUsage(node, context) {
|
|
|
5758
6237
|
}
|
|
5759
6238
|
if (parent.type === "function_definition" || parent.type === "function_declaration")
|
|
5760
6239
|
return null;
|
|
5761
|
-
if (!
|
|
6240
|
+
if (!isTransparentParent(parent, context))
|
|
5762
6241
|
return null;
|
|
5763
6242
|
current = parent;
|
|
5764
6243
|
}
|
|
@@ -5790,17 +6269,17 @@ function isEffectUsage(node, context) {
|
|
|
5790
6269
|
}
|
|
5791
6270
|
return false;
|
|
5792
6271
|
}
|
|
5793
|
-
function isStructuralUsage(node) {
|
|
6272
|
+
function isStructuralUsage(node, context) {
|
|
5794
6273
|
let current = node;
|
|
5795
6274
|
while (current) {
|
|
5796
6275
|
const parent = current.parent;
|
|
5797
6276
|
if (!parent)
|
|
5798
6277
|
return false;
|
|
5799
|
-
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")
|
|
5800
6279
|
return true;
|
|
5801
6280
|
if (parent.type === "function_definition" || parent.type === "function_declaration")
|
|
5802
6281
|
return false;
|
|
5803
|
-
if (!
|
|
6282
|
+
if (!isTransparentParent(parent, context))
|
|
5804
6283
|
return false;
|
|
5805
6284
|
current = parent;
|
|
5806
6285
|
}
|
|
@@ -5813,11 +6292,11 @@ function classifyBindingUsage(node, context, compatibleComponentProps) {
|
|
|
5813
6292
|
return "custom-component";
|
|
5814
6293
|
if (isEffectUsage(node, context))
|
|
5815
6294
|
return "effect";
|
|
5816
|
-
if (isStructuralUsage(node))
|
|
6295
|
+
if (isStructuralUsage(node, context))
|
|
5817
6296
|
return "structural";
|
|
5818
6297
|
return "unknown";
|
|
5819
6298
|
}
|
|
5820
|
-
function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps) {
|
|
6299
|
+
function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps, visited = new Set) {
|
|
5821
6300
|
const summary = {
|
|
5822
6301
|
reads: 0,
|
|
5823
6302
|
compatible: 0,
|
|
@@ -5829,9 +6308,45 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
|
|
|
5829
6308
|
};
|
|
5830
6309
|
if (!owner.body)
|
|
5831
6310
|
return summary;
|
|
6311
|
+
const visitKey = `${declaration.startIndex}:${valueName}`;
|
|
6312
|
+
if (visited.has(visitKey))
|
|
6313
|
+
return summary;
|
|
6314
|
+
visited.add(visitKey);
|
|
5832
6315
|
for (const node of context.walk(owner.body)) {
|
|
5833
6316
|
if (!isReadNode(node, valueName, owner, declaration))
|
|
5834
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
|
+
}
|
|
5835
6350
|
summary.reads += 1;
|
|
5836
6351
|
const customComponentUsage = createElementPropUsage(node, context);
|
|
5837
6352
|
const kind = classifyBindingUsage(node, context, compatibleComponentProps);
|
|
@@ -5854,11 +6369,94 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
|
|
|
5854
6369
|
function hasCandidatePresentationEvidence(summary) {
|
|
5855
6370
|
return summary.compatible > 0 || summary.visualCustomComponent > 0;
|
|
5856
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
|
+
}
|
|
5857
6455
|
var preferBindingOverState = {
|
|
5858
6456
|
id: "react-luau/prefer-binding-over-state",
|
|
5859
6457
|
category: "Performance",
|
|
5860
6458
|
severity: "warning",
|
|
5861
|
-
description: "Prefer React.useBinding when
|
|
6459
|
+
description: "Prefer React.useBinding when proven external presentation streams do not need React reconciliation.",
|
|
5862
6460
|
run(context) {
|
|
5863
6461
|
const diagnostics = [];
|
|
5864
6462
|
const seen = new Set;
|
|
@@ -5875,75 +6473,72 @@ var preferBindingOverState = {
|
|
|
5875
6473
|
const owner = context.containingComponent(call) ?? context.nearestFunction(call);
|
|
5876
6474
|
if (!declaration || !localName || !owner)
|
|
5877
6475
|
continue;
|
|
5878
|
-
if (
|
|
6476
|
+
if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "warning")
|
|
5879
6477
|
continue;
|
|
5880
6478
|
const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
|
|
5881
6479
|
if (seen.has(key))
|
|
5882
6480
|
continue;
|
|
5883
6481
|
seen.add(key);
|
|
5884
|
-
const hasBindingMode = summary
|
|
5885
|
-
const
|
|
5886
|
-
const
|
|
5887
|
-
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.";
|
|
5888
6488
|
diagnostics.push({
|
|
5889
6489
|
node: callNameNode(call),
|
|
5890
6490
|
message,
|
|
5891
6491
|
help,
|
|
5892
|
-
fixPreview: hasBindingMode ? bindingModeFixPreview(summary, call, context) : undefined
|
|
5893
|
-
});
|
|
5894
|
-
}
|
|
5895
|
-
for (const call of context.findCalls()) {
|
|
5896
|
-
const path7 = context.getCallPath(call) ?? "";
|
|
5897
|
-
const member = path7.split(/[.:]/).at(-1) ?? path7;
|
|
5898
|
-
const bindingAlternative = context.project.bindingApiAlternatives.get(member);
|
|
5899
|
-
if (!bindingAlternative)
|
|
5900
|
-
continue;
|
|
5901
|
-
const declaration = declarationForCall2(call);
|
|
5902
|
-
const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
|
|
5903
|
-
const owner = context.containingComponent(call) ?? context.nearestFunction(call);
|
|
5904
|
-
if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
|
|
5905
|
-
continue;
|
|
5906
|
-
if (!stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps))
|
|
5907
|
-
continue;
|
|
5908
|
-
const field = selectorFieldName(call, context);
|
|
5909
|
-
const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
|
|
5910
|
-
if (seen.has(key))
|
|
5911
|
-
continue;
|
|
5912
|
-
seen.add(key);
|
|
5913
|
-
diagnostics.push({
|
|
5914
|
-
node: callNameNode(call),
|
|
5915
|
-
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.`,
|
|
5916
|
-
help: `Use ${bindingAlternative} so the host property can update without rerendering the component.`,
|
|
5917
|
-
fixPreview: callMemberFixPreview(call, bindingAlternative)
|
|
6492
|
+
fixPreview: hasBindingMode && allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? bindingModeFixPreview(summary, call, context) : undefined
|
|
5918
6493
|
});
|
|
5919
6494
|
}
|
|
5920
6495
|
for (const binding of context.model.stateBindings) {
|
|
5921
6496
|
const owner = binding.owner;
|
|
5922
6497
|
if (!owner || !owner.isComponent && !owner.isHook)
|
|
5923
6498
|
continue;
|
|
5924
|
-
const
|
|
5925
|
-
if (!
|
|
6499
|
+
const update = bindingUpdateEvidence(binding, importedCallbacks, context);
|
|
6500
|
+
if (!update)
|
|
5926
6501
|
continue;
|
|
5927
6502
|
const localCompatible = stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
|
|
5928
6503
|
const hookCompatible = owner.isHook ? hookCallSitesAreBindingCompatible(binding, context, compatibleComponentProps) : false;
|
|
5929
|
-
|
|
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)
|
|
5930
6508
|
continue;
|
|
5931
6509
|
const key = `${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
|
|
5932
6510
|
if (seen.has(key))
|
|
5933
6511
|
continue;
|
|
5934
6512
|
seen.add(key);
|
|
5935
6513
|
if (owner.isHook && hookCompatible) {
|
|
6514
|
+
const properties = likelyContinuousProperty ? [...update.hostFeedbackProperties].sort().join(", ") : "";
|
|
6515
|
+
diagnostics.push({
|
|
6516
|
+
node: callNameNode(binding.call),
|
|
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(", ");
|
|
5936
6522
|
diagnostics.push({
|
|
5937
6523
|
node: callNameNode(binding.call),
|
|
5938
|
-
message: `
|
|
5939
|
-
help: "
|
|
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
|
|
5940
6535
|
});
|
|
5941
6536
|
} else {
|
|
5942
6537
|
diagnostics.push({
|
|
5943
6538
|
node: callNameNode(binding.call),
|
|
5944
|
-
message: `State ${binding.valueName} is updated
|
|
5945
|
-
help: "Prefer React.useBinding
|
|
5946
|
-
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
|
|
5947
6542
|
});
|
|
5948
6543
|
}
|
|
5949
6544
|
}
|
|
@@ -5964,27 +6559,49 @@ var preferBindingOverStateCandidate = {
|
|
|
5964
6559
|
for (const call of context.findCalls()) {
|
|
5965
6560
|
const path7 = context.getCallPath(call) ?? "";
|
|
5966
6561
|
const summary = importedHooks.get(path7);
|
|
5967
|
-
if (!summary
|
|
6562
|
+
if (!summary)
|
|
5968
6563
|
continue;
|
|
5969
6564
|
const declaration = declarationForCall2(call);
|
|
5970
6565
|
const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
|
|
5971
6566
|
const owner = context.containingComponent(call) ?? context.nearestFunction(call);
|
|
5972
|
-
if (!declaration || !localName || !owner
|
|
6567
|
+
if (!declaration || !localName || !owner)
|
|
5973
6568
|
continue;
|
|
5974
|
-
if (
|
|
6569
|
+
if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "candidate")
|
|
5975
6570
|
continue;
|
|
5976
6571
|
const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
|
|
5977
|
-
const
|
|
5978
|
-
|
|
5979
|
-
|
|
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);
|
|
5980
6578
|
const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
|
|
5981
6579
|
if (seen.has(key))
|
|
5982
6580
|
continue;
|
|
5983
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
|
+
}
|
|
5984
6600
|
diagnostics.push({
|
|
5985
6601
|
node: callNameNode(call),
|
|
5986
|
-
message
|
|
5987
|
-
help
|
|
6602
|
+
message,
|
|
6603
|
+
help,
|
|
6604
|
+
fixPreview: presentationOnly && hasBindingMode && allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? bindingModeFixPreview(summary, call, context) : undefined
|
|
5988
6605
|
});
|
|
5989
6606
|
}
|
|
5990
6607
|
for (const call of context.findCalls()) {
|
|
@@ -5998,39 +6615,80 @@ var preferBindingOverStateCandidate = {
|
|
|
5998
6615
|
const owner = context.containingComponent(call) ?? context.nearestFunction(call);
|
|
5999
6616
|
if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
|
|
6000
6617
|
continue;
|
|
6001
|
-
|
|
6618
|
+
const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
|
|
6619
|
+
const presentationOnly = isPresentationOnly(usage);
|
|
6620
|
+
if (!presentationOnly)
|
|
6002
6621
|
continue;
|
|
6622
|
+
const field = selectorFieldName(call, context);
|
|
6003
6623
|
const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
|
|
6004
6624
|
if (seen.has(key))
|
|
6005
6625
|
continue;
|
|
6006
6626
|
seen.add(key);
|
|
6007
6627
|
diagnostics.push({
|
|
6008
6628
|
node: callNameNode(call),
|
|
6009
|
-
message: `${path7}
|
|
6010
|
-
help: `
|
|
6011
|
-
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
|
|
6012
6632
|
});
|
|
6013
6633
|
}
|
|
6014
6634
|
for (const binding of context.model.stateBindings) {
|
|
6015
6635
|
const owner = binding.owner;
|
|
6016
|
-
if (!owner || !owner.isComponent || !owner.body)
|
|
6636
|
+
if (!owner || !owner.isComponent && !owner.isHook || !owner.body)
|
|
6017
6637
|
continue;
|
|
6018
|
-
const
|
|
6019
|
-
if (!
|
|
6638
|
+
const update = bindingUpdateEvidence(binding, importedCallbacks, context);
|
|
6639
|
+
if (!update)
|
|
6020
6640
|
continue;
|
|
6021
|
-
|
|
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
|
+
});
|
|
6022
6661
|
continue;
|
|
6662
|
+
}
|
|
6023
6663
|
const usage = bindingUsageSummary(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
|
|
6024
6664
|
if (!hasCandidatePresentationEvidence(usage))
|
|
6025
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;
|
|
6026
6684
|
const key = `state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
|
|
6027
6685
|
if (seen.has(key))
|
|
6028
6686
|
continue;
|
|
6029
6687
|
seen.add(key);
|
|
6030
6688
|
diagnostics.push({
|
|
6031
6689
|
node: callNameNode(binding.call),
|
|
6032
|
-
message: `State ${binding.valueName}
|
|
6033
|
-
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."
|
|
6034
6692
|
});
|
|
6035
6693
|
}
|
|
6036
6694
|
return diagnostics;
|
|
@@ -7389,6 +8047,16 @@ function nodeLocation(node) {
|
|
|
7389
8047
|
endColumn: node.endPosition.column + 1
|
|
7390
8048
|
};
|
|
7391
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
|
+
}
|
|
7392
8060
|
function toDiagnostic(file, ruleId, category, severity, input) {
|
|
7393
8061
|
const node = input.node;
|
|
7394
8062
|
const highlights = input.highlights?.map(nodeLocation);
|
|
@@ -7446,11 +8114,12 @@ async function analyzeReactFile(input, tree) {
|
|
|
7446
8114
|
if (rule.id === "react-luau/parse-error" && findings.length > 0)
|
|
7447
8115
|
break;
|
|
7448
8116
|
}
|
|
8117
|
+
const filteredDiagnostics = removeSupersededDiagnostics(diagnostics);
|
|
7449
8118
|
return {
|
|
7450
8119
|
relativePath: input.relativePath,
|
|
7451
8120
|
isReactFile: model.isReactFile,
|
|
7452
8121
|
scanned: true,
|
|
7453
|
-
diagnostics
|
|
8122
|
+
diagnostics: filteredDiagnostics
|
|
7454
8123
|
};
|
|
7455
8124
|
}
|
|
7456
8125
|
|
|
@@ -7609,7 +8278,34 @@ class AnalysisWorkerPool {
|
|
|
7609
8278
|
// src/project-model.ts
|
|
7610
8279
|
import fs6 from "fs";
|
|
7611
8280
|
import path7 from "path";
|
|
7612
|
-
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
|
+
]);
|
|
7613
8309
|
var CALLBACK_PARAMETER_NAME = /^(?:callback|handler|listener|subscriber|observer|effect|fn)$/i;
|
|
7614
8310
|
function exportedFunctionName(source) {
|
|
7615
8311
|
return source.match(/\breturn\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/)?.[1] ?? null;
|
|
@@ -7945,9 +8641,62 @@ function findExternalCallbackFunction(source) {
|
|
|
7945
8641
|
return {
|
|
7946
8642
|
name,
|
|
7947
8643
|
callbackParameterIndexes,
|
|
7948
|
-
highFrequency:
|
|
8644
|
+
highFrequency: sourceHasHighFrequencyRobloxEvent(source) || sourceHasLikelyContinuousSubscription(source)
|
|
7949
8645
|
};
|
|
7950
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
|
+
}
|
|
7951
8700
|
function findBindingCandidateHook(source) {
|
|
7952
8701
|
const exported = source.match(/\breturn\s+(use[A-Z0-9_][A-Za-z0-9_]*)\s*$/)?.[1];
|
|
7953
8702
|
if (!exported)
|
|
@@ -7969,18 +8718,35 @@ function findBindingCandidateHook(source) {
|
|
|
7969
8718
|
const setterPassedToSubscription = new RegExp(`(?::|\\.)(?:Connect|Once|Subscribe|Observe|Listen|Watch)\\s*\\(\\s*${escapedSetter}\\b`, "i").test(source);
|
|
7970
8719
|
if (!setterCalled && !setterPassedToSubscription)
|
|
7971
8720
|
return null;
|
|
7972
|
-
const highFrequency =
|
|
8721
|
+
const highFrequency = sourceHasHighFrequencyRobloxEvent(source) || sourceHasLikelyContinuousSubscription(source);
|
|
7973
8722
|
const external = highFrequency || EXTERNAL_UPDATE_SOURCE.test(source);
|
|
7974
8723
|
if (!external)
|
|
7975
8724
|
return null;
|
|
7976
|
-
const
|
|
7977
|
-
const
|
|
7978
|
-
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
|
+
}
|
|
7979
8742
|
return {
|
|
7980
8743
|
name: exported,
|
|
7981
8744
|
highFrequency,
|
|
7982
8745
|
external,
|
|
7983
|
-
sourceKind:
|
|
8746
|
+
sourceKind: instanceProperty ? "instance-property" : "external-state",
|
|
8747
|
+
mirrorConfidence,
|
|
8748
|
+
...instanceProperty && literalPropertyName ? { observedPropertyName: literalPropertyName } : {},
|
|
8749
|
+
...observedPropertyParameterIndex !== undefined ? { observedPropertyParameterIndex } : {},
|
|
7984
8750
|
...dualMode ? {
|
|
7985
8751
|
bindingModeParameterIndex: dualMode.bindingModeParameterIndex,
|
|
7986
8752
|
bindingModeParameterName: dualMode.bindingModeParameterName,
|
|
@@ -8018,7 +8784,8 @@ function findDerivedBindingCandidateHook(source, bindingCandidateHooks) {
|
|
|
8018
8784
|
name: exported,
|
|
8019
8785
|
highFrequency: summary.highFrequency,
|
|
8020
8786
|
external: true,
|
|
8021
|
-
sourceKind: "derived-external-state"
|
|
8787
|
+
sourceKind: "derived-external-state",
|
|
8788
|
+
mirrorConfidence: summary.mirrorConfidence
|
|
8022
8789
|
};
|
|
8023
8790
|
}
|
|
8024
8791
|
return null;
|
|
@@ -8382,7 +9149,11 @@ function findBindingCompatibleComponentProps(source) {
|
|
|
8382
9149
|
}
|
|
8383
9150
|
const compatible = new Set;
|
|
8384
9151
|
for (const [property, offsets] of reads) {
|
|
8385
|
-
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
|
+
}))) {
|
|
8386
9157
|
compatible.add(property);
|
|
8387
9158
|
}
|
|
8388
9159
|
}
|
|
@@ -10705,6 +11476,7 @@ import path12 from "path";
|
|
|
10705
11476
|
var UPDATE_CHECK_INTERVAL_MS = 2 * 60 * 60 * 1000;
|
|
10706
11477
|
var UPDATE_REQUEST_TIMEOUT_MS = 5000;
|
|
10707
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";
|
|
10708
11480
|
function updateCacheFilename() {
|
|
10709
11481
|
return path12.join(cacheBaseDirectory(), UPDATE_CACHE_FILENAME);
|
|
10710
11482
|
}
|
|
@@ -10786,6 +11558,9 @@ function updateRegistryUrl() {
|
|
|
10786
11558
|
const base = registry.endsWith("/") ? registry : `${registry}/`;
|
|
10787
11559
|
return new URL(`${encodeURIComponent(package_default.name)}/latest`, base).toString();
|
|
10788
11560
|
}
|
|
11561
|
+
function changelogUrl() {
|
|
11562
|
+
return process.env.REACT_LUAU_DOCTOR_CHANGELOG_URL ?? CHANGELOG_URL;
|
|
11563
|
+
}
|
|
10789
11564
|
async function fetchLatestVersion() {
|
|
10790
11565
|
const controller = new AbortController;
|
|
10791
11566
|
const timer = setTimeout(() => controller.abort(), UPDATE_REQUEST_TIMEOUT_MS);
|
|
@@ -10806,6 +11581,50 @@ async function fetchLatestVersion() {
|
|
|
10806
11581
|
clearTimeout(timer);
|
|
10807
11582
|
}
|
|
10808
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
|
+
}
|
|
10809
11628
|
function updateCacheIsStale(now = Date.now()) {
|
|
10810
11629
|
const cache = readUpdateCache();
|
|
10811
11630
|
return !cache || now - cache.checkedAt >= UPDATE_CHECK_INTERVAL_MS;
|
|
@@ -10861,7 +11680,7 @@ async function checkForUpdatesNow(currentVersion) {
|
|
|
10861
11680
|
|
|
10862
11681
|
// src/update-install.ts
|
|
10863
11682
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
10864
|
-
import { fileURLToPath } from "url";
|
|
11683
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
10865
11684
|
function normalizedPath(filename) {
|
|
10866
11685
|
return filename.replaceAll("\\", "/");
|
|
10867
11686
|
}
|
|
@@ -10889,7 +11708,7 @@ function updateInstallCommandForPath(filename) {
|
|
|
10889
11708
|
return null;
|
|
10890
11709
|
}
|
|
10891
11710
|
function currentUpdateInstallCommand() {
|
|
10892
|
-
return updateInstallCommandForPath(
|
|
11711
|
+
return updateInstallCommandForPath(fileURLToPath2(import.meta.url));
|
|
10893
11712
|
}
|
|
10894
11713
|
function installLatestVersion(command) {
|
|
10895
11714
|
const result = spawnSync3(command.command, command.args, {
|
|
@@ -10997,10 +11816,12 @@ end, {})`,
|
|
|
10997
11816
|
},
|
|
10998
11817
|
"react-luau/prefer-binding-over-state-candidate": {
|
|
10999
11818
|
before: `local value, setValue = React.useState(0)
|
|
11000
|
-
|
|
11001
|
-
|
|
11002
|
-
local
|
|
11003
|
-
|
|
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."
|
|
11004
11825
|
},
|
|
11005
11826
|
"react-luau/rerender-unstable-memo-props": {
|
|
11006
11827
|
before: `return React.createElement(MemoizedButton, {
|
|
@@ -11432,7 +12253,7 @@ CI commands:
|
|
|
11432
12253
|
|
|
11433
12254
|
Update commands:
|
|
11434
12255
|
update Update the global installation to the latest release
|
|
11435
|
-
update --check Check npm
|
|
12256
|
+
update --check Check npm and show release notes without updating
|
|
11436
12257
|
|
|
11437
12258
|
Rules commands:
|
|
11438
12259
|
rules list [--category <name>] [--configured] [--json]
|
|
@@ -11449,12 +12270,67 @@ Config:
|
|
|
11449
12270
|
function automaticUpdateNoticeEnabled(options, machineReadable) {
|
|
11450
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;
|
|
11451
12272
|
}
|
|
11452
|
-
function
|
|
12273
|
+
function renderUpdateHeader(current, latest, colorized) {
|
|
11453
12274
|
const label = whyPaint(colorized, "Update available:", WHY_ANSI.bold, WHY_ANSI.yellow);
|
|
11454
12275
|
const oldVersion = whyPaint(colorized, `v${current}`, WHY_ANSI.dim);
|
|
11455
12276
|
const newVersion = whyPaint(colorized, `v${latest}`, WHY_ANSI.bold);
|
|
11456
|
-
return `${label} ${oldVersion} \u2192 ${newVersion}
|
|
11457
|
-
|
|
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
|
+
`);
|
|
11458
12334
|
}
|
|
11459
12335
|
async function runUpdateCommand(argv) {
|
|
11460
12336
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
@@ -11473,7 +12349,14 @@ async function runUpdateCommand(argv) {
|
|
|
11473
12349
|
}
|
|
11474
12350
|
const colorized = shouldUseColor(false, false);
|
|
11475
12351
|
if (argv[0] === "--check") {
|
|
11476
|
-
|
|
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)}
|
|
11477
12360
|
`);
|
|
11478
12361
|
return;
|
|
11479
12362
|
}
|
|
@@ -12410,14 +13293,14 @@ function renderWhyCodeFrame(filename, diagnostic, colorized) {
|
|
|
12410
13293
|
}
|
|
12411
13294
|
function whySeverityMeaning(severity) {
|
|
12412
13295
|
if (severity === "error")
|
|
12413
|
-
return "This can
|
|
13296
|
+
return "This can cause incorrect behavior or violate React's rules and should normally be fixed.";
|
|
12414
13297
|
if (severity === "warning")
|
|
12415
13298
|
return "This pattern is very likely to be a real issue in the code shown and is normally worth fixing.";
|
|
12416
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.";
|
|
12417
13300
|
}
|
|
12418
13301
|
function whyRuleSpecificContext(diagnostic) {
|
|
12419
13302
|
if (diagnostic.rule === "react-luau/prefer-binding-over-state-candidate") {
|
|
12420
|
-
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.";
|
|
12421
13304
|
}
|
|
12422
13305
|
if (diagnostic.rule === "react-luau/prefer-binding-over-state") {
|
|
12423
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.";
|
|
@@ -12432,7 +13315,7 @@ function whyRuleSpecificContext(diagnostic) {
|
|
|
12432
13315
|
}
|
|
12433
13316
|
function whyRuleCaveat(diagnostic) {
|
|
12434
13317
|
if (diagnostic.rule === "react-luau/prefer-binding-over-state-candidate") {
|
|
12435
|
-
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.";
|
|
12436
13319
|
}
|
|
12437
13320
|
if (diagnostic.rule === "react-luau/prefer-binding-over-state") {
|
|
12438
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.";
|
|
@@ -12752,5 +13635,5 @@ ${renderUpdateNotice(update.current, update.latest, colorized)}
|
|
|
12752
13635
|
}
|
|
12753
13636
|
main();
|
|
12754
13637
|
|
|
12755
|
-
//# debugId=
|
|
13638
|
+
//# debugId=569DB065E837196F64756E2164756E21
|
|
12756
13639
|
//# sourceMappingURL=cli.js.map
|