feat(core): Add a schematics to migrate provideHttpClient to keep using the HttpXhrBackend implementation.

Exisiting applications will be migrated to keep using the XHR backend to prevent any breaking changes. `withXhr()` is to the `provideHttpClient` provider function.
This commit is contained in:
Matthieu Riegler 2024-10-15 12:04:06 -06:00 committed by Jessica Janiuk
parent 5c432fb8bb
commit 3bc095d508
9 changed files with 354 additions and 31 deletions

View file

@ -1,15 +0,0 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {FetchBackend} from './fetch';
/**
* A constant defining the default the default Http Backend.
* Extracted to a separate file to facilitate G3 patches.
*/
export const NG_DEFAULT_HTTP_BACKEND = FetchBackend;

View file

@ -8,11 +8,10 @@
import {
DestroyRef,
ɵformatRuntimeError as formatRuntimeError,
inject,
Injectable,
InjectionToken,
NgZone,
ɵformatRuntimeError as formatRuntimeError,
} from '@angular/core';
import {Observable, Observer} from 'rxjs';
import {RuntimeErrorCode} from './errors';
@ -35,14 +34,6 @@ import type {} from 'zone.js';
const XSSI_PREFIX = /^\)\]\}',?\n/;
/**
* An internal injection token to reference `FetchBackend` implementation
* in a tree-shakable way.
*/
export const FETCH_BACKEND = new InjectionToken<FetchBackend>(
typeof ngDevMode === 'undefined' || ngDevMode ? 'FETCH_BACKEND' : '',
);
/**
* Uses `fetch` to send requests to a backend server.
*

View file

@ -15,9 +15,8 @@ import {
} from '@angular/core';
import {HttpBackend, HttpHandler, HttpInterceptorHandler} from './backend';
import {NG_DEFAULT_HTTP_BACKEND} from './backend-default-value';
import {HttpClient} from './client';
import {FETCH_BACKEND, FetchBackend} from './fetch';
import {FetchBackend} from './fetch';
import {HTTP_INTERCEPTOR_FNS, HttpInterceptorFn, legacyInterceptorFnFactory} from './interceptor';
import {
jsonpCallbackContext,
@ -27,7 +26,6 @@ import {
} from './jsonp';
import {HttpXhrBackend} from './xhr';
import {XSRF_COOKIE_NAME, XSRF_ENABLED, XSRF_HEADER_NAME, xsrfInterceptorFn} from './xsrf';
import {NG_DEFAULT_HTTP_BACKEND} from './backend-default-value';
/**
* Identifies a particular kind of `HttpFeature`.
@ -113,13 +111,13 @@ export function provideHttpClient(
const providers: Provider[] = [
HttpClient,
NG_DEFAULT_HTTP_BACKEND,
FetchBackend,
HttpInterceptorHandler,
{provide: HttpHandler, useExisting: HttpInterceptorHandler},
{
provide: HttpBackend,
useFactory: () => {
return inject(FETCH_BACKEND, {optional: true}) ?? inject(NG_DEFAULT_HTTP_BACKEND);
return inject(FetchBackend);
},
},
{
@ -298,7 +296,6 @@ export function withRequestsMadeViaParent(): HttpFeature<HttpFeatureKind.Request
export function withFetch(): HttpFeature<HttpFeatureKind.Fetch> {
return makeHttpFeature(HttpFeatureKind.Fetch, [
FetchBackend,
{provide: FETCH_BACKEND, useExisting: FetchBackend},
{provide: HttpBackend, useExisting: FetchBackend},
]);
}

View file

@ -121,6 +121,10 @@ bundle_entrypoints = [
"router-testing-module-migration",
"packages/core/schematics/ng-generate/router-testing-module-migration/index.js",
],
[
"http-xhr-backend",
"packages/core/schematics/migrations/http-xhr-backend/index.js",
],
]
rollup.rollup(
@ -133,6 +137,7 @@ rollup.rollup(
"//:node_modules/semver",
"//packages/core/schematics:tsconfig_build",
"//packages/core/schematics/migrations/change-detection-eager",
"//packages/core/schematics/migrations/http-xhr-backend",
"//packages/core/schematics/ng-generate/cleanup-unused-imports",
"//packages/core/schematics/ng-generate/common-to-standalone-migration",
"//packages/core/schematics/ng-generate/control-flow-migration",

View file

@ -4,6 +4,11 @@
"version": "22.0.0",
"description": "Adds `ChangeDetectionStrategy.Eager` to all components.",
"factory": "./bundles/change-detection-eager.cjs#migrate"
},
"http-xhr-backend": {
"version": "22.0.0",
"description": "Adds 'withXhr' to 'provideHttpClient' function calls when the 'HttpXhrBackend' is used. For more information see: https://angular.dev/api/common/http/withXhr",
"factory": "./bundles/http-xhr-backend.cjs#migrate"
}
}
}

View file

@ -0,0 +1,41 @@
load("//tools:defaults.bzl", "jasmine_test", "ts_project")
package(
default_visibility = [
"//packages/core/schematics:__pkg__",
"//packages/core/schematics/test:__pkg__",
],
)
ts_project(
name = "http-xhr-backend",
srcs = glob(
["**/*.ts"],
exclude = ["*.spec.ts"],
),
deps = [
"//:node_modules/@angular-devkit/schematics",
"//:node_modules/typescript",
"//packages/compiler-cli/private",
"//packages/core/schematics/utils",
"//packages/core/schematics/utils/tsurge",
"//packages/core/schematics/utils/tsurge/helpers/angular_devkit",
],
)
ts_project(
name = "test_lib",
testonly = True,
srcs = glob(["*.spec.ts"]),
deps = [
":http-xhr-backend",
"//:node_modules/typescript",
"//packages/compiler-cli",
"//packages/core/schematics/utils/tsurge",
],
)
jasmine_test(
name = "test",
data = [":test_lib"],
)

