Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

router(feature): allow configuration of when URL gets updated in navigation flow #24820

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
33 changes: 26 additions & 7 deletions packages/router/src/router.ts
Expand Up @@ -285,6 +285,18 @@ export class Router {
*/
paramsInheritanceStrategy: 'emptyOnly'|'always' = 'emptyOnly';

/**
* Defines when the router updates the browser URL. The default behavior is to update after
* successful navigation. However, some applications may prefer a mode where the URL gets
* updated at the beginning of navigation. The most common use case would be updating the
* URL early so if navigation fails, you can show an error message with the URL that failed.
* Available options are:
*
* - `'deferred'`, the default, updates the browser URL after navigation has finished.
* - `'eager'`, updates browser URL at the beginning of navigation.
*/
urlUpdateStrategy: 'deferred'|'eager' = 'deferred';

/**
* Creates the router service.
*/
Expand Down Expand Up @@ -610,6 +622,9 @@ export class Router {

if ((this.onSameUrlNavigation === 'reload' ? true : urlTransition) &&
this.urlHandlingStrategy.shouldProcessUrl(rawUrl)) {
if (this.urlUpdateStrategy === 'eager' && !extras.skipLocationChange) {
this.setBrowserUrl(rawUrl, !!extras.replaceUrl, id);
}
(this.events as Subject<Event>)
.next(new NavigationStart(id, this.serializeUrl(url), source, state));
Promise.resolve()
Expand Down Expand Up @@ -791,13 +806,8 @@ export class Router {

(this as{routerState: RouterState}).routerState = state;

if (!skipLocationChange) {
const path = this.urlSerializer.serialize(this.rawUrlTree);
if (this.location.isCurrentPathEqualTo(path) || replaceUrl) {
this.location.replaceState(path, '', {navigationId: id});
} else {
this.location.go(path, '', {navigationId: id});
}
if (this.urlUpdateStrategy === 'deferred' && !skipLocationChange) {
this.setBrowserUrl(this.rawUrlTree, replaceUrl, id);
}

new ActivateRoutes(
Expand Down Expand Up @@ -843,6 +853,15 @@ export class Router {
});
}

private setBrowserUrl(url: UrlTree, replaceUrl: boolean, id: number) {
const path = this.urlSerializer.serialize(url);
if (this.location.isCurrentPathEqualTo(path) || replaceUrl) {
this.location.replaceState(path, '', {navigationId: id});
} else {
this.location.go(path, '', {navigationId: id});
}
}

private resetStateAndUrl(storedState: RouterState, storedUrl: UrlTree, rawUrl: UrlTree): void {
(this as{routerState: RouterState}).routerState = storedState;
this.currentUrlTree = storedUrl;
Expand Down
16 changes: 16 additions & 0 deletions packages/router/src/router_module.ts
Expand Up @@ -405,6 +405,18 @@ export interface ExtraOptions {
* */
malformedUriErrorHandler?:
(error: URIError, urlSerializer: UrlSerializer, url: string) => UrlTree;

/**
* Defines when the router updates the browser URL. The default behavior is to update after
* successful navigation. However, some applications may prefer a mode where the URL gets
* updated at the beginning of navigation. The most common use case would be updating the
* URL early so if navigation fails, you can show an error message with the URL that failed.
* Available options are:
*
* - `'deferred'`, the default, updates the browser URL after navigation has finished.
* - `'eager'`, updates browser URL at the beginning of navigation.
*/
urlUpdateStrategy?: 'deferred'|'eager';
}

export function setupRouter(
Expand Down Expand Up @@ -449,6 +461,10 @@ export function setupRouter(
router.paramsInheritanceStrategy = opts.paramsInheritanceStrategy;
}

if (opts.urlUpdateStrategy) {
router.urlUpdateStrategy = opts.urlUpdateStrategy;
}

return router;
}

Expand Down
163 changes: 97 additions & 66 deletions packages/router/test/integration.spec.ts
Expand Up @@ -469,6 +469,31 @@ describe('Integration', () => {
expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]');
})));

it('should eagerly update the URL with urlUpdateStrategy="eagar"',
fakeAsync(inject([Router, Location], (router: Router, location: Location) => {
const fixture = TestBed.createComponent(RootCmp);
advance(fixture);

router.resetConfig([{path: 'team/:id', component: TeamCmp}]);

router.navigateByUrl('/team/22');
advance(fixture);
expect(location.path()).toEqual('/team/22');

expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]');

router.urlUpdateStrategy = 'eager';
(router as any).hooks.beforePreactivation = () => {
expect(location.path()).toEqual('/team/33');
expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]');
return of (null);
};
router.navigateByUrl('/team/33');

advance(fixture);
expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]');
})));

it('should navigate back and forward',
fakeAsync(inject([Router, Location], (router: Router, location: Location) => {
const fixture = createRoot(router, RootCmp);
Expand Down Expand Up @@ -868,93 +893,99 @@ describe('Integration', () => {
]);
})));

