modality-ts 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extraction/index.d.ts +1 -0
- package/dist/extraction/index.d.ts.map +1 -1
- package/dist/extraction/index.js +501 -60
- package/dist/extraction/index.js.map +1 -1
- package/dist/extraction/pipeline/index.d.ts +2 -0
- package/dist/extraction/pipeline/index.d.ts.map +1 -1
- package/dist/extraction/pipeline/index.js +1 -0
- package/dist/extraction/pipeline/index.js.map +1 -1
- package/dist/kernel/ir/validator.js +35 -3
- package/dist/kernel/ir/validator.js.map +1 -1
- package/dist/modality/features/extract/command.d.ts.map +1 -1
- package/dist/modality/features/extract/command.js +245 -35
- package/dist/modality/features/extract/command.js.map +1 -1
- package/dist/sources/use-state/index.d.ts.map +1 -1
- package/dist/sources/use-state/index.js +13 -1
- package/dist/sources/use-state/index.js.map +1 -1
- package/package.json +1 -1
package/dist/extraction/index.js
CHANGED
|
@@ -2,6 +2,9 @@ import * as ts from "typescript";
|
|
|
2
2
|
import { effectReads, effectWrites } from "modality-ts/kernel";
|
|
3
3
|
export * from "./pipeline/index.js";
|
|
4
4
|
export * from "./spi/index.js";
|
|
5
|
+
function emptyContextBindings() {
|
|
6
|
+
return { vars: [], setters: new Map(), hookReturns: new Map() };
|
|
7
|
+
}
|
|
5
8
|
function setterBindingFromDecl(decl) {
|
|
6
9
|
const localMatch = /^local:([^.]+)\.(.+)$/.exec(decl.id);
|
|
7
10
|
const atomMatch = /^atom:(.+)$/.exec(decl.id);
|
|
@@ -12,6 +15,206 @@ function setterBindingFromDecl(decl) {
|
|
|
12
15
|
domain: decl.domain
|
|
13
16
|
};
|
|
14
17
|
}
|
|
18
|
+
function bindSetter(setters, symbolName, setter) {
|
|
19
|
+
setters.set(scopedSetterKey(setter.component, symbolName), setter);
|
|
20
|
+
const current = setters.get(symbolName);
|
|
21
|
+
if (!current || current.varId === setter.varId) {
|
|
22
|
+
setters.set(symbolName, setter);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
setters.delete(symbolName);
|
|
26
|
+
}
|
|
27
|
+
function scopedSetterKey(component, symbolName) {
|
|
28
|
+
return `${component}:${symbolName}`;
|
|
29
|
+
}
|
|
30
|
+
function settersForComponent(setters, component) {
|
|
31
|
+
if (!component)
|
|
32
|
+
return new Map(setters);
|
|
33
|
+
const scoped = new Map(setters);
|
|
34
|
+
for (const [key, setter] of setters) {
|
|
35
|
+
if (!key.startsWith(`${component}:`))
|
|
36
|
+
continue;
|
|
37
|
+
scoped.set(key.slice(component.length + 1), setter);
|
|
38
|
+
}
|
|
39
|
+
return scoped;
|
|
40
|
+
}
|
|
41
|
+
function discoverContextBindings(source, fileName, route, typeAliases) {
|
|
42
|
+
const bindings = emptyContextBindings();
|
|
43
|
+
const providerValues = new Map();
|
|
44
|
+
const visitProvider = (node, componentName) => {
|
|
45
|
+
const component = componentNameFor(node) ?? componentName;
|
|
46
|
+
const localSetters = new Map();
|
|
47
|
+
if ((ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node)) && component && node.body && ts.isBlock(node.body)) {
|
|
48
|
+
for (const statement of node.body.statements) {
|
|
49
|
+
if (!ts.isVariableStatement(statement))
|
|
50
|
+
continue;
|
|
51
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
52
|
+
if (!ts.isArrayBindingPattern(declaration.name) || !declaration.initializer || !isUseStateCall(declaration.initializer))
|
|
53
|
+
continue;
|
|
54
|
+
const stateName = declaration.name.elements[0];
|
|
55
|
+
const setterName = declaration.name.elements[1];
|
|
56
|
+
if (!setterName || !ts.isBindingElement(stateName) || !ts.isIdentifier(stateName.name) || !ts.isBindingElement(setterName) || !ts.isIdentifier(setterName.name))
|
|
57
|
+
continue;
|
|
58
|
+
const domain = inferUseStateDomain(declaration.initializer, typeAliases);
|
|
59
|
+
const varId = `local:${component}.${stateName.name.text}`;
|
|
60
|
+
const setter = { varId, component, stateName: stateName.name.text, domain };
|
|
61
|
+
localSetters.set(setterName.name.text, setter);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const statement of node.body.statements) {
|
|
65
|
+
if (!ts.isVariableStatement(statement))
|
|
66
|
+
continue;
|
|
67
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
68
|
+
if (!ts.isIdentifier(declaration.name) || !declaration.initializer)
|
|
69
|
+
continue;
|
|
70
|
+
const setter = setterAliasBinding(declaration.initializer, localSetters);
|
|
71
|
+
if (setter)
|
|
72
|
+
localSetters.set(declaration.name.text, setter);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (component && localSetters.size > 0 && node.getText(source).includes(".Provider")) {
|
|
77
|
+
const fields = providerValueFields(node, localSetters);
|
|
78
|
+
if (fields.size > 0) {
|
|
79
|
+
providerValues.set(component, fields);
|
|
80
|
+
for (const setter of fields.values())
|
|
81
|
+
bindings.setters.set(setter.stateName, setter);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
ts.forEachChild(node, (child) => visitProvider(child, component));
|
|
85
|
+
};
|
|
86
|
+
visitProvider(source, undefined);
|
|
87
|
+
const providerFieldMaps = [...providerValues.values()];
|
|
88
|
+
const visitHook = (node) => {
|
|
89
|
+
const name = customHookDeclarationName(node);
|
|
90
|
+
if (name && (ts.isFunctionDeclaration(node) || (ts.isVariableDeclaration(node) && node.initializer && isExtractableHandler(node.initializer)))) {
|
|
91
|
+
const hook = ts.isFunctionDeclaration(node) ? node : node.initializer;
|
|
92
|
+
if (hookUsesContext(hook) && providerFieldMaps.length > 0) {
|
|
93
|
+
const merged = new Map();
|
|
94
|
+
for (const map of providerFieldMaps)
|
|
95
|
+
for (const [field, setter] of map)
|
|
96
|
+
merged.set(field, setter);
|
|
97
|
+
bindings.hookReturns.set(name, merged);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
ts.forEachChild(node, visitHook);
|
|
101
|
+
};
|
|
102
|
+
visitHook(source);
|
|
103
|
+
return bindings;
|
|
104
|
+
}
|
|
105
|
+
function providerValueFields(node, localSetters) {
|
|
106
|
+
const fields = new Map();
|
|
107
|
+
const visit = (candidate) => {
|
|
108
|
+
if (ts.isJsxAttribute(candidate) && ts.isIdentifier(candidate.name) && candidate.name.text === "value" && candidate.initializer && ts.isJsxExpression(candidate.initializer)) {
|
|
109
|
+
const value = providerValueObject(node, candidate.initializer.expression);
|
|
110
|
+
if (value) {
|
|
111
|
+
for (const property of value.properties) {
|
|
112
|
+
if (!ts.isShorthandPropertyAssignment(property) && !ts.isPropertyAssignment(property))
|
|
113
|
+
continue;
|
|
114
|
+
const name = ts.isShorthandPropertyAssignment(property) ? property.name.text : propertyName(property.name);
|
|
115
|
+
const expr = ts.isShorthandPropertyAssignment(property) ? property.name : property.initializer;
|
|
116
|
+
if (!name || !ts.isIdentifier(expr))
|
|
117
|
+
continue;
|
|
118
|
+
const setter = localSetters.get(expr.text);
|
|
119
|
+
if (setter)
|
|
120
|
+
fields.set(name, setter);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
ts.forEachChild(candidate, visit);
|
|
125
|
+
};
|
|
126
|
+
visit(node);
|
|
127
|
+
return fields;
|
|
128
|
+
}
|
|
129
|
+
function setterAliasBinding(expression, localSetters) {
|
|
130
|
+
const callback = useCallbackFunction(expression);
|
|
131
|
+
if (!callback || callback.parameters.length !== 1 || !ts.isIdentifier(callback.parameters[0].name))
|
|
132
|
+
return undefined;
|
|
133
|
+
const parameter = callback.parameters[0].name.text;
|
|
134
|
+
const call = firstCallInFunction(callback);
|
|
135
|
+
if (!call || !ts.isIdentifier(call.expression) || call.arguments.length !== 1 || !ts.isIdentifier(call.arguments[0]) || call.arguments[0].text !== parameter)
|
|
136
|
+
return undefined;
|
|
137
|
+
return localSetters.get(call.expression.text);
|
|
138
|
+
}
|
|
139
|
+
function useCallbackFunction(expression) {
|
|
140
|
+
if (!ts.isCallExpression(expression) || !ts.isIdentifier(expression.expression) || expression.expression.text !== "useCallback")
|
|
141
|
+
return undefined;
|
|
142
|
+
const first = expression.arguments[0];
|
|
143
|
+
return first && isExtractableHandler(first) ? first : undefined;
|
|
144
|
+
}
|
|
145
|
+
function firstCallInFunction(fn) {
|
|
146
|
+
if (!ts.isBlock(fn.body))
|
|
147
|
+
return ts.isCallExpression(fn.body) ? fn.body : undefined;
|
|
148
|
+
for (const statement of fn.body.statements) {
|
|
149
|
+
if (ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression))
|
|
150
|
+
return statement.expression;
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
function providerValueObject(scope, expression) {
|
|
155
|
+
if (!expression)
|
|
156
|
+
return undefined;
|
|
157
|
+
if (ts.isObjectLiteralExpression(expression))
|
|
158
|
+
return expression;
|
|
159
|
+
if (!ts.isIdentifier(expression))
|
|
160
|
+
return undefined;
|
|
161
|
+
const declaration = variableDeclarationIn(scope, expression.text);
|
|
162
|
+
if (!declaration?.initializer || !ts.isCallExpression(declaration.initializer))
|
|
163
|
+
return undefined;
|
|
164
|
+
if (!ts.isIdentifier(declaration.initializer.expression) || declaration.initializer.expression.text !== "useMemo")
|
|
165
|
+
return undefined;
|
|
166
|
+
const callback = declaration.initializer.arguments[0];
|
|
167
|
+
if (!callback || !isExtractableHandler(callback))
|
|
168
|
+
return undefined;
|
|
169
|
+
if (ts.isObjectLiteralExpression(callback.body))
|
|
170
|
+
return callback.body;
|
|
171
|
+
if (ts.isParenthesizedExpression(callback.body) && ts.isObjectLiteralExpression(callback.body.expression))
|
|
172
|
+
return callback.body.expression;
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
function variableDeclarationIn(scope, name) {
|
|
176
|
+
let found;
|
|
177
|
+
const visit = (node) => {
|
|
178
|
+
if (found)
|
|
179
|
+
return;
|
|
180
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name) {
|
|
181
|
+
found = node;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
ts.forEachChild(node, visit);
|
|
185
|
+
};
|
|
186
|
+
visit(scope);
|
|
187
|
+
return found;
|
|
188
|
+
}
|
|
189
|
+
function hookUsesContext(hook) {
|
|
190
|
+
let found = false;
|
|
191
|
+
const visit = (node) => {
|
|
192
|
+
if (found)
|
|
193
|
+
return;
|
|
194
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useContext") {
|
|
195
|
+
found = true;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
ts.forEachChild(node, visit);
|
|
199
|
+
};
|
|
200
|
+
visit(hook);
|
|
201
|
+
return found;
|
|
202
|
+
}
|
|
203
|
+
function bindContextHookObjectDeclaration(node, contextBindings, setters) {
|
|
204
|
+
if (!ts.isVariableDeclaration(node) || !ts.isObjectBindingPattern(node.name) || !node.initializer || !ts.isCallExpression(node.initializer) || !ts.isIdentifier(node.initializer.expression))
|
|
205
|
+
return;
|
|
206
|
+
const hook = contextBindings.hookReturns.get(node.initializer.expression.text);
|
|
207
|
+
if (!hook)
|
|
208
|
+
return;
|
|
209
|
+
for (const element of node.name.elements) {
|
|
210
|
+
if (!ts.isIdentifier(element.name))
|
|
211
|
+
continue;
|
|
212
|
+
const property = element.propertyName && ts.isIdentifier(element.propertyName) ? element.propertyName.text : element.name.text;
|
|
213
|
+
const setter = hook.get(property);
|
|
214
|
+
if (setter)
|
|
215
|
+
setters.set(element.name.text, setter);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
15
218
|
export function inferDomainFromTypeNode(node, typeAliases = new Map()) {
|
|
16
219
|
if (!node)
|
|
17
220
|
return { kind: "tokens", count: 1 };
|
|
@@ -48,12 +251,15 @@ export function extractUseStateSkeleton(sourceText, options = {}) {
|
|
|
48
251
|
const transitions = [];
|
|
49
252
|
const warnings = [];
|
|
50
253
|
const route = options.route ?? "/";
|
|
254
|
+
const routePatterns = options.routePatterns ?? [];
|
|
51
255
|
const effectApis = new Set(options.effectApis ?? []);
|
|
52
256
|
const sourcePlugins = options.sourcePlugins ?? [];
|
|
53
257
|
const routerPlugin = options.routerPlugin;
|
|
54
258
|
const setters = new Map();
|
|
259
|
+
const contextBindings = discoverContextBindings(source, fileName, route, typeAliases);
|
|
55
260
|
const globalTaints = new Set();
|
|
56
261
|
const components = componentDeclarations(source);
|
|
262
|
+
const providerComponents = providerComponentNames(source);
|
|
57
263
|
const customHooks = customHookDeclarations(source);
|
|
58
264
|
const statefulListComponents = detectStatefulListComponents(source, components);
|
|
59
265
|
const reportedStatefulListComponents = new Set();
|
|
@@ -63,9 +269,15 @@ export function extractUseStateSkeleton(sourceText, options = {}) {
|
|
|
63
269
|
const decl = options.stateVars.find((candidate) => candidate.id === channel.varId);
|
|
64
270
|
if (!decl)
|
|
65
271
|
continue;
|
|
66
|
-
setters
|
|
272
|
+
bindSetter(setters, channel.symbolName, setterBindingFromDecl(decl));
|
|
67
273
|
}
|
|
68
274
|
}
|
|
275
|
+
for (const decl of contextBindings.vars) {
|
|
276
|
+
if (!vars.some((candidate) => candidate.id === decl.id))
|
|
277
|
+
vars.push(decl);
|
|
278
|
+
}
|
|
279
|
+
for (const [symbolName, setter] of contextBindings.setters)
|
|
280
|
+
setters.set(symbolName, setter);
|
|
69
281
|
const handlers = new Map();
|
|
70
282
|
const visit = (node, componentName) => {
|
|
71
283
|
if (!componentName && isCustomHookDeclaration(node))
|
|
@@ -76,16 +288,20 @@ export function extractUseStateSkeleton(sourceText, options = {}) {
|
|
|
76
288
|
if (handler)
|
|
77
289
|
handlers.set(node.name.text, handler);
|
|
78
290
|
}
|
|
291
|
+
if (ts.isFunctionDeclaration(node) && node.name && isExtractableHandler(node)) {
|
|
292
|
+
handlers.set(node.name.text, node);
|
|
293
|
+
}
|
|
79
294
|
if (ts.isVariableDeclaration(node) && node.initializer && isUseReducerCall(node.initializer)) {
|
|
80
295
|
warnings.push({ message: `Unsupported useReducer ${nextComponent ?? "Anonymous"}.useReducer`, ...lineAndColumn(source, node) });
|
|
81
296
|
}
|
|
297
|
+
bindContextHookObjectDeclaration(node, contextBindings, setters);
|
|
82
298
|
if (ts.isVariableDeclaration(node) && nextComponent && inlineCustomHookState(source, fileName, node, customHooks, vars, setters, nextComponent, route)) {
|
|
83
299
|
return;
|
|
84
300
|
}
|
|
85
301
|
const customHook = calledCustomHook(node, new Set(customHooks.keys()));
|
|
86
302
|
if (customHook && nextComponent) {
|
|
87
303
|
const key = `${nextComponent}.${customHook}`;
|
|
88
|
-
if (!reportedCustomHooks.has(key)) {
|
|
304
|
+
if (!contextBindings.hookReturns.has(customHook) && !reportedCustomHooks.has(key)) {
|
|
89
305
|
reportedCustomHooks.add(key);
|
|
90
306
|
warnings.push({ message: `Unextractable custom hook ${key}`, ...lineAndColumn(source, node) });
|
|
91
307
|
}
|
|
@@ -110,20 +326,24 @@ export function extractUseStateSkeleton(sourceText, options = {}) {
|
|
|
110
326
|
id: varId,
|
|
111
327
|
domain,
|
|
112
328
|
origin: { file: fileName, ...lineAndColumn(source, node) },
|
|
113
|
-
scope: { kind: "route-local", route },
|
|
329
|
+
scope: providerComponents.has(component) ? { kind: "global" } : { kind: "route-local", route },
|
|
114
330
|
initial: initialValueForUseState(node.initializer, domain)
|
|
115
331
|
});
|
|
116
332
|
}
|
|
117
333
|
if (setterName && ts.isBindingElement(setterName) && ts.isIdentifier(setterName.name)) {
|
|
118
334
|
if (!options.writeChannels)
|
|
119
|
-
setters
|
|
335
|
+
bindSetter(setters, setterName.name.text, { varId, component, stateName: stateName.name.text, domain });
|
|
120
336
|
}
|
|
121
337
|
}
|
|
122
338
|
else {
|
|
123
339
|
warnings.push({ message: "Unsupported useState binding pattern", ...lineAndColumn(source, node) });
|
|
124
340
|
}
|
|
125
341
|
}
|
|
126
|
-
const
|
|
342
|
+
const link = linkNavigationTransition(source, fileName, node, nextComponent ?? "Anonymous", routePatterns);
|
|
343
|
+
if (link)
|
|
344
|
+
transitions.push(link);
|
|
345
|
+
const scopedSetters = settersForComponent(setters, nextComponent);
|
|
346
|
+
const refTaint = refSetterTaint(node, scopedSetters);
|
|
127
347
|
if (refTaint) {
|
|
128
348
|
const key = `Global taint ${refTaint.varId}`;
|
|
129
349
|
if (!globalTaints.has(key)) {
|
|
@@ -131,8 +351,8 @@ export function extractUseStateSkeleton(sourceText, options = {}) {
|
|
|
131
351
|
warnings.push({ message: key, ...lineAndColumn(source, refTaint.node) });
|
|
132
352
|
}
|
|
133
353
|
}
|
|
134
|
-
transitions.push(...transitionsFromTimerCall(source, fileName, node,
|
|
135
|
-
for (const timerTaint of timerSetterTaints(node,
|
|
354
|
+
transitions.push(...transitionsFromTimerCall(source, fileName, node, scopedSetters, nextComponent ?? "Anonymous"));
|
|
355
|
+
for (const timerTaint of timerSetterTaints(node, scopedSetters)) {
|
|
136
356
|
const key = `Global taint ${timerTaint.varId}`;
|
|
137
357
|
if (!globalTaints.has(key)) {
|
|
138
358
|
globalTaints.add(key);
|
|
@@ -140,7 +360,7 @@ export function extractUseStateSkeleton(sourceText, options = {}) {
|
|
|
140
360
|
}
|
|
141
361
|
}
|
|
142
362
|
if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.initializer && isForwardablePropName(node.name.text) && !isIntrinsicJsxAttribute(node)) {
|
|
143
|
-
const extracted = transitionsFromComponentPropAttribute(source, fileName, node,
|
|
363
|
+
const extracted = transitionsFromComponentPropAttribute(source, fileName, node, scopedSetters, handlers, components, nextComponent ?? "Anonymous", effectApis, options.asyncOutcomes ?? {}, sourcePlugins, routerPlugin, warnings);
|
|
144
364
|
transitions.push(...extracted);
|
|
145
365
|
if (extracted.length === 0) {
|
|
146
366
|
warnings.push({ message: `Unextractable handler ${nextComponent ?? "Anonymous"}.${node.name.text}`, ...lineAndColumn(source, node) });
|
|
@@ -150,7 +370,7 @@ export function extractUseStateSkeleton(sourceText, options = {}) {
|
|
|
150
370
|
const listInfo = listRenderedHandlerInfo(node, vars, nextComponent ?? "Anonymous");
|
|
151
371
|
if (listInfo) {
|
|
152
372
|
if (listInfo.domain.kind === "boundedList") {
|
|
153
|
-
const extracted = transitionsFromBoundedListAttribute(source, fileName, node,
|
|
373
|
+
const extracted = transitionsFromBoundedListAttribute(source, fileName, node, scopedSetters, handlers, nextComponent ?? "Anonymous", {
|
|
154
374
|
varId: listInfo.varId,
|
|
155
375
|
domain: listInfo.domain,
|
|
156
376
|
itemName: listInfo.itemName
|
|
@@ -165,21 +385,21 @@ export function extractUseStateSkeleton(sourceText, options = {}) {
|
|
|
165
385
|
ts.forEachChild(node, (child) => visit(child, nextComponent));
|
|
166
386
|
return;
|
|
167
387
|
}
|
|
168
|
-
const guardLocals = componentGuardLocalsFor(node,
|
|
388
|
+
const guardLocals = componentGuardLocalsFor(node, scopedSetters);
|
|
169
389
|
const guard = combineParsedGuards([
|
|
170
|
-
renderGuardFor(node,
|
|
171
|
-
disabledGuardFor(node,
|
|
390
|
+
renderGuardFor(node, scopedSetters, warnings, source, nextComponent ?? "Anonymous", guardLocals),
|
|
391
|
+
disabledGuardFor(node, scopedSetters, warnings, source, nextComponent ?? "Anonymous", guardLocals)
|
|
172
392
|
]);
|
|
173
|
-
const extracted = transitionsFromJsxAttribute(source, fileName, node,
|
|
393
|
+
const extracted = transitionsFromJsxAttribute(source, fileName, node, scopedSetters, handlers, nextComponent ?? "Anonymous", effectApis, options.asyncOutcomes ?? {}, sourcePlugins, routerPlugin, guard, routePatterns, contextBindings, warnings);
|
|
174
394
|
transitions.push(...extracted);
|
|
175
|
-
if (extracted.length === 0 && !forwardsComponentProp(node, handlers, components.get(nextComponent ?? "")) && !handlerSchedulesModeledTimer(node, handlers,
|
|
395
|
+
if (extracted.length === 0 && !forwardsComponentProp(node, handlers, components.get(nextComponent ?? "")) && !handlerSchedulesModeledTimer(node, handlers, scopedSetters)) {
|
|
176
396
|
warnings.push({ message: `Unextractable handler ${nextComponent ?? "Anonymous"}.${node.name.text}`, ...lineAndColumn(source, node) });
|
|
177
397
|
}
|
|
178
398
|
}
|
|
179
399
|
if (ts.isCallExpression(node) && isUseEffectCall(node)) {
|
|
180
|
-
const extracted = transitionsFromUseEffect(source, fileName, node,
|
|
400
|
+
const extracted = transitionsFromUseEffect(source, fileName, node, scopedSetters, nextComponent ?? "Anonymous");
|
|
181
401
|
transitions.push(...extracted);
|
|
182
|
-
if (extracted.length === 0 && useEffectWritesModeledState(node,
|
|
402
|
+
if (extracted.length === 0 && useEffectWritesModeledState(node, scopedSetters) && !providerComponents.has(nextComponent ?? "")) {
|
|
183
403
|
warnings.push({ message: `Unextractable effect ${nextComponent ?? "Anonymous"}.useEffect`, ...lineAndColumn(source, node) });
|
|
184
404
|
}
|
|
185
405
|
}
|
|
@@ -394,7 +614,7 @@ function isUseEffectCall(node) {
|
|
|
394
614
|
return ts.isIdentifier(node.expression) && node.expression.text === "useEffect";
|
|
395
615
|
}
|
|
396
616
|
function isExtractableHandler(node) {
|
|
397
|
-
return ts.isArrowFunction(node) || ts.isFunctionExpression(node);
|
|
617
|
+
return ts.isArrowFunction(node) || ts.isFunctionExpression(node) || (ts.isFunctionDeclaration(node) && Boolean(node.body));
|
|
398
618
|
}
|
|
399
619
|
function extractableHandlerInitializer(node) {
|
|
400
620
|
if (isExtractableHandler(node))
|
|
@@ -447,7 +667,7 @@ function transitionsFromTimerCall(source, fileName, node, setters, component) {
|
|
|
447
667
|
if (!summaries || summaries.length === 0)
|
|
448
668
|
return [];
|
|
449
669
|
const effects = summaries.map((summary) => summary.effect);
|
|
450
|
-
const writes = uniqueStrings(effects.
|
|
670
|
+
const writes = uniqueStrings(effects.flatMap(effectWriteVars));
|
|
451
671
|
const suffix = writes.map((id) => stateNameForVar(id, setters) ?? safeId(id)).join("_") || "callback";
|
|
452
672
|
return [{
|
|
453
673
|
id: `${component}.${name}.${suffix}`,
|
|
@@ -504,7 +724,7 @@ function handlerSchedulesModeledTimer(attribute, handlers, setters) {
|
|
|
504
724
|
visit(handler.body);
|
|
505
725
|
return found;
|
|
506
726
|
}
|
|
507
|
-
function transitionsFromJsxAttribute(source, fileName, node, setters, handlers, component, effectApis, asyncOutcomes, sourcePlugins, routerPlugin, disabledGuard, warnings) {
|
|
727
|
+
function transitionsFromJsxAttribute(source, fileName, node, setters, handlers, component, effectApis, asyncOutcomes, sourcePlugins, routerPlugin, disabledGuard, routePatterns, contextBindings, warnings) {
|
|
508
728
|
if (!node.initializer)
|
|
509
729
|
return [];
|
|
510
730
|
const expression = ts.isJsxExpression(node.initializer) ? node.initializer.expression : undefined;
|
|
@@ -515,7 +735,7 @@ function transitionsFromJsxAttribute(source, fileName, node, setters, handlers,
|
|
|
515
735
|
return [];
|
|
516
736
|
const attr = node.name.text;
|
|
517
737
|
const locator = locatorForEventAttribute(node);
|
|
518
|
-
return tagStableIdKey(transitionsFromResolvedHandler(source, fileName, node, attr, handler, setters, handlers, component, effectApis, asyncOutcomes, sourcePlugins, routerPlugin, disabledGuard, locator, warnings), handler);
|
|
738
|
+
return tagStableIdKey(transitionsFromResolvedHandler(source, fileName, node, attr, handler, setters, handlers, component, effectApis, asyncOutcomes, sourcePlugins, routerPlugin, disabledGuard, locator, routePatterns, contextBindings, warnings), handler);
|
|
519
739
|
}
|
|
520
740
|
function transitionsFromComponentPropAttribute(source, fileName, node, setters, handlers, components, component, effectApis, asyncOutcomes, sourcePlugins, routerPlugin, warnings) {
|
|
521
741
|
if (!node.initializer || !ts.isIdentifier(node.name))
|
|
@@ -526,7 +746,7 @@ function transitionsFromComponentPropAttribute(source, fileName, node, setters,
|
|
|
526
746
|
const callee = components.get(tag);
|
|
527
747
|
if (!callee)
|
|
528
748
|
return [];
|
|
529
|
-
const trigger = componentPropTrigger(source, callee, node.name.text, setters, warnings);
|
|
749
|
+
const trigger = componentPropTrigger(source, callee, node.name.text, setters, warnings) ?? transparentComponentPropTrigger(callee, node.name.text);
|
|
530
750
|
if (!trigger)
|
|
531
751
|
return [];
|
|
532
752
|
const expression = ts.isJsxExpression(node.initializer) ? node.initializer.expression : undefined;
|
|
@@ -538,7 +758,7 @@ function transitionsFromComponentPropAttribute(source, fileName, node, setters,
|
|
|
538
758
|
renderGuardFor(node, setters, warnings, source, component, guardLocals),
|
|
539
759
|
disabledGuardFor(node, setters, warnings, source, component, guardLocals)
|
|
540
760
|
]);
|
|
541
|
-
return tagStableIdKey(transitionsFromResolvedHandler(source, fileName, node, trigger.attr, handler, setters, handlers, component, effectApis, asyncOutcomes, sourcePlugins, routerPlugin, combineParsedGuards([trigger.guard, callerGuard]), trigger.locator, warnings), handler);
|
|
761
|
+
return tagStableIdKey(transitionsFromResolvedHandler(source, fileName, node, trigger.attr, handler, setters, handlers, component, effectApis, asyncOutcomes, sourcePlugins, routerPlugin, combineParsedGuards([trigger.guard, callerGuard]), trigger.locator, [], emptyContextBindings(), warnings), handler);
|
|
542
762
|
}
|
|
543
763
|
function transitionsFromBoundedListAttribute(source, fileName, node, setters, handlers, component, listInfo) {
|
|
544
764
|
if (!node.initializer || !ts.isIdentifier(node.name))
|
|
@@ -598,8 +818,8 @@ function normalizedAstKey(node) {
|
|
|
598
818
|
.replace(/\/\/.*$/gm, "")
|
|
599
819
|
.replace(/\s+/g, "");
|
|
600
820
|
}
|
|
601
|
-
function transitionsFromResolvedHandler(source, fileName, node, attr, handler, setters, handlers, component, effectApis, asyncOutcomes, sourcePlugins, routerPlugin, disabledGuard, locator, warnings) {
|
|
602
|
-
const asyncTransitions = transitionsFromAsyncHandler(source, fileName, attr, handler, setters, component, effectApis, asyncOutcomes, locator, warnings);
|
|
821
|
+
function transitionsFromResolvedHandler(source, fileName, node, attr, handler, setters, handlers, component, effectApis, asyncOutcomes, sourcePlugins, routerPlugin, disabledGuard, locator, routePatterns, contextBindings, warnings) {
|
|
822
|
+
const asyncTransitions = transitionsFromAsyncHandler(source, fileName, attr, handler, setters, component, effectApis, asyncOutcomes, locator, routePatterns, warnings);
|
|
603
823
|
if (asyncTransitions.length > 0)
|
|
604
824
|
return applyParsedGuard(asyncTransitions, disabledGuard);
|
|
605
825
|
const conditionalTransition = conditionalTransitionFromHandler(source, fileName, node, attr, handler, setters, component, locator);
|
|
@@ -611,13 +831,13 @@ function transitionsFromResolvedHandler(source, fileName, node, attr, handler, s
|
|
|
611
831
|
const sequentialTransition = sequentialTransitionFromHandler(source, fileName, node, attr, handler, setters, handlers, component, locator);
|
|
612
832
|
if (sequentialTransition)
|
|
613
833
|
return applyParsedGuard([sequentialTransition], disabledGuard);
|
|
614
|
-
const summary = callSummaryFromHandler(handler, setters);
|
|
834
|
+
const summary = callSummaryFromHandler(handler, setters, componentScopeLocalsFor(node, setters, contextBindings));
|
|
615
835
|
if (!summary)
|
|
616
836
|
return [];
|
|
617
837
|
const inlined = inlinedHelperCall(summary.call, handlers, setters);
|
|
618
838
|
const inlinedCall = inlined?.call ?? summary.call;
|
|
619
839
|
const locals = inlined?.locals ?? summary.locals;
|
|
620
|
-
const navigation = navigationTransition(source, fileName, node, attr, component, inlinedCall, locator, routerPlugin);
|
|
840
|
+
const navigation = navigationTransition(source, fileName, node, attr, component, inlinedCall, locator, routerPlugin, routePatterns);
|
|
621
841
|
if (navigation)
|
|
622
842
|
return applyParsedGuard([navigation], disabledGuard);
|
|
623
843
|
const pluginWrite = pluginWriteTransition(source, fileName, node, attr, component, inlinedCall, setters, locals, sourcePlugins, locator);
|
|
@@ -626,6 +846,9 @@ function transitionsFromResolvedHandler(source, fileName, node, attr, handler, s
|
|
|
626
846
|
const swrMutate = swrMutateTransition(source, fileName, node, attr, component, inlinedCall, locator);
|
|
627
847
|
if (swrMutate)
|
|
628
848
|
return applyParsedGuard([swrMutate], disabledGuard);
|
|
849
|
+
const noop = noopCallTransition(source, fileName, node, attr, component, inlinedCall, locator);
|
|
850
|
+
if (noop)
|
|
851
|
+
return applyParsedGuard([noop], disabledGuard);
|
|
629
852
|
const setterCall = setterCallFrom(inlinedCall, setters);
|
|
630
853
|
if (!setterCall) {
|
|
631
854
|
const escaped = escapedSetters(inlinedCall, setters, locals);
|
|
@@ -674,7 +897,7 @@ function sequentialTransitionFromHandler(source, fileName, node, attr, handler,
|
|
|
674
897
|
if (summaries.length <= 1)
|
|
675
898
|
return undefined;
|
|
676
899
|
const effects = summaries.map((summary) => summary.effect);
|
|
677
|
-
const writes = uniqueStrings(effects.
|
|
900
|
+
const writes = uniqueStrings(effects.flatMap(effectWriteVars));
|
|
678
901
|
return {
|
|
679
902
|
id: `${component}.${attr}.${writes.map((id) => stateNameForVar(id, setters) ?? safeId(id)).join("_")}.seq`,
|
|
680
903
|
cls: "user",
|
|
@@ -1055,6 +1278,28 @@ function componentGuardLocalsFor(attribute, setters) {
|
|
|
1055
1278
|
}
|
|
1056
1279
|
return locals;
|
|
1057
1280
|
}
|
|
1281
|
+
function componentScopeLocalsFor(attribute, setters, contextBindings) {
|
|
1282
|
+
const body = enclosingFunctionBody(attribute);
|
|
1283
|
+
if (!body)
|
|
1284
|
+
return new Map();
|
|
1285
|
+
const locals = new Map();
|
|
1286
|
+
for (const statement of body.statements) {
|
|
1287
|
+
if (statement.pos > attribute.pos)
|
|
1288
|
+
break;
|
|
1289
|
+
if (ts.isReturnStatement(statement))
|
|
1290
|
+
break;
|
|
1291
|
+
bindConstStatement(statement, setters, locals, true);
|
|
1292
|
+
for (const declaration of variableDeclarations(statement)) {
|
|
1293
|
+
bindContextHookObjectDeclaration(declaration, contextBindings, setters);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
return locals;
|
|
1297
|
+
}
|
|
1298
|
+
function variableDeclarations(node) {
|
|
1299
|
+
if (!ts.isVariableStatement(node))
|
|
1300
|
+
return [];
|
|
1301
|
+
return [...node.declarationList.declarations];
|
|
1302
|
+
}
|
|
1058
1303
|
function enclosingFunctionBody(node) {
|
|
1059
1304
|
let current = node;
|
|
1060
1305
|
while (current) {
|
|
@@ -1161,6 +1406,25 @@ function swrMutateTransition(source, fileName, node, attr, component, call, loca
|
|
|
1161
1406
|
confidence: "exact"
|
|
1162
1407
|
};
|
|
1163
1408
|
}
|
|
1409
|
+
function noopCallTransition(source, fileName, node, attr, component, call, locator) {
|
|
1410
|
+
const name = callName(call.expression) ?? call.expression.getText(source);
|
|
1411
|
+
if (!isKnownPureUiCall(name))
|
|
1412
|
+
return undefined;
|
|
1413
|
+
return {
|
|
1414
|
+
id: `${component}.${attr}.${safeId(name)}.noop`,
|
|
1415
|
+
cls: "user",
|
|
1416
|
+
label: labelForEvent(attr, locator),
|
|
1417
|
+
source: [{ file: fileName, ...lineAndColumn(source, node) }],
|
|
1418
|
+
guard: { kind: "lit", value: true },
|
|
1419
|
+
effect: { kind: "seq", effects: [] },
|
|
1420
|
+
reads: [],
|
|
1421
|
+
writes: [],
|
|
1422
|
+
confidence: "exact"
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
function isKnownPureUiCall(name) {
|
|
1426
|
+
return name.endsWith(".click") || name === "confirm" || name === "navigator.clipboard.writeText" || name.endsWith(".writeText");
|
|
1427
|
+
}
|
|
1164
1428
|
function bindConstStatement(statement, setters, locals, partialBoolean = false) {
|
|
1165
1429
|
if (!ts.isVariableStatement(statement))
|
|
1166
1430
|
return false;
|
|
@@ -1184,8 +1448,8 @@ function inlinedHelperCall(call, handlers, setters) {
|
|
|
1184
1448
|
const helper = handlers.get(call.expression.text);
|
|
1185
1449
|
return helper ? callSummaryFromHandler(helper, setters) : undefined;
|
|
1186
1450
|
}
|
|
1187
|
-
function navigationTransition(source, fileName, node, attr, component, call, locator, routerPlugin) {
|
|
1188
|
-
const navigation = navigationCall(call, routerPlugin);
|
|
1451
|
+
function navigationTransition(source, fileName, node, attr, component, call, locator, routerPlugin, routePatterns = []) {
|
|
1452
|
+
const navigation = navigationCall(call, routerPlugin, routePatterns);
|
|
1189
1453
|
if (!navigation)
|
|
1190
1454
|
return undefined;
|
|
1191
1455
|
const routeId = navigation.to ? safeId(navigation.to) : "back";
|
|
@@ -1209,7 +1473,7 @@ function navigationTransition(source, fileName, node, attr, component, call, loc
|
|
|
1209
1473
|
confidence: "exact"
|
|
1210
1474
|
};
|
|
1211
1475
|
}
|
|
1212
|
-
function navigationCall(call, routerPlugin) {
|
|
1476
|
+
function navigationCall(call, routerPlugin, routePatterns = []) {
|
|
1213
1477
|
const name = callName(call.expression);
|
|
1214
1478
|
if (!name)
|
|
1215
1479
|
return undefined;
|
|
@@ -1217,11 +1481,11 @@ function navigationCall(call, routerPlugin) {
|
|
|
1217
1481
|
if (pluginNavigation && pluginNavigation !== "unsupported")
|
|
1218
1482
|
return pluginNavigation;
|
|
1219
1483
|
if (name === "navigate" && call.arguments.length === 1) {
|
|
1220
|
-
const to =
|
|
1484
|
+
const to = routeTargetValue(call.arguments[0], routePatterns);
|
|
1221
1485
|
return typeof to === "string" ? { mode: "push", to } : undefined;
|
|
1222
1486
|
}
|
|
1223
1487
|
if ((name.endsWith(".push") || name.endsWith(".replace")) && call.arguments.length === 1) {
|
|
1224
|
-
const to =
|
|
1488
|
+
const to = routeTargetValue(call.arguments[0], routePatterns);
|
|
1225
1489
|
if (typeof to !== "string")
|
|
1226
1490
|
return undefined;
|
|
1227
1491
|
return { mode: name.endsWith(".replace") ? "replace" : "push", to };
|
|
@@ -1231,6 +1495,68 @@ function navigationCall(call, routerPlugin) {
|
|
|
1231
1495
|
}
|
|
1232
1496
|
return undefined;
|
|
1233
1497
|
}
|
|
1498
|
+
function linkNavigationTransition(source, fileName, node, component, routePatterns) {
|
|
1499
|
+
if ((!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) || node.tagName.getText(source) !== "Link")
|
|
1500
|
+
return undefined;
|
|
1501
|
+
const toAttr = node.attributes.properties.find((property) => ts.isJsxAttribute(property) && ts.isIdentifier(property.name) && property.name.text === "to");
|
|
1502
|
+
if (!toAttr)
|
|
1503
|
+
return undefined;
|
|
1504
|
+
const to = jsxRouteTarget(toAttr, routePatterns);
|
|
1505
|
+
if (!to)
|
|
1506
|
+
return undefined;
|
|
1507
|
+
return {
|
|
1508
|
+
id: `${component}.Link.navigate.${safeId(to)}`,
|
|
1509
|
+
cls: "nav",
|
|
1510
|
+
label: { kind: "navigate", mode: "push", to },
|
|
1511
|
+
source: [{ file: fileName, ...lineAndColumn(source, toAttr) }],
|
|
1512
|
+
guard: { kind: "lit", value: true },
|
|
1513
|
+
effect: { kind: "navigate", mode: "push", to: { kind: "lit", value: to } },
|
|
1514
|
+
reads: ["sys:route", "sys:history"],
|
|
1515
|
+
writes: ["sys:route", "sys:history"],
|
|
1516
|
+
confidence: "exact"
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
function jsxRouteTarget(attribute, routePatterns) {
|
|
1520
|
+
if (!attribute.initializer)
|
|
1521
|
+
return undefined;
|
|
1522
|
+
if (ts.isStringLiteral(attribute.initializer))
|
|
1523
|
+
return normalizeRouteTarget(attribute.initializer.text, routePatterns);
|
|
1524
|
+
if (!ts.isJsxExpression(attribute.initializer) || !attribute.initializer.expression)
|
|
1525
|
+
return undefined;
|
|
1526
|
+
return routeTargetValue(attribute.initializer.expression, routePatterns);
|
|
1527
|
+
}
|
|
1528
|
+
function routeTargetValue(expression, routePatterns) {
|
|
1529
|
+
if (!expression)
|
|
1530
|
+
return undefined;
|
|
1531
|
+
const literal = literalValue(expression);
|
|
1532
|
+
if (typeof literal === "string")
|
|
1533
|
+
return normalizeRouteTarget(literal, routePatterns);
|
|
1534
|
+
if (ts.isNoSubstitutionTemplateLiteral(expression))
|
|
1535
|
+
return normalizeRouteTarget(expression.text, routePatterns);
|
|
1536
|
+
if (ts.isTemplateExpression(expression)) {
|
|
1537
|
+
const pattern = templateRoutePattern(expression);
|
|
1538
|
+
return pattern ? normalizeRouteTarget(pattern, routePatterns) : undefined;
|
|
1539
|
+
}
|
|
1540
|
+
return undefined;
|
|
1541
|
+
}
|
|
1542
|
+
function templateRoutePattern(expression) {
|
|
1543
|
+
let value = expression.head.text;
|
|
1544
|
+
for (const span of expression.templateSpans)
|
|
1545
|
+
value += ":param" + span.literal.text;
|
|
1546
|
+
return value;
|
|
1547
|
+
}
|
|
1548
|
+
function normalizeRouteTarget(target, routePatterns) {
|
|
1549
|
+
const slash = target.startsWith("/") ? target : `/${target}`;
|
|
1550
|
+
const matched = routePatterns.find((pattern) => routePatternMatches(pattern, slash));
|
|
1551
|
+
return matched ?? slash.replace(/\/:param(?=\/|$)/g, "/:id");
|
|
1552
|
+
}
|
|
1553
|
+
function routePatternMatches(pattern, target) {
|
|
1554
|
+
const left = pattern.replace(/^\/+/, "").split("/");
|
|
1555
|
+
const right = target.replace(/^\/+/, "").split("/");
|
|
1556
|
+
if (left.length !== right.length)
|
|
1557
|
+
return false;
|
|
1558
|
+
return left.every((part, index) => part.startsWith(":") || part === "*" || part === right[index] || right[index] === ":param");
|
|
1559
|
+
}
|
|
1234
1560
|
function escapedSetters(call, setters, locals = new Map()) {
|
|
1235
1561
|
return call.arguments
|
|
1236
1562
|
.filter(ts.isIdentifier)
|
|
@@ -1562,6 +1888,25 @@ function componentPropTrigger(source, component, propName, setters, warnings) {
|
|
|
1562
1888
|
visit(component);
|
|
1563
1889
|
return trigger;
|
|
1564
1890
|
}
|
|
1891
|
+
function transparentComponentPropTrigger(component, propName) {
|
|
1892
|
+
if (!isForwardablePropName(propName) || !componentSpreadsPropsToElement(component))
|
|
1893
|
+
return undefined;
|
|
1894
|
+
return { attr: propName };
|
|
1895
|
+
}
|
|
1896
|
+
function componentSpreadsPropsToElement(component) {
|
|
1897
|
+
let found = false;
|
|
1898
|
+
const visit = (node) => {
|
|
1899
|
+
if (found)
|
|
1900
|
+
return;
|
|
1901
|
+
if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && node.attributes.properties.some(ts.isJsxSpreadAttribute)) {
|
|
1902
|
+
found = true;
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
ts.forEachChild(node, visit);
|
|
1906
|
+
};
|
|
1907
|
+
visit(component);
|
|
1908
|
+
return found;
|
|
1909
|
+
}
|
|
1565
1910
|
function forwardsComponentProp(node, handlers, component) {
|
|
1566
1911
|
if (!component || !node.initializer)
|
|
1567
1912
|
return false;
|
|
@@ -1709,7 +2054,7 @@ function jsxTagName(attribute) {
|
|
|
1709
2054
|
return undefined;
|
|
1710
2055
|
return ts.isIdentifier(parent.tagName) ? parent.tagName.text : undefined;
|
|
1711
2056
|
}
|
|
1712
|
-
function transitionsFromAsyncHandler(source, fileName, attr, expression, setters, component, effectApis, asyncOutcomes, locator, warnings) {
|
|
2057
|
+
function transitionsFromAsyncHandler(source, fileName, attr, expression, setters, component, effectApis, asyncOutcomes, locator, routePatterns, warnings) {
|
|
1713
2058
|
if (!ts.isBlock(expression.body))
|
|
1714
2059
|
return [];
|
|
1715
2060
|
const statements = expression.body.statements;
|
|
@@ -1739,14 +2084,16 @@ function transitionsFromAsyncHandler(source, fileName, attr, expression, setters
|
|
|
1739
2084
|
const successSummaries = summarizeAsyncSegment(successStatements, setters);
|
|
1740
2085
|
const catchSummaries = tryStatement?.catchClause ? summarizeAsyncSegment(tryStatement.catchClause.block.statements, setters) : [];
|
|
1741
2086
|
const preEffects = preSummaries.map((summary) => summary.effect);
|
|
1742
|
-
const
|
|
1743
|
-
const
|
|
2087
|
+
const finallySummaries = tryStatement?.finallyBlock ? summarizeAsyncSegment(tryStatement.finallyBlock.statements, setters) : [];
|
|
2088
|
+
const finallyEffects = finallySummaries.map((summary) => summary.effect);
|
|
2089
|
+
const successEffects = [...successSummaries.map((summary) => summary.effect), ...finallyEffects];
|
|
2090
|
+
const catchEffects = [...catchSummaries.map((summary) => summary.effect), ...finallyEffects];
|
|
1744
2091
|
const preReads = uniqueStrings([...preSummaries.flatMap((summary) => summary.reads), ...opArgs.reads]);
|
|
1745
|
-
const successReads = uniqueStrings(successSummaries.flatMap((summary) => summary.reads));
|
|
1746
|
-
const catchReads = uniqueStrings(catchSummaries.flatMap((summary) => summary.reads));
|
|
2092
|
+
const successReads = uniqueStrings([...successSummaries.flatMap((summary) => summary.reads), ...finallySummaries.flatMap((summary) => summary.reads)]);
|
|
2093
|
+
const catchReads = uniqueStrings([...catchSummaries.flatMap((summary) => summary.reads), ...finallySummaries.flatMap((summary) => summary.reads)]);
|
|
1747
2094
|
if (successEffects.length === 0 && catchEffects.length === 0)
|
|
1748
2095
|
return [];
|
|
1749
|
-
const writes =
|
|
2096
|
+
const writes = uniqueStrings([...preEffects, ...successEffects, ...catchEffects].flatMap(effectWriteVars));
|
|
1750
2097
|
const baseId = `${component}.${attr}.${op}`;
|
|
1751
2098
|
for (const read of uniqueStrings([...successReads, ...catchReads])) {
|
|
1752
2099
|
warnings.push({ message: `Stale-read risk ${baseId}:${read}`, ...lineAndColumn(source, awaitStatement) });
|
|
@@ -1760,7 +2107,7 @@ function transitionsFromAsyncHandler(source, fileName, attr, expression, setters
|
|
|
1760
2107
|
guard: { kind: "lit", value: true },
|
|
1761
2108
|
effect: { kind: "seq", effects: [...preEffects, { kind: "enqueue", op, continuation: `${baseId}.cont`, args: opArgs.args }] },
|
|
1762
2109
|
reads: preReads,
|
|
1763
|
-
writes:
|
|
2110
|
+
writes: uniqueStrings([...preEffects.flatMap(effectWriteVars), "sys:pending"]),
|
|
1764
2111
|
confidence: confidenceForEffects(preEffects)
|
|
1765
2112
|
};
|
|
1766
2113
|
const success = {
|
|
@@ -1771,12 +2118,13 @@ function transitionsFromAsyncHandler(source, fileName, attr, expression, setters
|
|
|
1771
2118
|
guard: pendingIs(op),
|
|
1772
2119
|
effect: { kind: "seq", effects: [{ kind: "dequeue", index: 0 }, ...successEffects] },
|
|
1773
2120
|
reads: uniqueStrings(["sys:pending", ...successReads]),
|
|
1774
|
-
writes: ["sys:pending", ...successEffects.
|
|
2121
|
+
writes: [...new Set(["sys:pending", ...successEffects.flatMap(effectWriteVars)])],
|
|
1775
2122
|
confidence: confidenceForEffects(successEffects)
|
|
1776
2123
|
};
|
|
1777
|
-
const
|
|
2124
|
+
const successNavigate = firstNavigationInStatements(successStatements, routePatterns);
|
|
2125
|
+
const transitions = [enqueue, successNavigate ? appendEffect(success, navigationEffect(successNavigate)) : success];
|
|
1778
2126
|
if (catchEffects.length > 0 || asyncOutcomes[op]?.error !== undefined) {
|
|
1779
|
-
|
|
2127
|
+
const errorTransition = {
|
|
1780
2128
|
id: `${baseId}.error`,
|
|
1781
2129
|
cls: "env",
|
|
1782
2130
|
label: { kind: "resolve", op, outcome: "error" },
|
|
@@ -1784,9 +2132,10 @@ function transitionsFromAsyncHandler(source, fileName, attr, expression, setters
|
|
|
1784
2132
|
guard: pendingIs(op),
|
|
1785
2133
|
effect: { kind: "seq", effects: [{ kind: "dequeue", index: 0 }, ...catchEffects] },
|
|
1786
2134
|
reads: uniqueStrings(["sys:pending", ...catchReads]),
|
|
1787
|
-
writes: ["sys:pending", ...catchEffects.
|
|
2135
|
+
writes: [...new Set(["sys:pending", ...catchEffects.flatMap(effectWriteVars)])],
|
|
1788
2136
|
confidence: confidenceForEffects(catchEffects)
|
|
1789
|
-
}
|
|
2137
|
+
};
|
|
2138
|
+
transitions.push(errorTransition);
|
|
1790
2139
|
}
|
|
1791
2140
|
else {
|
|
1792
2141
|
warnings.push({ message: `Unhandled rejection ${baseId}`, ...lineAndColumn(source, awaitStatement) });
|
|
@@ -1838,7 +2187,7 @@ function transitionsFromSequentialAwait(source, fileName, attr, expression, firs
|
|
|
1838
2187
|
guard: promiseAllGuard(promiseAllOps),
|
|
1839
2188
|
effect: { kind: "seq", effects: [...promiseAllOps.map((_, index) => ({ kind: "dequeue", index })).reverse(), ...tailEffects] },
|
|
1840
2189
|
reads: uniqueStrings(["sys:pending", ...tailReads]),
|
|
1841
|
-
writes: uniqueStrings(["sys:pending", ...tailEffects.
|
|
2190
|
+
writes: uniqueStrings(["sys:pending", ...tailEffects.flatMap(effectWriteVars)]),
|
|
1842
2191
|
confidence: confidenceForEffects(tailEffects)
|
|
1843
2192
|
}
|
|
1844
2193
|
: {
|
|
@@ -1849,7 +2198,7 @@ function transitionsFromSequentialAwait(source, fileName, attr, expression, firs
|
|
|
1849
2198
|
guard: pendingIs(secondOp),
|
|
1850
2199
|
effect: { kind: "seq", effects: [{ kind: "dequeue", index: 0 }, ...tailEffects] },
|
|
1851
2200
|
reads: uniqueStrings(["sys:pending", ...tailReads]),
|
|
1852
|
-
writes: uniqueStrings(["sys:pending", ...tailEffects.
|
|
2201
|
+
writes: uniqueStrings(["sys:pending", ...tailEffects.flatMap(effectWriteVars)]),
|
|
1853
2202
|
confidence: confidenceForEffects(tailEffects)
|
|
1854
2203
|
};
|
|
1855
2204
|
const transitions = [
|
|
@@ -1861,7 +2210,7 @@ function transitionsFromSequentialAwait(source, fileName, attr, expression, firs
|
|
|
1861
2210
|
guard: { kind: "lit", value: true },
|
|
1862
2211
|
effect: { kind: "seq", effects: [...preEffects, { kind: "enqueue", op: firstOp, continuation: `${firstBaseId}.cont`, args: {} }] },
|
|
1863
2212
|
reads: preReads,
|
|
1864
|
-
writes: uniqueStrings([...preEffects.
|
|
2213
|
+
writes: uniqueStrings([...preEffects.flatMap(effectWriteVars), "sys:pending"]),
|
|
1865
2214
|
confidence: confidenceForEffects(preEffects)
|
|
1866
2215
|
},
|
|
1867
2216
|
{
|
|
@@ -1872,7 +2221,7 @@ function transitionsFromSequentialAwait(source, fileName, attr, expression, firs
|
|
|
1872
2221
|
guard: pendingIs(firstOp),
|
|
1873
2222
|
effect: { kind: "seq", effects: [{ kind: "dequeue", index: 0 }, ...betweenEffects, ...secondEnqueueEffects] },
|
|
1874
2223
|
reads: uniqueStrings(["sys:pending", ...betweenReads]),
|
|
1875
|
-
writes: uniqueStrings(["sys:pending", ...betweenEffects.
|
|
2224
|
+
writes: uniqueStrings(["sys:pending", ...betweenEffects.flatMap(effectWriteVars)]),
|
|
1876
2225
|
confidence: confidenceForEffects(betweenEffects)
|
|
1877
2226
|
},
|
|
1878
2227
|
secondSuccess
|
|
@@ -1965,14 +2314,14 @@ function transitionsFromUseEffect(source, fileName, node, setters, component) {
|
|
|
1965
2314
|
const guards = assignEffects.map((effect) => ({ kind: "neq", args: [{ kind: "read", var: effect.var }, effect.expr] }));
|
|
1966
2315
|
const guard = guards.length > 0 ? guards.slice(1).reduce((acc, next) => andGuard(acc, next), guards[0]) : { kind: "lit", value: true };
|
|
1967
2316
|
transitions.push({
|
|
1968
|
-
id: `${component}.useEffect.${effects.map((
|
|
2317
|
+
id: `${component}.useEffect.${effects.flatMap(effectWriteVars).map((varId) => varId.split(".").at(-1) ?? varId).join("_")}`,
|
|
1969
2318
|
cls: "internal",
|
|
1970
2319
|
label: { kind: "internal", text: `${component}.useEffect` },
|
|
1971
2320
|
source: [{ file: fileName, ...lineAndColumn(source, node) }],
|
|
1972
2321
|
guard,
|
|
1973
2322
|
effect: effects.length === 1 ? effects[0] : { kind: "seq", effects },
|
|
1974
|
-
reads: uniqueStrings([...deps, ...effectReads, ...effects.
|
|
1975
|
-
writes:
|
|
2323
|
+
reads: uniqueStrings([...deps, ...effectReads, ...effects.flatMap(effectWriteVars)]),
|
|
2324
|
+
writes: uniqueStrings(effects.flatMap(effectWriteVars)),
|
|
1976
2325
|
confidence: effects.some((effect) => effect.kind === "havoc") ? "over-approx" : "exact",
|
|
1977
2326
|
triggeredBy: deps
|
|
1978
2327
|
});
|
|
@@ -1981,14 +2330,14 @@ function transitionsFromUseEffect(source, fileName, node, setters, component) {
|
|
|
1981
2330
|
const cleanupEffects = cleanup.map((summary) => summary.effect);
|
|
1982
2331
|
const cleanupReads = uniqueStrings(cleanup.flatMap((summary) => summary.reads));
|
|
1983
2332
|
transitions.push({
|
|
1984
|
-
id: `${component}.useEffect.cleanup.${cleanupEffects.map((
|
|
2333
|
+
id: `${component}.useEffect.cleanup.${cleanupEffects.flatMap(effectWriteVars).map((varId) => varId.split(".").at(-1) ?? varId).join("_")}`,
|
|
1985
2334
|
cls: "internal",
|
|
1986
2335
|
label: { kind: "internal", text: `${component}.useEffect.cleanup` },
|
|
1987
2336
|
source: [{ file: fileName, ...lineAndColumn(source, node) }],
|
|
1988
2337
|
guard: { kind: "lit", value: true },
|
|
1989
2338
|
effect: cleanupEffects.length === 1 ? cleanupEffects[0] : { kind: "seq", effects: cleanupEffects },
|
|
1990
2339
|
reads: cleanupReads,
|
|
1991
|
-
writes:
|
|
2340
|
+
writes: uniqueStrings(cleanupEffects.flatMap(effectWriteVars)),
|
|
1992
2341
|
confidence: "over-approx",
|
|
1993
2342
|
triggeredBy: deps
|
|
1994
2343
|
});
|
|
@@ -2415,7 +2764,7 @@ function containsAwaitedEffect(statements, effectApis) {
|
|
|
2415
2764
|
}
|
|
2416
2765
|
if (insideAwait && ts.isCallExpression(node)) {
|
|
2417
2766
|
const name = callName(node.expression);
|
|
2418
|
-
if (name && effectApis.has(name)) {
|
|
2767
|
+
if (name && effectApis.has(effectOpForCall(name, node))) {
|
|
2419
2768
|
found = true;
|
|
2420
2769
|
return;
|
|
2421
2770
|
}
|
|
@@ -2430,13 +2779,94 @@ function awaitedOp(statement, effectApis) {
|
|
|
2430
2779
|
return awaitedCall(statement, effectApis)?.op;
|
|
2431
2780
|
}
|
|
2432
2781
|
function awaitedCall(statement, effectApis) {
|
|
2433
|
-
|
|
2782
|
+
const awaitExpression = awaitedCallExpressionInStatement(statement);
|
|
2783
|
+
if (!awaitExpression)
|
|
2434
2784
|
return undefined;
|
|
2435
|
-
const
|
|
2436
|
-
if (!
|
|
2785
|
+
const name = callName(awaitExpression.expression);
|
|
2786
|
+
if (!name)
|
|
2787
|
+
return undefined;
|
|
2788
|
+
const op = effectOpForCall(name, awaitExpression);
|
|
2789
|
+
if (!effectApis.has(op))
|
|
2790
|
+
return undefined;
|
|
2791
|
+
return { op, call: awaitExpression };
|
|
2792
|
+
}
|
|
2793
|
+
function awaitedCallExpressionInStatement(statement) {
|
|
2794
|
+
if (ts.isExpressionStatement(statement) && ts.isAwaitExpression(statement.expression) && ts.isCallExpression(statement.expression.expression)) {
|
|
2795
|
+
return statement.expression.expression;
|
|
2796
|
+
}
|
|
2797
|
+
if (ts.isVariableStatement(statement)) {
|
|
2798
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
2799
|
+
if (declaration.initializer &&
|
|
2800
|
+
ts.isAwaitExpression(declaration.initializer) &&
|
|
2801
|
+
ts.isCallExpression(declaration.initializer.expression) &&
|
|
2802
|
+
callName(declaration.initializer.expression.expression) === "fetch") {
|
|
2803
|
+
return declaration.initializer.expression;
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
return undefined;
|
|
2808
|
+
}
|
|
2809
|
+
function effectOpForCall(name, call) {
|
|
2810
|
+
if (name !== "fetch")
|
|
2811
|
+
return name;
|
|
2812
|
+
const url = fetchUrl(call.arguments[0]);
|
|
2813
|
+
const method = fetchMethod(call.arguments[1]) ?? "GET";
|
|
2814
|
+
return url ? `${method} ${url}` : "fetch";
|
|
2815
|
+
}
|
|
2816
|
+
function fetchUrl(argument) {
|
|
2817
|
+
if (!argument)
|
|
2818
|
+
return undefined;
|
|
2819
|
+
if (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
|
|
2820
|
+
return normalizeFetchUrl(argument.text);
|
|
2821
|
+
if (ts.isTemplateExpression(argument)) {
|
|
2822
|
+
const pattern = templateRoutePattern(argument);
|
|
2823
|
+
return pattern ? normalizeFetchUrl(pattern.replace(/\/:param(?=\/|$)/g, "/:id")) : undefined;
|
|
2824
|
+
}
|
|
2825
|
+
return undefined;
|
|
2826
|
+
}
|
|
2827
|
+
function normalizeFetchUrl(value) {
|
|
2828
|
+
return value.startsWith("/") ? value : `/${value}`;
|
|
2829
|
+
}
|
|
2830
|
+
function fetchMethod(argument) {
|
|
2831
|
+
if (!argument || !ts.isObjectLiteralExpression(argument))
|
|
2437
2832
|
return undefined;
|
|
2438
|
-
const
|
|
2439
|
-
|
|
2833
|
+
const method = argument.properties.find((property) => ts.isPropertyAssignment(property) && propertyName(property.name) === "method");
|
|
2834
|
+
const value = method ? literalValue(method.initializer) : undefined;
|
|
2835
|
+
return typeof value === "string" ? value.toUpperCase() : undefined;
|
|
2836
|
+
}
|
|
2837
|
+
function firstNavigationInStatements(statements, routePatterns) {
|
|
2838
|
+
for (const statement of statements) {
|
|
2839
|
+
let found;
|
|
2840
|
+
const visit = (node) => {
|
|
2841
|
+
if (found)
|
|
2842
|
+
return;
|
|
2843
|
+
if (ts.isCallExpression(node))
|
|
2844
|
+
found = navigationCall(node, undefined, routePatterns);
|
|
2845
|
+
ts.forEachChild(node, visit);
|
|
2846
|
+
};
|
|
2847
|
+
visit(statement);
|
|
2848
|
+
if (found)
|
|
2849
|
+
return found;
|
|
2850
|
+
}
|
|
2851
|
+
return undefined;
|
|
2852
|
+
}
|
|
2853
|
+
function navigationEffect(navigation) {
|
|
2854
|
+
return {
|
|
2855
|
+
kind: "navigate",
|
|
2856
|
+
mode: navigation.mode,
|
|
2857
|
+
...(navigation.to ? { to: { kind: "lit", value: navigation.to } } : {})
|
|
2858
|
+
};
|
|
2859
|
+
}
|
|
2860
|
+
function appendEffect(transition, effect) {
|
|
2861
|
+
const current = transition.effect.kind === "seq" ? transition.effect.effects : [transition.effect];
|
|
2862
|
+
const writes = uniqueStrings([...transition.writes, ...effectWriteVars(effect)]);
|
|
2863
|
+
const reads = uniqueStrings([...transition.reads, ...(effect.kind === "navigate" ? ["sys:route", "sys:history"] : [])]);
|
|
2864
|
+
return {
|
|
2865
|
+
...transition,
|
|
2866
|
+
effect: { kind: "seq", effects: [...current, effect] },
|
|
2867
|
+
reads,
|
|
2868
|
+
writes
|
|
2869
|
+
};
|
|
2440
2870
|
}
|
|
2441
2871
|
function effectCallArgs(call, setters, locals) {
|
|
2442
2872
|
const first = call.arguments[0];
|
|
@@ -2485,7 +2915,7 @@ function promiseAllAwaitOps(statement, effectApis) {
|
|
|
2485
2915
|
function callName(expression) {
|
|
2486
2916
|
if (ts.isIdentifier(expression))
|
|
2487
2917
|
return expression.text;
|
|
2488
|
-
if (ts.isPropertyAccessExpression(expression))
|
|
2918
|
+
if (ts.isPropertyAccessExpression(expression) || ts.isPropertyAccessChain(expression))
|
|
2489
2919
|
return `${callName(expression.expression) ?? expression.expression.getText()}.${expression.name.text}`;
|
|
2490
2920
|
return undefined;
|
|
2491
2921
|
}
|
|
@@ -2502,6 +2932,17 @@ function componentNameFor(node) {
|
|
|
2502
2932
|
return node.name.text;
|
|
2503
2933
|
return undefined;
|
|
2504
2934
|
}
|
|
2935
|
+
function providerComponentNames(source) {
|
|
2936
|
+
const names = new Set();
|
|
2937
|
+
const visit = (node) => {
|
|
2938
|
+
const name = componentNameFor(node);
|
|
2939
|
+
if (name && node.getText(source).includes(".Provider"))
|
|
2940
|
+
names.add(name);
|
|
2941
|
+
ts.forEachChild(node, visit);
|
|
2942
|
+
};
|
|
2943
|
+
visit(source);
|
|
2944
|
+
return names;
|
|
2945
|
+
}
|
|
2505
2946
|
function startsUppercase(value) {
|
|
2506
2947
|
return /^[A-Z]/.test(value);
|
|
2507
2948
|
}
|