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.
@@ -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.set(channel.symbolName, setterBindingFromDecl(decl));
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.set(setterName.name.text, { varId, component, stateName: stateName.name.text, domain });
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 refTaint = refSetterTaint(node, setters);
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, setters, nextComponent ?? "Anonymous"));
135
- for (const timerTaint of timerSetterTaints(node, setters)) {
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, setters, handlers, components, nextComponent ?? "Anonymous", effectApis, options.asyncOutcomes ?? {}, sourcePlugins, routerPlugin, warnings);
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, setters, handlers, nextComponent ?? "Anonymous", {
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, setters);
388
+ const guardLocals = componentGuardLocalsFor(node, scopedSetters);
169
389
  const guard = combineParsedGuards([
170
- renderGuardFor(node, setters, warnings, source, nextComponent ?? "Anonymous", guardLocals),
171
- disabledGuardFor(node, setters, warnings, source, nextComponent ?? "Anonymous", guardLocals)
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, setters, handlers, nextComponent ?? "Anonymous", effectApis, options.asyncOutcomes ?? {}, sourcePlugins, routerPlugin, guard, warnings);
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, setters)) {
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, setters, nextComponent ?? "Anonymous");
400
+ const extracted = transitionsFromUseEffect(source, fileName, node, scopedSetters, nextComponent ?? "Anonymous");
181
401
  transitions.push(...extracted);
182
- if (extracted.length === 0 && useEffectWritesModeledState(node, setters)) {
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.map((effect) => effect.var));
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.map((effect) => effect.var));
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 = literalValue(call.arguments[0]);
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 = literalValue(call.arguments[0]);
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 successEffects = successSummaries.map((summary) => summary.effect);
1743
- const catchEffects = catchSummaries.map((summary) => summary.effect);
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 = [...new Set([...preEffects, ...successEffects, ...catchEffects].map((effect) => effect.var))];
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: [...new Set([...preEffects.map((effect) => effect.var), "sys:pending"])],
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.map((effect) => effect.var)],
2121
+ writes: [...new Set(["sys:pending", ...successEffects.flatMap(effectWriteVars)])],
1775
2122
  confidence: confidenceForEffects(successEffects)
1776
2123
  };
1777
- const transitions = [enqueue, success];
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
- transitions.push({
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.map((effect) => effect.var)],
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.map((effect) => effect.var)]),
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.map((effect) => effect.var)]),
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.map((effect) => effect.var), "sys:pending"]),
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.map((effect) => effect.var)]),
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((effect) => effect.var.split(".").at(-1) ?? effect.var).join("_")}`,
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.map((effect) => effect.var)]),
1975
- writes: [...new Set(effects.map((effect) => effect.var))],
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((effect) => effect.var.split(".").at(-1) ?? effect.var).join("_")}`,
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: [...new Set(cleanupEffects.map((effect) => effect.var))],
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
- if (!ts.isExpressionStatement(statement))
2782
+ const awaitExpression = awaitedCallExpressionInStatement(statement);
2783
+ if (!awaitExpression)
2434
2784
  return undefined;
2435
- const expression = statement.expression;
2436
- if (!ts.isAwaitExpression(expression) || !ts.isCallExpression(expression.expression))
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 name = callName(expression.expression.expression);
2439
- return name && effectApis.has(name) ? { op: name, call: expression.expression } : undefined;
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
  }