Skip to content

Commit

Permalink
refactor(core): support let declarations during hydration
Browse files Browse the repository at this point in the history
Updates the hydration logic to account for the fact that let declarations don't create a DOM node.
  • Loading branch information
crisbeto committed May 14, 2024
1 parent 155cd7d commit 96d114f
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 12 deletions.
21 changes: 11 additions & 10 deletions packages/core/src/hydration/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,24 +510,25 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView
// those nodes to reach a corresponding anchor node (comment node).
ngh[ELEMENT_CONTAINERS] ??= {};
ngh[ELEMENT_CONTAINERS][noOffsetIndex] = calcNumRootNodes(tView, lView, tNode.child);
} else if (tNode.type & TNodeType.Projection) {
// Current TNode represents an `<ng-content>` slot, thus it has no
// DOM elements associated with it, so the **next sibling** node would
// not be able to find an anchor. In this case, use full path instead.
} else if (tNode.type & (TNodeType.Projection | TNodeType.LetDeclaration)) {
// Current TNode represents an `<ng-content>` slot or `@let` declaration,
// thus it has no DOM elements associated with it, so the **next sibling**
// node would not be able to find an anchor. In this case, use full path instead.
let nextTNode = tNode.next;
// Skip over all `<ng-content>` slots in a row.
while (nextTNode !== null && nextTNode.type & TNodeType.Projection) {
while (
nextTNode !== null &&
nextTNode.type & (TNodeType.Projection | TNodeType.LetDeclaration)
) {
nextTNode = nextTNode.next;
}
if (nextTNode && !isInSkipHydrationBlock(nextTNode)) {
// Handle a tNode after the `<ng-content>` slot.
appendSerializedNodePath(ngh, nextTNode, lView, i18nChildren);
}
} else {
if (tNode.type & TNodeType.Text) {
const rNode = unwrapRNode(lView[i]);
processTextNodeBeforeSerialization(context, rNode);
}
} else if (tNode.type & TNodeType.Text) {
const rNode = unwrapRNode(lView[i]);
processTextNodeBeforeSerialization(context, rNode);
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/hydration/error_handling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ function getFriendlyStringFromTNodeType(tNodeType: TNodeType): string {
return 'projection';
case TNodeType.Text:
return 'text';
case TNodeType.LetDeclaration:
return '@let';
default:
// This should not happen as we cover all possible TNode types above.
return '<unknown>';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/hydration/node_lookup_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function getNoOffsetIndex(tNode: TNode): number {
*/
export function isDisconnectedNode(tNode: TNode, lView: LView) {
return (
!(tNode.type & TNodeType.Projection) &&
!(tNode.type & (TNodeType.Projection | TNodeType.LetDeclaration)) &&
!!lView[tNode.index] &&
!(unwrapRNode(lView[tNode.index]) as Node)?.isConnected
);
Expand Down
131 changes: 130 additions & 1 deletion packages/platform-server/test/hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {computeMsgId} from '@angular/compiler';
import {
afterRender,
ApplicationRef,
ChangeDetectorRef,
Component,
ComponentRef,
ContentChildren,
Expand All @@ -37,13 +38,14 @@ import {
Injectable,
Input,
NgZone,
Pipe,
PipeTransform,
PLATFORM_ID,
Provider,
QueryList,
TemplateRef,
Type,
ViewChild,
viewChild,
ViewContainerRef,
ViewEncapsulation,
ɵwhenStable as whenStable,
Expand All @@ -70,6 +72,7 @@ import {provideServerRendering} from '../public_api';
import {renderApplication} from '../src/utils';

import {getAppContents, renderAndHydrate, resetTViewsFor, stripUtilAttributes} from './dom_utils';
import {ɵsetEnableLetSyntax} from '@angular/compiler/src/jit_compiler_facade';

/**
* The name of the attribute that contains a slot index
Expand Down Expand Up @@ -7184,6 +7187,132 @@ describe('platform-server hydration integration', () => {
});
});

describe('@let', () => {
beforeEach(() => ɵsetEnableLetSyntax(true));
afterEach(() => ɵsetEnableLetSyntax(false));

it('should handle a let declaration', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@let greeting = name + '!!!';
Hello, {{greeting}}
`,
})
class SimpleComponent {
name = 'Frodo';
}

const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);

expect(ssrContents).toContain('<app ngh');
expect(ssrContents).toContain('Hello, Frodo!!!');

resetTViewsFor(SimpleComponent);

const appRef = await renderAndHydrate(doc, html, SimpleComponent);
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();

const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
expect(clientRootNode.textContent).toContain('Hello, Frodo!!!');

compRef.instance.name = 'Bilbo';
compRef.changeDetectorRef.detectChanges();
expect(clientRootNode.textContent).toContain('Hello, Bilbo!!!');
});

it('should handle multiple let declarations that depend on each other', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@let plusOne = value + 1;
@let plusTwo = plusOne + 1;
@let result = plusTwo + 1;
Result: {{result}}
`,
})
class SimpleComponent {
value = 1;
}

const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);

expect(ssrContents).toContain('<app ngh');
expect(ssrContents).toContain('Result: 4');

resetTViewsFor(SimpleComponent);

const appRef = await renderAndHydrate(doc, html, SimpleComponent);
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();

const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
expect(clientRootNode.textContent).toContain('Result: 4');

compRef.instance.value = 2;
compRef.changeDetectorRef.detectChanges();

expect(clientRootNode.textContent).toContain('Result: 5');
});

it('should handle a let declaration using a pipe that injects ChangeDetectorRef', async () => {
@Pipe({
name: 'double',
standalone: true,
})
class DoublePipe implements PipeTransform {
changeDetectorRef = inject(ChangeDetectorRef);

transform(value: number) {
return value * 2;
}
}

@Component({
standalone: true,
selector: 'app',
imports: [DoublePipe],
template: `
@let result = value | double;
Result: {{result}}
`,
})
class SimpleComponent {
value = 1;
}

const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);

expect(ssrContents).toContain('<app ngh');
expect(ssrContents).toContain('Result: 2');

resetTViewsFor(SimpleComponent);

const appRef = await renderAndHydrate(doc, html, SimpleComponent);
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();

const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
expect(clientRootNode.textContent).toContain('Result: 2');

compRef.instance.value = 2;
compRef.changeDetectorRef.detectChanges();
expect(clientRootNode.textContent).toContain('Result: 4');
});
});

describe('Router', () => {
it('should wait for lazy routes before triggering post-hydration cleanup', async () => {
const ngZone = TestBed.inject(NgZone);
Expand Down

0 comments on commit 96d114f

Please sign in to comment.