@ethlete/query 6.0.0-next.61 → 6.0.0-next.63

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.
Files changed (28) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/fesm2022/ethlete-query-devtools-contract.mjs +1 -1
  3. package/fesm2022/ethlete-query-ngrx-toolkit.mjs +30 -7
  4. package/fesm2022/ethlete-query-ngrx-toolkit.mjs.map +1 -1
  5. package/fesm2022/ethlete-query.mjs +183 -43
  6. package/fesm2022/ethlete-query.mjs.map +1 -1
  7. package/generators/migrate-from-ngrx-toolkit/args-assignability.js +55 -8
  8. package/generators/migrate-from-ngrx-toolkit/consumers.js +133 -37
  9. package/generators/migrate-from-ngrx-toolkit/creators.js +8 -4
  10. package/generators/migrate-from-ngrx-toolkit/feature.js +31 -4
  11. package/generators/migrate-from-ngrx-toolkit/migration.js +3 -1
  12. package/generators/migrate-from-ngrx-toolkit/module-resolution.js +23 -0
  13. package/generators/migrate-from-ngrx-toolkit/store-removal.js +64 -11
  14. package/generators/migrate-to-query-v3/auth-consumers-migration.js +64 -0
  15. package/generators/migrate-to-query-v3/auth-provider-scaffold.js +93 -1
  16. package/generators/migrate-to-query-v3/cleanup-migration.js +135 -15
  17. package/generators/migrate-to-query-v3/legacy-prepare-migration.js +275 -27
  18. package/generators/migrate-to-query-v3/legacy-query-creator-migration.js +171 -25
  19. package/generators/migrate-to-query-v3/migration.js +9 -3
  20. package/generators/migrate-to-query-v3/module-resolution.js +131 -0
  21. package/generators/migrate-to-query-v3/query-client-migration.js +82 -14
  22. package/generators/migrate-to-query-v3/rename-symbols.js +16 -11
  23. package/generators/move-devtools-contract-imports/moved-names.js +1 -0
  24. package/migrations.json +10 -0
  25. package/package.json +1 -1
  26. package/types/ethlete-query-devtools-contract.d.ts +2 -2
  27. package/types/ethlete-query-ngrx-toolkit.d.ts +13 -0
  28. package/types/ethlete-query.d.ts +40 -6
@@ -349,11 +349,37 @@ export const removeFeatureStores = (tree, graph, inScope, features, report) => {
349
349
  const content = tree.read(file, 'utf-8');
350
350
  return content ? createSourceFile(content, file).statements.flatMap(declaredNames) : [];
351
351
  };
352
- let converting = new Set(features);
352
+ const importClosure = (feature, roots) => {
353
+ const storeFiles = new Set(storeFilesOf(feature));
354
+ const closure = new Set(roots);
355
+ const queue = [...closure];
356
+ for (let file = queue.pop(); file; file = queue.pop()) {
357
+ const content = tree.read(file, 'utf-8');
358
+ if (!content)
359
+ continue;
360
+ for (const statement of createSourceFile(content, file).statements) {
361
+ if ((!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) ||
362
+ !statement.moduleSpecifier ||
363
+ !ts.isStringLiteral(statement.moduleSpecifier)) {
364
+ continue;
365
+ }
366
+ const resolved = graph.resolveFile(file, statement.moduleSpecifier.text);
367
+ if (resolved && storeFiles.has(resolved) && !closure.has(resolved)) {
368
+ closure.add(resolved);
369
+ queue.push(resolved);
370
+ }
371
+ }
372
+ }
373
+ return closure;
374
+ };
375
+ const converting = new Set(features);
353
376
  let scan;
354
377
  const keptLocations = new Map();
