Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/core_private_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export {provideHydrationSupport as ɵprovideHydrationSupport} from './hydration/
export {IS_HYDRATION_FEATURE_ENABLED as ɵIS_HYDRATION_FEATURE_ENABLED} from './hydration/tokens';
export {CurrencyIndex as ɵCurrencyIndex, ExtraLocaleDataIndex as ɵExtraLocaleDataIndex, findLocaleData as ɵfindLocaleData, getLocaleCurrencyCode as ɵgetLocaleCurrencyCode, getLocalePluralCase as ɵgetLocalePluralCase, LocaleDataIndex as ɵLocaleDataIndex, registerLocaleData as ɵregisterLocaleData, unregisterAllLocaleData as ɵunregisterLocaleData} from './i18n/locale_data_api';
export {DEFAULT_LOCALE_ID as ɵDEFAULT_LOCALE_ID} from './i18n/localization';
export {InitialRenderPendingTasks as ɵInitialRenderPendingTasks} from './initial_render_pending_tasks';
export {ComponentFactory as ɵComponentFactory} from './linker/component_factory';
export {clearResolutionOfComponentResourcesQueue as ɵclearResolutionOfComponentResourcesQueue, resolveComponentResources as ɵresolveComponentResources} from './metadata/resource_loading';
export {ReflectionCapabilities as ɵReflectionCapabilities} from './reflection/reflection_capabilities';
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/hydration/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {APP_BOOTSTRAP_LISTENER, ApplicationRef} from '../application_ref';
import {PLATFORM_ID} from '../application_tokens';
import {ENVIRONMENT_INITIALIZER, EnvironmentProviders, makeEnvironmentProviders} from '../di';
import {inject} from '../di/injector_compatibility';
import {InitialRenderPendingTasks} from '../initial_render_pending_tasks';
import {enableLocateOrCreateContainerRefImpl} from '../linker/view_container_ref';
import {enableLocateOrCreateElementNodeImpl} from '../render3/instructions/element';
import {enableLocateOrCreateElementContainerNodeImpl} from '../render3/instructions/element_container';
Expand Down Expand Up @@ -137,7 +138,8 @@ export function provideHydrationSupport(): EnvironmentProviders {
useFactory: () => {
if (isBrowser()) {
const appRef = inject(ApplicationRef);
return () => cleanupDehydratedViews(appRef);
const pendingTasks = inject(InitialRenderPendingTasks);
return () => cleanupDehydratedViews(appRef, pendingTasks);
}
return () => {}; // noop
},
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/hydration/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import {first} from 'rxjs/operators';

import {ApplicationRef} from '../application_ref';
import {InitialRenderPendingTasks} from '../initial_render_pending_tasks';
import {CONTAINER_HEADER_OFFSET, DEHYDRATED_VIEWS, LContainer} from '../render3/interfaces/container';
import {Renderer} from '../render3/interfaces/renderer';
import {RNode} from '../render3/interfaces/renderer_dom';
Expand Down Expand Up @@ -91,11 +92,14 @@ function cleanupLView(lView: LView) {
* Walks over all views registered within the ApplicationRef and removes
* all dehydrated views from all `LContainer`s along the way.
*/
export function cleanupDehydratedViews(appRef: ApplicationRef) {
export function cleanupDehydratedViews(
appRef: ApplicationRef, pendingTasks: InitialRenderPendingTasks) {
// Wait once an app becomes stable and cleanup all views that
// were not claimed during the application bootstrap process.
// The timing is similar to when we kick off serialization on the server.
return appRef.isStable.pipe(first((isStable: boolean) => isStable)).toPromise().then(() => {
const isStablePromise = appRef.isStable.pipe(first((isStable: boolean) => isStable)).toPromise();
const pendingTasksPromise = pendingTasks.whenAllTasksComplete;
return Promise.allSettled([isStablePromise, pendingTasksPromise]).then(() => {
const viewRefs = appRef._views;
for (const viewRef of viewRefs) {
const lView = getComponentLViewForHydration(viewRef);
Expand Down
79 changes: 79 additions & 0 deletions packages/core/src/initial_render_pending_tasks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {Injectable} from './di';
import {inject} from './di/injector_compatibility';
import {OnDestroy} from './interface/lifecycle_hooks';
import {NgZone} from './zone/ng_zone';

/**
* *Internal* service that keeps track of pending tasks happening in the system
* during the initial rendering. No tasks are tracked after an initial
* rendering.
*
* This information is needed to make sure that the serialization on the server
* is delayed until all tasks in the queue (such as an initial navigation or a
* pending HTTP request) are completed.
*/
@Injectable({providedIn: 'root'})
export class InitialRenderPendingTasks implements OnDestroy {
private taskId = 0;
private collection = new Set<number>();
private ngZone = inject(NgZone);

private resolve!: VoidFunction;
private promise!: Promise<void>;

get whenAllTasksComplete(): Promise<void> {
if (this.collection.size > 0) {
return this.promise;
}
return Promise.resolve().then(() => {
this.completed = true;
});
}

completed = false;

constructor() {
// Run outside of the Angular zone to avoid triggering
// extra change detection cycles.
this.ngZone.runOutsideAngular(() => {
this.promise = new Promise<void>((resolve) => {
this.resolve = resolve;
});
});
}

add(): number {
if (this.completed) {
// Indicates that the task was added after
// the task queue completion, so it's a noop.
return -1;
}
const taskId = this.taskId++;
this.collection.add(taskId);
return taskId;
}

remove(taskId: number) {
if (this.completed) return;

this.collection.delete(taskId);
if (this.collection.size === 0) {
// We've removed the last task, resolve the promise.
this.completed = true;
this.resolve();
}
}

ngOnDestroy() {
this.completed = true;
this.collection.clear();
}
}
64 changes: 64 additions & 0 deletions packages/core/test/acceptance/initial_render_pending_tasks_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {TestBed} from '@angular/core/testing';

import {InitialRenderPendingTasks} from '../../src/initial_render_pending_tasks';

describe('InitialRenderPendingTasks', () => {
it('should resolve a promise even if there are no tasks', async () => {
const pendingTasks = TestBed.inject(InitialRenderPendingTasks);
expect(pendingTasks.completed).toBe(false);
await pendingTasks.whenAllTasksComplete;
expect(pendingTasks.completed).toBe(true);
});

it('should wait until all tasks are completed', async () => {
const pendingTasks = TestBed.inject(InitialRenderPendingTasks);
expect(pendingTasks.completed).toBe(false);

const taskA = pendingTasks.add();
const taskB = pendingTasks.add();
const taskC = pendingTasks.add();
expect(pendingTasks.completed).toBe(false);

pendingTasks.remove(taskA);
pendingTasks.remove(taskB);
pendingTasks.remove(taskC);
await pendingTasks.whenAllTasksComplete;
expect(pendingTasks.completed).toBe(true);
});

it('should allow calls to remove the same task multiple times', async () => {
const pendingTasks = TestBed.inject(InitialRenderPendingTasks);
expect(pendingTasks.completed).toBe(false);

const taskA = pendingTasks.add();

expect(pendingTasks.completed).toBe(false);

pendingTasks.remove(taskA);
pendingTasks.remove(taskA);
pendingTasks.remove(taskA);

await pendingTasks.whenAllTasksComplete;
expect(pendingTasks.completed).toBe(true);
});

it('should be tolerant to removal of non-existent ids', async () => {
const pendingTasks = TestBed.inject(InitialRenderPendingTasks);
expect(pendingTasks.completed).toBe(false);

pendingTasks.remove(Math.random());
pendingTasks.remove(Math.random());
pendingTasks.remove(Math.random());

await pendingTasks.whenAllTasksComplete;
expect(pendingTasks.completed).toBe(true);
});
});
6 changes: 6 additions & 0 deletions packages/core/test/bundling/router/bundle.golden_symbols.json
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,9 @@
{
"name": "INTERNAL_BROWSER_PLATFORM_PROVIDERS"
},
{
"name": "InitialRenderPendingTasks"
},
{
"name": "InjectFlags"
},
Expand Down Expand Up @@ -437,6 +440,9 @@
{
"name": "NavigationError"
},
{
"name": "NavigationResult"
},
{
"name": "NavigationSkipped"
},
Expand Down
98 changes: 50 additions & 48 deletions packages/platform-server/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, ɵ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, ɵ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';
Expand Down Expand Up @@ -53,56 +53,58 @@ function _render<T>(
environmentInjector.get(ApplicationRef);
const serverContext =
sanitizeServerContext(environmentInjector.get(SERVER_CONTEXT, DEFAULT_SERVER_CONTEXT));
return applicationRef.isStable.pipe((first((isStable: boolean) => isStable)))
.toPromise()
.then(() => {
appendServerContextInfo(serverContext, applicationRef);

const platformState = platform.injector.get(PlatformState);

const asyncPromises: Promise<any>[] = [];

if (applicationRef.injector.get(IS_HYDRATION_FEATURE_ENABLED, false)) {
annotateForHydration(applicationRef, platformState.getDocument());
}

// Run any BEFORE_APP_SERIALIZED callbacks just before rendering to string.
const callbacks = environmentInjector.get(BEFORE_APP_SERIALIZED, null);

if (callbacks) {
for (const callback of callbacks) {
try {
const callbackResult = callback();
if (ɵisPromise(callbackResult)) {
// TODO: in TS3.7, callbackResult is void.
asyncPromises.push(callbackResult as any);
}

} catch (e) {
// Ignore exceptions.
console.warn('Ignoring BEFORE_APP_SERIALIZED Exception: ', e);
}
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>[] = [];

if (applicationRef.injector.get(IS_HYDRATION_FEATURE_ENABLED, false)) {
annotateForHydration(applicationRef, platformState.getDocument());
}

// Run any BEFORE_APP_SERIALIZED callbacks just before rendering to string.
const callbacks = environmentInjector.get(BEFORE_APP_SERIALIZED, null);

if (callbacks) {
for (const callback of callbacks) {
try {
const callbackResult = callback();
if (ɵisPromise(callbackResult)) {
// TODO: in TS3.7, callbackResult is void.
asyncPromises.push(callbackResult as any);
}
}

const complete = () => {
const output = platformState.renderToString();
platform.destroy();
return output;
};

if (asyncPromises.length === 0) {
return complete();
} catch (e) {
// Ignore exceptions.
console.warn('Ignoring BEFORE_APP_SERIALIZED Exception: ', e);
}

return Promise
.all(asyncPromises.map(asyncPromise => {
return asyncPromise.catch(e => {
console.warn('Ignoring BEFORE_APP_SERIALIZED Exception: ', e);
});
}))
.then(complete);
});
}
}

const complete = () => {
const output = platformState.renderToString();
platform.destroy();
return output;
};

if (asyncPromises.length === 0) {
return complete();
}

return Promise
.all(asyncPromises.map(asyncPromise => {
return asyncPromise.catch(e => {
console.warn('Ignoring BEFORE_APP_SERIALIZED Exception: ', e);
});
}))
.then(complete);
});
});
}

Expand Down
2 changes: 2 additions & 0 deletions packages/platform-server/test/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ ts_library(
"//packages/common",
"//packages/common/http",
"//packages/common/http/testing",
"//packages/common/testing",
"//packages/compiler",
"//packages/core",
"//packages/core/testing",
"//packages/localize/init",
"//packages/platform-browser",
"//packages/platform-server",
"//packages/private/testing",
"//packages/router",
"@npm//rxjs",
],
)
Expand Down
Loading