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"