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 740b43722689f..a741b84034726 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 2a6059af3b50b..0940127b14008 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 6b45e5b1842aa..08ce73ba5604a 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 a4162e8919aec..aacf94c2c517e 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 14f84aa501bd8..48920c635a038 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 3db8f8f28847f..48fa32cce71bd 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 52852f632a319..ccf181ec2e513 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 d423dca5ae372..b18b67bd868d2 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 5b0d8f5e69d52..abbd266f3e6b1 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 0000000000000..ebf05f53e8f82 --- /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 164ec89f1d276..752bd7f7baffa 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 f62c6eb636371..cf10fcdc56dd6 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"