refactor(migrations): don't migration the server bootstrapApplicaiton on zoneless apps.

With the change we specifically analyse `boostrapApplication` with a config that uses `mergeApplicationConfig`.

fixes #65408
This commit is contained in:
Matthieu Riegler 2025-11-22 18:20:17 +01:00 committed by Kirill Cherkashin
parent 42e73ff9ce
commit c1dfd9cde6
2 changed files with 109 additions and 8 deletions

View file

@ -749,6 +749,55 @@ describe('bootstrap options migration', () => {
.toEqual(expected.replace(/\s+/g, ''));
});
});
it('should not migrate a SSR config that has provideZonelessChangeDetection in the base config', async () => {
return runTsurgeMigration(new BootstrapOptionsMigration(), [
...typeFiles,
{
name: absoluteFrom('/app/app.config.ts'),
contents: `
import { provideZonelessChangeDetection } from '@angular/core';
export const appConfig = {
providers: [provideZonelessChangeDetection()],
};
`,
},
{
name: absoluteFrom('/app/app.config.server.ts'),
contents: `
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: []
};
export const appServerConfig = mergeApplicationConfig(appConfig, serverConfig);
`,
},
{
name: absoluteFrom('/main.server.ts'),
isProgramRootFile: true,
contents: `
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appServerConfig } from './app/app.config.server';
const bootstrap = () => bootstrapApplication(AppComponent, appServerConfig);
export default bootstrap;
`,
},
]).then(({fs}) => {
const actualMainServer = fs.readFile(absoluteFrom('/main.server.ts'));
const expectedMainServer = `
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appServerConfig } from './app/app.config.server';
const bootstrap = () => bootstrapApplication(AppComponent, appServerConfig);
export default bootstrap;
`;
expect(actualMainServer.replace(/\s+/g, ''))
.withContext(diffText(expectedMainServer, actualMainServer))
.toEqual(expectedMainServer.replace(/\s+/g, ''));
});
});
});
describe('bootstrapModule', () => {

View file

@ -197,15 +197,10 @@ export class BootstrapOptionsMigration extends TsurgeFunnelMigration<
if (ts.isObjectLiteralExpression(optionsNode)) {
optionLiteral = optionsNode;
addProvidersToBootstrapOption(optionProjectFile, optionLiteral, providerFn, replacements);
} else if (this.isServerConfigZoneless(optionsNode, typeChecker)) {
// Nothing to migrate for the SSR bootstrap
return;
} else if (ts.isIdentifier(optionsNode)) {
// This case handled both `bootstrapApplication(App, appConfig)` and the server () => bootstrapApplication(App, appConfig)
// where appConfig is the result of a `mergeApplicationConfig` call.
// This is tricky case to handle, in G3 we're might not be able to resolve the identifier's value
// Our best alternative is to assume there is not CD providers set and add the ZoneChangeDetection provider
// In the cases where it is, we'll just override the zone provider we just set by re-used inthe appConfig providers
// TODO: Should we insert a TODO to clean this up ?
const text = `{...${optionsNode.getText()}, providers: [${providerFn}, ...${optionsNode.getText()}.providers]}`;
replacements.push(
new Replacement(
@ -239,6 +234,63 @@ export class BootstrapOptionsMigration extends TsurgeFunnelMigration<
});
}
/**
* The optionsNode can be a appConfig built with mergeApplicationConfig
* In this case we need to analyze if the base config uses provideZonelessChangeDetection
*/
private isServerConfigZoneless(optionsNode: ts.Expression, typeChecker: ts.TypeChecker): boolean {
// Check if optionsNode is a result of mergeApplicationConfig
let symbol = typeChecker.getSymbolAtLocation(optionsNode);
if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) {
symbol = typeChecker.getAliasedSymbol(symbol);
}
const optionDeclaration = symbol?.getDeclarations()?.[0];
if (!optionDeclaration) {
return false;
}
if (
!ts.isVariableDeclaration(optionDeclaration) ||
!optionDeclaration.initializer ||
!ts.isCallExpression(optionDeclaration.initializer) ||
!ts.isIdentifier(optionDeclaration.initializer.expression) ||
optionDeclaration.initializer.expression.text !== 'mergeApplicationConfig'
) {
// We didn't find a mergeApplicationConfig call, this isn't a server config
return false;
}
let maybeAppConfig = optionDeclaration.initializer.arguments[0];
if (ts.isIdentifier(maybeAppConfig)) {
const resolved = getObjectLiteralFromIdentifier(maybeAppConfig, typeChecker);
if (resolved) {
maybeAppConfig = resolved;
}
}
if (maybeAppConfig && ts.isObjectLiteralExpression(maybeAppConfig)) {
for (const prop of maybeAppConfig.properties) {
if (
ts.isPropertyAssignment(prop) &&
ts.isIdentifier(prop.name) &&
prop.name.text === 'providers' &&
ts.isArrayLiteralExpression(prop.initializer)
) {
for (const el of prop.initializer.elements) {
if (
ts.isCallExpression(el) &&
ts.isIdentifier(el.expression) &&
el.expression.text === 'provideZonelessChangeDetection'
) {
return true;
}
}
}
}
}
return false;
}
private analyzeCreateApplication(
node: ts.CallExpression,
sourceFile: ts.SourceFile,