diff --git a/packages/common/http/src/transfer_cache.ts b/packages/common/http/src/transfer_cache.ts index c011d1bc772..8b9cd792b2c 100644 --- a/packages/common/http/src/transfer_cache.ts +++ b/packages/common/http/src/transfer_cache.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {APP_BOOTSTRAP_LISTENER, ApplicationRef, inject, InjectionToken, makeStateKey, Provider, StateKey, TransferState, ɵInitialRenderPendingTasks as InitialRenderPendingTasks} from '@angular/core'; +import {APP_BOOTSTRAP_LISTENER, ApplicationRef, inject, InjectionToken, makeStateKey, Provider, StateKey, TransferState, ɵENABLED_SSR_FEATURES as ENABLED_SSR_FEATURES, ɵInitialRenderPendingTasks as InitialRenderPendingTasks} from '@angular/core'; import {Observable, of} from 'rxjs'; import {first, tap} from 'rxjs/operators'; @@ -149,6 +149,7 @@ export function withHttpTransferCache(): Provider[] { { provide: CACHE_STATE, useFactory: () => { + inject(ENABLED_SSR_FEATURES).add('httpcache'); return {isCacheActive: true}; } }, diff --git a/packages/core/src/application_tokens.ts b/packages/core/src/application_tokens.ts index a430c45fd6f..4f5eb790df7 100644 --- a/packages/core/src/application_tokens.ts +++ b/packages/core/src/application_tokens.ts @@ -112,3 +112,15 @@ export const CSP_NONCE = new InjectionToken('CSP nonce', { return getDocument().body.querySelector('[ngCspNonce]')?.getAttribute('ngCspNonce') || null; }, }); + +/** + * Internal token to collect all SSR-related features enabled for this application. + * + * Note: the token is in `core` to let other packages register features (the `core` + * package is imported in other packages). + */ +export const ENABLED_SSR_FEATURES = new InjectionToken>( + (typeof ngDevMode === 'undefined' || ngDevMode) ? 'ENABLED_SSR_FEATURES' : '', { + providedIn: 'root', + factory: () => new Set(), + }); diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index 67dca9dafe1..d9813cb33c9 100644 --- a/packages/core/src/core_private_export.ts +++ b/packages/core/src/core_private_export.ts @@ -7,6 +7,7 @@ */ export {ALLOW_MULTIPLE_PLATFORMS as ɵALLOW_MULTIPLE_PLATFORMS, internalCreateApplication as ɵinternalCreateApplication} from './application_ref'; +export {ENABLED_SSR_FEATURES as ɵENABLED_SSR_FEATURES} from './application_tokens'; export {defaultIterableDiffers as ɵdefaultIterableDiffers, defaultKeyValueDiffers as ɵdefaultKeyValueDiffers} from './change_detection/change_detection'; export {Console as ɵConsole} from './console'; export {convertToBitFlags as ɵconvertToBitFlags, setCurrentInjector as ɵsetCurrentInjector} from './di/injector_compatibility'; diff --git a/packages/core/src/hydration/api.ts b/packages/core/src/hydration/api.ts index 264b19c4290..ae613d7d82c 100644 --- a/packages/core/src/hydration/api.ts +++ b/packages/core/src/hydration/api.ts @@ -9,7 +9,7 @@ import {first} from 'rxjs/operators'; import {APP_BOOTSTRAP_LISTENER, ApplicationRef} from '../application_ref'; -import {PLATFORM_ID} from '../application_tokens'; +import {ENABLED_SSR_FEATURES, PLATFORM_ID} from '../application_tokens'; import {Console} from '../console'; import {ENVIRONMENT_INITIALIZER, EnvironmentProviders, Injector, makeEnvironmentProviders} from '../di'; import {inject} from '../di/injector_compatibility'; @@ -109,12 +109,13 @@ export function withDomHydration(): EnvironmentProviders { { provide: IS_HYDRATION_FEATURE_ENABLED, useFactory: () => { + let isEnabled = true; if (isBrowser()) { // On the client, verify that the server response contains // hydration annotations. Otherwise, keep hydration disabled. const transferState = inject(TransferState, {optional: true}); - const hasHydrationAnnotations = !!transferState?.get(NGH_DATA_KEY, null); - if (!hasHydrationAnnotations) { + isEnabled = !!transferState?.get(NGH_DATA_KEY, null); + if (!isEnabled) { const console = inject(Console); const message = formatRuntimeError( RuntimeErrorCode.MISSING_HYDRATION_ANNOTATIONS, @@ -126,10 +127,11 @@ export function withDomHydration(): EnvironmentProviders { // tslint:disable-next-line:no-console console.warn(message); } - return hasHydrationAnnotations; } - // We are running on the server, always return `true`. - return true; + if (isEnabled) { + inject(ENABLED_SSR_FEATURES).add('hydration'); + } + return isEnabled; }, }, { diff --git a/packages/platform-server/src/utils.ts b/packages/platform-server/src/utils.ts index 4888d6fc6c4..cc64b22e290 100644 --- a/packages/platform-server/src/utils.ts +++ b/packages/platform-server/src/utils.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {ApplicationRef, InjectionToken, NgModuleRef, PlatformRef, Provider, Renderer2, StaticProvider, Type, ɵannotateForHydration as annotateForHydration, ɵInitialRenderPendingTasks as InitialRenderPendingTasks, ɵIS_HYDRATION_FEATURE_ENABLED as IS_HYDRATION_FEATURE_ENABLED, ɵisPromise} from '@angular/core'; +import {ApplicationRef, InjectionToken, NgModuleRef, PlatformRef, Provider, Renderer2, StaticProvider, Type, ɵannotateForHydration as annotateForHydration, ɵENABLED_SSR_FEATURES as ENABLED_SSR_FEATURES, ɵInitialRenderPendingTasks as InitialRenderPendingTasks, ɵIS_HYDRATION_FEATURE_ENABLED as IS_HYDRATION_FEATURE_ENABLED, ɵisPromise} from '@angular/core'; import {first} from 'rxjs/operators'; import {PlatformState} from './platform_state'; @@ -33,7 +33,14 @@ function _getPlatform( * Adds the `ng-server-context` attribute to host elements of all bootstrapped components * within a given application. */ -function appendServerContextInfo(serverContext: string, applicationRef: ApplicationRef) { +function appendServerContextInfo(applicationRef: ApplicationRef) { + const injector = applicationRef.injector; + let serverContext = sanitizeServerContext(injector.get(SERVER_CONTEXT, DEFAULT_SERVER_CONTEXT)); + const features = injector.get(ENABLED_SSR_FEATURES); + if (features.size > 0) { + // Append features information into the server context value. + serverContext += `|${Array.from(features).join(',')}`; + } applicationRef.components.forEach(componentRef => { const renderer = componentRef.injector.get(Renderer2); const element = componentRef.location.nativeElement; @@ -51,15 +58,11 @@ function _render( const applicationRef: ApplicationRef = moduleOrApplicationRef instanceof ApplicationRef ? moduleOrApplicationRef : environmentInjector.get(ApplicationRef); - const serverContext = - sanitizeServerContext(environmentInjector.get(SERVER_CONTEXT, DEFAULT_SERVER_CONTEXT)); const isStablePromise = applicationRef.isStable.pipe((first((isStable: boolean) => isStable))).toPromise(); const pendingTasks = environmentInjector.get(InitialRenderPendingTasks); const pendingTasksPromise = pendingTasks.whenAllTasksComplete; return Promise.allSettled([isStablePromise, pendingTasksPromise]).then(() => { - appendServerContextInfo(serverContext, applicationRef); - const platformState = platform.injector.get(PlatformState); const asyncPromises: Promise[] = []; @@ -88,6 +91,7 @@ function _render( } const complete = () => { + appendServerContextInfo(applicationRef); const output = platformState.renderToString(); platform.destroy(); return output; diff --git a/packages/platform-server/test/integration_spec.ts b/packages/platform-server/test/integration_spec.ts index 45177734156..4e8e8091ecf 100644 --- a/packages/platform-server/test/integration_spec.ts +++ b/packages/platform-server/test/integration_spec.ts @@ -13,7 +13,7 @@ import {HttpClientTestingModule, HttpTestingController} from '@angular/common/ht import {ApplicationConfig, ApplicationRef, Component, destroyPlatform, EnvironmentProviders, getPlatform, HostListener, Inject, inject as coreInject, Injectable, Input, makeStateKey, mergeApplicationConfig, NgModule, NgZone, PLATFORM_ID, Provider, TransferState, Type, ViewEncapsulation} from '@angular/core'; import {InitialRenderPendingTasks} from '@angular/core/src/initial_render_pending_tasks'; import {TestBed, waitForAsync} from '@angular/core/testing'; -import {bootstrapApplication, BrowserModule, Title} from '@angular/platform-browser'; +import {bootstrapApplication, BrowserModule, provideClientHydration, Title, withNoDomReuse, withNoHttpTransferCache} from '@angular/platform-browser'; import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, platformDynamicServer, PlatformState, provideServerRendering, renderModule, ServerModule} from '@angular/platform-server'; import {provideRouter, RouterOutlet, Routes} from '@angular/router'; import {Observable} from 'rxjs'; @@ -903,6 +903,90 @@ describe('platform-server integration', () => { }); })); + it('includes a set of features into `ng-server-context` attribute', waitForAsync(() => { + const options = { + document: doc, + }; + const providers = [{ + provide: SERVER_CONTEXT, + useValue: 'ssg', + }]; + @Component({ + standalone: true, + selector: 'app', + template: `
Works!
`, + }) + class SimpleApp { + } + + const bootstrap = renderApplication( + getStandaloneBoostrapFn(SimpleApp, [provideClientHydration()]), + {...options, platformProviders: providers}); + bootstrap.then(output => { + // HttpClient cache and DOM hydration are enabled by default. + expect(output).toMatch(/ng-server-context="ssg\|httpcache,hydration"/); + called = true; + }); + })); + + it('should include a set of features into `ng-server-context` attribute ' + + '(excluding disabled hydration feature)', + waitForAsync(() => { + const options = { + document: doc, + }; + const providers = [{ + provide: SERVER_CONTEXT, + useValue: 'ssg', + }]; + @Component({ + standalone: true, + selector: 'app', + template: `
Works!
`, + }) + class SimpleApp { + } + + const bootstrap = renderApplication( + getStandaloneBoostrapFn(SimpleApp, [provideClientHydration(withNoDomReuse())]), + {...options, platformProviders: providers}); + bootstrap.then(output => { + // Dom hydration is disabled, so it should not be included. + expect(output).toMatch(/ng-server-context="ssg\|httpcache"/); + called = true; + }); + })); + + it('should not include features into `ng-server-context` attribute ' + + 'when all features are disabled', + waitForAsync(() => { + const options = { + document: doc, + }; + const providers = [{ + provide: SERVER_CONTEXT, + useValue: 'ssg', + }]; + @Component({ + standalone: true, + selector: 'app', + template: `
Works!
`, + }) + class SimpleApp { + } + + const bootstrap = renderApplication( + getStandaloneBoostrapFn( + SimpleApp, + [provideClientHydration(withNoDomReuse(), withNoHttpTransferCache())]), + {...options, platformProviders: providers}); + bootstrap.then(output => { + // All features were disabled, so none of them are included. + expect(output).toMatch(/ng-server-context="ssg"/); + called = true; + }); + })); + it('should handle false values on attributes', waitForAsync(() => { const options = {document: doc}; const bootstrap = isStandalone ? renderApplication(MyHostComponentStandalone, options) :