mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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
This commit is contained in:
committed by
Andrew Kushnir
parent
ac56efa410
commit
73e4bf2ed2
@@ -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<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>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,9 @@
|
||||
{
|
||||
"name": "CONTAINER_HEADER_OFFSET"
|
||||
},
|
||||
{
|
||||
"name": "CREATE_VIEW_TRANSITION"
|
||||
},
|
||||
{
|
||||
"name": "CSP_NONCE"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ ng_module(
|
||||
"//packages/common",
|
||||
"//packages/core",
|
||||
"//packages/platform-browser",
|
||||
"@npm//@types/dom-view-transitions",
|
||||
"@npm//rxjs",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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.
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user