diff --git a/packages/bazel/src/ngc-wrapped/index.ts b/packages/bazel/src/ngc-wrapped/index.ts index 884a23aeaf31..72a687f58fe0 100644 --- a/packages/bazel/src/ngc-wrapped/index.ts +++ b/packages/bazel/src/ngc-wrapped/index.ts @@ -92,6 +92,7 @@ export async function runOneBuild( 'onlyExplicitDeferDependencyImports', 'generateExtraImportsInLocalMode', '_enableLetSyntax', + '_enableHmr', ]); const userOverrides = Object.entries(userOptions) diff --git a/packages/compiler-cli/src/ngtsc/annotations/common/src/debug_info.ts b/packages/compiler-cli/src/ngtsc/annotations/common/src/debug_info.ts index d0edd1a73ace..3da7dab668d7 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/common/src/debug_info.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/common/src/debug_info.ts @@ -7,13 +7,15 @@ */ import {literal, R3ClassDebugInfo, WrappedNodeExpr} from '@angular/compiler'; -import * as path from 'path'; +import ts from 'typescript'; import {DeclarationNode, ReflectionHost} from '../../../reflection'; +import {getProjectRelativePath} from './util'; export function extractClassDebugInfo( clazz: DeclarationNode, reflection: ReflectionHost, + compilerHost: Pick, rootDirs: ReadonlyArray, forbidOrphanRendering: boolean, ): R3ClassDebugInfo | null { @@ -22,7 +24,7 @@ export function extractClassDebugInfo( } const srcFile = clazz.getSourceFile(); - const srcFileMaybeRelativePath = computeRelativePathIfPossible(srcFile.fileName, rootDirs); + const srcFileMaybeRelativePath = getProjectRelativePath(srcFile, rootDirs, compilerHost); return { type: new WrappedNodeExpr(clazz.name), @@ -32,21 +34,3 @@ export function extractClassDebugInfo( forbidOrphanRendering, }; } - -/** - * Computes a source file path relative to the project root folder if possible, otherwise returns - * null. - */ -function computeRelativePathIfPossible( - filePath: string, - rootDirs: ReadonlyArray, -): string | null { - for (const rootDir of rootDirs) { - const rel = path.relative(rootDir, filePath); - if (!rel.startsWith('..')) { - return rel; - } - } - - return null; -} diff --git a/packages/compiler-cli/src/ngtsc/annotations/common/src/util.ts b/packages/compiler-cli/src/ngtsc/annotations/common/src/util.ts index 18a09e700fe7..ea3c3d60dadb 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/common/src/util.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/common/src/util.ts @@ -20,6 +20,7 @@ import { Statement, WrappedNodeExpr, } from '@angular/compiler'; +import {relative} from 'path'; import ts from 'typescript'; import { @@ -413,6 +414,7 @@ export function compileResults( additionalFields: CompileResult[] | null, deferrableImports: Set | null, debugInfo: Statement | null = null, + hmrInitializer: Statement | null = null, ): CompileResult[] { const statements = def.statements; @@ -424,6 +426,10 @@ export function compileResults( statements.push(debugInfo); } + if (hmrInitializer !== null) { + statements.push(hmrInitializer); + } + const results = [ fac, { @@ -504,3 +510,31 @@ export function isAbstractClassDeclaration(clazz: ClassDeclaration): boolean { ? clazz.modifiers.some((mod) => mod.kind === ts.SyntaxKind.AbstractKeyword) : false; } + +/** + * Attempts to generate a project-relative path + * @param sourceFile + * @param rootDirs + * @param compilerHost + * @returns + */ +export function getProjectRelativePath( + sourceFile: ts.SourceFile, + rootDirs: readonly string[], + compilerHost: Pick, +): string | null { + // Note: we need to pass both the file name and the root directories through getCanonicalFileName, + // because the root directories might've been passed through it already while the source files + // definitely have not. This can break the relative return value, because in some platforms + // getCanonicalFileName lowercases the path. + const filePath = compilerHost.getCanonicalFileName(sourceFile.fileName); + + for (const rootDir of rootDirs) { + const rel = relative(compilerHost.getCanonicalFileName(rootDir), filePath); + if (!rel.startsWith('..')) { + return rel; + } + } + + return null; +} diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts index a23dc56fa8a6..29279becb0a0 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -10,6 +10,7 @@ import { AnimationTriggerNames, BoundTarget, compileClassDebugInfo, + compileClassHmrInitializer, compileComponentClassMetadata, compileComponentDeclareClassMetadata, compileComponentFromMetadata, @@ -179,6 +180,7 @@ import { } from './util'; import {getTemplateDiagnostics} from '../../../typecheck'; import {JitDeclarationRegistry} from '../../common/src/jit_declaration_registry'; +import {extractHmrInitializerMeta} from './hmr'; const EMPTY_ARRAY: any[] = []; @@ -219,7 +221,7 @@ export class ComponentDecoratorHandler private metaRegistry: MetadataRegistry, private metaReader: MetadataReader, private scopeReader: ComponentScopeReader, - private dtsScopeReader: DtsModuleScopeResolver, + private compilerHost: Pick, private scopeRegistry: LocalModuleScopeRegistry, private typeCheckScopeRegistry: TypeCheckScopeRegistry, private resourceRegistry: ResourceRegistry, @@ -255,6 +257,7 @@ export class ComponentDecoratorHandler private readonly jitDeclarationRegistry: JitDeclarationRegistry, private readonly i18nPreserveSignificantWhitespace: boolean, private readonly strictStandalone: boolean, + private readonly enableHmr: boolean, ) { this.extractTemplateOptions = { enableI18nLegacyMessageIdFormat: this.enableI18nLegacyMessageIdFormat, @@ -869,9 +872,13 @@ export class ComponentDecoratorHandler classDebugInfo: extractClassDebugInfo( node, this.reflector, + this.compilerHost, this.rootDirs, /* forbidOrphanRenderering */ this.forbidOrphanRendering, ), + hmrInitializerMeta: this.enableHmr + ? extractHmrInitializerMeta(node, this.reflector, this.compilerHost, this.rootDirs) + : null, template, providersRequiringFactory, viewProvidersRequiringFactory, @@ -1613,6 +1620,10 @@ export class ComponentDecoratorHandler analysis.classDebugInfo !== null ? compileClassDebugInfo(analysis.classDebugInfo).toStmt() : null; + const hmrInitializer = + analysis.hmrInitializerMeta !== null + ? compileClassHmrInitializer(analysis.hmrInitializerMeta).toStmt() + : null; const deferrableImports = this.deferredSymbolTracker.getDeferrableImportDecls(); return compileResults( fac, @@ -1622,6 +1633,7 @@ export class ComponentDecoratorHandler inputTransformFields, deferrableImports, debugInfo, + hmrInitializer, ); } @@ -1695,6 +1707,10 @@ export class ComponentDecoratorHandler analysis.classDebugInfo !== null ? compileClassDebugInfo(analysis.classDebugInfo).toStmt() : null; + const hmrInitializer = + analysis.hmrInitializerMeta !== null + ? compileClassHmrInitializer(analysis.hmrInitializerMeta).toStmt() + : null; const deferrableImports = this.deferredSymbolTracker.getDeferrableImportDecls(); return compileResults( fac, @@ -1704,6 +1720,7 @@ export class ComponentDecoratorHandler inputTransformFields, deferrableImports, debugInfo, + hmrInitializer, ); } diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/hmr.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/hmr.ts new file mode 100644 index 000000000000..cb5a2424e957 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/hmr.ts @@ -0,0 +1,40 @@ +/*! + * @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 {R3HmrInitializerMetadata, WrappedNodeExpr} from '@angular/compiler'; +import {DeclarationNode, ReflectionHost} from '../../../reflection'; +import {getProjectRelativePath} from '../../common'; +import ts from 'typescript'; + +/** + * Extracts the metadata necessary to generate an HMR initializer. + */ +export function extractHmrInitializerMeta( + clazz: DeclarationNode, + reflection: ReflectionHost, + compilerHost: Pick, + rootDirs: readonly string[], +): R3HmrInitializerMetadata | null { + if (!reflection.isClass(clazz)) { + return null; + } + + const sourceFile = clazz.getSourceFile(); + const filePath = + getProjectRelativePath(sourceFile, rootDirs, compilerHost) || + compilerHost.getCanonicalFileName(sourceFile.fileName); + + const meta: R3HmrInitializerMetadata = { + type: new WrappedNodeExpr(clazz.name), + className: clazz.name.text, + timestamp: Date.now() + '', + filePath, + }; + + return meta; +} diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts index bf0ff585e2cc..8f02d784a648 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts @@ -12,6 +12,7 @@ import { DeferBlockDepsEmitMode, R3ClassDebugInfo, R3ClassMetadata, + R3HmrInitializerMetadata, R3ComponentMetadata, R3DeferPerBlockDependency, R3DeferPerComponentDependency, @@ -56,6 +57,7 @@ export interface ComponentAnalysisData { template: ParsedTemplateWithSource; classMetadata: R3ClassMetadata | null; classDebugInfo: R3ClassDebugInfo | null; + hmrInitializerMeta: R3HmrInitializerMetadata | null; inputs: ClassPropertyMapping; inputFieldNamesFromMetadataArray: Set; diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts b/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts index 39a71408fd07..6e940f8a3021 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts @@ -120,7 +120,9 @@ function setup( metaRegistry, metaReader, scopeRegistry, - dtsResolver, + { + getCanonicalFileName: (fileName) => fileName, + }, scopeRegistry, typeCheckScopeRegistry, resourceRegistry, @@ -156,6 +158,7 @@ function setup( jitDeclarationRegistry, /* i18nPreserveSignificantWhitespace */ true, /* strictStandalone */ false, + /* enableHmr */ false, ); return {reflectionHost, handler, resourceLoader, metaRegistry}; } diff --git a/packages/compiler-cli/src/ngtsc/core/api/src/options.ts b/packages/compiler-cli/src/ngtsc/core/api/src/options.ts index b3b7823174dc..e87fdb71b0d0 100644 --- a/packages/compiler-cli/src/ngtsc/core/api/src/options.ts +++ b/packages/compiler-cli/src/ngtsc/core/api/src/options.ts @@ -113,6 +113,13 @@ export interface InternalOptions { * @internal */ _angularCoreVersion?: string; + + /** + * Whether to enable the necessary code generation for hot module reloading. + * + * @internal + */ + _enableHmr?: boolean; } /** diff --git a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts index 9353ee435915..002d5399048b 100644 --- a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts +++ b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts @@ -390,6 +390,7 @@ export class NgCompiler { private readonly enableBlockSyntax: boolean; private readonly enableLetSyntax: boolean; private readonly angularCoreVersion: string | null; + private readonly enableHmr: boolean; /** * `NgCompiler` can be reused for multiple compilations (for resource-only changes), and each @@ -462,6 +463,7 @@ export class NgCompiler { this.enableBlockSyntax = options['_enableBlockSyntax'] ?? true; this.enableLetSyntax = options['_enableLetSyntax'] ?? true; this.angularCoreVersion = options['_angularCoreVersion'] ?? null; + this.enableHmr = !!options['_enableHmr']; this.constructionDiagnostics.push( ...this.adapter.constructionDiagnostics, ...verifyCompatibleTypeCheckOptions(this.options), @@ -1427,7 +1429,7 @@ export class NgCompiler { metaRegistry, metaReader, scopeReader, - depScopeReader, + this.adapter, ngModuleScopeRegistry, typeCheckScopeRegistry, resourceRegistry, @@ -1463,6 +1465,7 @@ export class NgCompiler { jitDeclarationRegistry, this.options.i18nPreserveWhitespaceForLegacyExtraction ?? true, !!this.options.strictStandalone, + this.enableHmr, ), // TODO(alxhub): understand why the cast here is necessary (something to do with `null` diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index 844cca4aabb7..c6e88f153d3e 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -10519,6 +10519,69 @@ runInEachFileSystem((os: string) => { }); }); + describe('HMR initializer', () => { + it('should not generate an HMR initializer by default', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'cmp', + template: 'hello', + standalone: true, + }) + export class Cmp {} + `, + ); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + expect(jsContents).not.toContain('import.meta.hot'); + expect(jsContents).not.toContain('replaceMetadata'); + }); + + it('should generate an HMR initializer when enabled', () => { + env.write( + 'tsconfig.json', + JSON.stringify({ + extends: './tsconfig-base.json', + angularCompilerOptions: { + _enableHmr: true, + }, + }), + ); + + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'cmp', + template: 'hello', + standalone: true, + }) + export class Cmp {} + `, + ); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + // We need a regex match here, because the file path changes based on + // the file system and the timestamp will be different for each test run. + expect(jsContents).toMatch( + /import\.meta\.hot && import\.meta\.hot\.on\("angular:component-update", d => { if \(d\.id == "test\.ts%40Cmp"\) {/, + ); + expect(jsContents).toMatch( + /import\("\/@ng\/component\?c=test\.ts%40Cmp&t=\d+"\).then\(m => i0\.ɵɵreplaceMetadata\(Cmp, m\.default\)\);/, + ); + }); + }); + describe('tsickle compatibility', () => { it('should preserve fileoverview comments', () => { env.write( diff --git a/packages/compiler/src/compiler.ts b/packages/compiler/src/compiler.ts index 2ed8439c2586..4485488a3b0f 100644 --- a/packages/compiler/src/compiler.ts +++ b/packages/compiler/src/compiler.ts @@ -173,6 +173,7 @@ export { compileOpaqueAsyncClassMetadata, } from './render3/r3_class_metadata_compiler'; export {compileClassDebugInfo, R3ClassDebugInfo} from './render3/r3_class_debug_info_compiler'; +export {compileClassHmrInitializer, R3HmrInitializerMetadata} from './render3/r3_hmr_compiler'; export { compileFactoryFunction, R3DependencyMetadata, diff --git a/packages/compiler/src/render3/r3_hmr_compiler.ts b/packages/compiler/src/render3/r3_hmr_compiler.ts new file mode 100644 index 000000000000..5e3ae3a30965 --- /dev/null +++ b/packages/compiler/src/render3/r3_hmr_compiler.ts @@ -0,0 +1,67 @@ +/*! + * @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 * as o from '../output/output_ast'; +import {Identifiers as R3} from './r3_identifiers'; +import {devOnlyGuardedExpression} from './util'; + +/** Metadata necessary to compile the HMR initializer call. */ +export interface R3HmrInitializerMetadata { + /** Component class for which HMR is being enabled. */ + type: o.Expression; + + /** Name of the component class. */ + className: string; + + /** File path of the component class. */ + filePath: string; + + /** + * Timestamp when the compilation took place. + * Necessary to invalidate the browser cache. + */ + timestamp: string; +} + +/** Compiles the HMR initializer expression. */ +export function compileClassHmrInitializer(meta: R3HmrInitializerMetadata): o.Expression { + const id = encodeURIComponent(`${meta.filePath}@${meta.className}`); + const timestamp = encodeURIComponent(meta.timestamp); + const url = `/@ng/component?c=${id}&t=${timestamp}`; + const moduleName = 'm'; + const dataName = 'd'; + + // ɵɵreplaceMetadata(Comp, m.default); + const replaceMetadata = o + .importExpr(R3.replaceMetadata) + .callFn([meta.type, o.variable(moduleName).prop('default')]); + + // (m) => ɵɵreplaceMetadata(...) + const replaceCallback = o.arrowFn([new o.FnParam(moduleName)], replaceMetadata); + + // import(url).then(() => replaceMetadata(...)); + const dynamicImport = new o.DynamicImportExpr(url).prop('then').callFn([replaceCallback]); + + // (d) => { if (d.id === ) { replaceMetadata(...) } } + const listenerCallback = o.arrowFn( + [new o.FnParam(dataName)], + [o.ifStmt(o.variable(dataName).prop('id').equals(o.literal(id)), [dynamicImport.toStmt()])], + ); + + // import.meta.hot + const hotRead = o.variable('import').prop('meta').prop('hot'); + + // import.meta.hot.on('angular:component-update', () => ...); + const hotListener = hotRead + .clone() + .prop('on') + .callFn([o.literal('angular:component-update'), listenerCallback]); + + // import.meta.hot && import.meta.hot.on(...) + return o.arrowFn([], [devOnlyGuardedExpression(hotRead.and(hotListener)).toStmt()]).callFn([]); +}