View file

@ -0,0 +1,128 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {absoluteFrom} from '@angular/compiler-cli';
import {initMockFileSystem} from '@angular/compiler-cli/private/testing';
import {runTsurgeMigration} from '../../utils/tsurge/testing';
import {XhrBackendMigration} from './migration';
describe('http fetch backend migration', () => {
beforeEach(() => {
initMockFileSystem('Native');
});
it('should update an empty provideHttpClient', async () => {
const {fs} = await runTsurgeMigration(new XhrBackendMigration(), [
{
name: absoluteFrom('/index.ts'),
isProgramRootFile: true,
contents: `
import {AppConfig} from '@angular/core';
import {provideHttpClient} from '@angular/common/http';
const config: AppConfig = [
provideHttpClient(),
]
`,
},
]);
const actual = fs.readFile(absoluteFrom('/index.ts'));
expect(actual).toContain('provideHttpClient(withXhr())');
});
it('should update provideHttpClient without withFetch', async () => {
const {fs} = await runTsurgeMigration(new XhrBackendMigration(), [
{
name: absoluteFrom('/index.ts'),
isProgramRootFile: true,
contents: `
import {AppConfig} from '@angular/core';
import {provideHttpClient, withInterceptorsFromDi, withXsrfConfiguration} from '@angular/common/http';
const config: AppConfig = [
provideHttpClient(withInterceptorsFromDi(), withXsrfConfiguration({})),
]
`,
},
]);
const actual = fs.readFile(absoluteFrom('/index.ts'));
expect(actual).toContain(
'provideHttpClient(withXhr(), withInterceptorsFromDi(), withXsrfConfiguration({}))',
);
expect(actual).toMatch(/import \{.*withXhr.*\}/);
});
it('should update provideHttpClient to remove withFetch', async () => {
const {fs} = await runTsurgeMigration(new XhrBackendMigration(), [
{
name: absoluteFrom('/index.ts'),
isProgramRootFile: true,
contents: `
import {AppConfig} from '@angular/core';
import {provideHttpClient, withFetch, withInterceptorsFromDi, withXsrfConfiguration} from '@angular/common/http';
const config: AppConfig = [
provideHttpClient(withFetch(), withInterceptorsFromDi(), withXsrfConfiguration({})),
]
`,
},
]);
const actual = fs.readFile(absoluteFrom('/index.ts'));
expect(actual).toContain(
'provideHttpClient(withInterceptorsFromDi(), withXsrfConfiguration({}))',
);
expect(actual).not.toContain('withFetch');
});
it('should update provideHttpClient to remove withFetch as only arg', async () => {
const {fs} = await runTsurgeMigration(new XhrBackendMigration(), [
{
name: absoluteFrom('/index.ts'),
isProgramRootFile: true,
contents: `
import {AppConfig} from '@angular/core';
import {provideHttpClient, withFetch, withInterceptorsFromDi, withXsrfConfiguration} from '@angular/common/http';
const config: AppConfig = [
provideHttpClient(withFetch()),
]
`,
},
]);
const actual = fs.readFile(absoluteFrom('/index.ts'));
expect(actual).toContain('provideHttpClient()');
expect(actual).not.toContain('withFetch');
});
it('should not update provideHttpClient if withXhr is already present', async () => {
const {fs} = await runTsurgeMigration(new XhrBackendMigration(), [
{
name: absoluteFrom('/index.ts'),
isProgramRootFile: true,
contents: `
import {AppConfig} from '@angular/core';
import {provideHttpClient, withXhr, withInterceptorsFromDi, withXsrfConfiguration} from '@angular/common/http';
const config: AppConfig = [
provideHttpClient(withXhr(), withInterceptorsFromDi(), withXsrfConfiguration({})),
]
`,
},
]);
const actual = fs.readFile(absoluteFrom('/index.ts'));
expect(actual).toContain(
'provideHttpClient(withXhr(), withInterceptorsFromDi(), withXsrfConfiguration({})),',
);
expect(actual).not.toContain('withFetch');
});
});

