diff --git a/packages/angular/build/src/builders/application/results.ts b/packages/angular/build/src/builders/application/results.ts index 165315c2657b..842af17dda3f 100644 --- a/packages/angular/build/src/builders/application/results.ts +++ b/packages/angular/build/src/builders/application/results.ts @@ -68,7 +68,9 @@ export interface ResultMessage { export interface ComponentUpdateResult extends BaseResult { kind: ResultKind.ComponentUpdate; - id: string; - type: 'style' | 'template'; - content: string; + updates: { + id: string; + type: 'style' | 'template'; + content: string; + }[]; } diff --git a/packages/angular/build/src/builders/dev-server/tests/behavior/component-updates_spec.ts b/packages/angular/build/src/builders/dev-server/tests/behavior/component-updates_spec.ts new file mode 100644 index 000000000000..d471d487c556 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/tests/behavior/component-updates_spec.ts @@ -0,0 +1,51 @@ +/** + * @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 { executeDevServer } from '../../index'; +import { executeOnceAndFetch } from '../execute-fetch'; +import { describeServeBuilder } from '../jasmine-helpers'; +import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup'; + +describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => { + describe('Behavior: "Component updates"', () => { + beforeEach(async () => { + setupTarget(harness, {}); + + // Application code is not needed for these tests + await harness.writeFile('src/main.ts', 'console.log("foo");'); + }); + + it('responds with a 400 status if no request component query is present', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/@ng/component'); + + expect(result?.success).toBeTrue(); + expect(response?.status).toBe(400); + }); + + it('responds with an empty JS file when no component update is available', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + const { result, response } = await executeOnceAndFetch( + harness, + '/@ng/component?c=src%2Fapp%2Fapp.component.ts%40AppComponent', + ); + + expect(result?.success).toBeTrue(); + expect(response?.status).toBe(200); + const output = await response?.text(); + expect(response?.headers.get('Content-Type')).toEqual('text/javascript'); + expect(response?.headers.get('Cache-Control')).toEqual('no-cache'); + expect(output).toBe(''); + }); + }); +}); 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 0a059ce53fc8..d65ca93ff450 100644 --- a/packages/angular/build/src/builders/dev-server/vite-server.ts +++ b/packages/angular/build/src/builders/dev-server/vite-server.ts @@ -167,6 +167,7 @@ export async function* serveWithVite( explicitServer: [], }; const usedComponentStyles = new Map(); + const templateUpdates = new Map(); // Add cleanup logic via a builder teardown. let deferred: () => void; @@ -211,6 +212,9 @@ export async function* serveWithVite( assetFiles.set('/' + normalizePath(outputPath), normalizePath(file.inputPath)); } } + // Clear stale template updates on a code rebuilds + templateUpdates.clear(); + // Analyze result files for changes analyzeResultFiles(normalizePath, htmlIndexPath, result.files, generatedFiles); break; @@ -220,8 +224,22 @@ export async function* serveWithVite( break; case ResultKind.ComponentUpdate: assert(serverOptions.hmr, 'Component updates are only supported with HMR enabled.'); - // TODO: Implement support -- application builder currently does not use - break; + assert( + server, + 'Builder must provide an initial full build before component update results.', + ); + + for (const componentUpdate of result.updates) { + if (componentUpdate.type === 'template') { + templateUpdates.set(componentUpdate.id, componentUpdate.content); + server.ws.send('angular:component-update', { + id: componentUpdate.id, + timestamp: Date.now(), + }); + } + } + context.logger.info('Component update sent to client(s).'); + continue; default: context.logger.warn(`Unknown result kind [${(result as Result).kind}] provided by build.`); continue; @@ -353,6 +371,7 @@ export async function* serveWithVite( target, isZonelessApp(polyfills), usedComponentStyles, + templateUpdates, browserOptions.loader as EsbuildLoaderOption | undefined, extensions?.middleware, transformers?.indexHtml, @@ -460,7 +479,7 @@ async function handleUpdate( } return { - type: 'css-update', + type: 'css-update' as const, timestamp, path: filePath, acceptedPath: filePath, @@ -564,6 +583,7 @@ export async function setupServer( target: string[], zoneless: boolean, usedComponentStyles: Map, + templateUpdates: Map, prebundleLoaderExtensions: EsbuildLoaderOption | undefined, extensionMiddleware?: Connect.NextHandleFunction[], indexHtmlTransformer?: (content: string) => Promise, @@ -671,6 +691,7 @@ export async function setupServer( indexHtmlTransformer, extensionMiddleware, usedComponentStyles, + templateUpdates, ssrMode, }), createRemoveIdPrefixPlugin(externalMetadata.explicitBrowser), diff --git a/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts new file mode 100644 index 000000000000..abfd330dec90 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts @@ -0,0 +1,42 @@ +/** + * @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 type { Connect } from 'vite'; + +const ANGULAR_COMPONENT_PREFIX = '/@ng/component'; + +export function createAngularComponentMiddleware( + templateUpdates: ReadonlyMap, +): Connect.NextHandleFunction { + return function angularComponentMiddleware(req, res, next) { + if (req.url === undefined || res.writableEnded) { + return; + } + + if (!req.url.startsWith(ANGULAR_COMPONENT_PREFIX)) { + next(); + + return; + } + + const requestUrl = new URL(req.url, 'http://localhost'); + const componentId = requestUrl.searchParams.get('c'); + if (!componentId) { + res.statusCode = 400; + res.end(); + + return; + } + + const updateCode = templateUpdates.get(componentId) ?? ''; + + res.setHeader('Content-Type', 'text/javascript'); + res.setHeader('Cache-Control', 'no-cache'); + res.end(updateCode); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/index.ts b/packages/angular/build/src/tools/vite/middlewares/index.ts index 4fb4ad345cb7..fb5416c07e7e 100644 --- a/packages/angular/build/src/tools/vite/middlewares/index.ts +++ b/packages/angular/build/src/tools/vite/middlewares/index.ts @@ -14,3 +14,4 @@ export { createAngularSsrInternalMiddleware, } from './ssr-middleware'; export { createAngularHeadersMiddleware } from './headers-middleware'; +export { createAngularComponentMiddleware } from './component-middleware'; diff --git a/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts index 3f8611223c1c..81459aff4312 100644 --- a/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts +++ b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts @@ -10,6 +10,7 @@ import type { Connect, Plugin } from 'vite'; import { angularHtmlFallbackMiddleware, createAngularAssetsMiddleware, + createAngularComponentMiddleware, createAngularHeadersMiddleware, createAngularIndexHtmlMiddleware, createAngularSsrExternalMiddleware, @@ -48,6 +49,7 @@ interface AngularSetupMiddlewaresPluginOptions { extensionMiddleware?: Connect.NextHandleFunction[]; indexHtmlTransformer?: (content: string) => Promise; usedComponentStyles: Map; + templateUpdates: Map; ssrMode: ServerSsrMode; } @@ -64,11 +66,13 @@ export function createAngularSetupMiddlewaresPlugin( extensionMiddleware, assets, usedComponentStyles, + templateUpdates, ssrMode, } = options; // Headers, assets and resources get handled first server.middlewares.use(createAngularHeadersMiddleware(server)); + server.middlewares.use(createAngularComponentMiddleware(templateUpdates)); server.middlewares.use( createAngularAssetsMiddleware(server, assets, outputFiles, usedComponentStyles), );