378
+ const keptFiles = new Map();
379
+ const keptVia = new Map();
380
+ const removableFilesOf = (feature) => storeFilesOf(feature).filter((file) => !keptFiles.has(file));
355
381
  for (;;) {
356
- const deletedFiles = new Set([...converting].flatMap(storeFilesOf));
382
+ const deletedFiles = new Set([...converting].flatMap(removableFilesOf));
357
383
  const movedFiles = new Map([...converting].map((feature) => [feature.files.actions, feature.queriesFile]));
358
384
  const needles = [
359
385
  ...new Set([...deletedFiles].flatMap((file) => [...declaredIn(file), file.split('/').pop().replace(/\.ts$/, '')])),
@@ -371,20 +397,34 @@ export const removeFeatureStores = (tree, graph, inScope, features, report) => {
371
397
  movedFiles,
372
398
  needles,
373
399
  });
374
- const blocked = new Set();
400
+ const usedOutside = new Map();
375
401
  for (const reference of scan.references) {
376
402
  const feature = featureOfFile.get(reference.declaringFile);
377
403
  if (!feature || reference.target || !converting.has(feature))
378
404
  continue;
379
- blocked.add(feature);
380
- keptLocations.set(feature, [
381
- ...(keptLocations.get(feature) ?? []),
382
- { filePath: reference.file, line: reference.line },
383
- ]);
405
+ const files = usedOutside.get(feature) ?? new Map();
406
+ const location = { filePath: reference.file, line: reference.line };
407
+ files.set(reference.declaringFile, [...(files.get(reference.declaringFile) ?? []), location]);
408
+ usedOutside.set(feature, files);
409
+ keptLocations.set(feature, [...(keptLocations.get(feature) ?? []), location]);
384
410
  }
385
- if (blocked.size === 0)
411
+ if (usedOutside.size === 0)
386
412
  break;
387
- converting = new Set([...converting].filter((feature) => !blocked.has(feature)));
413
+ for (const [feature, files] of usedOutside) {
414
+ const closure = importClosure(feature, [...keptFiles.keys(), ...files.keys()].filter((file) => featureOfFile.get(file) === feature));
415
+ if (closure.has(feature.files.actions) || (feature.files.reducer && closure.has(feature.files.reducer))) {
416
+ converting.delete(feature);
417
+ continue;
418
+ }
419
+ for (const [file, locations] of files)
420
+ keptFiles.set(file, [...(keptFiles.get(file) ?? []), ...locations]);
421
+ for (const file of closure) {
422
+ if (keptFiles.has(file))
423
+ continue;
424
+ keptFiles.set(file, []);
425
+ keptVia.set(file, [...files.keys()][0]);
426
+ }
427
+ }
388
428
  }
389
429
  for (const [feature, locations] of keptLocations) {
390
430
  if (converting.has(feature))
@@ -395,7 +435,20 @@ export const removeFeatureStores = (tree, graph, inScope, features, report) => {
395
435
  locations: [{ filePath: feature.files.actions }, ...locations],
396
436
  });
397
437
  }
398
- const deletedFiles = new Set([...converting].flatMap(storeFilesOf));
438
+ for (const [file, locations] of keptFiles) {
439
+ const feature = featureOfFile.get(file);
440
+ if (!converting.has(feature))
441
+ continue;
442
+ const via = keptVia.get(file);
443
+ report.add({
444
+ id: TOOLKIT_TASK.STORE_FILE_KEPT,
445
+ summary: via
446
+ ? `\`${file}\` of \`${feature.base}\` is kept because the kept \`${via}\` imports it.`
447
+ : `\`${file}\` of \`${feature.base}\` is kept because code outside the feature uses it.`,
448
+ locations: [{ filePath: file }, ...locations],
449
+ });
450
+ }
451
+ const deletedFiles = new Set([...converting].flatMap(removableFilesOf));
399
452
  const movedFiles = new Map([...converting].map((feature) => [feature.files.actions, feature.queriesFile]));
400
453
  const editedFiles = new Set();
