Skip to content

Commit

Permalink
refactor(core): add hydration support for built-in for (#51920)
Browse files Browse the repository at this point in the history
This commit adds hydration support for repeaters (for loops) and empty blocks. The logic looks up a dehydrated view and use this information for hydration. Otherwise, DOM elements for a view are created from scratch.

PR Close #51920
  • Loading branch information
AndrewKushnir authored and alxhub committed Sep 29, 2023
1 parent 408d3b4 commit 6b6a44c
Show file tree
Hide file tree
Showing 2 changed files with 237 additions and 7 deletions.
17 changes: 12 additions & 5 deletions packages/core/src/render3/instructions/control_flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,14 @@ export function ɵɵrepeater(
if (item.previousIndex === null) {
// add
const newViewIdx = adjustToLastLContainerIndex(lContainer, currentIndex);
const dehydratedView =
findMatchingDehydratedView(lContainer, itemTemplateTNode.tView!.ssrId);
const embeddedLView = createAndRenderEmbeddedLView(
hostLView, itemTemplateTNode,
new RepeaterContext(lContainer, item.item, newViewIdx));
addLViewToLContainer(lContainer, embeddedLView, newViewIdx);
new RepeaterContext(lContainer, item.item, newViewIdx), {dehydratedView});
addLViewToLContainer(
lContainer, embeddedLView, newViewIdx,
shouldAddViewToDom(itemTemplateTNode, dehydratedView));
needsIndexUpdate = true;
} else if (currentIndex === null) {
// remove
Expand Down Expand Up @@ -228,9 +232,12 @@ export function ɵɵrepeater(
removeLViewFromLContainer(lContainer, 0);
} else {
const emptyTemplateTNode = getExistingTNode(hostTView, emptyTemplateIndex);
const embeddedLView =
createAndRenderEmbeddedLView(hostLView, emptyTemplateTNode, undefined);
addLViewToLContainer(lContainer, embeddedLView, 0);
const dehydratedView =
findMatchingDehydratedView(lContainer, emptyTemplateTNode.tView!.ssrId);
const embeddedLView = createAndRenderEmbeddedLView(
hostLView, emptyTemplateTNode, undefined, {dehydratedView});
addLViewToLContainer(
lContainer, embeddedLView, 0, shouldAddViewToDom(emptyTemplateTNode, dehydratedView));
}
}
}
Expand Down
227 changes: 225 additions & 2 deletions packages/platform-server/test/hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5814,7 +5814,7 @@ describe('platform-server hydration integration', () => {
});
});

describe('@if (built-in control flow)', () => {
describe('@if', () => {
it('should work with `if`s that have different value on the client and on the server',
async () => {
@Component({
Expand Down Expand Up @@ -5959,7 +5959,7 @@ describe('platform-server hydration integration', () => {
});
});

describe('#switch (built-in control flow)', () => {
describe('@switch', () => {
it('should work with `switch`es that have different value on the client and on the server',
async () => {
@Component({
Expand Down Expand Up @@ -6060,6 +6060,229 @@ describe('platform-server hydration integration', () => {
});
});

describe('@for', () => {
it('should hydrate for loop content', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@for (item of items; track item) {
<div>
<h1>Item #{{ item }}</h1>
</div>
}
`,
})
class SimpleComponent {
items = [1, 2, 3];
}

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

expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);

// Check whether serialized hydration info has a multiplier
// (which avoids repeated views serialization).
const hydrationInfo = getHydrationInfoFromTransferState(ssrContents);
expect(hydrationInfo).toContain('"x":3');

resetTViewsFor(SimpleComponent);

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

const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});

it('should hydrate @empty block content', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@for (item of items; track item) {
<p>Item #{{ item }}</p>
} @empty {
<div>This is an "empty" block</div>
}
`,
})
class SimpleComponent {
items = [];
}

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

expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);

resetTViewsFor(SimpleComponent);

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

const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});

it('should handle a case when @empty block is rendered ' +
'on the server and main content on the client',
async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@for (item of items; track item) {
<p>Item #{{ item }}</p>
} @empty {
<div>This is an "empty" block</div>
}
`,
})
class SimpleComponent {
items = isPlatformServer(inject(PLATFORM_ID)) ? [] : [1, 2, 3];
}

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

expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);

resetTViewsFor(SimpleComponent);

// Expect only the `@empty` block to be rendered on the server.
expect(ssrContents).not.toContain('Item #1');
expect(ssrContents).not.toContain('Item #2');
expect(ssrContents).not.toContain('Item #3');
expect(ssrContents).toContain('This is an "empty" block');

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

await whenStable(appRef);

const clientRootNode = compRef.location.nativeElement;

// After hydration and post-hydration cleanup,
// expect items to be present, but `@empty` block to be removed.
expect(clientRootNode.innerHTML).toContain('Item #1');
expect(clientRootNode.innerHTML).toContain('Item #2');
expect(clientRootNode.innerHTML).toContain('Item #3');
expect(clientRootNode.innerHTML).not.toContain('This is an "empty" block');

const clientRenderedItems = compRef.location.nativeElement.querySelectorAll('p');
verifyAllNodesClaimedForHydration(clientRootNode, Array.from(clientRenderedItems));
});

it('should handle a case when @empty block is rendered ' +
'on the client and main content on the server',
async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@for (item of items; track item) {
<p>Item #{{ item }}</p>
} @empty {
<div>This is an "empty" block</div>
}
`,
})
class SimpleComponent {
items = isPlatformServer(inject(PLATFORM_ID)) ? [1, 2, 3] : [];
}

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

expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);

resetTViewsFor(SimpleComponent);

// Expect items to be rendered on the server.
expect(ssrContents).toContain('Item #1');
expect(ssrContents).toContain('Item #2');
expect(ssrContents).toContain('Item #3');
expect(ssrContents).not.toContain('This is an "empty" block');

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

await whenStable(appRef);

const clientRootNode = compRef.location.nativeElement;

// After hydration and post-hydration cleanup,
// expect an `@empty` block to be present and items to be removed.
expect(clientRootNode.innerHTML).not.toContain('Item #1');
expect(clientRootNode.innerHTML).not.toContain('Item #2');
expect(clientRootNode.innerHTML).not.toContain('Item #3');
expect(clientRootNode.innerHTML).toContain('This is an "empty" block');

const clientRenderedItems = compRef.location.nativeElement.querySelectorAll('div');
verifyAllNodesClaimedForHydration(clientRootNode, Array.from(clientRenderedItems));
});

it('should handle different number of items rendered on the client and on the server',
async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@for (item of items; track item) {
<p id="{{ item }}">Item #{{ item }}</p>
}
`,
})
class SimpleComponent {
// Item '3' is the same, the rest of the items are different.
items = isPlatformServer(inject(PLATFORM_ID)) ? [3, 2, 1] : [3, 4, 5];
}

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

expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);

resetTViewsFor(SimpleComponent);

expect(ssrContents).toContain('Item #1');
expect(ssrContents).toContain('Item #2');
expect(ssrContents).toContain('Item #3');
expect(ssrContents).not.toContain('Item #4');
expect(ssrContents).not.toContain('Item #5');

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

await whenStable(appRef);

const clientRootNode = compRef.location.nativeElement;

// After hydration and post-hydration cleanup,
// expect items to be present, but `@empty` block to be removed.
expect(clientRootNode.innerHTML).not.toContain('Item #1');
expect(clientRootNode.innerHTML).not.toContain('Item #2');
expect(clientRootNode.innerHTML).toContain('Item #3');
expect(clientRootNode.innerHTML).toContain('Item #4');
expect(clientRootNode.innerHTML).toContain('Item #5');

// Note: we exclude item '3', since it's the same (and at the same location)
// on the server and on the client, so it was hydrated.
const clientRenderedItems =
[4, 5].map(id => compRef.location.nativeElement.querySelector(`[id=${id}]`));
verifyAllNodesClaimedForHydration(clientRootNode, Array.from(clientRenderedItems));
});
});

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 6b6a44c

Please sign in to comment.