Skip to content

Commit 73e4bf2

Browse files
atscottAndrewKushnir
authored andcommitted
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
1 parent ac56efa commit 73e4bf2

File tree

12 files changed

+190
-9
lines changed

12 files changed

+190
-9
lines changed

goldens/public-api/router/index.md

+8-1
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ export const enum EventType {
296296
export interface ExtraOptions extends InMemoryScrollingOptions, RouterConfigOptions {
297297
bindToComponentInputs?: boolean;
298298
enableTracing?: boolean;
299+
enableViewTransitions?: boolean;
299300
// @deprecated
300301
errorHandler?: (error: any) => any;
301302
initialNavigation?: InitialNavigation;
@@ -771,7 +772,7 @@ export interface RouterFeature<FeatureKind extends RouterFeatureKind> {
771772
}
772773

773774
// @public
774-
export type RouterFeatures = PreloadingFeature | DebugTracingFeature | InitialNavigationFeature | InMemoryScrollingFeature | RouterConfigurationFeature | NavigationErrorHandlerFeature | ComponentInputBindingFeature;
775+
export type RouterFeatures = PreloadingFeature | DebugTracingFeature | InitialNavigationFeature | InMemoryScrollingFeature | RouterConfigurationFeature | NavigationErrorHandlerFeature | ComponentInputBindingFeature | ViewTransitionsFeature;
775776

776777
// @public
777778
export type RouterHashLocationFeature = RouterFeature<RouterFeatureKind.RouterHashLocationFeature>;
@@ -1086,6 +1087,9 @@ export class UrlTree {
10861087
// @public (undocumented)
10871088
export const VERSION: Version;
10881089

1090+
// @public
1091+
export type ViewTransitionsFeature = RouterFeature<RouterFeatureKind.ViewTransitionsFeature>;
1092+
10891093
// @public
10901094
export function withComponentInputBinding(): ComponentInputBindingFeature;
10911095

@@ -1113,6 +1117,9 @@ export function withPreloading(preloadingStrategy: Type<PreloadingStrategy>): Pr
11131117
// @public
11141118
export function withRouterConfig(options: RouterConfigOptions): RouterConfigurationFeature;
11151119

1120+
// @public
1121+
export function withViewTransitions(): ViewTransitionsFeature;
1122+
11161123
// (No @packageDocumentation comment for this package)
11171124

11181125
```

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"@types/chrome": "^0.0.243",
8585
"@types/convert-source-map": "^2.0.0",
8686
"@types/diff": "^5.0.0",
87+
"@types/dom-view-transitions": "^1.0.1",
8788
"@types/hammerjs": "2.0.41",
8889
"@types/jasmine": "^4.0.0",
8990
"@types/jasmine-ajax": "^3.3.1",
@@ -206,4 +207,4 @@
206207
"**/https-proxy-agent": "7.0.1",
207208
"**/saucelabs": "7.2.2"
208209
}
209-
}
210+
}

packages/core/test/bundling/router/bundle.golden_symbols.json

+3
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@
7474
{
7575
"name": "CONTAINER_HEADER_OFFSET"
7676
},
77+
{
78+
"name": "CREATE_VIEW_TRANSITION"
79+
},
7780
{
7881
"name": "CSP_NONCE"
7982
},

packages/router/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ ng_module(
1414
"//packages/common",
1515
"//packages/core",
1616
"//packages/platform-browser",
17+
"@npm//@types/dom-view-transitions",
1718
"@npm//rxjs",
1819
],
1920
)

packages/router/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export {CanActivateChildFn, CanActivateFn, CanDeactivateFn, CanLoadFn, CanMatchF
1616
export * from './models_deprecated';
1717
export {Navigation, NavigationExtras, UrlCreationOptions} from './navigation_transition';
1818
export {DefaultTitleStrategy, TitleStrategy} from './page_title_strategy';
19-
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';
19+
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';
2020
export {BaseRouteReuseStrategy, DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy';
2121
export {Router} from './router';
2222
export {ExtraOptions, InitialNavigation, InMemoryScrollingOptions, ROUTER_CONFIGURATION, RouterConfigOptions} from './router_config';

packages/router/src/navigation_transition.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
910
import {EnvironmentInjector, inject, Injectable, Type} from '@angular/core';
10-
import {BehaviorSubject, combineLatest, EMPTY, Observable, of, Subject} from 'rxjs';
11+
import {BehaviorSubject, combineLatest, EMPTY, from, Observable, of, Subject} from 'rxjs';
1112
import {catchError, defaultIfEmpty, filter, finalize, map, switchMap, take, takeUntil, tap} from 'rxjs/operators';
1213

1314
import {createRouterState} from './create_router_state';
@@ -30,6 +31,7 @@ import {Params} from './shared';
3031
import {UrlHandlingStrategy} from './url_handling_strategy';
3132
import {isUrlTree, UrlSerializer, UrlTree} from './url_tree';
3233
import {Checks, getAllRouteGuards} from './utils/preactivation';
34+
import {CREATE_VIEW_TRANSITION} from './utils/view_transition';
3335

3436

3537

@@ -303,6 +305,7 @@ export class NavigationTransitions {
303305
private readonly paramsInheritanceStrategy =
304306
this.options.paramsInheritanceStrategy || 'emptyOnly';
305307
private readonly urlHandlingStrategy = inject(UrlHandlingStrategy);
308+
private readonly createViewTransition = inject(CREATE_VIEW_TRANSITION, {optional: true});
306309

307310
navigationId = 0;
308311
get hasRequestedNavigation() {
@@ -609,6 +612,17 @@ export class NavigationTransitions {
609612

610613
switchTap(() => this.afterPreactivation()),
611614

615+
switchMap(() => {
616+
const viewTransitionStarted =
617+
this.createViewTransition?.(this.environmentInjector);
618+
619+
// If view transitions are enabled, block the navigation until the view
620+
// transition callback starts. Otherwise, continue immediately.
621+
return viewTransitionStarted ?
622+
from(viewTransitionStarted).pipe(map(() => overallTransitionState)) :
623+
of(overallTransitionState);
624+
}),
625+
612626
map((t: NavigationTransition) => {
613627
const targetRouterState = createRouterState(
614628
router.routeReuseStrategy, t.targetSnapshot!, t.currentRouterState);

packages/router/src/provide_router.ts

+47-3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {ROUTER_SCROLLER, RouterScroller} from './router_scroller';
2222
import {ActivatedRoute} from './router_state';
2323
import {UrlSerializer} from './url_tree';
2424
import {afterNextNavigation} from './utils/navigations';
25+
import {CREATE_VIEW_TRANSITION, createViewTransition} from './utils/view_transition';
2526

2627

2728
/**
@@ -661,6 +662,16 @@ export function withNavigationErrorHandler(fn: (error: NavigationError) => void)
661662
export type ComponentInputBindingFeature =
662663
RouterFeature<RouterFeatureKind.ComponentInputBindingFeature>;
663664

665+
/**
666+
* A type alias for providers returned by `withViewTransitions` for use with `provideRouter`.
667+
*
668+
* @see {@link withViewTransitions}
669+
* @see {@link provideRouter}
670+
*
671+
* @publicApi
672+
*/
673+
export type ViewTransitionsFeature = RouterFeature<RouterFeatureKind.ViewTransitionsFeature>;
674+
664675
/**
665676
* Enables binding information from the `Router` state directly to the inputs of the component in
666677
* `Route` configurations.
@@ -690,6 +701,38 @@ export function withComponentInputBinding(): ComponentInputBindingFeature {
690701
return routerFeature(RouterFeatureKind.ComponentInputBindingFeature, providers);
691702
}
692703

704+
/**
705+
* Enables view transitions in the Router by running the route activation and deactivation inside of
706+
* `document.startViewTransition`.
707+
*
708+
* Note: The View Transitions API is not available in all browsers. If the browser does not support
709+
* view transitions, the Router will not attempt to start a view transition and continue processing
710+
* the navigation as usual.
711+
*
712+
* @usageNotes
713+
*
714+
* Basic example of how you can enable the feature:
715+
* ```
716+
* const appRoutes: Routes = [];
717+
* bootstrapApplication(AppComponent,
718+
* {
719+
* providers: [
720+
* provideRouter(appRoutes, withViewTransitions())
721+
* ]
722+
* }
723+
* );
724+
* ```
725+
*
726+
* @returns A set of providers for use with `provideRouter`.
727+
* @see https://developer.chrome.com/docs/web-platform/view-transitions/
728+
* @see https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API
729+
* @experimental
730+
*/
731+
export function withViewTransitions(): ViewTransitionsFeature {
732+
const providers = [{provide: CREATE_VIEW_TRANSITION, useValue: createViewTransition}];
733+
return routerFeature(RouterFeatureKind.ViewTransitionsFeature, providers);
734+
}
735+
693736
/**
694737
* A type alias that represents all Router features available for use with `provideRouter`.
695738
* Features can be enabled by adding special functions to the `provideRouter` call.
@@ -700,9 +743,9 @@ export function withComponentInputBinding(): ComponentInputBindingFeature {
700743
*
701744
* @publicApi
702745
*/
703-
export type RouterFeatures =
704-
PreloadingFeature|DebugTracingFeature|InitialNavigationFeature|InMemoryScrollingFeature|
705-
RouterConfigurationFeature|NavigationErrorHandlerFeature|ComponentInputBindingFeature;
746+
export type RouterFeatures = PreloadingFeature|DebugTracingFeature|InitialNavigationFeature|
747+
InMemoryScrollingFeature|RouterConfigurationFeature|NavigationErrorHandlerFeature|
748+
ComponentInputBindingFeature|ViewTransitionsFeature;
706749

707750
/**
708751
* The list of features as an enum to uniquely type each feature.
@@ -717,4 +760,5 @@ export const enum RouterFeatureKind {
717760
RouterHashLocationFeature,
718761
NavigationErrorHandlerFeature,
719762
ComponentInputBindingFeature,
763+
ViewTransitionsFeature,
720764
}

packages/router/src/router_config.ts

+10
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,16 @@ export interface ExtraOptions extends InMemoryScrollingOptions, RouterConfigOpti
203203
*/
204204
bindToComponentInputs?: boolean;
205205

206+
/**
207+
* When true, enables view transitions in the Router by running the route activation and
208+
* deactivation inside of `document.startViewTransition`.
209+
*
210+
* @see https://developer.chrome.com/docs/web-platform/view-transitions/
211+
* @see https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API
212+
* @experimental
213+
*/
214+
enableViewTransitions?: boolean;
215+
206216
/**
207217
* A custom error handler for failed navigations.
208218
* If the handler returns a value, the navigation Promise is resolved with this value.

packages/router/src/router_module.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {RouterOutlet} from './directives/router_outlet';
1616
import {RuntimeErrorCode} from './errors';
1717
import {Routes} from './models';
1818
import {NavigationTransitions} from './navigation_transition';
19-
import {getBootstrapListener, rootRoute, ROUTER_IS_PROVIDED, withComponentInputBinding, withDebugTracing, withDisabledInitialNavigation, withEnabledBlockingInitialNavigation, withPreloading} from './provide_router';
19+
import {getBootstrapListener, rootRoute, ROUTER_IS_PROVIDED, withComponentInputBinding, withDebugTracing, withDisabledInitialNavigation, withEnabledBlockingInitialNavigation, withPreloading, withViewTransitions} from './provide_router';
2020
import {Router} from './router';
2121
import {ExtraOptions, ROUTER_CONFIGURATION} from './router_config';
2222
import {RouterConfigLoader, ROUTES} from './router_config_loader';
@@ -126,6 +126,7 @@ export class RouterModule {
126126
{provide: NgProbeToken, multi: true, useFactory: routerNgProbeToken},
127127
config?.initialNavigation ? provideInitialNavigation(config) : [],
128128
config?.bindToComponentInputs ? withComponentInputBinding().ɵproviders : [],
129+
config?.enableViewTransitions ? withViewTransitions().ɵproviders : [],
129130
provideRouterInitializer(),
130131
],
131132
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
/// <reference types="@types/dom-view-transitions" />
10+
11+
import {afterNextRender, InjectionToken, Injector, NgZone} from '@angular/core';
12+
13+
export const CREATE_VIEW_TRANSITION =
14+
new InjectionToken<typeof createViewTransition>(ngDevMode ? 'view transition helper' : '');
15+
16+
/**
17+
* A helper function for using browser view transitions. This function skips the call to
18+
* `startViewTransition` if the browser does not support it.
19+
*
20+
* @returns A Promise that resolves when the view transition callback begins.
21+
*/
22+
export function createViewTransition(injector: Injector): Promise<void> {
23+
// Create promises outside the Angular zone to avoid causing extra change detections
24+
return injector.get(NgZone).runOutsideAngular(() => {
25+
if (!document.startViewTransition) {
26+
return Promise.resolve();
27+
}
28+
29+
let resolveViewTransitionStarted: () => void;
30+
const viewTransitionStarted = new Promise<void>((resolve) => {
31+
resolveViewTransitionStarted = resolve;
32+
});
33+
document.startViewTransition(() => {
34+
resolveViewTransitionStarted();
35+
return createRenderPromise(injector);
36+
});
37+
return viewTransitionStarted;
38+
});
39+
}
40+
41+
/**
42+
* Creates a promise that resolves after next render.
43+
*/
44+
function createRenderPromise(injector: Injector) {
45+
return new Promise<void>(resolve => {
46+
afterNextRender(resolve, {injector});
47+
});
48+
}

packages/router/test/bootstrap.spec.ts

+48-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {ApplicationRef, Component, CUSTOM_ELEMENTS_SCHEMA, destroyPlatform, ENVI
1414
import {TestBed} from '@angular/core/testing';
1515
import {BrowserModule} from '@angular/platform-browser';
1616
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
17-
import {NavigationEnd, provideRouter, Router, RouterModule, RouterOutlet, withEnabledBlockingInitialNavigation} from '@angular/router';
17+
import {Event, NavigationEnd, provideRouter, Router, RouterModule, RouterOutlet, withEnabledBlockingInitialNavigation} from '@angular/router';
1818

1919
// This is needed, because all files under `packages/` are compiled together as part of the
2020
// [legacy-unit-tests-saucelabs][1] CI job, including the `lib.webworker.d.ts` typings brought in by
@@ -533,6 +533,53 @@ describe('bootstrap', () => {
533533
expect(window.removeEventListener).toHaveBeenCalledWith('popstate', jasmine.any(Function));
534534
expect(window.removeEventListener).toHaveBeenCalledWith('hashchange', jasmine.any(Function));
535535
});
536+
537+
it('should have the correct event order when using view transitions', async () => {
538+
@Component({
539+
selector: 'component-a',
540+
template: `a`,
541+
standalone: true,
542+
})
543+
class ComponentA {
544+
}
545+
@Component({
546+
selector: 'component-b',
547+
template: `b`,
548+
standalone: true,
549+
})
550+
class ComponentB {
551+
}
552+
@NgModule({
553+
imports: [
554+
BrowserModule, ComponentA, ComponentB,
555+
RouterModule.forRoot(
556+
[
557+
{path: '', pathMatch: 'full', redirectTo: '/a'},
558+
{path: 'a', component: ComponentA},
559+
{path: 'b', component: ComponentB},
560+
],
561+
{
562+
enableViewTransitions: true,
563+
})
564+
],
565+
declarations: [RootCmp],
566+
bootstrap: [RootCmp],
567+
providers: [...testProviders],
568+
})
569+
class TestModule {
570+
}
571+
572+
573+
const res = await platformBrowserDynamic([]).bootstrapModule(TestModule);
574+
const router = res.injector.get(Router);
575+
const eventLog = [] as Event[];
576+
router.events.subscribe(e => {
577+
eventLog.push(e);
578+
});
579+
580+
await router.navigateByUrl('/b');
581+
expect(eventLog[eventLog.length - 1]).toBeInstanceOf(NavigationEnd);
582+
});
536583
}
537584

538585
it('can schedule a navigation from the NavigationEnd event #37460', (done) => {

yarn.lock

+5
Original file line numberDiff line numberDiff line change
@@ -3593,6 +3593,11 @@
35933593
resolved "https://registry.yarnpkg.com/@types/diff/-/diff-5.0.3.tgz#1f89e49ff83b5d200d78964fb896c68498ce1828"
35943594
integrity sha512-amrLbRqTU9bXMCc6uX0sWpxsQzRIo9z6MJPkH1pkez/qOxuqSZVuryJAWoBRq94CeG8JxY+VK4Le9HtjQR5T9A==
35953595

3596+
"@types/dom-view-transitions@^1.0.1":
3597+
version "1.0.1"
3598+
resolved "https://registry.yarnpkg.com/@types/dom-view-transitions/-/dom-view-transitions-1.0.1.tgz#55333c2862c349153b9007ca01011d9379c12c6c"
3599+
integrity sha512-A9S1ijj/4MX06I1W/6on8lhaYyq1Ir7gaOvfllW1o4RzVWW88HAeqX0pUx9VgOLnNpdiGeUW2CTkg18p5LWIrA==
3600+
35963601
"@types/duplexify@^3.6.0":
35973602
version "3.6.1"
35983603
resolved "https://registry.yarnpkg.com/@types/duplexify/-/duplexify-3.6.1.tgz#5685721cf7dc4a21b6f0e8a8efbec6b4d2fbafad"

0 commit comments

Comments
 (0)