refactor(platform-server): include info about enabled features into ng-server-context (#49773)

This commit updates the logic that adds the "ng-server-context" attribute to the root elements to also include information about SSR feature enabled got an application.

PR Close #49773
This commit is contained in:
Andrew Kushnir 2023-04-10 16:43:33 -07:00
parent 1da3e5f0bd
commit 7ee542d263
6 changed files with 118 additions and 14 deletions

View file

@ -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};
}
},

View file

@ -112,3 +112,15 @@ export const CSP_NONCE = new InjectionToken<string|null>('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<Set<string>>(
(typeof ngDevMode === 'undefined' || ngDevMode) ? 'ENABLED_SSR_FEATURES' : '', {
providedIn: 'root',
factory: () => new Set(),
});

View file

@ -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';

View file

@ -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;
},
},
{

View file

@ -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<T>(
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<any>[] = [];
@ -88,6 +91,7 @@ function _render<T>(
}
const complete = () => {
appendServerContextInfo(applicationRef);
const output = platformState.renderToString();
platform.destroy();
return output;

View file

@ -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: `<div>Works!</div>`,
})
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: `<div>Works!</div>`,
})
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: `<div>Works!</div>`,
})
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) :