401
454
  const applyScan = (result, extraEdits) => {
@@ -0,0 +1,64 @@
1
+ import * as ts from 'typescript';
2
+ import { isInterceptor } from '../migrate-from-ngrx-toolkit/consumers.js';
3
+ import { createSourceFile } from './shared.js';
4
+ const V2_AUTH_MEMBERS = new Set(['setAuthProvider', 'clearAuthProvider', 'authProvider$', 'isBearerAuthProvider']);
5
+ const lineOf = (sourceFile, position) => sourceFile.getLineAndCharacterOfPosition(position).line + 1;
6
+ /**
7
+ * Reports the code around a v2 auth setup that the migration does not rewrite: every use of the v2
8
+ * auth provider hooks, and every `HttpInterceptor` that sets an `Authorization` header.
9
+ *
10
+ * A v3 query already sends its bearer token, so an interceptor that adds one too sends it twice.
11
+ */
12
+ export const reportAuthConsumers = (tree, scope, report) => {
13
+ scope.visit(tree, (filePath) => {
14
+ if (!filePath.endsWith('.ts') || filePath.endsWith('.spec.ts')) {
15
+ return;
16
+ }
17
+ const content = tree.read(filePath, 'utf-8');
18
+ if (!content) {
19
+ return;
20
+ }
21
+ const hasAuthMember = [...V2_AUTH_MEMBERS].some((name) => content.includes(name));
22
+ const hasInterceptor = /HttpInterceptor/.test(content) && /authorization|bearer/i.test(content);
23
+ if (!hasAuthMember && !hasInterceptor) {
24
+ return;
25
+ }
26
+ const sourceFile = createSourceFile(content, filePath);
27
+ const members = new Map();
28
+ const visit = (node) => {
29
+ if (isInterceptor(node, sourceFile) && /authorization|bearer/i.test(node.getText(sourceFile))) {
30
+ const name = node.name?.getText(sourceFile) ?? 'interceptor';
31
+ report.addManualReview({
32
+ title: `Check the Authorization interceptor ${name}`,
33
+ summary: `\`${name}\` adds an \`Authorization\` header. A v3 query sends its own bearer token, so a request from a migrated query that passes through this interceptor carries the header twice.`,
34
+ action: 'Skip the requests of migrated clients in the interceptor, or remove the interceptor if the auth provider now covers every request.',
35
+ locations: [{ filePath, line: lineOf(sourceFile, node.getStart(sourceFile)) }],
36
+ source: 'auth-consumers-migration',
37
+ dedupeKey: `auth-interceptor:${filePath}:${name}`,
38
+ });
39
+ return;
40
+ }
41
+ const memberName = ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name)
42
+ ? node.name.text
43
+ : ts.isIdentifier(node) && ts.isCallExpression(node.parent) && node.parent.expression === node
44
+ ? node.text
45
+ : undefined;
46
+ if (memberName && V2_AUTH_MEMBERS.has(memberName) && !members.has(memberName)) {
47
+ members.set(memberName, lineOf(sourceFile, node.getStart(sourceFile)));
48
+ }
49
+ ts.forEachChild(node, visit);
50
+ };
51
+ visit(sourceFile);
52
+ members.forEach((line, name) => {
53
+ report.addManualReview({
54
+ title: `Replace the v2 auth hook ${name}`,
55
+ summary: `\`${name}\` belongs to the v2 bearer auth setup. The migration leaves the call site as it is, and a v3 auth provider has no member of this name.`,
56
+ action: 'Use the v3 auth provider of the client instead: inject it with its inject function and call its methods (see the auth guide).',
57
+ locations: [{ filePath, line }],
58
+ source: 'auth-consumers-migration',
59
+ dedupeKey: `v2-auth-hook:${filePath}:${name}`,
60
+ });
61
+ });
62
+ });
63
+ };
64
+ //# sourceMappingURL=auth-consumers-migration.js.map
@@ -1,3 +1,4 @@
1
+ import { dirname, join, relative } from 'node:path/posix';
1
2
  import * as ts from 'typescript';
2
3
  import { createSourceFile } from './shared.js';
