From 46b8f0b79a10183c6847b457eeeddbcdb336c937 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Sat, 23 Sep 2023 18:30:47 -0700 Subject: [PATCH] refactor(core): add hydration support for built-in `if` and `switch` This commit updates the `if` and `switch` logic to support hydration. On the server, we annotate a `template` instruction that is used to host and render selected branch. On the client, we lookup dehydrated views in case we detect that the flag is present. --- .../compiler/src/render3/view/template.ts | 76 ++++-- .../src/render3/instructions/control_flow.ts | 11 +- .../core/src/render3/instructions/template.ts | 26 +- .../platform-server/test/hydration_spec.ts | 252 +++++++++++++++++- 4 files changed, 337 insertions(+), 28 deletions(-) diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index f663235558c87..44b58a57b1a4e 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -59,6 +59,12 @@ const GLOBAL_TARGET_RESOLVERS = new Map( export const LEADING_TRIVIA_CHARS = [' ', '\n', '\r', '\t']; +// Special symbol that is used as a tag name in the `template` instruction in case +// an underlying LContainer is used by the runtime code to render different branches. +// This is needed to indicate that this particular LContainer needs to be hydrated +// (if an application uses SSR). +const BUILT_IN_CONTROL_FLOW_CONTAINER = '@'; + // if (rf & flags) { .. } export function renderFlagCheckIfStmt( flags: core.RenderFlags, statements: o.Statement[]): o.IfStmt { @@ -1144,29 +1150,42 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver // We have to process the block in two steps: once here and again in the update instruction // callback in order to generate the correct expressions when pipes or pure functions are // used inside the branch expressions. - const branchData = block.branches.map(({expression, expressionAlias, children, sourceSpan}) => { - let processedExpression: AST|null = null; + const branchData = + block.branches.map(({expression, expressionAlias, children, sourceSpan}, index) => { + let processedExpression: AST|null = null; - if (expression !== null) { - processedExpression = expression.visit(this._valueConverter); - this.allocateBindingSlots(processedExpression); - } + if (expression !== null) { + processedExpression = expression.visit(this._valueConverter); + this.allocateBindingSlots(processedExpression); + } - // If the branch has an alias, it'll be assigned directly to the container's context. - // We define a variable referring directly to the context so that any nested usages can be - // rewritten to refer to it. - const variables = expressionAlias !== null ? - [new t.Variable( - expressionAlias.name, DIRECT_CONTEXT_REFERENCE, expressionAlias.sourceSpan, - expressionAlias.keySpan)] : - undefined; - - return { - index: this.createEmbeddedTemplateFn(null, children, '_Conditional', sourceSpan, variables), - expression: processedExpression, - alias: expressionAlias - }; - }); + // If the branch has an alias, it'll be assigned directly to the container's context. + // We define a variable referring directly to the context so that any nested usages can be + // rewritten to refer to it. + const variables = expressionAlias !== null ? + [new t.Variable( + expressionAlias.name, DIRECT_CONTEXT_REFERENCE, expressionAlias.sourceSpan, + expressionAlias.keySpan)] : + undefined; + + // Since there is no creation mode instruction for {#if}, we need to annotate + // a template instruction, which creates an LContainer that is later used by + // the runtime code to render different branches. This is needed to indicate + // that this particular LContainer needs to be hydrated (if an application uses + // SSR). To avoid introducing an extra flag and consuming an extra argument, + // use a special symbol in the tag name. + // + // NOTE: this code will be removed once we split the `template` instruction logic + // into a `block` and `container` instructions and start generating them for control + // flow instead. + const tagName = index === 0 ? BUILT_IN_CONTROL_FLOW_CONTAINER : null; + return { + index: this.createEmbeddedTemplateFn( + tagName, children, '_Conditional', sourceSpan, variables), + expression: processedExpression, + alias: expressionAlias + }; + }); // Use the index of the first block as the index for the entire container. const containerIndex = branchData[0].index; @@ -1228,9 +1247,20 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver // We have to process the block in two steps: once here and again in the update instruction // callback in order to generate the correct expressions when pipes or pure functions are used. - const caseData = block.cases.map(currentCase => { + const caseData = block.cases.map((currentCase, caseIndex) => { + // Since there is no creation mode instruction for {#switch}, we need to annotate + // a template instruction, which creates an LContainer that is later used by + // the runtime code to render different branches. This is needed to indicate + // that this particular LContainer needs to be hydrated (if an application uses + // SSR). To avoid introducing an extra flag and consuming an extra argument, + // use a special symbol in the tag name. + // + // NOTE: this code will be removed once we split the `template` instruction logic + // into a `block` and `container` instructions and start generating them for control + // flow instead. + const tagName = caseIndex === 0 ? BUILT_IN_CONTROL_FLOW_CONTAINER : null; const index = this.createEmbeddedTemplateFn( - null, currentCase.children, '_Case', currentCase.sourceSpan); + tagName, currentCase.children, '_Case', currentCase.sourceSpan); let expression: AST|null = null; if (currentCase.expression !== null) { diff --git a/packages/core/src/render3/instructions/control_flow.ts b/packages/core/src/render3/instructions/control_flow.ts index 70ad8ee5c31f7..11876c46cfcc6 100644 --- a/packages/core/src/render3/instructions/control_flow.ts +++ b/packages/core/src/render3/instructions/control_flow.ts @@ -7,6 +7,7 @@ */ import {DefaultIterableDiffer, IterableChangeRecord, TrackByFunction} from '../../change_detection'; +import {findMatchingDehydratedView} from '../../hydration/views'; import {assertDefined} from '../../util/assert'; import {assertLContainer, assertLView, assertTNode} from '../assert'; import {bindingUpdated} from '../bindings'; @@ -17,7 +18,7 @@ import {CONTEXT, DECLARATION_COMPONENT_VIEW, HEADER_OFFSET, LView, TVIEW, TView} import {detachView} from '../node_manipulation'; import {getLView, nextBindingIndex} from '../state'; import {getTNode} from '../util/view_utils'; -import {addLViewToLContainer, createAndRenderEmbeddedLView, getLViewFromLContainer, removeLViewFromLContainer} from '../view_manipulation'; +import {addLViewToLContainer, createAndRenderEmbeddedLView, getLViewFromLContainer, removeLViewFromLContainer, shouldAddViewToDom} from '../view_manipulation'; import {ɵɵtemplate} from './template'; @@ -47,9 +48,13 @@ export function ɵɵconditional(containerIndex: number, matchingTemplateIndex // a truthy value and as the consequence we've got no view to show. if (matchingTemplateIndex !== -1) { const templateTNode = getExistingTNode(hostLView[TVIEW], matchingTemplateIndex); - const embeddedLView = createAndRenderEmbeddedLView(hostLView, templateTNode, value); + const dehydratedView = findMatchingDehydratedView(lContainer, templateTNode.tView!.ssrId); + const embeddedLView = + createAndRenderEmbeddedLView(hostLView, templateTNode, value, {dehydratedView}); - addLViewToLContainer(lContainer, embeddedLView, viewInContainerIdx); + addLViewToLContainer( + lContainer, embeddedLView, viewInContainerIdx, + shouldAddViewToDom(templateTNode, dehydratedView)); } } else { // We might keep displaying the same template but the actual value of the expression could have diff --git a/packages/core/src/render3/instructions/template.ts b/packages/core/src/render3/instructions/template.ts index 7d0cc202441b8..0bf699815b300 100644 --- a/packages/core/src/render3/instructions/template.ts +++ b/packages/core/src/render3/instructions/template.ts @@ -9,6 +9,7 @@ import {validateMatchingNode, validateNodeExists} from '../../hydration/error_ha import {TEMPLATES} from '../../hydration/interfaces'; import {locateNextRNode, siblingAfter} from '../../hydration/node_lookup_utils'; import {calcSerializedContainerSize, isDisconnectedNode, markRNodeAsClaimedByHydration, setSegmentHead} from '../../hydration/utils'; +import {populateDehydratedViewsInContainer} from '../../linker/view_container_ref'; import {assertEqual} from '../../util/assert'; import {assertFirstCreatePass} from '../assert'; import {attachPatchData} from '../context_discovery'; @@ -24,6 +25,12 @@ import {getConstant} from '../util/view_utils'; import {addToViewTree, createDirectivesInstances, createLContainer, createTView, getOrCreateTNode, resolveDirectives, saveResolvedLocalsInData} from './shared'; +// Special symbol that is used as a tag name in the `template` instruction in case +// an underlying LContainer is used by the runtime code to render different branches. +// This is needed to indicate that this particular LContainer needs to be hydrated +// (if an application uses SSR). +const BUILT_IN_CONTROL_FLOW_CONTAINER = '@'; + function templateFirstCreatePass( index: number, tView: TView, lView: LView, templateFn: ComponentTemplate|null, decls: number, vars: number, tagName?: string|null, attrsIndex?: number|null, @@ -92,7 +99,24 @@ export function ɵɵtemplate( } attachPatchData(comment, lView); - addToViewTree(lView, lView[adjustedIndex] = createLContainer(comment, lView, comment, tNode)); + const lContainer = createLContainer(comment, lView, comment, tNode); + lView[adjustedIndex] = lContainer; + addToViewTree(lView, lContainer); + + // Since there is no creation mode instruction for `if` and `for`, we annotate + // a `template` instruction, which creates an LContainer that is later used by + // the runtime code to render different branches. This is needed to indicate + // that this particular LContainer needs to be hydrated (if an application uses + // SSR). To avoid introducing an extra flag and consuming an extra argument, + // we use a special symbol in the tag name. + // + // NOTE: this check will be refactored once we split the `template` instruction logic + // into a `block` and `container` instructions and start generating them for control + // flow instead. In this case, when we call `container` from the `template` function, + // we'll just pass a flag to skip hydration. + if (tagName === BUILT_IN_CONTROL_FLOW_CONTAINER) { + populateDehydratedViewsInContainer(lContainer); + } if (isDirectiveHost(tNode)) { createDirectivesInstances(tView, lView, tNode); diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index 87caec9f4b411..696fe273a5662 100644 --- a/packages/platform-server/test/hydration_spec.ts +++ b/packages/platform-server/test/hydration_spec.ts @@ -236,7 +236,7 @@ describe('platform-server hydration integration', () => { // Keep those `beforeEach` and `afterEach` blocks separate, // since we'll need to remove them once new control flow // syntax is enabled by default. - beforeEach(() => setEnabledBlockTypes(['defer'])); + beforeEach(() => setEnabledBlockTypes(['defer', 'if', 'for', 'switch'])); afterEach(() => setEnabledBlockTypes([])); beforeEach(() => { @@ -5882,5 +5882,255 @@ describe('platform-server hydration integration', () => { verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); + + describe('#if (built-in control flow)', () => { + it('should work with `if`s that have different value on the client and on the server', + async () => { + @Component({ + standalone: true, + selector: 'app', + template: ` + {#if isServer}This is a SERVER-ONLY content{/if} + {#if !isServer}This is a CLIENT-ONLY content{/if} + {#if alwaysTrue}

CLIENT and SERVER content

{/if} + `, + }) + class SimpleComponent { + alwaysTrue = true; + + // This flag is intentionally different between the client + // and the server: we use it to test the logic to cleanup + // dehydrated views. + isServer = isPlatformServer(inject(PLATFORM_ID)); + } + + const html = await ssr(SimpleComponent); + let ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + ssrContents = stripExcessiveSpaces(stripUtilAttributes(ssrContents, false)); + + // In the SSR output we expect to see SERVER content, but not CLIENT. + expect(ssrContents).not.toContain('This is a CLIENT-ONLY content'); + expect(ssrContents).toContain('This is a SERVER-ONLY content'); + + // Content that should be rendered on both client and server should also be present. + expect(ssrContents).toContain('

CLIENT and SERVER content

'); + + const clientRootNode = compRef.location.nativeElement; + + await whenStable(appRef); + + const clientContents = + stripExcessiveSpaces(stripUtilAttributes(clientRootNode.outerHTML, false)); + + // After the cleanup, we expect to see CLIENT content, but not SERVER. + expect(clientContents).toContain('This is a CLIENT-ONLY content'); + expect(clientContents).not.toContain('This is a SERVER-ONLY content'); + + // Content that should be rendered on both client and server should still be present. + expect(clientContents).toContain('

CLIENT and SERVER content

'); + + const clientOnlyNode = clientRootNode.querySelector('i'); + verifyAllNodesClaimedForHydration(clientRootNode, [clientOnlyNode]); + }); + + it('should support nested `if`s', async () => { + @Component({ + standalone: true, + selector: 'app', + template: ` + This is a non-empty block: + {#if true} + {#if true} +

+ {#if true} + Hello world! + {/if} +

+ {/if} + {/if} +
Post-container element
+ `, + }) + 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 hydrate `else` blocks', async () => { + @Component({ + standalone: true, + selector: 'app', + template: ` + {#if conditionA} + if block + {:else} + else block + {/if} + `, + }) + class SimpleComponent { + conditionA = false; + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain(`(appRef); + appRef.tick(); + + await whenStable(appRef); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + + // Verify that we still have expected content rendered. + expect(clientRootNode.innerHTML).toContain(`else block`); + expect(clientRootNode.innerHTML).not.toContain(`if block`); + + // Verify that switching `if` condition results + // in an update to the DOM which was previously hydrated. + compRef.instance.conditionA = true; + compRef.changeDetectorRef.detectChanges(); + + expect(clientRootNode.innerHTML).not.toContain(`else block`); + expect(clientRootNode.innerHTML).toContain(`if block`); + }); + }); + + describe('#switch (built-in control flow)', () => { + it('should work with `switch`es that have different value on the client and on the server', + async () => { + @Component({ + standalone: true, + selector: 'app', + template: ` + {#switch isServer} + {:case true} + This is a SERVER-ONLY content + {:case false} + This is a CLIENT-ONLY content + {/switch} + `, + }) + class SimpleComponent { + // This flag is intentionally different between the client + // and the server: we use it to test the logic to cleanup + // dehydrated views. + isServer = isPlatformServer(inject(PLATFORM_ID)); + } + + const html = await ssr(SimpleComponent); + let ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + ssrContents = stripExcessiveSpaces(stripUtilAttributes(ssrContents, false)); + + // In the SSR output we expect to see SERVER content, but not CLIENT. + expect(ssrContents).not.toContain('This is a CLIENT-ONLY content'); + expect(ssrContents).toContain('This is a SERVER-ONLY content'); + + const clientRootNode = compRef.location.nativeElement; + + await whenStable(appRef); + + const clientContents = + stripExcessiveSpaces(stripUtilAttributes(clientRootNode.outerHTML, false)); + + // After the cleanup, we expect to see CLIENT content, but not SERVER. + expect(clientContents).toContain('This is a CLIENT-ONLY content'); + expect(clientContents).not.toContain('This is a SERVER-ONLY content'); + + const clientOnlyNode = clientRootNode.querySelector('i'); + verifyAllNodesClaimedForHydration(clientRootNode, [clientOnlyNode]); + }); + + it('should cleanup rendered case if none of the cases match on the client', async () => { + @Component({ + standalone: true, + selector: 'app', + template: ` + {#switch label} + {:case 'A'} + This is A. + {:case 'B'} + This is B. + {/switch} + `, + }) + class SimpleComponent { + // This flag is intentionally different between the client + // and the server: we use it to test the logic to cleanup + // dehydrated views. + label = isPlatformServer(inject(PLATFORM_ID)) ? 'A' : 'Not A'; + } + + const html = await ssr(SimpleComponent); + let ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + ssrContents = stripExcessiveSpaces(stripUtilAttributes(ssrContents, false)); + + expect(ssrContents).toContain('This is A.'); + + const clientRootNode = compRef.location.nativeElement; + + await whenStable(appRef); + + const clientContents = + stripExcessiveSpaces(stripUtilAttributes(clientRootNode.outerHTML, false)); + + // After the cleanup, we expect that the contents is removed and none + // of the cases are rendered, since they don't match the condition. + expect(clientContents).not.toContain('This is A'); + expect(clientContents).not.toContain('This is B'); + + verifyAllNodesClaimedForHydration(clientRootNode); + }); + }); }); });