Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/angular/build/src/builders/application/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}[];
}
Original file line number Diff line number Diff line change
@@ -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('');
});
});
});
27 changes: 24 additions & 3 deletions packages/angular/build/src/builders/dev-server/vite-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export async function* serveWithVite(
explicitServer: [],
};
const usedComponentStyles = new Map<string, string[]>();
const templateUpdates = new Map<string, string>();

// Add cleanup logic via a builder teardown.
let deferred: () => void;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -353,6 +371,7 @@ export async function* serveWithVite(
target,
isZonelessApp(polyfills),
usedComponentStyles,
templateUpdates,
browserOptions.loader as EsbuildLoaderOption | undefined,
extensions?.middleware,
transformers?.indexHtml,
Expand Down Expand Up @@ -460,7 +479,7 @@ async function handleUpdate(
}

return {
type: 'css-update',
type: 'css-update' as const,
timestamp,
path: filePath,
acceptedPath: filePath,
Expand Down Expand Up @@ -564,6 +583,7 @@ export async function setupServer(
target: string[],
zoneless: boolean,
usedComponentStyles: Map<string, string[]>,
templateUpdates: Map<string, string>,
prebundleLoaderExtensions: EsbuildLoaderOption | undefined,
extensionMiddleware?: Connect.NextHandleFunction[],
indexHtmlTransformer?: (content: string) => Promise<string>,
Expand Down Expand Up @@ -671,6 +691,7 @@ export async function setupServer(
indexHtmlTransformer,
extensionMiddleware,
usedComponentStyles,
templateUpdates,
ssrMode,
}),
createRemoveIdPrefixPlugin(externalMetadata.explicitBrowser),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string>,
): 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);
};
}
1 change: 1 addition & 0 deletions packages/angular/build/src/tools/vite/middlewares/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export {
createAngularSsrInternalMiddleware,
} from './ssr-middleware';
export { createAngularHeadersMiddleware } from './headers-middleware';
export { createAngularComponentMiddleware } from './component-middleware';
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { Connect, Plugin } from 'vite';
import {
angularHtmlFallbackMiddleware,
createAngularAssetsMiddleware,
createAngularComponentMiddleware,
createAngularHeadersMiddleware,
createAngularIndexHtmlMiddleware,
createAngularSsrExternalMiddleware,
Expand Down Expand Up @@ -48,6 +49,7 @@ interface AngularSetupMiddlewaresPluginOptions {
extensionMiddleware?: Connect.NextHandleFunction[];
indexHtmlTransformer?: (content: string) => Promise<string>;
usedComponentStyles: Map<string, string[]>;
templateUpdates: Map<string, string>;
ssrMode: ServerSsrMode;
}

Expand All @@ -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),
);
Expand Down