Skip to content

Commit

Permalink
feat(router): Add feature to support the View Transitions API
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
  • Loading branch information
atscott committed Aug 28, 2023
1 parent 440684d commit f275113
Show file tree
Hide file tree
Showing 14 changed files with 231 additions and 43 deletions.
9 changes: 8 additions & 1 deletion goldens/public-api/router/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,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 @@ -782,7 +783,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 @@ -1097,6 +1098,9 @@ export class UrlTree {
// @public (undocumented)
export const VERSION: Version;

// @public (undocumented)
export type ViewTransitionsFeature = RouterFeature<RouterFeatureKind.ViewTransitionsFeature>;

// @public
export function withComponentInputBinding(): ComponentInputBindingFeature;

Expand Down Expand Up @@ -1124,6 +1128,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)

```
2 changes: 1 addition & 1 deletion goldens/size-tracking/integration-payloads.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"platform-server/ngmodule/browser": {
"uncompressed": {
"runtime": 2688,
"main": 244602,
"main": 249919,
"polyfills": 33778
}
},
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,9 @@
{
"name": "TRACKED_LVIEWS"
},
{
"name": "TRANSITION_HELPER"
},
{
"name": "TYPE"
},
Expand Down
1 change: 1 addition & 0 deletions packages/router/BUILD.bazel
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
76 changes: 54 additions & 22 deletions packages/router/src/navigation_transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
*/

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';
import {INPUT_BINDER} from './directives/router_outlet';
import {BeforeActivateRoutes, Event, GuardsCheckEnd, GuardsCheckStart, IMPERATIVE_NAVIGATION, NavigationCancel, NavigationCancellationCode, NavigationEnd, NavigationError, NavigationSkipped, NavigationSkippedCode, NavigationStart, NavigationTrigger, RedirectRequest, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events';
import {NavigationBehaviorOptions, QueryParamsHandling, Route, Routes} from './models';
import {isNavigationCancelingError, isRedirectingNavigationCancelingError, redirectingNavigationError} from './navigation_canceling_error';
import {activateRoutes} from './operators/activate_routes';
import {ActivateRoutes} from './operators/activate_routes';
import {checkGuards} from './operators/check_guards';
import {recognize} from './operators/recognize';
import {resolveData} from './operators/resolve_data';
Expand All @@ -30,6 +30,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 {TRANSITION_HELPER} from './utils/view_transition';



Expand Down Expand Up @@ -295,6 +296,7 @@ export class NavigationTransitions {
private readonly urlSerializer = inject(UrlSerializer);
private readonly rootContexts = inject(ChildrenOutletContexts);
private readonly inputBindingEnabled = inject(INPUT_BINDER, {optional: true}) !== null;
private readonly transitionHelper = inject(TRANSITION_HELPER);
navigationId = 0;
get hasRequestedNavigation() {
return this.navigationId !== 0;
Expand Down Expand Up @@ -599,37 +601,67 @@ export class NavigationTransitions {

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

map((t: NavigationTransition) => {
const targetRouterState = createRouterState(
router.routeReuseStrategy, t.targetSnapshot!, t.currentRouterState);
this.currentTransition =
overallTransitionState = {...t, targetRouterState};
return overallTransitionState;
}),

tap(() => {
this.events.next(new BeforeActivateRoutes());
}),

activateRoutes(
this.rootContexts, router.routeReuseStrategy,
(evt: Event) => this.events.next(evt), this.inputBindingEnabled),

// Ensure that if some observable used to drive the transition doesn't
// complete, the navigation still finalizes This should never happen, but
// this is done as a safety measure to avoid surfacing this error (#49567).
take(1),

tap({
next: (t: NavigationTransition) => {
switchMap(t => {
// Everything in the transition must happen in a synchronous block.
// Breaking it up results in different timing of router state updates and
// router events.
const transitionDone = this.transitionHelper(() => {
// Because startViewTransition happens asynchronously, we need to
// ensure that we are on the same navigation when the callback
// executes.
if (this.currentNavigation?.id !== t.id) {
return;
}
const targetRouterState = createRouterState(
router.routeReuseStrategy, t.targetSnapshot!,
t.currentRouterState);
this.currentTransition = t =
overallTransitionState = {...t, targetRouterState};

this.events.next(new BeforeActivateRoutes());
// The above event causes entrance into user code that may error and
// cancel the navigation. Ensure we are still processing before
// continuing.
if (this.currentNavigation?.id !== t.id) {
return;
}

// activate and deactivate routes via the router outlets
new ActivateRoutes(
router.routeReuseStrategy, targetRouterState, t.currentRouterState,
(evt: Event) => this.events.next(evt), this.inputBindingEnabled)
.activate(this.rootContexts);
// Activating routes enters user code that could trigger a new
// navigation. Ensure the current navigation is the same as this
// transition before continuing.
if (this.currentNavigation?.id !== t.id) {
return;
}

// In order to preserve the timing of the `NavigationEnd` event, it
// must be done inside the transition callback. If it were placed in an
// rxjs operator, the event would happen after the next render because
// the view transition does not complete until change detection runs.
completed = true;
this.lastSuccessfulNavigation = this.currentNavigation;
this.events.next(new NavigationEnd(
t.id, this.urlSerializer.serialize(t.extractedUrl),
this.urlSerializer.serialize(t.urlAfterRedirects!)));
router.titleStrategy?.updateTitle(t.targetRouterState!.snapshot);
router.titleStrategy?.updateTitle(targetRouterState.snapshot);
t.resolve(true);
},
}, this.environmentInjector);

// prevent finalize block from running until the code above runs (which
// is async when run inside startViewTransition).
return transitionDone.pipe(map(() => overallTransitionState));
}),

tap({
complete: () => {
completed = true;
}
Expand Down
14 changes: 2 additions & 12 deletions packages/router/src/operators/activate_routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {InjectionToken} from '@angular/core';
import {MonoTypeOperatorFunction} from 'rxjs';
import {map} from 'rxjs/operators';

Expand All @@ -19,22 +20,11 @@ import {nodeChildrenAsMap, TreeNode} from '../utils/tree';

let warnedAboutUnsupportedInputBinding = false;

export const activateRoutes =
(rootContexts: ChildrenOutletContexts, routeReuseStrategy: RouteReuseStrategy,
forwardEvent: (evt: Event) => void,
inputBindingEnabled: boolean): MonoTypeOperatorFunction<NavigationTransition> => map(t => {
new ActivateRoutes(
routeReuseStrategy, t.targetRouterState!, t.currentRouterState, forwardEvent,
inputBindingEnabled)
.activate(rootContexts);
return t;
});

export class ActivateRoutes {
constructor(
private routeReuseStrategy: RouteReuseStrategy, private futureState: RouterState,
private currState: RouterState, private forwardEvent: (evt: Event) => void,
private inputBindingEnabled: boolean) {}
private readonly inputBindingEnabled: boolean) {}

activate(parentContexts: ChildrenOutletContexts): void {
const futureRoot = this.futureState._root;
Expand Down
38 changes: 35 additions & 3 deletions packages/router/src/provide_router.ts
Original file line number Diff line number Diff line change
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 {TRANSITION_HELPER, transitionHelper} from './utils/view_transition';


/**
Expand Down Expand Up @@ -661,6 +662,8 @@ export function withNavigationErrorHandler(fn: (error: NavigationError) => void)
export type ComponentInputBindingFeature =
RouterFeature<RouterFeatureKind.ComponentInputBindingFeature>;

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 +693,34 @@ 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`.
*
* @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
* @developerPreview
*/
export function withViewTransitions(): ViewTransitionsFeature {
const providers = [{provide: TRANSITION_HELPER, useValue: transitionHelper}];
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 +731,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 +748,5 @@ export const enum RouterFeatureKind {
RouterHashLocationFeature,
NavigationErrorHandlerFeature,
ComponentInputBindingFeature,
ViewTransitionsFeature,
}
9 changes: 9 additions & 0 deletions packages/router/src/router_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,15 @@ 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
*/
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
Original file line number Diff line number Diff line change
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
Loading

0 comments on commit f275113

Please sign in to comment.