@colrealpro/react-luau-doctor 0.18.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/cli.js +1019 -136
- package/dist/cli.js.map +29 -29
- package/dist/scan-worker.js +729 -108
- package/dist/scan-worker.js.map +25 -25
- package/package.json +2 -1
package/dist/scan-worker.js
CHANGED
|
@@ -1109,6 +1109,71 @@ function applyMasks(source, ranges) {
|
|
|
1109
1109
|
}
|
|
1110
1110
|
return output;
|
|
1111
1111
|
}
|
|
1112
|
+
function genericFunctionParameterRanges(source, mask) {
|
|
1113
|
+
const ranges = [];
|
|
1114
|
+
const functionPattern = /\bfunction\b/g;
|
|
1115
|
+
for (const match of source.matchAll(functionPattern)) {
|
|
1116
|
+
const start = match.index;
|
|
1117
|
+
if (!isCode(mask, start, start + match[0].length))
|
|
1118
|
+
continue;
|
|
1119
|
+
let cursor = start + match[0].length;
|
|
1120
|
+
while (cursor < source.length) {
|
|
1121
|
+
const char = source[cursor];
|
|
1122
|
+
if (/\s/.test(char)) {
|
|
1123
|
+
cursor += 1;
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
if (char === "(")
|
|
1127
|
+
break;
|
|
1128
|
+
if (char === "<") {
|
|
1129
|
+
let depth = 0;
|
|
1130
|
+
const rangeStart = cursor;
|
|
1131
|
+
while (cursor < source.length) {
|
|
1132
|
+
if (isCode(mask, cursor)) {
|
|
1133
|
+
if (source[cursor] === "<")
|
|
1134
|
+
depth += 1;
|
|
1135
|
+
else if (source[cursor] === ">") {
|
|
1136
|
+
depth -= 1;
|
|
1137
|
+
if (depth === 0) {
|
|
1138
|
+
cursor += 1;
|
|
1139
|
+
ranges.push({ start: rangeStart, end: cursor });
|
|
1140
|
+
break;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
cursor += 1;
|
|
1145
|
+
}
|
|
1146
|
+
break;
|
|
1147
|
+
}
|
|
1148
|
+
if (/[A-Za-z0-9_.:]/.test(char)) {
|
|
1149
|
+
cursor += 1;
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
return ranges;
|
|
1156
|
+
}
|
|
1157
|
+
function normalizeGenericTypePackUses(source, mask) {
|
|
1158
|
+
const declarationRanges = genericFunctionParameterRanges(source, mask);
|
|
1159
|
+
const replacements = [];
|
|
1160
|
+
const packPattern = /\b[A-Za-z_][A-Za-z0-9_]*\.\.\./g;
|
|
1161
|
+
for (const match of source.matchAll(packPattern)) {
|
|
1162
|
+
const start = match.index;
|
|
1163
|
+
const end = start + match[0].length;
|
|
1164
|
+
if (!isCode(mask, start, end))
|
|
1165
|
+
continue;
|
|
1166
|
+
if (declarationRanges.some((range) => start >= range.start && end <= range.end))
|
|
1167
|
+
continue;
|
|
1168
|
+
const name = match[0].slice(0, -3);
|
|
1169
|
+
replacements.push({ start, end, value: `...${name}` });
|
|
1170
|
+
}
|
|
1171
|
+
let output = source;
|
|
1172
|
+
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
|
|
1173
|
+
output = `${output.slice(0, replacement.start)}${replacement.value}${output.slice(replacement.end)}`;
|
|
1174
|
+
}
|
|
1175
|
+
return output;
|
|
1176
|
+
}
|
|
1112
1177
|
function normalizeIntegerLiteralSuffixes(source, mask) {
|
|
1113
1178
|
const suffixes = [];
|
|
1114
1179
|
const integerPattern = /\b(?:0[xX][0-9A-Fa-f_]+|0[bB][01_]+|[0-9][0-9_]*)i\b/g;
|
|
@@ -1148,13 +1213,14 @@ function parserCompatibleSource(source) {
|
|
|
1148
1213
|
const mask = codeMask(source);
|
|
1149
1214
|
const keywordCompatible = replaceContextualKeywords(source, mask);
|
|
1150
1215
|
const literalCompatible = normalizeIntegerLiteralSuffixes(keywordCompatible, mask);
|
|
1216
|
+
const packCompatible = normalizeGenericTypePackUses(literalCompatible, mask);
|
|
1151
1217
|
const ranges = [
|
|
1152
1218
|
...maskTypeAliases(source, mask),
|
|
1153
1219
|
...maskTypeLevelBlocks(source, mask),
|
|
1154
1220
|
...attributeRanges(source, mask),
|
|
1155
1221
|
...explicitTypeArgumentRanges(source, mask)
|
|
1156
1222
|
];
|
|
1157
|
-
const masked = applyMasks(
|
|
1223
|
+
const masked = applyMasks(packCompatible, ranges);
|
|
1158
1224
|
return masked.replace(/^(\s*)type(?=\s*(?:\+=|-=|\*=|\/=|%=|\^=|\.\.=|=(?!=)))/gm, "$1_typ");
|
|
1159
1225
|
}
|
|
1160
1226
|
|
|
@@ -1367,6 +1433,8 @@ function hasRobloxReceiver(path3, call, context) {
|
|
|
1367
1433
|
}
|
|
1368
1434
|
var runServiceCallCache = new WeakMap;
|
|
1369
1435
|
var runServiceExpressionCache = new WeakMap;
|
|
1436
|
+
var highFrequencyRobloxCallCache = new WeakMap;
|
|
1437
|
+
var highFrequencyRobloxExpressionCache = new WeakMap;
|
|
1370
1438
|
function cachedNodeBoolean(cache, context, node, compute) {
|
|
1371
1439
|
let entries = cache.get(context);
|
|
1372
1440
|
if (!entries) {
|
|
@@ -1431,6 +1499,33 @@ function isHighFrequencyRunServiceCall(call, context) {
|
|
|
1431
1499
|
return Boolean(bind && rootIsRunService(bind[1], call, context));
|
|
1432
1500
|
});
|
|
1433
1501
|
}
|
|
1502
|
+
var HIGH_FREQUENCY_ROBLOX_EVENTS = new Set([
|
|
1503
|
+
"InputChanged",
|
|
1504
|
+
"TouchMoved",
|
|
1505
|
+
"Move"
|
|
1506
|
+
]);
|
|
1507
|
+
function isHighFrequencyRobloxExpression(node, context) {
|
|
1508
|
+
return cachedNodeBoolean(highFrequencyRobloxExpressionCache, context, node, () => {
|
|
1509
|
+
if (isHighFrequencyRunServiceExpression(node, context))
|
|
1510
|
+
return true;
|
|
1511
|
+
const normalized = node.text.replace(/\s+/g, "");
|
|
1512
|
+
const match = normalized.match(/^(.+)\.(InputChanged|TouchMoved|Move)$/);
|
|
1513
|
+
if (!match || !HIGH_FREQUENCY_ROBLOX_EVENTS.has(match[2]))
|
|
1514
|
+
return false;
|
|
1515
|
+
return hasRobloxReceiver(match[1], node, context);
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
function isHighFrequencyRobloxCall(call, context) {
|
|
1519
|
+
return cachedNodeBoolean(highFrequencyRobloxCallCache, context, call, () => {
|
|
1520
|
+
if (isHighFrequencyRunServiceCall(call, context))
|
|
1521
|
+
return true;
|
|
1522
|
+
const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
|
|
1523
|
+
const event = normalized.match(/^(.+)\.(InputChanged|TouchMoved|Move):Connect$/);
|
|
1524
|
+
if (!event || !HIGH_FREQUENCY_ROBLOX_EVENTS.has(event[2]))
|
|
1525
|
+
return false;
|
|
1526
|
+
return hasRobloxReceiver(event[1], call, context);
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1434
1529
|
function sourceHasHighFrequencyRunService(source) {
|
|
1435
1530
|
if (/game\s*:\s*GetService\s*\(\s*["']RunService["']\s*\)\s*(?:\.\s*(?:RenderStepped|Heartbeat|Stepped|PreRender|PreSimulation|PostSimulation)\s*:\s*Connect|:\s*(?:BindToRenderStep|BindToSimulation))/.test(source)) {
|
|
1436
1531
|
return true;
|
|
@@ -1447,6 +1542,36 @@ function sourceHasHighFrequencyRunService(source) {
|
|
|
1447
1542
|
}
|
|
1448
1543
|
return false;
|
|
1449
1544
|
}
|
|
1545
|
+
function sourceHasHighFrequencyRobloxEvent(source) {
|
|
1546
|
+
if (sourceHasHighFrequencyRunService(source))
|
|
1547
|
+
return true;
|
|
1548
|
+
if (/game\s*:\s*GetService\s*\(\s*["']UserInputService["']\s*\)\s*\.\s*(?:InputChanged|TouchMoved)\s*:\s*Connect/.test(source)) {
|
|
1549
|
+
return true;
|
|
1550
|
+
}
|
|
1551
|
+
const serviceAliases = new Set;
|
|
1552
|
+
for (const match of source.matchAll(/\blocal\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*game\s*:\s*GetService\s*\(\s*["']UserInputService["']\s*\)/g)) {
|
|
1553
|
+
serviceAliases.add(match[1]);
|
|
1554
|
+
}
|
|
1555
|
+
for (const alias of serviceAliases) {
|
|
1556
|
+
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1557
|
+
if (new RegExp(`\\b${escaped}\\s*\\.\\s*(?:InputChanged|TouchMoved)\\s*:\\s*Connect`).test(source)) {
|
|
1558
|
+
return true;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
const playersAliases = new Set;
|
|
1562
|
+
for (const match of source.matchAll(/\blocal\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*game\s*:\s*GetService\s*\(\s*["']Players["']\s*\)/g)) {
|
|
1563
|
+
playersAliases.add(match[1]);
|
|
1564
|
+
}
|
|
1565
|
+
for (const players of playersAliases) {
|
|
1566
|
+
const escapedPlayers = players.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1567
|
+
for (const match of source.matchAll(new RegExp(`\\blocal\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*${escapedPlayers}\\s*\\.\\s*LocalPlayer\\s*:\\s*GetMouse\\s*\\(\\s*\\)`, "g"))) {
|
|
1568
|
+
const mouse = match[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1569
|
+
if (new RegExp(`\\b${mouse}\\s*\\.\\s*Move\\s*:\\s*Connect`).test(source))
|
|
1570
|
+
return true;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
return false;
|
|
1574
|
+
}
|
|
1450
1575
|
function knownYieldReason(path3, call, context) {
|
|
1451
1576
|
const normalized = path3.replace(/\s+/g, "");
|
|
1452
1577
|
if (normalized === "task.wait")
|
|
@@ -3993,7 +4118,7 @@ var rerenderHighFrequencyState = {
|
|
|
3993
4118
|
seen.add(key);
|
|
3994
4119
|
const renderCost = componentRenderCost(context, component);
|
|
3995
4120
|
diagnostics.push({
|
|
3996
|
-
node: callNameNode(call),
|
|
4121
|
+
node: callNameNode(binding.call),
|
|
3997
4122
|
severity: "warning",
|
|
3998
4123
|
message: `${binding.valueName} has a state setter called from a per-frame callback and may cause frequent rerenders when its value changes.`,
|
|
3999
4124
|
help: renderCost.length > 0 ? `This render also performs ${renderCost.join(" and ")}. Isolate or throttle the high-frequency state, or use Bindings for host-property-only updates.` : "Isolate or throttle the high-frequency state. Use React.useBinding when the value only drives host Instance properties."
|
|
@@ -4270,7 +4395,9 @@ var preferUseRefForMutableCell = {
|
|
|
4270
4395
|
};
|
|
4271
4396
|
|
|
4272
4397
|
// src/rules/prefer-binding-over-state.ts
|
|
4273
|
-
var EXTERNAL_CALLBACK = /(?:^|[.:])(?:Connect|
|
|
4398
|
+
var EXTERNAL_CALLBACK = /(?:^|[.:])(?:Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)$/i;
|
|
4399
|
+
var PERSISTENT_MIRROR_CALLBACK = /(?:^|[.:])(?:Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)$/i;
|
|
4400
|
+
var LIKELY_CONTINUOUS_CALLBACK = /(?:^|[.:])onStep$/i;
|
|
4274
4401
|
var NON_BINDABLE_HOST_FIELDS = new Set(["ref", "key", "children"]);
|
|
4275
4402
|
function replaceWithinNode(container, target, replacement) {
|
|
4276
4403
|
const start = target.startIndex - container.startIndex;
|
|
@@ -4393,13 +4520,64 @@ var TRANSPARENT_PARENTS = new Set([
|
|
|
4393
4520
|
"string_interpolation",
|
|
4394
4521
|
"expression_list",
|
|
4395
4522
|
"assignment_statement",
|
|
4396
|
-
"arguments",
|
|
4397
|
-
"function_call",
|
|
4398
4523
|
"dot_index_expression",
|
|
4399
4524
|
"method_index_expression",
|
|
4400
4525
|
"table_constructor",
|
|
4401
4526
|
"field"
|
|
4402
4527
|
]);
|
|
4528
|
+
var PURE_BINDING_DERIVATION_CALL = /^(?:tostring|tonumber|type|typeof|math\.[A-Za-z_][A-Za-z0-9_]*|string\.[A-Za-z_][A-Za-z0-9_]*|utf8\.[A-Za-z_][A-Za-z0-9_]*|bit32\.[A-Za-z_][A-Za-z0-9_]*|(?:UDim|UDim2|Vector2|Vector3|Color3|CFrame|Rect|NumberRange|NumberSequence|ColorSequence|BrickColor|Font)\.[A-Za-z_][A-Za-z0-9_]*)$/;
|
|
4529
|
+
var HOST_FEEDBACK_PROPERTIES = new Set([
|
|
4530
|
+
"AbsoluteSize",
|
|
4531
|
+
"AbsolutePosition",
|
|
4532
|
+
"AbsoluteRotation",
|
|
4533
|
+
"AbsoluteContentSize",
|
|
4534
|
+
"AbsoluteCanvasSize",
|
|
4535
|
+
"AbsoluteCellSize",
|
|
4536
|
+
"AbsoluteCellCount",
|
|
4537
|
+
"TextBounds",
|
|
4538
|
+
"CanvasPosition"
|
|
4539
|
+
]);
|
|
4540
|
+
var LIKELY_CONTINUOUS_PROPERTIES = new Set([
|
|
4541
|
+
"CFrame",
|
|
4542
|
+
"WorldCFrame",
|
|
4543
|
+
"Position",
|
|
4544
|
+
"WorldPosition",
|
|
4545
|
+
"Rotation",
|
|
4546
|
+
"Orientation",
|
|
4547
|
+
"CanvasPosition",
|
|
4548
|
+
"PlaybackLoudness",
|
|
4549
|
+
"TimePosition"
|
|
4550
|
+
]);
|
|
4551
|
+
var SEMANTIC_SNAPSHOT_EXPRESSION = /\b(?:getState|getSnapshot|snapshot|selector|select|table\s*\.\s*(?:clone|freeze|move))\b|(?:\.|\[\s*["'])State(?:\b|["']\s*\])/i;
|
|
4552
|
+
function isBindingMapSafeCall(call, context) {
|
|
4553
|
+
if (call.type !== "function_call")
|
|
4554
|
+
return false;
|
|
4555
|
+
const path4 = context.resolveCallPath(context.getCallPath(call) ?? "").replace(/\s+/g, "");
|
|
4556
|
+
return PURE_BINDING_DERIVATION_CALL.test(path4);
|
|
4557
|
+
}
|
|
4558
|
+
function isTransparentParent(parent, context) {
|
|
4559
|
+
if (parent.type === "function_call")
|
|
4560
|
+
return isBindingMapSafeCall(parent, context);
|
|
4561
|
+
if (parent.type === "arguments") {
|
|
4562
|
+
return parent.parent?.type === "function_call" && isBindingMapSafeCall(parent.parent, context);
|
|
4563
|
+
}
|
|
4564
|
+
return TRANSPARENT_PARENTS.has(parent.type);
|
|
4565
|
+
}
|
|
4566
|
+
function stringLiteralValue(node) {
|
|
4567
|
+
if (!node)
|
|
4568
|
+
return null;
|
|
4569
|
+
return node.text.trim().match(/^["']([^"']+)["']$/)?.[1] ?? null;
|
|
4570
|
+
}
|
|
4571
|
+
function observedPropertyForCall(summary, call, context) {
|
|
4572
|
+
if (summary.observedPropertyName)
|
|
4573
|
+
return summary.observedPropertyName;
|
|
4574
|
+
if (summary.observedPropertyParameterIndex === undefined)
|
|
4575
|
+
return null;
|
|
4576
|
+
return stringLiteralValue(context.callArguments(call)[summary.observedPropertyParameterIndex]);
|
|
4577
|
+
}
|
|
4578
|
+
function isStrongHostFeedbackProperty(property) {
|
|
4579
|
+
return Boolean(property && HOST_FEEDBACK_PROPERTIES.has(property));
|
|
4580
|
+
}
|
|
4403
4581
|
function isInside(node, ancestor) {
|
|
4404
4582
|
let current = node;
|
|
4405
4583
|
while (current) {
|
|
@@ -4483,13 +4661,75 @@ function isTransparentUsage(node, context, compatibleComponentProps) {
|
|
|
4483
4661
|
}
|
|
4484
4662
|
if (parent.type === "function_definition" || parent.type === "function_declaration")
|
|
4485
4663
|
return false;
|
|
4486
|
-
if (!
|
|
4664
|
+
if (!isTransparentParent(parent, context))
|
|
4487
4665
|
return false;
|
|
4488
4666
|
current = parent;
|
|
4489
4667
|
}
|
|
4490
4668
|
return false;
|
|
4491
4669
|
}
|
|
4492
|
-
function
|
|
4670
|
+
function isDirectBindingValueUsage(node, context, compatibleComponentProps) {
|
|
4671
|
+
let current = node;
|
|
4672
|
+
while (current) {
|
|
4673
|
+
const parent = current.parent;
|
|
4674
|
+
if (!parent)
|
|
4675
|
+
return false;
|
|
4676
|
+
if (parent.type === "parenthesized_expression" || parent.type === "cast_expression" || parent.type === "type_cast_expression") {
|
|
4677
|
+
current = parent;
|
|
4678
|
+
continue;
|
|
4679
|
+
}
|
|
4680
|
+
if (parent.type !== "field")
|
|
4681
|
+
return false;
|
|
4682
|
+
const value = fieldValue(parent);
|
|
4683
|
+
if (!value || !sameNode(value, current))
|
|
4684
|
+
return false;
|
|
4685
|
+
const property = fieldName(parent);
|
|
4686
|
+
if (!property || NON_BINDABLE_HOST_FIELDS.has(property))
|
|
4687
|
+
return false;
|
|
4688
|
+
const table = parent.parent;
|
|
4689
|
+
const args = table?.parent;
|
|
4690
|
+
const call = args?.parent;
|
|
4691
|
+
if (table?.type !== "table_constructor" || args?.type !== "arguments" || !call || !isCreateElementCall2(call, context)) {
|
|
4692
|
+
return false;
|
|
4693
|
+
}
|
|
4694
|
+
if (!sameNode(context.callArguments(call)[1], table))
|
|
4695
|
+
return false;
|
|
4696
|
+
if (isHostCreateElementCall(call, context))
|
|
4697
|
+
return true;
|
|
4698
|
+
const component = context.callArguments(call)[0]?.text.trim() ?? "";
|
|
4699
|
+
return compatibleComponentProps.get(component)?.has(property) ?? false;
|
|
4700
|
+
}
|
|
4701
|
+
return false;
|
|
4702
|
+
}
|
|
4703
|
+
function allReadsDirectlyBindingSubstitutable(valueName, owner, declaration, context, compatibleComponentProps) {
|
|
4704
|
+
if (!owner.body)
|
|
4705
|
+
return false;
|
|
4706
|
+
let reads = 0;
|
|
4707
|
+
for (const node of context.walk(owner.body)) {
|
|
4708
|
+
if (!isReadNode(node, valueName, owner, declaration))
|
|
4709
|
+
continue;
|
|
4710
|
+
reads += 1;
|
|
4711
|
+
if (!isDirectBindingValueUsage(node, context, compatibleComponentProps))
|
|
4712
|
+
return false;
|
|
4713
|
+
}
|
|
4714
|
+
return reads > 0;
|
|
4715
|
+
}
|
|
4716
|
+
function derivedLocalDeclarationForRead(node, context) {
|
|
4717
|
+
let current = node;
|
|
4718
|
+
while (current) {
|
|
4719
|
+
const parent = current.parent;
|
|
4720
|
+
if (!parent)
|
|
4721
|
+
return null;
|
|
4722
|
+
if (parent.type === "variable_declaration")
|
|
4723
|
+
return parent;
|
|
4724
|
+
if (parent.type === "function_definition" || parent.type === "function_declaration")
|
|
4725
|
+
return null;
|
|
4726
|
+
if (!isTransparentParent(parent, context))
|
|
4727
|
+
return null;
|
|
4728
|
+
current = parent;
|
|
4729
|
+
}
|
|
4730
|
+
return null;
|
|
4731
|
+
}
|
|
4732
|
+
function opaqueDerivedLocalDeclarationForRead(node, context) {
|
|
4493
4733
|
let current = node;
|
|
4494
4734
|
while (current) {
|
|
4495
4735
|
const parent = current.parent;
|
|
@@ -4499,7 +4739,14 @@ function derivedLocalDeclarationForRead(node) {
|
|
|
4499
4739
|
return parent;
|
|
4500
4740
|
if (parent.type === "function_definition" || parent.type === "function_declaration")
|
|
4501
4741
|
return null;
|
|
4502
|
-
if (
|
|
4742
|
+
if (parent.type === "if_statement" || parent.type === "elseif_clause" || parent.type === "while_statement" || parent.type === "repeat_statement" || parent.type === "for_statement" || parent.type === "for_in_statement" || parent.type === "return_statement")
|
|
4743
|
+
return null;
|
|
4744
|
+
if (parent.type === "function_call") {
|
|
4745
|
+
const path4 = context.resolveCallPath(context.getCallPath(parent) ?? "");
|
|
4746
|
+
if (/^React\.use(?:Memo|Callback|Effect|LayoutEffect)$/.test(path4))
|
|
4747
|
+
return null;
|
|
4748
|
+
}
|
|
4749
|
+
if (!TRANSPARENT_PARENTS.has(parent.type) && parent.type !== "arguments" && parent.type !== "function_call")
|
|
4503
4750
|
return null;
|
|
4504
4751
|
current = parent;
|
|
4505
4752
|
}
|
|
@@ -4519,7 +4766,7 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
|
|
|
4519
4766
|
reads += 1;
|
|
4520
4767
|
if (isTransparentUsage(node, context, compatibleComponentProps))
|
|
4521
4768
|
continue;
|
|
4522
|
-
const derivedDeclaration = derivedLocalDeclarationForRead(node);
|
|
4769
|
+
const derivedDeclaration = derivedLocalDeclarationForRead(node, context);
|
|
4523
4770
|
const derivedNames = derivedDeclaration ? declarationNames2(derivedDeclaration) : [];
|
|
4524
4771
|
if (derivedDeclaration && derivedNames.length === 1) {
|
|
4525
4772
|
if (stateReadsAreBindingCompatible(derivedNames[0], owner, derivedDeclaration, context, compatibleComponentProps, new Set(visited)))
|
|
@@ -4529,12 +4776,46 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
|
|
|
4529
4776
|
}
|
|
4530
4777
|
return reads > 0;
|
|
4531
4778
|
}
|
|
4779
|
+
function propertyChangedSignalProperty(text) {
|
|
4780
|
+
return text.match(/GetPropertyChangedSignal\s*\(\s*["']([^"']+)["']\s*\)/)?.[1] ?? null;
|
|
4781
|
+
}
|
|
4782
|
+
function reactChangePropertyForCallback(node) {
|
|
4783
|
+
let current = node.parent;
|
|
4784
|
+
while (current && current.type !== "function_definition" && current.type !== "function_declaration") {
|
|
4785
|
+
if (current.type === "field") {
|
|
4786
|
+
return current.text.match(/React\s*\.\s*Change\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)/)?.[1] ?? null;
|
|
4787
|
+
}
|
|
4788
|
+
current = current.parent;
|
|
4789
|
+
}
|
|
4790
|
+
return null;
|
|
4791
|
+
}
|
|
4792
|
+
function evidenceForExternalSource(highFrequency, hostFeedbackProperty) {
|
|
4793
|
+
const hostFeedbackProperties = new Set;
|
|
4794
|
+
if (hostFeedbackProperty)
|
|
4795
|
+
hostFeedbackProperties.add(hostFeedbackProperty);
|
|
4796
|
+
return {
|
|
4797
|
+
highFrequency,
|
|
4798
|
+
external: true,
|
|
4799
|
+
hostFeedbackProperties,
|
|
4800
|
+
genericExternal: !highFrequency && !hostFeedbackProperty
|
|
4801
|
+
};
|
|
4802
|
+
}
|
|
4803
|
+
function mergeSourceEvidence(target, source) {
|
|
4804
|
+
target.highFrequency ||= source.highFrequency;
|
|
4805
|
+
target.external ||= source.external;
|
|
4806
|
+
target.genericExternal ||= source.genericExternal;
|
|
4807
|
+
for (const property of source.hostFeedbackProperties)
|
|
4808
|
+
target.hostFeedbackProperties.add(property);
|
|
4809
|
+
}
|
|
4532
4810
|
function callbackSource(call, context) {
|
|
4533
4811
|
const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
|
|
4534
|
-
const highFrequency =
|
|
4812
|
+
const highFrequency = isHighFrequencyRobloxCall(call, context) || LIKELY_CONTINUOUS_CALLBACK.test(normalized);
|
|
4813
|
+
const hostFeedbackProperty = propertyChangedSignalProperty(call.text);
|
|
4535
4814
|
const final = normalized.split(/[.:]/).at(-1) ?? normalized;
|
|
4536
|
-
const external = highFrequency || EXTERNAL_CALLBACK.test(normalized) || EXTERNAL_CALLBACK.test(final);
|
|
4537
|
-
|
|
4815
|
+
const external = highFrequency || Boolean(hostFeedbackProperty) || EXTERNAL_CALLBACK.test(normalized) || EXTERNAL_CALLBACK.test(final);
|
|
4816
|
+
if (!external)
|
|
4817
|
+
return null;
|
|
4818
|
+
return evidenceForExternalSource(highFrequency, hostFeedbackProperty);
|
|
4538
4819
|
}
|
|
4539
4820
|
function importedCallbackSource(call, callbackArgument, importedCallbacks, context) {
|
|
4540
4821
|
const path4 = context.getCallPath(call) ?? "";
|
|
@@ -4545,12 +4826,14 @@ function importedCallbackSource(call, callbackArgument, importedCallbacks, conte
|
|
|
4545
4826
|
const callbackIndex = args.findIndex((arg) => sameNode(arg, callbackArgument));
|
|
4546
4827
|
if (callbackIndex < 0 || !summary.callbackParameterIndexes.includes(callbackIndex))
|
|
4547
4828
|
return null;
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
};
|
|
4829
|
+
const highFrequency = summary.highFrequency || args.some((arg) => isHighFrequencyRobloxExpression(arg, context));
|
|
4830
|
+
const hostFeedbackProperty = args.map((arg) => propertyChangedSignalProperty(arg.text)).find((property) => Boolean(property));
|
|
4831
|
+
return evidenceForExternalSource(highFrequency, hostFeedbackProperty);
|
|
4552
4832
|
}
|
|
4553
4833
|
function directCallbackSource(node, importedCallbacks, context) {
|
|
4834
|
+
const reactChangeProperty = reactChangePropertyForCallback(node);
|
|
4835
|
+
if (reactChangeProperty)
|
|
4836
|
+
return evidenceForExternalSource(false, reactChangeProperty);
|
|
4554
4837
|
const parent = node.parent;
|
|
4555
4838
|
if (parent?.type !== "arguments" || parent.parent?.type !== "function_call")
|
|
4556
4839
|
return null;
|
|
@@ -4558,14 +4841,17 @@ function directCallbackSource(node, importedCallbacks, context) {
|
|
|
4558
4841
|
const imported = importedCallbackSource(call, node, importedCallbacks, context);
|
|
4559
4842
|
if (imported)
|
|
4560
4843
|
return imported;
|
|
4561
|
-
|
|
4562
|
-
return source.external ? source : null;
|
|
4844
|
+
return callbackSource(call, context);
|
|
4563
4845
|
}
|
|
4564
4846
|
function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
|
|
4565
4847
|
if (!fn.name || !owner.body)
|
|
4566
4848
|
return null;
|
|
4567
|
-
|
|
4568
|
-
|
|
4849
|
+
const result = {
|
|
4850
|
+
highFrequency: false,
|
|
4851
|
+
external: false,
|
|
4852
|
+
hostFeedbackProperties: new Set,
|
|
4853
|
+
genericExternal: false
|
|
4854
|
+
};
|
|
4569
4855
|
for (const call of context.walk(owner.body)) {
|
|
4570
4856
|
if (call.type !== "function_call")
|
|
4571
4857
|
continue;
|
|
@@ -4573,14 +4859,12 @@ function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
|
|
|
4573
4859
|
const callbackArg = args.find((arg) => arg.type === "identifier" && arg.text === fn.name);
|
|
4574
4860
|
if (!callbackArg)
|
|
4575
4861
|
continue;
|
|
4576
|
-
const
|
|
4577
|
-
|
|
4578
|
-
if (!source.external)
|
|
4862
|
+
const source = importedCallbackSource(call, callbackArg, importedCallbacks, context) ?? callbackSource(call, context);
|
|
4863
|
+
if (!source)
|
|
4579
4864
|
continue;
|
|
4580
|
-
|
|
4581
|
-
foundHighFrequency ||= source.highFrequency;
|
|
4865
|
+
mergeSourceEvidence(result, source);
|
|
4582
4866
|
}
|
|
4583
|
-
return
|
|
4867
|
+
return result.external ? result : null;
|
|
4584
4868
|
}
|
|
4585
4869
|
function setterCallbackSource(call, binding, importedCallbacks, context) {
|
|
4586
4870
|
const owner = binding.owner;
|
|
@@ -4594,25 +4878,178 @@ function setterCallbackSource(call, binding, importedCallbacks, context) {
|
|
|
4594
4878
|
return direct;
|
|
4595
4879
|
return namedFunctionCallbackSource(nearest, owner, importedCallbacks, context);
|
|
4596
4880
|
}
|
|
4597
|
-
function
|
|
4881
|
+
function mirrorConfidenceRank(confidence) {
|
|
4882
|
+
if (confidence === "strong")
|
|
4883
|
+
return 2;
|
|
4884
|
+
if (confidence === "possible")
|
|
4885
|
+
return 1;
|
|
4886
|
+
return 0;
|
|
4887
|
+
}
|
|
4888
|
+
function mergeMirrorConfidence(a, b) {
|
|
4889
|
+
return mirrorConfidenceRank(b) > mirrorConfidenceRank(a) ? b : a;
|
|
4890
|
+
}
|
|
4891
|
+
function isLiteralStateTransition(node) {
|
|
4892
|
+
if (!node)
|
|
4893
|
+
return true;
|
|
4894
|
+
const text = node.text.trim();
|
|
4895
|
+
return /^(?:true|false|nil|[-+]?\d+(?:\.\d+)?|["'][^"']*["'])$/.test(text);
|
|
4896
|
+
}
|
|
4897
|
+
function isSemanticSnapshotExpression(node) {
|
|
4898
|
+
return Boolean(node && SEMANTIC_SNAPSHOT_EXPRESSION.test(node.text));
|
|
4899
|
+
}
|
|
4900
|
+
function expressionReferencesCallbackParameter(expression, callback, context) {
|
|
4901
|
+
if (callback.parameters.length === 0)
|
|
4902
|
+
return false;
|
|
4903
|
+
const parameters = new Set(callback.parameters);
|
|
4904
|
+
return [...context.walk(expression)].some((node) => node.type === "identifier" && parameters.has(node.text));
|
|
4905
|
+
}
|
|
4906
|
+
var PURE_EXPRESSION_ROOTS = new Set([
|
|
4907
|
+
"math",
|
|
4908
|
+
"string",
|
|
4909
|
+
"utf8",
|
|
4910
|
+
"bit32",
|
|
4911
|
+
"UDim",
|
|
4912
|
+
"UDim2",
|
|
4913
|
+
"Vector2",
|
|
4914
|
+
"Vector3",
|
|
4915
|
+
"Color3",
|
|
4916
|
+
"CFrame",
|
|
4917
|
+
"Rect",
|
|
4918
|
+
"NumberRange",
|
|
4919
|
+
"NumberSequence",
|
|
4920
|
+
"ColorSequence",
|
|
4921
|
+
"BrickColor",
|
|
4922
|
+
"Font",
|
|
4923
|
+
"Enum",
|
|
4924
|
+
"tostring",
|
|
4925
|
+
"tonumber",
|
|
4926
|
+
"type",
|
|
4927
|
+
"typeof"
|
|
4928
|
+
]);
|
|
4929
|
+
function callbackLocalNamesBefore(callback, before, context) {
|
|
4930
|
+
const names = new Set;
|
|
4931
|
+
if (!callback.body)
|
|
4932
|
+
return names;
|
|
4933
|
+
for (const node of context.walk(callback.body)) {
|
|
4934
|
+
if (node.startIndex >= before.startIndex)
|
|
4935
|
+
continue;
|
|
4936
|
+
if (context.nearestFunction(node) !== callback || node.type !== "variable_declaration")
|
|
4937
|
+
continue;
|
|
4938
|
+
for (const name of declarationNames2(node))
|
|
4939
|
+
names.add(name);
|
|
4940
|
+
}
|
|
4941
|
+
return names;
|
|
4942
|
+
}
|
|
4943
|
+
function expressionHasExternalInput(expression, callback, setterCall, context) {
|
|
4944
|
+
const callbackLocals = callbackLocalNamesBefore(callback, setterCall, context);
|
|
4945
|
+
const callbackParameters = new Set(callback.parameters);
|
|
4946
|
+
for (const node of context.walk(expression)) {
|
|
4947
|
+
if (node.type !== "identifier" || isIdentifierPropertyName2(node))
|
|
4948
|
+
continue;
|
|
4949
|
+
const name = node.text;
|
|
4950
|
+
if (callbackParameters.has(name))
|
|
4951
|
+
return true;
|
|
4952
|
+
if (callbackLocals.has(name))
|
|
4953
|
+
continue;
|
|
4954
|
+
if (context.model.stateValues.has(name))
|
|
4955
|
+
continue;
|
|
4956
|
+
if (PURE_EXPRESSION_ROOTS.has(name))
|
|
4957
|
+
continue;
|
|
4958
|
+
return true;
|
|
4959
|
+
}
|
|
4960
|
+
return false;
|
|
4961
|
+
}
|
|
4962
|
+
function setterHasDistinctValuesInCallback(call, callback, context) {
|
|
4963
|
+
if (!callback.body)
|
|
4964
|
+
return false;
|
|
4965
|
+
const setterPath = context.getCallPath(call);
|
|
4966
|
+
const distinctValues = new Set;
|
|
4967
|
+
for (const sibling of context.walk(callback.body)) {
|
|
4968
|
+
if (sibling.type !== "function_call" || context.nearestFunction(sibling) !== callback)
|
|
4969
|
+
continue;
|
|
4970
|
+
if (context.getCallPath(sibling) !== setterPath)
|
|
4971
|
+
continue;
|
|
4972
|
+
const argument = context.callArguments(sibling)[0];
|
|
4973
|
+
if (argument)
|
|
4974
|
+
distinctValues.add(argument.text.trim());
|
|
4975
|
+
}
|
|
4976
|
+
return distinctValues.size > 1;
|
|
4977
|
+
}
|
|
4978
|
+
function setterCallMirrorConfidence(call, callback, source, context) {
|
|
4979
|
+
const value = context.callArguments(call)[0];
|
|
4980
|
+
if (!value || value.type === "function_definition")
|
|
4981
|
+
return "none";
|
|
4982
|
+
if (isLiteralStateTransition(value)) {
|
|
4983
|
+
return source.highFrequency && setterHasDistinctValuesInCallback(call, callback, context) ? "strong" : "none";
|
|
4984
|
+
}
|
|
4985
|
+
if (isSemanticSnapshotExpression(value))
|
|
4986
|
+
return "none";
|
|
4987
|
+
if (expressionReferencesCallbackParameter(value, callback, context))
|
|
4988
|
+
return "strong";
|
|
4989
|
+
if (source.hostFeedbackProperties.size > 0) {
|
|
4990
|
+
const text = value.text;
|
|
4991
|
+
if ([...source.hostFeedbackProperties].some((property) => new RegExp(`(?:\\.|["'])${property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(text))) {
|
|
4992
|
+
return "strong";
|
|
4993
|
+
}
|
|
4994
|
+
}
|
|
4995
|
+
if (!expressionHasExternalInput(value, callback, call, context)) {
|
|
4996
|
+
return source.highFrequency && setterHasDistinctValuesInCallback(call, callback, context) ? "strong" : "none";
|
|
4997
|
+
}
|
|
4998
|
+
if (source.highFrequency)
|
|
4999
|
+
return "strong";
|
|
5000
|
+
return source.genericExternal ? "possible" : "strong";
|
|
5001
|
+
}
|
|
5002
|
+
function mergeBindingUpdateEvidence(target, source, mirrorConfidence) {
|
|
5003
|
+
if (mirrorConfidence === "none")
|
|
5004
|
+
return;
|
|
5005
|
+
mergeSourceEvidence(target, source);
|
|
5006
|
+
target.mirrorConfidence = mergeMirrorConfidence(target.mirrorConfidence, mirrorConfidence);
|
|
5007
|
+
}
|
|
5008
|
+
function bindingUpdateEvidence(binding, importedCallbacks, context) {
|
|
4598
5009
|
const owner = binding.owner;
|
|
4599
5010
|
if (!owner?.body)
|
|
4600
5011
|
return null;
|
|
4601
|
-
|
|
4602
|
-
|
|
5012
|
+
const result = {
|
|
5013
|
+
highFrequency: false,
|
|
5014
|
+
external: false,
|
|
5015
|
+
hostFeedbackProperties: new Set,
|
|
5016
|
+
genericExternal: false,
|
|
5017
|
+
mirrorConfidence: "none"
|
|
5018
|
+
};
|
|
4603
5019
|
for (const node of context.walk(owner.body)) {
|
|
4604
5020
|
if (node.type !== "function_call")
|
|
4605
5021
|
continue;
|
|
4606
5022
|
const path4 = context.getCallPath(node);
|
|
4607
|
-
if (path4
|
|
5023
|
+
if (path4 === binding.setterName) {
|
|
5024
|
+
const source2 = setterCallbackSource(node, binding, importedCallbacks, context);
|
|
5025
|
+
const callback = context.nearestFunction(node);
|
|
5026
|
+
if (!source2 || !callback || callback === owner)
|
|
5027
|
+
continue;
|
|
5028
|
+
mergeBindingUpdateEvidence(result, source2, setterCallMirrorConfidence(node, callback, source2, context));
|
|
4608
5029
|
continue;
|
|
4609
|
-
|
|
5030
|
+
}
|
|
5031
|
+
const args = context.callArguments(node);
|
|
5032
|
+
const setterArg = args.find((arg) => arg.type === "identifier" && arg.text === binding.setterName);
|
|
5033
|
+
if (!setterArg)
|
|
5034
|
+
continue;
|
|
5035
|
+
const normalized = (context.getCallPath(node) ?? "").replace(/\s+/g, "");
|
|
5036
|
+
const imported = importedCallbackSource(node, setterArg, importedCallbacks, context);
|
|
5037
|
+
const source = imported ?? (PERSISTENT_MIRROR_CALLBACK.test(normalized) ? callbackSource(node, context) : null);
|
|
4610
5038
|
if (!source)
|
|
4611
5039
|
continue;
|
|
4612
|
-
|
|
4613
|
-
external ||= source.external;
|
|
5040
|
+
mergeBindingUpdateEvidence(result, source, "strong");
|
|
4614
5041
|
}
|
|
4615
|
-
return
|
|
5042
|
+
return result.external && result.mirrorConfidence !== "none" ? result : null;
|
|
5043
|
+
}
|
|
5044
|
+
function hasStrongHostFeedbackEvidence(source) {
|
|
5045
|
+
if (source.genericExternal || source.hostFeedbackProperties.size === 0)
|
|
5046
|
+
return false;
|
|
5047
|
+
return [...source.hostFeedbackProperties].every((property) => isStrongHostFeedbackProperty(property));
|
|
5048
|
+
}
|
|
5049
|
+
function hasLikelyContinuousPropertyEvidence(source) {
|
|
5050
|
+
if (source.hostFeedbackProperties.size === 0)
|
|
5051
|
+
return false;
|
|
5052
|
+
return [...source.hostFeedbackProperties].every((property) => LIKELY_CONTINUOUS_PROPERTIES.has(property));
|
|
4616
5053
|
}
|
|
4617
5054
|
function returnedHookValues(binding, context) {
|
|
4618
5055
|
const owner = binding.owner;
|
|
@@ -4690,14 +5127,8 @@ function callDefinitelyUsesBindingMode(summary, call, context) {
|
|
|
4690
5127
|
return false;
|
|
4691
5128
|
return truthy === summary.bindingWhenTruthy;
|
|
4692
5129
|
}
|
|
4693
|
-
function
|
|
4694
|
-
|
|
4695
|
-
return false;
|
|
4696
|
-
if (!hasAnyRead(localName, owner, declaration, context))
|
|
4697
|
-
return false;
|
|
4698
|
-
if (summary.sourceKind === "measurement")
|
|
4699
|
-
return true;
|
|
4700
|
-
return stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps);
|
|
5130
|
+
function hasExplicitBindingMode(summary) {
|
|
5131
|
+
return summary.bindingModeParameterIndex !== undefined && summary.bindingWhenTruthy !== undefined;
|
|
4701
5132
|
}
|
|
4702
5133
|
var LIKELY_VISUAL_PROP = /(?:position|size|rotation|transparency|opacity|color|image|text|anchor|visible|enabled|layout|scale|offset|zindex|canvas|viewport|cframe|width|height|radius|stroke|padding|spacing|angle|alpha|progress)/i;
|
|
4703
5134
|
function createElementPropUsage(node, context) {
|
|
@@ -4726,7 +5157,7 @@ function createElementPropUsage(node, context) {
|
|
|
4726
5157
|
}
|
|
4727
5158
|
if (parent.type === "function_definition" || parent.type === "function_declaration")
|
|
4728
5159
|
return null;
|
|
4729
|
-
if (!
|
|
5160
|
+
if (!isTransparentParent(parent, context))
|
|
4730
5161
|
return null;
|
|
4731
5162
|
current = parent;
|
|
4732
5163
|
}
|
|
@@ -4758,17 +5189,17 @@ function isEffectUsage(node, context) {
|
|
|
4758
5189
|
}
|
|
4759
5190
|
return false;
|
|
4760
5191
|
}
|
|
4761
|
-
function isStructuralUsage(node) {
|
|
5192
|
+
function isStructuralUsage(node, context) {
|
|
4762
5193
|
let current = node;
|
|
4763
5194
|
while (current) {
|
|
4764
5195
|
const parent = current.parent;
|
|
4765
5196
|
if (!parent)
|
|
4766
5197
|
return false;
|
|
4767
|
-
if (parent.type === "if_statement" || parent.type === "elseif_clause" || parent.type === "while_statement" || parent.type === "repeat_statement" || parent.type === "return_statement")
|
|
5198
|
+
if (parent.type === "if_statement" || parent.type === "elseif_clause" || parent.type === "while_statement" || parent.type === "repeat_statement" || parent.type === "for_statement" || parent.type === "for_in_statement" || parent.type === "return_statement")
|
|
4768
5199
|
return true;
|
|
4769
5200
|
if (parent.type === "function_definition" || parent.type === "function_declaration")
|
|
4770
5201
|
return false;
|
|
4771
|
-
if (!
|
|
5202
|
+
if (!isTransparentParent(parent, context))
|
|
4772
5203
|
return false;
|
|
4773
5204
|
current = parent;
|
|
4774
5205
|
}
|
|
@@ -4781,11 +5212,11 @@ function classifyBindingUsage(node, context, compatibleComponentProps) {
|
|
|
4781
5212
|
return "custom-component";
|
|
4782
5213
|
if (isEffectUsage(node, context))
|
|
4783
5214
|
return "effect";
|
|
4784
|
-
if (isStructuralUsage(node))
|
|
5215
|
+
if (isStructuralUsage(node, context))
|
|
4785
5216
|
return "structural";
|
|
4786
5217
|
return "unknown";
|
|
4787
5218
|
}
|
|
4788
|
-
function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps) {
|
|
5219
|
+
function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps, visited = new Set) {
|
|
4789
5220
|
const summary = {
|
|
4790
5221
|
reads: 0,
|
|
4791
5222
|
compatible: 0,
|
|
@@ -4797,9 +5228,45 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
|
|
|
4797
5228
|
};
|
|
4798
5229
|
if (!owner.body)
|
|
4799
5230
|
return summary;
|
|
5231
|
+
const visitKey = `${declaration.startIndex}:${valueName}`;
|
|
5232
|
+
if (visited.has(visitKey))
|
|
5233
|
+
return summary;
|
|
5234
|
+
visited.add(visitKey);
|
|
4800
5235
|
for (const node of context.walk(owner.body)) {
|
|
4801
5236
|
if (!isReadNode(node, valueName, owner, declaration))
|
|
4802
5237
|
continue;
|
|
5238
|
+
if (!isTransparentUsage(node, context, compatibleComponentProps)) {
|
|
5239
|
+
const derivedDeclaration = derivedLocalDeclarationForRead(node, context);
|
|
5240
|
+
const derivedNames = derivedDeclaration ? declarationNames2(derivedDeclaration) : [];
|
|
5241
|
+
if (derivedDeclaration && derivedNames.length === 1) {
|
|
5242
|
+
const derived = bindingUsageSummary(derivedNames[0], owner, derivedDeclaration, context, compatibleComponentProps, new Set(visited));
|
|
5243
|
+
if (derived.reads > 0) {
|
|
5244
|
+
summary.reads += derived.reads;
|
|
5245
|
+
summary.compatible += derived.compatible;
|
|
5246
|
+
summary.customComponent += derived.customComponent;
|
|
5247
|
+
summary.visualCustomComponent += derived.visualCustomComponent;
|
|
5248
|
+
summary.effect += derived.effect;
|
|
5249
|
+
summary.structural += derived.structural;
|
|
5250
|
+
summary.unknown += derived.unknown;
|
|
5251
|
+
continue;
|
|
5252
|
+
}
|
|
5253
|
+
}
|
|
5254
|
+
const opaqueDeclaration = opaqueDerivedLocalDeclarationForRead(node, context);
|
|
5255
|
+
const opaqueNames = opaqueDeclaration ? declarationNames2(opaqueDeclaration) : [];
|
|
5256
|
+
if (opaqueDeclaration && opaqueNames.length === 1) {
|
|
5257
|
+
const derived = bindingUsageSummary(opaqueNames[0], owner, opaqueDeclaration, context, compatibleComponentProps, new Set(visited));
|
|
5258
|
+
if (derived.reads > 0) {
|
|
5259
|
+
summary.reads += derived.reads + 1;
|
|
5260
|
+
summary.compatible += derived.compatible;
|
|
5261
|
+
summary.customComponent += derived.customComponent;
|
|
5262
|
+
summary.visualCustomComponent += derived.visualCustomComponent;
|
|
5263
|
+
summary.effect += derived.effect;
|
|
5264
|
+
summary.structural += derived.structural;
|
|
5265
|
+
summary.unknown += derived.unknown + 1;
|
|
5266
|
+
continue;
|
|
5267
|
+
}
|
|
5268
|
+
}
|
|
5269
|
+
}
|
|
4803
5270
|
summary.reads += 1;
|
|
4804
5271
|
const customComponentUsage = createElementPropUsage(node, context);
|
|
4805
5272
|
const kind = classifyBindingUsage(node, context, compatibleComponentProps);
|
|
@@ -4822,11 +5289,94 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
|
|
|
4822
5289
|
function hasCandidatePresentationEvidence(summary) {
|
|
4823
5290
|
return summary.compatible > 0 || summary.visualCustomComponent > 0;
|
|
4824
5291
|
}
|
|
5292
|
+
function isPresentationOnly(summary) {
|
|
5293
|
+
return summary.reads > 0 && summary.compatible === summary.reads;
|
|
5294
|
+
}
|
|
5295
|
+
function stateBindingForSetter(setterName, context) {
|
|
5296
|
+
const valueName = context.model.stateSetters.get(setterName);
|
|
5297
|
+
if (!valueName)
|
|
5298
|
+
return null;
|
|
5299
|
+
return context.model.stateBindings.find((binding) => binding.setterName === setterName && binding.valueName === valueName) ?? null;
|
|
5300
|
+
}
|
|
5301
|
+
function effectOnlyDerivesPresentation(valueName, owner, declaration, context, compatibleComponentProps) {
|
|
5302
|
+
if (!owner.body)
|
|
5303
|
+
return false;
|
|
5304
|
+
let foundRelevantEffect = false;
|
|
5305
|
+
for (const call of context.walk(owner.body)) {
|
|
5306
|
+
if (call.type !== "function_call" || context.nearestFunction(call) !== owner)
|
|
5307
|
+
continue;
|
|
5308
|
+
const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
5309
|
+
if (path4 !== "React.useEffect" && path4 !== "React.useLayoutEffect")
|
|
5310
|
+
continue;
|
|
5311
|
+
const callbackNode = context.callArguments(call)[0];
|
|
5312
|
+
if (callbackNode?.type !== "function_definition")
|
|
5313
|
+
continue;
|
|
5314
|
+
const callback = context.model.functionByNode.get(callbackNode.id);
|
|
5315
|
+
if (!callback?.body)
|
|
5316
|
+
continue;
|
|
5317
|
+
const usesValue = [...context.walk(callback.body)].some((node) => context.nearestFunction(node) === callback && isReadNode(node, valueName, owner, declaration));
|
|
5318
|
+
if (!usesValue)
|
|
5319
|
+
continue;
|
|
5320
|
+
foundRelevantEffect = true;
|
|
5321
|
+
let presentationSetter = false;
|
|
5322
|
+
for (const nested of context.walk(callback.body)) {
|
|
5323
|
+
if (nested.type !== "function_call" || context.nearestFunction(nested) !== callback)
|
|
5324
|
+
continue;
|
|
5325
|
+
if (isBindingMapSafeCall(nested, context))
|
|
5326
|
+
continue;
|
|
5327
|
+
const nestedPath = context.resolveCallPath(context.getCallPath(nested) ?? "");
|
|
5328
|
+
if (nestedPath === "debug.profilebegin" || nestedPath === "debug.profileend")
|
|
5329
|
+
continue;
|
|
5330
|
+
if (context.model.bindingSetters.has(nestedPath)) {
|
|
5331
|
+
presentationSetter = true;
|
|
5332
|
+
continue;
|
|
5333
|
+
}
|
|
5334
|
+
const target = stateBindingForSetter(nestedPath, context);
|
|
5335
|
+
if (target?.owner && stateReadsAreBindingCompatible(target.valueName, target.owner, target.declaration, context, compatibleComponentProps)) {
|
|
5336
|
+
presentationSetter = true;
|
|
5337
|
+
continue;
|
|
5338
|
+
}
|
|
5339
|
+
return false;
|
|
5340
|
+
}
|
|
5341
|
+
if (!presentationSetter)
|
|
5342
|
+
return false;
|
|
5343
|
+
}
|
|
5344
|
+
return foundRelevantEffect;
|
|
5345
|
+
}
|
|
5346
|
+
function importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) {
|
|
5347
|
+
if (!summary.external || callDefinitelyUsesBindingMode(summary, call, context))
|
|
5348
|
+
return null;
|
|
5349
|
+
const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
|
|
5350
|
+
if (usage.reads === 0)
|
|
5351
|
+
return null;
|
|
5352
|
+
const presentationOnly = isPresentationOnly(usage);
|
|
5353
|
+
const explicitBindingMode = hasExplicitBindingMode(summary);
|
|
5354
|
+
const observedProperty = observedPropertyForCall(summary, call, context);
|
|
5355
|
+
const strongHostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
|
|
5356
|
+
const likelyContinuousProperty = summary.sourceKind === "instance-property" && Boolean(observedProperty && LIKELY_CONTINUOUS_PROPERTIES.has(observedProperty));
|
|
5357
|
+
const mirrorsExternalValue = summary.mirrorConfidence !== "none";
|
|
5358
|
+
if (presentationOnly && (summary.highFrequency || likelyContinuousProperty))
|
|
5359
|
+
return "warning";
|
|
5360
|
+
if (presentationOnly && strongHostFeedback && explicitBindingMode)
|
|
5361
|
+
return "warning";
|
|
5362
|
+
if (presentationOnly && (explicitBindingMode || strongHostFeedback || mirrorsExternalValue))
|
|
5363
|
+
return "candidate";
|
|
5364
|
+
if (strongHostFeedback && usage.effect > 0 && effectOnlyDerivesPresentation(localName, owner, declaration, context, compatibleComponentProps)) {
|
|
5365
|
+
return "candidate";
|
|
5366
|
+
}
|
|
5367
|
+
if (!hasCandidatePresentationEvidence(usage))
|
|
5368
|
+
return null;
|
|
5369
|
+
if (summary.highFrequency || likelyContinuousProperty)
|
|
5370
|
+
return "candidate";
|
|
5371
|
+
if (strongHostFeedback && explicitBindingMode && usage.effect === 0)
|
|
5372
|
+
return "candidate";
|
|
5373
|
+
return null;
|
|
5374
|
+
}
|
|
4825
5375
|
var preferBindingOverState = {
|
|
4826
5376
|
id: "react-luau/prefer-binding-over-state",
|
|
4827
5377
|
category: "Performance",
|
|
4828
5378
|
severity: "warning",
|
|
4829
|
-
description: "Prefer React.useBinding when
|
|
5379
|
+
description: "Prefer React.useBinding when proven external presentation streams do not need React reconciliation.",
|
|
4830
5380
|
run(context) {
|
|
4831
5381
|
const diagnostics = [];
|
|
4832
5382
|
const seen = new Set;
|
|
@@ -4843,75 +5393,72 @@ var preferBindingOverState = {
|
|
|
4843
5393
|
const owner = context.containingComponent(call) ?? context.nearestFunction(call);
|
|
4844
5394
|
if (!declaration || !localName || !owner)
|
|
4845
5395
|
continue;
|
|
4846
|
-
if (
|
|
5396
|
+
if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "warning")
|
|
4847
5397
|
continue;
|
|
4848
5398
|
const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
|
|
4849
5399
|
if (seen.has(key))
|
|
4850
5400
|
continue;
|
|
4851
5401
|
seen.add(key);
|
|
4852
|
-
const hasBindingMode = summary
|
|
4853
|
-
const
|
|
4854
|
-
const
|
|
4855
|
-
const
|
|
5402
|
+
const hasBindingMode = hasExplicitBindingMode(summary);
|
|
5403
|
+
const observedProperty = observedPropertyForCall(summary, call, context);
|
|
5404
|
+
const hostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
|
|
5405
|
+
const likelyContinuousProperty = summary.sourceKind === "instance-property" && Boolean(observedProperty && LIKELY_CONTINUOUS_PROPERTIES.has(observedProperty));
|
|
5406
|
+
const message = hostFeedback ? `${path4} mirrors ${observedProperty} through React state even though every analyzed use is presentation-only, so the measurement rerenders this consumer without needing reconciliation.` : likelyContinuousProperty ? `${path4} mirrors continuously mutable ${observedProperty} through React state, and ${localName} only feeds presentation updates that do not need reconciliation.` : `${path4} returns state updated from a high-frequency source, and ${localName} only feeds presentation updates that do not need React reconciliation.`;
|
|
5407
|
+
const help = hasBindingMode ? `Use the hook's Binding-returning mode${summary.bindingModeParameterName ? ` by passing ${summary.bindingWhenTruthy ? "true" : "false"} for ${summary.bindingModeParameterName}` : ""}. Binding:map can keep pure visual derivations outside the render cycle.` : "Prefer a Binding-returning hook API so continuous presentation updates can reach host properties without rerendering the consumer.";
|
|
4856
5408
|
diagnostics.push({
|
|
4857
5409
|
node: callNameNode(call),
|
|
4858
5410
|
message,
|
|
4859
5411
|
help,
|
|
4860
|
-
fixPreview: hasBindingMode ? bindingModeFixPreview(summary, call, context) : undefined
|
|
4861
|
-
});
|
|
4862
|
-
}
|
|
4863
|
-
for (const call of context.findCalls()) {
|
|
4864
|
-
const path4 = context.getCallPath(call) ?? "";
|
|
4865
|
-
const member = path4.split(/[.:]/).at(-1) ?? path4;
|
|
4866
|
-
const bindingAlternative = context.project.bindingApiAlternatives.get(member);
|
|
4867
|
-
if (!bindingAlternative)
|
|
4868
|
-
continue;
|
|
4869
|
-
const declaration = declarationForCall2(call);
|
|
4870
|
-
const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
|
|
4871
|
-
const owner = context.containingComponent(call) ?? context.nearestFunction(call);
|
|
4872
|
-
if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
|
|
4873
|
-
continue;
|
|
4874
|
-
if (!stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps))
|
|
4875
|
-
continue;
|
|
4876
|
-
const field = selectorFieldName(call, context);
|
|
4877
|
-
const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
|
|
4878
|
-
if (seen.has(key))
|
|
4879
|
-
continue;
|
|
4880
|
-
seen.add(key);
|
|
4881
|
-
diagnostics.push({
|
|
4882
|
-
node: callNameNode(call),
|
|
4883
|
-
message: `${path4} subscribes ${localName} through React state${field ? ` for ${field}` : ""}, but ${bindingAlternative} is available and every use of the value only updates bindable host properties.`,
|
|
4884
|
-
help: `Use ${bindingAlternative} so the host property can update without rerendering the component.`,
|
|
4885
|
-
fixPreview: callMemberFixPreview(call, bindingAlternative)
|
|
5412
|
+
fixPreview: hasBindingMode && allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? bindingModeFixPreview(summary, call, context) : undefined
|
|
4886
5413
|
});
|
|
4887
5414
|
}
|
|
4888
5415
|
for (const binding of context.model.stateBindings) {
|
|
4889
5416
|
const owner = binding.owner;
|
|
4890
5417
|
if (!owner || !owner.isComponent && !owner.isHook)
|
|
4891
5418
|
continue;
|
|
4892
|
-
const
|
|
4893
|
-
if (!
|
|
5419
|
+
const update = bindingUpdateEvidence(binding, importedCallbacks, context);
|
|
5420
|
+
if (!update)
|
|
4894
5421
|
continue;
|
|
4895
5422
|
const localCompatible = stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
|
|
4896
5423
|
const hookCompatible = owner.isHook ? hookCallSitesAreBindingCompatible(binding, context, compatibleComponentProps) : false;
|
|
4897
|
-
|
|
5424
|
+
const strongHostFeedback = hasStrongHostFeedbackEvidence(update);
|
|
5425
|
+
const likelyContinuousProperty = hasLikelyContinuousPropertyEvidence(update);
|
|
5426
|
+
const shouldWarn = owner.isHook ? hookCompatible && (update.highFrequency || likelyContinuousProperty) : localCompatible && (update.highFrequency || strongHostFeedback || likelyContinuousProperty);
|
|
5427
|
+
if (!shouldWarn)
|
|
4898
5428
|
continue;
|
|
4899
5429
|
const key = `${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
|
|
4900
5430
|
if (seen.has(key))
|
|
4901
5431
|
continue;
|
|
4902
5432
|
seen.add(key);
|
|
4903
5433
|
if (owner.isHook && hookCompatible) {
|
|
5434
|
+
const properties = likelyContinuousProperty ? [...update.hostFeedbackProperties].sort().join(", ") : "";
|
|
5435
|
+
diagnostics.push({
|
|
5436
|
+
node: callNameNode(binding.call),
|
|
5437
|
+
message: likelyContinuousProperty && !update.highFrequency ? `Hook state ${binding.valueName} mirrors continuously mutable ${properties} and is only consumed by presentation properties at analyzed call sites.` : `Hook state ${binding.valueName} is updated from a high-frequency source and is only consumed by presentation properties at analyzed call sites.`,
|
|
5438
|
+
help: "Consider returning a Binding, or adding a Binding-returning variant of this hook, so continuous presentation updates do not rerender every consumer."
|
|
5439
|
+
});
|
|
5440
|
+
} else if (strongHostFeedback && !update.highFrequency) {
|
|
5441
|
+
const properties = [...update.hostFeedbackProperties].sort().join(", ");
|
|
4904
5442
|
diagnostics.push({
|
|
4905
5443
|
node: callNameNode(binding.call),
|
|
4906
|
-
message: `
|
|
4907
|
-
help: "
|
|
5444
|
+
message: `State ${binding.valueName} mirrors ${properties} and only feeds presentation updates, so React reconciliation is acting as an unnecessary host-property feedback step.`,
|
|
5445
|
+
help: "Prefer React.useBinding for this host feedback path. Use Binding:map for pure visual derivations and keep React state only when a change needs to affect component structure or React effects.",
|
|
5446
|
+
fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
|
|
5447
|
+
});
|
|
5448
|
+
} else if (likelyContinuousProperty && !update.highFrequency) {
|
|
5449
|
+
const properties = [...update.hostFeedbackProperties].sort().join(", ");
|
|
5450
|
+
diagnostics.push({
|
|
5451
|
+
node: callNameNode(binding.call),
|
|
5452
|
+
message: `State ${binding.valueName} mirrors continuously mutable ${properties} and only feeds presentation updates that do not need React reconciliation.`,
|
|
5453
|
+
help: "Prefer React.useBinding so external visual property changes can reach presentation without routing every update through component state.",
|
|
5454
|
+
fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
|
|
4908
5455
|
});
|
|
4909
5456
|
} else {
|
|
4910
5457
|
diagnostics.push({
|
|
4911
5458
|
node: callNameNode(binding.call),
|
|
4912
|
-
message: `State ${binding.valueName} is updated
|
|
4913
|
-
help: "Prefer React.useBinding
|
|
4914
|
-
fixPreview: useBindingFixPreview(binding.call)
|
|
5459
|
+
message: `State ${binding.valueName} is updated from a high-frequency callback and only feeds presentation updates that do not need React reconciliation.`,
|
|
5460
|
+
help: "Prefer React.useBinding so continuous visual updates can reach host properties without rerendering the component.",
|
|
5461
|
+
fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
|
|
4915
5462
|
});
|
|
4916
5463
|
}
|
|
4917
5464
|
}
|
|
@@ -4932,27 +5479,49 @@ var preferBindingOverStateCandidate = {
|
|
|
4932
5479
|
for (const call of context.findCalls()) {
|
|
4933
5480
|
const path4 = context.getCallPath(call) ?? "";
|
|
4934
5481
|
const summary = importedHooks.get(path4);
|
|
4935
|
-
if (!summary
|
|
5482
|
+
if (!summary)
|
|
4936
5483
|
continue;
|
|
4937
5484
|
const declaration = declarationForCall2(call);
|
|
4938
5485
|
const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
|
|
4939
5486
|
const owner = context.containingComponent(call) ?? context.nearestFunction(call);
|
|
4940
|
-
if (!declaration || !localName || !owner
|
|
5487
|
+
if (!declaration || !localName || !owner)
|
|
4941
5488
|
continue;
|
|
4942
|
-
if (
|
|
5489
|
+
if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "candidate")
|
|
4943
5490
|
continue;
|
|
4944
5491
|
const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
|
|
4945
|
-
const
|
|
4946
|
-
|
|
4947
|
-
|
|
5492
|
+
const presentationOnly = isPresentationOnly(usage);
|
|
5493
|
+
const observedProperty = observedPropertyForCall(summary, call, context);
|
|
5494
|
+
const strongHostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
|
|
5495
|
+
const hasBindingMode = hasExplicitBindingMode(summary);
|
|
5496
|
+
const mirrorsExternalValue = summary.mirrorConfidence !== "none";
|
|
5497
|
+
const visualEffectBridge = strongHostFeedback && usage.effect > 0 && effectOnlyDerivesPresentation(localName, owner, declaration, context, compatibleComponentProps);
|
|
4948
5498
|
const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
|
|
4949
5499
|
if (seen.has(key))
|
|
4950
5500
|
continue;
|
|
4951
5501
|
seen.add(key);
|
|
5502
|
+
let message;
|
|
5503
|
+
let help;
|
|
5504
|
+
if (presentationOnly && hasBindingMode) {
|
|
5505
|
+
message = `${path4} has a Binding-returning mode and ${localName} is only used for presentation, but Doctor cannot prove the updates are frequent enough to make the Binding version clearly preferable.`;
|
|
5506
|
+
help = "Consider the Binding mode when this value changes often enough that avoiding reconciliation matters; ordinary React state may be clearer for infrequent semantic updates.";
|
|
5507
|
+
} else if (presentationOnly && strongHostFeedback) {
|
|
5508
|
+
message = `${path4} mirrors ${observedProperty} through React state only for presentation, but the hook does not expose a proven low-friction Binding mode.`;
|
|
5509
|
+
help = "Consider a Binding-returning variant if this host feedback path is performance-sensitive. Keep state when the simpler API is more valuable than avoiding occasional rerenders.";
|
|
5510
|
+
} else if (presentationOnly && mirrorsExternalValue) {
|
|
5511
|
+
message = `${path4} mirrors an external reactive value through React state, and ${localName} is only used for presentation.`;
|
|
5512
|
+
help = "Consider a Binding-returning hook API if this source changes often enough that rerendering is unnecessary. Doctor cannot prove the source's update pressure, so ordinary state may still be the clearer choice.";
|
|
5513
|
+
} else if (visualEffectBridge) {
|
|
5514
|
+
message = `${path4} mirrors ${observedProperty} through React state, and the dependent React effect only derives presentation state or Bindings from ${localName}.`;
|
|
5515
|
+
help = hasBindingMode ? "Consider the hook's Binding mode and move the visual derivation into Binding:map/joinBindings so the measurement does not need an intermediate React rerender." : "Consider a Binding-returning hook variant if this visual feedback path is performance-sensitive; the current effect appears to use React state only as an intermediate presentation step.";
|
|
5516
|
+
} else {
|
|
5517
|
+
message = `${path4} updates ${localName} through React state and some consumers are presentation-only, but other consumers may still require React reconciliation.`;
|
|
5518
|
+
help = "Inspect whether the presentation branch can move to a Binding while structural or effect-driven consumers keep React state. Avoid replacing the value wholesale when React still needs to react to it.";
|
|
5519
|
+
}
|
|
4952
5520
|
diagnostics.push({
|
|
4953
5521
|
node: callNameNode(call),
|
|
4954
|
-
message
|
|
4955
|
-
help
|
|
5522
|
+
message,
|
|
5523
|
+
help,
|
|
5524
|
+
fixPreview: presentationOnly && hasBindingMode && allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? bindingModeFixPreview(summary, call, context) : undefined
|
|
4956
5525
|
});
|
|
4957
5526
|
}
|
|
4958
5527
|
for (const call of context.findCalls()) {
|
|
@@ -4966,39 +5535,80 @@ var preferBindingOverStateCandidate = {
|
|
|
4966
5535
|
const owner = context.containingComponent(call) ?? context.nearestFunction(call);
|
|
4967
5536
|
if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
|
|
4968
5537
|
continue;
|
|
4969
|
-
|
|
5538
|
+
const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
|
|
5539
|
+
const presentationOnly = isPresentationOnly(usage);
|
|
5540
|
+
if (!presentationOnly)
|
|
4970
5541
|
continue;
|
|
5542
|
+
const field = selectorFieldName(call, context);
|
|
4971
5543
|
const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
|
|
4972
5544
|
if (seen.has(key))
|
|
4973
5545
|
continue;
|
|
4974
5546
|
seen.add(key);
|
|
4975
5547
|
diagnostics.push({
|
|
4976
5548
|
node: callNameNode(call),
|
|
4977
|
-
message: `${path4}
|
|
4978
|
-
help: `
|
|
4979
|
-
fixPreview: callMemberFixPreview(call, bindingAlternative)
|
|
5549
|
+
message: `${path4} subscribes ${localName} through React state${field ? ` for ${field}` : ""}, while ${bindingAlternative} is also available and every analyzed consumer is presentation-only.`,
|
|
5550
|
+
help: `Consider ${bindingAlternative} when this value updates frequently enough that avoiding reconciliation matters; state remains reasonable for ordinary semantic store updates.`,
|
|
5551
|
+
fixPreview: allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? callMemberFixPreview(call, bindingAlternative) : undefined
|
|
4980
5552
|
});
|
|
4981
5553
|
}
|
|
4982
5554
|
for (const binding of context.model.stateBindings) {
|
|
4983
5555
|
const owner = binding.owner;
|
|
4984
|
-
if (!owner || !owner.isComponent || !owner.body)
|
|
5556
|
+
if (!owner || !owner.isComponent && !owner.isHook || !owner.body)
|
|
4985
5557
|
continue;
|
|
4986
|
-
const
|
|
4987
|
-
if (!
|
|
5558
|
+
const update = bindingUpdateEvidence(binding, importedCallbacks, context);
|
|
5559
|
+
if (!update)
|
|
4988
5560
|
continue;
|
|
4989
|
-
|
|
5561
|
+
const localCompatible = stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
|
|
5562
|
+
const hookCompatible = owner.isHook ? hookCallSitesAreBindingCompatible(binding, context, compatibleComponentProps) : false;
|
|
5563
|
+
const strongHostFeedback = hasStrongHostFeedbackEvidence(update);
|
|
5564
|
+
const likelyContinuousProperty = hasLikelyContinuousPropertyEvidence(update);
|
|
5565
|
+
const mirrorsExternalValue = update.mirrorConfidence !== "none";
|
|
5566
|
+
if (owner.isHook) {
|
|
5567
|
+
if (hookCompatible && (update.highFrequency || likelyContinuousProperty))
|
|
5568
|
+
continue;
|
|
5569
|
+
if (!(hookCompatible && (strongHostFeedback || mirrorsExternalValue)))
|
|
5570
|
+
continue;
|
|
5571
|
+
const key2 = `hook-state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
|
|
5572
|
+
if (seen.has(key2))
|
|
5573
|
+
continue;
|
|
5574
|
+
seen.add(key2);
|
|
5575
|
+
const measurement = strongHostFeedback ? "a presentation measurement" : "an external reactive value";
|
|
5576
|
+
diagnostics.push({
|
|
5577
|
+
node: callNameNode(binding.call),
|
|
5578
|
+
message: `Hook state ${binding.valueName} mirrors ${measurement} and is only consumed by presentation properties at analyzed call sites, but converting it requires changing the hook API.`,
|
|
5579
|
+
help: "Consider adding a Binding-returning variant when this reactive path updates often enough that avoiding reconciliation matters; keeping the state API is reasonable when ordinary React state is simpler."
|
|
5580
|
+
});
|
|
4990
5581
|
continue;
|
|
5582
|
+
}
|
|
4991
5583
|
const usage = bindingUsageSummary(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
|
|
4992
5584
|
if (!hasCandidatePresentationEvidence(usage))
|
|
4993
5585
|
continue;
|
|
5586
|
+
if (localCompatible && (update.highFrequency || strongHostFeedback || likelyContinuousProperty))
|
|
5587
|
+
continue;
|
|
5588
|
+
if (localCompatible && mirrorsExternalValue) {
|
|
5589
|
+
const key2 = `state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
|
|
5590
|
+
if (seen.has(key2))
|
|
5591
|
+
continue;
|
|
5592
|
+
seen.add(key2);
|
|
5593
|
+
diagnostics.push({
|
|
5594
|
+
node: callNameNode(binding.call),
|
|
5595
|
+
message: `State ${binding.valueName} mirrors an external reactive value and only feeds presentation updates, but Doctor cannot prove the source changes frequently enough for Binding to be clearly preferable.`,
|
|
5596
|
+
help: "Consider React.useBinding when this source is a stream of presentation values rather than semantic React state. Keep useState when the external event represents an infrequent UI state transition."
|
|
5597
|
+
});
|
|
5598
|
+
continue;
|
|
5599
|
+
}
|
|
5600
|
+
const mixedHighFrequency = update.highFrequency || likelyContinuousProperty;
|
|
5601
|
+
const mixedHostFeedback = strongHostFeedback && usage.structural > 0 && usage.effect === 0;
|
|
5602
|
+
if (!mixedHighFrequency && !mixedHostFeedback)
|
|
5603
|
+
continue;
|
|
4994
5604
|
const key = `state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
|
|
4995
5605
|
if (seen.has(key))
|
|
4996
5606
|
continue;
|
|
4997
5607
|
seen.add(key);
|
|
4998
5608
|
diagnostics.push({
|
|
4999
5609
|
node: callNameNode(binding.call),
|
|
5000
|
-
message: `State ${binding.valueName}
|
|
5001
|
-
help: "Inspect whether
|
|
5610
|
+
message: `State ${binding.valueName} has presentation-oriented consumers, but other uses still appear to need React reconciliation.`,
|
|
5611
|
+
help: "Inspect whether the presentation branch can move to React.useBinding while structural consumers keep a smaller semantic state value. Splitting the responsibilities can remove hot-path rerenders without forcing Binding semantics onto the whole value."
|
|
5002
5612
|
});
|
|
5003
5613
|
}
|
|
5004
5614
|
return diagnostics;
|
|
@@ -6357,6 +6967,16 @@ function nodeLocation(node) {
|
|
|
6357
6967
|
endColumn: node.endPosition.column + 1
|
|
6358
6968
|
};
|
|
6359
6969
|
}
|
|
6970
|
+
function sameLocation(left, right) {
|
|
6971
|
+
return left.line === right.line && left.column === right.column && left.endLine === right.endLine && left.endColumn === right.endColumn;
|
|
6972
|
+
}
|
|
6973
|
+
function removeSupersededDiagnostics(diagnostics) {
|
|
6974
|
+
return diagnostics.filter((diagnostic) => {
|
|
6975
|
+
if (diagnostic.rule !== "react-luau/rerender-high-frequency-state")
|
|
6976
|
+
return true;
|
|
6977
|
+
return !diagnostics.some((candidate) => candidate.rule === "react-luau/prefer-binding-over-state" && sameLocation(candidate.location, diagnostic.location) && SEVERITY_RANK[candidate.severity] >= SEVERITY_RANK[diagnostic.severity]);
|
|
6978
|
+
});
|
|
6979
|
+
}
|
|
6360
6980
|
function toDiagnostic(file, ruleId, category, severity, input) {
|
|
6361
6981
|
const node = input.node;
|
|
6362
6982
|
const highlights = input.highlights?.map(nodeLocation);
|
|
@@ -6414,11 +7034,12 @@ async function analyzeReactFile(input, tree) {
|
|
|
6414
7034
|
if (rule.id === "react-luau/parse-error" && findings.length > 0)
|
|
6415
7035
|
break;
|
|
6416
7036
|
}
|
|
7037
|
+
const filteredDiagnostics = removeSupersededDiagnostics(diagnostics);
|
|
6417
7038
|
return {
|
|
6418
7039
|
relativePath: input.relativePath,
|
|
6419
7040
|
isReactFile: model.isReactFile,
|
|
6420
7041
|
scanned: true,
|
|
6421
|
-
diagnostics
|
|
7042
|
+
diagnostics: filteredDiagnostics
|
|
6422
7043
|
};
|
|
6423
7044
|
}
|
|
6424
7045
|
|
|
@@ -7389,5 +8010,5 @@ parentPort.on("message", async (request) => {
|
|
|
7389
8010
|
}
|
|
7390
8011
|
});
|
|
7391
8012
|
|
|
7392
|
-
//# debugId=
|
|
8013
|
+
//# debugId=07C5601A473D8F7164756E2164756E21
|
|
7393
8014
|
//# sourceMappingURL=scan-worker.js.map
|