diff --git a/.changeset/angular-migration-schematic.md b/.changeset/angular-migration-schematic.md new file mode 100644 index 00000000000..909f8f5cf77 --- /dev/null +++ b/.changeset/angular-migration-schematic.md @@ -0,0 +1,5 @@ +--- +"@tanstack/angular-query": minor +--- + +Bundle an Angular 20.1-compatible migrate-from-experimental schematic that updates package and devtools panel imports, migrates workspace and library dependencies, and reports remaining API changes with actionable guidance and a migration summary. diff --git a/docs/framework/angular/guides/migrating-from-experimental.md b/docs/framework/angular/guides/migrating-from-experimental.md index b541fe0309f..a3bcc824f80 100644 --- a/docs/framework/angular/guides/migrating-from-experimental.md +++ b/docs/framework/angular/guides/migrating-from-experimental.md @@ -7,6 +7,23 @@ The stable package keeps the callback-based query, mutation, and signal result A experimental adapter. Most application queries therefore only need an import change. The following sections cover the breaking configuration and entrypoint changes. +Angular 20.1 or newer is required by the stable package. Upgrade Angular first if your +application is on an older version. Save your work and confirm the application builds before +running the migration. + +## Run the migration schematic + +Install the stable package, then preview the bundled migration: + +```bash +npm install @tanstack/angular-query +ng generate @tanstack/angular-query:migrate-from-experimental --dry-run +ng generate @tanstack/angular-query:migrate-from-experimental +``` + +The schematic handles common migration steps. +Review the remaining sections for changes that require manual updates. + ## Replace the experimental package ```bash @@ -14,8 +31,6 @@ npm uninstall @tanstack/angular-query-experimental npm install @tanstack/angular-query ``` -Angular 20.1 or newer is required by the stable package. - ```ts import { injectQuery } from '@tanstack/angular-query-experimental' // [!code --] import { injectQuery } from '@tanstack/angular-query' // [!code ++] @@ -120,6 +135,44 @@ readonly summary = injectQueries(() => ({ })) ``` +## Move explicit injectors into an injection context + +All stable injection helpers must run in an Angular injection context. The +experimental `injectQuery`, `injectInfiniteQuery`, `injectMutation`, +`injectMutationState`, `injectIsFetching`, and `injectIsMutating` accepted a second +`{ injector }` options argument. `injectIsRestoring` accepted that options object +as its first argument. The experimental `injectDevtoolsPanel` also accepted a second +`{ injector }` options argument. These arguments have been removed. + +```ts +injectQuery(options, { injector }) // [!code --] +runInInjectionContext(injector, () => injectQuery(options)) // [!code ++] + +injectMutation(options, { injector }) // [!code --] +runInInjectionContext(injector, () => injectMutation(options)) // [!code ++] + +injectIsFetching({ queryKey: ['todos'] }, { injector }) // [!code --] +runInInjectionContext(injector, () => + injectIsFetching(() => ({ queryKey: ['todos'] })), +) // [!code ++] + +injectIsRestoring({ injector }) // [!code --] +runInInjectionContext(injector, () => injectIsRestoring()) // [!code ++] +``` + +Import `runInInjectionContext` from `@angular/core`. Unlike these helpers, +`injectQueries` previously took the injector directly, as shown above. When the +old argument is `undefined` or an empty options object, remove it and ensure the +call already runs in an injection context. + +The `InjectQueryOptions`, `InjectInfiniteQueryOptions`, `InjectMutationOptions`, +`InjectMutationStateOptions`, `InjectIsFetchingOptions`, and +`InjectIsMutatingOptions` types have also been removed, along with the devtools +`InjectDevtoolsPanelOptions` type. Update wrappers that use +these types. The schematic reports explicit arguments, spread arguments, and +imports of these removed types for manual review; it does not infer injector +handling through custom wrappers. + ## Review SSR hydration `provideTanStackQuery` now dehydrates the server cache into Angular `TransferState` and hydrates it diff --git a/knip.ts b/knip.ts index ab57d8fd9d0..9cc309b3c04 100644 --- a/knip.ts +++ b/knip.ts @@ -11,7 +11,7 @@ export default { ignoreDependencies: ['react', 'react-dom'], }, 'packages/angular-query': { - entry: ['schematics/ng-add/index.ts', 'src/__tests__/*.test-d.ts'], + entry: ['schematics/*/index.ts', 'src/__tests__/*.test-d.ts'], }, 'packages/query-codemods': { entry: ['src/v4/**/*.cjs', 'src/v5/**/*.cjs'], diff --git a/packages/angular-query/schematics-build/migrate-from-experimental.test.ts b/packages/angular-query/schematics-build/migrate-from-experimental.test.ts new file mode 100644 index 00000000000..1e934c801b2 --- /dev/null +++ b/packages/angular-query/schematics-build/migrate-from-experimental.test.ts @@ -0,0 +1,734 @@ +import { describe, expect, it } from 'vitest' +import packageJson from '../package.json' with { type: 'json' } +import { + SchematicTestRunner, + Tree, + devtoolsPackage, + queryCollection, +} from './test-utils' + +// cspell:ignore packagr + +function setup(source: string) { + const runner = new SchematicTestRunner(packageJson.name, queryCollection) + const tree = Tree.empty() + tree.create('/src/app.ts', source) + tree.create( + '/package.json', + JSON.stringify({ + dependencies: { '@tanstack/angular-query-experimental': '^5.0.0' }, + }), + ) + const warnings: Array = [] + const messages: Array = [] + runner.logger.subscribe((entry) => { + if (entry.level === 'warn') warnings.push(entry.message) + if (entry.level === 'info') messages.push(entry.message) + }) + return { runner, tree, warnings, messages } +} + +describe('migrate-from-experimental', () => { + it('migrates aliases, factories, spreads, filters, helpers and dependencies; is idempotent', async () => { + const { runner, tree } = + setup(`import { QueryClient as Client, provideAngularQuery as provide, injectQueryClient as client, injectIsFetching } from '@tanstack/angular-query-experimental'; +import { withDevtools } from '@tanstack/angular-query-experimental/devtools/production'; +const providers = [...provide(new Client(), withDevtools())]; +const qc = client(); +const count = injectIsFetching({ queryKey: ['todos'] }); +function unrelated(provide: any, client: any) { provide(new Client()); client(); } +`) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + const output = result.readContent('/src/app.ts') + expect(output).toContain('provideTanStackQuery as provide') + expect(output).toContain( + 'providers = [provide(() => new Client(), withDevtools())]', + ) + expect(output).toContain('inject(Client)') + expect(output).toContain("import { inject } from '@angular/core'") + expect(output).not.toContain('migrationCore') + expect(output).not.toContain('injectQueryClient') + expect(output).toContain( + "injectIsFetching(() => ({ queryKey: ['todos'] }))", + ) + expect(output).toContain('provide(new Client()); client();') + expect(output).toContain(`${devtoolsPackage.name}/production`) + expect( + JSON.parse(result.readContent('/package.json')).dependencies, + ).toEqual({ + [packageJson.name]: `^${packageJson.version}`, + [devtoolsPackage.name]: `^${devtoolsPackage.version}`, + }) + expect(runner.tasks).toHaveLength(1) + const again = await runner.runSchematic( + 'migrate-from-experimental', + {}, + result, + ) + expect(again.readContent('/src/app.ts')).toBe(output) + expect(runner.tasks).toHaveLength(0) + }) + + it('handles namespaces without touching shadowed bindings or existing factories', async () => { + const { runner, tree } = + setup(`import * as query from '@tanstack/angular-query-experimental'; +query.provideAngularQuery(new query.QueryClient()); +query.provideTanStackQuery(() => new query.QueryClient()); +query.injectQueryClient(); +function other(query: any) { query.provideAngularQuery(new query.QueryClient()); } +`) + const result = await runner.runSchematic( + 'migrate-from-experimental', + { skipInstall: true }, + tree, + ) + const output = result.readContent('/src/app.ts') + expect( + output.match( + /query.provideTanStackQuery\(\(\) => new query.QueryClient\(\)\)/g, + ), + ).toHaveLength(2) + expect(output).toContain( + 'function other(query: any) { query.provideAngularQuery(new query.QueryClient()); }', + ) + expect(runner.tasks).toHaveLength(0) + }) + + it('generates ordinary named imports when neither API is imported', async () => { + const { runner, tree } = + setup(`import { injectQueryClient } from '@tanstack/angular-query-experimental'; +const client = injectQueryClient();`) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + const output = result.readContent('/src/app.ts') + expect(output).toContain("import { inject } from '@angular/core';") + expect(output).toContain( + `import { QueryClient } from '${packageJson.name}';`, + ) + expect(output).toContain('const client = inject(QueryClient);') + expect(output).not.toContain('import { }') + }) + + it('reuses existing aliased and namespace imports', async () => { + const { runner, tree } = + setup(`import { inject as angularInject } from '@angular/core'; +import * as query from '@tanstack/angular-query-experimental'; +const client = query.injectQueryClient();`) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + const output = result.readContent('/src/app.ts') + expect(output).toContain('const client = angularInject(query.QueryClient);') + expect(output.match(/import /g)).toHaveLength(2) + }) + + it('adds readable aliases when existing imports are shadowed', async () => { + const { runner, tree } = setup(`import { inject } from '@angular/core'; +import { QueryClient, injectQueryClient } from '@tanstack/angular-query-experimental'; +function example(inject: unknown, QueryClient: unknown) { return injectQueryClient(); } +const client = injectQueryClient();`) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + const output = result.readContent('/src/app.ts') + expect(output).toContain( + "import { inject as angularInject } from '@angular/core';", + ) + expect(output).toContain( + `import { QueryClient as TanStackQueryClient } from '${packageJson.name}';`, + ) + expect(output).toContain('return angularInject(TanStackQueryClient)') + expect(output).toContain('const client = inject(QueryClient);') + expect(output).not.toContain('import { }') + const again = await runner.runSchematic( + 'migrate-from-experimental', + {}, + result, + ) + expect(again.readContent('/src/app.ts')).toBe(output) + }) + + it('does not use type-only imports as runtime values and avoids alias collisions', async () => { + const { runner, tree } = + setup(`import type { QueryClient } from '@tanstack/angular-query-experimental'; +import { injectQueryClient } from '@tanstack/angular-query-experimental'; +const TanStackQueryClient = 'occupied'; +const client = injectQueryClient();`) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + const output = result.readContent('/src/app.ts') + expect(output).toContain( + `import { QueryClient as TanStackQueryClient2 } from '${packageJson.name}';`, + ) + expect(output).toContain('const client = inject(TanStackQueryClient2);') + }) + + it('reports ambiguous changes and preserves imperative error options', async () => { + const { runner, tree, warnings } = + setup(`import { provideTanStackQuery, injectQueries, injectIsMutating } from '@tanstack/angular-query-experimental'; +provideTanStackQuery(client); +injectQueries(options, injector); +injectIsMutating(filters); +query.refetch({ throwOnError: true }); +`) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + expect(result.readContent('/src/app.ts')).toContain( + 'provideTanStackQuery(client)', + ) + expect(result.readContent('/src/app.ts')).toContain( + 'query.refetch({ throwOnError: true })', + ) + expect(warnings.join('\n')).toContain('Client reference') + expect(warnings.join('\n')).toContain('runInInjectionContext') + expect(warnings.join('\n')).toContain('activity filter') + }) + + describe('removed injector arguments', () => { + const helpers = [ + 'injectQuery', + 'injectInfiniteQuery', + 'injectMutation', + 'injectMutationState', + 'injectIsFetching', + 'injectIsMutating', + 'injectIsRestoring', + 'injectQueries', + ] + + it.each(helpers)( + 'warns for named, aliased and namespace calls to %s', + async (helper) => { + const args = + helper === 'injectIsRestoring' + ? '{ injector }' + : helper === 'injectQueries' + ? 'options, injector' + : 'options, { injector }' + const { runner, tree, warnings } = + setup(`import { ${helper}, ${helper} as helper } from '@tanstack/angular-query-experimental'; +import * as query from '@tanstack/angular-query-experimental'; +${helper}(${args}); +helper(${args}); +query.${helper}(${args});`) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + const injectorWarnings = warnings.filter((warning) => + warning.includes('no longer accepts'), + ) + expect(injectorWarnings).toHaveLength(3) + expect( + injectorWarnings.every( + (warning) => + warning.includes(helper) && + warning.includes('runInInjectionContext'), + ), + ).toBe(true) + expect(injectorWarnings[0]).toContain('/src/app.ts:3:') + expect(injectorWarnings[0]).toContain( + helper === 'injectQueries' + ? 'old injector argument' + : 'old options.injector', + ) + expect(result.readContent('/src/app.ts')).toContain(`helper(${args})`) + }, + ) + + it.each(helpers)( + 'does not warn for current signatures or shadowed %s', + async (helper) => { + const args = helper === 'injectIsRestoring' ? '' : '() => ({})' + const { runner, tree, warnings } = + setup(`import { ${helper} } from '@tanstack/angular-query-experimental'; +${helper}(${args}); +function unrelated(${helper}: any) { ${helper}(options, { injector }); }`) + await runner.runSchematic('migrate-from-experimental', {}, tree) + expect(warnings).toEqual([]) + }, + ) + + it('reports undefined options, optional injector objects and spread arguments', async () => { + const { runner, tree, warnings } = + setup(`import { injectQuery, injectMutation, injectIsRestoring } from '@tanstack/angular-query-experimental'; +injectQuery(options, undefined); +injectMutation(options, {}); +injectIsRestoring(injectionOptions); +injectQuery(...args);`) + await runner.runSchematic('migrate-from-experimental', {}, tree) + expect(warnings).toHaveLength(4) + expect(warnings[0]).toContain( + 'If it is undefined or contains no injector', + ) + expect(warnings[3]).toContain('Review spread arguments to injectQuery') + }) + + it.each(['injectIsFetching', 'injectIsMutating'])( + 'still wraps filters while warning about %s injector options', + async (helper) => { + const { runner, tree, warnings } = + setup(`import { ${helper} } from '@tanstack/angular-query-experimental'; +${helper}({}, { injector });`) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + expect(result.readContent('/src/app.ts')).toContain( + `${helper}(() => ({}), { injector })`, + ) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('old options.injector') + }, + ) + + it.each([ + 'InjectQueryOptions', + 'InjectInfiniteQueryOptions', + 'InjectMutationOptions', + 'InjectMutationStateOptions', + 'InjectIsFetchingOptions', + 'InjectIsMutatingOptions', + ])('flags removed %s types used by wrappers', async (name) => { + const { runner, tree, warnings } = + setup(`import type { ${name} as Options } from '@tanstack/angular-query-experimental'; +import type * as query from '@tanstack/angular-query-experimental'; +let options: Options; +let other: query.${name};`) + await runner.runSchematic('migrate-from-experimental', {}, tree) + expect( + warnings.filter((warning) => warning.includes(`${name} was removed`)), + ).toHaveLength(2) + }) + }) + + it('preserves existing stable dependency versions and skips generated files', async () => { + const { runner, tree } = setup( + `export { injectQueries } from '@tanstack/angular-query-experimental/inject-queries-experimental';`, + ) + const ignored = `import { injectQuery } from '@tanstack/angular-query-experimental';` + tree.create('/node_modules/example/index.ts', ignored) + tree.create('/dist/app.ts', ignored) + tree.create('/.angular/cache/app.ts', ignored) + tree.create('/.nx/cache/app.ts', ignored) + tree.overwrite( + '/package.json', + JSON.stringify({ + dependencies: { + [packageJson.name]: '~99.0.0', + '@tanstack/angular-query-experimental': '^5', + }, + }), + ) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + expect(result.readContent('/src/app.ts')).toContain( + `from "${packageJson.name}"`, + ) + expect(result.readContent('/node_modules/example/index.ts')).toBe(ignored) + expect(result.readContent('/dist/app.ts')).toBe(ignored) + expect(result.readContent('/.angular/cache/app.ts')).toBe(ignored) + expect(result.readContent('/.nx/cache/app.ts')).toBe(ignored) + expect( + JSON.parse(result.readContent('/package.json')).dependencies[ + packageJson.name + ], + ).toBe('~99.0.0') + }) +}) + +// These exercise the published collection, including dependency edits and diagnostics. +describe('migration regression coverage', () => { + it.each([ + ['devtools', ''], + ['devtools/production', '/production'], + ['devtools-panel', '/devtools-panel'], + ['devtools-panel/production', '/devtools-panel/production'], + ])( + 'moves the %s entrypoint and installs devtools', + async (oldPath, newPath) => { + const { runner, tree } = + setup(`import * as tools from '@tanstack/angular-query-experimental/${oldPath}'; +export * from '@tanstack/angular-query-experimental/${oldPath}';`) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + expect(result.readContent('/src/app.ts')).not.toContain( + '@tanstack/angular-query-experimental', + ) + expect( + result + .readContent('/src/app.ts') + .match(new RegExp(`${devtoolsPackage.name}${newPath}`, 'g')), + ).toHaveLength(2) + expect( + JSON.parse(result.readContent('/package.json')).dependencies[ + devtoolsPackage.name + ], + ).toBe(`^${devtoolsPackage.version}`) + expect(runner.tasks).toHaveLength(1) + }, + ) + + it('reports unknown entrypoints instead of inventing an export', async () => { + const { runner, tree, warnings } = setup( + `import * as tools from '@tanstack/angular-query-experimental/devtools/panel';`, + ) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + expect(result.readContent('/src/app.ts')).toContain('/devtools/panel') + expect(warnings.join('\n')).toContain( + 'Unrecognized experimental entrypoint', + ) + expect( + JSON.parse(result.readContent('/package.json')).dependencies[ + devtoolsPackage.name + ], + ).toBeUndefined() + }) + + it('renames provider references and reports indirect helper uses without touching shadowed namespaces', async () => { + const { runner, tree, warnings } = + setup(`import * as query from '@tanstack/angular-query-experimental'; +import { provideAngularQuery as provide, injectIsFetching } from '@tanstack/angular-query-experimental'; +const provider = query.provideAngularQuery; +const otherProvider = query.provideQueryClient; +const client = query.injectQueryClient; +const aliased = provide; +const count = injectIsFetching; +function unrelated(query: any) { return query.provideAngularQuery; }`) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + const output = result.readContent('/src/app.ts') + expect(output).toContain('const provider = query.provideTanStackQuery') + expect(output).toContain('const otherProvider = query.provideTanStackQuery') + expect(output).toContain('const client = query.injectQueryClient') + expect(output).toContain( + 'function unrelated(query: any) { return query.provideAngularQuery; }', + ) + expect( + warnings.filter((message) => message.includes('provider reference')), + ).toHaveLength(3) + expect(warnings.join('\n')).toContain( + 'Replace this indirect reference with a function that calls inject(QueryClient)', + ) + expect(warnings.join('\n')).toContain('callback-based filters') + }) + + it.each([ + ['provideIsRestoring', 'withPersistQueryClient'], + ['queryFeature', 'supported with* features'], + ['QueryFeatures', 'non-generic QueryFeature'], + ['DevtoolsFeature', 'non-generic QueryFeature'], + ['PersistQueryClientFeature', 'non-generic QueryFeature'], + ['CreateBaseQueryOptions', 'CreateQueryOptions'], + ])( + 'reports removed %s imports, namespace references and re-exports', + async (name, guidance) => { + const { runner, tree, warnings } = + setup(`import { ${name} as legacy } from '@tanstack/angular-query-experimental'; +import * as query from '@tanstack/angular-query-experimental'; +export { ${name} } from '@tanstack/angular-query-experimental'; +type Used = typeof query.${name};`) + await runner.runSchematic('migrate-from-experimental', {}, tree) + const removed = warnings.filter((message) => + message.includes(`${name} was removed`), + ) + expect(removed).toHaveLength(3) + expect(removed.every((message) => message.includes(guidance))).toBe(true) + }, + ) + + it('reports generic QueryFeature references through aliases and namespaces', async () => { + const { runner, tree, warnings } = + setup(`import type { QueryFeature as Feature } from '@tanstack/angular-query-experimental'; +import type * as query from '@tanstack/angular-query-experimental'; +let named: Feature<'Devtools'>; +let namespace: query.QueryFeature<'PersistQueryClient'>; +let supported: Feature;`) + await runner.runSchematic('migrate-from-experimental', {}, tree) + expect(warnings).toHaveLength(2) + expect( + warnings.every((message) => + message.includes('QueryFeature is no longer generic'), + ), + ).toBe(true) + }) + + it('preserves type-only provider specifiers when renaming imports', async () => { + const { runner, tree } = + setup(`import { type provideAngularQuery } from '@tanstack/angular-query-experimental'; +type Provider = typeof provideAngularQuery;`) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + expect(result.readContent('/src/app.ts')).toContain( + 'type provideTanStackQuery as provideAngularQuery', + ) + }) + + it('limits option diagnostics to observer options, client defaults and devtools injection dependencies', async () => { + const { runner, tree, warnings } = + setup(`import { injectQuery as query, mutationOptions, QueryClient } from '@tanstack/angular-query-experimental'; +import { withDevtools as tools } from '@tanstack/angular-query-experimental/devtools'; +query(() => ({ throwOnError: true, meta: { throwOnError: true } })); +mutationOptions({ throwOnError: true }); +new QueryClient({ defaultOptions: { queries: { throwOnError: true }, mutations: { throwOnError: true } } }); +tools(() => ({}), { deps: [Manager] }); +result.refetch({ throwOnError: true }); +result.fetchNextPage({ throwOnError: true }); +const unrelated = { deps: [], throwOnError: true }; +function shadowed(query: any, tools: any) { query(() => ({ throwOnError: true })); tools(() => ({}), { deps: [] }); }`) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + expect( + warnings.filter((message) => message.includes('Observer throwOnError')), + ).toHaveLength(4) + expect( + warnings.filter((message) => + message.includes('withDevtools no longer accepts deps'), + ), + ).toHaveLength(1) + expect(warnings).toHaveLength(5) + expect(result.readContent('/src/app.ts')).toContain( + 'result.refetch({ throwOnError: true })', + ) + expect(result.readContent('/src/app.ts')).toContain( + 'const unrelated = { deps: [], throwOnError: true }', + ) + }) + + it('finds returned observer options without treating nested functions as option factories', async () => { + const { runner, tree, warnings } = + setup(`import { injectInfiniteQuery, injectMutation } from '@tanstack/angular-query-experimental'; +injectInfiniteQuery(() => { const unrelated = () => ({ throwOnError: true }); return { throwOnError: true }; }); +injectMutation(() => condition ? { throwOnError: true } : ({ throwOnError: true } as any));`) + await runner.runSchematic('migrate-from-experimental', {}, tree) + expect(warnings).toHaveLength(3) + expect( + warnings.every((message) => message.includes('Observer throwOnError')), + ).toBe(true) + }) + + it('replaces peer and optional dependencies while preserving existing ranges and unrelated manifest formatting', async () => { + const { runner, tree, warnings } = setup( + `import { injectQuery } from '@tanstack/angular-query-experimental';`, + ) + tree.overwrite( + '/package.json', + '{\r\n "name": "library",\r\n "peerDependencies": {\r\n "@tanstack/angular-query-experimental": "^5",\r\n "@tanstack/angular-query": "~99.0.0"\r\n },\r\n "optionalDependencies": { "@tanstack/angular-query-experimental": "^5" },\r\n "peerDependenciesMeta": { "@tanstack/angular-query-experimental": { "optional": true } }\r\n}\r\n', + ) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + const text = result.readContent('/package.json') + const pkg = JSON.parse(text) + expect(text).toContain(' "name": "library",\r\n') + expect(pkg.peerDependencies).toEqual({ [packageJson.name]: '~99.0.0' }) + expect(pkg.optionalDependencies).toEqual({ + [packageJson.name]: `^${packageJson.version}`, + }) + expect(warnings.join('\n')).toContain('Rename peerDependenciesMeta') + expect(runner.tasks).toHaveLength(1) + }) + + it('uses inherited build/test configurations, follows local imports, and updates library manifests', async () => { + const legacy = `import { injectQuery } from '@tanstack/angular-query-experimental';` + const { runner, tree } = setup(legacy) + tree.create( + '/angular.json', + JSON.stringify({ + version: 1, + projects: { + app: { + projectType: 'application', + root: '', + sourceRoot: 'src', + architect: { + build: { + builder: '@angular/build:application', + options: { tsConfig: 'tsconfig.app.json' }, + configurations: { + production: { tsConfig: 'tsconfig.production.json' }, + }, + }, + test: { + builder: '@angular/build:unit-test', + options: { tsConfig: 'tsconfig.spec.json' }, + }, + }, + }, + lib: { + projectType: 'library', + root: 'projects/lib', + sourceRoot: 'projects/lib/src', + architect: { + build: { + builder: '@angular/build:ng-packagr', + options: { tsConfig: 'projects/lib/tsconfig.lib.json' }, + }, + }, + }, + }, + }), + ) + tree.create( + '/tsconfig.json', + JSON.stringify({ compilerOptions: { moduleResolution: 'node' } }), + ) + tree.create( + '/tsconfig.app.json', + JSON.stringify({ + extends: './tsconfig.json', + files: ['src/main.ts'], + include: [], + }), + ) + tree.create( + '/tsconfig.spec.json', + JSON.stringify({ + extends: './tsconfig.json', + include: ['src/**/*.spec.ts'], + exclude: ['src/ignored.spec.ts'], + }), + ) + tree.create( + '/tsconfig.production.json', + JSON.stringify({ files: ['production/app.ts'] }), + ) + tree.create('/src/main.ts', `import './app'; import '../shared/query';`) + tree.create('/shared/query.ts', legacy) + tree.create('/src/app.spec.ts', legacy) + tree.create('/src/ignored.spec.ts', legacy) + tree.create('/src/orphan.ts', legacy) + tree.create('/production/app.ts', legacy) + tree.create( + '/projects/lib/tsconfig.lib.json', + JSON.stringify({ + extends: '../../tsconfig.json', + include: ['src/**/*.ts'], + }), + ) + tree.create( + '/projects/lib/src/lib.ts', + `export { injectDevtoolsPanel } from '@tanstack/angular-query-experimental/devtools-panel';`, + ) + tree.create( + '/projects/lib/package.json', + JSON.stringify({ + peerDependencies: { '@tanstack/angular-query-experimental': '^5' }, + }), + ) + const result = await runner.runSchematic( + 'migrate-from-experimental', + {}, + tree, + ) + for (const path of [ + '/src/app.ts', + '/shared/query.ts', + '/src/app.spec.ts', + '/production/app.ts', + ]) + expect(result.readContent(path)).toContain(packageJson.name) + expect(result.readContent('/src/ignored.spec.ts')).toBe(legacy) + expect(result.readContent('/src/orphan.ts')).toBe(legacy) + expect(result.readContent('/projects/lib/src/lib.ts')).toContain( + `${devtoolsPackage.name}/devtools-panel`, + ) + expect( + JSON.parse(result.readContent('/projects/lib/package.json')) + .peerDependencies, + ).toEqual({ + [packageJson.name]: `^${packageJson.version}`, + [devtoolsPackage.name]: `^${devtoolsPackage.version}`, + }) + expect( + JSON.parse(result.readContent('/package.json')).dependencies[ + devtoolsPackage.name + ], + ).toBeUndefined() + expect(runner.tasks).toHaveLength(1) + const again = await runner.runSchematic( + 'migrate-from-experimental', + {}, + result, + ) + expect(again.readContent('/projects/lib/src/lib.ts')).toBe( + result.readContent('/projects/lib/src/lib.ts'), + ) + expect(runner.tasks).toHaveLength(0) + }) + + it('summarizes prepared changes, links to the guide, and explains skipped installation', async () => { + const { runner, tree, messages } = setup( + `import { injectQuery } from '@tanstack/angular-query-experimental';`, + ) + await runner.runSchematic( + 'migrate-from-experimental', + { skipInstall: true }, + tree, + ) + expect(messages.join('\n')).toContain( + '1 source file(s) changed, 2 dependency change(s), 0 item(s) requiring manual review', + ) + expect(messages.join('\n')).toContain( + 'Dependency installation was skipped. Run your package manager install command', + ) + expect(messages.join('\n')).toContain( + 'run your application build, tests, linting, and formatting checks', + ) + expect(messages.join('\n')).toContain( + 'https://tanstack.com/query/latest/docs/framework/angular/guides/migrating-from-experimental', + ) + expect(runner.tasks).toHaveLength(0) + }) + it('reports panel injector arguments and the removed panel options type', async () => { + const { runner, tree, warnings } = + setup(`import { injectDevtoolsPanel as panel, type InjectDevtoolsPanelOptions } from '@tanstack/angular-query-experimental/devtools-panel'; +panel(() => ({}), { injector });`) + await runner.runSchematic('migrate-from-experimental', {}, tree) + expect(warnings).toHaveLength(2) + expect(warnings.join('\n')).toContain( + 'InjectDevtoolsPanelOptions was removed', + ) + expect(warnings.join('\n')).toContain('old options.injector') + }) +}) diff --git a/packages/angular-query/schematics-build/ng-add.test.ts b/packages/angular-query/schematics-build/ng-add.test.ts index 8417badae20..8de35398775 100644 --- a/packages/angular-query/schematics-build/ng-add.test.ts +++ b/packages/angular-query/schematics-build/ng-add.test.ts @@ -1,20 +1,18 @@ -import { createRequire } from 'node:module' -import { dirname, resolve } from 'node:path' import { describe, expect, it } from 'vitest' -import { SchematicTestRunner } from '@angular-devkit/schematics/testing' import angularQueryPackage from '../package.json' with { type: 'json' } +import { + SchematicTestRunner, + angularCollection, + queryCollection, +} from './test-utils' -const require = createRequire(import.meta.url) const angularRunner = new SchematicTestRunner( '@schematics/angular', - resolve( - dirname(require.resolve('@schematics/angular/package.json')), - 'collection.json', - ), + angularCollection, ) const queryRunner = new SchematicTestRunner( angularQueryPackage.name, - resolve('dist/schematics/collection.json'), + queryCollection, ) async function createAngularApplication(standalone = true) { diff --git a/packages/angular-query/schematics-build/test-utils.ts b/packages/angular-query/schematics-build/test-utils.ts new file mode 100644 index 00000000000..f04a87d5a91 --- /dev/null +++ b/packages/angular-query/schematics-build/test-utils.ts @@ -0,0 +1,34 @@ +import { createRequire } from 'node:module' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import type * as Schematics from '@angular-devkit/schematics' +import type * as Testing from '@angular-devkit/schematics/testing' + +// Point to an isolated installed Angular workspace to exercise the same tests +// against its DevKit, utilities, and TypeScript instead of the monorepo versions. +const compatibilityRoot = process.env.ANGULAR_SCHEMATICS_TEST_ROOT +const loadModule = createRequire( + compatibilityRoot + ? resolve(compatibilityRoot, 'package.json') + : import.meta.url, +) + +export const { Tree } = loadModule( + '@angular-devkit/schematics', +) as typeof Schematics +export const { SchematicTestRunner } = loadModule( + '@angular-devkit/schematics/testing', +) as typeof Testing +export const devtoolsPackage = JSON.parse( + readFileSync(resolve('../angular-query-devtools/package.json'), 'utf8'), +) as { name: string; version: string } +export const queryCollection = compatibilityRoot + ? resolve( + compatibilityRoot, + 'node_modules/@tanstack/angular-query/schematics/collection.json', + ) + : resolve('dist/schematics/collection.json') +export const angularCollection = resolve( + dirname(loadModule.resolve('@schematics/angular/package.json')), + 'collection.json', +) diff --git a/packages/angular-query/schematics/collection.json b/packages/angular-query/schematics/collection.json index 88f809203d4..6ecc4f306ac 100644 --- a/packages/angular-query/schematics/collection.json +++ b/packages/angular-query/schematics/collection.json @@ -5,6 +5,11 @@ "description": "Configure TanStack Query in an Angular application.", "factory": "./ng-add/index#ngAdd", "schema": "./ng-add/schema.json" + }, + "migrate-from-experimental": { + "description": "Migrate Angular Query experimental imports and unambiguous API calls to the stable package.", + "factory": "./migrate-from-experimental/index#migrate", + "schema": "./migrate-from-experimental/schema.json" } } } diff --git a/packages/angular-query/schematics/migrate-from-experimental/imports.ts b/packages/angular-query/schematics/migrate-from-experimental/imports.ts new file mode 100644 index 00000000000..f73f7196a41 --- /dev/null +++ b/packages/angular-query/schematics/migrate-from-experimental/imports.ts @@ -0,0 +1,96 @@ +import ts from 'typescript' + +/** Reuse visible value imports; add ordinary named imports only when needed. */ +export class MigrationImports { + private readonly usedNames = new Set() + private readonly additions: Array<{ + name: string + local: string + module: string + }> = [] + private readonly existing: Array<{ + name?: string + local: ts.Identifier + module: string + }> = [] + + constructor( + source: ts.SourceFile, + private readonly checker: ts.TypeChecker, + normalizeModule: (module: string) => string, + ) { + const collectNames = (node: ts.Node) => { + if (ts.isIdentifier(node)) this.usedNames.add(node.text) + ts.forEachChild(node, collectNames) + } + collectNames(source) + for (const statement of source.statements) { + if ( + !ts.isImportDeclaration(statement) || + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.importClause?.isTypeOnly + ) + continue + const bindings = statement.importClause?.namedBindings + const module = normalizeModule(statement.moduleSpecifier.text) + if (bindings && ts.isNamespaceImport(bindings)) { + this.existing.push({ local: bindings.name, module }) + } else if (bindings) { + for (const element of bindings.elements) { + if (!element.isTypeOnly) + this.existing.push({ + name: (element.propertyName ?? element.name).text, + local: element.name, + module, + }) + } + } + } + } + + reference(name: string, module: string, location: ts.Node): string { + for (const imported of this.existing) { + if ( + imported.module !== module || + (imported.name && imported.name !== name) + ) + continue + const visible = this.checker.resolveName( + imported.local.text, + location, + ts.SymbolFlags.Value | ts.SymbolFlags.Alias, + false, + ) + if (visible === this.checker.getSymbolAtLocation(imported.local)) { + return imported.name + ? imported.local.text + : `${imported.local.text}.${name}` + } + } + const added = this.additions.find( + (entry) => entry.name === name && entry.module === module, + ) + if (added) return added.local + + // Check all scopes so the new import cannot be shadowed at another call site. + let local = name + if (this.usedNames.has(local)) { + const base = name === 'inject' ? 'angularInject' : 'TanStackQueryClient' + local = base + let suffix = 2 + while (this.usedNames.has(local)) local = `${base}${suffix++}` + } + this.usedNames.add(local) + this.additions.push({ name, local, module }) + return local + } + + render(): string { + return this.additions + .map( + ({ name, local, module }) => + `import { ${name === local ? name : `${name} as ${local}`} } from '${module}';\n`, + ) + .join('') + } +} diff --git a/packages/angular-query/schematics/migrate-from-experimental/index.ts b/packages/angular-query/schematics/migrate-from-experimental/index.ts new file mode 100644 index 00000000000..d5bc19b7bf6 --- /dev/null +++ b/packages/angular-query/schematics/migrate-from-experimental/index.ts @@ -0,0 +1,144 @@ +import { + NodeDependencyType, + addPackageJsonDependency, + removePackageJsonDependency, +} from '@schematics/angular/utility/dependencies' +import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks' +import { OLD_PACKAGE, migrateSource } from './source' +import { migrationWorkspace, sourceManifest } from './workspace' +import type { Rule, Tree } from '@angular-devkit/schematics' + +const PACKAGE_NAME = '__ANGULAR_QUERY_PACKAGE_NAME__' +const PACKAGE_VERSION = '__ANGULAR_QUERY_PACKAGE_VERSION__' +const DEVTOOLS_PACKAGE_NAME = '__ANGULAR_QUERY_DEVTOOLS_PACKAGE_NAME__' +const DEVTOOLS_PACKAGE_VERSION = '__ANGULAR_QUERY_DEVTOOLS_PACKAGE_VERSION__' +const MIGRATION_GUIDE = + 'https://tanstack.com/query/latest/docs/framework/angular/guides/migrating-from-experimental' +const dependencyTypes = [ + NodeDependencyType.Default, + NodeDependencyType.Dev, + NodeDependencyType.Optional, + NodeDependencyType.Peer, +] + +/** Migrates syntactically unambiguous uses; reports cases requiring human review. */ +export function migrate(options: { skipInstall?: boolean }): Rule { + return async (tree, context) => { + const workspace = await migrationWorkspace(tree) + const devtoolsManifests = new Set() + let changedFiles = 0 + let reviewItems = 0 + const report = (message: string) => { + reviewItems++ + context.logger.warn(message) + } + for (const path of workspace.files) { + const text = tree.readText(path) + if (!text.includes(OLD_PACKAGE) && !text.includes(PACKAGE_NAME)) continue + const result = migrateSource( + text, + path, + PACKAGE_NAME, + DEVTOOLS_PACKAGE_NAME, + report, + ) + if (result.needsDevtools) { + const manifest = sourceManifest(path, workspace.manifests) + if (manifest) devtoolsManifests.add(manifest) + else + report( + `${path}: Add ${DEVTOOLS_PACKAGE_NAME} to the package manifest manually.`, + ) + } + if (result.text !== text) { + tree.overwrite(path, result.text) + changedFiles++ + } + } + let packageChanges = 0 + for (const manifest of workspace.manifests) + packageChanges += migrateDependencies( + tree, + manifest, + devtoolsManifests.has(manifest), + report, + ) + if (packageChanges && !options.skipInstall) + context.addTask(new NodePackageInstallTask()) + + context.logger.info( + `Angular Query migration prepared: ${changedFiles} source file(s) changed, ${packageChanges} dependency change(s), ${reviewItems} item(s) requiring manual review.`, + ) + if (packageChanges) + context.logger.info( + options.skipInstall + ? 'Dependency installation was skipped. Run your package manager install command after applying the migration.' + : 'Dependency installation is scheduled after the migration is applied.', + ) + context.logger.info( + 'Address the warnings, then run your application build, tests, linting, and formatting checks. Review SSR hydration, error handling, persistence factories, and tests waiting for stability. Angular >=20.1 is required.', + ) + context.logger.info(`Migration guide: ${MIGRATION_GUIDE}`) + return tree + } +} + +function migrateDependencies( + tree: Tree, + manifest: string, + needsDevtools: boolean, + report: (message: string) => void, +) { + const pkg = tree.readJson(manifest) as Partial< + Record> + > + let changes = 0 + for (const type of dependencyTypes) { + const dependencies = pkg[type] + if (!dependencies?.[OLD_PACKAGE]) continue + changes++ + if (!dependencies[PACKAGE_NAME]) { + addPackageJsonDependency( + tree, + { + type, + name: PACKAGE_NAME, + version: `^${PACKAGE_VERSION}`, + overwrite: false, + }, + manifest, + ) + changes++ + } + } + if (changes) removePackageJsonDependency(tree, OLD_PACKAGE, manifest) + if ( + needsDevtools && + !dependencyTypes.some((type) => pkg[type]?.[DEVTOOLS_PACKAGE_NAME]) + ) { + const type = + pkg.peerDependencies?.[OLD_PACKAGE] || + pkg.peerDependencies?.[PACKAGE_NAME] + ? NodeDependencyType.Peer + : NodeDependencyType.Default + addPackageJsonDependency( + tree, + { + type, + name: DEVTOOLS_PACKAGE_NAME, + version: `^${DEVTOOLS_PACKAGE_VERSION}`, + overwrite: false, + }, + manifest, + ) + changes++ + } + const metadata = tree.readJson(manifest) as { + peerDependenciesMeta?: Record + } + if (metadata.peerDependenciesMeta?.[OLD_PACKAGE]) + report( + `${manifest}: Rename peerDependenciesMeta["${OLD_PACKAGE}"] to "${PACKAGE_NAME}" and review the optional peer configuration.`, + ) + return changes +} diff --git a/packages/angular-query/schematics/migrate-from-experimental/schema.json b/packages/angular-query/schematics/migrate-from-experimental/schema.json new file mode 100644 index 00000000000..a5c65c69816 --- /dev/null +++ b/packages/angular-query/schematics/migrate-from-experimental/schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Migrate experimental Angular Query", + "type": "object", + "properties": { + "skipInstall": { + "type": "boolean", + "default": false, + "description": "Skip installing changed package dependencies." + } + }, + "additionalProperties": false +} diff --git a/packages/angular-query/schematics/migrate-from-experimental/source.ts b/packages/angular-query/schematics/migrate-from-experimental/source.ts new file mode 100644 index 00000000000..99ffaa4ca21 --- /dev/null +++ b/packages/angular-query/schematics/migrate-from-experimental/source.ts @@ -0,0 +1,489 @@ +import ts from 'typescript' +import { MigrationImports } from './imports' + +export const OLD_PACKAGE = '@tanstack/angular-query-experimental' + +// The experimental helpers used two different injector argument shapes. +const injectorArguments: Record< + string, + { index: number; kind: 'injector' | 'options' } | undefined +> = { + injectQueries: { index: 1, kind: 'injector' }, + injectQuery: { index: 1, kind: 'options' }, + injectInfiniteQuery: { index: 1, kind: 'options' }, + injectMutation: { index: 1, kind: 'options' }, + injectMutationState: { index: 1, kind: 'options' }, + injectIsFetching: { index: 1, kind: 'options' }, + injectIsMutating: { index: 1, kind: 'options' }, + injectIsRestoring: { index: 0, kind: 'options' }, + injectDevtoolsPanel: { index: 1, kind: 'options' }, +} +const removedInjectorOptions = new Set([ + 'InjectQueryOptions', + 'InjectInfiniteQueryOptions', + 'InjectMutationOptions', + 'InjectMutationStateOptions', + 'InjectIsFetchingOptions', + 'InjectIsMutatingOptions', + 'InjectDevtoolsPanelOptions', +]) +const removedApis: Record = { + provideIsRestoring: + 'Restoration is managed by withPersistQueryClient. Review custom restoration providers.', + queryFeature: + 'Custom feature construction is no longer public. Use the supported with* features.', + QueryFeatures: 'Use the non-generic QueryFeature type.', + DevtoolsFeature: 'Use the non-generic QueryFeature type.', + PersistQueryClientFeature: 'Use the non-generic QueryFeature type.', + CreateBaseQueryOptions: 'Use CreateQueryOptions and review typed wrappers.', +} + +/** Source edits preserve untouched formatting; symbols protect shadowed local names. */ +export function migrateSource( + text: string, + path: string, + packageName: string, + devtoolsPackageName: string, + report: (message: string) => void, +) { + const result = { needsDevtools: false } + const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true) + // Binding symbols distinguish imports from identically named local parameters. + const host = ts.createCompilerHost({ noLib: true }) + host.getSourceFile = (name) => (name === path ? source : undefined) + const checker = ts + .createProgram([path], { noLib: true, noResolve: true }, host) + .getTypeChecker() + const bindings = new Map() + const namespaces = new Set() + const edits: Array<{ start: number; end: number; text: string }> = [] + const replace = (node: ts.Node, value: string) => { + const start = node.getStart(source) + // A whole-import removal supersedes its earlier module-specifier edit. + for (let index = edits.length - 1; index >= 0; index--) { + if (edits[index]!.start >= start && edits[index]!.end <= node.end) + edits.splice(index, 1) + } + edits.push({ start, end: node.end, text: value }) + } + const warn = (node: ts.Node, message: string) => + report( + `${path}:${source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1}: ${message}`, + ) + const renames: Record = { + provideAngularQuery: 'provideTanStackQuery', + provideQueryClient: 'provideTanStackQuery', + } + const imports = new MigrationImports(source, checker, (module) => + module === OLD_PACKAGE || + module === `${OLD_PACKAGE}/inject-queries-experimental` + ? packageName + : module, + ) + + function migrateImports() { + for (const statement of source.statements) { + if ( + (!ts.isImportDeclaration(statement) && + !ts.isExportDeclaration(statement)) || + !statement.moduleSpecifier || + !ts.isStringLiteral(statement.moduleSpecifier) + ) + continue + const module = statement.moduleSpecifier.text + const targets: Record = { + [OLD_PACKAGE]: packageName, + [`${OLD_PACKAGE}/inject-queries-experimental`]: packageName, + [`${OLD_PACKAGE}/devtools`]: devtoolsPackageName, + [`${OLD_PACKAGE}/devtools/production`]: `${devtoolsPackageName}/production`, + [`${OLD_PACKAGE}/devtools-panel`]: `${devtoolsPackageName}/devtools-panel`, + [`${OLD_PACKAGE}/devtools-panel/production`]: `${devtoolsPackageName}/devtools-panel/production`, + } + const target = targets[module] + const isDevtools = + target?.startsWith(devtoolsPackageName) || + module === devtoolsPackageName || + module.startsWith(`${devtoolsPackageName}/`) + if (isDevtools) result.needsDevtools = true + if (target) replace(statement.moduleSpecifier, JSON.stringify(target)) + else if (module.startsWith(`${OLD_PACKAGE}/`)) + warn( + statement, + 'Unrecognized experimental entrypoint. Update this package reference manually.', + ) + if ( + module !== OLD_PACKAGE && + module !== packageName && + module !== `${OLD_PACKAGE}/inject-queries-experimental` && + !isDevtools + ) + continue + if (ts.isExportDeclaration(statement)) { + warn( + statement, + 'Review re-exported APIs and migrate their consumers manually.', + ) + if ( + statement.exportClause && + ts.isNamedExports(statement.exportClause) + ) { + for (const element of statement.exportClause.elements) + warnRemovedApi(element, (element.propertyName ?? element.name).text) + } + continue + } + const named = statement.importClause?.namedBindings + if (!named) continue + if (ts.isNamespaceImport(named)) { + const symbol = checker.getSymbolAtLocation(named.name) + if (symbol) namespaces.add(symbol) + } else { + const specifiers: Array = [] + for (const element of named.elements) { + const name = (element.propertyName ?? element.name).text + const symbol = checker.getSymbolAtLocation(element.name) + if (symbol) bindings.set(symbol, name) + warnRemovedApi(element, name) + const removable = + name === 'injectQueryClient' && canRemoveHelperImport(element) + if (!removable) + specifiers.push( + !isDevtools && renames[name] + ? `${element.isTypeOnly ? 'type ' : ''}${renames[name]} as ${element.name.text}` + : element.getText(source), + ) + if (name === 'injectQueryClient' && !removable) + warn( + element, + 'Review non-call references or arguments to removed injectQueryClient; its import requires manual removal.', + ) + } + if ( + specifiers.join(', ') !== + named.elements.map((element) => element.getText(source)).join(', ') + ) { + if (specifiers.length === 0 && !statement.importClause.name) + replace(statement, '') + else replace(named, `{ ${specifiers.join(', ')} }`) + } + } + } + } + + function warnRemovedApi(node: ts.Node, name: string) { + if (removedInjectorOptions.has(name)) + warn( + node, + `${name} was removed. Review typed wrappers and move injector handling to runInInjectionContext.`, + ) + else if (removedApis[name]) + warn(node, `${name} was removed. ${removedApis[name]}`) + } + + function canRemoveHelperImport(element: ts.ImportSpecifier) { + const symbol = checker.getSymbolAtLocation(element.name) + if (!symbol) return false + function hasUnsupportedReference(node: ts.Node): boolean { + if ( + ts.isIdentifier(node) && + checker.getSymbolAtLocation(node) === symbol && + node !== element.name && + node !== element.propertyName + ) { + return ( + !ts.isCallExpression(node.parent) || + node.parent.expression !== node || + node.parent.arguments.length !== 0 + ) + } + return ts.forEachChild(node, hasUnsupportedReference) ?? false + } + return !hasUnsupportedReference(source) + } + + const apiName = ( + expression: ts.Expression | ts.EntityName, + ): string | undefined => { + if (ts.isIdentifier(expression)) { + const symbol = checker.getSymbolAtLocation(expression) + return symbol && bindings.get(symbol) + } + if (ts.isPropertyAccessExpression(expression)) { + const symbol = checker.getSymbolAtLocation(expression.expression) + if (symbol && namespaces.has(symbol)) return expression.name.text + } + if (ts.isQualifiedName(expression)) { + const symbol = checker.getSymbolAtLocation(expression.left) + if (symbol && namespaces.has(symbol)) return expression.right.text + } + return undefined + } + function migrateCall(node: ts.CallExpression) { + const name = apiName(node.expression) + if (name) warnAboutInjectorArgument(node, name) + switch (name) { + case 'provideTanStackQuery': + case 'provideAngularQuery': + case 'provideQueryClient': + migrateProvider(node) + break + case 'injectQueryClient': + migrateClientInjection(node) + break + case 'injectIsFetching': + case 'injectIsMutating': + migrateActivityFilter(node) + break + } + if ( + name && + [ + 'injectQuery', + 'injectInfiniteQuery', + 'injectMutation', + 'queryOptions', + 'infiniteQueryOptions', + 'mutationOptions', + ].includes(name) + ) + warnAboutObserverOptions(node.arguments[0]) + if (name === 'withDevtools') { + warnAboutOption( + node.arguments[1], + 'deps', + 'withDevtools no longer accepts deps. Call inject() inside the devtools options callback and remove deps.', + ) + } + } + + function warnAboutInjectorArgument(node: ts.CallExpression, name: string) { + const argument = injectorArguments[name] + if (!argument) return + if (node.arguments.some(ts.isSpreadElement)) { + warn( + node, + `Review spread arguments to ${name}; the stable helper no longer accepts an injector argument or injector options.`, + ) + return + } + if (node.arguments.length <= argument.index) return + const guidance = + argument.kind === 'injector' + ? 'Pass the old injector argument to runInInjectionContext' + : 'Pass the old options.injector to runInInjectionContext (not the options object itself)' + warn( + node, + `${name} no longer accepts ${argument.kind === 'injector' ? 'an injector argument' : 'injector options'}. ${guidance}, call ${name} inside its callback, and remove the old argument. If it is undefined or contains no injector, remove it and ensure the call is in an injection context.`, + ) + } + + function migrateProvider(node: ts.CallExpression) { + const first = node.arguments[0] + if ( + first && + ts.isNewExpression(first) && + apiName(first.expression) === 'QueryClient' + ) { + edits.push({ + start: first.getStart(source), + end: first.getStart(source), + text: '() => ', + }) + } else if ( + first && + !ts.isArrowFunction(first) && + !ts.isFunctionExpression(first) + ) + warn( + first, + 'Client reference may be an instance, token, or factory; provide a client factory manually.', + ) + if (ts.isSpreadElement(node.parent)) + edits.push({ + start: node.parent.getStart(source), + end: node.getStart(source), + text: '', + }) + warn( + node, + 'Verify this provider is in an environment injector; review default SSR hydration and PendingTasks behavior.', + ) + } + + function unwrap(node: ts.Expression): ts.Expression { + return ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) || + ts.isSatisfiesExpression(node) || + ts.isNonNullExpression(node) + ? unwrap(node.expression) + : node + } + + function property(node: ts.Expression | undefined, name: string) { + if (!node) return undefined + node = unwrap(node) + if (!ts.isObjectLiteralExpression(node)) return undefined + return node.properties.find( + ( + entry, + ): entry is ts.PropertyAssignment | ts.ShorthandPropertyAssignment => + (ts.isPropertyAssignment(entry) || + ts.isShorthandPropertyAssignment(entry)) && + (ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) && + entry.name.text === name, + ) + } + + function propertyValue(node: ts.Expression | undefined, name: string) { + const entry = property(node, name) + return ( + entry && (ts.isPropertyAssignment(entry) ? entry.initializer : entry.name) + ) + } + + function warnAboutOption( + node: ts.Expression | undefined, + name: string, + message: string, + ) { + const entry = property(node, name) + if (entry) warn(entry, message) + } + + function warnAboutObserverOptions(node: ts.Expression | undefined) { + if (!node) return + node = unwrap(node) + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + if (ts.isBlock(node.body)) { + const returns = (child: ts.Node) => { + if (ts.isReturnStatement(child)) + warnAboutObserverOptions(child.expression) + else if (!ts.isFunctionLike(child)) ts.forEachChild(child, returns) + } + ts.forEachChild(node.body, returns) + } else warnAboutObserverOptions(node.body) + } else if (ts.isConditionalExpression(node)) { + warnAboutObserverOptions(node.whenTrue) + warnAboutObserverOptions(node.whenFalse) + } else + warnAboutOption( + node, + 'throwOnError', + 'Observer throwOnError was removed. Read the error signal or configure an ErrorHandler through cache callbacks. Imperative refetch throwOnError remains supported.', + ) + } + + function warnAboutIndirectReference(node: ts.Expression, name: string) { + if (ts.isCallExpression(node.parent) && node.parent.expression === node) + return + if (name === 'injectQueryClient') + warn( + node, + 'injectQueryClient was removed. Replace this indirect reference with a function that calls inject(QueryClient), and review its consumers.', + ) + else if (name === 'provideTanStackQuery' || renames[name]) + warn( + node, + 'Review consumers of this provider reference: provideTanStackQuery expects a QueryClient factory and returns one EnvironmentProviders value.', + ) + else if (injectorArguments[name]) + warn( + node, + `Review consumers of this ${name} reference for removed injector arguments${name === 'injectIsFetching' || name === 'injectIsMutating' ? ' and callback-based filters' : ''}.`, + ) + } + + function migrateClientInjection(node: ts.CallExpression) { + if (node.arguments.length !== 0) { + warn( + node, + 'Migrate injectQueryClient arguments manually using Angular inject or runInInjectionContext.', + ) + return + } + const inject = imports.reference('inject', '@angular/core', node) + const client = imports.reference('QueryClient', packageName, node) + replace(node, `${inject}(${client})`) + } + + function migrateActivityFilter(node: ts.CallExpression) { + const first = node.arguments[0] + if (!first) return + if (ts.isObjectLiteralExpression(first)) { + edits.push( + { + start: first.getStart(source), + end: first.getStart(source), + text: '() => (', + }, + { start: first.end, end: first.end, text: ')' }, + ) + } else if (!ts.isArrowFunction(first) && !ts.isFunctionExpression(first)) + warn(first, 'Review activity filter: the stable API expects a callback.') + } + + function visit(node: ts.Node) { + if ( + ts.isStringLiteral(node) && + node.text.startsWith(OLD_PACKAGE) && + !ts.isImportDeclaration(node.parent) && + !ts.isExportDeclaration(node.parent) + ) { + warn( + node, + 'Review this package reference manually (for example, a dynamic import or require).', + ) + } + if (ts.isCallExpression(node)) migrateCall(node) + if (ts.isPropertyAccessExpression(node) || ts.isQualifiedName(node)) { + const name = apiName(node) + if (name) { + warnRemovedApi(node, name) + if (ts.isPropertyAccessExpression(node)) { + if (renames[name]) replace(node.name, renames[name]) + warnAboutIndirectReference(node, name) + } + } + } + if ( + ts.isIdentifier(node) && + !ts.isImportSpecifier(node.parent) && + !ts.isPropertyAccessExpression(node.parent) && + !ts.isQualifiedName(node.parent) && + !ts.isTypeReferenceNode(node.parent) + ) { + const name = apiName(node) + if (name) warnAboutIndirectReference(node, name) + } + if ( + ts.isTypeReferenceNode(node) && + apiName(node.typeName) === 'QueryFeature' && + node.typeArguments?.length + ) + warn( + node, + 'QueryFeature is no longer generic. Remove its type arguments and use features returned by the supported with* functions.', + ) + if ( + ts.isNewExpression(node) && + apiName(node.expression) === 'QueryClient' + ) { + const defaults = propertyValue(node.arguments?.[0], 'defaultOptions') + warnAboutObserverOptions(propertyValue(defaults, 'queries')) + warnAboutObserverOptions(propertyValue(defaults, 'mutations')) + } + ts.forEachChild(node, visit) + } + migrateImports() + visit(source) + const newImports = imports.render() + if (newImports) { + // Insert after existing statements' leading comments (and a possible shebang). + const position = source.statements[0]?.getStart(source) ?? text.length + edits.push({ start: position, end: position, text: newImports }) + } + let output = text + for (const edit of edits.sort((a, b) => b.start - a.start || b.end - a.end)) + output = output.slice(0, edit.start) + edit.text + output.slice(edit.end) + return { text: output, needsDevtools: result.needsDevtools } +} diff --git a/packages/angular-query/schematics/migrate-from-experimental/workspace.ts b/packages/angular-query/schematics/migrate-from-experimental/workspace.ts new file mode 100644 index 00000000000..fe0293921a8 --- /dev/null +++ b/packages/angular-query/schematics/migrate-from-experimental/workspace.ts @@ -0,0 +1,174 @@ +import { posix } from 'node:path' +import { getWorkspace } from '@schematics/angular/utility/workspace' +import { SchematicsException } from '@angular-devkit/schematics' +import ts from 'typescript' +import type { Tree } from '@angular-devkit/schematics' + +// cspell:ignore subdirs + +const ignoredDirectories = new Set([ + 'node_modules', + 'dist', + 'build', + '.git', + '.angular', + '.nx', + '.cache', + 'coverage', +]) + +function isSource(path: string) { + return /\.[cm]?tsx?$/.test(path) && !/\.d\.[cm]?ts$/.test(path) +} + +/** Visit source directories without descending into dependencies or generated output. */ +function sourcesUnder(tree: Tree, root: string): Array { + const files: Array = [] + const visit = (path: string) => { + const directory = tree.getDir(path) + for (const name of directory.subfiles) { + const file = posix.join(path, name) + if (isSource(file)) files.push(file) + } + for (const name of directory.subdirs) { + if (!ignoredDirectories.has(name)) visit(posix.join(path, name)) + } + } + visit(root) + return files +} + +/** TypeScript config patterns support *, ? and **; a bare directory includes its descendants. */ +function configPattern(root: string, pattern: string): RegExp { + let path = posix.resolve(root, pattern) + if (!/[?*]/.test(path) && !posix.extname(path)) path += '/**/*' + const segments = path.split('/') + const expression = segments + .map((segment, index) => { + if (segment === '**') + return index === segments.length - 1 ? '.*' : '(?:[^/]+/)*' + const value = segment + .split('') + .map((character) => { + if (character === '*') return '[^/]*' + if (character === '?') return '[^/]' + return character.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&') + }) + .join('') + return value + (index < segments.length - 1 ? '/' : '') + }) + .join('') + return new RegExp(`^${expression}$`) +} + +function configuredSources(tree: Tree, configPath: string) { + const readFile = (path: string) => + tree.exists(path) ? tree.readText(path) : undefined + const config = ts.getParsedCommandLineOfConfigFile( + configPath, + { noLib: true }, + { + useCaseSensitiveFileNames: true, + getCurrentDirectory: () => '/', + fileExists: (path) => tree.exists(path), + readFile, + readDirectory: (root, extensions, excludes, includes) => { + const patterns = includes + const roots = new Set([root]) + for (const pattern of patterns) { + const prefix = posix.resolve(root, pattern).split(/[?*]/)[0]! + roots.add(prefix.endsWith('/') ? prefix : posix.dirname(prefix)) + } + const candidates = new Set( + [...roots].flatMap((path) => sourcesUnder(tree, path)), + ) + return [...candidates].filter( + (path) => + extensions.some((extension) => path.endsWith(extension)) && + patterns.some((pattern) => + configPattern(root, pattern).test(path), + ) && + !(excludes ?? []).some((pattern) => + configPattern(root, pattern).test(path), + ), + ) + }, + onUnRecoverableConfigFileDiagnostic: (diagnostic) => { + throw new SchematicsException( + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), + ) + }, + }, + ) + if (!config) return [] + const host = ts.createCompilerHost(config.options) + host.getCurrentDirectory = () => '/' + host.fileExists = (path) => tree.exists(path) + host.readFile = readFile + host.realpath = (path) => path + host.directoryExists = (path) => { + const directory = tree.getDir(path) + return directory.subfiles.length > 0 || directory.subdirs.length > 0 + } + host.getSourceFile = (path) => { + if (path.split('/').some((part) => ignoredDirectories.has(part))) + return undefined + const text = readFile(path) + return text === undefined + ? undefined + : ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true) + } + return ts + .createProgram(config.fileNames, config.options, host) + .getSourceFiles() + .map((source) => source.fileName) + .filter(isSource) +} + +/** Use build/test configurations, including inherited configs and relative source imports. */ +export async function migrationWorkspace(tree: Tree) { + const manifests = new Set() + if (tree.exists('/package.json')) manifests.add('/package.json') + if (!tree.exists('/angular.json') && !tree.exists('/.angular.json')) + return { files: sourcesUnder(tree, '/'), manifests: [...manifests] } + + const workspace = await getWorkspace( + tree, + tree.exists('/angular.json') ? '/angular.json' : '/.angular.json', + ) + const files = new Set() + for (const project of workspace.projects.values()) { + const manifest = posix.resolve('/', project.root, 'package.json') + if (tree.exists(manifest)) manifests.add(manifest) + const configs = new Set() + for (const target of project.targets.values()) { + for (const options of [ + target.options, + ...Object.values(target.configurations ?? {}), + ]) { + if ( + typeof options?.tsConfig === 'string' && + tree.exists(options.tsConfig) + ) + configs.add(posix.resolve('/', options.tsConfig)) + } + } + const sources = configs.size + ? [...configs].flatMap((path) => configuredSources(tree, path)) + : sourcesUnder( + tree, + posix.resolve('/', project.sourceRoot ?? project.root), + ) + for (const path of sources) files.add(path) + } + return { files: [...files], manifests: [...manifests] } +} + +/** Associate each source with its closest declared workspace package. */ +export function sourceManifest(path: string, manifests: Array) { + return [...manifests] + .sort((a, b) => b.length - a.length) + .find((manifest) => + path.startsWith(`${posix.dirname(manifest).replace(/\/$/, '')}/`), + ) +} diff --git a/packages/angular-query/scripts/inject-schematic-package-info.js b/packages/angular-query/scripts/inject-schematic-package-info.js index c2911f1ef2e..506976e36a7 100644 --- a/packages/angular-query/scripts/inject-schematic-package-info.js +++ b/packages/angular-query/scripts/inject-schematic-package-info.js @@ -11,27 +11,28 @@ const devtoolsPackage = readPackageJson( path.resolve(packageDir, '../angular-query-devtools/package.json'), ) -const outputPath = path.join(packageDir, 'dist/schematics/ng-add/index.js') -let output = fs.readFileSync(outputPath, 'utf8') - const replacements = new Map([ ['__ANGULAR_QUERY_PACKAGE_NAME__', queryPackage.name], + ['__ANGULAR_QUERY_PACKAGE_VERSION__', queryPackage.version], ['__ANGULAR_QUERY_DEVTOOLS_PACKAGE_NAME__', devtoolsPackage.name], ['__ANGULAR_QUERY_DEVTOOLS_PACKAGE_VERSION__', devtoolsPackage.version], ]) -for (const [placeholder, value] of replacements) { - if (output.includes(placeholder)) { +for (const schematic of ['ng-add', 'migrate-from-experimental']) { + const outputPath = path.join( + packageDir, + `dist/schematics/${schematic}/index.js`, + ) + let output = fs.readFileSync(outputPath, 'utf8') + for (const [placeholder, value] of replacements) { output = output.replaceAll(placeholder, value) - } else if (!output.includes(value)) { - throw new Error( - `Could not find ${placeholder} or its expected value in ${outputPath}.`, - ) } + if (output.includes('__ANGULAR_QUERY_')) { + throw new Error(`Unresolved package metadata in ${outputPath}.`) + } + fs.writeFileSync(outputPath, output) } -fs.writeFileSync(outputPath, output) - /** * @param {string} packageJsonPath * @returns {{ name: string; version: string }} diff --git a/packages/angular-query/scripts/prepack.js b/packages/angular-query/scripts/prepack.js index 033e008dc09..3a76cc65d0b 100644 --- a/packages/angular-query/scripts/prepack.js +++ b/packages/angular-query/scripts/prepack.js @@ -24,6 +24,7 @@ const FILES_TO_COPY = [ 'schematics/collection.json', 'schematics/package.json', 'schematics/ng-add/schema.json', + 'schematics/migrate-from-experimental/schema.json', ] /**