@colrealpro/react-luau-doctor 0.18.3 → 0.19.1
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 +27 -0
- package/dist/cli.js +1839 -392
- package/dist/cli.js.map +19 -19
- package/dist/scan-worker.js +1441 -367
- package/dist/scan-worker.js.map +14 -14
- 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.1",
|
|
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 {
|
|
@@ -1022,6 +1071,7 @@ function serializeProjectModel(project) {
|
|
|
1022
1071
|
bindingCompatibleComponentProps: [...project.bindingCompatibleComponentProps].map(([key, value]) => [key, [...value].sort()]),
|
|
1023
1072
|
staticIterationTables: [...project.staticIterationTables].map(([key, value]) => [key, [...value].sort()]),
|
|
1024
1073
|
conditionalHookModes: [...project.conditionalHookModes],
|
|
1074
|
+
reactHookModules: [...project.reactHookModules],
|
|
1025
1075
|
sourceEffects: [...project.sourceEffects].map(([key, value]) => [key, serializeSourceEffect(value)])
|
|
1026
1076
|
};
|
|
1027
1077
|
}
|
|
@@ -1034,6 +1084,7 @@ function deserializeProjectModel(project) {
|
|
|
1034
1084
|
bindingCompatibleComponentProps: new Map(project.bindingCompatibleComponentProps.map(([key, value]) => [key, new Set(value)])),
|
|
1035
1085
|
staticIterationTables: new Map(project.staticIterationTables.map(([key, value]) => [key, new Set(value)])),
|
|
1036
1086
|
conditionalHookModes: new Map(project.conditionalHookModes),
|
|
1087
|
+
reactHookModules: new Map(project.reactHookModules ?? []),
|
|
1037
1088
|
sourceEffects: new Map(project.sourceEffects.map(([key, value]) => [key, deserializeSourceEffect(value)]))
|
|
1038
1089
|
};
|
|
1039
1090
|
}
|
|
@@ -1058,7 +1109,8 @@ function projectModelFeatureKey(project) {
|
|
|
1058
1109
|
bindingApiAlternatives: serialized.bindingApiAlternatives,
|
|
1059
1110
|
bindingCompatibleComponentProps: serialized.bindingCompatibleComponentProps,
|
|
1060
1111
|
staticIterationTables: serialized.staticIterationTables,
|
|
1061
|
-
conditionalHookModes: serialized.conditionalHookModes
|
|
1112
|
+
conditionalHookModes: serialized.conditionalHookModes,
|
|
1113
|
+
reactHookModules: serialized.reactHookModules
|
|
1062
1114
|
});
|
|
1063
1115
|
}
|
|
1064
1116
|
function cachedProjectModel(session) {
|
|
@@ -1121,8 +1173,7 @@ function saveProjectCache(session, project, effectModules = session.previous?.ef
|
|
|
1121
1173
|
reports[reportKey] = { ...report, lastUsedAt: Date.now() };
|
|
1122
1174
|
const prunedReports = Object.fromEntries(Object.entries(reports).sort(([, left], [, right]) => right.lastUsedAt - left.lastUsedAt).slice(0, MAX_CACHED_REPORTS));
|
|
1123
1175
|
writePayload(session.filename, {
|
|
1124
|
-
|
|
1125
|
-
analyzerVersion: package_default.version,
|
|
1176
|
+
analyzerHash: currentAnalyzerHash(),
|
|
1126
1177
|
root: path4.resolve(session.root),
|
|
1127
1178
|
fingerprint: session.fingerprint,
|
|
1128
1179
|
files: session.files,
|
|
@@ -1150,6 +1201,19 @@ function walk(node) {
|
|
|
1150
1201
|
}
|
|
1151
1202
|
return result;
|
|
1152
1203
|
}
|
|
1204
|
+
function parameterBindingNames(parameters) {
|
|
1205
|
+
if (!parameters)
|
|
1206
|
+
return [];
|
|
1207
|
+
const result = [];
|
|
1208
|
+
for (const parameter of parameters.namedChildren) {
|
|
1209
|
+
if (parameter.type !== "parameter")
|
|
1210
|
+
continue;
|
|
1211
|
+
const identifier = parameter.namedChildren.find((child) => child.type === "identifier");
|
|
1212
|
+
if (identifier)
|
|
1213
|
+
result.push(identifier.text);
|
|
1214
|
+
}
|
|
1215
|
+
return [...new Set(result)];
|
|
1216
|
+
}
|
|
1153
1217
|
function rootIdentifier(text) {
|
|
1154
1218
|
const match = text.trim().match(/^([A-Za-z_][A-Za-z0-9_]*)/);
|
|
1155
1219
|
return match?.[1] ?? null;
|
|
@@ -1278,6 +1342,63 @@ function createRuleContext(file) {
|
|
|
1278
1342
|
};
|
|
1279
1343
|
}
|
|
1280
1344
|
|
|
1345
|
+
// src/module-resolution.ts
|
|
1346
|
+
import path5 from "path";
|
|
1347
|
+
function normalizeRelative2(value) {
|
|
1348
|
+
return value.split(path5.sep).join("/");
|
|
1349
|
+
}
|
|
1350
|
+
function moduleKeys(relativePath) {
|
|
1351
|
+
let normalized = normalizeRelative2(relativePath).replace(/\.(?:lua|luau)$/i, "");
|
|
1352
|
+
if (normalized.endsWith("/init"))
|
|
1353
|
+
normalized = normalized.slice(0, -"/init".length);
|
|
1354
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
1355
|
+
if (segments[0]?.toLowerCase() === "src")
|
|
1356
|
+
segments.shift();
|
|
1357
|
+
const keys = new Set;
|
|
1358
|
+
for (let index = 0;index < segments.length; index += 1) {
|
|
1359
|
+
const suffix = segments.slice(index).join(".").toLowerCase();
|
|
1360
|
+
if (suffix)
|
|
1361
|
+
keys.add(suffix);
|
|
1362
|
+
}
|
|
1363
|
+
return [...keys];
|
|
1364
|
+
}
|
|
1365
|
+
function normalizeRequireTarget(text) {
|
|
1366
|
+
return (text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).join(".").toLowerCase();
|
|
1367
|
+
}
|
|
1368
|
+
function buildUniqueFeatureAliases(modules, featureByModuleId) {
|
|
1369
|
+
const owners = new Map;
|
|
1370
|
+
for (const module of modules) {
|
|
1371
|
+
for (const key of module.keys) {
|
|
1372
|
+
const ids = owners.get(key) ?? new Set;
|
|
1373
|
+
ids.add(module.id);
|
|
1374
|
+
owners.set(key, ids);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
const aliases = new Map;
|
|
1378
|
+
for (const module of modules) {
|
|
1379
|
+
const value = featureByModuleId.get(module.id);
|
|
1380
|
+
if (value === undefined)
|
|
1381
|
+
continue;
|
|
1382
|
+
for (const key of module.keys) {
|
|
1383
|
+
const ids = owners.get(key);
|
|
1384
|
+
if (ids?.size === 1 && ids.has(module.id))
|
|
1385
|
+
aliases.set(key, value);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
return aliases;
|
|
1389
|
+
}
|
|
1390
|
+
function resolveModuleReference(target, aliases) {
|
|
1391
|
+
let candidate = target;
|
|
1392
|
+
while (true) {
|
|
1393
|
+
if (aliases.has(candidate))
|
|
1394
|
+
return aliases.get(candidate);
|
|
1395
|
+
const separator = candidate.indexOf(".");
|
|
1396
|
+
if (separator < 0)
|
|
1397
|
+
return null;
|
|
1398
|
+
candidate = candidate.slice(separator + 1);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1281
1402
|
// src/ast/react-model.ts
|
|
1282
1403
|
var REACT_HOOKS = new Set([
|
|
1283
1404
|
"useState",
|
|
@@ -1308,18 +1429,7 @@ var INSTANCE_FACTORY_MEMBERS = new Set([
|
|
|
1308
1429
|
]);
|
|
1309
1430
|
var STABLE_ENGINE_FACTORY_MEMBERS = new Set(["GetMouse", "GetService"]);
|
|
1310
1431
|
function identifiersFromParameters(node) {
|
|
1311
|
-
|
|
1312
|
-
return [];
|
|
1313
|
-
const result = [];
|
|
1314
|
-
for (const child of walk(node)) {
|
|
1315
|
-
if (child.type !== "identifier")
|
|
1316
|
-
continue;
|
|
1317
|
-
const parentType = child.parent?.type;
|
|
1318
|
-
if (parentType === "parameters" || parentType === "parameter" || parentType === "typed_identifier") {
|
|
1319
|
-
result.push(child.text);
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
return [...new Set(result)];
|
|
1432
|
+
return parameterBindingNames(node);
|
|
1323
1433
|
}
|
|
1324
1434
|
function getFunctionName(node) {
|
|
1325
1435
|
if (node.type === "function_declaration") {
|
|
@@ -1373,15 +1483,19 @@ function directCalls(node) {
|
|
|
1373
1483
|
function hasDirectCreateElementCall(node, aliases, reactNamespaces, reactRobloxNamespaces) {
|
|
1374
1484
|
return directCalls(node).some((call) => {
|
|
1375
1485
|
const name = call.childForFieldName("name")?.text ?? "";
|
|
1376
|
-
const
|
|
1377
|
-
return
|
|
1486
|
+
const path6 = resolvePath(name, aliases, reactNamespaces, reactRobloxNamespaces);
|
|
1487
|
+
return path6 === "React.createElement" || path6 === "ReactRoblox.createPortal";
|
|
1378
1488
|
});
|
|
1379
1489
|
}
|
|
1380
|
-
function hasDirectHookCall(node, aliases, reactNamespaces, reactRobloxNamespaces) {
|
|
1490
|
+
function hasDirectHookCall(node, aliases, reactNamespaces, reactRobloxNamespaces, importedHookKinds) {
|
|
1381
1491
|
return directCalls(node).some((call) => {
|
|
1382
1492
|
const name = call.childForFieldName("name")?.text ?? "";
|
|
1383
|
-
const
|
|
1384
|
-
const
|
|
1493
|
+
const path6 = resolvePath(name, aliases, reactNamespaces, reactRobloxNamespaces);
|
|
1494
|
+
const normalizedName = normalizeExpressionText(name);
|
|
1495
|
+
const importedKind = importedHookKinds.get(normalizedName);
|
|
1496
|
+
if (importedKind !== undefined)
|
|
1497
|
+
return importedKind;
|
|
1498
|
+
const final = path6.split(/[.:]/).at(-1) ?? "";
|
|
1385
1499
|
return /^use[A-Z0-9_]/.test(final);
|
|
1386
1500
|
});
|
|
1387
1501
|
}
|
|
@@ -1467,8 +1581,8 @@ function isSyntacticallyStableInitializer(node) {
|
|
|
1467
1581
|
return false;
|
|
1468
1582
|
return ["nil", "true", "false", "number", "string", "string_content"].includes(node.type) || /^(?:nil|true|false|-?\d+(?:\.\d+)?(?:e[+-]?\d+)?)$/i.test(node.text.trim());
|
|
1469
1583
|
}
|
|
1470
|
-
function finalCallMember(
|
|
1471
|
-
return
|
|
1584
|
+
function finalCallMember(path6) {
|
|
1585
|
+
return path6.split(/[.:]/).at(-1) ?? path6;
|
|
1472
1586
|
}
|
|
1473
1587
|
function expressionCreatesInstance(node, aliases, reactNamespaces, reactRobloxNamespaces) {
|
|
1474
1588
|
if (!node)
|
|
@@ -1480,8 +1594,8 @@ function expressionCreatesInstance(node, aliases, reactNamespaces, reactRobloxNa
|
|
|
1480
1594
|
if (candidate.type !== "function_call")
|
|
1481
1595
|
continue;
|
|
1482
1596
|
const rawPath = candidate.childForFieldName("name")?.text ?? "";
|
|
1483
|
-
const
|
|
1484
|
-
if (
|
|
1597
|
+
const path6 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
|
|
1598
|
+
if (path6 === "Instance.new" || INSTANCE_FACTORY_MEMBERS.has(finalCallMember(path6)))
|
|
1485
1599
|
return true;
|
|
1486
1600
|
}
|
|
1487
1601
|
return false;
|
|
@@ -1493,8 +1607,8 @@ function expressionIsStableEngineObject(node, aliases, reactNamespaces, reactRob
|
|
|
1493
1607
|
if (calls.length !== 1)
|
|
1494
1608
|
return false;
|
|
1495
1609
|
const rawPath = calls[0].childForFieldName("name")?.text ?? "";
|
|
1496
|
-
const
|
|
1497
|
-
return STABLE_ENGINE_FACTORY_MEMBERS.has(finalCallMember(
|
|
1610
|
+
const path6 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
|
|
1611
|
+
return STABLE_ENGINE_FACTORY_MEMBERS.has(finalCallMember(path6));
|
|
1498
1612
|
}
|
|
1499
1613
|
function memoReturnsInstance(callback, aliases, reactNamespaces, reactRobloxNamespaces) {
|
|
1500
1614
|
if (!callback || callback.type !== "function_definition")
|
|
@@ -1510,7 +1624,21 @@ function memoReturnsInstance(callback, aliases, reactNamespaces, reactRobloxName
|
|
|
1510
1624
|
function isEmptyDependencyTable(node) {
|
|
1511
1625
|
return Boolean(node?.type === "table_constructor" && node.namedChildren.every((child) => child.type !== "field"));
|
|
1512
1626
|
}
|
|
1513
|
-
function
|
|
1627
|
+
function importedHookKinds(declarations, project) {
|
|
1628
|
+
const result = new Map;
|
|
1629
|
+
if (!project)
|
|
1630
|
+
return result;
|
|
1631
|
+
for (const declaration of declarations) {
|
|
1632
|
+
const match = declaration.text.match(/^\s*local\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)\s*$/s);
|
|
1633
|
+
if (!match || !/^use[A-Z0-9_]/.test(match[1]))
|
|
1634
|
+
continue;
|
|
1635
|
+
const kind = resolveModuleReference(normalizeRequireTarget(match[2]), project.reactHookModules);
|
|
1636
|
+
if (kind !== null)
|
|
1637
|
+
result.set(match[1], kind);
|
|
1638
|
+
}
|
|
1639
|
+
return result;
|
|
1640
|
+
}
|
|
1641
|
+
function buildReactModel(root, project) {
|
|
1514
1642
|
const reactNamespaces = new Set;
|
|
1515
1643
|
const reactRobloxNamespaces = new Set;
|
|
1516
1644
|
const aliases = new Map;
|
|
@@ -1526,6 +1654,7 @@ function buildReactModel(root) {
|
|
|
1526
1654
|
const instanceVariablesByFunction = new Map;
|
|
1527
1655
|
const allNodes = [...walk(root)];
|
|
1528
1656
|
const declarations = allNodes.filter((node) => node.type === "variable_declaration");
|
|
1657
|
+
const projectHookKinds = importedHookKinds(declarations, project);
|
|
1529
1658
|
let isReactFile = false;
|
|
1530
1659
|
for (const declaration of declarations) {
|
|
1531
1660
|
const text = declaration.text;
|
|
@@ -1600,7 +1729,7 @@ function buildReactModel(root) {
|
|
|
1600
1729
|
if (!isReactFile || !info.body || info.isHook)
|
|
1601
1730
|
continue;
|
|
1602
1731
|
const simplePascalName = Boolean(info.name && /^[A-Z][A-Za-z0-9_]*$/.test(info.name));
|
|
1603
|
-
const hasHooks = hasDirectHookCall(info.body, aliases, reactNamespaces, reactRobloxNamespaces);
|
|
1732
|
+
const hasHooks = hasDirectHookCall(info.body, aliases, reactNamespaces, reactRobloxNamespaces, projectHookKinds);
|
|
1604
1733
|
const renders = hasDirectCreateElementCall(info.body, aliases, reactNamespaces, reactRobloxNamespaces);
|
|
1605
1734
|
if (hasHooks || isMemoWrappedFunction(info.node, aliases, reactNamespaces) || simplePascalName && renders) {
|
|
1606
1735
|
info.isComponent = true;
|
|
@@ -1612,8 +1741,8 @@ function buildReactModel(root) {
|
|
|
1612
1741
|
if (call.type !== "function_call")
|
|
1613
1742
|
continue;
|
|
1614
1743
|
const rawPath = call.childForFieldName("name")?.text ?? "";
|
|
1615
|
-
const
|
|
1616
|
-
if (
|
|
1744
|
+
const path6 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
|
|
1745
|
+
if (path6 !== "React.createElement")
|
|
1617
1746
|
continue;
|
|
1618
1747
|
const element = call.childForFieldName("arguments")?.namedChildren[0]?.text.trim() ?? "";
|
|
1619
1748
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(element))
|
|
@@ -1648,13 +1777,13 @@ function buildReactModel(root) {
|
|
|
1648
1777
|
if (!call)
|
|
1649
1778
|
continue;
|
|
1650
1779
|
const rawPath = call.childForFieldName("name")?.text ?? "";
|
|
1651
|
-
const
|
|
1780
|
+
const path6 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
|
|
1652
1781
|
const args = call.childForFieldName("arguments")?.namedChildren ?? [];
|
|
1653
|
-
if (
|
|
1782
|
+
if (path6 === "React.joinBindings" && names.length >= 1) {
|
|
1654
1783
|
stableVariables.add(names[0]);
|
|
1655
1784
|
addScopedVariable(stableVariablesByFunction, owner, names[0]);
|
|
1656
1785
|
addScopedVariable(externalMutableVariablesByFunction, owner, names[0]);
|
|
1657
|
-
} else if (
|
|
1786
|
+
} else if (path6 === "React.useState" && names.length >= 2) {
|
|
1658
1787
|
const [valueName, setterName] = names;
|
|
1659
1788
|
stateSetters.set(setterName, valueName);
|
|
1660
1789
|
stateValues.set(valueName, setterName);
|
|
@@ -1668,10 +1797,10 @@ function buildReactModel(root) {
|
|
|
1668
1797
|
initializer: args[0] ?? null,
|
|
1669
1798
|
owner
|
|
1670
1799
|
});
|
|
1671
|
-
} else if (
|
|
1800
|
+
} else if (path6 === "React.useReducer" && names.length >= 2) {
|
|
1672
1801
|
stableVariables.add(names[1]);
|
|
1673
1802
|
addScopedVariable(stableVariablesByFunction, owner, names[1]);
|
|
1674
|
-
} else if (
|
|
1803
|
+
} else if (path6 === "React.useBinding" && names.length >= 1) {
|
|
1675
1804
|
stableVariables.add(names[0]);
|
|
1676
1805
|
addScopedVariable(stableVariablesByFunction, owner, names[0]);
|
|
1677
1806
|
if (names.length >= 2) {
|
|
@@ -1679,7 +1808,7 @@ function buildReactModel(root) {
|
|
|
1679
1808
|
bindingSetters.add(names[1]);
|
|
1680
1809
|
addScopedVariable(stableVariablesByFunction, owner, names[1]);
|
|
1681
1810
|
}
|
|
1682
|
-
} else if (
|
|
1811
|
+
} else if (path6 === "React.useMemo" && names.length >= 1) {
|
|
1683
1812
|
if (isEmptyDependencyTable(args[1])) {
|
|
1684
1813
|
stableVariables.add(names[0]);
|
|
1685
1814
|
addScopedVariable(stableVariablesByFunction, owner, names[0]);
|
|
@@ -1688,16 +1817,16 @@ function buildReactModel(root) {
|
|
|
1688
1817
|
addScopedVariable(externalMutableVariablesByFunction, owner, names[0]);
|
|
1689
1818
|
addScopedVariable(instanceVariablesByFunction, owner, names[0]);
|
|
1690
1819
|
}
|
|
1691
|
-
} else if (
|
|
1820
|
+
} else if (path6 === "React.useCallback" && names.length >= 1 && isEmptyDependencyTable(args[1])) {
|
|
1692
1821
|
stableVariables.add(names[0]);
|
|
1693
1822
|
addScopedVariable(stableVariablesByFunction, owner, names[0]);
|
|
1694
|
-
} else if (
|
|
1823
|
+
} else if (path6 === "React.useRef" && names.length >= 1) {
|
|
1695
1824
|
stableVariables.add(names[0]);
|
|
1696
1825
|
refVariables.add(names[0]);
|
|
1697
1826
|
addScopedVariable(stableVariablesByFunction, owner, names[0]);
|
|
1698
1827
|
addScopedVariable(refVariablesByFunction, owner, names[0]);
|
|
1699
1828
|
} else {
|
|
1700
|
-
const final =
|
|
1829
|
+
const final = path6.split(/[.:]/).at(-1) ?? "";
|
|
1701
1830
|
if (/^use[A-Z0-9_]/.test(final)) {
|
|
1702
1831
|
for (const name of names.slice(1)) {
|
|
1703
1832
|
if (!STABLE_CUSTOM_HOOK_RETURN.test(name))
|
|
@@ -1721,11 +1850,11 @@ function buildReactModel(root) {
|
|
|
1721
1850
|
if (!expression || expression.type !== "function_call")
|
|
1722
1851
|
continue;
|
|
1723
1852
|
const rawPath = expression.childForFieldName("name")?.text ?? "";
|
|
1724
|
-
const
|
|
1725
|
-
if (
|
|
1853
|
+
const path6 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
|
|
1854
|
+
if (path6 === "React.useState" && names.length >= 2) {
|
|
1726
1855
|
stableVariables.add(names[1]);
|
|
1727
1856
|
addScopedVariable(stableVariablesByFunction, owner, names[1]);
|
|
1728
|
-
} else if (
|
|
1857
|
+
} else if (path6 === "React.useBinding" && names.length >= 1) {
|
|
1729
1858
|
stableVariables.add(names[0]);
|
|
1730
1859
|
addScopedVariable(stableVariablesByFunction, owner, names[0]);
|
|
1731
1860
|
addScopedVariable(externalMutableVariablesByFunction, owner, names[0]);
|
|
@@ -1840,7 +1969,7 @@ function createInlineSuppressionChecker(source) {
|
|
|
1840
1969
|
|
|
1841
1970
|
// src/parser.ts
|
|
1842
1971
|
import fs5 from "fs";
|
|
1843
|
-
import
|
|
1972
|
+
import path6 from "path";
|
|
1844
1973
|
import { Language, Parser } from "web-tree-sitter";
|
|
1845
1974
|
|
|
1846
1975
|
// src/parser-compat.ts
|
|
@@ -1979,6 +2108,23 @@ function isCode(mask, start, end = start + 1) {
|
|
|
1979
2108
|
}
|
|
1980
2109
|
return true;
|
|
1981
2110
|
}
|
|
2111
|
+
function matchingCodeDelimiterEnd(source, mask, start, opener, closer) {
|
|
2112
|
+
if (source[start] !== opener || !isCode(mask, start))
|
|
2113
|
+
return null;
|
|
2114
|
+
let depth = 0;
|
|
2115
|
+
for (let cursor = start;cursor < source.length; cursor += 1) {
|
|
2116
|
+
if (!isCode(mask, cursor))
|
|
2117
|
+
continue;
|
|
2118
|
+
if (source[cursor] === opener)
|
|
2119
|
+
depth += 1;
|
|
2120
|
+
else if (source[cursor] === closer) {
|
|
2121
|
+
depth -= 1;
|
|
2122
|
+
if (depth === 0)
|
|
2123
|
+
return cursor + 1;
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
return null;
|
|
2127
|
+
}
|
|
1982
2128
|
function maskTypeAliases(source, mask) {
|
|
1983
2129
|
const { lines: lines2, offsets } = lineData(source);
|
|
1984
2130
|
const ranges = [];
|
|
@@ -2186,6 +2332,89 @@ function genericFunctionParameterRanges(source, mask) {
|
|
|
2186
2332
|
}
|
|
2187
2333
|
return ranges;
|
|
2188
2334
|
}
|
|
2335
|
+
function genericFunctionReturnPrefixRanges(source, mask) {
|
|
2336
|
+
const ranges = [];
|
|
2337
|
+
const functionPattern = /\bfunction\b/g;
|
|
2338
|
+
for (const match of source.matchAll(functionPattern)) {
|
|
2339
|
+
const start = match.index;
|
|
2340
|
+
if (!isCode(mask, start, start + match[0].length))
|
|
2341
|
+
continue;
|
|
2342
|
+
let cursor = start + match[0].length;
|
|
2343
|
+
let genericDepth = 0;
|
|
2344
|
+
while (cursor < source.length) {
|
|
2345
|
+
if (!isCode(mask, cursor)) {
|
|
2346
|
+
cursor += 1;
|
|
2347
|
+
continue;
|
|
2348
|
+
}
|
|
2349
|
+
if (source[cursor] === "<")
|
|
2350
|
+
genericDepth += 1;
|
|
2351
|
+
else if (source[cursor] === ">" && genericDepth > 0)
|
|
2352
|
+
genericDepth -= 1;
|
|
2353
|
+
else if (source[cursor] === "(" && genericDepth === 0)
|
|
2354
|
+
break;
|
|
2355
|
+
cursor += 1;
|
|
2356
|
+
}
|
|
2357
|
+
if (source[cursor] !== "(")
|
|
2358
|
+
continue;
|
|
2359
|
+
const parameterEnd = matchingCodeDelimiterEnd(source, mask, cursor, "(", ")");
|
|
2360
|
+
if (parameterEnd === null)
|
|
2361
|
+
continue;
|
|
2362
|
+
cursor = parameterEnd;
|
|
2363
|
+
while (cursor < source.length && /\s/.test(source[cursor]))
|
|
2364
|
+
cursor += 1;
|
|
2365
|
+
if (source[cursor] !== ":" || !isCode(mask, cursor))
|
|
2366
|
+
continue;
|
|
2367
|
+
cursor += 1;
|
|
2368
|
+
while (cursor < source.length && /\s/.test(source[cursor]))
|
|
2369
|
+
cursor += 1;
|
|
2370
|
+
if (source[cursor] !== "<")
|
|
2371
|
+
continue;
|
|
2372
|
+
const rangeStart = cursor;
|
|
2373
|
+
const genericEnd = matchingCodeDelimiterEnd(source, mask, cursor, "<", ">");
|
|
2374
|
+
if (genericEnd === null)
|
|
2375
|
+
continue;
|
|
2376
|
+
cursor = genericEnd;
|
|
2377
|
+
while (cursor < source.length && /\s/.test(source[cursor]))
|
|
2378
|
+
cursor += 1;
|
|
2379
|
+
const typeParametersEnd = matchingCodeDelimiterEnd(source, mask, cursor, "(", ")");
|
|
2380
|
+
if (typeParametersEnd === null)
|
|
2381
|
+
continue;
|
|
2382
|
+
cursor = typeParametersEnd;
|
|
2383
|
+
while (cursor < source.length && /\s/.test(source[cursor]))
|
|
2384
|
+
cursor += 1;
|
|
2385
|
+
if (source.slice(cursor, cursor + 2) !== "->" || !isCode(mask, cursor, cursor + 2))
|
|
2386
|
+
continue;
|
|
2387
|
+
cursor += 2;
|
|
2388
|
+
while (cursor < source.length && /[ \t]/.test(source[cursor]))
|
|
2389
|
+
cursor += 1;
|
|
2390
|
+
ranges.push({ start: rangeStart, end: cursor });
|
|
2391
|
+
}
|
|
2392
|
+
return ranges;
|
|
2393
|
+
}
|
|
2394
|
+
function normalizeLocalTypeofAnnotations(source, mask) {
|
|
2395
|
+
const replacements = [];
|
|
2396
|
+
const pattern = /\blocal\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*typeof\s*\(/g;
|
|
2397
|
+
for (const match of source.matchAll(pattern)) {
|
|
2398
|
+
const matchStart = match.index;
|
|
2399
|
+
if (!isCode(mask, matchStart, matchStart + match[0].length))
|
|
2400
|
+
continue;
|
|
2401
|
+
const typeofStart = matchStart + match[0].lastIndexOf("typeof");
|
|
2402
|
+
const open = source.indexOf("(", typeofStart + 6);
|
|
2403
|
+
if (open < 0)
|
|
2404
|
+
continue;
|
|
2405
|
+
const end = matchingCodeDelimiterEnd(source, mask, open, "(", ")");
|
|
2406
|
+
if (end === null)
|
|
2407
|
+
continue;
|
|
2408
|
+
const original = source.slice(typeofStart, end);
|
|
2409
|
+
const padding = blankPreservingUtf8Bytes(original).slice(3);
|
|
2410
|
+
replacements.push({ start: typeofStart, end, value: `any${padding}` });
|
|
2411
|
+
}
|
|
2412
|
+
let output = source;
|
|
2413
|
+
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
|
|
2414
|
+
output = `${output.slice(0, replacement.start)}${replacement.value}${output.slice(replacement.end)}`;
|
|
2415
|
+
}
|
|
2416
|
+
return output;
|
|
2417
|
+
}
|
|
2189
2418
|
function normalizeGenericTypePackUses(source, mask) {
|
|
2190
2419
|
const declarationRanges = genericFunctionParameterRanges(source, mask);
|
|
2191
2420
|
const replacements = [];
|
|
@@ -2246,13 +2475,15 @@ function parserCompatibleSource(source) {
|
|
|
2246
2475
|
const keywordCompatible = replaceContextualKeywords(source, mask);
|
|
2247
2476
|
const literalCompatible = normalizeIntegerLiteralSuffixes(keywordCompatible, mask);
|
|
2248
2477
|
const packCompatible = normalizeGenericTypePackUses(literalCompatible, mask);
|
|
2478
|
+
const typeofCompatible = normalizeLocalTypeofAnnotations(packCompatible, mask);
|
|
2249
2479
|
const ranges = [
|
|
2250
2480
|
...maskTypeAliases(source, mask),
|
|
2251
2481
|
...maskTypeLevelBlocks(source, mask),
|
|
2252
2482
|
...attributeRanges(source, mask),
|
|
2253
|
-
...explicitTypeArgumentRanges(source, mask)
|
|
2483
|
+
...explicitTypeArgumentRanges(source, mask),
|
|
2484
|
+
...genericFunctionReturnPrefixRanges(source, mask)
|
|
2254
2485
|
];
|
|
2255
|
-
const masked = applyMasks(
|
|
2486
|
+
const masked = applyMasks(typeofCompatible, ranges);
|
|
2256
2487
|
return masked.replace(/^(\s*)type(?=\s*(?:\+=|-=|\*=|\/=|%=|\^=|\.\.=|=(?!=)))/gm, "$1_typ");
|
|
2257
2488
|
}
|
|
2258
2489
|
|
|
@@ -2260,7 +2491,7 @@ function parserCompatibleSource(source) {
|
|
|
2260
2491
|
var parserPromise = null;
|
|
2261
2492
|
async function createParser() {
|
|
2262
2493
|
await Parser.init();
|
|
2263
|
-
const grammarPath =
|
|
2494
|
+
const grammarPath = path6.resolve(import.meta.dir, "../vendor/tree-sitter-luau.wasm");
|
|
2264
2495
|
const grammar = new Uint8Array(fs5.readFileSync(grammarPath));
|
|
2265
2496
|
const language = await Language.load(grammar);
|
|
2266
2497
|
const parser = new Parser;
|
|
@@ -2385,8 +2616,8 @@ var INSTANCE_FACTORY_MEMBERS2 = new Set([
|
|
|
2385
2616
|
"GetService",
|
|
2386
2617
|
"WaitForChild"
|
|
2387
2618
|
]);
|
|
2388
|
-
function finalCallMember2(
|
|
2389
|
-
return
|
|
2619
|
+
function finalCallMember2(path7) {
|
|
2620
|
+
return path7.split(/[.:]/).at(-1) ?? path7;
|
|
2390
2621
|
}
|
|
2391
2622
|
function declarationInitializer(node, name) {
|
|
2392
2623
|
if (node.type !== "variable_declaration")
|
|
@@ -2454,8 +2685,8 @@ function expressionHasRobloxOrigin(expression, from, context, seen) {
|
|
|
2454
2685
|
seen.add(root);
|
|
2455
2686
|
return expressionHasRobloxOrigin(visibleInitializer(root, from, context), from, context, seen);
|
|
2456
2687
|
}
|
|
2457
|
-
function hasRobloxReceiver(
|
|
2458
|
-
const normalized =
|
|
2688
|
+
function hasRobloxReceiver(path7, call, context) {
|
|
2689
|
+
const normalized = path7.replace(/\s+/g, "");
|
|
2459
2690
|
if (/^(?:game|workspace|script)(?:[.:]|$)/.test(normalized))
|
|
2460
2691
|
return true;
|
|
2461
2692
|
const root = normalized.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1];
|
|
@@ -2465,6 +2696,8 @@ function hasRobloxReceiver(path6, call, context) {
|
|
|
2465
2696
|
}
|
|
2466
2697
|
var runServiceCallCache = new WeakMap;
|
|
2467
2698
|
var runServiceExpressionCache = new WeakMap;
|
|
2699
|
+
var highFrequencyRobloxCallCache = new WeakMap;
|
|
2700
|
+
var highFrequencyRobloxExpressionCache = new WeakMap;
|
|
2468
2701
|
function cachedNodeBoolean(cache, context, node, compute) {
|
|
2469
2702
|
let entries = cache.get(context);
|
|
2470
2703
|
if (!entries) {
|
|
@@ -2529,6 +2762,33 @@ function isHighFrequencyRunServiceCall(call, context) {
|
|
|
2529
2762
|
return Boolean(bind && rootIsRunService(bind[1], call, context));
|
|
2530
2763
|
});
|
|
2531
2764
|
}
|
|
2765
|
+
var HIGH_FREQUENCY_ROBLOX_EVENTS = new Set([
|
|
2766
|
+
"InputChanged",
|
|
2767
|
+
"TouchMoved",
|
|
2768
|
+
"Move"
|
|
2769
|
+
]);
|
|
2770
|
+
function isHighFrequencyRobloxExpression(node, context) {
|
|
2771
|
+
return cachedNodeBoolean(highFrequencyRobloxExpressionCache, context, node, () => {
|
|
2772
|
+
if (isHighFrequencyRunServiceExpression(node, context))
|
|
2773
|
+
return true;
|
|
2774
|
+
const normalized = node.text.replace(/\s+/g, "");
|
|
2775
|
+
const match = normalized.match(/^(.+)\.(InputChanged|TouchMoved|Move)$/);
|
|
2776
|
+
if (!match || !HIGH_FREQUENCY_ROBLOX_EVENTS.has(match[2]))
|
|
2777
|
+
return false;
|
|
2778
|
+
return hasRobloxReceiver(match[1], node, context);
|
|
2779
|
+
});
|
|
2780
|
+
}
|
|
2781
|
+
function isHighFrequencyRobloxCall(call, context) {
|
|
2782
|
+
return cachedNodeBoolean(highFrequencyRobloxCallCache, context, call, () => {
|
|
2783
|
+
if (isHighFrequencyRunServiceCall(call, context))
|
|
2784
|
+
return true;
|
|
2785
|
+
const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
|
|
2786
|
+
const event = normalized.match(/^(.+)\.(InputChanged|TouchMoved|Move):Connect$/);
|
|
2787
|
+
if (!event || !HIGH_FREQUENCY_ROBLOX_EVENTS.has(event[2]))
|
|
2788
|
+
return false;
|
|
2789
|
+
return hasRobloxReceiver(event[1], call, context);
|
|
2790
|
+
});
|
|
2791
|
+
}
|
|
2532
2792
|
function sourceHasHighFrequencyRunService(source) {
|
|
2533
2793
|
if (/game\s*:\s*GetService\s*\(\s*["']RunService["']\s*\)\s*(?:\.\s*(?:RenderStepped|Heartbeat|Stepped|PreRender|PreSimulation|PostSimulation)\s*:\s*Connect|:\s*(?:BindToRenderStep|BindToSimulation))/.test(source)) {
|
|
2534
2794
|
return true;
|
|
@@ -2545,8 +2805,38 @@ function sourceHasHighFrequencyRunService(source) {
|
|
|
2545
2805
|
}
|
|
2546
2806
|
return false;
|
|
2547
2807
|
}
|
|
2548
|
-
function
|
|
2549
|
-
|
|
2808
|
+
function sourceHasHighFrequencyRobloxEvent(source) {
|
|
2809
|
+
if (sourceHasHighFrequencyRunService(source))
|
|
2810
|
+
return true;
|
|
2811
|
+
if (/game\s*:\s*GetService\s*\(\s*["']UserInputService["']\s*\)\s*\.\s*(?:InputChanged|TouchMoved)\s*:\s*Connect/.test(source)) {
|
|
2812
|
+
return true;
|
|
2813
|
+
}
|
|
2814
|
+
const serviceAliases = new Set;
|
|
2815
|
+
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)) {
|
|
2816
|
+
serviceAliases.add(match[1]);
|
|
2817
|
+
}
|
|
2818
|
+
for (const alias of serviceAliases) {
|
|
2819
|
+
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2820
|
+
if (new RegExp(`\\b${escaped}\\s*\\.\\s*(?:InputChanged|TouchMoved)\\s*:\\s*Connect`).test(source)) {
|
|
2821
|
+
return true;
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
const playersAliases = new Set;
|
|
2825
|
+
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)) {
|
|
2826
|
+
playersAliases.add(match[1]);
|
|
2827
|
+
}
|
|
2828
|
+
for (const players of playersAliases) {
|
|
2829
|
+
const escapedPlayers = players.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2830
|
+
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"))) {
|
|
2831
|
+
const mouse = match[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2832
|
+
if (new RegExp(`\\b${mouse}\\s*\\.\\s*Move\\s*:\\s*Connect`).test(source))
|
|
2833
|
+
return true;
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
return false;
|
|
2837
|
+
}
|
|
2838
|
+
function knownYieldReason(path7, call, context) {
|
|
2839
|
+
const normalized = path7.replace(/\s+/g, "");
|
|
2550
2840
|
if (normalized === "task.wait")
|
|
2551
2841
|
return "task.wait";
|
|
2552
2842
|
if (normalized === "coroutine.yield")
|
|
@@ -2566,15 +2856,15 @@ function functionYieldPoint(node, context) {
|
|
|
2566
2856
|
const owner = context.model.functionByNode.get(node.id);
|
|
2567
2857
|
if (owner && nearest !== owner)
|
|
2568
2858
|
continue;
|
|
2569
|
-
const
|
|
2570
|
-
const reason = knownYieldReason(
|
|
2859
|
+
const path7 = context.resolveCallPath(context.getCallPath(candidate) ?? "");
|
|
2860
|
+
const reason = knownYieldReason(path7, candidate, context);
|
|
2571
2861
|
if (reason)
|
|
2572
2862
|
return { call: candidate, reason };
|
|
2573
2863
|
}
|
|
2574
2864
|
return null;
|
|
2575
2865
|
}
|
|
2576
|
-
function mutableDependencyBase(
|
|
2577
|
-
const normalized =
|
|
2866
|
+
function mutableDependencyBase(path7, externallyMutableRoots) {
|
|
2867
|
+
const normalized = path7.replace(/\s+/g, "");
|
|
2578
2868
|
if (/\.current(?:\.|$)/.test(normalized))
|
|
2579
2869
|
return "";
|
|
2580
2870
|
const segments = normalized.split(".");
|
|
@@ -2600,8 +2890,8 @@ var CONDITIONAL_TYPES = new Set([
|
|
|
2600
2890
|
"while_statement",
|
|
2601
2891
|
"repeat_statement"
|
|
2602
2892
|
]);
|
|
2603
|
-
function isHookPath(
|
|
2604
|
-
const final =
|
|
2893
|
+
function isHookPath(path7) {
|
|
2894
|
+
const final = path7.split(/[.:]/).at(-1) ?? "";
|
|
2605
2895
|
return /^use[A-Z0-9_]/.test(final);
|
|
2606
2896
|
}
|
|
2607
2897
|
function assignmentTargetNode(statement) {
|
|
@@ -2677,8 +2967,8 @@ function functionInfoForNode(context, node) {
|
|
|
2677
2967
|
function stateBindingsFor(context, owner) {
|
|
2678
2968
|
return context.model.stateBindings.filter((binding) => binding.owner === owner);
|
|
2679
2969
|
}
|
|
2680
|
-
function isEffectPath(
|
|
2681
|
-
return
|
|
2970
|
+
function isEffectPath(path7) {
|
|
2971
|
+
return path7 === "React.useEffect" || path7 === "React.useLayoutEffect";
|
|
2682
2972
|
}
|
|
2683
2973
|
function dependencyExpressions(node) {
|
|
2684
2974
|
if (!node || node.type !== "table_constructor")
|
|
@@ -2933,11 +3223,11 @@ function isManagedByCleanupContainer(call, context, callback) {
|
|
|
2933
3223
|
let current = call.parent;
|
|
2934
3224
|
while (current && !sameNode(current, callback.node)) {
|
|
2935
3225
|
if (current.type === "function_call") {
|
|
2936
|
-
const
|
|
2937
|
-
if (ALWAYS_MANAGED_METHOD.test(
|
|
3226
|
+
const path7 = context.getCallPath(current) ?? "";
|
|
3227
|
+
if (ALWAYS_MANAGED_METHOD.test(path7))
|
|
2938
3228
|
return true;
|
|
2939
|
-
if (GENERIC_MANAGER_METHOD.test(
|
|
2940
|
-
const receiver =
|
|
3229
|
+
if (GENERIC_MANAGER_METHOD.test(path7)) {
|
|
3230
|
+
const receiver = path7.split(":", 1)[0] ?? "";
|
|
2941
3231
|
if (MANAGER_ROOT.test(receiver))
|
|
2942
3232
|
return true;
|
|
2943
3233
|
}
|
|
@@ -3042,10 +3332,10 @@ function isCleanupOnlyTask(callbackNode, context) {
|
|
|
3042
3332
|
return false;
|
|
3043
3333
|
return calls.every((call) => /:(?:Destroy|Disconnect)$/.test(context.getCallPath(call) ?? ""));
|
|
3044
3334
|
}
|
|
3045
|
-
function taskSeverity(
|
|
3046
|
-
if (
|
|
3335
|
+
function taskSeverity(path7, call, taskCallback, yieldPoint, context) {
|
|
3336
|
+
if (path7 === "task.defer" && !yieldPoint)
|
|
3047
3337
|
return "suggestion";
|
|
3048
|
-
if (
|
|
3338
|
+
if (path7 === "task.delay" && !yieldPoint) {
|
|
3049
3339
|
if (isCleanupOnlyTask(taskCallback, context))
|
|
3050
3340
|
return "suggestion";
|
|
3051
3341
|
const delay = numericTaskDelay(call, context);
|
|
@@ -3095,10 +3385,10 @@ end`,
|
|
|
3095
3385
|
};
|
|
3096
3386
|
}
|
|
3097
3387
|
if ([...labels].every((label) => label.includes("task"))) {
|
|
3098
|
-
const
|
|
3099
|
-
const taskCall =
|
|
3388
|
+
const path7 = context.resolveCallPath(context.getCallPath(first.call) ?? "task.delay");
|
|
3389
|
+
const taskCall = path7 === "task.defer" ? `task.defer(function()
|
|
3100
3390
|
doWork()
|
|
3101
|
-
end)` :
|
|
3391
|
+
end)` : path7 === "task.spawn" ? `task.spawn(function()
|
|
3102
3392
|
doWork()
|
|
3103
3393
|
end)` : `task.delay(delaySeconds, function()
|
|
3104
3394
|
doWork()
|
|
@@ -3151,9 +3441,9 @@ end`,
|
|
|
3151
3441
|
}
|
|
3152
3442
|
function classifyResource(call, context, callback) {
|
|
3153
3443
|
const rawPath = context.getCallPath(call) ?? "";
|
|
3154
|
-
const
|
|
3444
|
+
const path7 = context.resolveCallPath(rawPath);
|
|
3155
3445
|
const variable = assignedVariableName(call, callback);
|
|
3156
|
-
const finalMember =
|
|
3446
|
+
const finalMember = path7.split(/[.:]/).at(-1) ?? path7;
|
|
3157
3447
|
if (/:Connect$/.test(rawPath)) {
|
|
3158
3448
|
const ownerExpression = connectionOwnerExpression(rawPath);
|
|
3159
3449
|
const ownerIsInstance = isProvenInstanceExpression(ownerExpression, call, context, callback);
|
|
@@ -3222,7 +3512,7 @@ function classifyResource(call, context, callback) {
|
|
|
3222
3512
|
}
|
|
3223
3513
|
};
|
|
3224
3514
|
}
|
|
3225
|
-
if (
|
|
3515
|
+
if (path7 === "ReactRoblox.createRoot") {
|
|
3226
3516
|
return {
|
|
3227
3517
|
call,
|
|
3228
3518
|
label: "React root",
|
|
@@ -3232,17 +3522,17 @@ function classifyResource(call, context, callback) {
|
|
|
3232
3522
|
}
|
|
3233
3523
|
};
|
|
3234
3524
|
}
|
|
3235
|
-
if (
|
|
3525
|
+
if (path7 === "task.delay" || path7 === "task.defer" || path7 === "task.spawn") {
|
|
3236
3526
|
const taskCallback = taskCallbackNode(call, context, callback);
|
|
3237
3527
|
const yieldPoint = taskCallback ? functionYieldPoint(taskCallback, context) : null;
|
|
3238
|
-
const outlivesEffect =
|
|
3528
|
+
const outlivesEffect = path7 !== "task.spawn" || Boolean(yieldPoint);
|
|
3239
3529
|
if (!outlivesEffect)
|
|
3240
3530
|
return null;
|
|
3241
3531
|
return {
|
|
3242
3532
|
call,
|
|
3243
|
-
label:
|
|
3533
|
+
label: path7 === "task.spawn" ? "yielding spawned task" : path7 === "task.defer" ? "deferred task" : "delayed task",
|
|
3244
3534
|
detail: yieldPoint ? `The task can outlive the effect because it yields at ${yieldPoint.reason}.` : "The task is scheduled to run after the effect callback can finish.",
|
|
3245
|
-
severity: taskSeverity(
|
|
3535
|
+
severity: taskSeverity(path7, call, taskCallback, yieldPoint, context),
|
|
3246
3536
|
variable,
|
|
3247
3537
|
cleanupMatches(text, name) {
|
|
3248
3538
|
if (name) {
|
|
@@ -3269,8 +3559,8 @@ var effectNeedsCleanup = {
|
|
|
3269
3559
|
const rawPath = context.getCallPath(call);
|
|
3270
3560
|
if (!rawPath)
|
|
3271
3561
|
continue;
|
|
3272
|
-
const
|
|
3273
|
-
if (
|
|
3562
|
+
const path7 = context.resolveCallPath(rawPath);
|
|
3563
|
+
if (path7 !== "React.useEffect" && path7 !== "React.useLayoutEffect")
|
|
3274
3564
|
continue;
|
|
3275
3565
|
const callbackNode = firstFunctionArgument(context, call);
|
|
3276
3566
|
const callback = functionInfoForNode(context, callbackNode);
|
|
@@ -3324,10 +3614,22 @@ function ownerForHook(context, call) {
|
|
|
3324
3614
|
const owner = context.nearestFunction(call);
|
|
3325
3615
|
return owner && (owner.isComponent || owner.isHook) ? owner : null;
|
|
3326
3616
|
}
|
|
3617
|
+
function effectCallbackWithoutDeps(context, call) {
|
|
3618
|
+
const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
3619
|
+
if (!isEffectPath(path7))
|
|
3620
|
+
return null;
|
|
3621
|
+
const args = context.callArguments(call);
|
|
3622
|
+
const callback = args[0];
|
|
3623
|
+
if (callback?.type !== "function_definition")
|
|
3624
|
+
return null;
|
|
3625
|
+
if (args.length < 2 || args[1]?.type === "nil")
|
|
3626
|
+
return callback;
|
|
3627
|
+
return null;
|
|
3628
|
+
}
|
|
3327
3629
|
function callbackAndDeps(context, call) {
|
|
3328
|
-
const
|
|
3630
|
+
const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
3329
3631
|
const args = context.callArguments(call);
|
|
3330
|
-
if (
|
|
3632
|
+
if (path7 === "React.useImperativeHandle") {
|
|
3331
3633
|
const callback2 = args[1];
|
|
3332
3634
|
const deps2 = args[2];
|
|
3333
3635
|
if (callback2?.type === "function_definition" && deps2?.type === "table_constructor")
|
|
@@ -3357,8 +3659,8 @@ function hasExternalSubscription(node, context) {
|
|
|
3357
3659
|
for (const child of context.walk(node)) {
|
|
3358
3660
|
if (child.type !== "function_call")
|
|
3359
3661
|
continue;
|
|
3360
|
-
const
|
|
3361
|
-
if (/:Connect$/.test(
|
|
3662
|
+
const path7 = context.getCallPath(child) ?? "";
|
|
3663
|
+
if (/:Connect$/.test(path7) || /BindToRenderStep$/.test(path7) || /:BindAction(?:AtPriority)?$/.test(path7))
|
|
3362
3664
|
return true;
|
|
3363
3665
|
}
|
|
3364
3666
|
return false;
|
|
@@ -3392,6 +3694,33 @@ function setterCalledOutsideCallback(context, owner, callback, setterName) {
|
|
|
3392
3694
|
}
|
|
3393
3695
|
return false;
|
|
3394
3696
|
}
|
|
3697
|
+
function guaranteedRepeatedStateChange(argument, stateName, context) {
|
|
3698
|
+
if (argument.type === "table_constructor")
|
|
3699
|
+
return true;
|
|
3700
|
+
if (argument.type === "function_definition") {
|
|
3701
|
+
const updater = functionInfoForNode(context, argument);
|
|
3702
|
+
const previousName = updater?.parameters[0];
|
|
3703
|
+
const returnStatement = updater?.body?.namedChildren.find((child) => child.type === "return_statement");
|
|
3704
|
+
const returnValue = returnStatement?.namedChildren[0];
|
|
3705
|
+
if (previousName && returnValue && guaranteedRepeatedStateChange(returnValue, previousName, context))
|
|
3706
|
+
return true;
|
|
3707
|
+
return false;
|
|
3708
|
+
}
|
|
3709
|
+
const text = argument.text.trim();
|
|
3710
|
+
const escaped = stateName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3711
|
+
if (new RegExp(`^not\\s+${escaped}$`).test(text))
|
|
3712
|
+
return true;
|
|
3713
|
+
if (new RegExp(`^${escaped}\\s*[+-]\\s*(?:[1-9]\\d*(?:\\.\\d+)?|0?\\.\\d*[1-9]\\d*)$`).test(text))
|
|
3714
|
+
return true;
|
|
3715
|
+
if (new RegExp(`^(?:[1-9]\\d*(?:\\.\\d+)?|0?\\.\\d*[1-9]\\d*)\\s*\\+\\s*${escaped}$`).test(text))
|
|
3716
|
+
return true;
|
|
3717
|
+
if (argument.type === "function_call") {
|
|
3718
|
+
const path7 = context.resolveCallPath(context.getCallPath(argument) ?? "");
|
|
3719
|
+
if (path7 === "table.clone" || path7 === "table.create" || path7 === "table.pack")
|
|
3720
|
+
return true;
|
|
3721
|
+
}
|
|
3722
|
+
return false;
|
|
3723
|
+
}
|
|
3395
3724
|
function freshDependencyKind(node, owner) {
|
|
3396
3725
|
if (node.type === "table_constructor")
|
|
3397
3726
|
return "table";
|
|
@@ -3414,8 +3743,8 @@ var noDerivedStateEffect = {
|
|
|
3414
3743
|
run(context) {
|
|
3415
3744
|
const diagnostics = [];
|
|
3416
3745
|
for (const call of context.findCalls()) {
|
|
3417
|
-
const
|
|
3418
|
-
if (!isEffectPath(
|
|
3746
|
+
const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
3747
|
+
if (!isEffectPath(path7))
|
|
3419
3748
|
continue;
|
|
3420
3749
|
const owner = ownerForHook(context, call);
|
|
3421
3750
|
const parts = callbackAndDeps(context, call);
|
|
@@ -3453,28 +3782,48 @@ var noSelfUpdatingEffect = {
|
|
|
3453
3782
|
id: "react-luau/no-self-updating-effect",
|
|
3454
3783
|
category: "Effects",
|
|
3455
3784
|
severity: "warning",
|
|
3456
|
-
description: "Effects should not unconditionally update state
|
|
3785
|
+
description: "Effects should not unconditionally update state in a way that schedules themselves again.",
|
|
3457
3786
|
run(context) {
|
|
3458
3787
|
const diagnostics = [];
|
|
3459
3788
|
for (const call of context.findCalls()) {
|
|
3460
|
-
const
|
|
3461
|
-
if (!isEffectPath(
|
|
3789
|
+
const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
3790
|
+
if (!isEffectPath(path7))
|
|
3462
3791
|
continue;
|
|
3463
3792
|
const owner = ownerForHook(context, call);
|
|
3793
|
+
if (!owner)
|
|
3794
|
+
continue;
|
|
3464
3795
|
const parts = callbackAndDeps(context, call);
|
|
3465
|
-
if (
|
|
3796
|
+
if (parts) {
|
|
3797
|
+
const deps = parseDependencyRoots(parts.deps);
|
|
3798
|
+
for (const binding of stateBindingsFor(context, owner)) {
|
|
3799
|
+
if (!deps.has(binding.valueName))
|
|
3800
|
+
continue;
|
|
3801
|
+
for (const setterCall of directTopLevelCalls(context, parts.callback)) {
|
|
3802
|
+
if (context.getCallPath(setterCall) !== binding.setterName)
|
|
3803
|
+
continue;
|
|
3804
|
+
diagnostics.push({
|
|
3805
|
+
node: callNameNode(setterCall),
|
|
3806
|
+
message: `${binding.setterName}() updates ${binding.valueName}, which is also in this effect's dependency table.`,
|
|
3807
|
+
help: "Guard the update so it converges, move it to the originating event, or derive the value during render."
|
|
3808
|
+
});
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
continue;
|
|
3812
|
+
}
|
|
3813
|
+
const callback = effectCallbackWithoutDeps(context, call);
|
|
3814
|
+
if (!callback)
|
|
3466
3815
|
continue;
|
|
3467
|
-
const deps = parseDependencyRoots(parts.deps);
|
|
3468
3816
|
for (const binding of stateBindingsFor(context, owner)) {
|
|
3469
|
-
|
|
3470
|
-
continue;
|
|
3471
|
-
for (const setterCall of directTopLevelCalls(context, parts.callback)) {
|
|
3817
|
+
for (const setterCall of directTopLevelCalls(context, callback)) {
|
|
3472
3818
|
if (context.getCallPath(setterCall) !== binding.setterName)
|
|
3473
3819
|
continue;
|
|
3820
|
+
const argument = context.callArguments(setterCall)[0];
|
|
3821
|
+
if (!argument || !guaranteedRepeatedStateChange(argument, binding.valueName, context))
|
|
3822
|
+
continue;
|
|
3474
3823
|
diagnostics.push({
|
|
3475
3824
|
node: callNameNode(setterCall),
|
|
3476
|
-
message: `${binding.setterName}()
|
|
3477
|
-
help: "
|
|
3825
|
+
message: `${binding.setterName}() unconditionally changes ${binding.valueName} in an effect that runs after every render.`,
|
|
3826
|
+
help: "Add a dependency table or convergence guard, move the update to the event that owns it, or derive the value during render."
|
|
3478
3827
|
});
|
|
3479
3828
|
}
|
|
3480
3829
|
}
|
|
@@ -3490,8 +3839,8 @@ var noEffectWithFreshDeps = {
|
|
|
3490
3839
|
run(context) {
|
|
3491
3840
|
const diagnostics = [];
|
|
3492
3841
|
for (const call of context.findCalls()) {
|
|
3493
|
-
const
|
|
3494
|
-
if (!HOOKS_WITH_DEPS.has(
|
|
3842
|
+
const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
3843
|
+
if (!HOOKS_WITH_DEPS.has(path7))
|
|
3495
3844
|
continue;
|
|
3496
3845
|
const owner = ownerForHook(context, call);
|
|
3497
3846
|
const parts = callbackAndDeps(context, call);
|
|
@@ -3504,7 +3853,7 @@ var noEffectWithFreshDeps = {
|
|
|
3504
3853
|
const label = dependency.type === "identifier" ? ` ${dependency.text}` : "";
|
|
3505
3854
|
diagnostics.push({
|
|
3506
3855
|
node: dependency,
|
|
3507
|
-
message: `${
|
|
3856
|
+
message: `${path7} depends on${label || " an inline value"} that is a new ${kind} on every render.`,
|
|
3508
3857
|
help: "Move the value inside the hook callback and depend on its simple inputs, or intentionally stabilize the value with useMemo/useCallback when identity is part of the contract."
|
|
3509
3858
|
});
|
|
3510
3859
|
}
|
|
@@ -3520,8 +3869,8 @@ var noMutableInDeps = {
|
|
|
3520
3869
|
run(context) {
|
|
3521
3870
|
const diagnostics = [];
|
|
3522
3871
|
for (const call of context.findCalls()) {
|
|
3523
|
-
const
|
|
3524
|
-
if (!HOOKS_WITH_DEPS.has(
|
|
3872
|
+
const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
3873
|
+
if (!HOOKS_WITH_DEPS.has(path7))
|
|
3525
3874
|
continue;
|
|
3526
3875
|
const owner = ownerForHook(context, call);
|
|
3527
3876
|
const parts = callbackAndDeps(context, call);
|
|
@@ -3614,23 +3963,61 @@ var HOOK_ARGUMENTS = new Map([
|
|
|
3614
3963
|
["React.useImperativeHandle", { callbackIndex: 1, depsIndex: 2 }]
|
|
3615
3964
|
]);
|
|
3616
3965
|
function parameterNames2(functionNode) {
|
|
3617
|
-
|
|
3618
|
-
const parameters = functionNode.childForFieldName("parameters");
|
|
3619
|
-
if (!parameters)
|
|
3620
|
-
return names;
|
|
3621
|
-
for (const node of parameters.namedChildren) {
|
|
3622
|
-
for (const candidate of descendants(node)) {
|
|
3623
|
-
if (candidate.type === "identifier")
|
|
3624
|
-
names.add(candidate.text);
|
|
3625
|
-
}
|
|
3626
|
-
}
|
|
3627
|
-
return names;
|
|
3966
|
+
return new Set(parameterBindingNames(functionNode.childForFieldName("parameters")));
|
|
3628
3967
|
}
|
|
3629
3968
|
function* descendants(node) {
|
|
3630
3969
|
yield node;
|
|
3631
3970
|
for (const child of node.namedChildren)
|
|
3632
3971
|
yield* descendants(child);
|
|
3633
3972
|
}
|
|
3973
|
+
var TYPE_ONLY_NODES = new Set([
|
|
3974
|
+
"type_definition",
|
|
3975
|
+
"type_annotation",
|
|
3976
|
+
"type_parameters",
|
|
3977
|
+
"generic_type_list",
|
|
3978
|
+
"generic_type",
|
|
3979
|
+
"function_type",
|
|
3980
|
+
"optional_type",
|
|
3981
|
+
"object_type",
|
|
3982
|
+
"union_type",
|
|
3983
|
+
"intersection_type",
|
|
3984
|
+
"tuple_type",
|
|
3985
|
+
"singleton_type",
|
|
3986
|
+
"variadic_type",
|
|
3987
|
+
"variadic_type_pack"
|
|
3988
|
+
]);
|
|
3989
|
+
function isTypeOnlyChild(parent, child) {
|
|
3990
|
+
if (TYPE_ONLY_NODES.has(child.type))
|
|
3991
|
+
return true;
|
|
3992
|
+
if (TYPE_ONLY_NODES.has(parent.type))
|
|
3993
|
+
return true;
|
|
3994
|
+
if (parent.type === "parameter") {
|
|
3995
|
+
return !sameNode(parent.namedChildren.find((candidate) => candidate.type === "identifier"), child);
|
|
3996
|
+
}
|
|
3997
|
+
if (parent.type === "cast_expression" || parent.type === "type_cast_expression") {
|
|
3998
|
+
return !sameNode(parent.namedChildren[0], child);
|
|
3999
|
+
}
|
|
4000
|
+
if (parent.type === "function_definition" || parent.type === "function_declaration") {
|
|
4001
|
+
const name = parent.childForFieldName("name");
|
|
4002
|
+
const parameters = parent.childForFieldName("parameters");
|
|
4003
|
+
const body = parent.childForFieldName("body");
|
|
4004
|
+
if (sameNode(child, name) || sameNode(child, parameters) || sameNode(child, body))
|
|
4005
|
+
return false;
|
|
4006
|
+
return true;
|
|
4007
|
+
}
|
|
4008
|
+
if (parent.type === "variable_list" && parent.parent?.parent?.type === "variable_declaration") {
|
|
4009
|
+
return true;
|
|
4010
|
+
}
|
|
4011
|
+
return false;
|
|
4012
|
+
}
|
|
4013
|
+
function* runtimeDescendants(node) {
|
|
4014
|
+
yield node;
|
|
4015
|
+
for (const child of node.namedChildren) {
|
|
4016
|
+
if (isTypeOnlyChild(node, child))
|
|
4017
|
+
continue;
|
|
4018
|
+
yield* runtimeDescendants(child);
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
3634
4021
|
function directBlockBindingNames(block, beforeIndex) {
|
|
3635
4022
|
const names = new Set;
|
|
3636
4023
|
for (const child of block.namedChildren) {
|
|
@@ -3706,8 +4093,8 @@ function rootIdentifierNode(node, root) {
|
|
|
3706
4093
|
}
|
|
3707
4094
|
return null;
|
|
3708
4095
|
}
|
|
3709
|
-
function normalizeCapturedPath(
|
|
3710
|
-
const normalized = normalizeExpressionText(
|
|
4096
|
+
function normalizeCapturedPath(path7, stableVariables, externallyMutableRoots) {
|
|
4097
|
+
const normalized = normalizeExpressionText(path7);
|
|
3711
4098
|
const root = rootIdentifier(normalized);
|
|
3712
4099
|
if (!root)
|
|
3713
4100
|
return normalized;
|
|
@@ -3724,7 +4111,7 @@ function normalizeCapturedPath(path6, stableVariables, externallyMutableRoots) {
|
|
|
3724
4111
|
}
|
|
3725
4112
|
function capturedDependencyPaths(callback, available, stableVariables, externallyMutableRoots) {
|
|
3726
4113
|
const captured = new Set;
|
|
3727
|
-
for (const node of
|
|
4114
|
+
for (const node of runtimeDescendants(callback)) {
|
|
3728
4115
|
if (node.type === "dot_index_expression" && outermostDotPath(node)) {
|
|
3729
4116
|
const root = rootIdentifier(node.text);
|
|
3730
4117
|
if (!root || !available.has(root) || GLOBALS.has(root) || stableVariables.has(root))
|
|
@@ -3793,21 +4180,94 @@ function expandLocalFunctionCaptures(captured, owner, available, stableVariables
|
|
|
3793
4180
|
addCapture(capture);
|
|
3794
4181
|
return result;
|
|
3795
4182
|
}
|
|
4183
|
+
function dependencyCoveragePath(node) {
|
|
4184
|
+
if (node.type === "cast_expression" || node.type === "type_cast_expression" || node.type === "parenthesized_expression") {
|
|
4185
|
+
const expression = node.namedChildren[0];
|
|
4186
|
+
if (expression)
|
|
4187
|
+
return dependencyCoveragePath(expression);
|
|
4188
|
+
}
|
|
4189
|
+
if (node.type === "binary_expression") {
|
|
4190
|
+
const left = node.childForFieldName("left") ?? node.namedChildren[0];
|
|
4191
|
+
const right = node.childForFieldName("right") ?? node.namedChildren[1];
|
|
4192
|
+
const isOr = node.children.some((child) => child.type === "or");
|
|
4193
|
+
if (isOr && left && right?.type === "false")
|
|
4194
|
+
return dependencyCoveragePath(left);
|
|
4195
|
+
}
|
|
4196
|
+
return normalizeExpressionText(node.text);
|
|
4197
|
+
}
|
|
3796
4198
|
function dependencyPaths(node) {
|
|
3797
|
-
return new Set(dependencyExpressions(node).map(
|
|
4199
|
+
return new Set(dependencyExpressions(node).map(dependencyCoveragePath));
|
|
3798
4200
|
}
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
4201
|
+
function transparentAliasPath(node) {
|
|
4202
|
+
if (node.type === "cast_expression" || node.type === "type_cast_expression" || node.type === "parenthesized_expression") {
|
|
4203
|
+
const expression = node.namedChildren[0];
|
|
4204
|
+
return expression ? transparentAliasPath(expression) : null;
|
|
4205
|
+
}
|
|
4206
|
+
if (node.type !== "identifier" && node.type !== "dot_index_expression")
|
|
4207
|
+
return null;
|
|
4208
|
+
return normalizeExpressionText(node.text);
|
|
4209
|
+
}
|
|
4210
|
+
function immutableTransparentAliases(owner, context) {
|
|
4211
|
+
const aliases = new Map;
|
|
4212
|
+
if (!owner.body)
|
|
4213
|
+
return aliases;
|
|
4214
|
+
const mutated = mutatedOwnerLocals(owner, context);
|
|
4215
|
+
for (const statement of owner.body.namedChildren) {
|
|
4216
|
+
if (statement.type !== "variable_declaration")
|
|
4217
|
+
continue;
|
|
4218
|
+
const names = declarationNames2(statement);
|
|
4219
|
+
const expressions = declarationExpressions3(statement);
|
|
4220
|
+
for (let index = 0;index < names.length; index += 1) {
|
|
4221
|
+
const name = names[index];
|
|
4222
|
+
const expression = expressions[index];
|
|
4223
|
+
if (!expression || mutated.has(name))
|
|
4224
|
+
continue;
|
|
4225
|
+
const source = transparentAliasPath(expression);
|
|
4226
|
+
if (!source || source === name)
|
|
4227
|
+
continue;
|
|
4228
|
+
const sourceRoot = rootIdentifier(source);
|
|
4229
|
+
if (!sourceRoot || mutated.has(sourceRoot))
|
|
4230
|
+
continue;
|
|
4231
|
+
aliases.set(name, source);
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
4234
|
+
return aliases;
|
|
4235
|
+
}
|
|
4236
|
+
function resolveAliasPath(path7, aliases) {
|
|
4237
|
+
let current = path7;
|
|
4238
|
+
const visiting = new Set;
|
|
4239
|
+
while (true) {
|
|
4240
|
+
const root = rootIdentifier(current);
|
|
4241
|
+
if (!root || visiting.has(root))
|
|
4242
|
+
return current;
|
|
4243
|
+
const source = aliases.get(root);
|
|
4244
|
+
if (!source)
|
|
4245
|
+
return current;
|
|
4246
|
+
visiting.add(root);
|
|
4247
|
+
current = `${source}${current.slice(root.length)}`;
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
function dependencyPathsWithAliases(node, aliases) {
|
|
4251
|
+
const dependencies = dependencyPaths(node);
|
|
4252
|
+
for (const dependency of [...dependencies]) {
|
|
4253
|
+
const resolved = resolveAliasPath(dependency, aliases);
|
|
4254
|
+
if (resolved !== dependency)
|
|
4255
|
+
dependencies.add(resolved);
|
|
4256
|
+
}
|
|
4257
|
+
return dependencies;
|
|
4258
|
+
}
|
|
4259
|
+
var PURE_DERIVED_CALL = /^(?:math\.(?!random(?:seed)?$)[A-Za-z_][A-Za-z0-9_]*|string\.[A-Za-z_][A-Za-z0-9_]*|table\.(?:find|concat|clone|create|isfrozen)|(?:Color3|Vector2|Vector3|UDim|UDim2|CFrame|BrickColor|Rect|NumberRange|NumberSequence|ColorSequence|TweenInfo|Font|Ray|Region3|PhysicalProperties)\.[A-Za-z_][A-Za-z0-9_]*|[^:]+:(?:ToHSV|Lerp|Dot|Cross|FuzzyEq|Inverse|ToObjectSpace|ToWorldSpace))$/;
|
|
4260
|
+
function isPureDerivedExpression(node, context) {
|
|
4261
|
+
for (const candidate of context.walk(node)) {
|
|
4262
|
+
if (candidate !== node && (candidate.type === "function_definition" || candidate.type === "function_declaration"))
|
|
4263
|
+
return false;
|
|
4264
|
+
if (candidate.type !== "function_call")
|
|
4265
|
+
continue;
|
|
4266
|
+
const rawPath = context.getCallPath(candidate);
|
|
3807
4267
|
if (!rawPath)
|
|
3808
4268
|
return false;
|
|
3809
|
-
const
|
|
3810
|
-
if (!PURE_DERIVED_CALL.test(
|
|
4269
|
+
const path7 = normalizeExpressionText(context.resolveCallPath(rawPath));
|
|
4270
|
+
if (!PURE_DERIVED_CALL.test(path7))
|
|
3811
4271
|
return false;
|
|
3812
4272
|
}
|
|
3813
4273
|
return true;
|
|
@@ -3886,8 +4346,8 @@ function hasDynamicUnpackDependencies(node, context) {
|
|
|
3886
4346
|
const rawPath = context.getCallPath(candidate);
|
|
3887
4347
|
if (!rawPath)
|
|
3888
4348
|
continue;
|
|
3889
|
-
const
|
|
3890
|
-
if (
|
|
4349
|
+
const path7 = normalizeExpressionText(context.resolveCallPath(rawPath));
|
|
4350
|
+
if (path7 === "unpack" || path7 === "table.unpack")
|
|
3891
4351
|
return true;
|
|
3892
4352
|
}
|
|
3893
4353
|
}
|
|
@@ -3912,8 +4372,8 @@ function memoizedProducerDependencies(owner, context) {
|
|
|
3912
4372
|
const rawPath = context.getCallPath(expression);
|
|
3913
4373
|
if (!rawPath)
|
|
3914
4374
|
continue;
|
|
3915
|
-
const
|
|
3916
|
-
if (
|
|
4375
|
+
const path7 = context.resolveCallPath(rawPath);
|
|
4376
|
+
if (path7 !== "React.useMemo" && path7 !== "React.useCallback")
|
|
3917
4377
|
continue;
|
|
3918
4378
|
const args = context.callArguments(expression);
|
|
3919
4379
|
const deps = args[1];
|
|
@@ -3972,9 +4432,9 @@ function directCallsInExpression(node) {
|
|
|
3972
4432
|
return calls;
|
|
3973
4433
|
}
|
|
3974
4434
|
function isCustomHookCall(call, context) {
|
|
3975
|
-
const
|
|
3976
|
-
const final =
|
|
3977
|
-
return !
|
|
4435
|
+
const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
4436
|
+
const final = path7.split(/[.:]/).at(-1) ?? "";
|
|
4437
|
+
return !path7.startsWith("React.") && /^use[A-Z0-9_]/.test(final);
|
|
3978
4438
|
}
|
|
3979
4439
|
var HANDLE_VALUE_NAME = /(?:ref|binding|tween|spring|motion|animation|controller|handle|connection|signal)$/i;
|
|
3980
4440
|
var HANDLE_HOOK_NAME = /(?:Tween|Spring|Binding|Motion|Animated|Animation|Sound)$/i;
|
|
@@ -3984,8 +4444,8 @@ function isCalledAsFunction(owner, name, context) {
|
|
|
3984
4444
|
for (const call of context.walk(owner.body)) {
|
|
3985
4445
|
if (call.type !== "function_call")
|
|
3986
4446
|
continue;
|
|
3987
|
-
const
|
|
3988
|
-
if (
|
|
4447
|
+
const path7 = normalizeExpressionText(context.getCallPath(call) ?? "");
|
|
4448
|
+
if (path7 === name)
|
|
3989
4449
|
return true;
|
|
3990
4450
|
}
|
|
3991
4451
|
return false;
|
|
@@ -4015,8 +4475,8 @@ function customHookDependencyHints(owner, context) {
|
|
|
4015
4475
|
continue;
|
|
4016
4476
|
const directHook = calls.find((call) => isCustomHookCall(call, context));
|
|
4017
4477
|
if (directHook && expression.type === "function_call") {
|
|
4018
|
-
const
|
|
4019
|
-
const final =
|
|
4478
|
+
const path7 = context.resolveCallPath(context.getCallPath(directHook) ?? "");
|
|
4479
|
+
const final = path7.split(/[.:]/).at(-1) ?? "";
|
|
4020
4480
|
const likelyHandle = HANDLE_VALUE_NAME.test(name) || HANDLE_HOOK_NAME.test(final) || isCalledAsFunction(owner, name, context);
|
|
4021
4481
|
if (likelyHandle) {
|
|
4022
4482
|
hints.memberHandleRoots.add(name);
|
|
@@ -4026,14 +4486,14 @@ function customHookDependencyHints(owner, context) {
|
|
|
4026
4486
|
}
|
|
4027
4487
|
if (expression.type === "table_constructor") {
|
|
4028
4488
|
const meaningfulCalls = calls.filter((call) => {
|
|
4029
|
-
const
|
|
4030
|
-
return !/^(?:Color3|Vector2|Vector3|UDim|UDim2|CFrame|TweenInfo|NumberSequence|ColorSequence)\./.test(
|
|
4489
|
+
const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
4490
|
+
return !/^(?:Color3|Vector2|Vector3|UDim|UDim2|CFrame|TweenInfo|NumberSequence|ColorSequence)\./.test(path7);
|
|
4031
4491
|
});
|
|
4032
4492
|
const allHandleHooks = meaningfulCalls.length > 0 && meaningfulCalls.every((call) => {
|
|
4033
4493
|
if (!isCustomHookCall(call, context))
|
|
4034
4494
|
return false;
|
|
4035
|
-
const
|
|
4036
|
-
const final =
|
|
4495
|
+
const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
4496
|
+
const final = path7.split(/[.:]/).at(-1) ?? "";
|
|
4037
4497
|
return HANDLE_HOOK_NAME.test(final);
|
|
4038
4498
|
});
|
|
4039
4499
|
if (allHandleHooks)
|
|
@@ -4063,12 +4523,13 @@ var exhaustiveDeps = {
|
|
|
4063
4523
|
const derivedDependenciesByOwner = new Map;
|
|
4064
4524
|
const dependencyHintsByOwner = new Map;
|
|
4065
4525
|
const memoizedProducersByOwner = new Map;
|
|
4526
|
+
const transparentAliasesByOwner = new Map;
|
|
4066
4527
|
for (const call of context.findCalls()) {
|
|
4067
4528
|
const rawPath = context.getCallPath(call);
|
|
4068
4529
|
if (!rawPath)
|
|
4069
4530
|
continue;
|
|
4070
|
-
const
|
|
4071
|
-
const shape = HOOK_ARGUMENTS.get(
|
|
4531
|
+
const path7 = context.resolveCallPath(rawPath);
|
|
4532
|
+
const shape = HOOK_ARGUMENTS.get(path7);
|
|
4072
4533
|
if (!shape)
|
|
4073
4534
|
continue;
|
|
4074
4535
|
const args = context.callArguments(call);
|
|
@@ -4084,7 +4545,12 @@ var exhaustiveDeps = {
|
|
|
4084
4545
|
const stableVariables = context.model.stableVariablesByFunction.get(ownerKey) ?? new Set;
|
|
4085
4546
|
const externallyMutableRoots = context.model.externalMutableVariablesByFunction.get(ownerKey) ?? new Set;
|
|
4086
4547
|
const functionExpandedCaptures = expandLocalFunctionCaptures(capturedDependencyPaths(callback, available, stableVariables, externallyMutableRoots), owner, available, stableVariables, externallyMutableRoots, context);
|
|
4087
|
-
|
|
4548
|
+
let transparentAliases = transparentAliasesByOwner.get(ownerKey);
|
|
4549
|
+
if (!transparentAliases) {
|
|
4550
|
+
transparentAliases = immutableTransparentAliases(owner, context);
|
|
4551
|
+
transparentAliasesByOwner.set(ownerKey, transparentAliases);
|
|
4552
|
+
}
|
|
4553
|
+
const dependencies = dependencyPathsWithAliases(deps, transparentAliases);
|
|
4088
4554
|
let derivedDependencies = derivedDependenciesByOwner.get(ownerKey);
|
|
4089
4555
|
if (!derivedDependencies) {
|
|
4090
4556
|
derivedDependencies = derivedLocalDependencies(owner, available, stableVariables, externallyMutableRoots, context);
|
|
@@ -4098,7 +4564,7 @@ var exhaustiveDeps = {
|
|
|
4098
4564
|
diagnostics.push({
|
|
4099
4565
|
node: tableStartNode(deps),
|
|
4100
4566
|
severity: "suggestion",
|
|
4101
|
-
message: `${
|
|
4567
|
+
message: `${path7} uses dynamic unpack(...) dependencies, so exhaustive dependency analysis is partial.`,
|
|
4102
4568
|
help: "The analyzer cannot prove which values unpack(...) contributes. Review the explicit dependencies and the custom hook contract."
|
|
4103
4569
|
});
|
|
4104
4570
|
continue;
|
|
@@ -4119,7 +4585,7 @@ var exhaustiveDeps = {
|
|
|
4119
4585
|
if (reactiveMissing.length > 0) {
|
|
4120
4586
|
diagnostics.push({
|
|
4121
4587
|
node: tableStartNode(deps),
|
|
4122
|
-
message: `${
|
|
4588
|
+
message: `${path7} dependency table is missing ${reactiveMissing.join(", ")}.`,
|
|
4123
4589
|
help: `Add ${reactiveMissing.join(", ")} to the dependency table, or stop capturing ${reactiveMissing.length === 1 ? "that value" : "those values"} in the callback.`
|
|
4124
4590
|
});
|
|
4125
4591
|
}
|
|
@@ -4127,7 +4593,7 @@ var exhaustiveDeps = {
|
|
|
4127
4593
|
diagnostics.push({
|
|
4128
4594
|
node: tableStartNode(deps),
|
|
4129
4595
|
severity: "suggestion",
|
|
4130
|
-
message: `${
|
|
4596
|
+
message: `${path7} captures memoized value ${transitivelyCoveredMemoized.join(", ")} without listing ${transitivelyCoveredMemoized.length === 1 ? "it directly" : "them directly"}, but ${transitivelyCoveredMemoized.length === 1 ? "its memo dependencies are" : "their memo dependencies are"} already covered.`,
|
|
4131
4597
|
help: "The producing memo dependencies are already covered. Listing the memoized value directly can make the dependency relationship clearer."
|
|
4132
4598
|
});
|
|
4133
4599
|
}
|
|
@@ -4135,7 +4601,7 @@ var exhaustiveDeps = {
|
|
|
4135
4601
|
diagnostics.push({
|
|
4136
4602
|
node: tableStartNode(deps),
|
|
4137
4603
|
severity: "suggestion",
|
|
4138
|
-
message: `${
|
|
4604
|
+
message: `${path7} captures custom-hook handle ${uncertainHandles.join(", ")} without listing ${uncertainHandles.length === 1 ? "it as a dependency" : "them as dependencies"}.`,
|
|
4139
4605
|
help: "The analyzer cannot prove this custom-hook return is stable. Check the hook contract before changing the dependency table."
|
|
4140
4606
|
});
|
|
4141
4607
|
}
|
|
@@ -4169,8 +4635,8 @@ function createElementCallsInLoop(context, loop) {
|
|
|
4169
4635
|
for (const node of context.walk(loop)) {
|
|
4170
4636
|
if (node.type !== "function_call")
|
|
4171
4637
|
continue;
|
|
4172
|
-
const
|
|
4173
|
-
if (
|
|
4638
|
+
const path7 = context.resolveCallPath(context.getCallPath(node) ?? "");
|
|
4639
|
+
if (path7 === "React.createElement")
|
|
4174
4640
|
result.push(node);
|
|
4175
4641
|
}
|
|
4176
4642
|
return result;
|
|
@@ -4226,8 +4692,8 @@ var noCreateContextInRender = {
|
|
|
4226
4692
|
run(context) {
|
|
4227
4693
|
const diagnostics = [];
|
|
4228
4694
|
for (const call of context.findCalls()) {
|
|
4229
|
-
const
|
|
4230
|
-
if (
|
|
4695
|
+
const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
4696
|
+
if (path7 !== "React.createContext")
|
|
4231
4697
|
continue;
|
|
4232
4698
|
const owner = context.nearestFunction(call);
|
|
4233
4699
|
if (!owner || !owner.isComponent && !owner.isHook)
|
|
@@ -4263,8 +4729,8 @@ function isRenderedBy(context, component, name) {
|
|
|
4263
4729
|
continue;
|
|
4264
4730
|
if (context.nearestFunction(call) !== component)
|
|
4265
4731
|
continue;
|
|
4266
|
-
const
|
|
4267
|
-
if (
|
|
4732
|
+
const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
4733
|
+
if (path7 !== "React.createElement")
|
|
4268
4734
|
continue;
|
|
4269
4735
|
if (context.callArguments(call)[0]?.text.trim() === name)
|
|
4270
4736
|
return true;
|
|
@@ -4308,63 +4774,6 @@ var noNestedComponentDefinition = {
|
|
|
4308
4774
|
}
|
|
4309
4775
|
};
|
|
4310
4776
|
|
|
4311
|
-
// src/module-resolution.ts
|
|
4312
|
-
import path6 from "path";
|
|
4313
|
-
function normalizeRelative2(value) {
|
|
4314
|
-
return value.split(path6.sep).join("/");
|
|
4315
|
-
}
|
|
4316
|
-
function moduleKeys(relativePath) {
|
|
4317
|
-
let normalized = normalizeRelative2(relativePath).replace(/\.(?:lua|luau)$/i, "");
|
|
4318
|
-
if (normalized.endsWith("/init"))
|
|
4319
|
-
normalized = normalized.slice(0, -"/init".length);
|
|
4320
|
-
const segments = normalized.split("/").filter(Boolean);
|
|
4321
|
-
if (segments[0]?.toLowerCase() === "src")
|
|
4322
|
-
segments.shift();
|
|
4323
|
-
const keys = new Set;
|
|
4324
|
-
for (let index = 0;index < segments.length; index += 1) {
|
|
4325
|
-
const suffix = segments.slice(index).join(".").toLowerCase();
|
|
4326
|
-
if (suffix)
|
|
4327
|
-
keys.add(suffix);
|
|
4328
|
-
}
|
|
4329
|
-
return [...keys];
|
|
4330
|
-
}
|
|
4331
|
-
function normalizeRequireTarget(text) {
|
|
4332
|
-
return (text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).join(".").toLowerCase();
|
|
4333
|
-
}
|
|
4334
|
-
function buildUniqueFeatureAliases(modules, featureByModuleId) {
|
|
4335
|
-
const owners = new Map;
|
|
4336
|
-
for (const module of modules) {
|
|
4337
|
-
for (const key of module.keys) {
|
|
4338
|
-
const ids = owners.get(key) ?? new Set;
|
|
4339
|
-
ids.add(module.id);
|
|
4340
|
-
owners.set(key, ids);
|
|
4341
|
-
}
|
|
4342
|
-
}
|
|
4343
|
-
const aliases = new Map;
|
|
4344
|
-
for (const module of modules) {
|
|
4345
|
-
const value = featureByModuleId.get(module.id);
|
|
4346
|
-
if (value === undefined)
|
|
4347
|
-
continue;
|
|
4348
|
-
for (const key of module.keys) {
|
|
4349
|
-
const ids = owners.get(key);
|
|
4350
|
-
if (ids?.size === 1 && ids.has(module.id))
|
|
4351
|
-
aliases.set(key, value);
|
|
4352
|
-
}
|
|
4353
|
-
}
|
|
4354
|
-
return aliases;
|
|
4355
|
-
}
|
|
4356
|
-
function resolveModuleReference(target, aliases) {
|
|
4357
|
-
let candidate = target;
|
|
4358
|
-
while (true) {
|
|
4359
|
-
if (aliases.has(candidate))
|
|
4360
|
-
return aliases.get(candidate);
|
|
4361
|
-
const separator = candidate.indexOf(".");
|
|
4362
|
-
if (separator < 0)
|
|
4363
|
-
return null;
|
|
4364
|
-
candidate = candidate.slice(separator + 1);
|
|
4365
|
-
}
|
|
4366
|
-
}
|
|
4367
|
-
|
|
4368
4777
|
// src/rules/parameter-mutations.ts
|
|
4369
4778
|
var analysisCache = new WeakMap;
|
|
4370
4779
|
var EMPTY_INDEXES = new Set;
|
|
@@ -5091,7 +5500,7 @@ var rerenderHighFrequencyState = {
|
|
|
5091
5500
|
seen.add(key);
|
|
5092
5501
|
const renderCost = componentRenderCost(context, component);
|
|
5093
5502
|
diagnostics.push({
|
|
5094
|
-
node: callNameNode(call),
|
|
5503
|
+
node: callNameNode(binding.call),
|
|
5095
5504
|
severity: "warning",
|
|
5096
5505
|
message: `${binding.valueName} has a state setter called from a per-frame callback and may cause frequent rerenders when its value changes.`,
|
|
5097
5506
|
help: renderCost.length > 0 ? `This render also performs ${renderCost.join(" and ")}. Isolate or throttle the high-frequency state, or use Bindings for host-property-only updates.` : "Isolate or throttle the high-frequency state. Use React.useBinding when the value only drives host Instance properties."
|
|
@@ -5368,7 +5777,9 @@ var preferUseRefForMutableCell = {
|
|
|
5368
5777
|
};
|
|
5369
5778
|
|
|
5370
5779
|
// src/rules/prefer-binding-over-state.ts
|
|
5371
|
-
var EXTERNAL_CALLBACK = /(?:^|[.:])(?:Connect|
|
|
5780
|
+
var EXTERNAL_CALLBACK = /(?:^|[.:])(?:Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)$/i;
|
|
5781
|
+
var PERSISTENT_MIRROR_CALLBACK = /(?:^|[.:])(?:Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)$/i;
|
|
5782
|
+
var LIKELY_CONTINUOUS_CALLBACK = /(?:^|[.:])onStep$/i;
|
|
5372
5783
|
var NON_BINDABLE_HOST_FIELDS = new Set(["ref", "key", "children"]);
|
|
5373
5784
|
function replaceWithinNode(container, target, replacement) {
|
|
5374
5785
|
const start = target.startIndex - container.startIndex;
|
|
@@ -5491,13 +5902,64 @@ var TRANSPARENT_PARENTS = new Set([
|
|
|
5491
5902
|
"string_interpolation",
|
|
5492
5903
|
"expression_list",
|
|
5493
5904
|
"assignment_statement",
|
|
5494
|
-
"arguments",
|
|
5495
|
-
"function_call",
|
|
5496
5905
|
"dot_index_expression",
|
|
5497
5906
|
"method_index_expression",
|
|
5498
5907
|
"table_constructor",
|
|
5499
5908
|
"field"
|
|
5500
5909
|
]);
|
|
5910
|
+
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_]*)$/;
|
|
5911
|
+
var HOST_FEEDBACK_PROPERTIES = new Set([
|
|
5912
|
+
"AbsoluteSize",
|
|
5913
|
+
"AbsolutePosition",
|
|
5914
|
+
"AbsoluteRotation",
|
|
5915
|
+
"AbsoluteContentSize",
|
|
5916
|
+
"AbsoluteCanvasSize",
|
|
5917
|
+
"AbsoluteCellSize",
|
|
5918
|
+
"AbsoluteCellCount",
|
|
5919
|
+
"TextBounds",
|
|
5920
|
+
"CanvasPosition"
|
|
5921
|
+
]);
|
|
5922
|
+
var LIKELY_CONTINUOUS_PROPERTIES = new Set([
|
|
5923
|
+
"CFrame",
|
|
5924
|
+
"WorldCFrame",
|
|
5925
|
+
"Position",
|
|
5926
|
+
"WorldPosition",
|
|
5927
|
+
"Rotation",
|
|
5928
|
+
"Orientation",
|
|
5929
|
+
"CanvasPosition",
|
|
5930
|
+
"PlaybackLoudness",
|
|
5931
|
+
"TimePosition"
|
|
5932
|
+
]);
|
|
5933
|
+
var SEMANTIC_SNAPSHOT_EXPRESSION = /\b(?:getState|getSnapshot|snapshot|selector|select|table\s*\.\s*(?:clone|freeze|move))\b|(?:\.|\[\s*["'])State(?:\b|["']\s*\])/i;
|
|
5934
|
+
function isBindingMapSafeCall(call, context) {
|
|
5935
|
+
if (call.type !== "function_call")
|
|
5936
|
+
return false;
|
|
5937
|
+
const path7 = context.resolveCallPath(context.getCallPath(call) ?? "").replace(/\s+/g, "");
|
|
5938
|
+
return PURE_BINDING_DERIVATION_CALL.test(path7);
|
|
5939
|
+
}
|
|
5940
|
+
function isTransparentParent(parent, context) {
|
|
5941
|
+
if (parent.type === "function_call")
|
|
5942
|
+
return isBindingMapSafeCall(parent, context);
|
|
5943
|
+
if (parent.type === "arguments") {
|
|
5944
|
+
return parent.parent?.type === "function_call" && isBindingMapSafeCall(parent.parent, context);
|
|
5945
|
+
}
|
|
5946
|
+
return TRANSPARENT_PARENTS.has(parent.type);
|
|
5947
|
+
}
|
|
5948
|
+
function stringLiteralValue(node) {
|
|
5949
|
+
if (!node)
|
|
5950
|
+
return null;
|
|
5951
|
+
return node.text.trim().match(/^["']([^"']+)["']$/)?.[1] ?? null;
|
|
5952
|
+
}
|
|
5953
|
+
function observedPropertyForCall(summary, call, context) {
|
|
5954
|
+
if (summary.observedPropertyName)
|
|
5955
|
+
return summary.observedPropertyName;
|
|
5956
|
+
if (summary.observedPropertyParameterIndex === undefined)
|
|
5957
|
+
return null;
|
|
5958
|
+
return stringLiteralValue(context.callArguments(call)[summary.observedPropertyParameterIndex]);
|
|
5959
|
+
}
|
|
5960
|
+
function isStrongHostFeedbackProperty(property) {
|
|
5961
|
+
return Boolean(property && HOST_FEEDBACK_PROPERTIES.has(property));
|
|
5962
|
+
}
|
|
5501
5963
|
function isInside2(node, ancestor) {
|
|
5502
5964
|
let current = node;
|
|
5503
5965
|
while (current) {
|
|
@@ -5581,13 +6043,59 @@ function isTransparentUsage(node, context, compatibleComponentProps) {
|
|
|
5581
6043
|
}
|
|
5582
6044
|
if (parent.type === "function_definition" || parent.type === "function_declaration")
|
|
5583
6045
|
return false;
|
|
5584
|
-
if (!
|
|
6046
|
+
if (!isTransparentParent(parent, context))
|
|
5585
6047
|
return false;
|
|
5586
6048
|
current = parent;
|
|
5587
6049
|
}
|
|
5588
6050
|
return false;
|
|
5589
6051
|
}
|
|
5590
|
-
function
|
|
6052
|
+
function isDirectBindingValueUsage(node, context, compatibleComponentProps) {
|
|
6053
|
+
let current = node;
|
|
6054
|
+
while (current) {
|
|
6055
|
+
const parent = current.parent;
|
|
6056
|
+
if (!parent)
|
|
6057
|
+
return false;
|
|
6058
|
+
if (parent.type === "parenthesized_expression" || parent.type === "cast_expression" || parent.type === "type_cast_expression") {
|
|
6059
|
+
current = parent;
|
|
6060
|
+
continue;
|
|
6061
|
+
}
|
|
6062
|
+
if (parent.type !== "field")
|
|
6063
|
+
return false;
|
|
6064
|
+
const value = fieldValue(parent);
|
|
6065
|
+
if (!value || !sameNode(value, current))
|
|
6066
|
+
return false;
|
|
6067
|
+
const property = fieldName(parent);
|
|
6068
|
+
if (!property || NON_BINDABLE_HOST_FIELDS.has(property))
|
|
6069
|
+
return false;
|
|
6070
|
+
const table = parent.parent;
|
|
6071
|
+
const args = table?.parent;
|
|
6072
|
+
const call = args?.parent;
|
|
6073
|
+
if (table?.type !== "table_constructor" || args?.type !== "arguments" || !call || !isCreateElementCall2(call, context)) {
|
|
6074
|
+
return false;
|
|
6075
|
+
}
|
|
6076
|
+
if (!sameNode(context.callArguments(call)[1], table))
|
|
6077
|
+
return false;
|
|
6078
|
+
if (isHostCreateElementCall(call, context))
|
|
6079
|
+
return true;
|
|
6080
|
+
const component = context.callArguments(call)[0]?.text.trim() ?? "";
|
|
6081
|
+
return compatibleComponentProps.get(component)?.has(property) ?? false;
|
|
6082
|
+
}
|
|
6083
|
+
return false;
|
|
6084
|
+
}
|
|
6085
|
+
function allReadsDirectlyBindingSubstitutable(valueName, owner, declaration, context, compatibleComponentProps) {
|
|
6086
|
+
if (!owner.body)
|
|
6087
|
+
return false;
|
|
6088
|
+
let reads = 0;
|
|
6089
|
+
for (const node of context.walk(owner.body)) {
|
|
6090
|
+
if (!isReadNode(node, valueName, owner, declaration))
|
|
6091
|
+
continue;
|
|
6092
|
+
reads += 1;
|
|
6093
|
+
if (!isDirectBindingValueUsage(node, context, compatibleComponentProps))
|
|
6094
|
+
return false;
|
|
6095
|
+
}
|
|
6096
|
+
return reads > 0;
|
|
6097
|
+
}
|
|
6098
|
+
function derivedLocalDeclarationForRead(node, context) {
|
|
5591
6099
|
let current = node;
|
|
5592
6100
|
while (current) {
|
|
5593
6101
|
const parent = current.parent;
|
|
@@ -5597,7 +6105,30 @@ function derivedLocalDeclarationForRead(node) {
|
|
|
5597
6105
|
return parent;
|
|
5598
6106
|
if (parent.type === "function_definition" || parent.type === "function_declaration")
|
|
5599
6107
|
return null;
|
|
5600
|
-
if (!
|
|
6108
|
+
if (!isTransparentParent(parent, context))
|
|
6109
|
+
return null;
|
|
6110
|
+
current = parent;
|
|
6111
|
+
}
|
|
6112
|
+
return null;
|
|
6113
|
+
}
|
|
6114
|
+
function opaqueDerivedLocalDeclarationForRead(node, context) {
|
|
6115
|
+
let current = node;
|
|
6116
|
+
while (current) {
|
|
6117
|
+
const parent = current.parent;
|
|
6118
|
+
if (!parent)
|
|
6119
|
+
return null;
|
|
6120
|
+
if (parent.type === "variable_declaration")
|
|
6121
|
+
return parent;
|
|
6122
|
+
if (parent.type === "function_definition" || parent.type === "function_declaration")
|
|
6123
|
+
return null;
|
|
6124
|
+
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")
|
|
6125
|
+
return null;
|
|
6126
|
+
if (parent.type === "function_call") {
|
|
6127
|
+
const path7 = context.resolveCallPath(context.getCallPath(parent) ?? "");
|
|
6128
|
+
if (/^React\.use(?:Memo|Callback|Effect|LayoutEffect)$/.test(path7))
|
|
6129
|
+
return null;
|
|
6130
|
+
}
|
|
6131
|
+
if (!TRANSPARENT_PARENTS.has(parent.type) && parent.type !== "arguments" && parent.type !== "function_call")
|
|
5601
6132
|
return null;
|
|
5602
6133
|
current = parent;
|
|
5603
6134
|
}
|
|
@@ -5617,7 +6148,7 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
|
|
|
5617
6148
|
reads += 1;
|
|
5618
6149
|
if (isTransparentUsage(node, context, compatibleComponentProps))
|
|
5619
6150
|
continue;
|
|
5620
|
-
const derivedDeclaration = derivedLocalDeclarationForRead(node);
|
|
6151
|
+
const derivedDeclaration = derivedLocalDeclarationForRead(node, context);
|
|
5621
6152
|
const derivedNames = derivedDeclaration ? declarationNames2(derivedDeclaration) : [];
|
|
5622
6153
|
if (derivedDeclaration && derivedNames.length === 1) {
|
|
5623
6154
|
if (stateReadsAreBindingCompatible(derivedNames[0], owner, derivedDeclaration, context, compatibleComponentProps, new Set(visited)))
|
|
@@ -5627,12 +6158,46 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
|
|
|
5627
6158
|
}
|
|
5628
6159
|
return reads > 0;
|
|
5629
6160
|
}
|
|
6161
|
+
function propertyChangedSignalProperty(text) {
|
|
6162
|
+
return text.match(/GetPropertyChangedSignal\s*\(\s*["']([^"']+)["']\s*\)/)?.[1] ?? null;
|
|
6163
|
+
}
|
|
6164
|
+
function reactChangePropertyForCallback(node) {
|
|
6165
|
+
let current = node.parent;
|
|
6166
|
+
while (current && current.type !== "function_definition" && current.type !== "function_declaration") {
|
|
6167
|
+
if (current.type === "field") {
|
|
6168
|
+
return current.text.match(/React\s*\.\s*Change\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)/)?.[1] ?? null;
|
|
6169
|
+
}
|
|
6170
|
+
current = current.parent;
|
|
6171
|
+
}
|
|
6172
|
+
return null;
|
|
6173
|
+
}
|
|
6174
|
+
function evidenceForExternalSource(highFrequency, hostFeedbackProperty) {
|
|
6175
|
+
const hostFeedbackProperties = new Set;
|
|
6176
|
+
if (hostFeedbackProperty)
|
|
6177
|
+
hostFeedbackProperties.add(hostFeedbackProperty);
|
|
6178
|
+
return {
|
|
6179
|
+
highFrequency,
|
|
6180
|
+
external: true,
|
|
6181
|
+
hostFeedbackProperties,
|
|
6182
|
+
genericExternal: !highFrequency && !hostFeedbackProperty
|
|
6183
|
+
};
|
|
6184
|
+
}
|
|
6185
|
+
function mergeSourceEvidence(target, source) {
|
|
6186
|
+
target.highFrequency ||= source.highFrequency;
|
|
6187
|
+
target.external ||= source.external;
|
|
6188
|
+
target.genericExternal ||= source.genericExternal;
|
|
6189
|
+
for (const property of source.hostFeedbackProperties)
|
|
6190
|
+
target.hostFeedbackProperties.add(property);
|
|
6191
|
+
}
|
|
5630
6192
|
function callbackSource(call, context) {
|
|
5631
6193
|
const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
|
|
5632
|
-
const highFrequency =
|
|
6194
|
+
const highFrequency = isHighFrequencyRobloxCall(call, context) || LIKELY_CONTINUOUS_CALLBACK.test(normalized);
|
|
6195
|
+
const hostFeedbackProperty = propertyChangedSignalProperty(call.text);
|
|
5633
6196
|
const final = normalized.split(/[.:]/).at(-1) ?? normalized;
|
|
5634
|
-
const external = highFrequency || EXTERNAL_CALLBACK.test(normalized) || EXTERNAL_CALLBACK.test(final);
|
|
5635
|
-
|
|
6197
|
+
const external = highFrequency || Boolean(hostFeedbackProperty) || EXTERNAL_CALLBACK.test(normalized) || EXTERNAL_CALLBACK.test(final);
|
|
6198
|
+
if (!external)
|
|
6199
|
+
return null;
|
|
6200
|
+
return evidenceForExternalSource(highFrequency, hostFeedbackProperty);
|
|
5636
6201
|
}
|
|
5637
6202
|
function importedCallbackSource(call, callbackArgument, importedCallbacks, context) {
|
|
5638
6203
|
const path7 = context.getCallPath(call) ?? "";
|
|
@@ -5643,12 +6208,14 @@ function importedCallbackSource(call, callbackArgument, importedCallbacks, conte
|
|
|
5643
6208
|
const callbackIndex = args.findIndex((arg) => sameNode(arg, callbackArgument));
|
|
5644
6209
|
if (callbackIndex < 0 || !summary.callbackParameterIndexes.includes(callbackIndex))
|
|
5645
6210
|
return null;
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
};
|
|
6211
|
+
const highFrequency = summary.highFrequency || args.some((arg) => isHighFrequencyRobloxExpression(arg, context));
|
|
6212
|
+
const hostFeedbackProperty = args.map((arg) => propertyChangedSignalProperty(arg.text)).find((property) => Boolean(property));
|
|
6213
|
+
return evidenceForExternalSource(highFrequency, hostFeedbackProperty);
|
|
5650
6214
|
}
|
|
5651
6215
|
function directCallbackSource(node, importedCallbacks, context) {
|
|
6216
|
+
const reactChangeProperty = reactChangePropertyForCallback(node);
|
|
6217
|
+
if (reactChangeProperty)
|
|
6218
|
+
return evidenceForExternalSource(false, reactChangeProperty);
|
|
5652
6219
|
const parent = node.parent;
|
|
5653
6220
|
if (parent?.type !== "arguments" || parent.parent?.type !== "function_call")
|
|
5654
6221
|
return null;
|
|
@@ -5656,14 +6223,17 @@ function directCallbackSource(node, importedCallbacks, context) {
|
|
|
5656
6223
|
const imported = importedCallbackSource(call, node, importedCallbacks, context);
|
|
5657
6224
|
if (imported)
|
|
5658
6225
|
return imported;
|
|
5659
|
-
|
|
5660
|
-
return source.external ? source : null;
|
|
6226
|
+
return callbackSource(call, context);
|
|
5661
6227
|
}
|
|
5662
6228
|
function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
|
|
5663
6229
|
if (!fn.name || !owner.body)
|
|
5664
6230
|
return null;
|
|
5665
|
-
|
|
5666
|
-
|
|
6231
|
+
const result = {
|
|
6232
|
+
highFrequency: false,
|
|
6233
|
+
external: false,
|
|
6234
|
+
hostFeedbackProperties: new Set,
|
|
6235
|
+
genericExternal: false
|
|
6236
|
+
};
|
|
5667
6237
|
for (const call of context.walk(owner.body)) {
|
|
5668
6238
|
if (call.type !== "function_call")
|
|
5669
6239
|
continue;
|
|
@@ -5671,14 +6241,12 @@ function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
|
|
|
5671
6241
|
const callbackArg = args.find((arg) => arg.type === "identifier" && arg.text === fn.name);
|
|
5672
6242
|
if (!callbackArg)
|
|
5673
6243
|
continue;
|
|
5674
|
-
const
|
|
5675
|
-
|
|
5676
|
-
if (!source.external)
|
|
6244
|
+
const source = importedCallbackSource(call, callbackArg, importedCallbacks, context) ?? callbackSource(call, context);
|
|
6245
|
+
if (!source)
|
|
5677
6246
|
continue;
|
|
5678
|
-
|
|
5679
|
-
foundHighFrequency ||= source.highFrequency;
|
|
6247
|
+
mergeSourceEvidence(result, source);
|
|
5680
6248
|
}
|
|
5681
|
-
return
|
|
6249
|
+
return result.external ? result : null;
|
|
5682
6250
|
}
|
|
5683
6251
|
function setterCallbackSource(call, binding, importedCallbacks, context) {
|
|
5684
6252
|
const owner = binding.owner;
|
|
@@ -5692,25 +6260,178 @@ function setterCallbackSource(call, binding, importedCallbacks, context) {
|
|
|
5692
6260
|
return direct;
|
|
5693
6261
|
return namedFunctionCallbackSource(nearest, owner, importedCallbacks, context);
|
|
5694
6262
|
}
|
|
5695
|
-
function
|
|
6263
|
+
function mirrorConfidenceRank(confidence) {
|
|
6264
|
+
if (confidence === "strong")
|
|
6265
|
+
return 2;
|
|
6266
|
+
if (confidence === "possible")
|
|
6267
|
+
return 1;
|
|
6268
|
+
return 0;
|
|
6269
|
+
}
|
|
6270
|
+
function mergeMirrorConfidence(a, b) {
|
|
6271
|
+
return mirrorConfidenceRank(b) > mirrorConfidenceRank(a) ? b : a;
|
|
6272
|
+
}
|
|
6273
|
+
function isLiteralStateTransition(node) {
|
|
6274
|
+
if (!node)
|
|
6275
|
+
return true;
|
|
6276
|
+
const text = node.text.trim();
|
|
6277
|
+
return /^(?:true|false|nil|[-+]?\d+(?:\.\d+)?|["'][^"']*["'])$/.test(text);
|
|
6278
|
+
}
|
|
6279
|
+
function isSemanticSnapshotExpression(node) {
|
|
6280
|
+
return Boolean(node && SEMANTIC_SNAPSHOT_EXPRESSION.test(node.text));
|
|
6281
|
+
}
|
|
6282
|
+
function expressionReferencesCallbackParameter(expression, callback, context) {
|
|
6283
|
+
if (callback.parameters.length === 0)
|
|
6284
|
+
return false;
|
|
6285
|
+
const parameters = new Set(callback.parameters);
|
|
6286
|
+
return [...context.walk(expression)].some((node) => node.type === "identifier" && parameters.has(node.text));
|
|
6287
|
+
}
|
|
6288
|
+
var PURE_EXPRESSION_ROOTS = new Set([
|
|
6289
|
+
"math",
|
|
6290
|
+
"string",
|
|
6291
|
+
"utf8",
|
|
6292
|
+
"bit32",
|
|
6293
|
+
"UDim",
|
|
6294
|
+
"UDim2",
|
|
6295
|
+
"Vector2",
|
|
6296
|
+
"Vector3",
|
|
6297
|
+
"Color3",
|
|
6298
|
+
"CFrame",
|
|
6299
|
+
"Rect",
|
|
6300
|
+
"NumberRange",
|
|
6301
|
+
"NumberSequence",
|
|
6302
|
+
"ColorSequence",
|
|
6303
|
+
"BrickColor",
|
|
6304
|
+
"Font",
|
|
6305
|
+
"Enum",
|
|
6306
|
+
"tostring",
|
|
6307
|
+
"tonumber",
|
|
6308
|
+
"type",
|
|
6309
|
+
"typeof"
|
|
6310
|
+
]);
|
|
6311
|
+
function callbackLocalNamesBefore(callback, before, context) {
|
|
6312
|
+
const names = new Set;
|
|
6313
|
+
if (!callback.body)
|
|
6314
|
+
return names;
|
|
6315
|
+
for (const node of context.walk(callback.body)) {
|
|
6316
|
+
if (node.startIndex >= before.startIndex)
|
|
6317
|
+
continue;
|
|
6318
|
+
if (context.nearestFunction(node) !== callback || node.type !== "variable_declaration")
|
|
6319
|
+
continue;
|
|
6320
|
+
for (const name of declarationNames2(node))
|
|
6321
|
+
names.add(name);
|
|
6322
|
+
}
|
|
6323
|
+
return names;
|
|
6324
|
+
}
|
|
6325
|
+
function expressionHasExternalInput(expression, callback, setterCall, context) {
|
|
6326
|
+
const callbackLocals = callbackLocalNamesBefore(callback, setterCall, context);
|
|
6327
|
+
const callbackParameters = new Set(callback.parameters);
|
|
6328
|
+
for (const node of context.walk(expression)) {
|
|
6329
|
+
if (node.type !== "identifier" || isIdentifierPropertyName2(node))
|
|
6330
|
+
continue;
|
|
6331
|
+
const name = node.text;
|
|
6332
|
+
if (callbackParameters.has(name))
|
|
6333
|
+
return true;
|
|
6334
|
+
if (callbackLocals.has(name))
|
|
6335
|
+
continue;
|
|
6336
|
+
if (context.model.stateValues.has(name))
|
|
6337
|
+
continue;
|
|
6338
|
+
if (PURE_EXPRESSION_ROOTS.has(name))
|
|
6339
|
+
continue;
|
|
6340
|
+
return true;
|
|
6341
|
+
}
|
|
6342
|
+
return false;
|
|
6343
|
+
}
|
|
6344
|
+
function setterHasDistinctValuesInCallback(call, callback, context) {
|
|
6345
|
+
if (!callback.body)
|
|
6346
|
+
return false;
|
|
6347
|
+
const setterPath = context.getCallPath(call);
|
|
6348
|
+
const distinctValues = new Set;
|
|
6349
|
+
for (const sibling of context.walk(callback.body)) {
|
|
6350
|
+
if (sibling.type !== "function_call" || context.nearestFunction(sibling) !== callback)
|
|
6351
|
+
continue;
|
|
6352
|
+
if (context.getCallPath(sibling) !== setterPath)
|
|
6353
|
+
continue;
|
|
6354
|
+
const argument = context.callArguments(sibling)[0];
|
|
6355
|
+
if (argument)
|
|
6356
|
+
distinctValues.add(argument.text.trim());
|
|
6357
|
+
}
|
|
6358
|
+
return distinctValues.size > 1;
|
|
6359
|
+
}
|
|
6360
|
+
function setterCallMirrorConfidence(call, callback, source, context) {
|
|
6361
|
+
const value = context.callArguments(call)[0];
|
|
6362
|
+
if (!value || value.type === "function_definition")
|
|
6363
|
+
return "none";
|
|
6364
|
+
if (isLiteralStateTransition(value)) {
|
|
6365
|
+
return source.highFrequency && setterHasDistinctValuesInCallback(call, callback, context) ? "strong" : "none";
|
|
6366
|
+
}
|
|
6367
|
+
if (isSemanticSnapshotExpression(value))
|
|
6368
|
+
return "none";
|
|
6369
|
+
if (expressionReferencesCallbackParameter(value, callback, context))
|
|
6370
|
+
return "strong";
|
|
6371
|
+
if (source.hostFeedbackProperties.size > 0) {
|
|
6372
|
+
const text = value.text;
|
|
6373
|
+
if ([...source.hostFeedbackProperties].some((property) => new RegExp(`(?:\\.|["'])${property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(text))) {
|
|
6374
|
+
return "strong";
|
|
6375
|
+
}
|
|
6376
|
+
}
|
|
6377
|
+
if (!expressionHasExternalInput(value, callback, call, context)) {
|
|
6378
|
+
return source.highFrequency && setterHasDistinctValuesInCallback(call, callback, context) ? "strong" : "none";
|
|
6379
|
+
}
|
|
6380
|
+
if (source.highFrequency)
|
|
6381
|
+
return "strong";
|
|
6382
|
+
return source.genericExternal ? "possible" : "strong";
|
|
6383
|
+
}
|
|
6384
|
+
function mergeBindingUpdateEvidence(target, source, mirrorConfidence) {
|
|
6385
|
+
if (mirrorConfidence === "none")
|
|
6386
|
+
return;
|
|
6387
|
+
mergeSourceEvidence(target, source);
|
|
6388
|
+
target.mirrorConfidence = mergeMirrorConfidence(target.mirrorConfidence, mirrorConfidence);
|
|
6389
|
+
}
|
|
6390
|
+
function bindingUpdateEvidence(binding, importedCallbacks, context) {
|
|
5696
6391
|
const owner = binding.owner;
|
|
5697
6392
|
if (!owner?.body)
|
|
5698
6393
|
return null;
|
|
5699
|
-
|
|
5700
|
-
|
|
6394
|
+
const result = {
|
|
6395
|
+
highFrequency: false,
|
|
6396
|
+
external: false,
|
|
6397
|
+
hostFeedbackProperties: new Set,
|
|
6398
|
+
genericExternal: false,
|
|
6399
|
+
mirrorConfidence: "none"
|
|
6400
|
+
};
|
|
5701
6401
|
for (const node of context.walk(owner.body)) {
|
|
5702
6402
|
if (node.type !== "function_call")
|
|
5703
6403
|
continue;
|
|
5704
6404
|
const path7 = context.getCallPath(node);
|
|
5705
|
-
if (path7
|
|
6405
|
+
if (path7 === binding.setterName) {
|
|
6406
|
+
const source2 = setterCallbackSource(node, binding, importedCallbacks, context);
|
|
6407
|
+
const callback = context.nearestFunction(node);
|
|
6408
|
+
if (!source2 || !callback || callback === owner)
|
|
6409
|
+
continue;
|
|
6410
|
+
mergeBindingUpdateEvidence(result, source2, setterCallMirrorConfidence(node, callback, source2, context));
|
|
6411
|
+
continue;
|
|
6412
|
+
}
|
|
6413
|
+
const args = context.callArguments(node);
|
|
6414
|
+
const setterArg = args.find((arg) => arg.type === "identifier" && arg.text === binding.setterName);
|
|
6415
|
+
if (!setterArg)
|
|
5706
6416
|
continue;
|
|
5707
|
-
const
|
|
6417
|
+
const normalized = (context.getCallPath(node) ?? "").replace(/\s+/g, "");
|
|
6418
|
+
const imported = importedCallbackSource(node, setterArg, importedCallbacks, context);
|
|
6419
|
+
const source = imported ?? (PERSISTENT_MIRROR_CALLBACK.test(normalized) ? callbackSource(node, context) : null);
|
|
5708
6420
|
if (!source)
|
|
5709
6421
|
continue;
|
|
5710
|
-
|
|
5711
|
-
external ||= source.external;
|
|
6422
|
+
mergeBindingUpdateEvidence(result, source, "strong");
|
|
5712
6423
|
}
|
|
5713
|
-
return
|
|
6424
|
+
return result.external && result.mirrorConfidence !== "none" ? result : null;
|
|
6425
|
+
}
|
|
6426
|
+
function hasStrongHostFeedbackEvidence(source) {
|
|
6427
|
+
if (source.genericExternal || source.hostFeedbackProperties.size === 0)
|
|
6428
|
+
return false;
|
|
6429
|
+
return [...source.hostFeedbackProperties].every((property) => isStrongHostFeedbackProperty(property));
|
|
6430
|
+
}
|
|
6431
|
+
function hasLikelyContinuousPropertyEvidence(source) {
|
|
6432
|
+
if (source.hostFeedbackProperties.size === 0)
|
|
6433
|
+
return false;
|
|
6434
|
+
return [...source.hostFeedbackProperties].every((property) => LIKELY_CONTINUOUS_PROPERTIES.has(property));
|
|
5714
6435
|
}
|
|
5715
6436
|
function returnedHookValues(binding, context) {
|
|
5716
6437
|
const owner = binding.owner;
|
|
@@ -5788,14 +6509,8 @@ function callDefinitelyUsesBindingMode(summary, call, context) {
|
|
|
5788
6509
|
return false;
|
|
5789
6510
|
return truthy === summary.bindingWhenTruthy;
|
|
5790
6511
|
}
|
|
5791
|
-
function
|
|
5792
|
-
|
|
5793
|
-
return false;
|
|
5794
|
-
if (!hasAnyRead(localName, owner, declaration, context))
|
|
5795
|
-
return false;
|
|
5796
|
-
if (summary.sourceKind === "measurement")
|
|
5797
|
-
return true;
|
|
5798
|
-
return stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps);
|
|
6512
|
+
function hasExplicitBindingMode(summary) {
|
|
6513
|
+
return summary.bindingModeParameterIndex !== undefined && summary.bindingWhenTruthy !== undefined;
|
|
5799
6514
|
}
|
|
5800
6515
|
var LIKELY_VISUAL_PROP = /(?:position|size|rotation|transparency|opacity|color|image|text|anchor|visible|enabled|layout|scale|offset|zindex|canvas|viewport|cframe|width|height|radius|stroke|padding|spacing|angle|alpha|progress)/i;
|
|
5801
6516
|
function createElementPropUsage(node, context) {
|
|
@@ -5824,7 +6539,7 @@ function createElementPropUsage(node, context) {
|
|
|
5824
6539
|
}
|
|
5825
6540
|
if (parent.type === "function_definition" || parent.type === "function_declaration")
|
|
5826
6541
|
return null;
|
|
5827
|
-
if (!
|
|
6542
|
+
if (!isTransparentParent(parent, context))
|
|
5828
6543
|
return null;
|
|
5829
6544
|
current = parent;
|
|
5830
6545
|
}
|
|
@@ -5856,17 +6571,17 @@ function isEffectUsage(node, context) {
|
|
|
5856
6571
|
}
|
|
5857
6572
|
return false;
|
|
5858
6573
|
}
|
|
5859
|
-
function isStructuralUsage(node) {
|
|
6574
|
+
function isStructuralUsage(node, context) {
|
|
5860
6575
|
let current = node;
|
|
5861
6576
|
while (current) {
|
|
5862
6577
|
const parent = current.parent;
|
|
5863
6578
|
if (!parent)
|
|
5864
6579
|
return false;
|
|
5865
|
-
if (parent.type === "if_statement" || parent.type === "elseif_clause" || parent.type === "while_statement" || parent.type === "repeat_statement" || parent.type === "return_statement")
|
|
6580
|
+
if (parent.type === "if_statement" || parent.type === "elseif_clause" || parent.type === "while_statement" || parent.type === "repeat_statement" || parent.type === "for_statement" || parent.type === "for_in_statement" || parent.type === "return_statement")
|
|
5866
6581
|
return true;
|
|
5867
6582
|
if (parent.type === "function_definition" || parent.type === "function_declaration")
|
|
5868
6583
|
return false;
|
|
5869
|
-
if (!
|
|
6584
|
+
if (!isTransparentParent(parent, context))
|
|
5870
6585
|
return false;
|
|
5871
6586
|
current = parent;
|
|
5872
6587
|
}
|
|
@@ -5879,11 +6594,11 @@ function classifyBindingUsage(node, context, compatibleComponentProps) {
|
|
|
5879
6594
|
return "custom-component";
|
|
5880
6595
|
if (isEffectUsage(node, context))
|
|
5881
6596
|
return "effect";
|
|
5882
|
-
if (isStructuralUsage(node))
|
|
6597
|
+
if (isStructuralUsage(node, context))
|
|
5883
6598
|
return "structural";
|
|
5884
6599
|
return "unknown";
|
|
5885
6600
|
}
|
|
5886
|
-
function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps) {
|
|
6601
|
+
function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps, visited = new Set) {
|
|
5887
6602
|
const summary = {
|
|
5888
6603
|
reads: 0,
|
|
5889
6604
|
compatible: 0,
|
|
@@ -5895,9 +6610,45 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
|
|
|
5895
6610
|
};
|
|
5896
6611
|
if (!owner.body)
|
|
5897
6612
|
return summary;
|
|
6613
|
+
const visitKey = `${declaration.startIndex}:${valueName}`;
|
|
6614
|
+
if (visited.has(visitKey))
|
|
6615
|
+
return summary;
|
|
6616
|
+
visited.add(visitKey);
|
|
5898
6617
|
for (const node of context.walk(owner.body)) {
|
|
5899
6618
|
if (!isReadNode(node, valueName, owner, declaration))
|
|
5900
6619
|
continue;
|
|
6620
|
+
if (!isTransparentUsage(node, context, compatibleComponentProps)) {
|
|
6621
|
+
const derivedDeclaration = derivedLocalDeclarationForRead(node, context);
|
|
6622
|
+
const derivedNames = derivedDeclaration ? declarationNames2(derivedDeclaration) : [];
|
|
6623
|
+
if (derivedDeclaration && derivedNames.length === 1) {
|
|
6624
|
+
const derived = bindingUsageSummary(derivedNames[0], owner, derivedDeclaration, context, compatibleComponentProps, new Set(visited));
|
|
6625
|
+
if (derived.reads > 0) {
|
|
6626
|
+
summary.reads += derived.reads;
|
|
6627
|
+
summary.compatible += derived.compatible;
|
|
6628
|
+
summary.customComponent += derived.customComponent;
|
|
6629
|
+
summary.visualCustomComponent += derived.visualCustomComponent;
|
|
6630
|
+
summary.effect += derived.effect;
|
|
6631
|
+
summary.structural += derived.structural;
|
|
6632
|
+
summary.unknown += derived.unknown;
|
|
6633
|
+
continue;
|
|
6634
|
+
}
|
|
6635
|
+
}
|
|
6636
|
+
const opaqueDeclaration = opaqueDerivedLocalDeclarationForRead(node, context);
|
|
6637
|
+
const opaqueNames = opaqueDeclaration ? declarationNames2(opaqueDeclaration) : [];
|
|
6638
|
+
if (opaqueDeclaration && opaqueNames.length === 1) {
|
|
6639
|
+
const derived = bindingUsageSummary(opaqueNames[0], owner, opaqueDeclaration, context, compatibleComponentProps, new Set(visited));
|
|
6640
|
+
if (derived.reads > 0) {
|
|
6641
|
+
summary.reads += derived.reads + 1;
|
|
6642
|
+
summary.compatible += derived.compatible;
|
|
6643
|
+
summary.customComponent += derived.customComponent;
|
|
6644
|
+
summary.visualCustomComponent += derived.visualCustomComponent;
|
|
6645
|
+
summary.effect += derived.effect;
|
|
6646
|
+
summary.structural += derived.structural;
|
|
6647
|
+
summary.unknown += derived.unknown + 1;
|
|
6648
|
+
continue;
|
|
6649
|
+
}
|
|
6650
|
+
}
|
|
6651
|
+
}
|
|
5901
6652
|
summary.reads += 1;
|
|
5902
6653
|
const customComponentUsage = createElementPropUsage(node, context);
|
|
5903
6654
|
const kind = classifyBindingUsage(node, context, compatibleComponentProps);
|
|
@@ -5920,11 +6671,94 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
|
|
|
5920
6671
|
function hasCandidatePresentationEvidence(summary) {
|
|
5921
6672
|
return summary.compatible > 0 || summary.visualCustomComponent > 0;
|
|
5922
6673
|
}
|
|
6674
|
+
function isPresentationOnly(summary) {
|
|
6675
|
+
return summary.reads > 0 && summary.compatible === summary.reads;
|
|
6676
|
+
}
|
|
6677
|
+
function stateBindingForSetter(setterName, context) {
|
|
6678
|
+
const valueName = context.model.stateSetters.get(setterName);
|
|
6679
|
+
if (!valueName)
|
|
6680
|
+
return null;
|
|
6681
|
+
return context.model.stateBindings.find((binding) => binding.setterName === setterName && binding.valueName === valueName) ?? null;
|
|
6682
|
+
}
|
|
6683
|
+
function effectOnlyDerivesPresentation(valueName, owner, declaration, context, compatibleComponentProps) {
|
|
6684
|
+
if (!owner.body)
|
|
6685
|
+
return false;
|
|
6686
|
+
let foundRelevantEffect = false;
|
|
6687
|
+
for (const call of context.walk(owner.body)) {
|
|
6688
|
+
if (call.type !== "function_call" || context.nearestFunction(call) !== owner)
|
|
6689
|
+
continue;
|
|
6690
|
+
const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
6691
|
+
if (path7 !== "React.useEffect" && path7 !== "React.useLayoutEffect")
|
|
6692
|
+
continue;
|
|
6693
|
+
const callbackNode = context.callArguments(call)[0];
|
|
6694
|
+
if (callbackNode?.type !== "function_definition")
|
|
6695
|
+
continue;
|
|
6696
|
+
const callback = context.model.functionByNode.get(callbackNode.id);
|
|
6697
|
+
if (!callback?.body)
|
|
6698
|
+
continue;
|
|
6699
|
+
const usesValue = [...context.walk(callback.body)].some((node) => context.nearestFunction(node) === callback && isReadNode(node, valueName, owner, declaration));
|
|
6700
|
+
if (!usesValue)
|
|
6701
|
+
continue;
|
|
6702
|
+
foundRelevantEffect = true;
|
|
6703
|
+
let presentationSetter = false;
|
|
6704
|
+
for (const nested of context.walk(callback.body)) {
|
|
6705
|
+
if (nested.type !== "function_call" || context.nearestFunction(nested) !== callback)
|
|
6706
|
+
continue;
|
|
6707
|
+
if (isBindingMapSafeCall(nested, context))
|
|
6708
|
+
continue;
|
|
6709
|
+
const nestedPath = context.resolveCallPath(context.getCallPath(nested) ?? "");
|
|
6710
|
+
if (nestedPath === "debug.profilebegin" || nestedPath === "debug.profileend")
|
|
6711
|
+
continue;
|
|
6712
|
+
if (context.model.bindingSetters.has(nestedPath)) {
|
|
6713
|
+
presentationSetter = true;
|
|
6714
|
+
continue;
|
|
6715
|
+
}
|
|
6716
|
+
const target = stateBindingForSetter(nestedPath, context);
|
|
6717
|
+
if (target?.owner && stateReadsAreBindingCompatible(target.valueName, target.owner, target.declaration, context, compatibleComponentProps)) {
|
|
6718
|
+
presentationSetter = true;
|
|
6719
|
+
continue;
|
|
6720
|
+
}
|
|
6721
|
+
return false;
|
|
6722
|
+
}
|
|
6723
|
+
if (!presentationSetter)
|
|
6724
|
+
return false;
|
|
6725
|
+
}
|
|
6726
|
+
return foundRelevantEffect;
|
|
6727
|
+
}
|
|
6728
|
+
function importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) {
|
|
6729
|
+
if (!summary.external || callDefinitelyUsesBindingMode(summary, call, context))
|
|
6730
|
+
return null;
|
|
6731
|
+
const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
|
|
6732
|
+
if (usage.reads === 0)
|
|
6733
|
+
return null;
|
|
6734
|
+
const presentationOnly = isPresentationOnly(usage);
|
|
6735
|
+
const explicitBindingMode = hasExplicitBindingMode(summary);
|
|
6736
|
+
const observedProperty = observedPropertyForCall(summary, call, context);
|
|
6737
|
+
const strongHostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
|
|
6738
|
+
const likelyContinuousProperty = summary.sourceKind === "instance-property" && Boolean(observedProperty && LIKELY_CONTINUOUS_PROPERTIES.has(observedProperty));
|
|
6739
|
+
const mirrorsExternalValue = summary.mirrorConfidence !== "none";
|
|
6740
|
+
if (presentationOnly && (summary.highFrequency || likelyContinuousProperty))
|
|
6741
|
+
return "warning";
|
|
6742
|
+
if (presentationOnly && strongHostFeedback && explicitBindingMode)
|
|
6743
|
+
return "warning";
|
|
6744
|
+
if (presentationOnly && (explicitBindingMode || strongHostFeedback || mirrorsExternalValue))
|
|
6745
|
+
return "candidate";
|
|
6746
|
+
if (strongHostFeedback && usage.effect > 0 && effectOnlyDerivesPresentation(localName, owner, declaration, context, compatibleComponentProps)) {
|
|
6747
|
+
return "candidate";
|
|
6748
|
+
}
|
|
6749
|
+
if (!hasCandidatePresentationEvidence(usage))
|
|
6750
|
+
return null;
|
|
6751
|
+
if (summary.highFrequency || likelyContinuousProperty)
|
|
6752
|
+
return "candidate";
|
|
6753
|
+
if (strongHostFeedback && explicitBindingMode && usage.effect === 0)
|
|
6754
|
+
return "candidate";
|
|
6755
|
+
return null;
|
|
6756
|
+
}
|
|
5923
6757
|
var preferBindingOverState = {
|
|
5924
6758
|
id: "react-luau/prefer-binding-over-state",
|
|
5925
6759
|
category: "Performance",
|
|
5926
6760
|
severity: "warning",
|
|
5927
|
-
description: "Prefer React.useBinding when
|
|
6761
|
+
description: "Prefer React.useBinding when proven external presentation streams do not need React reconciliation.",
|
|
5928
6762
|
run(context) {
|
|
5929
6763
|
const diagnostics = [];
|
|
5930
6764
|
const seen = new Set;
|
|
@@ -5941,75 +6775,72 @@ var preferBindingOverState = {
|
|
|
5941
6775
|
const owner = context.containingComponent(call) ?? context.nearestFunction(call);
|
|
5942
6776
|
if (!declaration || !localName || !owner)
|
|
5943
6777
|
continue;
|
|
5944
|
-
if (
|
|
6778
|
+
if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "warning")
|
|
5945
6779
|
continue;
|
|
5946
6780
|
const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
|
|
5947
6781
|
if (seen.has(key))
|
|
5948
6782
|
continue;
|
|
5949
6783
|
seen.add(key);
|
|
5950
|
-
const hasBindingMode = summary
|
|
5951
|
-
const
|
|
5952
|
-
const
|
|
5953
|
-
const
|
|
6784
|
+
const hasBindingMode = hasExplicitBindingMode(summary);
|
|
6785
|
+
const observedProperty = observedPropertyForCall(summary, call, context);
|
|
6786
|
+
const hostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
|
|
6787
|
+
const likelyContinuousProperty = summary.sourceKind === "instance-property" && Boolean(observedProperty && LIKELY_CONTINUOUS_PROPERTIES.has(observedProperty));
|
|
6788
|
+
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.`;
|
|
6789
|
+
const help = hasBindingMode ? `Use the hook's Binding-returning mode${summary.bindingModeParameterName ? ` by passing ${summary.bindingWhenTruthy ? "true" : "false"} for ${summary.bindingModeParameterName}` : ""}. Binding:map can keep pure visual derivations outside the render cycle.` : "Prefer a Binding-returning hook API so continuous presentation updates can reach host properties without rerendering the consumer.";
|
|
5954
6790
|
diagnostics.push({
|
|
5955
6791
|
node: callNameNode(call),
|
|
5956
6792
|
message,
|
|
5957
6793
|
help,
|
|
5958
|
-
fixPreview: hasBindingMode ? bindingModeFixPreview(summary, call, context) : undefined
|
|
5959
|
-
});
|
|
5960
|
-
}
|
|
5961
|
-
for (const call of context.findCalls()) {
|
|
5962
|
-
const path7 = context.getCallPath(call) ?? "";
|
|
5963
|
-
const member = path7.split(/[.:]/).at(-1) ?? path7;
|
|
5964
|
-
const bindingAlternative = context.project.bindingApiAlternatives.get(member);
|
|
5965
|
-
if (!bindingAlternative)
|
|
5966
|
-
continue;
|
|
5967
|
-
const declaration = declarationForCall2(call);
|
|
5968
|
-
const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
|
|
5969
|
-
const owner = context.containingComponent(call) ?? context.nearestFunction(call);
|
|
5970
|
-
if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
|
|
5971
|
-
continue;
|
|
5972
|
-
if (!stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps))
|
|
5973
|
-
continue;
|
|
5974
|
-
const field = selectorFieldName(call, context);
|
|
5975
|
-
const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
|
|
5976
|
-
if (seen.has(key))
|
|
5977
|
-
continue;
|
|
5978
|
-
seen.add(key);
|
|
5979
|
-
diagnostics.push({
|
|
5980
|
-
node: callNameNode(call),
|
|
5981
|
-
message: `${path7} subscribes ${localName} through React state${field ? ` for ${field}` : ""}, but ${bindingAlternative} is available and every use of the value only updates bindable host properties.`,
|
|
5982
|
-
help: `Use ${bindingAlternative} so the host property can update without rerendering the component.`,
|
|
5983
|
-
fixPreview: callMemberFixPreview(call, bindingAlternative)
|
|
6794
|
+
fixPreview: hasBindingMode && allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? bindingModeFixPreview(summary, call, context) : undefined
|
|
5984
6795
|
});
|
|
5985
6796
|
}
|
|
5986
6797
|
for (const binding of context.model.stateBindings) {
|
|
5987
6798
|
const owner = binding.owner;
|
|
5988
6799
|
if (!owner || !owner.isComponent && !owner.isHook)
|
|
5989
6800
|
continue;
|
|
5990
|
-
const
|
|
5991
|
-
if (!
|
|
6801
|
+
const update = bindingUpdateEvidence(binding, importedCallbacks, context);
|
|
6802
|
+
if (!update)
|
|
5992
6803
|
continue;
|
|
5993
6804
|
const localCompatible = stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
|
|
5994
6805
|
const hookCompatible = owner.isHook ? hookCallSitesAreBindingCompatible(binding, context, compatibleComponentProps) : false;
|
|
5995
|
-
|
|
6806
|
+
const strongHostFeedback = hasStrongHostFeedbackEvidence(update);
|
|
6807
|
+
const likelyContinuousProperty = hasLikelyContinuousPropertyEvidence(update);
|
|
6808
|
+
const shouldWarn = owner.isHook ? hookCompatible && (update.highFrequency || likelyContinuousProperty) : localCompatible && (update.highFrequency || strongHostFeedback || likelyContinuousProperty);
|
|
6809
|
+
if (!shouldWarn)
|
|
5996
6810
|
continue;
|
|
5997
6811
|
const key = `${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
|
|
5998
6812
|
if (seen.has(key))
|
|
5999
6813
|
continue;
|
|
6000
6814
|
seen.add(key);
|
|
6001
6815
|
if (owner.isHook && hookCompatible) {
|
|
6816
|
+
const properties = likelyContinuousProperty ? [...update.hostFeedbackProperties].sort().join(", ") : "";
|
|
6002
6817
|
diagnostics.push({
|
|
6003
6818
|
node: callNameNode(binding.call),
|
|
6004
|
-
message: `Hook state ${binding.valueName} is
|
|
6005
|
-
help: "Consider returning a Binding, or adding a Binding-returning variant of this hook, so
|
|
6819
|
+
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.`,
|
|
6820
|
+
help: "Consider returning a Binding, or adding a Binding-returning variant of this hook, so continuous presentation updates do not rerender every consumer."
|
|
6821
|
+
});
|
|
6822
|
+
} else if (strongHostFeedback && !update.highFrequency) {
|
|
6823
|
+
const properties = [...update.hostFeedbackProperties].sort().join(", ");
|
|
6824
|
+
diagnostics.push({
|
|
6825
|
+
node: callNameNode(binding.call),
|
|
6826
|
+
message: `State ${binding.valueName} mirrors ${properties} and only feeds presentation updates, so React reconciliation is acting as an unnecessary host-property feedback step.`,
|
|
6827
|
+
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.",
|
|
6828
|
+
fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
|
|
6829
|
+
});
|
|
6830
|
+
} else if (likelyContinuousProperty && !update.highFrequency) {
|
|
6831
|
+
const properties = [...update.hostFeedbackProperties].sort().join(", ");
|
|
6832
|
+
diagnostics.push({
|
|
6833
|
+
node: callNameNode(binding.call),
|
|
6834
|
+
message: `State ${binding.valueName} mirrors continuously mutable ${properties} and only feeds presentation updates that do not need React reconciliation.`,
|
|
6835
|
+
help: "Prefer React.useBinding so external visual property changes can reach presentation without routing every update through component state.",
|
|
6836
|
+
fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
|
|
6006
6837
|
});
|
|
6007
6838
|
} else {
|
|
6008
6839
|
diagnostics.push({
|
|
6009
6840
|
node: callNameNode(binding.call),
|
|
6010
|
-
message: `State ${binding.valueName} is updated
|
|
6011
|
-
help: "Prefer React.useBinding
|
|
6012
|
-
fixPreview: useBindingFixPreview(binding.call)
|
|
6841
|
+
message: `State ${binding.valueName} is updated from a high-frequency callback and only feeds presentation updates that do not need React reconciliation.`,
|
|
6842
|
+
help: "Prefer React.useBinding so continuous visual updates can reach host properties without rerendering the component.",
|
|
6843
|
+
fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
|
|
6013
6844
|
});
|
|
6014
6845
|
}
|
|
6015
6846
|
}
|
|
@@ -6030,27 +6861,49 @@ var preferBindingOverStateCandidate = {
|
|
|
6030
6861
|
for (const call of context.findCalls()) {
|
|
6031
6862
|
const path7 = context.getCallPath(call) ?? "";
|
|
6032
6863
|
const summary = importedHooks.get(path7);
|
|
6033
|
-
if (!summary
|
|
6864
|
+
if (!summary)
|
|
6034
6865
|
continue;
|
|
6035
6866
|
const declaration = declarationForCall2(call);
|
|
6036
6867
|
const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
|
|
6037
6868
|
const owner = context.containingComponent(call) ?? context.nearestFunction(call);
|
|
6038
|
-
if (!declaration || !localName || !owner
|
|
6869
|
+
if (!declaration || !localName || !owner)
|
|
6039
6870
|
continue;
|
|
6040
|
-
if (
|
|
6871
|
+
if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "candidate")
|
|
6041
6872
|
continue;
|
|
6042
6873
|
const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
|
|
6043
|
-
const
|
|
6044
|
-
|
|
6045
|
-
|
|
6874
|
+
const presentationOnly = isPresentationOnly(usage);
|
|
6875
|
+
const observedProperty = observedPropertyForCall(summary, call, context);
|
|
6876
|
+
const strongHostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
|
|
6877
|
+
const hasBindingMode = hasExplicitBindingMode(summary);
|
|
6878
|
+
const mirrorsExternalValue = summary.mirrorConfidence !== "none";
|
|
6879
|
+
const visualEffectBridge = strongHostFeedback && usage.effect > 0 && effectOnlyDerivesPresentation(localName, owner, declaration, context, compatibleComponentProps);
|
|
6046
6880
|
const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
|
|
6047
6881
|
if (seen.has(key))
|
|
6048
6882
|
continue;
|
|
6049
6883
|
seen.add(key);
|
|
6884
|
+
let message;
|
|
6885
|
+
let help;
|
|
6886
|
+
if (presentationOnly && hasBindingMode) {
|
|
6887
|
+
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.`;
|
|
6888
|
+
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.";
|
|
6889
|
+
} else if (presentationOnly && strongHostFeedback) {
|
|
6890
|
+
message = `${path7} mirrors ${observedProperty} through React state only for presentation, but the hook does not expose a proven low-friction Binding mode.`;
|
|
6891
|
+
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.";
|
|
6892
|
+
} else if (presentationOnly && mirrorsExternalValue) {
|
|
6893
|
+
message = `${path7} mirrors an external reactive value through React state, and ${localName} is only used for presentation.`;
|
|
6894
|
+
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.";
|
|
6895
|
+
} else if (visualEffectBridge) {
|
|
6896
|
+
message = `${path7} mirrors ${observedProperty} through React state, and the dependent React effect only derives presentation state or Bindings from ${localName}.`;
|
|
6897
|
+
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.";
|
|
6898
|
+
} else {
|
|
6899
|
+
message = `${path7} updates ${localName} through React state and some consumers are presentation-only, but other consumers may still require React reconciliation.`;
|
|
6900
|
+
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.";
|
|
6901
|
+
}
|
|
6050
6902
|
diagnostics.push({
|
|
6051
6903
|
node: callNameNode(call),
|
|
6052
|
-
message
|
|
6053
|
-
help
|
|
6904
|
+
message,
|
|
6905
|
+
help,
|
|
6906
|
+
fixPreview: presentationOnly && hasBindingMode && allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? bindingModeFixPreview(summary, call, context) : undefined
|
|
6054
6907
|
});
|
|
6055
6908
|
}
|
|
6056
6909
|
for (const call of context.findCalls()) {
|
|
@@ -6064,39 +6917,80 @@ var preferBindingOverStateCandidate = {
|
|
|
6064
6917
|
const owner = context.containingComponent(call) ?? context.nearestFunction(call);
|
|
6065
6918
|
if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
|
|
6066
6919
|
continue;
|
|
6067
|
-
|
|
6920
|
+
const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
|
|
6921
|
+
const presentationOnly = isPresentationOnly(usage);
|
|
6922
|
+
if (!presentationOnly)
|
|
6068
6923
|
continue;
|
|
6924
|
+
const field = selectorFieldName(call, context);
|
|
6069
6925
|
const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
|
|
6070
6926
|
if (seen.has(key))
|
|
6071
6927
|
continue;
|
|
6072
6928
|
seen.add(key);
|
|
6073
6929
|
diagnostics.push({
|
|
6074
6930
|
node: callNameNode(call),
|
|
6075
|
-
message: `${path7}
|
|
6076
|
-
help: `
|
|
6077
|
-
fixPreview: callMemberFixPreview(call, bindingAlternative)
|
|
6931
|
+
message: `${path7} subscribes ${localName} through React state${field ? ` for ${field}` : ""}, while ${bindingAlternative} is also available and every analyzed consumer is presentation-only.`,
|
|
6932
|
+
help: `Consider ${bindingAlternative} when this value updates frequently enough that avoiding reconciliation matters; state remains reasonable for ordinary semantic store updates.`,
|
|
6933
|
+
fixPreview: allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? callMemberFixPreview(call, bindingAlternative) : undefined
|
|
6078
6934
|
});
|
|
6079
6935
|
}
|
|
6080
6936
|
for (const binding of context.model.stateBindings) {
|
|
6081
6937
|
const owner = binding.owner;
|
|
6082
|
-
if (!owner || !owner.isComponent || !owner.body)
|
|
6938
|
+
if (!owner || !owner.isComponent && !owner.isHook || !owner.body)
|
|
6083
6939
|
continue;
|
|
6084
|
-
const
|
|
6085
|
-
if (!
|
|
6940
|
+
const update = bindingUpdateEvidence(binding, importedCallbacks, context);
|
|
6941
|
+
if (!update)
|
|
6086
6942
|
continue;
|
|
6087
|
-
|
|
6943
|
+
const localCompatible = stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
|
|
6944
|
+
const hookCompatible = owner.isHook ? hookCallSitesAreBindingCompatible(binding, context, compatibleComponentProps) : false;
|
|
6945
|
+
const strongHostFeedback = hasStrongHostFeedbackEvidence(update);
|
|
6946
|
+
const likelyContinuousProperty = hasLikelyContinuousPropertyEvidence(update);
|
|
6947
|
+
const mirrorsExternalValue = update.mirrorConfidence !== "none";
|
|
6948
|
+
if (owner.isHook) {
|
|
6949
|
+
if (hookCompatible && (update.highFrequency || likelyContinuousProperty))
|
|
6950
|
+
continue;
|
|
6951
|
+
if (!(hookCompatible && (strongHostFeedback || mirrorsExternalValue)))
|
|
6952
|
+
continue;
|
|
6953
|
+
const key2 = `hook-state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
|
|
6954
|
+
if (seen.has(key2))
|
|
6955
|
+
continue;
|
|
6956
|
+
seen.add(key2);
|
|
6957
|
+
const measurement = strongHostFeedback ? "a presentation measurement" : "an external reactive value";
|
|
6958
|
+
diagnostics.push({
|
|
6959
|
+
node: callNameNode(binding.call),
|
|
6960
|
+
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.`,
|
|
6961
|
+
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."
|
|
6962
|
+
});
|
|
6088
6963
|
continue;
|
|
6964
|
+
}
|
|
6089
6965
|
const usage = bindingUsageSummary(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
|
|
6090
6966
|
if (!hasCandidatePresentationEvidence(usage))
|
|
6091
6967
|
continue;
|
|
6968
|
+
if (localCompatible && (update.highFrequency || strongHostFeedback || likelyContinuousProperty))
|
|
6969
|
+
continue;
|
|
6970
|
+
if (localCompatible && mirrorsExternalValue) {
|
|
6971
|
+
const key2 = `state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
|
|
6972
|
+
if (seen.has(key2))
|
|
6973
|
+
continue;
|
|
6974
|
+
seen.add(key2);
|
|
6975
|
+
diagnostics.push({
|
|
6976
|
+
node: callNameNode(binding.call),
|
|
6977
|
+
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.`,
|
|
6978
|
+
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."
|
|
6979
|
+
});
|
|
6980
|
+
continue;
|
|
6981
|
+
}
|
|
6982
|
+
const mixedHighFrequency = update.highFrequency || likelyContinuousProperty;
|
|
6983
|
+
const mixedHostFeedback = strongHostFeedback && usage.structural > 0 && usage.effect === 0;
|
|
6984
|
+
if (!mixedHighFrequency && !mixedHostFeedback)
|
|
6985
|
+
continue;
|
|
6092
6986
|
const key = `state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
|
|
6093
6987
|
if (seen.has(key))
|
|
6094
6988
|
continue;
|
|
6095
6989
|
seen.add(key);
|
|
6096
6990
|
diagnostics.push({
|
|
6097
6991
|
node: callNameNode(binding.call),
|
|
6098
|
-
message: `State ${binding.valueName}
|
|
6099
|
-
help: "Inspect whether
|
|
6992
|
+
message: `State ${binding.valueName} has presentation-oriented consumers, but other uses still appear to need React reconciliation.`,
|
|
6993
|
+
help: "Inspect whether the presentation branch can move to React.useBinding while structural consumers keep a smaller semantic state value. Splitting the responsibilities can remove hot-path rerenders without forcing Binding semantics onto the whole value."
|
|
6100
6994
|
});
|
|
6101
6995
|
}
|
|
6102
6996
|
return diagnostics;
|
|
@@ -6602,6 +7496,20 @@ function staticIterationImports(context) {
|
|
|
6602
7496
|
}
|
|
6603
7497
|
return result;
|
|
6604
7498
|
}
|
|
7499
|
+
function nonReactHookImports(context) {
|
|
7500
|
+
const result = new Set;
|
|
7501
|
+
for (const node of context.walk(context.root)) {
|
|
7502
|
+
if (node.type !== "variable_declaration")
|
|
7503
|
+
continue;
|
|
7504
|
+
const match = node.text.match(/^\s*local\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)\s*$/s);
|
|
7505
|
+
if (!match || !/^use[A-Z0-9_]/.test(match[1]))
|
|
7506
|
+
continue;
|
|
7507
|
+
const kind = resolveModuleReference(normalizeRequireTarget(match[2]), context.project.reactHookModules);
|
|
7508
|
+
if (kind === false)
|
|
7509
|
+
result.add(match[1]);
|
|
7510
|
+
}
|
|
7511
|
+
return result;
|
|
7512
|
+
}
|
|
6605
7513
|
function conditionalHookModeImports(context) {
|
|
6606
7514
|
const result = new Map;
|
|
6607
7515
|
for (const node of context.walk(context.root)) {
|
|
@@ -6649,30 +7557,12 @@ function controlledParameterIndex(controlFlow, fn, summary) {
|
|
|
6649
7557
|
}
|
|
6650
7558
|
return null;
|
|
6651
7559
|
}
|
|
6652
|
-
function isStableModeArgument(node, context) {
|
|
6653
|
-
if (!node)
|
|
6654
|
-
return true;
|
|
6655
|
-
const text = node.text.trim();
|
|
6656
|
-
if (["true", "false", "nil"].includes(text))
|
|
6657
|
-
return true;
|
|
6658
|
-
if ([
|
|
6659
|
-
"number",
|
|
6660
|
-
"string",
|
|
6661
|
-
"string_content",
|
|
6662
|
-
"table_constructor",
|
|
6663
|
-
"function_definition"
|
|
6664
|
-
].includes(node.type))
|
|
6665
|
-
return true;
|
|
6666
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(text) && context.model.stableVariables.has(text))
|
|
6667
|
-
return true;
|
|
6668
|
-
return false;
|
|
6669
|
-
}
|
|
6670
7560
|
function dynamicHookModeFixPreview(hookName, argumentText) {
|
|
6671
7561
|
return {
|
|
6672
7562
|
kind: "pattern",
|
|
6673
7563
|
before: `local value = ${hookName}(${argumentText})`,
|
|
6674
|
-
after: `local value = ${hookName}(
|
|
6675
|
-
note: "A custom hook may choose different internal hooks only when
|
|
7564
|
+
after: `local value = ${hookName}(${argumentText}) -- keep the hook-topology mode stable for this instance`,
|
|
7565
|
+
note: "A custom hook may choose different internal hooks only when the value controlling that choice stays stable for the lifetime of the component instance."
|
|
6676
7566
|
};
|
|
6677
7567
|
}
|
|
6678
7568
|
function unwrapIteratorExpression(text) {
|
|
@@ -6692,6 +7582,17 @@ function declarationExpressions4(node) {
|
|
|
6692
7582
|
function isEmptyTable(node) {
|
|
6693
7583
|
return Boolean(node?.type === "table_constructor" && node.namedChildren.every((child) => child.type !== "field"));
|
|
6694
7584
|
}
|
|
7585
|
+
function tableConstructorHasStableArity(node) {
|
|
7586
|
+
if (node.type !== "table_constructor")
|
|
7587
|
+
return false;
|
|
7588
|
+
const fields = node.namedChildren.filter((child) => child.type === "field");
|
|
7589
|
+
if (fields.length === 0)
|
|
7590
|
+
return true;
|
|
7591
|
+
if (fields.some((field) => [...field.namedChildren].some((child) => child.type === "vararg_expression")))
|
|
7592
|
+
return false;
|
|
7593
|
+
const last = fields.at(-1);
|
|
7594
|
+
return !last?.namedChildren.some((child) => child.type === "function_call");
|
|
7595
|
+
}
|
|
6695
7596
|
function stableShapeVariablesByFunction(context) {
|
|
6696
7597
|
const stable = new Map;
|
|
6697
7598
|
const functionsByName = new Map;
|
|
@@ -6718,7 +7619,7 @@ function stableShapeVariablesByFunction(context) {
|
|
|
6718
7619
|
const expression = expressions[index] ?? expressions[0];
|
|
6719
7620
|
if (!expression)
|
|
6720
7621
|
continue;
|
|
6721
|
-
if (expression.type === "table_constructor") {
|
|
7622
|
+
if (expression.type === "table_constructor" && tableConstructorHasStableArity(expression)) {
|
|
6722
7623
|
namesFor(owner).add(names[index]);
|
|
6723
7624
|
continue;
|
|
6724
7625
|
}
|
|
@@ -6745,7 +7646,7 @@ function stableShapeVariablesByFunction(context) {
|
|
|
6745
7646
|
if (!expression)
|
|
6746
7647
|
return false;
|
|
6747
7648
|
if (expression.type === "table_constructor")
|
|
6748
|
-
return
|
|
7649
|
+
return tableConstructorHasStableArity(expression);
|
|
6749
7650
|
const text = expression.text.trim();
|
|
6750
7651
|
if (!owner || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(text))
|
|
6751
7652
|
return false;
|
|
@@ -6798,6 +7699,187 @@ function stableShapeVariablesByFunction(context) {
|
|
|
6798
7699
|
}
|
|
6799
7700
|
return stable;
|
|
6800
7701
|
}
|
|
7702
|
+
function moduleInvariantVariables(context) {
|
|
7703
|
+
const declarations = new Map;
|
|
7704
|
+
const reassigned = new Set;
|
|
7705
|
+
for (const node of context.walk(context.root)) {
|
|
7706
|
+
if (node.type === "variable_declaration" && !context.nearestFunction(node)) {
|
|
7707
|
+
for (const name of declarationNames2(node))
|
|
7708
|
+
declarations.set(name, node);
|
|
7709
|
+
continue;
|
|
7710
|
+
}
|
|
7711
|
+
if (node.type !== "assignment_statement" || node.parent?.type === "variable_declaration")
|
|
7712
|
+
continue;
|
|
7713
|
+
const variableList = node.namedChildren.find((child) => child.type === "variable_list");
|
|
7714
|
+
for (const target of variableList?.namedChildren ?? []) {
|
|
7715
|
+
if (target.type === "identifier")
|
|
7716
|
+
reassigned.add(target.text);
|
|
7717
|
+
}
|
|
7718
|
+
}
|
|
7719
|
+
return new Set([...declarations.keys()].filter((name) => !reassigned.has(name)));
|
|
7720
|
+
}
|
|
7721
|
+
function simpleConditionRoot(controlFlow) {
|
|
7722
|
+
if (controlFlow.type !== "if_statement" && controlFlow.type !== "if_expression")
|
|
7723
|
+
return null;
|
|
7724
|
+
const condition = controlFlow.namedChildren[0];
|
|
7725
|
+
if (!condition)
|
|
7726
|
+
return null;
|
|
7727
|
+
const text = condition.text.trim();
|
|
7728
|
+
const match = text.match(/^(?:not\s+)?([A-Za-z_][A-Za-z0-9_]*)(?:\s*(?:==|~=)\s*(?:true|false|nil))?$/);
|
|
7729
|
+
return match?.[1] ?? null;
|
|
7730
|
+
}
|
|
7731
|
+
function conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants) {
|
|
7732
|
+
const condition = controlFlow.namedChildren[0];
|
|
7733
|
+
if (!condition)
|
|
7734
|
+
return false;
|
|
7735
|
+
if (["true", "false", "nil", "number", "string"].includes(condition.type))
|
|
7736
|
+
return true;
|
|
7737
|
+
const root = simpleConditionRoot(controlFlow);
|
|
7738
|
+
if (!root)
|
|
7739
|
+
return false;
|
|
7740
|
+
if (moduleInvariants.has(root))
|
|
7741
|
+
return true;
|
|
7742
|
+
return Boolean(context.model.stableVariablesByFunction.get(nodeKey(fn.node))?.has(root));
|
|
7743
|
+
}
|
|
7744
|
+
function branchHookSequence(branch, fn, context) {
|
|
7745
|
+
const hooks = [];
|
|
7746
|
+
for (const node of context.walk(branch)) {
|
|
7747
|
+
if (node.type !== "function_call")
|
|
7748
|
+
continue;
|
|
7749
|
+
if (context.nearestFunction(node) !== fn)
|
|
7750
|
+
continue;
|
|
7751
|
+
const rawPath = context.getCallPath(node);
|
|
7752
|
+
if (!rawPath)
|
|
7753
|
+
continue;
|
|
7754
|
+
const path7 = context.resolveCallPath(rawPath);
|
|
7755
|
+
if (!isHookPath(path7))
|
|
7756
|
+
continue;
|
|
7757
|
+
const nestedControl = findAncestorBetween(node, branch, (ancestor) => CONDITIONAL_TYPES.has(ancestor.type));
|
|
7758
|
+
if (nestedControl)
|
|
7759
|
+
return null;
|
|
7760
|
+
hooks.push(path7);
|
|
7761
|
+
}
|
|
7762
|
+
return hooks;
|
|
7763
|
+
}
|
|
7764
|
+
function ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context) {
|
|
7765
|
+
if (controlFlow.type !== "if_statement")
|
|
7766
|
+
return false;
|
|
7767
|
+
const thenBlock = controlFlow.namedChildren.find((child) => child.type === "block");
|
|
7768
|
+
const elseStatement = controlFlow.namedChildren.find((child) => child.type === "else_statement");
|
|
7769
|
+
const elseBlock = elseStatement?.namedChildren.find((child) => child.type === "block");
|
|
7770
|
+
if (!thenBlock || !elseBlock)
|
|
7771
|
+
return false;
|
|
7772
|
+
const thenHooks = branchHookSequence(thenBlock, fn, context);
|
|
7773
|
+
const elseHooks = branchHookSequence(elseBlock, fn, context);
|
|
7774
|
+
if (!thenHooks || !elseHooks || thenHooks.length !== elseHooks.length)
|
|
7775
|
+
return false;
|
|
7776
|
+
if (!thenHooks.every((path7, index) => path7 === elseHooks[index]))
|
|
7777
|
+
return false;
|
|
7778
|
+
return thenHooks.every((path7) => path7.startsWith("React."));
|
|
7779
|
+
}
|
|
7780
|
+
function stateModeStability(name, owner, context) {
|
|
7781
|
+
const binding = context.model.stateBindings.find((candidate) => candidate.owner === owner && candidate.valueName === name);
|
|
7782
|
+
if (!binding || !owner.body)
|
|
7783
|
+
return null;
|
|
7784
|
+
const initializerText = binding.initializer?.text.trim() ?? "nil";
|
|
7785
|
+
const stableLiteral = /^(?:true|false|nil|-?\d+(?:\.\d+)?|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')$/;
|
|
7786
|
+
let sawIndirectSetterReference = false;
|
|
7787
|
+
for (const node of context.walk(owner.body)) {
|
|
7788
|
+
if (node.type === "function_call" && context.getCallPath(node) === binding.setterName) {
|
|
7789
|
+
const argument = context.callArguments(node)[0];
|
|
7790
|
+
if (!argument || !stableLiteral.test(initializerText) || argument.text.trim() !== initializerText)
|
|
7791
|
+
return "unstable";
|
|
7792
|
+
continue;
|
|
7793
|
+
}
|
|
7794
|
+
if (node.type !== "identifier" || node.text !== binding.setterName)
|
|
7795
|
+
continue;
|
|
7796
|
+
if (node.startIndex >= binding.declaration.startIndex && node.endIndex <= binding.declaration.endIndex)
|
|
7797
|
+
continue;
|
|
7798
|
+
if (node.parent?.type === "function_call")
|
|
7799
|
+
continue;
|
|
7800
|
+
sawIndirectSetterReference = true;
|
|
7801
|
+
}
|
|
7802
|
+
if (sawIndirectSetterReference)
|
|
7803
|
+
return "unknown";
|
|
7804
|
+
return "stable";
|
|
7805
|
+
}
|
|
7806
|
+
function localAliasExpression(name, owner, context) {
|
|
7807
|
+
if (!owner.body)
|
|
7808
|
+
return null;
|
|
7809
|
+
let result = null;
|
|
7810
|
+
for (const node of context.walk(owner.body)) {
|
|
7811
|
+
if (node.type !== "variable_declaration" || context.nearestFunction(node) !== owner)
|
|
7812
|
+
continue;
|
|
7813
|
+
const names = declarationNames2(node);
|
|
7814
|
+
const index = names.indexOf(name);
|
|
7815
|
+
if (index < 0)
|
|
7816
|
+
continue;
|
|
7817
|
+
const expressions = declarationExpressions4(node);
|
|
7818
|
+
const expression = expressions[index] ?? expressions[0] ?? null;
|
|
7819
|
+
if (result)
|
|
7820
|
+
return null;
|
|
7821
|
+
result = expression;
|
|
7822
|
+
}
|
|
7823
|
+
return result;
|
|
7824
|
+
}
|
|
7825
|
+
function modeValueStability(node, owner, context, moduleInvariants, depth = 0) {
|
|
7826
|
+
if (!node)
|
|
7827
|
+
return "stable";
|
|
7828
|
+
if (depth > 4)
|
|
7829
|
+
return "unknown";
|
|
7830
|
+
const text = node.text.trim();
|
|
7831
|
+
if (["true", "false", "nil"].includes(text))
|
|
7832
|
+
return "stable";
|
|
7833
|
+
if (["number", "string", "string_content", "function_definition"].includes(node.type))
|
|
7834
|
+
return "stable";
|
|
7835
|
+
const root = text.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1];
|
|
7836
|
+
if (!root)
|
|
7837
|
+
return "unknown";
|
|
7838
|
+
const stateStability = stateModeStability(root, owner, context);
|
|
7839
|
+
if (stateStability)
|
|
7840
|
+
return stateStability;
|
|
7841
|
+
if (moduleInvariants.has(root))
|
|
7842
|
+
return "stable";
|
|
7843
|
+
if (context.model.stableVariablesByFunction.get(nodeKey(owner.node))?.has(root))
|
|
7844
|
+
return "stable";
|
|
7845
|
+
if (text === root) {
|
|
7846
|
+
const alias = localAliasExpression(root, owner, context);
|
|
7847
|
+
if (alias && alias.text.trim() !== root)
|
|
7848
|
+
return modeValueStability(alias, owner, context, moduleInvariants, depth + 1);
|
|
7849
|
+
}
|
|
7850
|
+
return "unknown";
|
|
7851
|
+
}
|
|
7852
|
+
function tableFieldValue(table, accessPath) {
|
|
7853
|
+
if (!table || accessPath === "")
|
|
7854
|
+
return table;
|
|
7855
|
+
if (table.type !== "table_constructor")
|
|
7856
|
+
return;
|
|
7857
|
+
let current = table;
|
|
7858
|
+
for (const part of accessPath.split(".")) {
|
|
7859
|
+
if (!current || current.type !== "table_constructor")
|
|
7860
|
+
return;
|
|
7861
|
+
const field = current.namedChildren.find((child) => {
|
|
7862
|
+
if (child.type !== "field")
|
|
7863
|
+
return false;
|
|
7864
|
+
return child.namedChildren[0]?.type === "identifier" && child.namedChildren[0]?.text === part;
|
|
7865
|
+
});
|
|
7866
|
+
if (!field)
|
|
7867
|
+
return;
|
|
7868
|
+
current = field.namedChildren[1];
|
|
7869
|
+
}
|
|
7870
|
+
return current;
|
|
7871
|
+
}
|
|
7872
|
+
function controlledAccessPaths(summary, parameterIndex) {
|
|
7873
|
+
const paths = new Set;
|
|
7874
|
+
for (const [conditionName, index] of Object.entries(summary.conditionVariables)) {
|
|
7875
|
+
if (index !== parameterIndex)
|
|
7876
|
+
continue;
|
|
7877
|
+
paths.add(summary.conditionAccessPaths?.[conditionName] ?? "");
|
|
7878
|
+
}
|
|
7879
|
+
if (paths.size === 0)
|
|
7880
|
+
paths.add("");
|
|
7881
|
+
return [...paths];
|
|
7882
|
+
}
|
|
6801
7883
|
function loopIterationIsProvablyStable(loop, imports, owner, stableShapes) {
|
|
6802
7884
|
const numeric = loop.namedChildren.find((child) => child.type === "for_numeric_clause");
|
|
6803
7885
|
if (numeric) {
|
|
@@ -6859,9 +7941,9 @@ function impossibleNilGuardReturn(node, fn, parameterTypes) {
|
|
|
6859
7941
|
}
|
|
6860
7942
|
return false;
|
|
6861
7943
|
}
|
|
6862
|
-
function
|
|
7944
|
+
function reachableReturnsBefore(call, fn, context, cache) {
|
|
6863
7945
|
if (!fn.body)
|
|
6864
|
-
return
|
|
7946
|
+
return [];
|
|
6865
7947
|
const key = nodeKey(fn.node);
|
|
6866
7948
|
let returns = cache.get(key);
|
|
6867
7949
|
if (!returns) {
|
|
@@ -6869,7 +7951,21 @@ function hasReachableReturnBefore(call, fn, context, cache) {
|
|
|
6869
7951
|
returns = [...context.walk(fn.body)].filter((node) => node.type === "return_statement" && !isNestedInsideFunction(node, fn.node) && !impossibleNilGuardReturn(node, fn, parameterTypes));
|
|
6870
7952
|
cache.set(key, returns);
|
|
6871
7953
|
}
|
|
6872
|
-
return returns.
|
|
7954
|
+
return returns.filter((node) => node.endIndex <= call.startIndex);
|
|
7955
|
+
}
|
|
7956
|
+
function returnIsControlledByStableTopologyMode(node, fn, summary, context, moduleInvariants) {
|
|
7957
|
+
let current = node.parent;
|
|
7958
|
+
while (current && current !== fn.node) {
|
|
7959
|
+
if (current.type === "if_statement" || current.type === "if_expression") {
|
|
7960
|
+
if (summary && controlledParameterIndex(current, fn, summary) !== null)
|
|
7961
|
+
return true;
|
|
7962
|
+
if (conditionIsProvablyInvariant(current, fn, context, moduleInvariants))
|
|
7963
|
+
return true;
|
|
7964
|
+
return false;
|
|
7965
|
+
}
|
|
7966
|
+
current = current.parent;
|
|
7967
|
+
}
|
|
7968
|
+
return false;
|
|
6873
7969
|
}
|
|
6874
7970
|
function conditionalFixPreview(kind, hookName) {
|
|
6875
7971
|
if (kind === "for statement") {
|
|
@@ -6941,15 +8037,21 @@ var rulesOfHooks = {
|
|
|
6941
8037
|
description: "Hooks must run in the same order on every render of a React component or custom hook.",
|
|
6942
8038
|
run(context) {
|
|
6943
8039
|
const diagnostics = [];
|
|
8040
|
+
if (!context.model.isReactFile)
|
|
8041
|
+
return diagnostics;
|
|
6944
8042
|
const staticImports = staticIterationImports(context);
|
|
6945
8043
|
const stableShapes = stableShapeVariablesByFunction(context);
|
|
8044
|
+
const moduleInvariants = moduleInvariantVariables(context);
|
|
6946
8045
|
const modeImports = conditionalHookModeImports(context);
|
|
8046
|
+
const ignoredHookImports = nonReactHookImports(context);
|
|
6947
8047
|
const currentModeSummary = currentConditionalHookMode(context);
|
|
6948
8048
|
const reachableReturns = new Map;
|
|
6949
8049
|
for (const call of context.findCalls()) {
|
|
6950
8050
|
const rawPath = context.getCallPath(call);
|
|
6951
8051
|
if (!rawPath)
|
|
6952
8052
|
continue;
|
|
8053
|
+
if (ignoredHookImports.has(rawPath))
|
|
8054
|
+
continue;
|
|
6953
8055
|
const path7 = context.resolveCallPath(rawPath);
|
|
6954
8056
|
if (!isHookPath(path7))
|
|
6955
8057
|
continue;
|
|
@@ -6978,25 +8080,36 @@ end`,
|
|
|
6978
8080
|
const importedMode = modeImports.get(rawPath);
|
|
6979
8081
|
if (importedMode) {
|
|
6980
8082
|
const args = context.callArguments(call);
|
|
6981
|
-
const
|
|
6982
|
-
|
|
6983
|
-
const
|
|
6984
|
-
const
|
|
8083
|
+
const unstableControls = importedMode.parameterIndexes.flatMap((index) => controlledAccessPaths(importedMode, index).map((accessPath) => {
|
|
8084
|
+
const argument = args[index];
|
|
8085
|
+
const value = tableFieldValue(argument, accessPath);
|
|
8086
|
+
const stability = accessPath !== "" ? argument?.type === "table_constructor" ? value ? modeValueStability(value, fn, context, moduleInvariants) : "stable" : "unknown" : modeValueStability(argument, fn, context, moduleInvariants);
|
|
8087
|
+
return { index, accessPath, argument, value, stability };
|
|
8088
|
+
}).filter((control) => control.stability === "unstable"));
|
|
8089
|
+
if (unstableControls.length > 0) {
|
|
8090
|
+
const first = unstableControls[0];
|
|
8091
|
+
const firstIndex = first.index;
|
|
8092
|
+
const argument = first.argument;
|
|
6985
8093
|
const parameterOffset = importedMode.parameterIndexes.indexOf(firstIndex);
|
|
6986
|
-
const
|
|
6987
|
-
const
|
|
8094
|
+
const baseParameterName = importedMode.parameterNames[parameterOffset] ?? `argument ${firstIndex + 1}`;
|
|
8095
|
+
const parameterName = first.accessPath ? `${baseParameterName}.${first.accessPath}` : baseParameterName;
|
|
8096
|
+
const argumentText = first.value?.text.trim() || argument?.text.trim() || "<omitted>";
|
|
6988
8097
|
diagnostics.push({
|
|
6989
|
-
node: argument ?? callNameNode(call),
|
|
6990
|
-
highlights:
|
|
6991
|
-
message: `Hook ${rawPath} receives
|
|
6992
|
-
help: `
|
|
8098
|
+
node: first.value ?? argument ?? callNameNode(call),
|
|
8099
|
+
highlights: unstableControls.map((control) => control.value ?? control.argument).filter((node) => Boolean(node)),
|
|
8100
|
+
message: `Hook ${rawPath} receives hook-topology mode ${parameterName} from ${argumentText}, which is updated between renders and can make the custom hook execute a different hook sequence.`,
|
|
8101
|
+
help: `Keep ${parameterName} stable for this hook instance, or refactor ${rawPath} so it always calls the same hooks regardless of that mode.`,
|
|
6993
8102
|
fixPreview: dynamicHookModeFixPreview(rawPath, argumentText)
|
|
6994
8103
|
});
|
|
6995
8104
|
}
|
|
6996
8105
|
}
|
|
6997
8106
|
const controlFlow = findAncestorBetween(call, fn.node, (node) => CONDITIONAL_TYPES.has(node.type));
|
|
6998
8107
|
if (controlFlow) {
|
|
6999
|
-
if (currentModeSummary &&
|
|
8108
|
+
if (currentModeSummary && controlledParameterIndex(controlFlow, fn, currentModeSummary) !== null)
|
|
8109
|
+
continue;
|
|
8110
|
+
if (conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants))
|
|
8111
|
+
continue;
|
|
8112
|
+
if (ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context))
|
|
7000
8113
|
continue;
|
|
7001
8114
|
if (controlFlow.type === "for_statement" && loopIterationIsProvablyStable(controlFlow, staticImports, fn, stableShapes))
|
|
7002
8115
|
continue;
|
|
@@ -7010,7 +8123,8 @@ end`,
|
|
|
7010
8123
|
});
|
|
7011
8124
|
continue;
|
|
7012
8125
|
}
|
|
7013
|
-
|
|
8126
|
+
const earlierReturns = reachableReturnsBefore(call, fn, context, reachableReturns);
|
|
8127
|
+
if (earlierReturns.length > 0 && !earlierReturns.every((node) => returnIsControlledByStableTopologyMode(node, fn, currentModeSummary, context, moduleInvariants))) {
|
|
7014
8128
|
diagnostics.push({
|
|
7015
8129
|
node: callNameNode(call),
|
|
7016
8130
|
message: `Hook ${path7} may run after an earlier reachable return, so some renders can execute fewer hooks.`,
|
|
@@ -7455,6 +8569,16 @@ function nodeLocation(node) {
|
|
|
7455
8569
|
endColumn: node.endPosition.column + 1
|
|
7456
8570
|
};
|
|
7457
8571
|
}
|
|
8572
|
+
function sameLocation(left, right) {
|
|
8573
|
+
return left.line === right.line && left.column === right.column && left.endLine === right.endLine && left.endColumn === right.endColumn;
|
|
8574
|
+
}
|
|
8575
|
+
function removeSupersededDiagnostics(diagnostics) {
|
|
8576
|
+
return diagnostics.filter((diagnostic) => {
|
|
8577
|
+
if (diagnostic.rule !== "react-luau/rerender-high-frequency-state")
|
|
8578
|
+
return true;
|
|
8579
|
+
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]);
|
|
8580
|
+
});
|
|
8581
|
+
}
|
|
7458
8582
|
function toDiagnostic(file, ruleId, category, severity, input) {
|
|
7459
8583
|
const node = input.node;
|
|
7460
8584
|
const highlights = input.highlights?.map(nodeLocation);
|
|
@@ -7473,7 +8597,7 @@ function toDiagnostic(file, ruleId, category, severity, input) {
|
|
|
7473
8597
|
}
|
|
7474
8598
|
async function analyzeReactFile(input, tree) {
|
|
7475
8599
|
const parsedTree = tree ?? await parseLuau(input.source);
|
|
7476
|
-
const model = buildReactModel(parsedTree.rootNode);
|
|
8600
|
+
const model = buildReactModel(parsedTree.rootNode, input.project);
|
|
7477
8601
|
if (!input.forceScan && !model.isReactFile) {
|
|
7478
8602
|
return { relativePath: input.relativePath, isReactFile: false, scanned: false, diagnostics: [] };
|
|
7479
8603
|
}
|
|
@@ -7512,11 +8636,12 @@ async function analyzeReactFile(input, tree) {
|
|
|
7512
8636
|
if (rule.id === "react-luau/parse-error" && findings.length > 0)
|
|
7513
8637
|
break;
|
|
7514
8638
|
}
|
|
8639
|
+
const filteredDiagnostics = removeSupersededDiagnostics(diagnostics);
|
|
7515
8640
|
return {
|
|
7516
8641
|
relativePath: input.relativePath,
|
|
7517
8642
|
isReactFile: model.isReactFile,
|
|
7518
8643
|
scanned: true,
|
|
7519
|
-
diagnostics
|
|
8644
|
+
diagnostics: filteredDiagnostics
|
|
7520
8645
|
};
|
|
7521
8646
|
}
|
|
7522
8647
|
|
|
@@ -7675,7 +8800,34 @@ class AnalysisWorkerPool {
|
|
|
7675
8800
|
// src/project-model.ts
|
|
7676
8801
|
import fs6 from "fs";
|
|
7677
8802
|
import path7 from "path";
|
|
7678
|
-
var EXTERNAL_UPDATE_SOURCE = /(?::|\.)(?:Connect|Once|Subscribe|Observe|Listen|Watch)\s*\(|\b(?:subscribe|observe|listen|watch)[A-Za-z0-9_]*\s*\(|GetPropertyChangedSignal\s*\(|\.Changed\b/i;
|
|
8803
|
+
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;
|
|
8804
|
+
var SEMANTIC_SNAPSHOT_EXPRESSION2 = /\b(?:getState|getSnapshot|snapshot|selector|select|table\s*\.\s*(?:clone|freeze|move))\b|(?:\.|\[\s*["'])State(?:\b|["']\s*\])/i;
|
|
8805
|
+
var PURE_MIRROR_EXPRESSION_ROOTS = new Set([
|
|
8806
|
+
"math",
|
|
8807
|
+
"string",
|
|
8808
|
+
"utf8",
|
|
8809
|
+
"bit32",
|
|
8810
|
+
"UDim",
|
|
8811
|
+
"UDim2",
|
|
8812
|
+
"Vector2",
|
|
8813
|
+
"Vector3",
|
|
8814
|
+
"Color3",
|
|
8815
|
+
"CFrame",
|
|
8816
|
+
"Rect",
|
|
8817
|
+
"NumberRange",
|
|
8818
|
+
"NumberSequence",
|
|
8819
|
+
"ColorSequence",
|
|
8820
|
+
"BrickColor",
|
|
8821
|
+
"Font",
|
|
8822
|
+
"Enum",
|
|
8823
|
+
"tostring",
|
|
8824
|
+
"tonumber",
|
|
8825
|
+
"type",
|
|
8826
|
+
"typeof",
|
|
8827
|
+
"true",
|
|
8828
|
+
"false",
|
|
8829
|
+
"nil"
|
|
8830
|
+
]);
|
|
7679
8831
|
var CALLBACK_PARAMETER_NAME = /^(?:callback|handler|listener|subscriber|observer|effect|fn)$/i;
|
|
7680
8832
|
function exportedFunctionName(source) {
|
|
7681
8833
|
return source.match(/\breturn\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/)?.[1] ?? null;
|
|
@@ -7817,37 +8969,81 @@ function parameterName(parameter) {
|
|
|
7817
8969
|
function branchContainsHookCall(text) {
|
|
7818
8970
|
return /\b(?:[A-Za-z_][A-Za-z0-9_]*[.:])?use[A-Z0-9_][A-Za-z0-9_]*\s*\(/.test(text);
|
|
7819
8971
|
}
|
|
8972
|
+
var BUILT_IN_REACT_HOOK_NAMES = new Set([
|
|
8973
|
+
"useState",
|
|
8974
|
+
"useEffect",
|
|
8975
|
+
"useLayoutEffect",
|
|
8976
|
+
"useMemo",
|
|
8977
|
+
"useCallback",
|
|
8978
|
+
"useContext",
|
|
8979
|
+
"useReducer",
|
|
8980
|
+
"useRef",
|
|
8981
|
+
"useImperativeHandle",
|
|
8982
|
+
"useBinding"
|
|
8983
|
+
]);
|
|
8984
|
+
function simpleBranchHookPaths(text) {
|
|
8985
|
+
if (/\b(?:if|for|while|repeat|function)\b/.test(text))
|
|
8986
|
+
return null;
|
|
8987
|
+
return [...text.matchAll(/\b(React\.(use[A-Z0-9_][A-Za-z0-9_]*))\s*\(/g)].map((match) => match[2]);
|
|
8988
|
+
}
|
|
8989
|
+
function simpleIfHasEquivalentBuiltInTopology(source, ifStart) {
|
|
8990
|
+
if (ifStart === undefined)
|
|
8991
|
+
return false;
|
|
8992
|
+
const statement = source.slice(ifStart).match(/^if\b[\s\S]*?\bthen\b([\s\S]*?)\belse\b([\s\S]*?)\bend\b/);
|
|
8993
|
+
if (!statement)
|
|
8994
|
+
return false;
|
|
8995
|
+
const left = simpleBranchHookPaths(statement[1] ?? "");
|
|
8996
|
+
const right = simpleBranchHookPaths(statement[2] ?? "");
|
|
8997
|
+
if (!left || !right || left.length === 0 || left.length !== right.length)
|
|
8998
|
+
return false;
|
|
8999
|
+
return left.every((name, index) => name === right[index] && BUILT_IN_REACT_HOOK_NAMES.has(name));
|
|
9000
|
+
}
|
|
7820
9001
|
function conditionAliasesForParameter(source, parameter) {
|
|
7821
|
-
const aliases = new
|
|
9002
|
+
const aliases = new Map([[parameter, ""]]);
|
|
7822
9003
|
const escaped = parameter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9004
|
+
for (const match of source.matchAll(new RegExp(`\\b${escaped}((?:\\.[A-Za-z_][A-Za-z0-9_]*)+)`, "g"))) {
|
|
9005
|
+
aliases.set(`${parameter}${match[1]}`, match[1].slice(1));
|
|
9006
|
+
}
|
|
7823
9007
|
const directPatterns = [
|
|
7824
|
-
new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s
|
|
7825
|
-
new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*not\\s
|
|
7826
|
-
new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s
|
|
7827
|
-
new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*(?:true|false|nil)\\s*(?:==|~=)\\s
|
|
7828
|
-
new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*if\\s+${escaped}\\s*(?:==|~=)\\s*nil\\s+then\\s+(?:true|false)\\s+else\\s+${escaped}\\s*$`, "gm"),
|
|
7829
|
-
new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*if\\s+${escaped}\\s+then\\s+(?:true|false)\\s+else\\s+(?:true|false)\\s*$`, "gm")
|
|
9008
|
+
new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*(${escaped}(?:\\.[A-Za-z_][A-Za-z0-9_]*)*)\\s*$`, "gm"),
|
|
9009
|
+
new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*not\\s+(${escaped}(?:\\.[A-Za-z_][A-Za-z0-9_]*)*)\\s*$`, "gm"),
|
|
9010
|
+
new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*(${escaped}(?:\\.[A-Za-z_][A-Za-z0-9_]*)*)\\s*(?:==|~=)\\s*(?:true|false|nil)\\s*$`, "gm"),
|
|
9011
|
+
new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*(?:true|false|nil)\\s*(?:==|~=)\\s*(${escaped}(?:\\.[A-Za-z_][A-Za-z0-9_]*)*)\\s*$`, "gm")
|
|
7830
9012
|
];
|
|
7831
9013
|
for (const pattern of directPatterns) {
|
|
7832
|
-
for (const match of source.matchAll(pattern))
|
|
7833
|
-
|
|
9014
|
+
for (const match of source.matchAll(pattern)) {
|
|
9015
|
+
const sourcePath = match[2];
|
|
9016
|
+
const accessPath = sourcePath === parameter ? "" : sourcePath.slice(parameter.length + 1);
|
|
9017
|
+
aliases.set(match[1], accessPath);
|
|
9018
|
+
}
|
|
7834
9019
|
}
|
|
7835
|
-
|
|
9020
|
+
for (const match of source.matchAll(new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*if\\s+(${escaped}(?:\\.[A-Za-z_][A-Za-z0-9_]*)*)\\s*(?:==|~=)\\s*nil\\s+then\\s+(?:true|false)\\s+else\\s+\\2\\s*$`, "gm"))) {
|
|
9021
|
+
const sourcePath = match[2];
|
|
9022
|
+
aliases.set(match[1], sourcePath === parameter ? "" : sourcePath.slice(parameter.length + 1));
|
|
9023
|
+
}
|
|
9024
|
+
for (const match of source.matchAll(new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*if\\s+(${escaped}(?:\\.[A-Za-z_][A-Za-z0-9_]*)*)\\s+then\\s+(?:true|false)\\s+else\\s+(?:true|false)\\s*$`, "gm"))) {
|
|
9025
|
+
const sourcePath = match[2];
|
|
9026
|
+
aliases.set(match[1], sourcePath === parameter ? "" : sourcePath.slice(parameter.length + 1));
|
|
9027
|
+
}
|
|
9028
|
+
return aliases;
|
|
7836
9029
|
}
|
|
7837
9030
|
function findConditionalHookMode(source) {
|
|
7838
9031
|
const exported = exportedFunctionParameters(source);
|
|
7839
9032
|
if (!exported)
|
|
7840
9033
|
return null;
|
|
9034
|
+
if (exported.name && !/^use[A-Z0-9_]/.test(exported.name))
|
|
9035
|
+
return null;
|
|
7841
9036
|
const parameterNames3 = exported.parameters.map(parameterName);
|
|
7842
9037
|
const controlledIndexes = [];
|
|
7843
9038
|
const controlledNames = [];
|
|
7844
9039
|
const conditionVariables = {};
|
|
9040
|
+
const conditionAccessPaths = {};
|
|
7845
9041
|
for (const [index, name] of parameterNames3.entries()) {
|
|
7846
9042
|
if (!name)
|
|
7847
9043
|
continue;
|
|
7848
9044
|
const aliases = conditionAliasesForParameter(source, name);
|
|
7849
9045
|
let controlsHook = false;
|
|
7850
|
-
for (const alias of aliases) {
|
|
9046
|
+
for (const [alias, accessPath] of aliases) {
|
|
7851
9047
|
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7852
9048
|
const patterns = [
|
|
7853
9049
|
new RegExp(`\\bif\\s+${escaped}\\s+then([\\s\\S]*?)(?=\\belse\\b|\\bend\\b)`, "g"),
|
|
@@ -7859,8 +9055,11 @@ function findConditionalHookMode(source) {
|
|
|
7859
9055
|
for (const match of source.matchAll(pattern)) {
|
|
7860
9056
|
if (!branchContainsHookCall(match[1] ?? ""))
|
|
7861
9057
|
continue;
|
|
9058
|
+
if (simpleIfHasEquivalentBuiltInTopology(source, match.index))
|
|
9059
|
+
continue;
|
|
7862
9060
|
controlsHook = true;
|
|
7863
9061
|
conditionVariables[alias] = index;
|
|
9062
|
+
conditionAccessPaths[alias] = accessPath;
|
|
7864
9063
|
break;
|
|
7865
9064
|
}
|
|
7866
9065
|
if (controlsHook)
|
|
@@ -7879,6 +9078,7 @@ function findConditionalHookMode(source) {
|
|
|
7879
9078
|
parameterIndexes: controlledIndexes,
|
|
7880
9079
|
parameterNames: controlledNames,
|
|
7881
9080
|
conditionVariables,
|
|
9081
|
+
conditionAccessPaths,
|
|
7882
9082
|
knownCallSites: 0,
|
|
7883
9083
|
dynamicCallSites: 0
|
|
7884
9084
|
};
|
|
@@ -8011,9 +9211,62 @@ function findExternalCallbackFunction(source) {
|
|
|
8011
9211
|
return {
|
|
8012
9212
|
name,
|
|
8013
9213
|
callbackParameterIndexes,
|
|
8014
|
-
highFrequency:
|
|
9214
|
+
highFrequency: sourceHasHighFrequencyRobloxEvent(source) || sourceHasLikelyContinuousSubscription(source)
|
|
8015
9215
|
};
|
|
8016
9216
|
}
|
|
9217
|
+
function sourceHasLikelyContinuousSubscription(source) {
|
|
9218
|
+
return /(?::|\.)onStep\s*\(/i.test(source);
|
|
9219
|
+
}
|
|
9220
|
+
function expressionReferencesName(expression, name) {
|
|
9221
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9222
|
+
return new RegExp(`\\b${escaped}\\b`).test(expression);
|
|
9223
|
+
}
|
|
9224
|
+
function isLiteralStateTransitionExpression(expression) {
|
|
9225
|
+
const text = expression.trim();
|
|
9226
|
+
return /^(?:true|false|nil|[-+]?\d+(?:\.\d+)?|["'][^"']*["'])$/.test(text);
|
|
9227
|
+
}
|
|
9228
|
+
function expressionHasLikelyExternalInput(expression) {
|
|
9229
|
+
const withoutMembers = expression.replace(/\.\s*[A-Za-z_][A-Za-z0-9_]*/g, "");
|
|
9230
|
+
for (const match of withoutMembers.matchAll(/\b[A-Za-z_][A-Za-z0-9_]*\b/g)) {
|
|
9231
|
+
if (!PURE_MIRROR_EXPRESSION_ROOTS.has(match[0]))
|
|
9232
|
+
return true;
|
|
9233
|
+
}
|
|
9234
|
+
return false;
|
|
9235
|
+
}
|
|
9236
|
+
function subscriptionMirrorConfidence(source, setterName) {
|
|
9237
|
+
const escapedSetter = setterName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9238
|
+
const directSetter = new RegExp(`(?::|\\.)(Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)\\s*\\(\\s*${escapedSetter}\\b`, "i");
|
|
9239
|
+
if (directSetter.test(source))
|
|
9240
|
+
return "strong";
|
|
9241
|
+
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");
|
|
9242
|
+
let possible = false;
|
|
9243
|
+
for (const match of source.matchAll(callbackPattern)) {
|
|
9244
|
+
if (match.index === undefined)
|
|
9245
|
+
continue;
|
|
9246
|
+
const callbackParameters = splitTopLevelParameters(match[2] ?? "").map(parameterName).filter((name) => Boolean(name));
|
|
9247
|
+
const setterOffset = match[0].lastIndexOf(setterName);
|
|
9248
|
+
if (setterOffset < 0)
|
|
9249
|
+
continue;
|
|
9250
|
+
const setterStart = match.index + setterOffset;
|
|
9251
|
+
const open = source.indexOf("(", setterStart + setterName.length);
|
|
9252
|
+
const argumentsText = extractCallArguments(source, open);
|
|
9253
|
+
if (argumentsText === null)
|
|
9254
|
+
continue;
|
|
9255
|
+
const setterArgs = splitTopLevelParameters(argumentsText);
|
|
9256
|
+
const expression = setterArgs[0]?.trim() ?? "";
|
|
9257
|
+
if (!expression || /^function\b/.test(expression) || isLiteralStateTransitionExpression(expression))
|
|
9258
|
+
continue;
|
|
9259
|
+
if (SEMANTIC_SNAPSHOT_EXPRESSION2.test(expression))
|
|
9260
|
+
continue;
|
|
9261
|
+
if (callbackParameters.some((name) => expressionReferencesName(expression, name))) {
|
|
9262
|
+
return "strong";
|
|
9263
|
+
}
|
|
9264
|
+
if (!expressionHasLikelyExternalInput(expression))
|
|
9265
|
+
continue;
|
|
9266
|
+
possible = true;
|
|
9267
|
+
}
|
|
9268
|
+
return possible ? "possible" : "none";
|
|
9269
|
+
}
|
|
8017
9270
|
function findBindingCandidateHook(source) {
|
|
8018
9271
|
const exported = source.match(/\breturn\s+(use[A-Z0-9_][A-Za-z0-9_]*)\s*$/)?.[1];
|
|
8019
9272
|
if (!exported)
|
|
@@ -8035,18 +9288,35 @@ function findBindingCandidateHook(source) {
|
|
|
8035
9288
|
const setterPassedToSubscription = new RegExp(`(?::|\\.)(?:Connect|Once|Subscribe|Observe|Listen|Watch)\\s*\\(\\s*${escapedSetter}\\b`, "i").test(source);
|
|
8036
9289
|
if (!setterCalled && !setterPassedToSubscription)
|
|
8037
9290
|
return null;
|
|
8038
|
-
const highFrequency =
|
|
9291
|
+
const highFrequency = sourceHasHighFrequencyRobloxEvent(source) || sourceHasLikelyContinuousSubscription(source);
|
|
8039
9292
|
const external = highFrequency || EXTERNAL_UPDATE_SOURCE.test(source);
|
|
8040
9293
|
if (!external)
|
|
8041
9294
|
return null;
|
|
8042
|
-
const
|
|
8043
|
-
const
|
|
8044
|
-
const
|
|
9295
|
+
const literalPropertySignal = source.match(/GetPropertyChangedSignal\s*\(\s*["']([^"']+)["']\s*\)/);
|
|
9296
|
+
const dynamicPropertySignal = literalPropertySignal ? null : source.match(/GetPropertyChangedSignal\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/);
|
|
9297
|
+
const literalPropertyName = literalPropertySignal?.[1] ?? null;
|
|
9298
|
+
const dynamicPropertyName = dynamicPropertySignal?.[1] ?? null;
|
|
9299
|
+
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));
|
|
9300
|
+
const instanceProperty = Boolean((literalPropertyName || dynamicPropertyName) && readsObservedProperty);
|
|
9301
|
+
const mirrorConfidence = instanceProperty ? "strong" : subscriptionMirrorConfidence(source, setterName);
|
|
9302
|
+
let observedPropertyParameterIndex;
|
|
9303
|
+
if (instanceProperty && dynamicPropertyName) {
|
|
9304
|
+
const parameters = findFunctionParameters(source, exported);
|
|
9305
|
+
if (parameters !== null) {
|
|
9306
|
+
const parameterNames3 = splitTopLevelParameters(parameters).map((parameter) => parameter.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1] ?? "");
|
|
9307
|
+
const index = parameterNames3.indexOf(dynamicPropertyName);
|
|
9308
|
+
if (index >= 0)
|
|
9309
|
+
observedPropertyParameterIndex = index;
|
|
9310
|
+
}
|
|
9311
|
+
}
|
|
8045
9312
|
return {
|
|
8046
9313
|
name: exported,
|
|
8047
9314
|
highFrequency,
|
|
8048
9315
|
external,
|
|
8049
|
-
sourceKind:
|
|
9316
|
+
sourceKind: instanceProperty ? "instance-property" : "external-state",
|
|
9317
|
+
mirrorConfidence,
|
|
9318
|
+
...instanceProperty && literalPropertyName ? { observedPropertyName: literalPropertyName } : {},
|
|
9319
|
+
...observedPropertyParameterIndex !== undefined ? { observedPropertyParameterIndex } : {},
|
|
8050
9320
|
...dualMode ? {
|
|
8051
9321
|
bindingModeParameterIndex: dualMode.bindingModeParameterIndex,
|
|
8052
9322
|
bindingModeParameterName: dualMode.bindingModeParameterName,
|
|
@@ -8084,7 +9354,8 @@ function findDerivedBindingCandidateHook(source, bindingCandidateHooks) {
|
|
|
8084
9354
|
name: exported,
|
|
8085
9355
|
highFrequency: summary.highFrequency,
|
|
8086
9356
|
external: true,
|
|
8087
|
-
sourceKind: "derived-external-state"
|
|
9357
|
+
sourceKind: "derived-external-state",
|
|
9358
|
+
mirrorConfidence: summary.mirrorConfidence
|
|
8088
9359
|
};
|
|
8089
9360
|
}
|
|
8090
9361
|
return null;
|
|
@@ -8448,12 +9719,73 @@ function findBindingCompatibleComponentProps(source) {
|
|
|
8448
9719
|
}
|
|
8449
9720
|
const compatible = new Set;
|
|
8450
9721
|
for (const [property, offsets] of reads) {
|
|
8451
|
-
if (offsets.length > 0 && offsets.every((offset) => ranges.some((range) =>
|
|
9722
|
+
if (offsets.length > 0 && offsets.every((offset) => ranges.some((range) => {
|
|
9723
|
+
if (offset < range.start || offset >= range.end)
|
|
9724
|
+
return false;
|
|
9725
|
+
return !/\bfunction\b/.test(source.slice(range.start, offset));
|
|
9726
|
+
}))) {
|
|
8452
9727
|
compatible.add(property);
|
|
8453
9728
|
}
|
|
8454
9729
|
}
|
|
8455
9730
|
return compatible.size > 0 ? compatible : null;
|
|
8456
9731
|
}
|
|
9732
|
+
function exportedUseFunctionName(source) {
|
|
9733
|
+
const name = exportedFunctionName(source);
|
|
9734
|
+
return name && /^use[A-Z0-9_]/.test(name) ? name : null;
|
|
9735
|
+
}
|
|
9736
|
+
function directReactHookUsage(source) {
|
|
9737
|
+
const namespaces = new Set(["React"]);
|
|
9738
|
+
const requirePattern = /\blocal\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)/gs;
|
|
9739
|
+
for (const match of source.matchAll(requirePattern)) {
|
|
9740
|
+
const target = normalizeRequireTarget(match[2]);
|
|
9741
|
+
if (target.split(".").includes("react"))
|
|
9742
|
+
namespaces.add(match[1]);
|
|
9743
|
+
}
|
|
9744
|
+
for (const namespace of namespaces) {
|
|
9745
|
+
const escaped = namespace.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9746
|
+
if (new RegExp(`\\b${escaped}\\.use[A-Z0-9_][A-Za-z0-9_]*\\b`).test(source))
|
|
9747
|
+
return true;
|
|
9748
|
+
}
|
|
9749
|
+
return false;
|
|
9750
|
+
}
|
|
9751
|
+
function calledRequiredModules(source) {
|
|
9752
|
+
const result = [];
|
|
9753
|
+
const requirePattern = /\blocal\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)/gs;
|
|
9754
|
+
for (const match of source.matchAll(requirePattern)) {
|
|
9755
|
+
const localName = match[1];
|
|
9756
|
+
const escaped = localName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9757
|
+
if (!new RegExp(`\\b${escaped}\\s*\\(`).test(source.slice(match.index + match[0].length)))
|
|
9758
|
+
continue;
|
|
9759
|
+
result.push({ localName, target: normalizeRequireTarget(match[2]) });
|
|
9760
|
+
}
|
|
9761
|
+
return result;
|
|
9762
|
+
}
|
|
9763
|
+
function findReactHookModules(records) {
|
|
9764
|
+
const hookRecords = records.filter((record) => exportedUseFunctionName(record.source));
|
|
9765
|
+
const provenReact = new Map;
|
|
9766
|
+
for (const record of hookRecords) {
|
|
9767
|
+
if (directReactHookUsage(record.source))
|
|
9768
|
+
provenReact.set(record.id, true);
|
|
9769
|
+
}
|
|
9770
|
+
let changed = true;
|
|
9771
|
+
while (changed) {
|
|
9772
|
+
changed = false;
|
|
9773
|
+
const aliases = buildUniqueFeatureAliases(records, provenReact);
|
|
9774
|
+
for (const record of hookRecords) {
|
|
9775
|
+
if (provenReact.has(record.id))
|
|
9776
|
+
continue;
|
|
9777
|
+
const callsReactHookModule = calledRequiredModules(record.source).some(({ target }) => resolveModuleReference(target, aliases) === true);
|
|
9778
|
+
if (!callsReactHookModule)
|
|
9779
|
+
continue;
|
|
9780
|
+
provenReact.set(record.id, true);
|
|
9781
|
+
changed = true;
|
|
9782
|
+
}
|
|
9783
|
+
}
|
|
9784
|
+
const result = new Map;
|
|
9785
|
+
for (const record of hookRecords)
|
|
9786
|
+
result.set(record.id, provenReact.has(record.id));
|
|
9787
|
+
return result;
|
|
9788
|
+
}
|
|
8457
9789
|
function findStaticIterationTables(source) {
|
|
8458
9790
|
const match = /^return\s*\{/m.exec(source);
|
|
8459
9791
|
if (!match || match.index === undefined)
|
|
@@ -8529,11 +9861,13 @@ function buildProjectModel(root, candidates, options = {}) {
|
|
|
8529
9861
|
parameterIndexes: [...analysis.conditionalHookMode.parameterIndexes],
|
|
8530
9862
|
parameterNames: [...analysis.conditionalHookMode.parameterNames],
|
|
8531
9863
|
conditionVariables: { ...analysis.conditionalHookMode.conditionVariables },
|
|
9864
|
+
conditionAccessPaths: { ...analysis.conditionalHookMode.conditionAccessPaths },
|
|
8532
9865
|
knownCallSites: 0,
|
|
8533
9866
|
dynamicCallSites: 0
|
|
8534
9867
|
});
|
|
8535
9868
|
}
|
|
8536
9869
|
const conditionalHookModes = buildUniqueFeatureAliases(records, conditionalHookModesByModule);
|
|
9870
|
+
const reactHookModules = buildUniqueFeatureAliases(records, findReactHookModules(records));
|
|
8537
9871
|
collectConditionalHookModeCallSites(records, conditionalHookModes);
|
|
8538
9872
|
let changed = true;
|
|
8539
9873
|
while (changed) {
|
|
@@ -8557,6 +9891,7 @@ function buildProjectModel(root, candidates, options = {}) {
|
|
|
8557
9891
|
bindingCompatibleComponentProps: buildUniqueFeatureAliases(records, bindingCompatibleComponentPropsByModule),
|
|
8558
9892
|
staticIterationTables: buildUniqueFeatureAliases(records, staticIterationTablesByModule),
|
|
8559
9893
|
conditionalHookModes,
|
|
9894
|
+
reactHookModules,
|
|
8560
9895
|
sourceEffects: new Map
|
|
8561
9896
|
};
|
|
8562
9897
|
}
|
|
@@ -10771,6 +12106,7 @@ import path12 from "path";
|
|
|
10771
12106
|
var UPDATE_CHECK_INTERVAL_MS = 2 * 60 * 60 * 1000;
|
|
10772
12107
|
var UPDATE_REQUEST_TIMEOUT_MS = 5000;
|
|
10773
12108
|
var UPDATE_CACHE_FILENAME = "update-check.json";
|
|
12109
|
+
var CHANGELOG_URL = "https://raw.githubusercontent.com/ColRealPro/react-luau-doctor/refs/heads/main/CHANGELOG.md";
|
|
10774
12110
|
function updateCacheFilename() {
|
|
10775
12111
|
return path12.join(cacheBaseDirectory(), UPDATE_CACHE_FILENAME);
|
|
10776
12112
|
}
|
|
@@ -10852,6 +12188,9 @@ function updateRegistryUrl() {
|
|
|
10852
12188
|
const base = registry.endsWith("/") ? registry : `${registry}/`;
|
|
10853
12189
|
return new URL(`${encodeURIComponent(package_default.name)}/latest`, base).toString();
|
|
10854
12190
|
}
|
|
12191
|
+
function changelogUrl() {
|
|
12192
|
+
return process.env.REACT_LUAU_DOCTOR_CHANGELOG_URL ?? CHANGELOG_URL;
|
|
12193
|
+
}
|
|
10855
12194
|
async function fetchLatestVersion() {
|
|
10856
12195
|
const controller = new AbortController;
|
|
10857
12196
|
const timer = setTimeout(() => controller.abort(), UPDATE_REQUEST_TIMEOUT_MS);
|
|
@@ -10872,6 +12211,50 @@ async function fetchLatestVersion() {
|
|
|
10872
12211
|
clearTimeout(timer);
|
|
10873
12212
|
}
|
|
10874
12213
|
}
|
|
12214
|
+
function changelogReleasesBetween(markdown, currentVersion, latestVersion) {
|
|
12215
|
+
const releases = [];
|
|
12216
|
+
let active = null;
|
|
12217
|
+
const flush = () => {
|
|
12218
|
+
if (!active)
|
|
12219
|
+
return;
|
|
12220
|
+
const afterCurrent = compareVersions(active.version, currentVersion);
|
|
12221
|
+
const atOrBeforeLatest = compareVersions(active.version, latestVersion);
|
|
12222
|
+
if (afterCurrent !== null && atOrBeforeLatest !== null && afterCurrent > 0 && atOrBeforeLatest <= 0) {
|
|
12223
|
+
releases.push({ version: active.version, notes: active.lines.join(`
|
|
12224
|
+
`).trim() });
|
|
12225
|
+
}
|
|
12226
|
+
active = null;
|
|
12227
|
+
};
|
|
12228
|
+
for (const line of markdown.split(/\r?\n/)) {
|
|
12229
|
+
if (/^##\s+/.test(line.trim())) {
|
|
12230
|
+
flush();
|
|
12231
|
+
const match = /^##\s+\[?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s+-.*)?\s*$/.exec(line.trim());
|
|
12232
|
+
if (match)
|
|
12233
|
+
active = { version: match[1], lines: [] };
|
|
12234
|
+
continue;
|
|
12235
|
+
}
|
|
12236
|
+
active?.lines.push(line);
|
|
12237
|
+
}
|
|
12238
|
+
flush();
|
|
12239
|
+
releases.sort((left, right) => compareVersions(right.version, left.version) ?? 0);
|
|
12240
|
+
return releases;
|
|
12241
|
+
}
|
|
12242
|
+
async function fetchChangelogReleases(currentVersion, latestVersion) {
|
|
12243
|
+
const controller = new AbortController;
|
|
12244
|
+
const timer = setTimeout(() => controller.abort(), UPDATE_REQUEST_TIMEOUT_MS);
|
|
12245
|
+
timer.unref?.();
|
|
12246
|
+
try {
|
|
12247
|
+
const response = await fetch(changelogUrl(), {
|
|
12248
|
+
headers: { accept: "text/markdown, text/plain;q=0.9, */*;q=0.1" },
|
|
12249
|
+
signal: controller.signal
|
|
12250
|
+
});
|
|
12251
|
+
if (!response.ok)
|
|
12252
|
+
throw new Error(`changelog returned HTTP ${response.status}`);
|
|
12253
|
+
return changelogReleasesBetween(await response.text(), currentVersion, latestVersion);
|
|
12254
|
+
} finally {
|
|
12255
|
+
clearTimeout(timer);
|
|
12256
|
+
}
|
|
12257
|
+
}
|
|
10875
12258
|
function updateCacheIsStale(now = Date.now()) {
|
|
10876
12259
|
const cache = readUpdateCache();
|
|
10877
12260
|
return !cache || now - cache.checkedAt >= UPDATE_CHECK_INTERVAL_MS;
|
|
@@ -10927,7 +12310,7 @@ async function checkForUpdatesNow(currentVersion) {
|
|
|
10927
12310
|
|
|
10928
12311
|
// src/update-install.ts
|
|
10929
12312
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
10930
|
-
import { fileURLToPath } from "url";
|
|
12313
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
10931
12314
|
function normalizedPath(filename) {
|
|
10932
12315
|
return filename.replaceAll("\\", "/");
|
|
10933
12316
|
}
|
|
@@ -10955,7 +12338,7 @@ function updateInstallCommandForPath(filename) {
|
|
|
10955
12338
|
return null;
|
|
10956
12339
|
}
|
|
10957
12340
|
function currentUpdateInstallCommand() {
|
|
10958
|
-
return updateInstallCommandForPath(
|
|
12341
|
+
return updateInstallCommandForPath(fileURLToPath2(import.meta.url));
|
|
10959
12342
|
}
|
|
10960
12343
|
function installLatestVersion(command) {
|
|
10961
12344
|
const result = spawnSync3(command.command, command.args, {
|
|
@@ -11063,10 +12446,12 @@ end, {})`,
|
|
|
11063
12446
|
},
|
|
11064
12447
|
"react-luau/prefer-binding-over-state-candidate": {
|
|
11065
12448
|
before: `local value, setValue = React.useState(0)
|
|
11066
|
-
|
|
11067
|
-
|
|
11068
|
-
local
|
|
11069
|
-
|
|
12449
|
+
signal:Connect(setValue)
|
|
12450
|
+
-- value is used for presentation`,
|
|
12451
|
+
after: `local value, setValue = React.useBinding(0)
|
|
12452
|
+
signal:Connect(setValue)
|
|
12453
|
+
-- keep separate React state too if structure depends on it`,
|
|
12454
|
+
note: "Use a Binding when this is really an external presentation stream. Keep or split React state when the event represents semantic UI state or needs reconciliation."
|
|
11070
12455
|
},
|
|
11071
12456
|
"react-luau/rerender-unstable-memo-props": {
|
|
11072
12457
|
before: `return React.createElement(MemoizedButton, {
|
|
@@ -11498,7 +12883,7 @@ CI commands:
|
|
|
11498
12883
|
|
|
11499
12884
|
Update commands:
|
|
11500
12885
|
update Update the global installation to the latest release
|
|
11501
|
-
update --check Check npm
|
|
12886
|
+
update --check Check npm and show release notes without updating
|
|
11502
12887
|
|
|
11503
12888
|
Rules commands:
|
|
11504
12889
|
rules list [--category <name>] [--configured] [--json]
|
|
@@ -11515,12 +12900,67 @@ Config:
|
|
|
11515
12900
|
function automaticUpdateNoticeEnabled(options, machineReadable) {
|
|
11516
12901
|
return !options.noUpdateCheck && !machineReadable && Boolean(process.stdout.isTTY) && !process.env.CI && process.env.NO_UPDATE_NOTIFIER === undefined && process.env.REACT_LUAU_DOCTOR_NO_UPDATE_CHECK === undefined;
|
|
11517
12902
|
}
|
|
11518
|
-
function
|
|
12903
|
+
function renderUpdateHeader(current, latest, colorized) {
|
|
11519
12904
|
const label = whyPaint(colorized, "Update available:", WHY_ANSI.bold, WHY_ANSI.yellow);
|
|
11520
12905
|
const oldVersion = whyPaint(colorized, `v${current}`, WHY_ANSI.dim);
|
|
11521
12906
|
const newVersion = whyPaint(colorized, `v${latest}`, WHY_ANSI.bold);
|
|
11522
|
-
return `${label} ${oldVersion} \u2192 ${newVersion}
|
|
11523
|
-
|
|
12907
|
+
return `${label} ${oldVersion} \u2192 ${newVersion}`;
|
|
12908
|
+
}
|
|
12909
|
+
function renderUpdateNotice(current, latest, colorized) {
|
|
12910
|
+
return `${renderUpdateHeader(current, latest, colorized)}
|
|
12911
|
+
Run \`react-luau-doctor update --check\` to see what's new.`;
|
|
12912
|
+
}
|
|
12913
|
+
function renderChangelogInline(value, colorized, ...baseCodes) {
|
|
12914
|
+
if (!colorized)
|
|
12915
|
+
return value;
|
|
12916
|
+
const parts = [];
|
|
12917
|
+
let offset = 0;
|
|
12918
|
+
for (const match of value.matchAll(/`([^`\n]+)`/g)) {
|
|
12919
|
+
const index = match.index ?? 0;
|
|
12920
|
+
if (index > offset)
|
|
12921
|
+
parts.push(whyPaint(true, value.slice(offset, index), ...baseCodes));
|
|
12922
|
+
parts.push(whyPaint(true, match[1], WHY_THEME.variable));
|
|
12923
|
+
offset = index + match[0].length;
|
|
12924
|
+
}
|
|
12925
|
+
if (parts.length === 0)
|
|
12926
|
+
return whyPaint(true, value, ...baseCodes);
|
|
12927
|
+
if (offset < value.length)
|
|
12928
|
+
parts.push(whyPaint(true, value.slice(offset), ...baseCodes));
|
|
12929
|
+
return parts.join("");
|
|
12930
|
+
}
|
|
12931
|
+
function renderChangelogNotes(notes, colorized) {
|
|
12932
|
+
return notes.split(/\r?\n/).map((line) => {
|
|
12933
|
+
const heading = /^#{3,6}\s+(.+)$/.exec(line.trim());
|
|
12934
|
+
if (heading)
|
|
12935
|
+
return renderChangelogInline(heading[1], colorized, WHY_ANSI.bold);
|
|
12936
|
+
const bullet = /^(\s*)[-*]\s+(.+)$/.exec(line);
|
|
12937
|
+
if (bullet)
|
|
12938
|
+
return `${bullet[1] || " "}\u2022 ${renderChangelogInline(bullet[2], colorized)}`;
|
|
12939
|
+
return renderChangelogInline(line, colorized);
|
|
12940
|
+
}).join(`
|
|
12941
|
+
`).trim();
|
|
12942
|
+
}
|
|
12943
|
+
function renderUpdateCheck(current, latest, releases, releaseNotesUnavailable, colorized) {
|
|
12944
|
+
const lines2 = [
|
|
12945
|
+
renderUpdateHeader(current, latest, colorized),
|
|
12946
|
+
"",
|
|
12947
|
+
whyPaint(colorized, "What's new", WHY_ANSI.bold)
|
|
12948
|
+
];
|
|
12949
|
+
if (releaseNotesUnavailable) {
|
|
12950
|
+
lines2.push(" Release notes could not be loaded from GitHub.");
|
|
12951
|
+
} else if (releases.length === 0) {
|
|
12952
|
+
lines2.push(" No changelog entries were found between these versions.");
|
|
12953
|
+
} else {
|
|
12954
|
+
for (const release of releases) {
|
|
12955
|
+
lines2.push("", whyPaint(colorized, `v${release.version}`, WHY_ANSI.bold));
|
|
12956
|
+
const notes = renderChangelogNotes(release.notes, colorized);
|
|
12957
|
+
if (notes)
|
|
12958
|
+
lines2.push(notes);
|
|
12959
|
+
}
|
|
12960
|
+
}
|
|
12961
|
+
lines2.push("", "Run `react-luau-doctor update` to update.");
|
|
12962
|
+
return lines2.join(`
|
|
12963
|
+
`);
|
|
11524
12964
|
}
|
|
11525
12965
|
async function runUpdateCommand(argv) {
|
|
11526
12966
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
@@ -11539,7 +12979,14 @@ async function runUpdateCommand(argv) {
|
|
|
11539
12979
|
}
|
|
11540
12980
|
const colorized = shouldUseColor(false, false);
|
|
11541
12981
|
if (argv[0] === "--check") {
|
|
11542
|
-
|
|
12982
|
+
let releases = [];
|
|
12983
|
+
let releaseNotesUnavailable = false;
|
|
12984
|
+
try {
|
|
12985
|
+
releases = await fetchChangelogReleases(VERSION2, result.latest);
|
|
12986
|
+
} catch {
|
|
12987
|
+
releaseNotesUnavailable = true;
|
|
12988
|
+
}
|
|
12989
|
+
process.stdout.write(`${renderUpdateCheck(VERSION2, result.latest, releases, releaseNotesUnavailable, colorized)}
|
|
11543
12990
|
`);
|
|
11544
12991
|
return;
|
|
11545
12992
|
}
|
|
@@ -12476,14 +13923,14 @@ function renderWhyCodeFrame(filename, diagnostic, colorized) {
|
|
|
12476
13923
|
}
|
|
12477
13924
|
function whySeverityMeaning(severity) {
|
|
12478
13925
|
if (severity === "error")
|
|
12479
|
-
return "This can
|
|
13926
|
+
return "This can cause incorrect behavior or violate React's rules and should normally be fixed.";
|
|
12480
13927
|
if (severity === "warning")
|
|
12481
13928
|
return "This pattern is very likely to be a real issue in the code shown and is normally worth fixing.";
|
|
12482
13929
|
return "This may be intentional. Doctor found a plausible improvement, but the right choice depends on how this value or pattern is meant to affect rendering.";
|
|
12483
13930
|
}
|
|
12484
13931
|
function whyRuleSpecificContext(diagnostic) {
|
|
12485
13932
|
if (diagnostic.rule === "react-luau/prefer-binding-over-state-candidate") {
|
|
12486
|
-
return "A Binding can update visual values without rerendering the component.
|
|
13933
|
+
return "A Binding can update visual values without rerendering the component. Doctor found an external reactive mirror or presentation branch, but it cannot fully prove the source's update pressure, conversion safety, or that every consumer can leave React state.";
|
|
12487
13934
|
}
|
|
12488
13935
|
if (diagnostic.rule === "react-luau/prefer-binding-over-state") {
|
|
12489
13936
|
return "A Binding can update these values without rerendering the component, and every use of the value only needs that kind of update. React state is therefore doing extra reconciliation work here.";
|
|
@@ -12498,7 +13945,7 @@ function whyRuleSpecificContext(diagnostic) {
|
|
|
12498
13945
|
}
|
|
12499
13946
|
function whyRuleCaveat(diagnostic) {
|
|
12500
13947
|
if (diagnostic.rule === "react-luau/prefer-binding-over-state-candidate") {
|
|
12501
|
-
return "Keep React state when the
|
|
13948
|
+
return "Keep React state when the external event represents semantic UI state, changes component structure, drives hook/effect behavior, or is infrequent enough that a Binding would only make the code harder to follow. Mixed cases can split visual Binding updates from the smaller value that truly needs reconciliation.";
|
|
12502
13949
|
}
|
|
12503
13950
|
if (diagnostic.rule === "react-luau/prefer-binding-over-state") {
|
|
12504
13951
|
return "Keep React state if changing the value must rebuild component structure rather than only update Roblox Instance properties or other Binding-aware consumers.";
|
|
@@ -12818,5 +14265,5 @@ ${renderUpdateNotice(update.current, update.latest, colorized)}
|
|
|
12818
14265
|
}
|
|
12819
14266
|
main();
|
|
12820
14267
|
|
|
12821
|
-
//# debugId=
|
|
14268
|
+
//# debugId=C71A438CE8D868EF64756E2164756E21
|
|
12822
14269
|
//# sourceMappingURL=cli.js.map
|