View file

@ -0,0 +1,20 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Rule} from '@angular-devkit/schematics';
import {runMigrationInDevkit} from '../../utils/tsurge/helpers/angular_devkit';
import {XhrBackendMigration} from './migration';
export function migrate(): Rule {
return async (tree) => {
await runMigrationInDevkit({
tree,
getMigration: () => new XhrBackendMigration(),
});
};
}

View file

@ -0,0 +1,151 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ImportManager} from '@angular/compiler-cli/private/migrations';
import ts from 'typescript';
import {
confirmAsSerializable,
ProgramInfo,
projectFile,
ProjectFile,
Replacement,
Serializable,
TextUpdate,
TsurgeFunnelMigration,
} from '../../utils/tsurge';
import {applyImportManagerChanges} from '../../utils/tsurge/helpers/apply_import_manager';
import {getImportSpecifier, getNamedImports} from '../../utils/typescript/imports';
const HTTP = '@angular/common/http';
const provideHttpClient = 'provideHttpClient';
const WITH_FETCH = 'withFetch';
const WITH_XHR = 'withXhr';
const CORE_PACKAGE = '@angular/core';
const HTTP_PACKAGE = '@angular/common/http';
const PROVIDE_HTTP_CLIENT = 'provideHttpClient';
export interface CompilationUnitData {
replacements: Replacement[];
}
export interface MigrationConfig {
/**
* Whether to migrate this component template to self-closing tags.
*/
shouldMigrate?: (containingFile: ProjectFile) => boolean;
}
const provideHttpClientIdentifier = ts.factory.createIdentifier('provideHttpClient');
export class XhrBackendMigration extends TsurgeFunnelMigration<
CompilationUnitData,
CompilationUnitData
> {
constructor(private readonly config: MigrationConfig = {}) {
super();
}
override async analyze(info: ProgramInfo): Promise<Serializable<CompilationUnitData>> {
const replacements: Replacement[] = [];
const importManager = new ImportManager();
for (const sourceFile of info.sourceFiles) {
const walk = (node: ts.Node): void => {
const file = projectFile(sourceFile, info);
if (this.config.shouldMigrate && !this.config.shouldMigrate(file)) {
return;
}
const httpImports = getNamedImports(sourceFile, HTTP);
if (!httpImports) {
return;
}
const importSpecifier = getImportSpecifier(sourceFile, HTTP, provideHttpClient);
if (!importSpecifier) {
return;
}
node.forEachChild(walk);
if (!ts.isCallExpression(node)) return;
if (!ts.isIdentifier(node.expression)) return;
if (node.expression.text !== 'provideHttpClient') return;
const withFetchNode = node.arguments.find((arg) => {
return (
ts.isCallExpression(arg) &&
ts.isIdentifier(arg.expression) &&
arg.expression.text === WITH_FETCH
);
});
const withXhrNode = node.arguments.find((arg) => {
return (
ts.isCallExpression(arg) &&
ts.isIdentifier(arg.expression) &&
arg.expression.text === WITH_XHR
);
});
if (!withFetchNode && !withXhrNode) {
replacements.push(
new Replacement(
projectFile(sourceFile, info),
new TextUpdate({
position: node.arguments.pos,
end: node.arguments.pos,
toInsert: node.arguments.length ? 'withXhr(), ' : 'withXhr()',
}),
),
);
importManager.addImport({
exportModuleSpecifier: HTTP_PACKAGE,
exportSymbolName: WITH_XHR,
requestedFile: sourceFile,
});
} else if (withFetchNode) {
const isLastArg = node.arguments[node.arguments.length - 1] === withFetchNode;
replacements.push(
new Replacement(
projectFile(sourceFile, info),
new TextUpdate({
position: withFetchNode.getStart(),
end: isLastArg ? withFetchNode.getEnd() : withFetchNode.getEnd() + 2, // +2 to remove the comma and space, could be improved
toInsert: '',
}),
),
);
importManager.removeImport(sourceFile, 'withFetch', HTTP_PACKAGE);
}
};
sourceFile.forEachChild(walk);
}
applyImportManagerChanges(importManager, replacements, info.sourceFiles, info);
return confirmAsSerializable({replacements});
}
override async combine(
unitA: CompilationUnitData,
unitB: CompilationUnitData,
): Promise<Serializable<CompilationUnitData>> {
const combined = [...unitA.replacements, ...unitB.replacements];
return confirmAsSerializable({replacements: combined});
}
override async globalMeta(data: CompilationUnitData): Promise<Serializable<CompilationUnitData>> {
return confirmAsSerializable(data);
}
override async stats(data: CompilationUnitData) {
return confirmAsSerializable({});
}
override async migrate(data: CompilationUnitData) {
return {replacements: data.replacements};
}
}