mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
refactor(core): restructure hydration test files (#58196)
This re-organizes the hydration tests to make it easier to add new tests. Incremental hydration can have tests in a separate file after this groundwork is landed. PR Close #58196
This commit is contained in:
parent
d6a8d35aec
commit
d8b5f4e0c2
6 changed files with 566 additions and 431 deletions
|
|
@ -73,6 +73,9 @@ ts_library(
|
|||
jasmine_node_test(
|
||||
name = "test",
|
||||
bootstrap = ["//tools/testing:node"],
|
||||
data = [
|
||||
"//packages/core/primitives/event-dispatch:contract_bundle_min",
|
||||
],
|
||||
deps = [
|
||||
":test_lib",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ export function hydrate(
|
|||
return bootstrapApplication(component, {providers});
|
||||
}
|
||||
|
||||
export function render(doc: Document, html: string) {
|
||||
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);
|
||||
|
||||
|
|
@ -125,6 +125,24 @@ export function render(doc: Document, html: string) {
|
|||
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.
|
||||
|
|
@ -134,7 +152,7 @@ export function render(doc: Document, html: string) {
|
|||
* @param envProviders the environment providers
|
||||
* @returns a promise with the application ref
|
||||
*/
|
||||
export async function renderAndHydrate(
|
||||
export async function prepareEnvironmentAndHydrate(
|
||||
doc: Document,
|
||||
html: string,
|
||||
component: Type<unknown>,
|
||||
|
|
@ -143,7 +161,7 @@ export async function renderAndHydrate(
|
|||
hydrationFeatures?: HydrationFeature<HydrationFeatureKind>[];
|
||||
},
|
||||
): Promise<ApplicationRef> {
|
||||
render(doc, html);
|
||||
prepareEnvironment(doc, html);
|
||||
return hydrate(doc, component, options);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,10 +29,12 @@ import {EventPhase} from '@angular/core/primitives/event-dispatch';
|
|||
import {
|
||||
getAppContents,
|
||||
hydrate,
|
||||
renderAndHydrate,
|
||||
render as renderHtml,
|
||||
prepareEnvironment,
|
||||
prepareEnvironmentAndHydrate,
|
||||
resetTViewsFor,
|
||||
} from './dom_utils';
|
||||
import {getDocument} from '@angular/core/src/render3/interfaces/document';
|
||||
import {serializeDocument} from '../src/domino_adapter';
|
||||
|
||||
/**
|
||||
* Represents the <script> tag added by the build process to inject
|
||||
|
|
@ -70,7 +72,6 @@ function withStrictErrorHandler() {
|
|||
}
|
||||
|
||||
describe('event replay', () => {
|
||||
let doc: Document;
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
|
|
@ -81,7 +82,6 @@ describe('event replay', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
if (getPlatform()) destroyPlatform();
|
||||
doc = TestBed.inject(DOCUMENT);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
|
@ -91,7 +91,6 @@ describe('event replay', () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
doc.body.outerHTML = '<body></body>';
|
||||
window._ejsas = {};
|
||||
});
|
||||
|
||||
|
|
@ -125,17 +124,6 @@ describe('event replay', () => {
|
|||
});
|
||||
}
|
||||
|
||||
function render(doc: Document, html: string) {
|
||||
renderHtml(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('should work for elements with local refs', async () => {
|
||||
const onClickSpy = jasmine.createSpy();
|
||||
|
||||
|
|
@ -151,7 +139,9 @@ describe('event replay', () => {
|
|||
}
|
||||
const html = await ssr(AppComponent);
|
||||
const ssrContents = getAppContents(html);
|
||||
render(doc, ssrContents);
|
||||
const doc = getDocument();
|
||||
|
||||
prepareEnvironment(doc, ssrContents);
|
||||
resetTViewsFor(AppComponent);
|
||||
const btn = doc.getElementById('btn')!;
|
||||
btn.click();
|
||||
|
|
@ -171,7 +161,7 @@ describe('event replay', () => {
|
|||
template: `
|
||||
<div class="card">
|
||||
<button id="inner-button" (click)="onClick()"></button>
|
||||
<ng-content></ng-content>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
|
@ -196,13 +186,16 @@ describe('event replay', () => {
|
|||
}
|
||||
const html = await ssr(AppComponent);
|
||||
const ssrContents = getAppContents(html);
|
||||
render(doc, ssrContents);
|
||||
const doc = getDocument();
|
||||
|
||||
prepareEnvironment(doc, ssrContents);
|
||||
resetTViewsFor(AppComponent);
|
||||
const outer = doc.getElementById('outer-button')!;
|
||||
const inner = doc.getElementById('inner-button')!;
|
||||
outer.click();
|
||||
inner.click();
|
||||
const appRef = await hydrate(doc, AppComponent, {
|
||||
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}],
|
||||
hydrationFeatures: [withEventReplay()],
|
||||
});
|
||||
expect(outerOnClickSpy).toHaveBeenCalledBefore(innerOnClickSpy);
|
||||
|
|
@ -225,7 +218,8 @@ describe('event replay', () => {
|
|||
const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
|
||||
const html = await ssr(SimpleComponent, {doc: docContents});
|
||||
const ssrContents = getAppContents(html);
|
||||
render(doc, ssrContents);
|
||||
const doc = getDocument();
|
||||
prepareEnvironment(doc, ssrContents);
|
||||
const el = doc.getElementById('1')!;
|
||||
expect(el.hasAttribute('jsaction')).toBeTrue();
|
||||
expect((el.firstChild as Element).hasAttribute('jsaction')).toBeTrue();
|
||||
|
|
@ -276,11 +270,14 @@ describe('event replay', () => {
|
|||
const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
|
||||
const html = await ssr(SimpleComponent, {doc: docContents});
|
||||
const ssrContents = getAppContents(html);
|
||||
render(doc, ssrContents);
|
||||
const doc = getDocument();
|
||||
|
||||
prepareEnvironment(doc, ssrContents);
|
||||
resetTViewsFor(SimpleComponent);
|
||||
const bottomEl = doc.getElementById('bottom')!;
|
||||
bottomEl.click();
|
||||
const appRef = await hydrate(doc, SimpleComponent, {
|
||||
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}],
|
||||
hydrationFeatures: [withEventReplay()],
|
||||
});
|
||||
expect(onClickSpy).toHaveBeenCalledTimes(2);
|
||||
|
|
@ -308,7 +305,8 @@ describe('event replay', () => {
|
|||
const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
|
||||
const html = await ssr(SimpleComponent, {doc: docContents});
|
||||
const ssrContents = getAppContents(html);
|
||||
render(doc, ssrContents);
|
||||
const doc = getDocument();
|
||||
prepareEnvironment(doc, ssrContents);
|
||||
resetTViewsFor(SimpleComponent);
|
||||
const bottomEl = doc.getElementById('bottom')!;
|
||||
bottomEl.click();
|
||||
|
|
@ -344,11 +342,13 @@ describe('event replay', () => {
|
|||
const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
|
||||
const html = await ssr(SimpleComponent, {doc: docContents});
|
||||
const ssrContents = getAppContents(html);
|
||||
render(doc, ssrContents);
|
||||
const doc = getDocument();
|
||||
prepareEnvironment(doc, ssrContents);
|
||||
resetTViewsFor(SimpleComponent);
|
||||
const bottomEl = doc.getElementById('bottom')!;
|
||||
bottomEl.click();
|
||||
await hydrate(doc, SimpleComponent, {
|
||||
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}],
|
||||
hydrationFeatures: [withEventReplay()],
|
||||
});
|
||||
const replayedEvent = currentEvent;
|
||||
|
|
@ -396,7 +396,8 @@ describe('event replay', () => {
|
|||
expect(hasEventDispatchScript(ssrContents)).toBeFalse();
|
||||
|
||||
resetTViewsFor(SimpleComponent);
|
||||
await renderAndHydrate(doc, ssrContents, SimpleComponent, {
|
||||
const doc = getDocument();
|
||||
await prepareEnvironmentAndHydrate(doc, ssrContents, SimpleComponent, {
|
||||
envProviders: [
|
||||
{provide: PLATFORM_ID, useValue: 'browser'},
|
||||
// This ensures that there are no errors while bootstrapping an application
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
270
packages/platform-server/test/hydration_utils.ts
Normal file
270
packages/platform-server/test/hydration_utils.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
/**
|
||||
* @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.io/license
|
||||
*/
|
||||
|
||||
import {
|
||||
ApplicationRef,
|
||||
ComponentRef,
|
||||
ErrorHandler,
|
||||
Injectable,
|
||||
Provider,
|
||||
Type,
|
||||
} from '@angular/core';
|
||||
import {Console} from '@angular/core/src/console';
|
||||
import {
|
||||
HydrationStatus,
|
||||
readHydrationInfo,
|
||||
SSR_CONTENT_INTEGRITY_MARKER,
|
||||
} from '@angular/core/src/hydration/utils';
|
||||
import {
|
||||
bootstrapApplication,
|
||||
HydrationFeature,
|
||||
provideClientHydration,
|
||||
} from '@angular/platform-browser';
|
||||
import {HydrationFeatureKind} from '@angular/platform-browser/src/hydration';
|
||||
|
||||
import {provideServerRendering} from '../public_api';
|
||||
import {EVENT_DISPATCH_SCRIPT_ID, renderApplication} from '../src/utils';
|
||||
|
||||
import {getAppContents, stripUtilAttributes} from './dom_utils';
|
||||
|
||||
/**
|
||||
* The name of the attribute that contains a slot index
|
||||
* inside the TransferState storage where hydration info
|
||||
* could be found.
|
||||
*/
|
||||
export const NGH_ATTR_NAME = 'ngh';
|
||||
export const EMPTY_TEXT_NODE_COMMENT = 'ngetn';
|
||||
export const TEXT_NODE_SEPARATOR_COMMENT = 'ngtns';
|
||||
|
||||
export const SKIP_HYDRATION_ATTR_NAME = 'ngSkipHydration';
|
||||
export const SKIP_HYDRATION_ATTR_NAME_LOWER_CASE = SKIP_HYDRATION_ATTR_NAME.toLowerCase();
|
||||
|
||||
export const TRANSFER_STATE_TOKEN_ID = '__nghData__';
|
||||
|
||||
/**
|
||||
* Represents the <script> tag added by the build process to inject
|
||||
* event dispatch (JSAction) logic.
|
||||
*/
|
||||
export const EVENT_DISPATCH_SCRIPT = `<script type="text/javascript" id="${EVENT_DISPATCH_SCRIPT_ID}"></script>`;
|
||||
export const DEFAULT_DOCUMENT = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
|
||||
|
||||
export function getComponentRef<T>(appRef: ApplicationRef): ComponentRef<T> {
|
||||
return appRef.components[0];
|
||||
}
|
||||
|
||||
export function stripSsrIntegrityMarker(input: string): string {
|
||||
return input.replace(`<!--${SSR_CONTENT_INTEGRITY_MARKER}-->`, '');
|
||||
}
|
||||
|
||||
export function stripTransferDataScript(input: string): string {
|
||||
return input.replace(/<script (.*?)<\/script>/s, '');
|
||||
}
|
||||
|
||||
export function stripExcessiveSpaces(html: string): string {
|
||||
return html.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function verifyClientAndSSRContentsMatch(
|
||||
ssrContents: string,
|
||||
clientAppRootElement: HTMLElement,
|
||||
) {
|
||||
const clientContents = stripSsrIntegrityMarker(
|
||||
stripTransferDataScript(stripUtilAttributes(clientAppRootElement.outerHTML, false)),
|
||||
);
|
||||
ssrContents = stripSsrIntegrityMarker(
|
||||
stripTransferDataScript(stripUtilAttributes(ssrContents, false)),
|
||||
);
|
||||
expect(getAppContents(clientContents)).toBe(ssrContents, 'Client and server contents mismatch');
|
||||
}
|
||||
|
||||
export function verifyNodeHasMismatchInfo(doc: Document, selector = 'app'): void {
|
||||
expect(readHydrationInfo(doc.querySelector(selector)!)?.status).toBe(HydrationStatus.Mismatched);
|
||||
}
|
||||
|
||||
/** Checks whether a given element is a <script> that contains transfer state data. */
|
||||
export function isTransferStateScript(el: HTMLElement): boolean {
|
||||
return (
|
||||
el.nodeType === Node.ELEMENT_NODE &&
|
||||
el.tagName.toLowerCase() === 'script' &&
|
||||
el.getAttribute('id') === 'ng-state'
|
||||
);
|
||||
}
|
||||
|
||||
export function isSsrContentsIntegrityMarker(el: Node): boolean {
|
||||
return (
|
||||
el.nodeType === Node.COMMENT_NODE && el.textContent?.trim() === SSR_CONTENT_INTEGRITY_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks over DOM nodes starting from a given node and checks
|
||||
* whether all nodes were claimed for hydration, i.e. annotated
|
||||
* with a special monkey-patched flag (which is added in dev mode
|
||||
* only). It skips any nodes with the skip hydration attribute.
|
||||
*/
|
||||
export function verifyAllNodesClaimedForHydration(el: HTMLElement, exceptions: HTMLElement[] = []) {
|
||||
if (
|
||||
(el.nodeType === Node.ELEMENT_NODE && el.hasAttribute(SKIP_HYDRATION_ATTR_NAME_LOWER_CASE)) ||
|
||||
exceptions.includes(el) ||
|
||||
isTransferStateScript(el) ||
|
||||
isSsrContentsIntegrityMarker(el)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (readHydrationInfo(el)?.status !== HydrationStatus.Hydrated) {
|
||||
fail('Hydration error: the node is *not* hydrated: ' + el.outerHTML);
|
||||
}
|
||||
verifyAllChildNodesClaimedForHydration(el, exceptions);
|
||||
}
|
||||
|
||||
export function verifyAllChildNodesClaimedForHydration(
|
||||
el: HTMLElement,
|
||||
exceptions: HTMLElement[] = [],
|
||||
) {
|
||||
let current = el.firstChild;
|
||||
while (current) {
|
||||
verifyAllNodesClaimedForHydration(current as HTMLElement, exceptions);
|
||||
current = current.nextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks over DOM nodes starting from a given node and make sure
|
||||
* those nodes were not annotated as "claimed" by hydration.
|
||||
* This helper function is needed to verify that the non-destructive
|
||||
* hydration feature can be turned off.
|
||||
*/
|
||||
export function verifyNoNodesWereClaimedForHydration(el: HTMLElement) {
|
||||
if (readHydrationInfo(el)?.status === HydrationStatus.Hydrated) {
|
||||
fail(
|
||||
'Unexpected state: the following node was hydrated, when the test ' +
|
||||
'expects the node to be re-created instead: ' +
|
||||
el.outerHTML,
|
||||
);
|
||||
}
|
||||
let current = el.firstChild;
|
||||
while (current) {
|
||||
verifyNoNodesWereClaimedForHydration(current as HTMLElement);
|
||||
current = current.nextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyNodeHasSkipHydrationMarker(element: HTMLElement): void {
|
||||
expect(readHydrationInfo(element)?.status).toBe(HydrationStatus.Skipped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies whether a console has a log entry that contains a given message.
|
||||
*/
|
||||
export function verifyHasLog(appRef: ApplicationRef, message: string) {
|
||||
const console = appRef.injector.get(Console) as DebugConsole;
|
||||
const context =
|
||||
`Expected '${message}' to be present in the log, but it was not found. ` +
|
||||
`Logs content: ${JSON.stringify(console.logs)}`;
|
||||
expect(console.logs.some((log) => log.includes(message)))
|
||||
.withContext(context)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that there is no message with a particular content in a console.
|
||||
*/
|
||||
export function verifyHasNoLog(appRef: ApplicationRef, message: string) {
|
||||
const console = appRef.injector.get(Console) as DebugConsole;
|
||||
const context =
|
||||
`Expected '${message}' to be present in the log, but it was not found. ` +
|
||||
`Logs content: ${JSON.stringify(console.logs)}`;
|
||||
expect(console.logs.some((log) => log.includes(message)))
|
||||
.withContext(context)
|
||||
.toBe(false);
|
||||
}
|
||||
|
||||
export function timeout(delay: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, delay);
|
||||
});
|
||||
}
|
||||
|
||||
export function getHydrationInfoFromTransferState(input: string): string | undefined {
|
||||
return input.match(/<script[^>]+>(.*?)<\/script>/)?.[1];
|
||||
}
|
||||
|
||||
export function withNoopErrorHandler() {
|
||||
class NoopErrorHandler extends ErrorHandler {
|
||||
override handleError(error: any): void {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
return [
|
||||
{
|
||||
provide: ErrorHandler,
|
||||
useClass: NoopErrorHandler,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DebugConsole extends Console {
|
||||
logs: string[] = [];
|
||||
override log(message: string) {
|
||||
this.logs.push(message);
|
||||
}
|
||||
override warn(message: string) {
|
||||
this.logs.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function withDebugConsole() {
|
||||
return [{provide: Console, useClass: DebugConsole}];
|
||||
}
|
||||
|
||||
/**
|
||||
* This renders the application with server side rendering logic.
|
||||
*
|
||||
* @param component the test component to be rendered
|
||||
* @param doc the document
|
||||
* @param envProviders the environment providers
|
||||
* @returns a promise containing the server rendered app as a string
|
||||
*/
|
||||
export async function ssr(
|
||||
component: Type<unknown>,
|
||||
options?: {
|
||||
doc?: string;
|
||||
envProviders?: Provider[];
|
||||
hydrationFeatures?: HydrationFeature<HydrationFeatureKind>[];
|
||||
enableHydration?: boolean;
|
||||
},
|
||||
): Promise<string> {
|
||||
const defaultHtml = DEFAULT_DOCUMENT;
|
||||
const enableHydration = options?.enableHydration ?? true;
|
||||
const envProviders = options?.envProviders ?? [];
|
||||
const hydrationFeatures = options?.hydrationFeatures ?? [];
|
||||
const providers = [
|
||||
...envProviders,
|
||||
provideServerRendering(),
|
||||
enableHydration ? provideClientHydration(...hydrationFeatures) : [],
|
||||
];
|
||||
|
||||
const bootstrap = () => bootstrapApplication(component, {providers});
|
||||
|
||||
return renderApplication(bootstrap, {
|
||||
document: options?.doc ?? defaultHtml,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that there are no messages in a console.
|
||||
*/
|
||||
export function verifyEmptyConsole(appRef: ApplicationRef) {
|
||||
const console = appRef.injector.get(Console) as DebugConsole;
|
||||
const logs = console.logs.filter(
|
||||
(msg) => !msg.startsWith('Angular is running in development mode'),
|
||||
);
|
||||
expect(logs).toEqual([]);
|
||||
}
|
||||
|
|
@ -570,6 +570,10 @@ class HiddenModule {}
|
|||
destroyPlatform();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyPlatform();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
destroyPlatform();
|
||||
});
|
||||
|
|
@ -772,6 +776,11 @@ class HiddenModule {}
|
|||
doc = '<html><head></head><body><app></app></body></html>';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
doc = '<html><head></head><body><app></app></body></html>';
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
it('using long form should work', async () => {
|
||||
const platform = platformServer([{provide: INITIAL_CONFIG, useValue: {document: doc}}]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue