test(core): add incremental hydration tests (#58601)

This adds some additional tests for incremental hydration around registry and contract cleanup.

PR Close #58601
This commit is contained in:
Jessica Janiuk 2024-11-11 16:48:01 -05:00
parent b59c218b2d
commit d190f82147
5 changed files with 243 additions and 12 deletions

View file

@ -8,7 +8,11 @@
import {inject} from '../di';
import {InjectionToken} from '../di/injection_token';
import {ɵɵdefineInjectable} from '../di/interface/defs';
import {removeListenersFromBlocks} from '../event_delegation_utils';
import {
EventContractDetails,
JSACTION_EVENT_CONTRACT,
removeListenersFromBlocks,
} from '../event_delegation_utils';
import {JSACTION_BLOCK_ELEMENT_MAP} from '../hydration/tokens';
import {DehydratedDeferBlock} from './interfaces';
@ -30,6 +34,7 @@ export class DehydratedBlockRegistry {
private registry = new Map<string, DehydratedDeferBlock>();
private cleanupFns = new Map<string, Function[]>();
private jsActionMap: Map<string, Set<Element>> = inject(JSACTION_BLOCK_ELEMENT_MAP);
private contract: EventContractDetails = inject(JSACTION_EVENT_CONTRACT);
add(blockId: string, info: DehydratedDeferBlock) {
this.registry.set(blockId, info);
}
@ -49,6 +54,9 @@ export class DehydratedBlockRegistry {
this.invokeTriggerCleanupFns(blockId);
this.hydrating.delete(blockId);
}
if (this.size === 0) {
this.contract.instance?.cleanUp();
}
}
get size(): number {

View file

@ -9,7 +9,6 @@
import {Injector} from '../di';
import {internalImportProvidersFrom} from '../di/provider_collection';
import {RuntimeError, RuntimeErrorCode} from '../errors';
import {cleanupContracts} from '../event_delegation_utils';
import {cleanupDeferBlock} from '../hydration/cleanup';
import {
assertSsrIdDefined,
@ -412,10 +411,6 @@ async function triggerBlockTreeHydrationByName(
const hydratedBlockId = hydrationQueue.slice(-1)[0];
const hydratedBlock = dehydratedBlockRegistry.get(hydratedBlockId)!;
if (dehydratedBlockRegistry.size === 0) {
cleanupContracts(injector);
}
return {deferBlock: hydratedBlock, hydratedBlocks};
}

View file

@ -110,11 +110,6 @@ export const JSACTION_EVENT_CONTRACT = new InjectionToken<EventContractDetails>(
},
);
export function cleanupContracts(injector: Injector) {
const eventContractDetails = injector.get(JSACTION_EVENT_CONTRACT);
eventContractDetails.instance!.cleanUp();
}
export function invokeListeners(event: Event, currentTarget: Element | null) {
const handlerFns = currentTarget?.__jsaction_fns?.get(event.type);
if (!handlerFns) {

View file

@ -24,7 +24,6 @@ import {nativeRemoveNode} from '../render3/node_manipulation';
import {validateSiblingNodeExists} from './error_handling';
import {cleanupI18nHydrationData} from './i18n';
import {DEFER_BLOCK_ID, DehydratedContainerView, NUM_ROOT_NODES} from './interfaces';
import {JSACTION_BLOCK_ELEMENT_MAP} from './tokens';
import {getLNodeForHydration} from './utils';
/**

View file

@ -28,6 +28,12 @@ import {
} from '@angular/platform-browser';
import {TestBed} from '@angular/core/testing';
import {PLATFORM_BROWSER_ID} from '@angular/common/src/platform_id';
import {DEHYDRATED_BLOCK_REGISTRY, DehydratedBlockRegistry} from '@angular/core/src/defer/registry';
import {JSACTION_BLOCK_ELEMENT_MAP} from '@angular/core/src/hydration/tokens';
import {
EventContractDetails,
JSACTION_EVENT_CONTRACT,
} from '@angular/core/src/event_delegation_utils';
describe('platform-server partial hydration integration', () => {
const originalWindow = globalThis.window;
@ -1514,5 +1520,233 @@ describe('platform-server partial hydration integration', () => {
expect(appHostNode.outerHTML).toContain('<span>Client!</span>');
expect(appHostNode.outerHTML).not.toContain('>Server!</span>');
}, 100_000);
it('should clear registry of blocks as they are hydrated', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<main (click)="fnA()">
@defer (on viewport; hydrate on interaction) {
<div id="main" (click)="fnA()">
Main defer block rendered!
@defer (on viewport; hydrate on interaction) {
<p id="nested">Nested defer block</p>
} @placeholder {
<span>Inner block placeholder</span>
}
</div>
} @placeholder {
<span>Outer block placeholder</span>
}
</main>
`,
})
class SimpleComponent {
fnA() {}
registry = inject(DEHYDRATED_BLOCK_REGISTRY);
jsActionMap = inject(JSACTION_BLOCK_ELEMENT_MAP);
contract = inject(JSACTION_EVENT_CONTRACT);
}
const appId = 'custom-app-id';
const providers = [{provide: APP_ID, useValue: appId}];
const hydrationFeatures = () => [withIncrementalHydration()];
const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
// Internal cleanup before we do server->client transition in this test.
resetTViewsFor(SimpleComponent);
////////////////////////////////
const doc = getDocument();
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
hydrationFeatures,
});
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();
await whenStable(appRef);
const registry = compRef.instance.registry;
const jsActionMap = compRef.instance.jsActionMap;
const contract = compRef.instance.contract;
spyOn(contract.instance!, 'cleanUp').and.callThrough();
expect(registry.size).toBe(1);
expect(jsActionMap.size).toBe(2);
expect(registry.has('d0')).toBeTruthy();
const mainBlock = doc.getElementById('main')!;
const clickEvent = new CustomEvent('click', {bubbles: true});
mainBlock.dispatchEvent(clickEvent);
await timeout(1000); // wait for defer blocks to resolve
appRef.tick();
expect(registry.size).toBe(1);
expect(registry.has('d0')).toBeFalsy();
expect(jsActionMap.size).toBe(1);
const nested = doc.getElementById('nested')!;
const clickEvent2 = new CustomEvent('click', {bubbles: true});
nested.dispatchEvent(clickEvent2);
await timeout(1000); // wait for defer blocks to resolve
appRef.tick();
expect(registry.size).toBe(0);
expect(jsActionMap.size).toBe(0);
expect(contract.instance!.cleanUp).toHaveBeenCalled();
});
it('should clear registry of multiple blocks if they are hydrated in one go', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<main (click)="fnA()">
@defer (on viewport; hydrate on interaction) {
<div id="main" (click)="fnA()">
Main defer block rendered!
@defer (on viewport; hydrate on interaction) {
<p id="nested">Nested defer block</p>
} @placeholder {
<span>Inner block placeholder</span>
}
</div>
} @placeholder {
<span>Outer block placeholder</span>
}
</main>
`,
})
class SimpleComponent {
fnA() {}
registry = inject(DEHYDRATED_BLOCK_REGISTRY);
jsActionMap = inject(JSACTION_BLOCK_ELEMENT_MAP);
contract = inject(JSACTION_EVENT_CONTRACT);
}
const appId = 'custom-app-id';
const providers = [{provide: APP_ID, useValue: appId}];
const hydrationFeatures = () => [withIncrementalHydration()];
const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
// Internal cleanup before we do server->client transition in this test.
resetTViewsFor(SimpleComponent);
////////////////////////////////
const doc = getDocument();
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
hydrationFeatures,
});
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();
await whenStable(appRef);
const registry = compRef.instance.registry;
const jsActionMap = compRef.instance.jsActionMap;
const contract = compRef.instance.contract;
spyOn(contract.instance!, 'cleanUp').and.callThrough();
expect(registry.size).toBe(1);
expect(jsActionMap.size).toBe(2);
expect(registry.has('d0')).toBeTruthy();
const nested = doc.getElementById('nested')!;
const clickEvent2 = new CustomEvent('click', {bubbles: true});
nested.dispatchEvent(clickEvent2);
await timeout(1000); // wait for defer blocks to resolve
appRef.tick();
expect(registry.size).toBe(0);
expect(jsActionMap.size).toBe(0);
expect(contract.instance!.cleanUp).toHaveBeenCalled();
});
it('should leave blocks in registry when not hydrated', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<main (click)="fnA()">
@defer (on viewport; hydrate on interaction) {
<div id="main" (click)="fnA()">
<aside>Main defer block rendered!</aside>
@defer (on viewport; hydrate on interaction) {
<p id="nested">Nested defer block</p>
} @placeholder {
<span>Inner block placeholder</span>
}
</div>
} @placeholder {
<span>Outer block placeholder</span>
}
@defer (on viewport; hydrate on hover) {
<p>This should remain in the registry</p>
} @placeholder {
<span>a second placeholder</span>
}
</main>
`,
})
class SimpleComponent {
fnA() {}
registry = inject(DEHYDRATED_BLOCK_REGISTRY);
jsActionMap = inject(JSACTION_BLOCK_ELEMENT_MAP);
contract = inject(JSACTION_EVENT_CONTRACT);
}
const appId = 'custom-app-id';
const providers = [{provide: APP_ID, useValue: appId}];
const hydrationFeatures = () => [withIncrementalHydration()];
const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
// Internal cleanup before we do server->client transition in this test.
resetTViewsFor(SimpleComponent);
////////////////////////////////
const doc = getDocument();
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
hydrationFeatures,
});
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();
await whenStable(appRef);
const contract = compRef.instance.contract;
spyOn(contract.instance!, 'cleanUp').and.callThrough();
const registry = compRef.instance.registry;
const jsActionMap = compRef.instance.jsActionMap;
// registry size should be the number of highest level dehydrated defer blocks
// in this case, 2.
expect(registry.size).toBe(2);
// jsactionmap should include all elements that have jsaction on them, in this
// case, 3, due to the defer block root nodes.
expect(jsActionMap.size).toBe(3);
expect(registry.has('d0')).toBeTruthy();
const nested = doc.getElementById('nested')!;
const clickEvent2 = new CustomEvent('click', {bubbles: true});
nested.dispatchEvent(clickEvent2);
await timeout(1000); // wait for defer blocks to resolve
appRef.tick();
expect(registry.size).toBe(1);
expect(jsActionMap.size).toBe(1);
expect(registry.has('d2')).toBeTruthy();
expect(contract.instance!.cleanUp).not.toHaveBeenCalled();
});
});
});