Skip to content

Commit

Permalink
feat(router): Add feature to support the View Transitions API (#51314)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
atscott authored and AndrewKushnir committed Sep 11, 2023
1 parent ac56efa commit 73e4bf2
Show file tree
Hide file tree
Showing 12 changed files with 190 additions and 9 deletions.
9 changes: 8 additions & 1 deletion goldens/public-api/router/index.md
Expand Up @@ -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;
Expand Down Expand Up @@ -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>;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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)

```
3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -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",
Expand Down Expand Up @@ -206,4 +207,4 @@
"**/https-proxy-agent": "7.0.1",
"**/saucelabs": "7.2.2"
}
}
}
3 changes: 3 additions & 0 deletions packages/core/test/bundling/router/bundle.golden_symbols.json
Expand Up @@ -74,6 +74,9 @@
{
"name": "CONTAINER_HEADER_OFFSET"
},
{
"name": "CREATE_VIEW_TRANSITION"
},
{
"name": "CSP_NONCE"
},
Expand Down
1 change: 1 addition & 0 deletions packages/router/BUILD.bazel
Expand Up @@ -14,6 +14,7 @@ ng_module(
"//packages/common",
"//packages/core",
"//packages/platform-browser",
"@npm//@types/dom-view-transitions",
"@npm//rxjs",
],
)
Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/index.ts
Expand Up @@ -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';
Expand Down
16 changes: 15 additions & 1 deletion packages/router/src/navigation_transition.ts
Expand Up @@ -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';
Expand All @@ -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';



Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand Down
50 changes: 47 additions & 3 deletions packages/router/src/provide_router.ts
Expand Up @@ -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';


/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -717,4 +760,5 @@ export const enum RouterFeatureKind {
RouterHashLocationFeature,
NavigationErrorHandlerFeature,
ComponentInputBindingFeature,
ViewTransitionsFeature,
}
10 changes: 10 additions & 0 deletions packages/router/src/router_config.ts
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion packages/router/src/router_module.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
],
};
Expand Down
48 changes: 48 additions & 0 deletions 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
*/

/// <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});
});
}
49 changes: 48 additions & 1 deletion packages/router/test/bootstrap.spec.ts
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Expand Up @@ -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"
Expand Down

0 comments on commit 73e4bf2

Please sign in to comment.