3
4
  const REFRESH_CONFIG_KEYS = new Set([
@@ -43,7 +44,10 @@ const readConfig = (configArgument, sourceFile, filePath, node) => {
43
44
  const config = {
44
45
  filePath,
45
46
  line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1,
47
+ copiedImports: [],
48
+ localNames: [],
46
49
  };
50
+ const copiedNodes = [];
47
51
  const refreshConfig = configArgument.properties.find((property) => ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'refreshConfig');
48
52
  if (!refreshConfig ||
49
53
  !ts.isPropertyAssignment(refreshConfig) ||
@@ -68,9 +72,88 @@ const readConfig = (configArgument, sourceFile, filePath, node) => {
68
72
  return;
69
73
  }
70
74
  config[key] = text;
75
+ copiedNodes.push(value);
71
76
  });
77
+ resolveCopiedNames(config, copiedNodes, sourceFile);
72
78
  return config;
73
79
  };
80
+ const collectIdentifierNames = (node, names) => {
81
+ if (ts.isIdentifier(node)) {
82
+ const parent = node.parent;
83
+ const isMemberName = (ts.isPropertyAccessExpression(parent) && parent.name === node) ||
84
+ (ts.isPropertyAssignment(parent) && parent.name === node);
85
+ if (!isMemberName) {
86
+ names.add(node.text);
87
+ }
88
+ }
89
+ ts.forEachChild(node, (child) => collectIdentifierNames(child, names));
90
+ };
91
+ const resolveCopiedNames = (config, copiedNodes, sourceFile) => {
92
+ const used = new Set();
93
+ copiedNodes.forEach((node) => collectIdentifierNames(node, used));
94
+ const importedNames = new Set();
95
+ sourceFile.statements.forEach((statement) => {
96
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
97
+ return;
98
+ }
99
+ const clause = statement.importClause;
100
+ if (!clause || clause.isTypeOnly) {
101
+ return;
102
+ }
103
+ const names = [];
104
+ const defaultName = clause.name && used.has(clause.name.text) ? clause.name.text : undefined;
105
+ const namedBindings = clause.namedBindings;
106
+ let namedText;
107
+ if (defaultName)
108
+ names.push(defaultName);
109
+ if (namedBindings && ts.isNamespaceImport(namedBindings) && used.has(namedBindings.name.text)) {
110
+ names.push(namedBindings.name.text);
111
+ namedText = `* as ${namedBindings.name.text}`;
112
+ }
113
+ else if (namedBindings && ts.isNamedImports(namedBindings)) {
114
+ const elements = namedBindings.elements.filter((element) => !element.isTypeOnly && used.has(element.name.text));
115
+ elements.forEach((element) => names.push(element.name.text));
116
+ if (elements.length > 0) {
117
+ namedText = `{ ${elements.map((element) => element.getText(sourceFile)).join(', ')} }`;
118
+ }
119
+ }
120
+ if (names.length === 0) {
121
+ return;
122
+ }
123
+ names.forEach((name) => importedNames.add(name));
124
+ const bindings = [defaultName, namedText].filter(Boolean).join(', ');
125
+ config.copiedImports.push({
126
+ moduleSpecifier: statement.moduleSpecifier.text,
127
+ names,
128
+ statement: (moduleSpecifier) => `import ${bindings} from '${moduleSpecifier}';`,
129
+ });
130
+ });
131
+ sourceFile.statements.forEach((statement) => {
132
+ const declared = [];
133
+ if (ts.isVariableStatement(statement)) {
134
+ statement.declarationList.declarations.forEach((declaration) => {
135
+ if (ts.isIdentifier(declaration.name))
136
+ declared.push(declaration.name.text);
137
+ });
138
+ }
139
+ else if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name) {
140
+ declared.push(statement.name.text);
141
+ }
142
+ declared.forEach((name) => {
143
+ if (used.has(name) && !importedNames.has(name) && !config.localNames.includes(name)) {
144
+ config.localNames.push(name);
145
+ }
146
+ });
147
+ });
148
+ };
149
+ /** Re-points a relative specifier of the v2 file at the file that now holds the provider. */
150
+ export const rebaseModuleSpecifier = (moduleSpecifier, fromFilePath, toFilePath) => {
151
+ if (!moduleSpecifier.startsWith('.')) {
152
+ return moduleSpecifier;
153
+ }
154
+ const rebased = relative(dirname(toFilePath), join(dirname(fromFilePath), moduleSpecifier));
155
+ return rebased.startsWith('.') ? rebased : `./${rebased}`;
156
+ };
74
157
  const REFRESH_QUERY_KEY = 'tokenRefresh';