it('should dispatch NavigationError after the url has been reset back', fakeAsync(() => {
const router: Router = TestBed.get(Router);
const location: SpyLocation = TestBed.get(Location);
const fixture = createRoot(router, RootCmp);
// Errors should behave the same for both deferred and eager URL update strategies
['deferred', 'eager'].forEach((strat: any) => {
it('should dispatch NavigationError after the url has been reset back', fakeAsync(() => {
const router: Router = TestBed.get(Router);
const location: SpyLocation = TestBed.get(Location);
const fixture = createRoot(router, RootCmp);

router.resetConfig(
[{path: 'simple', component: SimpleCmp}, {path: 'throwing', component: ThrowingCmp}]);
router.resetConfig(
[{path: 'simple', component: SimpleCmp}, {path: 'throwing', component: ThrowingCmp}]);
router.urlUpdateStrategy = strat;

router.navigateByUrl('/simple');
advance(fixture);
router.navigateByUrl('/simple');
advance(fixture);

let routerUrlBeforeEmittingError = '';
let locationUrlBeforeEmittingError = '';
router.events.forEach(e => {
if (e instanceof NavigationError) {
routerUrlBeforeEmittingError = router.url;
locationUrlBeforeEmittingError = location.path();
}
});
let routerUrlBeforeEmittingError = '';
let locationUrlBeforeEmittingError = '';
router.events.forEach(e => {
if (e instanceof NavigationError) {
routerUrlBeforeEmittingError = router.url;
locationUrlBeforeEmittingError = location.path();
}
});

router.navigateByUrl('/throwing').catch(() => null);
advance(fixture);
router.navigateByUrl('/throwing').catch(() => null);
advance(fixture);

expect(routerUrlBeforeEmittingError).toEqual('/simple');
expect(locationUrlBeforeEmittingError).toEqual('/simple');
}));
expect(routerUrlBeforeEmittingError).toEqual('/simple');
expect(locationUrlBeforeEmittingError).toEqual('/simple');
}));

it('should reset the url with the right state when navigation errors', fakeAsync(() => {
const router: Router = TestBed.get(Router);
const location: SpyLocation = TestBed.get(Location);
const fixture = createRoot(router, RootCmp);
it('should reset the url with the right state when navigation errors', fakeAsync(() => {
const router: Router = TestBed.get(Router);
const location: SpyLocation = TestBed.get(Location);
const fixture = createRoot(router, RootCmp);

router.resetConfig([
{path: 'simple1', component: SimpleCmp}, {path: 'simple2', component: SimpleCmp},
{path: 'throwing', component: ThrowingCmp}
]);
router.resetConfig([
{path: 'simple1', component: SimpleCmp}, {path: 'simple2', component: SimpleCmp},
{path: 'throwing', component: ThrowingCmp}
]);
router.urlUpdateStrategy = strat;

let event: NavigationStart;
router.events.subscribe(e => {
if (e instanceof NavigationStart) {
event = e;
}
});

let event: NavigationStart;
router.events.subscribe(e => {
if (e instanceof NavigationStart) {
event = e;
}
});
router.navigateByUrl('/simple1');
advance(fixture);
const simple1NavStart = event !;

router.navigateByUrl('/simple1');
advance(fixture);
const simple1NavStart = event !;
router.navigateByUrl('/throwing').catch(() => null);
advance(fixture);

router.navigateByUrl('/throwing').catch(() => null);
advance(fixture);
router.navigateByUrl('/simple2');
advance(fixture);

router.navigateByUrl('/simple2');
advance(fixture);
location.back();
tick();

location.back();
tick();
expect(event !.restoredState !.navigationId).toEqual(simple1NavStart.id);
}));

expect(event !.restoredState !.navigationId).toEqual(simple1NavStart.id);
}));
it('should not trigger another navigation when resetting the url back due to a NavigationError',
fakeAsync(() => {
const router = TestBed.get(Router);
router.onSameUrlNavigation = 'reload';

it('should not trigger another navigation when resetting the url back due to a NavigationError',
fakeAsync(() => {
const router = TestBed.get(Router);
router.onSameUrlNavigation = 'reload';
const fixture = createRoot(router, RootCmp);

const fixture = createRoot(router, RootCmp);
router.resetConfig(
[{path: 'simple', component: SimpleCmp}, {path: 'throwing', component: ThrowingCmp}]);
router.urlUpdateStrategy = strat;

router.resetConfig(
[{path: 'simple', component: SimpleCmp}, {path: 'throwing', component: ThrowingCmp}]);
const events: any[] = [];
router.events.forEach((e: any) => {
if (e instanceof NavigationStart) {
events.push(e.url);
}
});

const events: any[] = [];
router.events.forEach((e: any) => {
if (e instanceof NavigationStart) {
events.push(e.url);
}
});
router.navigateByUrl('/simple');
advance(fixture);

router.navigateByUrl('/simple');
advance(fixture);
router.navigateByUrl('/throwing').catch(() => null);
advance(fixture);

router.navigateByUrl('/throwing').catch(() => null);
advance(fixture);
// we do not trigger another navigation to /simple
expect(events).toEqual(['/simple', '/throwing']);
}));

// we do not trigger another navigation to /simple
expect(events).toEqual(['/simple', '/throwing']);
}));
});

it('should dispatch NavigationCancel after the url has been reset back', fakeAsync(() => {
TestBed.configureTestingModule(
Expand Down
2 changes: 2 additions & 0 deletions tools/public_api_guard/router/router.d.ts
Expand Up @@ -121,6 +121,7 @@ export interface ExtraOptions {
preloadingStrategy?: any;
scrollOffset?: [number, number] | (() => [number, number]);
scrollPositionRestoration?: 'disabled' | 'enabled' | 'top';
urlUpdateStrategy?: 'deferred' | 'eager';
useHash?: boolean;
}

Expand Down Expand Up @@ -323,6 +324,7 @@ export declare class Router {
readonly routerState: RouterState;
readonly url: string;
urlHandlingStrategy: UrlHandlingStrategy;
urlUpdateStrategy: 'deferred' | 'eager';
constructor(rootComponentType: Type<any> | null, urlSerializer: UrlSerializer, rootContexts: ChildrenOutletContexts, location: Location, injector: Injector, loader: NgModuleFactoryLoader, compiler: Compiler, config: Routes);
createUrlTree(commands: any[], navigationExtras?: NavigationExtras): UrlTree;
dispose(): void;
Expand Down