Files
Jaime Burgos af26a8c521 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.
2026-09-02 16:04:41 -07:00

351 lines
11 KiB
TypeScript

/**
* @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.dev/license
*/
import {
APP_BASE_HREF,
CommonModule,
Location,
LocationStrategy,
PathLocationStrategy,
PlatformLocation,
} from '../../index';
import {MockLocationStrategy, MockPlatformLocation} from '../../testing';
import {TestBed} from '@angular/core/testing';
const baseUrl = '/base';
describe('Location Class', () => {
describe('stripTrailingSlash', () => {
it('should strip single character slash', () => {
const input = '/';
expect(Location.stripTrailingSlash(input)).toBe('');
});
it('should normalize strip a trailing slash', () => {
const input = baseUrl + '/';
expect(Location.stripTrailingSlash(input)).toBe(baseUrl);
});
it('should ignore query params when stripping a slash', () => {
const input = baseUrl + '/?param=1';
expect(Location.stripTrailingSlash(input)).toBe(baseUrl + '?param=1');
});
it('should not remove slashes inside query params', () => {
const input = baseUrl + '?test/?=3';
expect(Location.stripTrailingSlash(input)).toBe(input);
});
it('should not remove slashes after a pound sign', () => {
const input = baseUrl + '#test/?=3';
expect(Location.stripTrailingSlash(input)).toBe(input);
});
});
describe('location.getState()', () => {
let location: Location;
beforeEach(() => {
TestBed.configureTestingModule({
teardown: {destroyAfterEach: true},
imports: [CommonModule],
providers: [
{provide: LocationStrategy, useClass: PathLocationStrategy},
{
provide: PlatformLocation,
useFactory: () => {
return new MockPlatformLocation();
},
},
{provide: Location, useClass: Location, deps: [LocationStrategy]},
],
});
location = TestBed.inject(Location);
});
it('should get the state object', () => {
expect(location.getState()).toBe(null);
location.go('/test', '', {foo: 'bar'});
expect(location.getState()).toEqual({foo: 'bar'});
});
it('should work after using back button', () => {
expect(location.getState()).toBe(null);
location.go('/test1', '', {url: 'test1'});
location.go('/test2', '', {url: 'test2'});
expect(location.getState()).toEqual({url: 'test2'});
location.back();
expect(location.getState()).toEqual({url: 'test1'});
});
it('should work after using forward button', () => {
expect(location.getState()).toBe(null);
location.go('/test1', '', {url: 'test1'});
location.go('/test2', '', {url: 'test2'});
expect(location.getState()).toEqual({url: 'test2'});
location.back();
expect(location.getState()).toEqual({url: 'test1'});
location.forward();
expect(location.getState()).toEqual({url: 'test2'});
});
it('should work after using location.historyGo()', () => {
expect(location.getState()).toBe(null);
location.go('/test1', '', {url: 'test1'});
location.go('/test2', '', {url: 'test2'});
location.go('/test3', '', {url: 'test3'});
expect(location.getState()).toEqual({url: 'test3'});
location.historyGo(-2);
expect(location.getState()).toEqual({url: 'test1'});
location.historyGo(2);
expect(location.getState()).toEqual({url: 'test3'});
location.go('/test3', '', {url: 'test4'});
location.historyGo(0);
expect(location.getState()).toEqual({url: 'test4'});
location.historyGo();
expect(location.getState()).toEqual({url: 'test4'});
// we are testing the behaviour of the `historyGo` method at the moment when the value of
// the relativePosition goes out of bounds.
// The result should be that the locationState does not change.
location.historyGo(100);
expect(location.getState()).toEqual({url: 'test4'});
location.historyGo(-100);
expect(location.getState()).toEqual({url: 'test4'});
location.back();
expect(location.getState()).toEqual({url: 'test3'});
});
});
describe('location.onUrlChange()', () => {
let location: Location;
let locationStrategy: MockLocationStrategy;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CommonModule],
providers: [
{provide: LocationStrategy, useClass: MockLocationStrategy},
{
provide: PlatformLocation,
useFactory: () => {
return new MockPlatformLocation();
},
},
{provide: Location, useClass: Location, deps: [LocationStrategy]},
],
});
location = TestBed.inject(Location);
locationStrategy = TestBed.inject(LocationStrategy) as MockLocationStrategy;
});
it('should have onUrlChange method', () => {
expect(typeof location.onUrlChange).toBe('function');
});
it('should add registered functions to urlChangeListeners', () => {
function changeListener(url: string, state: unknown) {
return undefined;
}
expect((location as any)._urlChangeListeners.length).toBe(0);
location.onUrlChange(changeListener);
expect((location as any)._urlChangeListeners.length).toBe(1);
expect((location as any)._urlChangeListeners[0]).toEqual(changeListener);
});
it('should unregister a URL change listener and unsubscribe from URL changes when the root view is removed', () => {
const changeListener = jasmine.createSpy('changeListener');
const removeUrlChangeFn = location.onUrlChange(changeListener);
location.go('x');
expect(changeListener).toHaveBeenCalledTimes(1);
removeUrlChangeFn();
expect(changeListener).toHaveBeenCalledTimes(1);
location.onUrlChange((url: string, state: unknown) => {});
TestBed.resetTestingModule();
// Let's ensure that URL change listeners are unregistered when the root view is removed,
// tho the last returned `onUrlChange` function hasn't been invoked.
expect((location as any)._urlChangeListeners.length).toEqual(0);
expect((location as any)._urlChangeSubscription.closed).toEqual(true);
});
it('should only notify listeners once when multiple listeners are registered', () => {
let notificationCount = 0;
function incrementChangeListener(url: string, state: unknown) {
notificationCount += 1;
return undefined;
}
function noopChangeListener(url: string, state: unknown) {
return undefined;
}
location.onUrlChange(incrementChangeListener);
location.onUrlChange(noopChangeListener);
expect(notificationCount).toBe(0);
locationStrategy.simulatePopState('/test');
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', () => {
const basePath = '/en';
const route = '/go/to/there';
const url = basePath + route;
const getBaseHref = (origin: string) => origin + basePath + '/';
it('in case APP_BASE_HREF starts with http:', () => {
const origin = 'http://example.com';
const baseHref = getBaseHref(origin);
TestBed.configureTestingModule({providers: [{provide: APP_BASE_HREF, useValue: baseHref}]});
const location = TestBed.inject(Location);
expect(location.normalize(url)).toBe(route);
});
it('in case APP_BASE_HREF starts with https:', () => {
const origin = 'https://example.com';
const baseHref = getBaseHref(origin);
TestBed.configureTestingModule({providers: [{provide: APP_BASE_HREF, useValue: baseHref}]});
const location = TestBed.inject(Location);
expect(location.normalize(url)).toBe(route);
});
it('in case APP_BASE_HREF starts with no protocol', () => {
const origin = '//example.com';
const baseHref = getBaseHref(origin);
TestBed.configureTestingModule({providers: [{provide: APP_BASE_HREF, useValue: baseHref}]});
const location = TestBed.inject(Location);
expect(location.normalize(url)).toBe(route);
});
it('in case APP_BASE_HREF starts with no origin', () => {
const origin = '';
const baseHref = getBaseHref(origin);
TestBed.configureTestingModule({providers: [{provide: APP_BASE_HREF, useValue: baseHref}]});
const location = TestBed.inject(Location);
expect(location.normalize(url)).toBe(route);
});
});
describe('location.normalize(url) should return properly normalized url', () => {
it('in case url starts with the substring equals APP_BASE_HREF', () => {
const baseHref = '/en';
const path = '/enigma';
const queryParams = '?param1=123';
const matrixParams = ';param1=123';
const fragment = '#anchor1';
TestBed.configureTestingModule({providers: [{provide: APP_BASE_HREF, useValue: baseHref}]});
const location = TestBed.inject(Location);
expect(location.normalize(path)).toBe(path);
expect(location.normalize(baseHref)).toBe('');
expect(location.normalize(baseHref + path)).toBe(path);
expect(location.normalize(baseHref + queryParams)).toBe(queryParams);
expect(location.normalize(baseHref + matrixParams)).toBe(matrixParams);
expect(location.normalize(baseHref + fragment)).toBe(fragment);
});
it('in case APP_BASE_HREF contains characters that have special meaning in a regex', () => {
const baseHref = 'c:/users/name(test)/en';
const path = '/test-path';
TestBed.configureTestingModule({providers: [{provide: APP_BASE_HREF, useValue: baseHref}]});
const location = TestBed.inject(Location);
expect(location.normalize(path)).toBe(path);
expect(location.normalize(baseHref)).toBe('');
expect(location.normalize(baseHref + path)).toBe(path);
});
it('should only strip a literal /index.html suffix, not arbitrary characters', () => {
const baseHref = '/en';
TestBed.configureTestingModule({providers: [{provide: APP_BASE_HREF, useValue: baseHref}]});
const location = TestBed.inject(Location);
// The `.` in the strip regex must be escaped; otherwise it would match any
// character and turn e.g. `/foo/indexXhtml` into `/foo`.
expect(location.normalize('/en/page/indexXhtml')).toBe('/page/indexXhtml');
expect(location.normalize('/en/page/index_html')).toBe('/page/index_html');
expect(location.normalize('/en/page/index.html')).toBe('/page');
});
});
});