Skip to content

Commit

Permalink
fixup! feat(@angular/ssr): add modulepreload for lazy-loaded routes
Browse files Browse the repository at this point in the history
(cherry picked from commit c2a01afc3a1bf7681382994e74931ab7b08caa74)
  • Loading branch information
alan-agius4 committed Nov 29, 2024
1 parent 18d0d35 commit 84c0543
Show file tree
Hide file tree
Showing 8 changed files with 317 additions and 118 deletions.
1 change: 1 addition & 0 deletions packages/angular/build/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ ts_library(
"@npm//@angular/compiler-cli",
"@npm//@babel/core",
"@npm//prettier",
"@npm//typescript",
],
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
ensureSourceFileVersions,
} from '../angular-host';
import { replaceBootstrap } from '../transformers/jit-bootstrap-transformer';
import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer';
import { createWorkerTransformer } from '../transformers/web-worker-transformer';
import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';

Expand Down Expand Up @@ -287,6 +288,7 @@ export class AotCompilation extends AngularCompilation {
transformers.before ??= [];
transformers.before.push(
replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()),
lazyRoutesTransformer(typeScriptProgram.getProgram(), compilerHost),
);
transformers.before.push(webWorkerTransform);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { loadEsmModule } from '../../../utils/load-esm';
import { profileSync } from '../../esbuild/profiling';
import { AngularHostOptions, createAngularCompilerHost } from '../angular-host';
import { createJitResourceTransformer } from '../transformers/jit-resource-transformer';
import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer';
import { createWorkerTransformer } from '../transformers/web-worker-transformer';
import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';

Expand Down Expand Up @@ -137,6 +138,7 @@ export class JitCompilation extends AngularCompilation {
replaceResourcesTransform,
constructorParametersDownlevelTransform,
webWorkerTransform,
lazyRoutesTransformer(typeScriptProgram.getProgram(), compilerHost),
],
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* @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 assert from 'node:assert';
import { relative } from 'node:path/posix';
import ts from 'typescript';

