mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
fix(router): avoid view transitions when the user agent provides one
Preserve hasUAVisualTransition through Location and the Router navigation pipeline. This prevents withViewTransitions from starting an author transition after the browser has already performed one, including across redirects and when using experimental platform navigation.
(cherry picked from commit af26a8c521)
This commit is contained in:
committed by
Matthew Beck
parent
a7195a4f08
commit
4340a63c52
@@ -429,6 +429,7 @@ export const LOCATION_INITIALIZED: InjectionToken<Promise<any>>;
|
||||
|
||||
// @public
|
||||
export interface LocationChangeEvent {
|
||||
hasUAVisualTransition?: boolean;
|
||||
// (undocumented)
|
||||
state: any;
|
||||
// (undocumented)
|
||||
@@ -929,6 +930,7 @@ export enum Plural {
|
||||
|
||||
// @public (undocumented)
|
||||
interface PopStateEvent_2 {
|
||||
hasUAVisualTransition?: boolean;
|
||||
// (undocumented)
|
||||
pop?: boolean;
|
||||
// (undocumented)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"dist/main.js": 153915,
|
||||
"dist/main.js": 158918,
|
||||
"dist/polyfills.js": 35726,
|
||||
"dist/open-close.component-[hash].js": 1190
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface PopStateEvent {
|
||||
state?: any;
|
||||
type?: string;
|
||||
url?: string;
|
||||
/** Whether the user agent performed a visual transition for this navigation. */
|
||||
hasUAVisualTransition?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,12 +77,16 @@ export class Location implements OnDestroy {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/URL/URL#parameters
|
||||
this._basePath = _stripOrigin(stripTrailingSlash(_stripIndexHtml(baseHref)));
|
||||
this._locationStrategy.onPopState((ev) => {
|
||||
this._subject.next({
|
||||
const popStateEvent: PopStateEvent = {
|
||||
'url': this.path(true),
|
||||
'pop': true,
|
||||
'state': ev.state,
|
||||
'type': ev.type,
|
||||
});
|
||||
};
|
||||
if (ev.hasUAVisualTransition) {
|
||||
popStateEvent.hasUAVisualTransition = true;
|
||||
}
|
||||
this._subject.next(popStateEvent);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,8 @@ export const LOCATION_INITIALIZED = new InjectionToken<Promise<any>>(
|
||||
export interface LocationChangeEvent {
|
||||
type: string;
|
||||
state: any;
|
||||
/** Whether the user agent performed a visual transition for this navigation. */
|
||||
hasUAVisualTransition?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -219,6 +219,34 @@ describe('Location Class', () => {
|
||||
|
||||
expect(notificationCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should preserve whether the user agent performed a visual transition', () => {
|
||||
let hasUAVisualTransition: boolean | undefined;
|
||||
location.subscribe((event) => {
|
||||
hasUAVisualTransition = event.hasUAVisualTransition;
|
||||
});
|
||||
|
||||
locationStrategy.internalPath = '/test';
|
||||
(locationStrategy as any)._subject.next({
|
||||
newUrl: '/test',
|
||||
pop: true,
|
||||
type: 'popstate',
|
||||
hasUAVisualTransition: true,
|
||||
} as any);
|
||||
|
||||
expect(hasUAVisualTransition).toBeTrue();
|
||||
});
|
||||
|
||||
it('should not add a UA visual transition when the platform event does not provide one', () => {
|
||||
let hasOwnUAVisualTransition: boolean | undefined;
|
||||
location.subscribe((event) => {
|
||||
hasOwnUAVisualTransition = Object.hasOwn(event, 'hasUAVisualTransition');
|
||||
});
|
||||
|
||||
locationStrategy.simulatePopState('/test');
|
||||
|
||||
expect(hasOwnUAVisualTransition).toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('location.normalize(url) should return only route', () => {
|
||||
|
||||
@@ -146,6 +146,7 @@ export declare class NavigateEvent extends Event {
|
||||
readonly canIntercept: boolean;
|
||||
readonly userInitiated: boolean;
|
||||
readonly hashChange: boolean;
|
||||
readonly hasUAVisualTransition: boolean;
|
||||
readonly destination: NavigationDestination;
|
||||
readonly signal: AbortSignal;
|
||||
readonly formData: FormData | null;
|
||||
@@ -161,6 +162,7 @@ export interface NavigateEventInit extends EventInit {
|
||||
canIntercept?: boolean;
|
||||
userInitiated?: boolean;
|
||||
hashChange?: boolean;
|
||||
hasUAVisualTransition?: boolean;
|
||||
destination: NavigationDestination;
|
||||
signal: AbortSignal;
|
||||
formData?: FormData | null;
|
||||
|
||||
@@ -540,6 +540,7 @@ export class FakeNavigation implements Navigation {
|
||||
// Happens as part of "updating the document" steps https://whatpr.org/html/10919/browsing-the-web.html#updating-the-document
|
||||
const popStateEvent = createPopStateEvent({
|
||||
state: navigateEvent.destination.getHistoryState(),
|
||||
hasUAVisualTransition: navigateEvent.hasUAVisualTransition,
|
||||
});
|
||||
this._window.dispatchEvent(popStateEvent);
|
||||
if (navigateEvent.hashChange) {
|
||||
@@ -810,6 +811,7 @@ function dispatchNavigateEvent({
|
||||
canIntercept,
|
||||
userInitiated,
|
||||
hashChange,
|
||||
hasUAVisualTransition,
|
||||
navigationType,
|
||||
destination,
|
||||
info,
|
||||
@@ -820,6 +822,7 @@ function dispatchNavigateEvent({
|
||||
canIntercept: boolean;
|
||||
userInitiated: boolean;
|
||||
hashChange: boolean;
|
||||
hasUAVisualTransition?: boolean;
|
||||
navigationType: NavigationType;
|
||||
destination: FakeNavigationDestination;
|
||||
info: unknown;
|
||||
@@ -838,6 +841,9 @@ function dispatchNavigateEvent({
|
||||
event.canIntercept = canIntercept;
|
||||
event.userInitiated = userInitiated;
|
||||
event.hashChange = hashChange;
|
||||
if (hasUAVisualTransition) {
|
||||
event.hasUAVisualTransition = true;
|
||||
}
|
||||
event.signal = eventAbortController.signal;
|
||||
event.abortController = eventAbortController;
|
||||
event.info = info;
|
||||
@@ -1162,12 +1168,21 @@ function createFakeNavigationCurrentEntryChangeEvent({
|
||||
* Create a fake equivalent of `PopStateEvent`. This does not use a class
|
||||
* because ES5 transpiled JavaScript cannot extend native Event.
|
||||
*/
|
||||
function createPopStateEvent({state}: {state: unknown}) {
|
||||
function createPopStateEvent({
|
||||
state,
|
||||
hasUAVisualTransition,
|
||||
}: {
|
||||
state: unknown;
|
||||
hasUAVisualTransition?: boolean;
|
||||
}) {
|
||||
const event = new Event('popstate', {
|
||||
bubbles: false,
|
||||
cancelable: false,
|
||||
}) as {-readonly [P in keyof PopStateEvent]: PopStateEvent[P]};
|
||||
event.state = state;
|
||||
if (hasUAVisualTransition) {
|
||||
event.hasUAVisualTransition = true;
|
||||
}
|
||||
return event as PopStateEvent;
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +169,7 @@ describe('navigation', () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(Object.hasOwn(navigateEvent, 'hasUAVisualTransition')).toBeFalse();
|
||||
expect(navigateEvent.destination.getState()).toBeUndefined();
|
||||
const committedEntry = await committed;
|
||||
expect(committedEntry).toEqual(
|
||||
@@ -958,6 +959,7 @@ describe('navigation', () => {
|
||||
expect(locals.popStateEvents.length).toBe(1);
|
||||
const popStateEvent = locals.popStateEvents[0];
|
||||
expect(popStateEvent.state).toBeNull();
|
||||
expect(Object.hasOwn(popStateEvent, 'hasUAVisualTransition')).toBeFalse();
|
||||
expect(locals.navigation.canGoBack).toBeTrue();
|
||||
expect(locals.navigation.canGoForward).toBeTrue();
|
||||
const finishedEntry = await finished;
|
||||
@@ -968,6 +970,18 @@ describe('navigation', () => {
|
||||
expect(locals.popStateEvents.length).toBe(1);
|
||||
});
|
||||
|
||||
it('propagates a UA visual transition to the popstate event', async () => {
|
||||
await setUpEntries();
|
||||
locals.setExtraNavigateCallback((event) => {
|
||||
Object.defineProperty(event, 'hasUAVisualTransition', {value: true});
|
||||
});
|
||||
|
||||
await locals.navigation.back().finished;
|
||||
|
||||
expect(locals.navigateEvents[0].hasUAVisualTransition).toBeTrue();
|
||||
expect(locals.popStateEvents[0].hasUAVisualTransition).toBeTrue();
|
||||
});
|
||||
|
||||
it('traverses forward', async () => {
|
||||
expect(locals.navigation.canGoBack).toBeFalse();
|
||||
expect(locals.navigation.canGoForward).toBeFalse();
|
||||
|
||||
@@ -318,6 +318,7 @@ export interface NavigationTransition {
|
||||
urlAfterRedirects?: UrlTree;
|
||||
rawUrl: UrlTree;
|
||||
extras: NavigationExtras;
|
||||
hasUAVisualTransition?: boolean;
|
||||
resolve: (value: boolean | PromiseLike<boolean>) => void;
|
||||
reject: (reason?: any) => void;
|
||||
promise: Promise<boolean>;
|
||||
@@ -414,6 +415,7 @@ export class NavigationTransitions {
|
||||
| 'currentRawUrl'
|
||||
| 'rawUrl'
|
||||
| 'extras'
|
||||
| 'hasUAVisualTransition'
|
||||
| 'resolve'
|
||||
| 'reject'
|
||||
| 'promise'
|
||||
@@ -775,6 +777,7 @@ export class NavigationTransitions {
|
||||
this.environmentInjector,
|
||||
currentSnapshot.root,
|
||||
targetSnapshot!.root,
|
||||
overallTransitionState.hasUAVisualTransition,
|
||||
);
|
||||
|
||||
// If view transitions are enabled, block the navigation until the view
|
||||
|
||||
@@ -233,11 +233,18 @@ export class Router {
|
||||
...opts,
|
||||
};
|
||||
|
||||
this.scheduleNavigation(mergedTree, IMPERATIVE_NAVIGATION, null, extras, {
|
||||
resolve: currentTransition.resolve,
|
||||
reject: currentTransition.reject,
|
||||
promise: currentTransition.promise,
|
||||
});
|
||||
this.scheduleNavigation(
|
||||
mergedTree,
|
||||
IMPERATIVE_NAVIGATION,
|
||||
null,
|
||||
extras,
|
||||
currentTransition.hasUAVisualTransition,
|
||||
{
|
||||
resolve: currentTransition.resolve,
|
||||
reject: currentTransition.reject,
|
||||
promise: currentTransition.promise,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,8 +295,8 @@ export class Router {
|
||||
// run into ngZone
|
||||
this.nonRouterCurrentEntryChangeSubscription ??=
|
||||
this.stateManager.registerNonRouterCurrentEntryChangeListener(
|
||||
(url, state, source, extras) => {
|
||||
this.navigateToSyncWithBrowser(url, source, state, extras);
|
||||
(url, state, source, extras, hasUAVisualTransition) => {
|
||||
this.navigateToSyncWithBrowser(url, source, state, extras, hasUAVisualTransition);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -306,6 +313,7 @@ export class Router {
|
||||
source: NavigationTrigger,
|
||||
state: RestoredState | null | undefined,
|
||||
extras: NavigationExtras,
|
||||
hasUAVisualTransition?: boolean,
|
||||
) {
|
||||
// TODO: restoredState should always include the entire state, regardless
|
||||
// of navigationId. This requires a breaking change to update the type on
|
||||
@@ -338,12 +346,14 @@ export class Router {
|
||||
}
|
||||
|
||||
const urlTree = this.parseUrl(routerUrl);
|
||||
this.scheduleNavigation(urlTree, source, restoredState, extras).catch((e) => {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.injector.get(ɵINTERNAL_APPLICATION_ERROR_HANDLER)(e);
|
||||
});
|
||||
this.scheduleNavigation(urlTree, source, restoredState, extras, hasUAVisualTransition).catch(
|
||||
(e) => {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.injector.get(ɵINTERNAL_APPLICATION_ERROR_HANDLER)(e);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** The current URL. */
|
||||
@@ -657,6 +667,7 @@ export class Router {
|
||||
source: NavigationTrigger,
|
||||
restoredState: RestoredState | null,
|
||||
extras: NavigationExtras,
|
||||
hasUAVisualTransition?: boolean,
|
||||
priorPromise?: {
|
||||
resolve: (result: boolean | PromiseLike<boolean>) => void;
|
||||
reject: (reason?: any) => void;
|
||||
@@ -696,6 +707,7 @@ export class Router {
|
||||
currentRawUrl: this.currentUrlTree,
|
||||
rawUrl,
|
||||
extras,
|
||||
hasUAVisualTransition,
|
||||
resolve: resolve!,
|
||||
reject: reject!,
|
||||
promise,
|
||||
|
||||
@@ -101,6 +101,7 @@ export class NavigationStateManager extends StateManager {
|
||||
private nonRouterCurrentEntryChangeSubject = new Subject<{
|
||||
path: string;
|
||||
state: RestoredState | null | undefined;
|
||||
hasUAVisualTransition?: boolean;
|
||||
}>();
|
||||
|
||||
nonRouterEntryChangeListener?: SubscriptionLike;
|
||||
@@ -130,16 +131,18 @@ export class NavigationStateManager extends StateManager {
|
||||
state: RestoredState | null | undefined,
|
||||
trigger: NavigationTrigger,
|
||||
extras: NavigationExtras,
|
||||
hasUAVisualTransition?: boolean,
|
||||
) => void,
|
||||
): SubscriptionLike {
|
||||
this.activeHistoryEntry = this.navigation.currentEntry!;
|
||||
this.nonRouterEntryChangeListener = this.nonRouterCurrentEntryChangeSubject.subscribe(
|
||||
({path, state}) => {
|
||||
({path, state, hasUAVisualTransition}) => {
|
||||
listener(
|
||||
path,
|
||||
state,
|
||||
'popstate',
|
||||
!this.precommitHandlerSupported ? {replaceUrl: true} : {},
|
||||
hasUAVisualTransition,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -538,7 +541,11 @@ export class NavigationStateManager extends StateManager {
|
||||
// The url will always start with the appRootUrl because of the boundary check in handleNavigate.
|
||||
const path = event.destination.url.substring(this.appRootUrl.href.length - 1);
|
||||
const state = event.destination.getState() as RestoredState | null | undefined;
|
||||
this.nonRouterCurrentEntryChangeSubject.next({path, state});
|
||||
this.nonRouterCurrentEntryChangeSubject.next({
|
||||
path,
|
||||
state,
|
||||
hasUAVisualTransition: event.hasUAVisualTransition,
|
||||
});
|
||||
}
|
||||
|
||||
private eventAndRouterDestinationsMatch(
|
||||
|
||||
@@ -153,6 +153,7 @@ export abstract class StateManager {
|
||||
state: RestoredState | null | undefined,
|
||||
trigger: NavigationTrigger,
|
||||
extras: NavigationExtras,
|
||||
hasUAVisualTransition?: boolean,
|
||||
) => void,
|
||||
): SubscriptionLike;
|
||||
|
||||
@@ -194,6 +195,7 @@ export class HistoryStateManager extends StateManager {
|
||||
state: RestoredState | null | undefined,
|
||||
trigger: NavigationTrigger,
|
||||
extras: NavigationExtras,
|
||||
hasUAVisualTransition?: boolean,
|
||||
) => void,
|
||||
): SubscriptionLike {
|
||||
return this.location.subscribe((event) => {
|
||||
@@ -201,9 +203,15 @@ export class HistoryStateManager extends StateManager {
|
||||
// The `setTimeout` was added in #12160 and is likely to support Angular/AngularJS
|
||||
// hybrid apps.
|
||||
setTimeout(() => {
|
||||
listener(event['url']!, event.state as RestoredState | null | undefined, 'popstate', {
|
||||
replaceUrl: true,
|
||||
});
|
||||
listener(
|
||||
event['url']!,
|
||||
event.state as RestoredState | null | undefined,
|
||||
'popstate',
|
||||
{
|
||||
replaceUrl: true,
|
||||
},
|
||||
event.hasUAVisualTransition,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -63,17 +63,27 @@ export interface ViewTransitionInfo {
|
||||
|
||||
/**
|
||||
* A helper function for using browser view transitions. This function skips the call to
|
||||
* `startViewTransition` if the browser does not support it.
|
||||
* `startViewTransition` if the browser does not support it or has already provided a transition.
|
||||
*
|
||||
* @returns A Promise that resolves when the view transition callback begins.
|
||||
* @returns A Promise that resolves when the view transition callback begins, or `undefined` when
|
||||
* the user agent has already provided a transition and the navigation should continue immediately.
|
||||
*/
|
||||
export function createViewTransition(
|
||||
injector: Injector,
|
||||
from: ActivatedRouteSnapshot,
|
||||
to: ActivatedRouteSnapshot,
|
||||
): Promise<void> {
|
||||
hasUAVisualTransition?: boolean,
|
||||
): Promise<void> | undefined {
|
||||
const transitionOptions = injector.get(VIEW_TRANSITION_OPTIONS);
|
||||
const document = injector.get(DOCUMENT);
|
||||
|
||||
if (hasUAVisualTransition) {
|
||||
transitionOptions.skipNextTransition = false;
|
||||
// The browser has already started presenting the navigation. Continuing synchronously gives it
|
||||
// the earliest opportunity to display the post-navigation DOM.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.startViewTransition || transitionOptions.skipNextTransition) {
|
||||
transitionOptions.skipNextTransition = false;
|
||||
// The timing of `startViewTransition` is closer to a macrotask. It won't be called
|
||||
|
||||
Reference in New Issue
Block a user