From cdfcb779df9d9d266fbd61ff5989ae7a09ceccfd Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Fri, 8 Mar 2024 18:13:15 +0000 Subject: [PATCH] refactor(core): add internal API to enable i18n hydration (#54784) Add an internal API to enable and use i18n hydration for testing and development. This helps ensure that we don't accidentally break the current behavior until we are completely ready to roll out i18n support. PR Close #54784 --- packages/core/src/core_private_export.ts | 2 +- packages/core/src/hydration/annotate.ts | 4 +- packages/core/src/hydration/api.ts | 31 +- packages/core/src/hydration/tokens.ts | 7 + packages/core/src/render3/i18n/i18n_apply.ts | 38 +- .../hydration/bundle.golden_symbols.json | 3 + .../platform-server/test/hydration_spec.ts | 489 ++++++++++-------- 7 files changed, 355 insertions(+), 219 deletions(-) diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index 5b56e184c81..ddc79340486 100644 --- a/packages/core/src/core_private_export.ts +++ b/packages/core/src/core_private_export.ts @@ -25,7 +25,7 @@ export {INJECTOR_SCOPE as ɵINJECTOR_SCOPE} from './di/scope'; export {XSS_SECURITY_URL as ɵXSS_SECURITY_URL} from './error_details_base_url'; export {formatRuntimeError as ɵformatRuntimeError, RuntimeError as ɵRuntimeError, RuntimeErrorCode as ɵRuntimeErrorCode} from './errors'; export {annotateForHydration as ɵannotateForHydration} from './hydration/annotate'; -export {withDomHydration as ɵwithDomHydration} from './hydration/api'; +export {withDomHydration as ɵwithDomHydration, withI18nHydration as ɵwithI18nHydration} from './hydration/api'; export {IS_HYDRATION_DOM_REUSE_ENABLED as ɵIS_HYDRATION_DOM_REUSE_ENABLED} from './hydration/tokens'; export {HydratedNode as ɵHydratedNode, HydrationInfo as ɵHydrationInfo, readHydrationInfo as ɵreadHydrationInfo, SSR_CONTENT_INTEGRITY_MARKER as ɵSSR_CONTENT_INTEGRITY_MARKER} from './hydration/utils'; export {CurrencyIndex as ɵCurrencyIndex, ExtraLocaleDataIndex as ɵExtraLocaleDataIndex, findLocaleData as ɵfindLocaleData, getLocaleCurrencyCode as ɵgetLocaleCurrencyCode, getLocalePluralCase as ɵgetLocalePluralCase, LocaleDataIndex as ɵLocaleDataIndex, registerLocaleData as ɵregisterLocaleData, unregisterAllLocaleData as ɵunregisterLocaleData} from './i18n/locale_data_api'; diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index 17c2e11a90c..3c2ea73631c 100644 --- a/packages/core/src/hydration/annotate.ts +++ b/packages/core/src/hydration/annotate.ts @@ -19,6 +19,7 @@ import {CONTEXT, HEADER_OFFSET, HOST, LView, PARENT, RENDERER, TView, TVIEW, TVi import {unwrapLView, unwrapRNode} from '../render3/util/view_utils'; import {TransferState} from '../transfer_state'; +import {isI18nHydrationSupportEnabled} from './api'; import {unsupportedProjectionOfDomNodes} from './error_handling'; import {CONTAINERS, DISCONNECTED_NODES, ELEMENT_CONTAINERS, MULTIPLIER, NODES, NUM_ROOT_NODES, SerializedContainerView, SerializedView, TEMPLATE_ID, TEMPLATES} from './interfaces'; import {calcPathForNode, isDisconnectedNode} from './node_lookup_utils'; @@ -526,7 +527,8 @@ function componentUsesShadowDomEncapsulation(lView: LView): boolean { function annotateHostElementForHydration( element: RElement, lView: LView, context: HydrationContext): number|null { const renderer = lView[RENDERER]; - if (hasI18n(lView) || componentUsesShadowDomEncapsulation(lView)) { + if ((hasI18n(lView) && !isI18nHydrationSupportEnabled()) || + componentUsesShadowDomEncapsulation(lView)) { // Attach the skip hydration attribute if this component: // - either has i18n blocks, since hydrating such blocks is not yet supported // - or uses ShadowDom view encapsulation, since Domino doesn't support diff --git a/packages/core/src/hydration/api.ts b/packages/core/src/hydration/api.ts index 3702cf857b5..1b842137323 100644 --- a/packages/core/src/hydration/api.ts +++ b/packages/core/src/hydration/api.ts @@ -12,6 +12,7 @@ import {ENVIRONMENT_INITIALIZER, EnvironmentProviders, Injector, makeEnvironment import {inject} from '../di/injector_compatibility'; import {formatRuntimeError, RuntimeError, RuntimeErrorCode} from '../errors'; import {enableLocateOrCreateContainerRefImpl} from '../linker/view_container_ref'; +import {enableLocateOrCreateI18nNodeImpl} from '../render3/i18n/i18n_apply'; import {enableLocateOrCreateElementNodeImpl} from '../render3/instructions/element'; import {enableLocateOrCreateElementContainerNodeImpl} from '../render3/instructions/element_container'; import {enableApplyRootElementTransformImpl} from '../render3/instructions/shared'; @@ -24,7 +25,7 @@ import {performanceMarkFeature} from '../util/performance'; import {NgZone} from '../zone'; import {cleanupDehydratedViews} from './cleanup'; -import {IS_HYDRATION_DOM_REUSE_ENABLED, PRESERVE_HOST_CONTENT} from './tokens'; +import {IS_HYDRATION_DOM_REUSE_ENABLED, IS_I18N_HYDRATION_ENABLED, PRESERVE_HOST_CONTENT} from './tokens'; import {enableRetrieveHydrationInfoImpl, NGH_DATA_KEY, SSR_CONTENT_INTEGRITY_MARKER} from './utils'; import {enableFindMatchingDehydratedViewImpl} from './views'; @@ -34,6 +35,11 @@ import {enableFindMatchingDehydratedViewImpl} from './views'; */ let isHydrationSupportEnabled = false; +/** + * Indicates whether support for hydrating i18n blocks is enabled. + */ +let _isI18nHydrationSupportEnabled = false; + /** * Defines a period of time that Angular waits for the `ApplicationRef.isStable` to emit `true`. * If there was no event with the `true` value during this time, Angular reports a warning. @@ -62,6 +68,7 @@ function enableHydrationRuntimeSupport() { enableLocateOrCreateContainerRefImpl(); enableFindMatchingDehydratedViewImpl(); enableApplyRootElementTransformImpl(); + enableLocateOrCreateI18nNodeImpl(); } } @@ -145,6 +152,8 @@ export function withDomHydration(): EnvironmentProviders { { provide: ENVIRONMENT_INITIALIZER, useValue: () => { + _isI18nHydrationSupportEnabled = !!inject(IS_I18N_HYDRATION_ENABLED, {optional: true}); + // Since this function is used across both server and client, // make sure that the runtime code is only added when invoked // on the client. Moving forward, the `isPlatformBrowser` check should @@ -198,6 +207,26 @@ export function withDomHydration(): EnvironmentProviders { ]); } +/** + * Returns a set of providers required to setup support for i18n hydration. + * Requires hydration to be enabled separately. + */ +export function withI18nHydration(): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: IS_I18N_HYDRATION_ENABLED, + useValue: true, + }, + ]); +} + +/** + * Returns whether i18n hydration support is enabled. + */ +export function isI18nHydrationSupportEnabled(): boolean { + return _isI18nHydrationSupportEnabled; +} + /** * * @param time The time in ms until the stable timedout warning message is logged diff --git a/packages/core/src/hydration/tokens.ts b/packages/core/src/hydration/tokens.ts index f732d26b4e3..97bf75df213 100644 --- a/packages/core/src/hydration/tokens.ts +++ b/packages/core/src/hydration/tokens.ts @@ -28,3 +28,10 @@ export const PRESERVE_HOST_CONTENT = new InjectionToken( providedIn: 'root', factory: () => PRESERVE_HOST_CONTENT_DEFAULT, }); + +/** + * Internal token that indicates whether hydration support for i18n + * is enabled. + */ +export const IS_I18N_HYDRATION_ENABLED = new InjectionToken( + (typeof ngDevMode === 'undefined' || !!ngDevMode ? 'IS_I18N_HYDRATION_ENABLED' : '')); diff --git a/packages/core/src/render3/i18n/i18n_apply.ts b/packages/core/src/render3/i18n/i18n_apply.ts index 4aacc33904a..70d6d1f2d7e 100644 --- a/packages/core/src/render3/i18n/i18n_apply.ts +++ b/packages/core/src/render3/i18n/i18n_apply.ts @@ -18,7 +18,7 @@ import {RElement, RNode, RText} from '../interfaces/renderer_dom'; import {SanitizerFn} from '../interfaces/sanitization'; import {HEADER_OFFSET, LView, RENDERER, TView} from '../interfaces/view'; import {createCommentNode, createElementNode, createTextNode, nativeInsertBefore, nativeParentNode, nativeRemoveNode, updateTextNode} from '../node_manipulation'; -import {getBindingIndex, lastNodeWasCreated} from '../state'; +import {getBindingIndex, lastNodeWasCreated, wasLastNodeCreated} from '../state'; import {renderStringify} from '../util/stringify_utils'; import {getNativeByIndex, unwrapRNode} from '../util/view_utils'; @@ -78,12 +78,9 @@ export function applyI18n(tView: TView, lView: LView, index: number) { changeMaskCounter = 0; } -function locateOrCreateNode( - lView: LView, index: number, textOrName: string, +function createNodeWithoutHydration( + lView: LView, textOrName: string, nodeType: typeof Node.COMMENT_NODE|typeof Node.TEXT_NODE|typeof Node.ELEMENT_NODE) { - // TODO: Add support for hydration - lastNodeWasCreated(true); - const renderer = lView[RENDERER]; switch (nodeType) { @@ -98,6 +95,23 @@ function locateOrCreateNode( } } +let _locateOrCreateNode: typeof locateOrCreateNodeImpl = (lView, index, textOrName, nodeType) => { + lastNodeWasCreated(true); + return createNodeWithoutHydration(lView, textOrName, nodeType); +}; + +function locateOrCreateNodeImpl( + lView: LView, index: number, textOrName: string, + nodeType: typeof Node.COMMENT_NODE|typeof Node.TEXT_NODE|typeof Node.ELEMENT_NODE) { + // TODO: Add support for hydration + lastNodeWasCreated(true); + return createNodeWithoutHydration(lView, textOrName, nodeType); +} + +export function enableLocateOrCreateI18nNodeImpl() { + _locateOrCreateNode = locateOrCreateNodeImpl; +} + /** * Apply `I18nCreateOpCodes` op-codes as stored in `TI18n.create`. * @@ -121,13 +135,15 @@ export function applyCreateOpCodes( (opCode & I18nCreateOpCode.APPEND_EAGERLY) === I18nCreateOpCode.APPEND_EAGERLY; const index = opCode >>> I18nCreateOpCode.SHIFT; let rNode = lView[index]; + let lastNodeWasCreated = false; if (rNode === null) { // We only create new DOM nodes if they don't already exist: If ICU switches case back to a // case which was already instantiated, no need to create new DOM nodes. rNode = lView[index] = - locateOrCreateNode(lView, index, text, isComment ? Node.COMMENT_NODE : Node.TEXT_NODE); + _locateOrCreateNode(lView, index, text, isComment ? Node.COMMENT_NODE : Node.TEXT_NODE); + lastNodeWasCreated = wasLastNodeCreated(); } - if (appendNow && parentRNode !== null) { + if (appendNow && parentRNode !== null && lastNodeWasCreated) { nativeInsertBefore(renderer, parentRNode, rNode, insertInFrontOf, false); } } @@ -160,7 +176,7 @@ export function applyMutableOpCodes( if (lView[textNodeIndex] === null) { ngDevMode && ngDevMode.rendererCreateTextNode++; ngDevMode && assertIndexInRange(lView, textNodeIndex); - lView[textNodeIndex] = locateOrCreateNode(lView, textNodeIndex, opCode, Node.TEXT_NODE); + lView[textNodeIndex] = _locateOrCreateNode(lView, textNodeIndex, opCode, Node.TEXT_NODE); } } else if (typeof opCode == 'number') { switch (opCode & IcuCreateOpCode.MASK_INSTRUCTION) { @@ -238,7 +254,7 @@ export function applyMutableOpCodes( ngDevMode && ngDevMode.rendererCreateComment++; ngDevMode && assertIndexInExpandoRange(lView, commentNodeIndex); const commentRNode = lView[commentNodeIndex] = - locateOrCreateNode(lView, commentNodeIndex, commentValue, Node.COMMENT_NODE); + _locateOrCreateNode(lView, commentNodeIndex, commentValue, Node.COMMENT_NODE); // FIXME(misko): Attaching patch data is only needed for the root (Also add tests) attachPatchData(commentRNode, lView); } @@ -255,7 +271,7 @@ export function applyMutableOpCodes( ngDevMode && ngDevMode.rendererCreateElement++; ngDevMode && assertIndexInExpandoRange(lView, elementNodeIndex); const elementRNode = lView[elementNodeIndex] = - locateOrCreateNode(lView, elementNodeIndex, tagName, Node.ELEMENT_NODE); + _locateOrCreateNode(lView, elementNodeIndex, tagName, Node.ELEMENT_NODE); // FIXME(misko): Attaching patch data is only needed for the root (Also add tests) attachPatchData(elementRNode, lView); } diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 89d39a7db6e..dfcdb0e5dfc 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -215,6 +215,9 @@ { "name": "IS_HYDRATION_DOM_REUSE_ENABLED" }, + { + "name": "IS_I18N_HYDRATION_ENABLED" + }, { "name": "InjectFlags" }, diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index df2a700d839..7e2450e47b6 100644 --- a/packages/platform-server/test/hydration_spec.ts +++ b/packages/platform-server/test/hydration_spec.ts @@ -10,7 +10,7 @@ import '@angular/localize/init'; import {CommonModule, DOCUMENT, isPlatformServer, NgComponentOutlet, NgFor, NgIf, NgTemplateOutlet, PlatformLocation} from '@angular/common'; import {MockPlatformLocation} from '@angular/common/testing'; -import {afterRender, ApplicationRef, Component, ComponentRef, ContentChildren, createComponent, destroyPlatform, Directive, ElementRef, EnvironmentInjector, ErrorHandler, getPlatform, inject, Injectable, Input, NgZone, PLATFORM_ID, Provider, QueryList, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation, ɵsetDocument, ɵwhenStable as whenStable} from '@angular/core'; +import {afterRender, ApplicationRef, Component, ComponentRef, ContentChildren, createComponent, destroyPlatform, Directive, ElementRef, EnvironmentInjector, ErrorHandler, getPlatform, inject, Injectable, Input, NgZone, PLATFORM_ID, Provider, QueryList, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation, ɵsetDocument, ɵwhenStable as whenStable, ɵwithI18nHydration as withI18nHydration} from '@angular/core'; import {Console} from '@angular/core/src/console'; import {HydrationStatus, readHydrationInfo, SSR_CONTENT_INTEGRITY_MARKER} from '@angular/core/src/hydration/utils'; import {getComponentDef} from '@angular/core/src/render3/definition'; @@ -1996,254 +1996,333 @@ describe('platform-server hydration integration', () => { }); }); - // Note: hydration for i18n blocks is not *yet* supported, so the tests - // below verify that components that use i18n are excluded from the hydration - // by adding the `ngSkipHydration` flag onto the component host element. describe('i18n', () => { - it('should append skip hydration flag if component uses i18n blocks', async () => { - @Component({ - standalone: true, - selector: 'app', - template: ` + describe('support is enabled', () => { + it('should not append skip hydration flag if component uses i18n blocks', async () => { + @Component({ + standalone: true, + selector: 'app', + template: `
Hi!
`, - }) - class SimpleComponent { - } + }) + class SimpleComponent { + } - const html = await ssr(SimpleComponent); - const ssrContents = getAppContents(html); - expect(ssrContents).toContain(''); + const providers = [withI18nHydration()] as unknown as Provider[]; + const html = await ssr(SimpleComponent, undefined, providers); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain('(appRef); - appRef.tick(); - - const clientRootNode = compRef.location.nativeElement; - verifyNoNodesWereClaimedForHydration(clientRootNode); - verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); - }); - - it('should keep the skip hydration flag if component uses i18n blocks', async () => { - @Component({ - standalone: true, - selector: 'app', - host: {ngSkipHydration: 'true'}, - template: ` -
Hi!
- `, - }) - class SimpleComponent { - } - - const html = await ssr(SimpleComponent); - const ssrContents = getAppContents(html); - expect(ssrContents).toContain(''); - - resetTViewsFor(SimpleComponent); - - const appRef = await hydrate(html, SimpleComponent); - const compRef = getComponentRef(appRef); - appRef.tick(); - - const clientRootNode = compRef.location.nativeElement; - verifyNoNodesWereClaimedForHydration(clientRootNode); - verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); - }); - - it('should append skip hydration flag if component uses i18n blocks inside embedded views', - async () => { - @Component({ - standalone: true, - imports: [NgIf], - selector: 'app', - template: ` + it('should not append skip hydration flag if component uses i18n blocks inside embedded views', + async () => { + @Component({ + standalone: true, + imports: [NgIf], + selector: 'app', + template: `
Hi!
`, - }) - class SimpleComponent { - } + }) + class SimpleComponent { + } - const html = await ssr(SimpleComponent); - const ssrContents = getAppContents(html); - expect(ssrContents).toContain(''); + const providers = [withI18nHydration()] as unknown as Provider[]; + const html = await ssr(SimpleComponent, undefined, providers); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain('(appRef); - appRef.tick(); - - const clientRootNode = compRef.location.nativeElement; - verifyNoNodesWereClaimedForHydration(clientRootNode); - verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); - }); - - it('should append skip hydration flag if component uses i18n blocks on s', - async () => { - @Component({ - standalone: true, - selector: 'app', - template: ` + it('should not append skip hydration flag if component uses i18n blocks on s', + async () => { + @Component({ + standalone: true, + selector: 'app', + template: ` Hi! `, - }) - class SimpleComponent { - } + }) + class SimpleComponent { + } - const html = await ssr(SimpleComponent); - const ssrContents = getAppContents(html); - expect(ssrContents).toContain(''); + const providers = [withI18nHydration()] as unknown as Provider[]; + const html = await ssr(SimpleComponent, undefined, providers); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain('(appRef); - appRef.tick(); - - const clientRootNode = compRef.location.nativeElement; - verifyNoNodesWereClaimedForHydration(clientRootNode); - verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); - }); - - it('should append skip hydration flag if component uses i18n blocks (with *ngIfs on s)', - async () => { - @Component({ - standalone: true, - imports: [CommonModule], - selector: 'app', - template: ` + it('should not append skip hydration flag if component uses i18n blocks (with *ngIfs on s)', + async () => { + @Component({ + standalone: true, + imports: [CommonModule], + selector: 'app', + template: ` Hi! `, - }) - class SimpleComponent { - } + }) + class SimpleComponent { + } - const html = await ssr(SimpleComponent); - const ssrContents = getAppContents(html); - expect(ssrContents).toContain(''); - - resetTViewsFor(SimpleComponent); - - const appRef = await hydrate(html, SimpleComponent); - const compRef = getComponentRef(appRef); - appRef.tick(); - - const clientRootNode = compRef.location.nativeElement; - verifyNoNodesWereClaimedForHydration(clientRootNode); - verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); - }); - - it('should *not* throw when i18n attributes are used', async () => { - @Component({ - standalone: true, - selector: 'app', - template: ` -
Hi!
- `, - }) - class SimpleComponent { - } - - const html = await ssr(SimpleComponent); - const ssrContents = getAppContents(html); - - expect(ssrContents).toContain('(appRef); - appRef.tick(); - - const clientRootNode = compRef.location.nativeElement; - verifyAllNodesClaimedForHydration(clientRootNode); - verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + const providers = [withI18nHydration()] as unknown as Provider[]; + const html = await ssr(SimpleComponent, undefined, providers); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain(' { - @Component({ - standalone: true, - selector: 'nested', - template: ` + // Note: hydration for i18n blocks is not *yet* fully supported, so the tests + // below verify that components that use i18n are excluded from the hydration + // by adding the `ngSkipHydration` flag onto the component host element. + describe('support is disabled', () => { + it('should append skip hydration flag if component uses i18n blocks', async () => { + @Component({ + standalone: true, + selector: 'app', + template: ` +
Hi!
+ `, + }) + class SimpleComponent { + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain(''); + + resetTViewsFor(SimpleComponent); + + const appRef = await hydrate(html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyNoNodesWereClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should keep the skip hydration flag if component uses i18n blocks', async () => { + @Component({ + standalone: true, + selector: 'app', + host: {ngSkipHydration: 'true'}, + template: ` +
Hi!
+ `, + }) + class SimpleComponent { + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain(''); + + resetTViewsFor(SimpleComponent); + + const appRef = await hydrate(html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyNoNodesWereClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should append skip hydration flag if component uses i18n blocks inside embedded views', + async () => { + @Component({ + standalone: true, + imports: [NgIf], + selector: 'app', + template: ` +
+
Hi!
+
+ `, + }) + class SimpleComponent { + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain(''); + + resetTViewsFor(SimpleComponent); + + const appRef = await hydrate(html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyNoNodesWereClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should append skip hydration flag if component uses i18n blocks on s', + async () => { + @Component({ + standalone: true, + selector: 'app', + template: ` + Hi! + `, + }) + class SimpleComponent { + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain(''); + + resetTViewsFor(SimpleComponent); + + const appRef = await hydrate(html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyNoNodesWereClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should append skip hydration flag if component uses i18n blocks (with *ngIfs on s)', + async () => { + @Component({ + standalone: true, + imports: [CommonModule], + selector: 'app', + template: ` + Hi! + `, + }) + class SimpleComponent { + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain(''); + + resetTViewsFor(SimpleComponent); + + const appRef = await hydrate(html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyNoNodesWereClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should *not* throw when i18n attributes are used', async () => { + @Component({ + standalone: true, + selector: 'app', + template: ` +
Hi!
+ `, + }) + class SimpleComponent { + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should *not* throw when i18n is used in nested component ' + + 'excluded using `ngSkipHydration`', + async () => { + @Component({ + standalone: true, + selector: 'nested', + template: `
Hi!
`, - }) - class NestedComponent { - } + }) + class NestedComponent { + } - @Component({ - standalone: true, - imports: [NestedComponent], - selector: 'app', - template: ` + @Component({ + standalone: true, + imports: [NestedComponent], + selector: 'app', + template: ` Nested component with i18n inside: `, - }) - class SimpleComponent { - } + }) + class SimpleComponent { + } - const html = await ssr(SimpleComponent); - const ssrContents = getAppContents(html); + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); - expect(ssrContents).toContain('(appRef); - appRef.tick(); + const appRef = await hydrate(html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); - const clientRootNode = compRef.location.nativeElement; - verifyAllNodesClaimedForHydration(clientRootNode); - verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); - }); + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); - it('should exclude components with i18n from hydration automatically', async () => { - @Component({ - standalone: true, - selector: 'nested', - template: ` + it('should exclude components with i18n from hydration automatically', async () => { + @Component({ + standalone: true, + selector: 'nested', + template: `
Hi!
`, - }) - class NestedComponent { - } + }) + class NestedComponent { + } - @Component({ - standalone: true, - imports: [NestedComponent], - selector: 'app', - template: ` + @Component({ + standalone: true, + imports: [NestedComponent], + selector: 'app', + template: ` Nested component with i18n inside (the content of this component would be excluded from hydration): `, - }) - class SimpleComponent { - } + }) + class SimpleComponent { + } - const html = await ssr(SimpleComponent); - const ssrContents = getAppContents(html); + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); - expect(ssrContents).toContain('(appRef); - appRef.tick(); + const appRef = await hydrate(html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); - const clientRootNode = compRef.location.nativeElement; - verifyAllNodesClaimedForHydration(clientRootNode); - verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); }); });