diff --git a/packages/core/src/hydration/api.ts b/packages/core/src/hydration/api.ts index e6272d6eae4..53faf67b841 100644 --- a/packages/core/src/hydration/api.ts +++ b/packages/core/src/hydration/api.ts @@ -29,12 +29,21 @@ import {performanceMarkFeature} from '../util/performance'; import {NgZone} from '../zone'; import {withEventReplay} from './event_replay'; +import { + ChangeDetectionScheduler, + NotificationSource, +} from '../change_detection/scheduling/zoneless_scheduling'; +import {DEHYDRATED_BLOCK_REGISTRY, DehydratedBlockRegistry} from '../defer/registry'; +import {processAndInitTriggers} from '../defer/triggering'; +import {DOCUMENT} from '../document'; +import {DOC_PAGE_BASE_URL} from '../error_details_base_url'; import {cleanupDehydratedViews} from './cleanup'; import { enableClaimDehydratedIcuCaseImpl, enablePrepareI18nBlockForHydrationImpl, setIsI18nHydrationSupportEnabled, } from './i18n'; +import {gatherDeferBlocksCommentNodes} from './node_lookup_utils'; import { IS_HYDRATION_DOM_REUSE_ENABLED, IS_I18N_HYDRATION_ENABLED, @@ -52,11 +61,6 @@ import { verifySsrContentsIntegrity, } from './utils'; import {enableFindMatchingDehydratedViewImpl} from './views'; -import {DEHYDRATED_BLOCK_REGISTRY, DehydratedBlockRegistry} from '../defer/registry'; -import {gatherDeferBlocksCommentNodes} from './node_lookup_utils'; -import {processAndInitTriggers} from '../defer/triggering'; -import {DOCUMENT} from '../document'; -import {DOC_PAGE_BASE_URL} from '../error_details_base_url'; /** * Indicates whether the hydration-related code was added, @@ -277,6 +281,7 @@ export function withDomHydration(): EnvironmentProviders { { provide: APP_BOOTSTRAP_LISTENER, useFactory: () => { + const scheduler = inject(ChangeDetectionScheduler); if (inject(IS_HYDRATION_DOM_REUSE_ENABLED)) { const appRef = inject(ApplicationRef); @@ -304,6 +309,8 @@ export function withDomHydration(): EnvironmentProviders { countBlocksSkippedByHydration(appRef.injector); printHydrationStats(appRef.injector); } + // We need to schedule the execution of the render hooks because the hydration cleanup alters the DOM. + scheduler.notify(NotificationSource.RenderHook); }); }; } diff --git a/packages/platform-server/test/BUILD.bazel b/packages/platform-server/test/BUILD.bazel index 0556143d2b7..7ba9e979242 100644 --- a/packages/platform-server/test/BUILD.bazel +++ b/packages/platform-server/test/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "angular_jasmine_test", "ts_project") +load("//tools:defaults.bzl", "angular_jasmine_test", "ts_project", "zoneless_jasmine_test") ts_project( name = "dom_utils", @@ -65,6 +65,7 @@ ts_project( ], ) +# This target is meant to run with Zone.js as some test cases are shared between Zone & Zoneless. angular_jasmine_test( name = "test", data = [ @@ -73,7 +74,7 @@ angular_jasmine_test( ], ) -angular_jasmine_test( +zoneless_jasmine_test( name = "event_replay_test", data = [ ":event_replay_test_lib", diff --git a/packages/platform-server/test/full_app_hydration_spec.ts b/packages/platform-server/test/full_app_hydration_spec.ts index 1c70be62f08..37042e5e690 100644 --- a/packages/platform-server/test/full_app_hydration_spec.ts +++ b/packages/platform-server/test/full_app_hydration_spec.ts @@ -46,8 +46,8 @@ import { PLATFORM_ID, Provider, provideZoneChangeDetection, - provideZonelessChangeDetection, QueryList, + signal, TemplateRef, ViewChild, ViewContainerRef, @@ -2821,9 +2821,9 @@ describe('platform-server full application hydration integration', () => { selector: 'app', imports: [MyLazyCmp], template: ` - Visible: {{ isVisible }}. + Visible: {{ isVisible() }}. - @defer (when isVisible) { + @defer (when isVisible()) { } @loading { Loading... @@ -2835,19 +2835,21 @@ describe('platform-server full application hydration integration', () => { `, }) class SimpleComponent { - isVisible = false; + isVisible = signal(false); + pendingTasks = inject(PendingTasks); ngOnInit() { + const removeTask = this.pendingTasks.add(); setTimeout(() => { // This changes the triggering condition of the defer block, // but it should be ignored and the placeholder content should be visible. - this.isVisible = true; + this.isVisible.set(true); + removeTask(); }); } } - const envProviders = [provideZoneChangeDetection() as any]; - const html = await ssr(SimpleComponent, {envProviders}); + const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(' { resetTViewsFor(SimpleComponent); - const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { - envProviders, - }); + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef(appRef); appRef.tick(); @@ -4591,103 +4591,129 @@ describe('platform-server full application hydration integration', () => { expect(clientContents).not.toContain('This is a SERVER-ONLY content'); }); - it('should trigger change detection after cleanup (immediate)', async () => { - const observedChildCountLog: number[] = []; + [true, false].forEach((zoneless) => { + it(`should trigger ${zoneless ? 'zoneless' : 'zone'} change detection after cleanup (immediate)`, async () => { + const observedChildCountLog: number[] = []; - @Component({ - selector: 'app', - imports: [NgIf], - template: ` - This is a SERVER-ONLY content - This is a CLIENT-ONLY content - `, - }) - class SimpleComponent { - isServer = isPlatformServer(inject(PLATFORM_ID)); - elementRef = inject(ElementRef); + @Component({ + selector: 'app', + imports: [NgIf], + template: ` + This is a SERVER-ONLY content + This is a CLIENT-ONLY content + `, + }) + class SimpleComponent { + isServer = isPlatformServer(inject(PLATFORM_ID)); + elementRef = inject(ElementRef); - constructor() { - afterEveryRender(() => { - observedChildCountLog.push(this.elementRef.nativeElement.childElementCount); - }); + constructor() { + afterEveryRender(() => { + observedChildCountLog.push(this.elementRef.nativeElement.childElementCount); + }); + } } - } - const envProviders = [provideZoneChangeDetection() as any]; - const html = await ssr(SimpleComponent, {envProviders}); - let ssrContents = getAppContents(html); + const envProviders = zoneless ? [] : [provideZoneChangeDetection() as any]; + const html = await ssr(SimpleComponent, {envProviders}); + let ssrContents = getAppContents(html); - expect(ssrContents).toContain(' { - const observedChildCountLog: number[] = []; - - @Component({ - selector: 'app', - imports: [NgIf], - template: ` - This is a SERVER-ONLY content - This is a CLIENT-ONLY content - `, - }) - class SimpleComponent { - isServer = isPlatformServer(inject(PLATFORM_ID)); - elementRef = inject(ElementRef); - - constructor() { - afterEveryRender(() => { - observedChildCountLog.push(this.elementRef.nativeElement.childElementCount); - }); - - // Create a dummy promise to prevent stabilization - new Promise((resolve) => { - setTimeout(resolve, 0); - }); + if (zoneless) { + // 1.) Bootstrap + // 2.) After cleanup + expect(observedChildCountLog).toEqual([2, 1]); + } else { + // afterRender should be triggered by: + // 1.) Bootstrap + // 2.) Microtask empty event + // 3.) Stabilization + cleanup + expect(observedChildCountLog).toEqual([2, 2, 1]); } - } - - const envProviders = [provideZoneChangeDetection() as any]; - const html = await ssr(SimpleComponent, {envProviders}); - let ssrContents = getAppContents(html); - - expect(ssrContents).toContain(' { + const observedChildCountLog: number[] = []; - await appRef.whenStable(); + @Component({ + selector: 'app', + imports: [NgIf], + template: ` + This is a SERVER-ONLY content + This is a CLIENT-ONLY content + `, + }) + class SimpleComponent { + isServer = isPlatformServer(inject(PLATFORM_ID)); + elementRef = inject(ElementRef); - // afterRender should be triggered by: - // 3.) Microtask empty event - // 4.) Stabilization + cleanup - expect(observedChildCountLog).toEqual([2, 2, 2, 1]); + constructor() { + afterEveryRender(() => { + observedChildCountLog.push(this.elementRef.nativeElement.childElementCount); + }); + + // Create a dummy promise to prevent stabilization + inject(PendingTasks).run( + async () => + await new Promise((resolve) => { + setTimeout(resolve, 0); + }), + ); + } + } + + const envProviders = zoneless ? [] : [provideZoneChangeDetection() as any]; + const html = await ssr(SimpleComponent, {envProviders}); + let ssrContents = getAppContents(html); + + expect(ssrContents).toContain(' { // and the server: we use it to test the logic to cleanup // dehydrated views. isServer = isPlatformServer(inject(PLATFORM_ID)); + pendingTasks = inject(PendingTasks); ngOnInit() { - setTimeout(() => {}, 100); + const remove = this.pendingTasks.add(); + setTimeout(() => { + remove(); + }, 100); } } - const envProviders = [provideZoneChangeDetection() as any]; - const html = await ssr(SimpleComponent, {envProviders}); + const html = await ssr(SimpleComponent); let ssrContents = getAppContents(html); expect(ssrContents).toContain(' { expect(ssrContents).toContain('This is NgSwitch SERVER-ONLY content'); resetTViewsFor(SimpleComponent); - const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { - envProviders, - }); + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef(appRef); appRef.tick(); @@ -7629,46 +7656,6 @@ describe('platform-server full application hydration integration', () => { }); }); - describe('zoneless', () => { - it('should not produce "unsupported configuration" warnings for zoneless mode', async () => { - @Component({ - selector: 'app', - template: ` -
Header
-
Footer
- `, - }) - class SimpleComponent {} - - const html = await ssr(SimpleComponent); - const ssrContents = getAppContents(html); - - expect(ssrContents).toContain(`(appRef); - appRef.tick(); - - // Make sure there are no extra logs in case zoneless mode is enabled. - verifyHasNoLog( - appRef, - 'NG05000: Angular detected that hydration was enabled for an application ' + - 'that uses a custom or a noop Zone.js implementation.', - ); - - const clientRootNode = compRef.location.nativeElement; - verifyAllNodesClaimedForHydration(clientRootNode); - verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); - }); - }); - describe('Router', () => { it('should wait for lazy routes before triggering post-hydration cleanup', async () => { const ngZone = TestBed.inject(NgZone); @@ -7764,7 +7751,6 @@ describe('platform-server full application hydration integration', () => { class SimpleComponent {} const envProviders = [ - provideZonelessChangeDetection(), {provide: PlatformLocation, useClass: MockPlatformLocation}, provideRouter(routes), ] as unknown as Provider[]; diff --git a/packages/platform-server/test/incremental_hydration_spec.ts b/packages/platform-server/test/incremental_hydration_spec.ts index 0f5392f29c6..1c7dd993743 100644 --- a/packages/platform-server/test/incremental_hydration_spec.ts +++ b/packages/platform-server/test/incremental_hydration_spec.ts @@ -10,25 +10,38 @@ import { APP_ID, ApplicationRef, Component, + ɵDEHYDRATED_BLOCK_REGISTRY as DEHYDRATED_BLOCK_REGISTRY, destroyPlatform, + ɵgetDocument as getDocument, inject, Input, - NgZone, + ɵJSACTION_BLOCK_ELEMENT_MAP as JSACTION_BLOCK_ELEMENT_MAP, + ɵJSACTION_EVENT_CONTRACT as JSACTION_EVENT_CONTRACT, + PendingTasks, PLATFORM_ID, Provider, QueryList, + ɵresetIncrementalHydrationEnabledWarnedForTests as resetIncrementalHydrationEnabledWarnedForTests, signal, + ɵTimerScheduler as TimerScheduler, ViewChildren, ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, - ɵDEHYDRATED_BLOCK_REGISTRY as DEHYDRATED_BLOCK_REGISTRY, - ɵJSACTION_BLOCK_ELEMENT_MAP as JSACTION_BLOCK_ELEMENT_MAP, - ɵJSACTION_EVENT_CONTRACT as JSACTION_EVENT_CONTRACT, - ɵgetDocument as getDocument, - ɵresetIncrementalHydrationEnabledWarnedForTests as resetIncrementalHydrationEnabledWarnedForTests, - ɵTimerScheduler as TimerScheduler, - provideZoneChangeDetection, } from '@angular/core'; +import { + isPlatformServer, + Location, + ɵPLATFORM_BROWSER_ID as PLATFORM_BROWSER_ID, + PlatformLocation, +} from '@angular/common'; +import {MockPlatformLocation} from '@angular/common/testing'; +import {TestBed} from '@angular/core/testing'; +import { + provideClientHydration, + withEventReplay, + withIncrementalHydration, +} from '@angular/platform-browser'; +import {provideRouter, RouterLink, RouterOutlet, Routes} from '@angular/router'; import {getAppContents, prepareEnvironmentAndHydrate, resetTViewsFor} from './dom_utils'; import { clearConsole, @@ -41,20 +54,6 @@ import { verifyNodeWasNotHydrated, withDebugConsole, } from './hydration_utils'; -import { - isPlatformServer, - Location, - PlatformLocation, - ɵPLATFORM_BROWSER_ID as PLATFORM_BROWSER_ID, -} from '@angular/common'; -import { - provideClientHydration, - withEventReplay, - withIncrementalHydration, -} from '@angular/platform-browser'; -import {TestBed} from '@angular/core/testing'; -import {provideRouter, RouterLink, RouterOutlet, Routes} from '@angular/router'; -import {MockPlatformLocation} from '@angular/common/testing'; /** * Emulates a dynamic import promise. @@ -1410,7 +1409,6 @@ describe('platform-server partial hydration integration', () => { ): number => { onIdleCallbackQueue.set(id, callback); expect(idleCallbacksRequested).toBe(0); - expect(NgZone.isInAngularZone()).toBe(true); idleCallbacksRequested++; return id++; }; @@ -1469,10 +1467,7 @@ describe('platform-server partial hydration integration', () => { } const appId = 'custom-app-id'; - const providers = [ - {provide: APP_ID, useValue: appId}, - provideZoneChangeDetection() as any, - ]; + const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); @@ -2062,10 +2057,18 @@ describe('platform-server partial hydration integration', () => { this.value.set('end'); } registry = inject(DEHYDRATED_BLOCK_REGISTRY); + + constructor() { + // TODO: Understand why this is needed to get the full rendering of the HTML + // Without it, bindings aren't properly rendered in SSR and the test fails. + // There was no issue with the zone based scheduler. + const remove = inject(PendingTasks).add(); + setTimeout(() => remove(), 10); + } } const appId = 'custom-app-id'; - const providers = [{provide: APP_ID, useValue: appId}, provideZoneChangeDetection() as any]; + const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); diff --git a/packages/platform-server/test/integration_spec.ts b/packages/platform-server/test/integration_spec.ts index 9899e74dd50..a40f4fd9ff8 100644 --- a/packages/platform-server/test/integration_spec.ts +++ b/packages/platform-server/test/integration_spec.ts @@ -28,7 +28,6 @@ import { destroyPlatform, EnvironmentProviders, getPlatform, - HostListener, Inject, inject, Injectable, @@ -43,6 +42,7 @@ import { provideNgReflectAttributes, Provider, provideZoneChangeDetection, + signal, ɵSSR_CONTENT_INTEGRITY_MARKER as SSR_CONTENT_INTEGRITY_MARKER, TransferState, Type, @@ -112,17 +112,17 @@ function createAppWithPendingTask(standalone: boolean) { @Component({ standalone, selector: 'app', - template: `Completed: {{ completed }}`, + template: `Completed: {{ completed() }}`, }) class PendingTasksApp { - completed = 'No'; + completed = signal('No'); constructor() { const pendingTasks = coreInject(PendingTasks); const removeTask = pendingTasks.add(); setTimeout(() => { removeTask(); - this.completed = 'Yes'; + this.completed.set('Yes'); }); } } @@ -138,7 +138,6 @@ const PendingTasksAppStandalone = createAppWithPendingTask(true); exports: [PendingTasksApp], imports: [ServerModule], bootstrap: [PendingTasksApp], - providers: [provideZoneChangeDetection()], }) export class PendingTasksAppModule {} @@ -309,23 +308,20 @@ class TitleAppModule {} function createMyAsyncServerApp(standalone: boolean) { @Component({ selector: 'app', - template: '{{text}}

', + template: '{{text()}}

', standalone, }) class MyAsyncServerApp { - text = ''; - h1 = ''; + text = signal(''); + h1 = signal(''); - @HostListener('window:scroll') - track() { - console.error('scroll'); - } - - ngOnInit() { + constructor() { + const remove = inject(PendingTasks).add(); Promise.resolve(null).then(() => setTimeout(() => { - this.text = 'Works!'; - this.h1 = 'fine'; + this.text.set('Works!'); + this.h1.set('fine'); + remove(); }, 10), ); } @@ -335,17 +331,19 @@ function createMyAsyncServerApp(standalone: boolean) { } const MyAsyncServerApp = createMyAsyncServerApp(false); -const MyAsyncServerAppStandalone = getStandaloneBootstrapFn(createMyAsyncServerApp(true), [ - provideZoneChangeDetection(), -]); +const MyAsyncServerAppStandalone = getStandaloneBootstrapFn(createMyAsyncServerApp(true), []); -@NgModule({ - declarations: [MyAsyncServerApp], - imports: [BrowserModule, ServerModule], - bootstrap: [MyAsyncServerApp], - providers: [provideZoneChangeDetection()], -}) -class AsyncServerModule {} +function createAsyncServerModule(zoneless: boolean) { + @NgModule({ + declarations: [MyAsyncServerApp], + imports: [BrowserModule, ServerModule], + bootstrap: [MyAsyncServerApp], + providers: zoneless ? [] : [provideZoneChangeDetection()], + }) + class AsyncServerModule {} + + return AsyncServerModule; +} function createSVGComponent(standalone: boolean) { @Component({ @@ -643,6 +641,7 @@ const MyHiddenComponentStandalone = getStandaloneBootstrapFn(createMyHiddenCompo }) class HiddenModule {} +// TODO: Remove that IIFE, angular_jasmine_test only runs on nodes. (function () { if (getDOM().supportsDOMEvents) return; // NODE only @@ -765,542 +764,570 @@ class HiddenModule {} TestBed.resetTestingModule(); }); - it('should render with `createApplication`', async () => { - const output = await renderApplication( - async (context) => { - const appRef = await createApplication( - { - providers: [provideZoneChangeDetection()], - }, - context, - ); - appRef.bootstrap(createMyAsyncServerApp(true)); - return appRef; - }, - {document: doc}, - ); + [true, false].forEach((zoneless: boolean) => { + it(`should render with \`createApplication \` (zoneless:${zoneless})`, async () => { + const output = await renderApplication( + async (context) => { + const appRef = await createApplication( + { + providers: [provideZoneChangeDetection()], + }, + context, + ); + appRef.bootstrap(createMyAsyncServerApp(true)); + return appRef; + }, + {document: doc}, + ); - expect(output).toBe(expectedOutput); - }); - - it('using long form should work', async () => { - const platform = platformServer([ - { - provide: INITIAL_CONFIG, - useValue: {document: doc}, - }, - ]); - - const moduleRef = await platform.bootstrapModule(AsyncServerModule); - const applicationRef = moduleRef.injector.get(ApplicationRef); - await applicationRef.whenStable(); - // Note: the `ng-server-context` is not present in this output, since - // `renderModule` or `renderApplication` functions are not used here. - const expectedOutput = - '' + - 'Works!

fine

'; - - expect(platform.injector.get(PlatformState).renderToString()).toBe(expectedOutput); - }); - - // Run the set of tests with regular and standalone components. - [true, false].forEach((isStandalone: boolean) => { - it(`using ${isStandalone ? 'renderApplication' : 'renderModule'} should work`, async () => { - const options = {document: doc}; - const bootstrap = isStandalone - ? renderApplication(MyAsyncServerAppStandalone, options) - : renderModule(AsyncServerModule, options); - const output = await bootstrap; expect(output).toBe(expectedOutput); }); - it( - `using ${isStandalone ? 'renderApplication' : 'renderModule'} ` + - `should allow passing a document reference`, - async () => { - const document = TestBed.inject(DOCUMENT); - - // Append root element based on the app selector. - const rootEl = document.createElement('app'); - document.body.appendChild(rootEl); - - // Append a special marker to verify that we use a correct instance - // of the document for rendering. - const markerEl = document.createComment('test marker'); - document.body.appendChild(markerEl); - - const options = {document}; - const bootstrap = isStandalone - ? renderApplication(MyAsyncServerAppStandalone, {document}) - : renderModule(AsyncServerModule, options); - const output = await bootstrap.finally(() => { - rootEl.remove(); - markerEl.remove(); - }); - - expect(output).toBe( - 'fakeTitle' + - '' + - 'Works!

fine

' + - '', - ); - }, - ); - - it(`works with SVG elements (standalone:${isStandalone})`, async () => { - const options = {document: doc}; - const bootstrap = isStandalone - ? renderApplication(SVGComponentStandalone, {...options}) - : renderModule(SVGServerModule, options); - const output = await bootstrap; - expect(output).toBe( - '' + - '', - ); - }); - - it('works with animation' + `(standalone:${isStandalone})`, async () => { - const options = {document: doc}; - const bootstrap = isStandalone - ? renderApplication(MyAnimationAppStandalone, options) - : renderModule(AnimationServerModule, options); - const output = await bootstrap; - expect(output).toContain('Works!'); - expect(output).toContain('ng-trigger-myAnimation'); - expect(output).toContain('opacity: 1;'); - expect(output).toContain('transform: translate3d(0, 0, 0);'); - expect(output).toContain('font-weight: bold;'); - }); - - it( - 'should handle ViewEncapsulation.ShadowDom' + `(standalone:${isStandalone})`, - async () => { - const options = {document: doc}; - const bootstrap = isStandalone - ? renderApplication(ShadowDomEncapsulationAppStandalone, options) - : renderModule(ShadowDomExampleModule, options); - const output = await bootstrap; - expect(output).not.toBe(''); - expect(output).toContain('color: red'); - }, - ); - - it( - 'adds the `ng-server-context` attribute to host elements' + - `(standalone:${isStandalone})`, - async () => { - const options = { - document: doc, - }; - const providers = [ - { - provide: SERVER_CONTEXT, - useValue: 'ssg', - }, - ]; - const bootstrap = isStandalone - ? renderApplication(MyStylesAppStandalone, { - ...options, - platformProviders: providers, - }) - : renderModule(ExampleStylesModule, { - ...options, - extraProviders: providers, - }); - const output = await bootstrap; - expect(output).toMatch( - //, - ); - }, - ); - - it('sanitizes the `serverContext` value' + `(standalone:${isStandalone})`, async () => { - const options = { - document: doc, - }; - const providers = [ + it(`using long form should work (zoneless:${zoneless})`, async () => { + const platform = platformServer([ { - provide: SERVER_CONTEXT, - useValue: '!!!Some extra chars&& -->', + ); + }, + ); + + it(`works with SVG elements (standalone:${isStandalone}, zoneless:${zoneless})`, async () => { const options = {document: doc}; const bootstrap = isStandalone - ? renderApplication(MyTransferStateAppStandalone, options) - : renderModule(MyTransferStateModule, options); - const expectedOutput = - '
Works!
' + - ''; - const output = await bootstrap; - expect(output).toEqual(expectedOutput); - }, - ); - - it( - 'uses `other` as the `serverContext` value when all symbols are removed after sanitization' + - `(standalone:${isStandalone})`, - async () => { - const options = { - document: doc, - }; - const providers = [ - { - provide: SERVER_CONTEXT, - useValue: '!!! &&<>', - }, - ]; - const bootstrap = isStandalone - ? renderApplication(MyStylesAppStandalone, { - ...options, - platformProviders: providers, - }) - : renderModule(ExampleStylesModule, { - ...options, - extraProviders: providers, - }); - // All symbols other than [a-zA-Z0-9\-] are removed, - // the `other` is used as the default. - const output = await bootstrap; - expect(output).toMatch(/ng-server-context="other"/); - }, - ); - - it( - 'appends SSR integrity marker comment when hydration is enabled' + - `(standalone:${isStandalone})`, - async () => { - @Component({ - selector: 'app', - template: ``, - }) - class SimpleApp {} - - const output = await renderApplication( - getStandaloneBootstrapFn(SimpleApp, [provideClientHydration()]), - {document: doc}, - ); - - // HttpClient cache and DOM hydration are enabled by default. - expect(output).toContain(``); - }, - ); - - it( - 'should handle false values on attributes' + `(standalone:${isStandalone})`, - async () => { - const options = {document: doc}; - const bootstrap = isStandalone - ? renderApplication(MyHostComponentStandalone, options) - : renderModule(FalseAttributesModule, options); + ? renderApplication(SVGComponentStandalone, {...options}) + : renderModule(SVGServerModule, options); const output = await bootstrap; expect(output).toBe( '' + - 'Works!', + '
', ); - }, - ); + }); - it('should handle element property "name"' + `(standalone:${isStandalone})`, async () => { - const options = {document: doc}; - const bootstrap = isStandalone - ? renderApplication(MyInputComponentStandalone, options) - : renderModule(NameModule, options); - const output = await bootstrap; - expect(output).toBe( - '' + - '', + it( + 'works with animation' + `(standalone:${isStandalone}, zoneless:${zoneless}`, + async () => { + const options = {document: doc}; + const bootstrap = isStandalone + ? renderApplication(MyAnimationAppStandalone, options) + : renderModule(AnimationServerModule, options); + const output = await bootstrap; + expect(output).toContain('Works!'); + expect(output).toContain('ng-trigger-myAnimation'); + expect(output).toContain('opacity: 1;'); + expect(output).toContain('transform: translate3d(0, 0, 0);'); + expect(output).toContain('font-weight: bold;'); + }, ); - }); - it( - 'should work with sanitizer to handle "innerHTML"' + `(standalone:${isStandalone})`, - async () => { - // Clear out any global states. These should be set when platform-server - // is initialized. - (global as any).Node = undefined; - (global as any).Document = undefined; - const options = {document: doc}; - const bootstrap = isStandalone - ? renderApplication(HTMLTypesAppStandalone, options) - : renderModule(HTMLTypesModule, options); - const output = await bootstrap; - expect(output).toBe( - '' + - '
foo bar
', - ); - }, - ); - - it('should handle element property "hidden"' + `(standalone:${isStandalone})`, async () => { - const options = {document: doc}; - const bootstrap = isStandalone - ? renderApplication(MyHiddenComponentStandalone, options) - : renderModule(HiddenModule, options); - const output = await bootstrap; - expect(output).toBe( - '' + - '', + it( + 'should handle ViewEncapsulation.ShadowDom' + + `(standalone:${isStandalone}, zoneless:${zoneless})`, + async () => { + const options = {document: doc}; + const bootstrap = isStandalone + ? renderApplication(ShadowDomEncapsulationAppStandalone, options) + : renderModule(ShadowDomExampleModule, options); + const output = await bootstrap; + expect(output).not.toBe(''); + expect(output).toContain('color: red'); + }, ); - }); - it('should call render hook' + `(standalone:${isStandalone})`, async () => { - const options = {document: doc}; - const bootstrap = isStandalone - ? renderApplication( - getStandaloneBootstrapFn(MyServerAppStandalone, RenderHookProviders), - options, - ) - : renderModule(RenderHookModule, options); - const output = await bootstrap; - // title should be added by the render hook. - expect(output).toBe( - 'RenderHook' + - 'Works!', - ); - }); - - it('should call multiple render hooks' + `(standalone:${isStandalone})`, async () => { - const consoleSpy = spyOn(console, 'warn'); - const options = {document: doc}; - const bootstrap = isStandalone - ? renderApplication( - getStandaloneBootstrapFn(MyServerAppStandalone, MultiRenderHookProviders), - options, - ) - : renderModule(MultiRenderHookModule, options); - const output = await bootstrap; - // title should be added by the render hook. - expect(output).toBe( - 'RenderHook' + - 'Works!', - ); - expect(consoleSpy).toHaveBeenCalled(); - }); - - it('should call async render hooks' + `(standalone:${isStandalone})`, async () => { - const options = {document: doc}; - const bootstrap = isStandalone - ? renderApplication( - getStandaloneBootstrapFn(MyServerAppStandalone, AsyncRenderHookProviders), - options, - ) - : renderModule(AsyncRenderHookModule, options); - const output = await bootstrap; - // title should be added by the render hook. - expect(output).toBe( - 'AsyncRenderHook' + - 'Works!', - ); - }); - - it( - 'should call multiple async and sync render hooks' + `(standalone:${isStandalone})`, - async () => { - const consoleSpy = spyOn(console, 'warn'); - const options = {document: doc}; - const bootstrap = isStandalone - ? renderApplication( - getStandaloneBootstrapFn(MyServerAppStandalone, AsyncMultiRenderHookProviders), - options, - ) - : renderModule(AsyncMultiRenderHookModule, options); - const output = await bootstrap; - // title should be added by the render hook. - expect(output).toBe( - 'AsyncRenderHook' + - 'Works!', - ); - expect(consoleSpy).toHaveBeenCalled(); - }, - ); - - it( - `should wait for InitialRenderPendingTasks before serializing ` + - `(standalone: ${isStandalone})`, - async () => { - const options = {document: doc}; - const bootstrap = isStandalone - ? renderApplication( - getStandaloneBootstrapFn(PendingTasksAppStandalone, [ - provideZoneChangeDetection(), - ]), - options, - ) - : renderModule(PendingTasksAppModule, options); - const output = await bootstrap; - expect(output).toBe( - '' + - 'Completed: Yes' + - '', - ); - }, - ); - - it( - `should call onOnDestroy of a service after a successful render` + - `(standalone: ${isStandalone})`, - async () => { - let wasServiceNgOnDestroyCalled = false; - - @Injectable({providedIn: 'root'}) - class DestroyableService { - ngOnDestroy() { - wasServiceNgOnDestroyCalled = true; - } - } - - const SuccessfulAppInitializerProviders = [ - provideZoneChangeDetection(), - { - provide: APP_INITIALIZER, - useFactory: () => { - inject(DestroyableService); - return () => Promise.resolve(); // Success in APP_INITIALIZER + it( + 'adds the `ng-server-context` attribute to host elements' + + `(standalone:${isStandalone}, zoneless:${zoneless})`, + async () => { + const options = { + document: doc, + }; + const providers = [ + { + provide: SERVER_CONTEXT, + useValue: 'ssg', }, - multi: true, - }, - ]; + ]; + const bootstrap = isStandalone + ? renderApplication(MyStylesAppStandalone, { + ...options, + platformProviders: providers, + }) + : renderModule(ExampleStylesModule, { + ...options, + extraProviders: providers, + }); + const output = await bootstrap; + expect(output).toMatch( + //, + ); + }, + ); - @NgModule({ - providers: SuccessfulAppInitializerProviders, - imports: [MyServerAppModule, ServerModule], - bootstrap: [MyServerApp], - }) - class ServerSuccessfulAppInitializerModule {} - - const ServerSuccessfulAppInitializerAppStandalone = getStandaloneBootstrapFn( - createMyServerApp(true), - SuccessfulAppInitializerProviders, - ); - - const options = {document: doc}; - const bootstrap = isStandalone - ? renderApplication(ServerSuccessfulAppInitializerAppStandalone, options) - : renderModule(ServerSuccessfulAppInitializerModule, options); - await bootstrap; - - expect(getPlatform()).withContext('PlatformRef should be destroyed').toBeNull(); - expect(wasServiceNgOnDestroyCalled) - .withContext('DestroyableService.ngOnDestroy() should be called') - .toBeTrue(); - }, - ); - - it( - `should call onOnDestroy of a service after some APP_INITIALIZER fails ` + - `(standalone: ${isStandalone})`, - async () => { - let wasServiceNgOnDestroyCalled = false; - - @Injectable({providedIn: 'root'}) - class DestroyableService { - ngOnDestroy() { - wasServiceNgOnDestroyCalled = true; - } - } - - const FailingAppInitializerProviders = [ - { - provide: APP_INITIALIZER, - useFactory: () => { - inject(DestroyableService); - return () => Promise.reject('Error in APP_INITIALIZER'); + it( + 'sanitizes the `serverContext` value' + + `(standalone:${isStandalone}, zoneless:${zoneless})`, + async () => { + const options = { + document: doc, + }; + const providers = [ + { + provide: SERVER_CONTEXT, + useValue: '!!!Some extra chars&& -->`); + }, + ); - @Injectable({providedIn: 'root'}) - class DestroyableService { - ngOnDestroy() { - wasServiceNgOnDestroyCalled = true; + it( + 'should handle false values on attributes' + + `(standalone:${isStandalone}, zoneless:${zoneless})`, + async () => { + const options = {document: doc}; + const bootstrap = isStandalone + ? renderApplication(MyHostComponentStandalone, options) + : renderModule(FalseAttributesModule, options); + const output = await bootstrap; + expect(output).toBe( + '' + + 'Works!', + ); + }, + ); + + it( + 'should handle element property "name"' + + `(standalone:${isStandalone}, zoneless:${zoneless})`, + async () => { + const options = {document: doc}; + const bootstrap = isStandalone + ? renderApplication(MyInputComponentStandalone, options) + : renderModule(NameModule, options); + const output = await bootstrap; + expect(output).toBe( + '' + + '', + ); + }, + ); + + it( + 'should work with sanitizer to handle "innerHTML"' + + `(standalone:${isStandalone}, zoneless:${zoneless})`, + async () => { + // Clear out any global states. These should be set when platform-server + // is initialized. + (global as any).Node = undefined; + (global as any).Document = undefined; + const options = {document: doc}; + const bootstrap = isStandalone + ? renderApplication(HTMLTypesAppStandalone, options) + : renderModule(HTMLTypesModule, options); + const output = await bootstrap; + expect(output).toBe( + '' + + '
foo bar
', + ); + }, + ); + + it( + 'should handle element property "hidden"' + + `(standalone:${isStandalone}, zoneless:${zoneless})`, + async () => { + const options = {document: doc}; + const bootstrap = isStandalone + ? renderApplication(MyHiddenComponentStandalone, options) + : renderModule(HiddenModule, options); + const output = await bootstrap; + expect(output).toBe( + '' + + '', + ); + }, + ); + + it( + 'should call render hook' + `(standalone:${isStandalone}, zoneless:${zoneless})`, + async () => { + const options = {document: doc}; + const bootstrap = isStandalone + ? renderApplication( + getStandaloneBootstrapFn(MyServerAppStandalone, RenderHookProviders), + options, + ) + : renderModule(RenderHookModule, options); + const output = await bootstrap; + // title should be added by the render hook. + expect(output).toBe( + 'RenderHook' + + 'Works!', + ); + }, + ); + + it( + 'should call multiple render hooks' + + `(standalone:${isStandalone}, zoneless:${zoneless})`, + async () => { + const consoleSpy = spyOn(console, 'warn'); + const options = {document: doc}; + const bootstrap = isStandalone + ? renderApplication( + getStandaloneBootstrapFn(MyServerAppStandalone, MultiRenderHookProviders), + options, + ) + : renderModule(MultiRenderHookModule, options); + const output = await bootstrap; + // title should be added by the render hook. + expect(output).toBe( + 'RenderHook' + + 'Works!', + ); + expect(consoleSpy).toHaveBeenCalled(); + }, + ); + + it( + 'should call async render hooks' + `(standalone:${isStandalone}, zoneless:${zoneless})`, + async () => { + const options = {document: doc}; + const bootstrap = isStandalone + ? renderApplication( + getStandaloneBootstrapFn(MyServerAppStandalone, AsyncRenderHookProviders), + options, + ) + : renderModule(AsyncRenderHookModule, options); + const output = await bootstrap; + // title should be added by the render hook. + expect(output).toBe( + 'AsyncRenderHook' + + 'Works!', + ); + }, + ); + + it( + 'should call multiple async and sync render hooks' + + `(standalone:${isStandalone}, zoneless:${zoneless})`, + async () => { + const consoleSpy = spyOn(console, 'warn'); + const options = {document: doc}; + const bootstrap = isStandalone + ? renderApplication( + getStandaloneBootstrapFn(MyServerAppStandalone, AsyncMultiRenderHookProviders), + options, + ) + : renderModule(AsyncMultiRenderHookModule, options); + const output = await bootstrap; + // title should be added by the render hook. + expect(output).toBe( + 'AsyncRenderHook' + + 'Works!', + ); + expect(consoleSpy).toHaveBeenCalled(); + }, + ); + + it( + `should wait for InitialRenderPendingTasks before serializing ` + + `(standalone:${isStandalone}, zoneless:${zoneless})`, + async () => { + const options = {document: doc}; + const bootstrap = isStandalone + ? renderApplication( + getStandaloneBootstrapFn(PendingTasksAppStandalone, []), + options, + ) + : renderModule(PendingTasksAppModule, options); + const output = await bootstrap; + expect(output).toBe( + '' + + 'Completed: Yes' + + '', + ); + }, + ); + + it( + `should call onOnDestroy of a service after a successful render` + + `(standalone:${isStandalone}, zoneless:${zoneless})`, + async () => { + let wasServiceNgOnDestroyCalled = false; + + @Injectable({providedIn: 'root'}) + class DestroyableService { + ngOnDestroy() { + wasServiceNgOnDestroyCalled = true; + } } - } - @Component({ - standalone: isStandalone, - selector: 'app', - template: `Works!`, - }) - class MyServerFailingConstructorApp { - constructor() { - inject(DestroyableService); - throw 'Error in constructor of the root component'; + const SuccessfulAppInitializerProviders = [ + { + provide: APP_INITIALIZER, + useFactory: () => { + inject(DestroyableService); + return () => Promise.resolve(); // Success in APP_INITIALIZER + }, + multi: true, + }, + ]; + + @NgModule({ + providers: SuccessfulAppInitializerProviders, + imports: [MyServerAppModule, ServerModule], + bootstrap: [MyServerApp], + }) + class ServerSuccessfulAppInitializerModule {} + + const ServerSuccessfulAppInitializerAppStandalone = getStandaloneBootstrapFn( + createMyServerApp(true), + SuccessfulAppInitializerProviders, + ); + + const options = {document: doc}; + const bootstrap = isStandalone + ? renderApplication(ServerSuccessfulAppInitializerAppStandalone, options) + : renderModule(ServerSuccessfulAppInitializerModule, options); + await bootstrap; + + expect(getPlatform()).withContext('PlatformRef should be destroyed').toBeNull(); + expect(wasServiceNgOnDestroyCalled) + .withContext('DestroyableService.ngOnDestroy() should be called') + .toBeTrue(); + }, + ); + + it( + `should call onOnDestroy of a service after some APP_INITIALIZER fails ` + + `(standalone:${isStandalone}, zoneless:${zoneless})`, + async () => { + let wasServiceNgOnDestroyCalled = false; + + @Injectable({providedIn: 'root'}) + class DestroyableService { + ngOnDestroy() { + wasServiceNgOnDestroyCalled = true; + } } - } - @NgModule({ - declarations: [MyServerFailingConstructorApp], - imports: [MyServerAppModule, ServerModule], - bootstrap: [MyServerFailingConstructorApp], - }) - class MyServerFailingConstructorAppModule {} + const FailingAppInitializerProviders = [ + { + provide: APP_INITIALIZER, + useFactory: () => { + inject(DestroyableService); + return () => Promise.reject('Error in APP_INITIALIZER'); + }, + multi: true, + }, + ]; - const MyServerFailingConstructorAppStandalone = getStandaloneBootstrapFn( - MyServerFailingConstructorApp, - ); - const options = {document: doc}; - const bootstrap = isStandalone - ? renderApplication(MyServerFailingConstructorAppStandalone, options) - : renderModule(MyServerFailingConstructorAppModule, options); - await expectAsync(bootstrap).toBeRejectedWith( - 'Error in constructor of the root component', - ); - expect(getPlatform()).withContext('PlatformRef should be destroyed').toBeNull(); - expect(wasServiceNgOnDestroyCalled) - .withContext('DestroyableService.ngOnDestroy() should be called') - .toBeTrue(); - }, - ); + @NgModule({ + providers: FailingAppInitializerProviders, + imports: [MyServerAppModule, ServerModule], + bootstrap: [MyServerApp], + }) + class ServerFailingAppInitializerModule {} + + const ServerFailingAppInitializerAppStandalone = getStandaloneBootstrapFn( + createMyServerApp(true), + FailingAppInitializerProviders, + ); + + const options = {document: doc}; + const bootstrap = isStandalone + ? renderApplication(ServerFailingAppInitializerAppStandalone, options) + : renderModule(ServerFailingAppInitializerModule, options); + await expectAsync(bootstrap).toBeRejectedWith('Error in APP_INITIALIZER'); + + expect(getPlatform()).withContext('PlatformRef should be destroyed').toBeNull(); + expect(wasServiceNgOnDestroyCalled) + .withContext('DestroyableService.ngOnDestroy() should be called') + .toBeTrue(); + }, + ); + + it( + `should call onOnDestroy of a service after an error happens in a root component's constructor ` + + `(standalone:${isStandalone}, zoneless:${zoneless})`, + async () => { + let wasServiceNgOnDestroyCalled = false; + + @Injectable({providedIn: 'root'}) + class DestroyableService { + ngOnDestroy() { + wasServiceNgOnDestroyCalled = true; + } + } + + @Component({ + standalone: isStandalone, + selector: 'app', + template: `Works!`, + }) + class MyServerFailingConstructorApp { + constructor() { + inject(DestroyableService); + throw 'Error in constructor of the root component'; + } + } + + @NgModule({ + declarations: [MyServerFailingConstructorApp], + imports: [MyServerAppModule, ServerModule], + bootstrap: [MyServerFailingConstructorApp], + }) + class MyServerFailingConstructorAppModule {} + + const MyServerFailingConstructorAppStandalone = getStandaloneBootstrapFn( + MyServerFailingConstructorApp, + ); + const options = {document: doc}; + const bootstrap = isStandalone + ? renderApplication(MyServerFailingConstructorAppStandalone, options) + : renderModule(MyServerFailingConstructorAppModule, options); + await expectAsync(bootstrap).toBeRejectedWith( + 'Error in constructor of the root component', + ); + expect(getPlatform()).withContext('PlatformRef should be destroyed').toBeNull(); + expect(wasServiceNgOnDestroyCalled) + .withContext('DestroyableService.ngOnDestroy() should be called') + .toBeTrue(); + }, + ); + }); }); });