angular/packages/platform-server/test/dom_utils.ts
Andrew Scott 859958dbfe fix(core): Ensure application remains unstable during bootstrap (#62631)
This commit ensures the application remains unstable during the entire
bootstrap process. This ensures all bootstrap listeners and app
initializers observe the application as being unstable until each one
has gotten a chance to execute the synchronous block (potentially adding
more pending tasks).

Prior to this commit, application initializers or bootstrap
listeners may observe the application as being stable, even though other
initializers/listeners had not yet executed. This created an ordering
issue whereby the hydration bootstrap listener would observe the
application as stable prior to the router performing its initial
navigation.

fixes #62592

PR Close #62631
2025-07-15 07:52:36 -07:00

182 lines
5.5 KiB
TypeScript

/**
* @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 {DOCUMENT} from '@angular/common';
import {
ApplicationRef,
PLATFORM_ID,
Provider,
Type,
ɵsetDocument,
ɵCLIENT_RENDER_MODE_FLAG as CLIENT_RENDER_MODE_FLAG,
ɵgetComponentDef as getComponentDef,
} from '@angular/core';
import {
bootstrapApplication,
HydrationFeature,
HydrationFeatureKind,
provideClientHydration,
} from '@angular/platform-browser';
/**
* The name of the attribute that contains a slot index
* inside the TransferState storage where hydration info
* could be found.
*/
const NGH_ATTR_NAME = 'ngh';
const EMPTY_TEXT_NODE_COMMENT = 'ngetn';
const TEXT_NODE_SEPARATOR_COMMENT = 'ngtns';
const NGH_ATTR_REGEXP = new RegExp(` ${NGH_ATTR_NAME}=".*?"`, 'g');
const EMPTY_TEXT_NODE_REGEXP = new RegExp(`<!--${EMPTY_TEXT_NODE_COMMENT}-->`, 'g');
const TEXT_NODE_SEPARATOR_REGEXP = new RegExp(`<!--${TEXT_NODE_SEPARATOR_COMMENT}-->`, 'g');
/**
* Drop utility attributes such as `ng-version`, `ng-server-context` and `ngh`,
* so that it's easier to make assertions in tests.
*/
export function stripUtilAttributes(html: string, keepNgh: boolean): string {
html = html
.replace(/ ng-version=".*?"/g, '')
.replace(/ ng-server-context=".*?"/g, '')
.replace(/ ng-reflect-(.*?)=".*?"/g, '')
.replace(/ _nghost(.*?)=""/g, '')
.replace(/ _ngcontent(.*?)=""/g, '');
if (!keepNgh) {
html = html
.replace(NGH_ATTR_REGEXP, '')
.replace(EMPTY_TEXT_NODE_REGEXP, '')
.replace(TEXT_NODE_SEPARATOR_REGEXP, '');
}
return html;
}
/**
* Extracts a portion of HTML located inside of the `<body>` element.
* This content belongs to the application view (and supporting TransferState
* scripts) rendered on the server.
*/
export function getAppContents(html: string): string {
const result = stripUtilAttributes(html, true).match(/<body>(.*?)<\/body>/s);
return result ? result[1] : html;
}
/**
* Converts a static HTML to a DOM structure.
*
* @param html the rendered html in test
* @param doc the document object
* @returns a div element containing a copy of the app contents
*/
function convertHtmlToDom(html: string, doc: Document): HTMLElement {
const contents = getAppContents(html);
const container = doc.createElement('div');
container.innerHTML = contents;
return container;
}
/**
* Reset TView, so that we re-enter the first create pass as
* we would normally do when we hydrate on the client. Otherwise,
* hydration info would not be applied to T data structures.
*/
export function resetTViewsFor(...types: Type<unknown>[]) {
for (const type of types) {
getComponentDef(type)!.tView = null;
}
}
export function hydrate(
doc: Document,
component: Type<unknown>,
options: {
envProviders?: Provider[];
hydrationFeatures?: () => HydrationFeature<HydrationFeatureKind>[];
} = {},
) {
const {envProviders = [], hydrationFeatures = () => []} = options;
// Apply correct reference to the `document` object,
// which will be used by runtime.
ɵsetDocument(doc);
// Define `document` to make `DefaultDomRenderer2` work, since it
// references `document` directly to create style tags.
global.document = doc;
const providers = [
{provide: PLATFORM_ID, useValue: 'browser'},
{provide: DOCUMENT, useFactory: () => doc},
provideClientHydration(...hydrationFeatures()),
...envProviders,
];
return bootstrapApplication(component, {providers});
}
export function insertDomInDocument(doc: Document, html: string) {
// Get HTML contents of the `<app>`, create a DOM element and append it into the body.
const container = convertHtmlToDom(html, doc);
// If there was a client render mode marker present in HTML - apply it to the <body>
// element as well.
const hasClientModeMarker = new RegExp(` ${CLIENT_RENDER_MODE_FLAG}`, 'g').test(html);
if (hasClientModeMarker) {
doc.body.setAttribute(CLIENT_RENDER_MODE_FLAG, '');
}
Array.from(container.childNodes).forEach((node) => doc.body.appendChild(node));
}
/**
* This prepares the environment before hydration begins.
*
* @param doc the document object
* @param html the server side rendered DOM string to be hydrated
* @returns a promise with the application ref
*/
export function prepareEnvironment(doc: Document, html: string) {
insertDomInDocument(doc, html);
globalThis.document = doc;
const scripts = doc.getElementsByTagName('script');
for (const script of Array.from(scripts)) {
if (script?.textContent?.startsWith('window.__jsaction_bootstrap')) {
eval(script.textContent);
}
}
}
/**
* This bootstraps an application with existing html and enables hydration support
* causing hydration to be invoked.
*
* @param html the server side rendered DOM string to be hydrated
* @param component the root component
* @param envProviders the environment providers
* @returns a promise with the application ref
*/
export async function prepareEnvironmentAndHydrate(
doc: Document,
html: string,
component: Type<unknown>,
options?: {
envProviders?: Provider[];
hydrationFeatures?: () => HydrationFeature<HydrationFeatureKind>[];
},
): Promise<ApplicationRef> {
prepareEnvironment(doc, html);
return hydrate(doc, component, options);
}
/**
* Clears document contents to have a clean state for the next test.
*/
export function clearDocument(doc: Document) {
doc.body.textContent = '';
doc.body.removeAttribute(CLIENT_RENDER_MODE_FLAG);
}