Skip to content
Draft
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
18 changes: 18 additions & 0 deletions checks.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
test fail 11m2s https://github.com/angular/angular/actions/runs/23516209930/job/68449134784
Inclusive Language pass 1s https://github.com/jpoehnelt/in-solidarity-bot
adev pass 14m43s https://github.com/angular/angular/actions/runs/23516209930/job/68449134770
assistant_to_the_branch_manager pass 8s https://github.com/angular/angular/actions/runs/23516209405/job/68449132364
cla/google pass 13s https://cla.developers.google.com/about
devtools pass 13m24s https://github.com/angular/angular/actions/runs/23516209930/job/68449134797
integration-tests pass 7m51s https://github.com/angular/angular/actions/runs/23516209930/job/68449134761
lint pass 1m39s https://github.com/angular/angular/actions/runs/23516209930/job/68449134747
post_approval_changes pass 6s https://github.com/angular/angular/actions/runs/23516209415/job/68449132415
pull_request_labels pass 8s https://github.com/angular/angular/actions/runs/23516209415/job/68449132385
trigger pass 10s https://github.com/angular/angular/actions/runs/23516209409/job/68449132368
adev-build skipping 0 https://github.com/angular/angular/actions/runs/23516209924/job/68449134701
issue_labels skipping 0 https://github.com/angular/angular/actions/runs/23516209415/job/68449132615
vscode-ng-language-service pass 7m54s https://github.com/angular/angular/actions/runs/23516209930/job/68449134746
zone-js pass 2m27s https://github.com/angular/angular/actions/runs/23516209930/job/68449134769
ci/angular: merge status pending 0 Missing required labels: target: *, status "google-internal-tests" is pending, status "pullapprove" is pending
google-internal-tests pending 0 http://go/angular-g3sync-start Waiting for tests to start. @Googlers: Initiate a presubmit. See -->
pullapprove pending 0 https://app.pullapprove.com/report/?url=https%3A//pullapprove-storage-production.s3.amazonaws.com/reports/54594185-98d6-4f14-a989-34e3a42bd220.json&fingerprint=6166234310dd0dfd3d81c40997c21e97 Waiting to send reviews as PR is WIP
2,152 changes: 2,152 additions & 0 deletions failed_log.txt

Large diffs are not rendered by default.

