From b456f4ab7175ff717b2b1ff12daf03c4d146f3b7 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Thu, 30 May 2024 17:45:27 +0000 Subject: [PATCH] fix(core): improve support for i18n hydration of projected content When collecting nodes from the DOM for hydration, we need to treat nodes with paths (e.g. content projection) as the new root for all subsequent elements, not just the next one. Additionally, when using content projection it's possible for translated content to become disconnected, e.g. when it doesn't match a selector and there isn't a default. We need to handle such cases by manipulating the disconnected node data associated with hydration as usual. --- packages/core/src/hydration/annotate.ts | 24 +- packages/core/src/hydration/i18n.ts | 169 +++++++++++-- .../core/src/hydration/node_lookup_utils.ts | 19 +- .../i18n_icu_container_visitor.ts | 116 +++++---- .../platform-server/test/hydration_spec.ts | 222 ++++++++++++++++++ 5 files changed, 471 insertions(+), 79 deletions(-) diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index bbc851e6621759..c99454ddf16e03 100644 --- a/packages/core/src/hydration/annotate.ts +++ b/packages/core/src/hydration/annotate.ts @@ -11,6 +11,7 @@ import {APP_ID} from '../application/application_tokens'; import {isDetachedByI18n} from '../i18n/utils'; import {ViewEncapsulation} from '../metadata'; import {Renderer2} from '../render'; +import {assertTNode} from '../render3/assert'; import {collectNativeNodes, collectNativeNodesInLContainer} from '../render3/collect_native_nodes'; import {getComponentDef} from '../render3/definition'; import {CONTAINER_HEADER_OFFSET, LContainer} from '../render3/interfaces/container'; @@ -362,7 +363,8 @@ function appendSerializedNodePath( ) { const noOffsetIndex = tNode.index - HEADER_OFFSET; ngh[NODES] ??= {}; - ngh[NODES][noOffsetIndex] = calcPathForNode(tNode, lView, excludedParentNodes); + // Ensure we don't calculate the path multiple times. + ngh[NODES][noOffsetIndex] ??= calcPathForNode(tNode, lView, excludedParentNodes); } /** @@ -370,8 +372,11 @@ function appendSerializedNodePath( * This info is needed at runtime to avoid DOM lookups for this element * and instead, the element would be created from scratch. */ -function appendDisconnectedNodeIndex(ngh: SerializedView, tNode: TNode) { - const noOffsetIndex = tNode.index - HEADER_OFFSET; +function appendDisconnectedNodeIndex(ngh: SerializedView, tNodeOrNoOffsetIndex: TNode | number) { + const noOffsetIndex = + typeof tNodeOrNoOffsetIndex === 'number' + ? tNodeOrNoOffsetIndex + : tNodeOrNoOffsetIndex.index - HEADER_OFFSET; ngh[DISCONNECTED_NODES] ??= []; if (!ngh[DISCONNECTED_NODES].includes(noOffsetIndex)) { ngh[DISCONNECTED_NODES].push(noOffsetIndex); @@ -404,7 +409,18 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView const i18nData = trySerializeI18nBlock(lView, i, context); if (i18nData) { ngh[I18N_DATA] ??= {}; - ngh[I18N_DATA][noOffsetIndex] = i18nData; + ngh[I18N_DATA][noOffsetIndex] = i18nData.caseQueue; + + for (const nodeNoOffsetIndex of i18nData.disconnectedNodes) { + appendDisconnectedNodeIndex(ngh, nodeNoOffsetIndex); + } + + for (const nodeNoOffsetIndex of i18nData.disjointNodes) { + const tNode = tView.data[nodeNoOffsetIndex + HEADER_OFFSET] as TNode; + ngDevMode && assertTNode(tNode); + appendSerializedNodePath(ngh, tNode, lView, i18nChildren); + } + continue; } diff --git a/packages/core/src/hydration/i18n.ts b/packages/core/src/hydration/i18n.ts index 4519007c023b23..0896b7739354d8 100644 --- a/packages/core/src/hydration/i18n.ts +++ b/packages/core/src/hydration/i18n.ts @@ -8,18 +8,19 @@ import {inject, Injector} from '../di'; import {isRootTemplateMessage} from '../render3/i18n/i18n_util'; -import {I18nNode, I18nNodeKind, I18nPlaceholderType, TI18n} from '../render3/interfaces/i18n'; -import {TNode, TNodeType} from '../render3/interfaces/node'; +import {createIcuIterator} from '../render3/instructions/i18n_icu_container_visitor'; +import {I18nNode, I18nNodeKind, I18nPlaceholderType, TI18n, TIcu} from '../render3/interfaces/i18n'; +import {isTNodeShape, TNode, TNodeType} from '../render3/interfaces/node'; import type {Renderer} from '../render3/interfaces/renderer'; import type {RNode} from '../render3/interfaces/renderer_dom'; import {HEADER_OFFSET, HYDRATION, LView, RENDERER, TView, TVIEW} from '../render3/interfaces/view'; -import {nativeRemoveNode} from '../render3/node_manipulation'; +import {getFirstNativeNode, nativeRemoveNode} from '../render3/node_manipulation'; import {unwrapRNode} from '../render3/util/view_utils'; import {assertDefined, assertNotEqual} from '../util/assert'; import type {HydrationContext} from './annotate'; import {DehydratedIcuData, DehydratedView, I18N_DATA} from './interfaces'; -import {locateNextRNode, tryLocateRNodeByPath} from './node_lookup_utils'; +import {isDisconnectedRNode, locateNextRNode, tryLocateRNodeByPath} from './node_lookup_utils'; import {IS_I18N_HYDRATION_ENABLED} from './tokens'; import { getNgContainerSize, @@ -130,6 +131,38 @@ function collectI18nChildren(tView: TView): Set | null { return children.size === 0 ? null : children; } +/** + * Resulting data from serializing an i18n block. + */ +export interface SerializedI18nBlock { + /** + * A queue of active ICU cases from a depth-first traversal + * of the i18n AST. This is serialized to the client in order + * to correctly associate DOM nodes with i18n nodes during + * hydration. + */ + caseQueue: Array; + + /** + * A set of indices in the lView of the block for nodes + * that are disconnected from the DOM. In i18n, this can + * happen when using content projection but some nodes are + * not selected by an . + */ + disconnectedNodes: Set; + + /** + * A set of indices in the lView of the block for nodes + * considered "disjoint", indicating that we need to serialize + * a path to the node in order to hydrate it. + * + * A node is considered disjoint when its RNode does not + * directly follow the RNode of the previous i18n node, for + * example, because of content projection. + */ + disjointNodes: Set; +} + /** * Attempts to serialize i18n data for an i18n block, located at * the given view and instruction index. @@ -143,7 +176,7 @@ export function trySerializeI18nBlock( lView: LView, index: number, context: HydrationContext, -): Array | null { +): SerializedI18nBlock | null { if (!context.isI18nHydrationEnabled) { return null; } @@ -154,38 +187,123 @@ export function trySerializeI18nBlock( return null; } - const caseQueue: number[] = []; - tI18n.ast.forEach((node) => serializeI18nBlock(lView, caseQueue, context, node)); - return caseQueue.length > 0 ? caseQueue : null; + const serializedI18nBlock: SerializedI18nBlock = { + caseQueue: [], + disconnectedNodes: new Set(), + disjointNodes: new Set(), + }; + serializeI18nBlock(lView, serializedI18nBlock, context, tI18n.ast); + + return serializedI18nBlock.caseQueue.length === 0 && + serializedI18nBlock.disconnectedNodes.size === 0 && + serializedI18nBlock.disjointNodes.size === 0 + ? null + : serializedI18nBlock; } function serializeI18nBlock( lView: LView, - caseQueue: number[], + serializedI18nBlock: SerializedI18nBlock, + context: HydrationContext, + nodes: I18nNode[], +): Node | null { + let prevRNode = null; + for (const node of nodes) { + const nextRNode = serializeI18nNode(lView, serializedI18nBlock, context, node); + if (nextRNode) { + if (isDisjointNode(prevRNode, nextRNode)) { + serializedI18nBlock.disjointNodes.add(node.index - HEADER_OFFSET); + } + prevRNode = nextRNode; + } + } + return prevRNode; +} + +/** + * Helper to determine whether the given nodes are "disjoint". + * + * The i18n hydration process walks through the DOM and i18n nodes + * at the same time. It expects the sibling DOM node of the previous + * i18n node to be the first node of the next i18n node. + * + * In cases of content projection, this won't always be the case. So + * when we detect that, we mark the node as "disjoint", ensuring that + * we will serialize the path to the node. This way, when we hydrate the + * i18n node, we will be able to find the correct place to start. + */ +function isDisjointNode(prevNode: Node | null, nextNode: Node) { + return prevNode && prevNode.nextSibling !== nextNode; +} + +/** + * Process the given i18n node for serialization. + * Returns the first RNode for the i18n node to begin hydration. + */ +function serializeI18nNode( + lView: LView, + serializedI18nBlock: SerializedI18nBlock, context: HydrationContext, node: I18nNode, -) { +): Node | null { + const maybeRNode = unwrapRNode(lView[node.index]!); + if (!maybeRNode || isDisconnectedRNode(maybeRNode)) { + serializedI18nBlock.disconnectedNodes.add(node.index - HEADER_OFFSET); + return null; + } + + const rNode = maybeRNode as Node; switch (node.kind) { - case I18nNodeKind.TEXT: - const rNode = unwrapRNode(lView[node.index]!); + case I18nNodeKind.TEXT: { processTextNodeBeforeSerialization(context, rNode); break; + } case I18nNodeKind.ELEMENT: - case I18nNodeKind.PLACEHOLDER: - node.children.forEach((node) => serializeI18nBlock(lView, caseQueue, context, node)); + case I18nNodeKind.PLACEHOLDER: { + serializeI18nBlock(lView, serializedI18nBlock, context, node.children); break; + } - case I18nNodeKind.ICU: + case I18nNodeKind.ICU: { const currentCase = lView[node.currentCaseLViewIndex] as number | null; if (currentCase != null) { // i18n uses a negative value to signal a change to a new case, so we // need to invert it to get the proper value. const caseIdx = currentCase < 0 ? ~currentCase : currentCase; - caseQueue.push(caseIdx); - node.cases[caseIdx].forEach((node) => serializeI18nBlock(lView, caseQueue, context, node)); + serializedI18nBlock.caseQueue.push(caseIdx); + serializeI18nBlock(lView, serializedI18nBlock, context, node.cases[caseIdx]); } break; + } + } + + return getFirstNativeNodeForI18nNode(lView, node) as Node | null; +} + +/** + * Helper function to get the first native node to begin hydrating + * the given i18n node. + */ +function getFirstNativeNodeForI18nNode(lView: LView, node: I18nNode) { + const tView = lView[TVIEW]; + const maybeTNode = tView.data[node.index]; + + if (node.kind === I18nNodeKind.ICU) { + // A nested ICU container won't have an actual TNode. In that case, we can use + // an iterator to find the first child. + const icuIterator = createIcuIterator(maybeTNode as TIcu, lView); + let rNode: RNode | null = icuIterator(); + + // If the ICU container has no nodes, then we use the ICU anchor as the node. + return rNode ?? unwrapRNode(lView[node.index]); + } else if (isTNodeShape(maybeTNode)) { + // If the node is backed by an actual TNode, we can simply delegate. + return getFirstNativeNode(lView, maybeTNode); + } else { + // Otherwise, the node is a text or trivial element in an ICU container, + // and we can just use the RNode directly. + return unwrapRNode(lView[node.index]) ?? null; } } @@ -346,19 +464,28 @@ function collectI18nNodesFromDom( nodeOrNodes: I18nNode | I18nNode[], ) { if (Array.isArray(nodeOrNodes)) { + let nextState = state; for (const node of nodeOrNodes) { - // If the node is being projected elsewhere, we need to temporarily - // branch the state to that location to continue hydration. - // Otherwise, we continue hydration from the current location. + // Whenever a node doesn't directly follow the previous RNode, it + // is given a path. We need to resume collecting nodes from that location + // until and unless we find another disjoint node. const targetNode = tryLocateRNodeByPath( context.hydrationInfo, context.lView, node.index - HEADER_OFFSET, ); - const nextState = targetNode ? forkHydrationState(state, targetNode as Node) : state; + if (targetNode) { + nextState = forkHydrationState(state, targetNode as Node); + } collectI18nNodesFromDom(context, nextState, node); } } else { + if (context.disconnectedNodes.has(nodeOrNodes.index - HEADER_OFFSET)) { + // i18n nodes can be considered disconnected if e.g. they were projected. + // In that case, we have to make sure to skip over them. + return; + } + switch (nodeOrNodes.kind) { case I18nNodeKind.TEXT: { // Claim a text node for hydration diff --git a/packages/core/src/hydration/node_lookup_utils.ts b/packages/core/src/hydration/node_lookup_utils.ts index 12254e76486853..b007772c79eb95 100644 --- a/packages/core/src/hydration/node_lookup_utils.ts +++ b/packages/core/src/hydration/node_lookup_utils.ts @@ -48,19 +48,26 @@ function getNoOffsetIndex(tNode: TNode): number { /** * Check whether a given node exists, but is disconnected from the DOM. - * - * Note: we leverage the fact that we have this information available in the DOM emulation - * layer (in Domino) for now. Longer-term solution should not rely on the DOM emulation and - * only use internal data structures and state to compute this information. */ export function isDisconnectedNode(tNode: TNode, lView: LView) { return ( !(tNode.type & TNodeType.Projection) && !!lView[tNode.index] && - !(unwrapRNode(lView[tNode.index]) as Node)?.isConnected + isDisconnectedRNode(unwrapRNode(lView[tNode.index])) ); } +/** + * Check whether the given node exists, but is disconnected from the DOM. + * + * Note: we leverage the fact that we have this information available in the DOM emulation + * layer (in Domino) for now. Longer-term solution should not rely on the DOM emulation and + * only use internal data structures and state to compute this information. + */ +export function isDisconnectedRNode(rNode: RNode | null) { + return !!rNode && !(rNode as Node).isConnected; +} + /** * Locate a node in an i18n tree that corresponds to a given instruction index. * @@ -352,7 +359,7 @@ export function calcPathForNode( referenceNodeName = renderStringify(parentIndex - HEADER_OFFSET); } let rNode = unwrapRNode(lView[tNode.index]); - if (tNode.type & TNodeType.AnyContainer) { + if (tNode.type & (TNodeType.AnyContainer | TNodeType.Icu)) { // For nodes, instead of serializing a reference // to the anchor comment node, serialize a location of the first // DOM element. Paired with the container size (serialized as a part diff --git a/packages/core/src/render3/instructions/i18n_icu_container_visitor.ts b/packages/core/src/render3/instructions/i18n_icu_container_visitor.ts index 2ac99ecda2aab2..a724dbd37e159a 100644 --- a/packages/core/src/render3/instructions/i18n_icu_container_visitor.ts +++ b/packages/core/src/render3/instructions/i18n_icu_container_visitor.ts @@ -15,11 +15,59 @@ import {TIcuContainerNode} from '../interfaces/node'; import {RNode} from '../interfaces/renderer_dom'; import {LView, TVIEW} from '../interfaces/view'; +interface IcuIteratorState { + stack: any[]; + index: number; + lView?: LView; + removes?: I18nRemoveOpCodes; +} + +type IcuIterator = () => RNode | null; + +function enterIcu(state: IcuIteratorState, tIcu: TIcu, lView: LView) { + state.index = 0; + const currentCase = getCurrentICUCaseIndex(tIcu, lView); + if (currentCase !== null) { + ngDevMode && assertNumberInRange(currentCase, 0, tIcu.cases.length - 1); + state.removes = tIcu.remove[currentCase]; + } else { + state.removes = EMPTY_ARRAY as any; + } +} + +function icuContainerIteratorNext(state: IcuIteratorState): RNode | null { + if (state.index < state.removes!.length) { + const removeOpCode = state.removes![state.index++] as number; + ngDevMode && assertNumber(removeOpCode, 'Expecting OpCode number'); + if (removeOpCode > 0) { + const rNode = state.lView![removeOpCode]; + ngDevMode && assertDomNode(rNode); + return rNode; + } else { + state.stack.push(state.index, state.removes); + // ICUs are represented by negative indices + const tIcuIndex = ~removeOpCode; + const tIcu = state.lView![TVIEW].data[tIcuIndex] as TIcu; + ngDevMode && assertTIcu(tIcu); + enterIcu(state, tIcu, state.lView!); + return icuContainerIteratorNext(state); + } + } else { + if (state.stack.length === 0) { + return null; + } else { + state.removes = state.stack.pop(); + state.index = state.stack.pop(); + return icuContainerIteratorNext(state); + } + } +} + export function loadIcuContainerVisitor() { - const _stack: any[] = []; - let _index: number = -1; - let _lView: LView; - let _removes: I18nRemoveOpCodes; + const _state: IcuIteratorState = { + stack: [], + index: -1, + }; /** * Retrieves a set of root nodes from `TIcu.remove`. Used by `TNodeType.ICUContainer` @@ -40,52 +88,24 @@ export function loadIcuContainerVisitor() { function icuContainerIteratorStart( tIcuContainerNode: TIcuContainerNode, lView: LView, - ): () => RNode | null { - _lView = lView; - while (_stack.length) _stack.pop(); + ): IcuIterator { + _state.lView = lView; + while (_state.stack.length) _state.stack.pop(); ngDevMode && assertTNodeForLView(tIcuContainerNode, lView); - enterIcu(tIcuContainerNode.value, lView); - return icuContainerIteratorNext; - } - - function enterIcu(tIcu: TIcu, lView: LView) { - _index = 0; - const currentCase = getCurrentICUCaseIndex(tIcu, lView); - if (currentCase !== null) { - ngDevMode && assertNumberInRange(currentCase, 0, tIcu.cases.length - 1); - _removes = tIcu.remove[currentCase]; - } else { - _removes = EMPTY_ARRAY as any; - } - } - - function icuContainerIteratorNext(): RNode | null { - if (_index < _removes.length) { - const removeOpCode = _removes[_index++] as number; - ngDevMode && assertNumber(removeOpCode, 'Expecting OpCode number'); - if (removeOpCode > 0) { - const rNode = _lView[removeOpCode]; - ngDevMode && assertDomNode(rNode); - return rNode; - } else { - _stack.push(_index, _removes); - // ICUs are represented by negative indices - const tIcuIndex = ~removeOpCode; - const tIcu = _lView[TVIEW].data[tIcuIndex] as TIcu; - ngDevMode && assertTIcu(tIcu); - enterIcu(tIcu, _lView); - return icuContainerIteratorNext(); - } - } else { - if (_stack.length === 0) { - return null; - } else { - _removes = _stack.pop(); - _index = _stack.pop(); - return icuContainerIteratorNext(); - } - } + enterIcu(_state, tIcuContainerNode.value, lView); + return icuContainerIteratorNext.bind(null, _state); } return icuContainerIteratorStart; } + +export function createIcuIterator(tIcu: TIcu, lView: LView): IcuIterator { + const state: IcuIteratorState = { + stack: [], + index: -1, + lView, + }; + ngDevMode && assertTIcu(tIcu); + enterIcu(state, tIcu, lView); + return icuContainerIteratorNext.bind(null, state); +} diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index 2582be4b2008de..ce27e0ad69fbbf 100644 --- a/packages/platform-server/test/hydration_spec.ts +++ b/packages/platform-server/test/hydration_spec.ts @@ -2122,6 +2122,146 @@ describe('platform-server hydration integration', () => { expect(content.innerHTML).toBe('two
one
'); }); + it('should support interleaving projected content', async () => { + @Component({ + standalone: true, + selector: 'app-content', + template: `Start Middle End`, + }) + class ContentComponent {} + + @Component({ + standalone: true, + selector: 'app', + template: ` + + Span + Middle Start + Middle End +
Div
+
+ `, + imports: [ContentComponent], + }) + class SimpleComponent {} + + const hydrationFeatures = [withI18nSupport()] as unknown as HydrationFeature[]; + const html = await ssr(SimpleComponent, {hydrationFeatures}); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + + const content = clientRootNode.querySelector('app-content'); + expect(content.innerHTML).toBe('Start
Div
Middle Span End'); + }); + + it('should support disjoint nodes', async () => { + @Component({ + standalone: true, + selector: 'app-content', + template: `Start Middle End`, + }) + class ContentComponent {} + + @Component({ + standalone: true, + selector: 'app', + template: ` + + Inner Start + Span + { count, plural, other { Hello World! }} + Inner End + + `, + imports: [ContentComponent], + }) + class SimpleComponent { + count = 0; + } + + const hydrationFeatures = [withI18nSupport()] as unknown as HydrationFeature[]; + const html = await ssr(SimpleComponent, {hydrationFeatures}); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + + const content = clientRootNode.querySelector('app-content'); + expect(content.innerHTML).toBe( + 'Start Inner Start Hello World! Inner End Middle Span End', + ); + }); + + it('should support nested content projection', async () => { + @Component({ + standalone: true, + selector: 'app-content-inner', + template: `Start Middle End`, + }) + class InnerContentComponent {} + + @Component({ + standalone: true, + selector: 'app-content-outer', + template: ``, + imports: [InnerContentComponent], + }) + class OuterContentComponent {} + + @Component({ + standalone: true, + selector: 'app', + template: ` + + Outer Start + Span + { count, plural, other { Hello World! }} + Outer End + + `, + imports: [OuterContentComponent], + }) + class SimpleComponent {} + + const hydrationFeatures = [withI18nSupport()] as unknown as HydrationFeature[]; + const html = await ssr(SimpleComponent, {hydrationFeatures}); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + + const content = clientRootNode.querySelector('app-content-outer'); + expect(content.innerHTML).toBe( + 'Start Outer Start Span Hello World! Outer End Middle End', + ); + }); + it('should support hosting projected content', async () => { @Component({ standalone: true, @@ -2157,6 +2297,88 @@ describe('platform-server hydration integration', () => { expect(div.innerHTML).toBe('Start Middle End'); }); + it('should support projecting multiple elements', async () => { + @Component({ + standalone: true, + selector: 'app-content', + template: ``, + }) + class ContentComponent {} + + @Component({ + standalone: true, + selector: 'app', + template: ` + + Start + Middle + End + + `, + imports: [ContentComponent], + }) + class SimpleComponent {} + + const hydrationFeatures = [withI18nSupport()] as unknown as HydrationFeature[]; + const html = await ssr(SimpleComponent, {hydrationFeatures}); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + + const content = clientRootNode.querySelector('app-content'); + expect(content.innerHTML).toMatch(/ Start Middle<\/span> End /); + }); + + it('should support disconnecting i18n nodes during projection', async () => { + @Component({ + standalone: true, + selector: 'app-content', + template: `Start End`, + }) + class ContentComponent {} + + @Component({ + standalone: true, + selector: 'app', + template: ` + + Middle Start + Middle + Middle End + + `, + imports: [ContentComponent], + }) + class SimpleComponent {} + + const hydrationFeatures = [withI18nSupport()] as unknown as HydrationFeature[]; + const html = await ssr(SimpleComponent, {hydrationFeatures}); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + + const content = clientRootNode.querySelector('app-content'); + expect(content.innerHTML).toBe('Start Middle End'); + }); + it('should support using translated views as view container anchors', async () => { @Component({ standalone: true,