export function lazyRoutesTransformer(
program: ts.Program,
compilerHost: ts.CompilerHost,
): ts.TransformerFactory<ts.SourceFile> {
const compilerOptions = program.getCompilerOptions();
const moduleResolutionCache = compilerHost.getModuleResolutionCache?.();
assert(
typeof compilerOptions.basePath === 'string',
'compilerOptions.basePath should be a string.',
);
const basePath = compilerOptions.basePath;

return (context: ts.TransformationContext) => {
const factory = context.factory;

const visitor = (node: ts.Node): ts.Node => {
if (!ts.isObjectLiteralExpression(node)) {
return ts.visitEachChild(node, visitor, context);
}

let hasPathProperty = false;
let loadComponentOrChildrenProperty: ts.PropertyAssignment | undefined;

for (const prop of node.properties) {
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) {
continue;
}

if (prop.name.text === 'path') {
hasPathProperty = true;
} else if (prop.name.text === 'loadChildren' || prop.name.text === 'loadComponent') {
loadComponentOrChildrenProperty = prop;
}

if (hasPathProperty && loadComponentOrChildrenProperty) {
break;
}
}

const initializer = loadComponentOrChildrenProperty?.initializer;
if (
!hasPathProperty ||
!initializer ||
!ts.isArrowFunction(initializer) ||
!ts.isCallExpression(initializer.body) ||
!ts.isPropertyAccessExpression(initializer.body.expression) ||
initializer.body.expression.name.text !== 'then' ||
!ts.isCallExpression(initializer.body.expression.expression) ||
initializer.body.expression.expression.expression.kind !== ts.SyntaxKind.ImportKeyword
) {
return ts.visitEachChild(node, visitor, context);
}

const callExpressionArgument = initializer.body.expression.expression.arguments[0];
if (
!ts.isStringLiteral(callExpressionArgument) &&
!ts.isNoSubstitutionTemplateLiteral(callExpressionArgument)
) {
return ts.visitEachChild(node, visitor, context);
}

const resolvedPath = ts.resolveModuleName(
callExpressionArgument.text,
node.getSourceFile().fileName,
compilerOptions,
compilerHost,
moduleResolutionCache,
)?.resolvedModule?.resolvedFileName;

if (!resolvedPath) {
return ts.visitEachChild(node, visitor, context);
}

const resolvedRelativePath = relative(basePath, resolvedPath);

// Create the new property
// Exmaple: ɵentryName: typeof ngServerMode !== 'undefined' && ngServerMode ? 'src/app/home.ts' : undefined
const importPathProp = factory.createPropertyAssignment(
factory.createIdentifier('ɵentryName'),
factory.createConditionalExpression(
factory.createBinaryExpression(
factory.createBinaryExpression(
factory.createTypeOfExpression(factory.createIdentifier('ngServerMode')),
factory.createToken(ts.SyntaxKind.ExclamationEqualsEqualsToken),
factory.createStringLiteral('undefined'),
),
factory.createToken(ts.SyntaxKind.AmpersandAmpersandToken),
factory.createIdentifier('ngServerMode'),
),
factory.createToken(ts.SyntaxKind.QuestionToken),
factory.createStringLiteral(resolvedRelativePath),
factory.createToken(ts.SyntaxKind.ColonToken),
factory.createIdentifier('undefined'),
),
);

return factory.updateObjectLiteralExpression(node, [...node.properties, importPathProp]);
};

return (sourceFile) => {
const text = sourceFile.text;
if (!text.includes('loadComponent') && !text.includes('loadChildren')) {
// Fast check
return sourceFile;
}

return ts.visitEachChild(sourceFile, visitor, context);
};
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* @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 ts from 'typescript';
import { lazyRoutesTransformer } from './lazy-routes-transformer';

describe('lazyRoutesTransformer', () => {
let program: ts.Program;
let compilerHost: ts.CompilerHost;

beforeEach(() => {
// Mock a basic TypeScript program and compilerHost
program = ts.createProgram(['/project/src/dummy.ts'], { basePath: '/project/' });
compilerHost = {
getNewLine: () => '\n',
fileExists: () => true,
readFile: () => '',
writeFile: () => undefined,
getCanonicalFileName: (fileName: string) => fileName,
getCurrentDirectory: () => '/project',
getDefaultLibFileName: () => 'lib.d.ts',
getSourceFile: () => undefined,
useCaseSensitiveFileNames: () => true,
resolveModuleNames: (moduleNames, containingFile) =>
moduleNames.map(
(name) =>
({
resolvedFileName: `/project/src/${name}.ts`,
}) as ts.ResolvedModule,
),
};
});

const transformSourceFile = (sourceCode: string): ts.SourceFile => {
const sourceFile = ts.createSourceFile(
'/project/src/dummy.ts',
sourceCode,
ts.ScriptTarget.ESNext,
true,
ts.ScriptKind.TS,
);

const transformer = lazyRoutesTransformer(program, compilerHost);
const result = ts.transform(sourceFile, [transformer]);

return result.transformed[0];
};

it('should add ɵentryName property to object with loadComponent and path', () => {
const source = `
const routes = [
{
path: 'home',
loadComponent: () => import('./home').then(m => m.HomeComponent)
}
];
`;

const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);

expect(transformedCode).toContain(
`ɵentryName: typeof ngServerMode !== "undefined" && ngServerMode ? "src/home.ts" : undefined`,
);
});

it('should not modify unrelated object literals', () => {
const source = `
const routes = [
{
path: 'home',
component: HomeComponent
}
];
`;

const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);

expect(transformedCode).not.toContain(`ɵentryName`);
});

it('should ignore loadComponent without a valid import call', () => {
const source = `
const routes = [
{
path: 'home',
loadComponent: () => someFunction()
}
];
`;

const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);

expect(transformedCode).not.toContain(`ɵentryName`);
});

it('should resolve paths relative to basePath', () => {
const source = `
const routes = [
{
path: 'about',
loadChildren: () => import('./features/about').then(m => m.AboutModule)
}
];
`;

const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);

expect(transformedCode).toContain(
`typeof ngServerMode !== "undefined" && ngServerMode ? "src/features/about.ts" : undefined`,
);
});
});
Loading

0 comments on commit 84c0543

Please sign in to comment.