100 changes: 100 additions & 0 deletions failed_log_tail.txt

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions goldens/public-api/platform-browser/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export enum HydrationFeatureKind {
// (undocumented)
HttpTransferCacheOptions = 1,
// (undocumented)
HydrationBoundary = 5,
// (undocumented)
I18nSupport = 2,
// (undocumented)
IncrementalHydration = 4,
Expand Down Expand Up @@ -206,6 +208,9 @@ export function withEventReplay(): HydrationFeature<HydrationFeatureKind.EventRe
// @public
export function withHttpTransferCacheOptions(options: HttpTransferCacheOptions): HydrationFeature<HydrationFeatureKind.HttpTransferCacheOptions>;

// @public
export function withHydrationBoundary(hostNodes: (Element | string)[]): HydrationFeature<HydrationFeatureKind.HydrationBoundary>;

// @public
export function withI18nSupport(): HydrationFeature<HydrationFeatureKind.I18nSupport>;

Expand Down
19 changes: 19 additions & 0 deletions goldens/public-api/platform-server/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export function platformServer(extraProviders?: StaticProvider[] | undefined): P
export class PlatformState {
constructor(_doc: any);
getDocument(): any;
renderToParts(): {
head: string;
body: string;
};
renderToString(): string;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<PlatformState, never>;
Expand All @@ -52,13 +56,28 @@ export function renderApplication(bootstrap: (context: BootstrapContext) => Prom
platformProviders?: Provider[];
}): Promise<string>;

// @public
export function renderApplicationParts(bootstrap: (context: BootstrapContext) => Promise<ApplicationRef>, options: {
document?: string | Document;
url?: string;
platformProviders?: Provider[];
}): Promise<ServerApplicationParts>;

// @public
export function renderModule<T>(moduleType: Type<T>, options: {
document?: string | Document;
url?: string;
extraProviders?: StaticProvider[];
}): Promise<string>;

// @public
export interface ServerApplicationParts {
// (undocumented)
body: string;
// (undocumented)
head: string;
}

// @public
export class ServerModule {
// (undocumented)
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/core_private_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export {
JSACTION_BLOCK_ELEMENT_MAP as ɵJSACTION_BLOCK_ELEMENT_MAP,
IS_ENABLED_BLOCKING_INITIAL_NAVIGATION as ɵIS_ENABLED_BLOCKING_INITIAL_NAVIGATION,
EVENT_REPLAY_QUEUE as ɵEVENT_REPLAY_QUEUE,
ISOLATED_HYDRATION_DOM_BOUNDARY as ɵISOLATED_HYDRATION_DOM_BOUNDARY,
} from './hydration/tokens';
export {
HydrationStatus as ɵHydrationStatus,
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/hydration/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
IS_I18N_HYDRATION_ENABLED,
IS_INCREMENTAL_HYDRATION_ENABLED,
PRESERVE_HOST_CONTENT,
ISOLATED_HYDRATION_DOM_BOUNDARY,
} from './tokens';
import {
appendDeferBlocksToJSActionMap,
Expand Down Expand Up @@ -242,8 +243,9 @@ export function withDomHydration(): EnvironmentProviders {
}

const doc = inject(DOCUMENT);
const hydrationBoundaries = inject(ISOLATED_HYDRATION_DOM_BOUNDARY);
if (inject(IS_HYDRATION_DOM_REUSE_ENABLED)) {
verifySsrContentsIntegrity(doc);
verifySsrContentsIntegrity(doc, hydrationBoundaries);
enableHydrationRuntimeSupport();
} else if (
typeof ngDevMode !== 'undefined' &&
Expand Down Expand Up @@ -382,12 +384,13 @@ export function withIncrementalHydration(): Provider[] {
useFactory: () => {
const injector = inject(Injector);
const doc = inject(DOCUMENT);
const hydrationBoundaries = inject(ISOLATED_HYDRATION_DOM_BOUNDARY);

return () => {
const deferBlockData = processBlockData(injector);
const commentsByBlockId = gatherDeferBlocksCommentNodes(doc, doc.body);
const commentsByBlockId = gatherDeferBlocksCommentNodes(doc, hydrationBoundaries);
processAndInitTriggers(injector, deferBlockData, commentsByBlockId);
appendDeferBlocksToJSActionMap(doc, injector);
appendDeferBlocksToJSActionMap(doc, injector, hydrationBoundaries);
};
},
multi: true,
Expand Down
45 changes: 27 additions & 18 deletions packages/core/src/hydration/node_lookup_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,26 +403,35 @@ export function calcPathForNode(
*/
export function gatherDeferBlocksCommentNodes(
doc: Document,
node: HTMLElement,
boundaries: (Element | string)[] = [],
): Map<string, Comment> {
const commentNodesIterator = doc.createNodeIterator(node, NodeFilter.SHOW_COMMENT, {acceptNode});
let currentNode: Comment;

if (!boundaries || boundaries.length === 0) {
boundaries = [doc.body];
}
const nodesByBlockId = new Map<string, Comment>();
while ((currentNode = commentNodesIterator.nextNode() as Comment)) {
const nghPattern = 'ngh=';
const content = currentNode?.textContent;
const nghIdx = content?.indexOf(nghPattern) ?? -1;
if (nghIdx > -1) {
const nghValue = content!.substring(nghIdx + nghPattern.length).trim();
// Make sure the value has an expected format.
ngDevMode &&
assertEqual(
nghValue.startsWith('d'),
true,
'Invalid defer block id found in a comment node.',
);
nodesByBlockId.set(nghValue, currentNode);
for (const boundary of boundaries) {
const parentNode = typeof boundary === 'string' ? doc.querySelector(boundary) : boundary;
if (!parentNode) continue;
const commentNodesIterator = doc.createNodeIterator(parentNode, NodeFilter.SHOW_COMMENT, {
acceptNode,
});
let currentNode: Comment;

while ((currentNode = commentNodesIterator.nextNode() as Comment)) {
const nghPattern = 'ngh=';
const content = currentNode?.textContent;
const nghIdx = content?.indexOf(nghPattern) ?? -1;
if (nghIdx > -1) {
const nghValue = content!.substring(nghIdx + nghPattern.length).trim();
// Make sure the value has an expected format.
ngDevMode &&
assertEqual(
nghValue.startsWith('d'),
true,
'Invalid defer block id found in a comment node.',
);
nodesByBlockId.set(nghValue, currentNode);
}
}
}
return nodesByBlockId;
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/hydration/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {inject} from '../di/injector_compatibility';
import {InjectionToken} from '../di/injection_token';

/**
Expand Down Expand Up @@ -88,3 +89,14 @@ export const JSACTION_BLOCK_ELEMENT_MAP = new InjectionToken<Map<string, Set<Ele
export const IS_ENABLED_BLOCKING_INITIAL_NAVIGATION = new InjectionToken<boolean>(
typeof ngDevMode === 'undefined' || ngDevMode ? 'IS_ENABLED_BLOCKING_INITIAL_NAVIGATION' : '',
);

/**
* Internal token that stores the explicit DOM boundary(ies) for hydration targeting document fragments.
* Defaults to `[document.body]`.
*/
export const ISOLATED_HYDRATION_DOM_BOUNDARY = new InjectionToken<(Element | string)[]>(
typeof ngDevMode === 'undefined' || ngDevMode ? 'ISOLATED_HYDRATION_DOM_BOUNDARY' : '',
{
factory: () => [],
},
);
87 changes: 60 additions & 27 deletions packages/core/src/hydration/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
Expand Down Expand Up @@ -630,22 +630,42 @@ export function getParentBlockHydrationQueue(
return {parentBlockPromise, hydrationQueue};
}

function gatherDeferBlocksByJSActionAttribute(doc: Document): Set<HTMLElement> {
const jsactionNodes = doc.body.querySelectorAll('[jsaction]');
function gatherDeferBlocksByJSActionAttribute(
doc: Document,
boundaries: (Element | string)[] = [],
): Set<HTMLElement> {
if (!boundaries || boundaries.length === 0) {
boundaries = [doc.body];
}
const blockMap = new Set<HTMLElement>();
const eventTypes = [hoverEventNames.join(':;'), interactionEventNames.join(':;')].join('|');
for (let node of jsactionNodes) {
const attr = node.getAttribute('jsaction');
const blockId = node.getAttribute('ngb');
if (attr?.match(eventTypes) && blockId !== null) {
blockMap.add(node as HTMLElement);

for (const boundary of boundaries) {
const parentNode = typeof boundary === 'string' ? doc.querySelector(boundary) : boundary;
if (!parentNode) continue;

const jsactionNodes = parentNode.querySelectorAll('[jsaction]');
for (let i = 0; i < jsactionNodes.length; i++) {
const node = jsactionNodes[i];
const attr = node.getAttribute('jsaction');
const blockId = node.getAttribute('ngb');
if (attr?.match(eventTypes) && blockId !== null) {
blockMap.add(node as HTMLElement);
}
}
}
return blockMap;
}

export function appendDeferBlocksToJSActionMap(doc: Document, injector: Injector) {
const blockMap = gatherDeferBlocksByJSActionAttribute(doc);
export function appendDeferBlocksToJSActionMap(
doc: Document,
injector: Injector,
boundaries: (Element | string)[] = [],
) {
if (!boundaries || boundaries.length === 0) {
boundaries = [doc.body];
}
const blockMap = gatherDeferBlocksByJSActionAttribute(doc, boundaries);
const jsActionMap = injector.get(JSACTION_BLOCK_ELEMENT_MAP);
for (let rNode of blockMap) {
sharedMapFunction(rNode, jsActionMap);
Expand Down Expand Up @@ -773,29 +793,42 @@ function skipTextNodes(node: ChildNode | null): ChildNode | null {
*
* Note: this function is invoked only on the client, so it's safe to use DOM APIs.
*/
export function verifySsrContentsIntegrity(doc: Document): void {
for (const node of doc.body.childNodes) {
if (isSsrContentsIntegrity(node)) {
return;
export function verifySsrContentsIntegrity(
doc: Document,
boundaries: (Element | string)[] = [],
): void {
if (!boundaries || boundaries.length === 0) {
boundaries = [doc.body];
}
for (const boundary of boundaries) {
const boundaryElement = typeof boundary === 'string' ? doc.querySelector(boundary) : boundary;
if (boundaryElement) {
for (const node of boundaryElement.childNodes) {
if (isSsrContentsIntegrity(node)) {
return;
}
}
}
}

// Check if the HTML parser may have moved the marker to just before the <body> tag,
// Fallback check: if boundaries precisely matches `[doc.body]`, we check
// if the HTML parser may have moved the marker to just before the <body> tag,
// e.g. because the body tag was implicit and not present in the markup. An implicit body
// tag is unlikely to interfer with whitespace/comments inside of the app's root element.
if (boundaries.length === 1 && boundaries[0] === doc.body) {
// Case 1: Implicit body. Example:
// <!doctype html><head><title>Hi</title></head><!--nghm--><app-root></app-root>
const beforeBody = skipTextNodes(doc.body.previousSibling);
if (isSsrContentsIntegrity(beforeBody)) {
return;
}

// Case 1: Implicit body. Example:
// <!doctype html><head><title>Hi</title></head><!--nghm--><app-root></app-root>
const beforeBody = skipTextNodes(doc.body.previousSibling);
if (isSsrContentsIntegrity(beforeBody)) {
return;
}

// Case 2: Implicit body & head. Example:
// <!doctype html><head><title>Hi</title><!--nghm--><app-root></app-root>
let endOfHead = skipTextNodes(doc.head.lastChild);
if (isSsrContentsIntegrity(endOfHead)) {
return;
// Case 2: Implicit body & head. Example:
// <!doctype html><head><title>Hi</title><!--nghm--><app-root></app-root>
let endOfHead = skipTextNodes(doc.head.lastChild);
if (isSsrContentsIntegrity(endOfHead)) {
return;
}
}

throw new RuntimeError(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
"INJECTOR_SCOPE",
"INTERNAL_APPLICATION_ERROR_HANDLER",
"INTERNAL_BROWSER_PLATFORM_PROVIDERS",
"ISOLATED_HYDRATION_DOM_BOUNDARY",
"IS_HYDRATION_DOM_REUSE_ENABLED",
"InjectionToken",
"Injector",
Expand Down
25 changes: 24 additions & 1 deletion packages/core/test/hydration/marker_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,29 @@ describe('verifySsrContentsIntegrity', () => {
const dom = await doc(
`<!doctype html><title>Hi</title>\n<!--${SSR_CONTENT_INTEGRITY_MARKER}-->\n<app-root></app-root>`,
);
expect(() => verifySsrContentsIntegrity(dom)).not.toThrow();
expect(() => verifySsrContentsIntegrity(dom, [dom.body])).not.toThrow();
});

it('succeeds when integrity marker is inside a specified element boundary', async () => {
const dom = await doc(
`<!doctype html><head></head><body><div id="island"><!--${SSR_CONTENT_INTEGRITY_MARKER}--><app-root></app-root></div></body>`,
);
const island = dom.getElementById('island')!;
expect(() => verifySsrContentsIntegrity(dom, [island])).not.toThrow();
});

it('succeeds when integrity marker is inside a specified selector boundary', async () => {
const dom = await doc(
`<!doctype html><head></head><body><div id="island"><!--${SSR_CONTENT_INTEGRITY_MARKER}--><app-root></app-root></div></body>`,
);
expect(() => verifySsrContentsIntegrity(dom, ['#island'])).not.toThrow();
});

it('fails when integrity marker is outside the specified boundary', async () => {
const dom = await doc(
// Marker is inside body, but we configure `#island` as the boundary.
`<!doctype html><head></head><body><!--${SSR_CONTENT_INTEGRITY_MARKER}--><div id="island"><app-root></app-root></div></body>`,
);
expect(() => verifySsrContentsIntegrity(dom, ['#island'])).toThrowError(/NG0507/);
});
});
Loading
Loading