75
158
  /**
76
159
  * Renders the `queries` / `features` arguments of a v3 provider from a v2 config.
@@ -93,7 +176,7 @@ export const renderAuthProviderBody = (config) => {
93
176
  }
94
177
  const refreshEntries = [` queryCreator: ${config.refreshCreatorName},`];
95
178
  if (config.responseAdapter) {
96
- refreshEntries.push(` // TODO(query-v3): v2 responseAdapter. It must return { accessToken, refreshToken } - v2 returned { token, refreshToken }.`, ` extractTokens: ${config.responseAdapter},`);
179
+ refreshEntries.push(' extractTokens: (response) => {', ` const { token, refreshToken } = (${config.responseAdapter})(response) as { token: string; refreshToken: string };`, '', ' return { accessToken: token, refreshToken };', ' },');
97
180
  }
98
181
  if (config.requestArgsAdapter) {
99
182
  refreshEntries.push(` // TODO(query-v3): v2 requestArgsAdapter took { token, refreshToken }; buildArgs takes the refresh token alone.`, ` buildArgs: ${config.requestArgsAdapter},`);
@@ -111,6 +194,14 @@ export const renderAuthProviderBody = (config) => {
111
194
  action: 'If the cookie was only written after an opt-in (a "remember me" box), drive `setRememberMe()` from it. If it was never written, remove `withPersistentAuth` from the scaffold.',
112
195
  });
113
196
  }
197
+ refreshEntries.push(' retryConfig: { maxAttempts: 8 },');
198
+ if (config.cookieName && !config.cookieDomain) {
199
+ warnings.push({
200
+ title: 'Check the cookie host: v3 keeps the login cookie per host',
201
+ summary: 'The v2 provider set no `cookieDomain`. `withPersistentAuth` keys the cookie per origin, so a login does not reach a sibling host.',
202
+ action: 'If the app runs on more than one host, check that each host signs in on its own.',
203
+ });
204
+ }
114
205
  const queries = [
115
206
  ' queries: [',
116
207
  ` // Derived from the v2 BearerAuthProvider in ${config.filePath}:${config.line}. Verify before shipping.`,
@@ -139,6 +230,7 @@ export const renderAuthProviderBody = (config) => {
139
230
  ' cookie: {',
140
231
  ...cookieEntries,
141
232
  ' },',
233
+ ' defaultRememberMe: true,',
142
234
  ' autoLogin: {',
143
235
  ` queryKey: '${REFRESH_QUERY_KEY}',`,
144
236
  ` // TODO(query-v3): shape the auto-login request from the cookie value.`,
@@ -1,6 +1,6 @@
1
1
  import * as ts from 'typescript';
2
2
  import { createModuleGraph } from './module-graph.js';
3
- import { isShadowedInNestedScope } from './rename-symbols.js';
3
+ import { findUsedNames, isShadowedInNestedScope, pruneUnusedNamedImports } from './rename-symbols.js';
4
4
  import { REMOVED_EXPERIMENTAL_QUERY_HELPERS, createSourceFile, ensureImportFromQuery, ensureNamedImports, findRemovedExperimentalQueryHelpers, getLineNumber, printImportDeclaration, } from './shared.js';
5
5
  export const reportRemovedExperimentalQueryHelpers = (tree, scope, report) => {
6
6
  scope.visit(tree, (filePath) => {
@@ -34,7 +34,22 @@ export const reportRemovedExperimentalQueryHelpers = (tree, scope, report) => {
34
34
  export const migrateDevtoolsUsage = (tree, scope, report) => {
35
35
  const updatedFiles = [];
36
36
  const filesNeedingDevtoolsPackage = [];
37
+ let mountsFromProvider = false;
37
38
  scope.visit(tree, (filePath) => {
39
+ if (filePath.endsWith('.ts') && tree.read(filePath, 'utf-8')?.includes('provideQueryClientForDevtools')) {
40
+ mountsFromProvider = true;
41
+ }
42
+ });
43
+ scope.visit(tree, (filePath) => {
44
+ if (filePath.endsWith('.html')) {
45
+ const html = tree.read(filePath, 'utf-8');
46
+ const nextHtml = html && (mountsFromProvider ? removeDevtoolsTags(html) : selectLazyDevtools(html));
47
+ if (nextHtml && nextHtml !== html) {
48
+ tree.write(filePath, nextHtml);
49
+ updatedFiles.push(filePath);
50
+ }
51
+ return;
52
+ }
38
53
  if (!filePath.endsWith('.ts')) {
39
54
  return;
40
55
  }
@@ -43,13 +58,14 @@ export const migrateDevtoolsUsage = (tree, scope, report) => {
43
58
  (!content.includes('provideQueryClientForDevtools') && !content.includes('QueryDevtoolsComponent'))) {
44
59
  return;
45
60
  }
46
- const { content: nextContent, importsComponent } = migrateDevtoolsInFile(content);
61
+ const { content: migrated, importsComponent, mountsDevtools } = migrateDevtoolsInFile(content, mountsFromProvider);
62
+ const nextContent = pruneUnusedNamedImports(mountsFromProvider ? removeDevtoolsTags(migrated) : migrated, findUsedNames(content));
47
63
  if (nextContent === content) {
48
64
  return;
49
65
  }
50
66
  tree.write(filePath, nextContent);
51
67
  updatedFiles.push(filePath);
52
- if (importsComponent) {
68
+ if (importsComponent || mountsDevtools) {
53
69
  filesNeedingDevtoolsPackage.push(filePath);
54
70
  }
55
71
  });
@@ -64,7 +80,7 @@ export const migrateDevtoolsUsage = (tree, scope, report) => {
64
80
  // `@ethlete/query-devtools`.
65
81
  report.addManualReview({
66
82
  title: 'Add @ethlete/query-devtools for the query devtools',
67
- summary: '`QueryDevtoolsComponent` now lives in `@ethlete/query-devtools`. The imports were rewritten, but the package may not be a dependency of these projects yet.',
83
+ summary: "`provideQueryDevtools()` now mounts the devtools through `ui: () => import('@ethlete/query-devtools/lazy')`, so `<et-query-devtools>` and its component import were removed. Where no devtools provider was found, `QueryDevtoolsLazyComponent` and `<et-query-devtools-lazy>` replace them. The package may not be a dependency of these projects yet.",
68
84
  action: 'Run `yarn add @ethlete/query-devtools` where needed, then `yarn install` and re-lint the affected libs so the `@nx/dependency-checks` rule sees the new dependency.',
69
85
  locations: filesNeedingDevtoolsPackage.map((filePath) => ({ filePath })),
70
86
  source: 'cleanup-migration',
@@ -92,6 +108,48 @@ export const replaceAnyQueryWithLegacy = (tree, scope) => {
92
108
  console.log(`\n✅ Replaced legacy AnyV2Query aliases in ${updatedFiles.length} files`);
93
109
  }
94
110
  };
111
+ export const reportLeftoverV2Clients = (tree, scope, report) => {
112
+ scope.visit(tree, (filePath) => {
113
+ if (!filePath.endsWith('.ts') || filePath.endsWith('.spec.ts')) {
114
+ return;
115
+ }
116
+ const content = tree.read(filePath, 'utf-8');
117
+ const index = content?.search(/new\s+V2QueryClient\s*[(<]/);
118
+ if (!content || index === undefined || index < 0) {
119
+ return;
120
+ }
121
+ const line = content.slice(0, index).split('\n').length;
122
+ report.addManualReview({
123
+ title: 'Migrator error: a V2QueryClient is left',
124
+ summary: 'The migration should leave no `V2QueryClient` behind, so it missed this client. Report this case as a migrator bug.',
125
+ action: 'Migrate the client by hand to `createQueryClient`, then report the shape the migration did not handle.',
126
+ locations: [{ filePath, line }],
127
+ source: 'cleanup-migration',
128
+ dedupeKey: `leftover-v2-client:${filePath}:${line}`,
129
+ });
130
+ });
131
+ };
132
+ export const reportLeftoverLegacyDevtools = (tree, scope, report) => {
133
+ scope.visit(tree, (filePath) => {
134
+ if (filePath.endsWith('.spec.ts') || (!filePath.endsWith('.ts') && !filePath.endsWith('.html'))) {
135
+ return;
136
+ }
137
+ const content = tree.read(filePath, 'utf-8');
138
+ const index = content?.search(/<et-query-devtools(?![\w-])|\b(QueryDevtoolsComponent|provideQueryClientForDevtools)\b/);
139
+ if (!content || index === undefined || index < 0) {
140
+ return;
141
+ }
142
+ const line = content.slice(0, index).split('\n').length;
143
+ report.addManualReview({
144
+ title: 'Migrator error: legacy devtools usage is left',
145
+ summary: 'The migration should leave no legacy `<et-query-devtools>`, `QueryDevtoolsComponent` or `provideQueryClientForDevtools` behind, so it missed this one. Report this case as a migrator bug.',
146
+ action: 'Switch it by hand to `provideQueryDevtools()` and `QueryDevtoolsLazyComponent` from `@ethlete/query-devtools/lazy`, then report the shape the migration did not handle.',
147
+ locations: [{ filePath, line }],
148
+ source: 'cleanup-migration',
149
+ dedupeKey: `leftover-legacy-devtools:${filePath}:${line}`,
150
+ });
151
+ });
152
+ };
95
153
  export const migrateEmptyPrepareCalls = (tree, scope, report) => {
96
154
  const updatedFiles = [];
97
155
  const graph = createModuleGraph(tree);
@@ -123,26 +181,79 @@ export const migrateEmptyPrepareCalls = (tree, scope, report) => {
123
181
  console.log(`\n✅ Migrated empty .prepare() calls in ${updatedFiles.length} files`);
124
182
  }
125
183
  };
126
- const migrateDevtoolsInFile = (content) => {
184
+ const LAZY_DEVTOOLS_COMPONENT = 'QueryDevtoolsLazyComponent';
185
+ const DEVTOOLS_UI_PROVIDER_CALL = "provideQueryDevtools({ ui: () => import('@ethlete/query-devtools/lazy') })";
186
+ const selectLazyDevtools = (content) => content.replace(/(<\/?)et-query-devtools(?![\w-])/g, '$1et-query-devtools-lazy');
187
+ const removeDevtoolsTags = (content) => content.replace(/[ \t]*<et-query-devtools(?![\w-])[^>]*?(?:\/>|>\s*<\/et-query-devtools>)[ \t]*\n?/g, '');
188
+ const removeFromImportsArrays = (content, names) => {
189
+ const sourceFile = createSourceFile(content);
190
+ const ranges = [];
191
+ const visit = (node) => {
192
+ if (ts.isPropertyAssignment(node) &&
193
+ ts.isIdentifier(node.name) &&
194
+ node.name.text === 'imports' &&
195
+ ts.isArrayLiteralExpression(node.initializer)) {
196
+ node.initializer.elements
197
+ .filter((element) => ts.isIdentifier(element) && names.has(element.text))
198
+ .forEach((element) => {
199
+ const range = withSurroundingComma(content, element.getStart(sourceFile), element.getEnd());
200
+ const padding = content[range.start - 1] === '[' ? (/^[ \t]*/.exec(content.slice(range.end))?.[0] ?? '') : '';
201
+ ranges.push({ start: range.start, end: range.end + padding.length });
202
+ });
203
+ }
204
+ ts.forEachChild(node, visit);
205
+ };
206
+ visit(sourceFile);
207
+ return ranges
208
+ .sort((left, right) => right.start - left.start)
209
+ .reduce((result, { start, end }) => result.slice(0, start) + result.slice(end), content);
210
+ };
211
+ const renameIdentifiers = (content, names, replacement) => {
212
+ const sourceFile = createSourceFile(content);
213
+ const ranges = [];
214
+ const visit = (node) => {
215
+ if (ts.isImportDeclaration(node)) {
216
+ return;
217
+ }
218
+ if (ts.isIdentifier(node) && names.has(node.text)) {
219
+ ranges.push({ start: node.getStart(sourceFile), end: node.getEnd() });
220
+ }
221
+ ts.forEachChild(node, visit);
222
+ };
223
+ visit(sourceFile);
224
+ return ranges
225
+ .sort((left, right) => right.start - left.start)
226
+ .reduce((result, { start, end }) => result.slice(0, start) + replacement + result.slice(end), content);
227
+ };
228
+ const migrateDevtoolsInFile = (content, mountsFromProvider) => {
127
229
  const componentNames = localNamesImportedFromQuery(content, 'QueryDevtoolsComponent');
128
230
  const providerNames = new Set([
129
231
  'provideQueryClientForDevtools',
130
232
  ...localNamesImportedFromQuery(content, 'provideQueryClientForDevtools'),
131
233
  ]);
132
- let result = replaceDevtoolsProviderCalls(content, providerNames);
133
- const hasProvider = result.includes('provideQueryDevtools()');
234
+ const providerCall = mountsFromProvider ? DEVTOOLS_UI_PROVIDER_CALL : 'provideQueryDevtools()';
235
+ let result = replaceDevtoolsProviderCalls(content, providerNames, providerCall);
236
+ const hasProvider = result.includes(providerCall);
134
237
  result = dropLegacyDevtoolsImports(result);
135
238
  if (hasProvider) {
136
239
  result = ensureImportFromQuery(result, ['provideQueryDevtools']);
137
240
  }
138
- if (componentNames.length > 0) {
241
+ if (componentNames.length > 0 && mountsFromProvider) {
242
+ result = removeFromImportsArrays(result, new Set(componentNames));
243
+ }
244
+ else if (componentNames.length > 0) {
245
+ result = selectLazyDevtools(renameIdentifiers(result, new Set(componentNames), LAZY_DEVTOOLS_COMPONENT));
139
246
  result = ensureNamedImports({
140
247
  content: result,
141
- importsNeeded: componentNames.map((name) => name === 'QueryDevtoolsComponent' ? name : `QueryDevtoolsComponent as ${name}`),
142
- moduleSpecifier: '@ethlete/query-devtools',
248
+ importsNeeded: [LAZY_DEVTOOLS_COMPONENT],
249
+ moduleSpecifier: '@ethlete/query-devtools/lazy',
143
250
  });
144
251
  }
145
- return { content: result, importsComponent: componentNames.length > 0 };
252
+ return {
253
+ content: result,
254
+ importsComponent: componentNames.length > 0 && !mountsFromProvider,
255
+ mountsDevtools: hasProvider && mountsFromProvider,
256
+ };
146
257
  };
147
258
  const queryImports = (sourceFile) => sourceFile.statements.filter((node) => ts.isImportDeclaration(node) &&
148
259
  ts.isStringLiteral(node.moduleSpecifier) &&
@@ -162,7 +273,7 @@ const localNamesImportedFromQuery = (content, name) => queryImports(createSource
162
273
  * first call site keeps its position - it is already where the app wanted its devtools - and the
163
274
  * rest are removed along with the comma that separated them.
164
275
  */
165
- const replaceDevtoolsProviderCalls = (content, providerNames) => {
276
+ const replaceDevtoolsProviderCalls = (content, providerNames, providerCall) => {
166
277
  const sourceFile = createSourceFile(content);
167
278
  const calls = [];
168
279
  const visit = (node) => {
@@ -179,7 +290,7 @@ const replaceDevtoolsProviderCalls = (content, providerNames) => {
179
290
  const start = call.getStart(sourceFile);
180
291
  const end = call.getEnd();
181
292
  if (index === 0) {
182
- return { start, end, replacement: 'provideQueryDevtools()' };
293
+ return { start, end, replacement: providerCall };
183
294
  }
184
295
  return { ...withSurroundingComma(content, start, end), replacement: '' };
185
296
  });
@@ -231,16 +342,25 @@ const dropLegacyDevtoolsImports = (content) => {
231
342
  const replaceAnyQueryInFile = (content) => {
232
343
  const sourceFile = createSourceFile(content);
233
344
  const replacements = [];
345
+ const names = new Set();
346
+ const collect = (node) => {
347
+ if (ts.isIdentifier(node))
348
+ names.add(node.text);
349
+ ts.forEachChild(node, collect);
350
+ };
351
+ collect(sourceFile);
352
+ const renamesQuery = !names.has('AnyLegacyQuery');
353
+ const renamesCreator = !names.has('AnyLegacyQueryCreator');
234
354
  const visit = (node) => {
235
355
  if (ts.isIdentifier(node)) {
236
- if (node.text === 'AnyV2Query') {
356
+ if (renamesQuery && node.text === 'AnyV2Query') {
237
357
  replacements.push({
238
358
  start: node.getStart(sourceFile),
239
359
  end: node.getEnd(),
240
360
  replacement: 'AnyLegacyQuery',
241
361
  });
242
362
  }
243
- if (node.text === 'AnyV2QueryCreator') {
363
+ if (renamesCreator && node.text === 'AnyV2QueryCreator') {
244
364
  replacements.push({
245
365
  start: node.getStart(sourceFile),
246
366
  end: node.getEnd(),