From 73e4bf2ed2471faf44a49b591e19a390d5867449 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Fri, 1 Sep 2023 06:51:17 -0700 Subject: [PATCH] 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 --- goldens/public-api/router/index.md | 9 +++- package.json | 3 +- .../router/bundle.golden_symbols.json | 3 ++ packages/router/BUILD.bazel | 1 + packages/router/src/index.ts | 2 +- packages/router/src/navigation_transition.ts | 16 +++++- packages/router/src/provide_router.ts | 50 +++++++++++++++++-- packages/router/src/router_config.ts | 10 ++++ packages/router/src/router_module.ts | 3 +- packages/router/src/utils/view_transition.ts | 48 ++++++++++++++++++ packages/router/test/bootstrap.spec.ts | 49 +++++++++++++++++- yarn.lock | 5 ++ 12 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 packages/router/src/utils/view_transition.ts diff --git a/goldens/public-api/router/index.md b/goldens/public-api/router/index.md index 740b4372268..a741b840347 100644 --- a/goldens/public-api/router/index.md +++ b/goldens/public-api/router/index.md @@ -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 { } // @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; @@ -1086,6 +1087,9 @@ export class UrlTree { // @public (undocumented) export const VERSION: Version; +// @public +export type ViewTransitionsFeature = RouterFeature; + // @public export function withComponentInputBinding(): ComponentInputBindingFeature; @@ -1113,6 +1117,9 @@ export function withPreloading(preloadingStrategy: Type): Pr // @public export function withRouterConfig(options: RouterConfigOptions): RouterConfigurationFeature; +// @public +export function withViewTransitions(): ViewTransitionsFeature; + // (No @packageDocumentation comment for this package) ``` diff --git a/package.json b/package.json index 2a6059af3b5..0940127b140 100644 --- a/package.json +++ b/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 6b45e5b1842..08ce73ba560 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -74,6 +74,9 @@ { "name": "CONTAINER_HEADER_OFFSET" }, + { + "name": "CREATE_VIEW_TRANSITION" + }, { "name": "CSP_NONCE" }, diff --git a/packages/router/BUILD.bazel b/packages/router/BUILD.bazel index a4162e8919a..aacf94c2c51 100644 --- a/packages/router/BUILD.bazel +++ b/packages/router/BUILD.bazel @@ -14,6 +14,7 @@ ng_module( "//packages/common", "//packages/core", "//packages/platform-browser", + "@npm//@types/dom-view-transitions", "@npm//rxjs", ], ) diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 14f84aa501b..48920c635a0 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -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'; diff --git a/packages/router/src/navigation_transition.ts b/packages/router/src/navigation_transition.ts index 3db8f8f2884..48fa32cce71 100644 --- a/packages/router/src/navigation_transition.ts +++ b/packages/router/src/navigation_transition.ts @@ -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); diff --git a/packages/router/src/provide_router.ts b/packages/router/src/provide_router.ts index 52852f632a3..ccf181ec2e5 100644 --- a/packages/router/src/provide_router.ts +++ b/packages/router/src/provide_router.ts @@ -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; +/** + * A type alias for providers returned by `withViewTransitions` for use with `provideRouter`. + * + * @see {@link withViewTransitions} + * @see {@link provideRouter} + * + * @publicApi + */ +export type ViewTransitionsFeature = RouterFeature; + /** * 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, } diff --git a/packages/router/src/router_config.ts b/packages/router/src/router_config.ts index d423dca5ae3..b18b67bd868 100644 --- a/packages/router/src/router_config.ts +++ b/packages/router/src/router_config.ts @@ -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. diff --git a/packages/router/src/router_module.ts b/packages/router/src/router_module.ts index 5b0d8f5e69d..abbd266f3e6 100644 --- a/packages/router/src/router_module.ts +++ b/packages/router/src/router_module.ts @@ -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(), ], }; diff --git a/packages/router/src/utils/view_transition.ts b/packages/router/src/utils/view_transition.ts new file mode 100644 index 00000000000..ebf05f53e8f --- /dev/null +++ b/packages/router/src/utils/view_transition.ts @@ -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 + */ + +/// + +import {afterNextRender, InjectionToken, Injector, NgZone} from '@angular/core'; + +export const CREATE_VIEW_TRANSITION = + new InjectionToken(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 { + // 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((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(resolve => { + afterNextRender(resolve, {injector}); + }); +} diff --git a/packages/router/test/bootstrap.spec.ts b/packages/router/test/bootstrap.spec.ts index 164ec89f1d2..752bd7f7baf 100644 --- a/packages/router/test/bootstrap.spec.ts +++ b/packages/router/test/bootstrap.spec.ts @@ -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) => { diff --git a/yarn.lock b/yarn.lock index f62c6eb6363..cf10fcdc56d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"