diff --git a/packages/angular/build/src/builders/dev-server/vite-server.ts b/packages/angular/build/src/builders/dev-server/vite-server.ts index f3309d94f6d9..a5fa4216572f 100644 --- a/packages/angular/build/src/builders/dev-server/vite-server.ts +++ b/packages/angular/build/src/builders/dev-server/vite-server.ts @@ -138,17 +138,14 @@ export async function* serveWithVite( process.setSourceMapsEnabled(true); } - // Enable to support component style hot reloading (`NG_HMR_CSTYLES=0` can be used to disable selectively) + // Enable to support link-based component style hot reloading (`NG_HMR_CSTYLES=0` can be used to disable selectively) browserOptions.externalRuntimeStyles = serverOptions.liveReload && serverOptions.hmr && useComponentStyleHmr; - // Enable to support component template hot replacement (`NG_HMR_TEMPLATE=1` can be used to enable) - browserOptions.templateUpdates = !!serverOptions.liveReload && useComponentTemplateHmr; - if (browserOptions.templateUpdates) { - context.logger.warn( - 'Experimental support for component template hot replacement has been enabled via the "NG_HMR_TEMPLATE" environment variable.', - ); - } + // Enable to support component template hot replacement (`NG_HMR_TEMPLATE=0` can be used to disable selectively) + // This will also replace file-based/inline styles as code if external runtime styles are not enabled. + browserOptions.templateUpdates = + serverOptions.liveReload && serverOptions.hmr && useComponentTemplateHmr; // Setup the prebundling transformer that will be shared across Vite prebundling requests const prebundleTransformer = new JavaScriptTransformer( @@ -233,6 +230,12 @@ export async function* serveWithVite( assetFiles.set('/' + normalizePath(outputPath), normalizePath(file.inputPath)); } } + + // Invalidate SSR module graph to ensure that only new rebuild is used and not stale component updates + if (server && browserOptions.ssr && templateUpdates.size > 0) { + server.moduleGraph.invalidateAll(); + } + // Clear stale template updates on code rebuilds templateUpdates.clear(); @@ -256,6 +259,16 @@ export async function* serveWithVite( 'Builder must provide an initial full build before component update results.', ); + // Invalidate SSR module graph to ensure that new component updates are used + // TODO: Use fine-grained invalidation of only the component update modules + if (browserOptions.ssr) { + server.moduleGraph.invalidateAll(); + const { ɵresetCompiledComponents } = (await server.ssrLoadModule('/main.server.mjs')) as { + ɵresetCompiledComponents: () => void; + }; + ɵresetCompiledComponents(); + } + for (const componentUpdate of result.updates) { if (componentUpdate.type === 'template') { templateUpdates.set(componentUpdate.id, componentUpdate.content); diff --git a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts index db2581eb9b00..30f6b750a1a0 100644 --- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -357,6 +357,9 @@ export function createServerMainCodeBundleOptions( ɵgetOrCreateAngularServerApp, } from '@angular/ssr';`, + // Need for HMR + `export { ɵresetCompiledComponents } from '@angular/core';`, + // Re-export all symbols including default export from 'main.server.ts' `export { default } from '${mainServerEntryPointJsImport}';`, `export * from '${mainServerEntryPointJsImport}';`, diff --git a/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts b/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts index 92fd7ac7df54..201ce171b3ea 100644 --- a/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts +++ b/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts @@ -78,7 +78,7 @@ export async function createAngularMemoryPlugin( const requestUrl = new URL(id.slice(1), 'http://localhost'); const componentId = requestUrl.searchParams.get('c'); - return (componentId && options.templateUpdates?.get(componentId)) ?? ''; + return (componentId && options.templateUpdates?.get(encodeURIComponent(componentId))) ?? ''; } const [file] = id.split('?', 1); diff --git a/packages/angular/build/src/utils/environment-options.ts b/packages/angular/build/src/utils/environment-options.ts index 40de16a535e3..63abd82af46e 100644 --- a/packages/angular/build/src/utils/environment-options.ts +++ b/packages/angular/build/src/utils/environment-options.ts @@ -107,7 +107,7 @@ export const useComponentStyleHmr = const hmrComponentTemplateVariable = process.env['NG_HMR_TEMPLATES']; export const useComponentTemplateHmr = - isPresent(hmrComponentTemplateVariable) && isEnabled(hmrComponentTemplateVariable); + !isPresent(hmrComponentTemplateVariable) || !isDisabled(hmrComponentTemplateVariable); const partialSsrBuildVariable = process.env['NG_BUILD_PARTIAL_SSR']; export const usePartialSsrBuild = diff --git a/tests/legacy-cli/e2e/tests/basic/rebuild.ts b/tests/legacy-cli/e2e/tests/basic/rebuild.ts index f512961366bc..d289587d78b8 100644 --- a/tests/legacy-cli/e2e/tests/basic/rebuild.ts +++ b/tests/legacy-cli/e2e/tests/basic/rebuild.ts @@ -9,13 +9,8 @@ export default async function () { const validBundleRegEx = esbuild ? /sent to client/ : /Compiled successfully\./; const lazyBundleRegEx = esbuild ? /chunk-/ : /src_app_lazy_lazy_component_ts\.js/; - // Disable component stylesheet HMR to support page reload based rebuild testing. - // Ideally this environment variable would be passed directly to the new serve process - // but this would require signficant test changes due to the existing `ngServe` signature. - const oldHMRValue = process.env['NG_HMR_CSTYLES']; - process.env['NG_HMR_CSTYLES'] = '0'; - const port = await ngServe(); - process.env['NG_HMR_CSTYLES'] = oldHMRValue; + // Disable HMR to support page reload based rebuild testing. + const port = await ngServe('--no-hmr'); // Add a lazy route. await silentNg('generate', 'component', 'lazy'); diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts index 5337ad5e5cc5..a8fbbea83503 100644 --- a/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts @@ -90,6 +90,7 @@ export default async function () { 'src/app/home/home.component.html', 'home works', 'yay home works!!!', + true, ); await validateResponse('/api/test', /foo/); await validateResponse('/home', /yay home works/); @@ -111,9 +112,12 @@ async function modifyFileAndWaitUntilUpdated( filePath: string, searchValue: string, replaceValue: string, + hmr = false, ): Promise { await Promise.all([ - waitForAnyProcessOutputToMatch(/Page reload sent to client/), + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), ]); } diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts index 87c84ad0010f..aca0251b27db 100644 --- a/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts @@ -90,6 +90,7 @@ export default async function () { 'src/app/home/home.component.html', 'home works', 'yay home works!!!', + true, ); await validateResponse('/api/test', /foo/); await validateResponse('/home', /yay home works/); @@ -111,9 +112,12 @@ async function modifyFileAndWaitUntilUpdated( filePath: string, searchValue: string, replaceValue: string, + hmr = false, ): Promise { await Promise.all([ - waitForAnyProcessOutputToMatch(/Page reload sent to client/), + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), ]); } diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts index 0027190395f4..88e03d34aea9 100644 --- a/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts @@ -81,6 +81,7 @@ export default async function () { 'src/app/home/home.component.html', 'home works', 'yay home works!!!', + true, ); await validateResponse('/api/test', /foo/); await validateResponse('/home', /yay home works/); @@ -102,9 +103,12 @@ async function modifyFileAndWaitUntilUpdated( filePath: string, searchValue: string, replaceValue: string, + hmr = false, ): Promise { await Promise.all([ - waitForAnyProcessOutputToMatch(/Page reload sent to client/), + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), ]); } diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts index 353a6cf5b855..f36b36eec332 100644 --- a/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts @@ -3,7 +3,7 @@ import { setTimeout } from 'node:timers/promises'; import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; import { installPackage, installWorkspacePackages, uninstallPackage } from '../../utils/packages'; -import { ngServe, updateJsonFile, useSha } from '../../utils/project'; +import { ngServe, useSha } from '../../utils/project'; import { getGlobalVariable } from '../../utils/env'; export default async function () { @@ -73,6 +73,7 @@ export default async function () { 'src/app/home/home.component.html', 'home works', 'yay home works!!!', + true, ); await validateResponse('/api/test', /foo/); await validateResponse('/home', /yay home works/); @@ -94,9 +95,12 @@ async function modifyFileAndWaitUntilUpdated( filePath: string, searchValue: string, replaceValue: string, + hmr = false, ): Promise { await Promise.all([ - waitForAnyProcessOutputToMatch(/Page reload sent to client/), + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), ]); }