From d190f82147f69a607eade2ddbcd081499ea3ef64 Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Mon, 11 Nov 2024 16:48:01 -0500 Subject: [PATCH] test(core): add incremental hydration tests (#58601) This adds some additional tests for incremental hydration around registry and contract cleanup. PR Close #58601 --- packages/core/src/defer/registry.ts | 10 +- packages/core/src/defer/triggering.ts | 5 - packages/core/src/event_delegation_utils.ts | 5 - packages/core/src/hydration/cleanup.ts | 1 - .../test/incremental_hydration_spec.ts | 234 ++++++++++++++++++ 5 files changed, 243 insertions(+), 12 deletions(-) diff --git a/packages/core/src/defer/registry.ts b/packages/core/src/defer/registry.ts index 1b67ce7de85..1334aec1b5f 100644 --- a/packages/core/src/defer/registry.ts +++ b/packages/core/src/defer/registry.ts @@ -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(); private cleanupFns = new Map(); private jsActionMap: Map> = 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 { diff --git a/packages/core/src/defer/triggering.ts b/packages/core/src/defer/triggering.ts index d6e0fa7e9fa..6775a5dcc67 100644 --- a/packages/core/src/defer/triggering.ts +++ b/packages/core/src/defer/triggering.ts @@ -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}; } diff --git a/packages/core/src/event_delegation_utils.ts b/packages/core/src/event_delegation_utils.ts index 87d869af4d9..f4d296b33f8 100644 --- a/packages/core/src/event_delegation_utils.ts +++ b/packages/core/src/event_delegation_utils.ts @@ -110,11 +110,6 @@ export const JSACTION_EVENT_CONTRACT = new InjectionToken( }, ); -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) { diff --git a/packages/core/src/hydration/cleanup.ts b/packages/core/src/hydration/cleanup.ts index c5db63766de..42f5f32d1a2 100644 --- a/packages/core/src/hydration/cleanup.ts +++ b/packages/core/src/hydration/cleanup.ts @@ -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'; /** diff --git a/packages/platform-server/test/incremental_hydration_spec.ts b/packages/platform-server/test/incremental_hydration_spec.ts index 3cb9a1b0064..f5dda7b1103 100644 --- a/packages/platform-server/test/incremental_hydration_spec.ts +++ b/packages/platform-server/test/incremental_hydration_spec.ts @@ -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('Client!'); expect(appHostNode.outerHTML).not.toContain('>Server!'); }, 100_000); + + it('should clear registry of blocks as they are hydrated', async () => { + @Component({ + standalone: true, + selector: 'app', + template: ` +
+ @defer (on viewport; hydrate on interaction) { +
+ Main defer block rendered! + @defer (on viewport; hydrate on interaction) { +

Nested defer block

+ } @placeholder { + Inner block placeholder + } +
+ } @placeholder { + Outer block placeholder + } +
+ `, + }) + 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(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: ` +
+ @defer (on viewport; hydrate on interaction) { +
+ Main defer block rendered! + @defer (on viewport; hydrate on interaction) { +

Nested defer block

+ } @placeholder { + Inner block placeholder + } +
+ } @placeholder { + Outer block placeholder + } +
+ `, + }) + 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(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: ` +
+ @defer (on viewport; hydrate on interaction) { +
+ + @defer (on viewport; hydrate on interaction) { +

Nested defer block

+ } @placeholder { + Inner block placeholder + } +
+ } @placeholder { + Outer block placeholder + } + @defer (on viewport; hydrate on hover) { +

This should remain in the registry

+ } @placeholder { + a second placeholder + } +
+ `, + }) + 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(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(); + }); }); });