feat(router): Add feature to support the View Transitions API (#51314)

The View Transitions API enables easy animations when transitioning between different DOM states. This commit adds an opt-in feature to the Router which runs the component activation and deactivation logic in the document.startViewTransition callback. If the browser does not support this API, route activation and deactivation will happen synchronously.

resolves #49401

PR Close #51314
This commit is contained in:
Andrew Scott
2023-09-01 06:51:17 -07:00
committed by Andrew Kushnir
parent ac56efa410
commit 73e4bf2ed2
12 changed files with 190 additions and 9 deletions
+8 -1
View File
@@ -296,6 +296,7 @@ export const enum EventType {
export interface ExtraOptions extends InMemoryScrollingOptions, RouterConfigOptions {
bindToComponentInputs?: boolean;
enableTracing?: boolean;
enableViewTransitions?: boolean;
// @deprecated
errorHandler?: (error: any) => any;
initialNavigation?: InitialNavigation;
@@ -771,7 +772,7 @@ export interface RouterFeature<FeatureKind extends RouterFeatureKind> {
}
// @public
export type RouterFeatures = PreloadingFeature | DebugTracingFeature | InitialNavigationFeature | InMemoryScrollingFeature | RouterConfigurationFeature | NavigationErrorHandlerFeature | ComponentInputBindingFeature;
export type RouterFeatures = PreloadingFeature | DebugTracingFeature | InitialNavigationFeature | InMemoryScrollingFeature | RouterConfigurationFeature | NavigationErrorHandlerFeature | ComponentInputBindingFeature | ViewTransitionsFeature;
// @public
export type RouterHashLocationFeature = RouterFeature<RouterFeatureKind.RouterHashLocationFeature>;
@@ -1086,6 +1087,9 @@ export class UrlTree {
// @public (undocumented)
export const VERSION: Version;
// @public
export type ViewTransitionsFeature = RouterFeature<RouterFeatureKind.ViewTransitionsFeature>;
// @public
export function withComponentInputBinding(): ComponentInputBindingFeature;
@@ -1113,6 +1117,9 @@ export function withPreloading(preloadingStrategy: Type<PreloadingStrategy>): Pr
// @public
export function withRouterConfig(options: RouterConfigOptions): RouterConfigurationFeature;
// @public
export function withViewTransitions(): ViewTransitionsFeature;
// (No @packageDocumentation comment for this package)
```
+2 -1
View File
@@ -84,6 +84,7 @@
"@types/chrome": "^0.0.243",
"@types/convert-source-map": "^2.0.0",
"@types/diff": "^5.0.0",
"@types/dom-view-transitions": "^1.0.1",
"@types/hammerjs": "2.0.41",
"@types/jasmine": "^4.0.0",
"@types/jasmine-ajax": "^3.3.1",
@@ -206,4 +207,4 @@
"**/https-proxy-agent": "7.0.1",
"**/saucelabs": "7.2.2"
}
}
}
@@ -74,6 +74,9 @@
{
"name": "CONTAINER_HEADER_OFFSET"
},
{
"name": "CREATE_VIEW_TRANSITION"
},
{
"name": "CSP_NONCE"
},
+1
View File
@@ -14,6 +14,7 @@ ng_module(
"//packages/common",
"//packages/core",
"//packages/platform-browser",
"@npm//@types/dom-view-transitions",
"@npm//rxjs",
],
)
+1 -1
View File
@@ -16,7 +16,7 @@ export {CanActivateChildFn, CanActivateFn, CanDeactivateFn, CanLoadFn, CanMatchF
export * from './models_deprecated';
export {Navigation, NavigationExtras, UrlCreationOptions} from './navigation_transition';
export {DefaultTitleStrategy, TitleStrategy} from './page_title_strategy';
export {DebugTracingFeature, DisabledInitialNavigationFeature, EnabledBlockingInitialNavigationFeature, InitialNavigationFeature, InMemoryScrollingFeature, NavigationErrorHandlerFeature, PreloadingFeature, provideRouter, provideRoutes, RouterConfigurationFeature, RouterFeature, RouterFeatures, RouterHashLocationFeature, withComponentInputBinding, withDebugTracing, withDisabledInitialNavigation, withEnabledBlockingInitialNavigation, withHashLocation, withInMemoryScrolling, withNavigationErrorHandler, withPreloading, withRouterConfig} from './provide_router';
export {DebugTracingFeature, DisabledInitialNavigationFeature, withViewTransitions, ViewTransitionsFeature, EnabledBlockingInitialNavigationFeature, InitialNavigationFeature, InMemoryScrollingFeature, NavigationErrorHandlerFeature, PreloadingFeature, provideRouter, provideRoutes, RouterConfigurationFeature, RouterFeature, RouterFeatures, RouterHashLocationFeature, withComponentInputBinding, withDebugTracing, withDisabledInitialNavigation, withEnabledBlockingInitialNavigation, withHashLocation, withInMemoryScrolling, withNavigationErrorHandler, withPreloading, withRouterConfig} from './provide_router';
export {BaseRouteReuseStrategy, DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy';
export {Router} from './router';
export {ExtraOptions, InitialNavigation, InMemoryScrollingOptions, ROUTER_CONFIGURATION, RouterConfigOptions} from './router_config';
+15 -1
View File
@@ -6,8 +6,9 @@
* found in the LICENSE file at https://angular.io/license
*/
import {EnvironmentInjector, inject, Injectable, Type} from '@angular/core';
import {BehaviorSubject, combineLatest, EMPTY, Observable, of, Subject} from 'rxjs';
import {BehaviorSubject, combineLatest, EMPTY, from, Observable, of, Subject} from 'rxjs';
import {catchError, defaultIfEmpty, filter, finalize, map, switchMap, take, takeUntil, tap} from 'rxjs/operators';
import {createRouterState} from './create_router_state';
@@ -30,6 +31,7 @@ import {Params} from './shared';
import {UrlHandlingStrategy} from './url_handling_strategy';
import {isUrlTree, UrlSerializer, UrlTree} from './url_tree';
import {Checks, getAllRouteGuards} from './utils/preactivation';
import {CREATE_VIEW_TRANSITION} from './utils/view_transition';
@@ -303,6 +305,7 @@ export class NavigationTransitions {
private readonly paramsInheritanceStrategy =
this.options.paramsInheritanceStrategy || 'emptyOnly';
private readonly urlHandlingStrategy = inject(UrlHandlingStrategy);
private readonly createViewTransition = inject(CREATE_VIEW_TRANSITION, {optional: true});
navigationId = 0;
get hasRequestedNavigation() {
@@ -609,6 +612,17 @@ export class NavigationTransitions {
switchTap(() => this.afterPreactivation()),
switchMap(() => {
const viewTransitionStarted =
this.createViewTransition?.(this.environmentInjector);
// If view transitions are enabled, block the navigation until the view
// transition callback starts. Otherwise, continue immediately.
return viewTransitionStarted ?
from(viewTransitionStarted).pipe(map(() => overallTransitionState)) :
of(overallTransitionState);
}),
map((t: NavigationTransition) => {
const targetRouterState = createRouterState(
router.routeReuseStrategy, t.targetSnapshot!, t.currentRouterState);
+47 -3
View File
@@ -22,6 +22,7 @@ import {ROUTER_SCROLLER, RouterScroller} from './router_scroller';
import {ActivatedRoute} from './router_state';
import {UrlSerializer} from './url_tree';
import {afterNextNavigation} from './utils/navigations';
import {CREATE_VIEW_TRANSITION, createViewTransition} from './utils/view_transition';
/**
@@ -661,6 +662,16 @@ export function withNavigationErrorHandler(fn: (error: NavigationError) => void)
export type ComponentInputBindingFeature =
RouterFeature<RouterFeatureKind.ComponentInputBindingFeature>;
/**
* A type alias for providers returned by `withViewTransitions` for use with `provideRouter`.
*
* @see {@link withViewTransitions}
* @see {@link provideRouter}
*
* @publicApi
*/
export type ViewTransitionsFeature = RouterFeature<RouterFeatureKind.ViewTransitionsFeature>;
/**
* Enables binding information from the `Router` state directly to the inputs of the component in
* `Route` configurations.
@@ -690,6 +701,38 @@ export function withComponentInputBinding(): ComponentInputBindingFeature {
return routerFeature(RouterFeatureKind.ComponentInputBindingFeature, providers);
}
/**
* Enables view transitions in the Router by running the route activation and deactivation inside of
* `document.startViewTransition`.
*
* Note: The View Transitions API is not available in all browsers. If the browser does not support
* view transitions, the Router will not attempt to start a view transition and continue processing
* the navigation as usual.
*
* @usageNotes
*
* Basic example of how you can enable the feature:
* ```
* const appRoutes: Routes = [];
* bootstrapApplication(AppComponent,
* {
* providers: [
* provideRouter(appRoutes, withViewTransitions())
* ]
* }
* );
* ```
*
* @returns A set of providers for use with `provideRouter`.
* @see https://developer.chrome.com/docs/web-platform/view-transitions/
* @see https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API
* @experimental
*/
export function withViewTransitions(): ViewTransitionsFeature {
const providers = [{provide: CREATE_VIEW_TRANSITION, useValue: createViewTransition}];
return routerFeature(RouterFeatureKind.ViewTransitionsFeature, providers);
}
/**
* A type alias that represents all Router features available for use with `provideRouter`.
* Features can be enabled by adding special functions to the `provideRouter` call.
@@ -700,9 +743,9 @@ export function withComponentInputBinding(): ComponentInputBindingFeature {
*
* @publicApi
*/
export type RouterFeatures =
PreloadingFeature|DebugTracingFeature|InitialNavigationFeature|InMemoryScrollingFeature|
RouterConfigurationFeature|NavigationErrorHandlerFeature|ComponentInputBindingFeature;
export type RouterFeatures = PreloadingFeature|DebugTracingFeature|InitialNavigationFeature|
InMemoryScrollingFeature|RouterConfigurationFeature|NavigationErrorHandlerFeature|
ComponentInputBindingFeature|ViewTransitionsFeature;
/**
* The list of features as an enum to uniquely type each feature.
@@ -717,4 +760,5 @@ export const enum RouterFeatureKind {
RouterHashLocationFeature,
NavigationErrorHandlerFeature,
ComponentInputBindingFeature,
ViewTransitionsFeature,
}
+10
View File
@@ -203,6 +203,16 @@ export interface ExtraOptions extends InMemoryScrollingOptions, RouterConfigOpti
*/
bindToComponentInputs?: boolean;
/**
* When true, enables view transitions in the Router by running the route activation and
* deactivation inside of `document.startViewTransition`.
*
* @see https://developer.chrome.com/docs/web-platform/view-transitions/
* @see https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API
* @experimental
*/
enableViewTransitions?: boolean;
/**
* A custom error handler for failed navigations.
* If the handler returns a value, the navigation Promise is resolved with this value.
+2 -1
View File
@@ -16,7 +16,7 @@ import {RouterOutlet} from './directives/router_outlet';
import {RuntimeErrorCode} from './errors';
import {Routes} from './models';
import {NavigationTransitions} from './navigation_transition';
import {getBootstrapListener, rootRoute, ROUTER_IS_PROVIDED, withComponentInputBinding, withDebugTracing, withDisabledInitialNavigation, withEnabledBlockingInitialNavigation, withPreloading} from './provide_router';
import {getBootstrapListener, rootRoute, ROUTER_IS_PROVIDED, withComponentInputBinding, withDebugTracing, withDisabledInitialNavigation, withEnabledBlockingInitialNavigation, withPreloading, withViewTransitions} from './provide_router';
import {Router} from './router';
import {ExtraOptions, ROUTER_CONFIGURATION} from './router_config';
import {RouterConfigLoader, ROUTES} from './router_config_loader';
@@ -126,6 +126,7 @@ export class RouterModule {
{provide: NgProbeToken, multi: true, useFactory: routerNgProbeToken},
config?.initialNavigation ? provideInitialNavigation(config) : [],
config?.bindToComponentInputs ? withComponentInputBinding().ɵproviders : [],
config?.enableViewTransitions ? withViewTransitions().ɵproviders : [],
provideRouterInitializer(),
],
};
@@ -0,0 +1,48 @@
/**
* @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
*/
/// <reference types="@types/dom-view-transitions" />
import {afterNextRender, InjectionToken, Injector, NgZone} from '@angular/core';
export const CREATE_VIEW_TRANSITION =
new InjectionToken<typeof createViewTransition>(ngDevMode ? 'view transition helper' : '');
/**
* A helper function for using browser view transitions. This function skips the call to
* `startViewTransition` if the browser does not support it.
*
* @returns A Promise that resolves when the view transition callback begins.
*/
export function createViewTransition(injector: Injector): Promise<void> {
// Create promises outside the Angular zone to avoid causing extra change detections
return injector.get(NgZone).runOutsideAngular(() => {
if (!document.startViewTransition) {
return Promise.resolve();
}
let resolveViewTransitionStarted: () => void;
const viewTransitionStarted = new Promise<void>((resolve) => {
resolveViewTransitionStarted = resolve;
});
document.startViewTransition(() => {
resolveViewTransitionStarted();
return createRenderPromise(injector);
});
return viewTransitionStarted;
});
}
/**
* Creates a promise that resolves after next render.
*/
function createRenderPromise(injector: Injector) {
return new Promise<void>(resolve => {
afterNextRender(resolve, {injector});
});
}
+48 -1
View File
@@ -14,7 +14,7 @@ import {ApplicationRef, Component, CUSTOM_ELEMENTS_SCHEMA, destroyPlatform, ENVI
import {TestBed} from '@angular/core/testing';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {NavigationEnd, provideRouter, Router, RouterModule, RouterOutlet, withEnabledBlockingInitialNavigation} from '@angular/router';
import {Event, NavigationEnd, provideRouter, Router, RouterModule, RouterOutlet, withEnabledBlockingInitialNavigation} from '@angular/router';
// This is needed, because all files under `packages/` are compiled together as part of the
// [legacy-unit-tests-saucelabs][1] CI job, including the `lib.webworker.d.ts` typings brought in by
@@ -533,6 +533,53 @@ describe('bootstrap', () => {
expect(window.removeEventListener).toHaveBeenCalledWith('popstate', jasmine.any(Function));
expect(window.removeEventListener).toHaveBeenCalledWith('hashchange', jasmine.any(Function));
});
it('should have the correct event order when using view transitions', async () => {
@Component({
selector: 'component-a',
template: `a`,
standalone: true,
})
class ComponentA {
}
@Component({
selector: 'component-b',
template: `b`,
standalone: true,
})
class ComponentB {
}
@NgModule({
imports: [
BrowserModule, ComponentA, ComponentB,
RouterModule.forRoot(
[
{path: '', pathMatch: 'full', redirectTo: '/a'},
{path: 'a', component: ComponentA},
{path: 'b', component: ComponentB},
],
{
enableViewTransitions: true,
})
],
declarations: [RootCmp],
bootstrap: [RootCmp],
providers: [...testProviders],
})
class TestModule {
}
const res = await platformBrowserDynamic([]).bootstrapModule(TestModule);
const router = res.injector.get(Router);
const eventLog = [] as Event[];
router.events.subscribe(e => {
eventLog.push(e);
});
await router.navigateByUrl('/b');
expect(eventLog[eventLog.length - 1]).toBeInstanceOf(NavigationEnd);
});
}
it('can schedule a navigation from the NavigationEnd event #37460', (done) => {
+5
View File
@@ -3593,6 +3593,11 @@
resolved "https://registry.yarnpkg.com/@types/diff/-/diff-5.0.3.tgz#1f89e49ff83b5d200d78964fb896c68498ce1828"
integrity sha512-amrLbRqTU9bXMCc6uX0sWpxsQzRIo9z6MJPkH1pkez/qOxuqSZVuryJAWoBRq94CeG8JxY+VK4Le9HtjQR5T9A==
"@types/dom-view-transitions@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/dom-view-transitions/-/dom-view-transitions-1.0.1.tgz#55333c2862c349153b9007ca01011d9379c12c6c"
integrity sha512-A9S1ijj/4MX06I1W/6on8lhaYyq1Ir7gaOvfllW1o4RzVWW88HAeqX0pUx9VgOLnNpdiGeUW2CTkg18p5LWIrA==
"@types/duplexify@^3.6.0":
version "3.6.1"
resolved "https://registry.yarnpkg.com/@types/duplexify/-/duplexify-3.6.1.tgz#5685721cf7dc4a21b6f0e8a8efbec6b4d2fbafad"