feat(router): add urlUpdateStrategy allow updating the browser URL at the beginning of navigation (#24820)

Fixes #24616

PR Close #24820
This commit is contained in:
Jason Aden 2018-07-10 09:44:15 -07:00 committed by Matias Niemelä
parent 4d8b8ad372
commit 328971ffcc
4 changed files with 141 additions and 73 deletions

View File

@ -285,6 +285,18 @@ export class Router {
*/ */
paramsInheritanceStrategy: 'emptyOnly'|'always' = 'emptyOnly'; 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. * Creates the router service.
*/ */
@ -610,6 +622,9 @@ export class Router {
if ((this.onSameUrlNavigation === 'reload' ? true : urlTransition) && if ((this.onSameUrlNavigation === 'reload' ? true : urlTransition) &&
this.urlHandlingStrategy.shouldProcessUrl(rawUrl)) { this.urlHandlingStrategy.shouldProcessUrl(rawUrl)) {
if (this.urlUpdateStrategy === 'eager' && !extras.skipLocationChange) {
this.setBrowserUrl(rawUrl, !!extras.replaceUrl, id);
}
(this.events as Subject<Event>) (this.events as Subject<Event>)
.next(new NavigationStart(id, this.serializeUrl(url), source, state)); .next(new NavigationStart(id, this.serializeUrl(url), source, state));
Promise.resolve() Promise.resolve()
@ -791,13 +806,8 @@ export class Router {
(this as{routerState: RouterState}).routerState = state; (this as{routerState: RouterState}).routerState = state;
if (!skipLocationChange) { if (this.urlUpdateStrategy === 'deferred' && !skipLocationChange) {
const path = this.urlSerializer.serialize(this.rawUrlTree); this.setBrowserUrl(this.rawUrlTree, replaceUrl, id);
if (this.location.isCurrentPathEqualTo(path) || replaceUrl) {
this.location.replaceState(path, '', {navigationId: id});
} else {
this.location.go(path, '', {navigationId: id});
}
} }
new ActivateRoutes( new ActivateRoutes(
@ -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 { private resetStateAndUrl(storedState: RouterState, storedUrl: UrlTree, rawUrl: UrlTree): void {
(this as{routerState: RouterState}).routerState = storedState; (this as{routerState: RouterState}).routerState = storedState;
this.currentUrlTree = storedUrl; this.currentUrlTree = storedUrl;

View File

@ -405,6 +405,18 @@ export interface ExtraOptions {
* */ * */
malformedUriErrorHandler?: malformedUriErrorHandler?:
(error: URIError, urlSerializer: UrlSerializer, url: string) => UrlTree; (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( export function setupRouter(
@ -449,6 +461,10 @@ export function setupRouter(
router.paramsInheritanceStrategy = opts.paramsInheritanceStrategy; router.paramsInheritanceStrategy = opts.paramsInheritanceStrategy;
} }
if (opts.urlUpdateStrategy) {
router.urlUpdateStrategy = opts.urlUpdateStrategy;
}
return router; return router;
} }

View File

@ -469,6 +469,31 @@ describe('Integration', () => {
expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]'); 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', it('should navigate back and forward',
fakeAsync(inject([Router, Location], (router: Router, location: Location) => { fakeAsync(inject([Router, Location], (router: Router, location: Location) => {
const fixture = createRoot(router, RootCmp); const fixture = createRoot(router, RootCmp);
@ -868,93 +893,99 @@ describe('Integration', () => {
]); ]);
}))); })));
it('should dispatch NavigationError after the url has been reset back', fakeAsync(() => { // Errors should behave the same for both deferred and eager URL update strategies
const router: Router = TestBed.get(Router); ['deferred', 'eager'].forEach((strat: any) => {
const location: SpyLocation = TestBed.get(Location); it('should dispatch NavigationError after the url has been reset back', fakeAsync(() => {
const fixture = createRoot(router, RootCmp); const router: Router = TestBed.get(Router);
const location: SpyLocation = TestBed.get(Location);
const fixture = createRoot(router, RootCmp);
router.resetConfig( router.resetConfig(
[{path: 'simple', component: SimpleCmp}, {path: 'throwing', component: ThrowingCmp}]); [{path: 'simple', component: SimpleCmp}, {path: 'throwing', component: ThrowingCmp}]);
router.urlUpdateStrategy = strat;
router.navigateByUrl('/simple'); router.navigateByUrl('/simple');
advance(fixture); advance(fixture);
let routerUrlBeforeEmittingError = ''; let routerUrlBeforeEmittingError = '';
let locationUrlBeforeEmittingError = ''; let locationUrlBeforeEmittingError = '';
router.events.forEach(e => { router.events.forEach(e => {
if (e instanceof NavigationError) { if (e instanceof NavigationError) {
routerUrlBeforeEmittingError = router.url; routerUrlBeforeEmittingError = router.url;
locationUrlBeforeEmittingError = location.path(); locationUrlBeforeEmittingError = location.path();
} }
}); });
router.navigateByUrl('/throwing').catch(() => null); router.navigateByUrl('/throwing').catch(() => null);
advance(fixture); advance(fixture);
expect(routerUrlBeforeEmittingError).toEqual('/simple'); expect(routerUrlBeforeEmittingError).toEqual('/simple');
expect(locationUrlBeforeEmittingError).toEqual('/simple'); expect(locationUrlBeforeEmittingError).toEqual('/simple');
})); }));
it('should reset the url with the right state when navigation errors', fakeAsync(() => { it('should reset the url with the right state when navigation errors', fakeAsync(() => {
const router: Router = TestBed.get(Router); const router: Router = TestBed.get(Router);
const location: SpyLocation = TestBed.get(Location); const location: SpyLocation = TestBed.get(Location);
const fixture = createRoot(router, RootCmp); const fixture = createRoot(router, RootCmp);
router.resetConfig([ router.resetConfig([
{path: 'simple1', component: SimpleCmp}, {path: 'simple2', component: SimpleCmp}, {path: 'simple1', component: SimpleCmp}, {path: 'simple2', component: SimpleCmp},
{path: 'throwing', component: ThrowingCmp} {path: 'throwing', component: ThrowingCmp}
]); ]);
router.urlUpdateStrategy = strat;
let event: NavigationStart;
router.events.subscribe(e => {
if (e instanceof NavigationStart) {
event = e;
}
});
let event: NavigationStart; router.navigateByUrl('/simple1');
router.events.subscribe(e => { advance(fixture);
if (e instanceof NavigationStart) { const simple1NavStart = event !;
event = e;
}
});
router.navigateByUrl('/simple1'); router.navigateByUrl('/throwing').catch(() => null);
advance(fixture); advance(fixture);
const simple1NavStart = event !;
router.navigateByUrl('/throwing').catch(() => null); router.navigateByUrl('/simple2');
advance(fixture); advance(fixture);
router.navigateByUrl('/simple2'); location.back();
advance(fixture); tick();
location.back(); expect(event !.restoredState !.navigationId).toEqual(simple1NavStart.id);
tick(); }));
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', const fixture = createRoot(router, RootCmp);
fakeAsync(() => {
const router = TestBed.get(Router);
router.onSameUrlNavigation = 'reload';
const fixture = createRoot(router, RootCmp); router.resetConfig(
[{path: 'simple', component: SimpleCmp}, {path: 'throwing', component: ThrowingCmp}]);
router.urlUpdateStrategy = strat;
router.resetConfig( const events: any[] = [];
[{path: 'simple', component: SimpleCmp}, {path: 'throwing', component: ThrowingCmp}]); router.events.forEach((e: any) => {
if (e instanceof NavigationStart) {
events.push(e.url);
}
});
const events: any[] = []; router.navigateByUrl('/simple');
router.events.forEach((e: any) => { advance(fixture);
if (e instanceof NavigationStart) {
events.push(e.url);
}
});
router.navigateByUrl('/simple'); router.navigateByUrl('/throwing').catch(() => null);
advance(fixture); advance(fixture);
router.navigateByUrl('/throwing').catch(() => null); // we do not trigger another navigation to /simple
advance(fixture); 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(() => { it('should dispatch NavigationCancel after the url has been reset back', fakeAsync(() => {
TestBed.configureTestingModule( TestBed.configureTestingModule(

View File

@ -121,6 +121,7 @@ export interface ExtraOptions {
preloadingStrategy?: any; preloadingStrategy?: any;
scrollOffset?: [number, number] | (() => [number, number]); scrollOffset?: [number, number] | (() => [number, number]);
scrollPositionRestoration?: 'disabled' | 'enabled' | 'top'; scrollPositionRestoration?: 'disabled' | 'enabled' | 'top';
urlUpdateStrategy?: 'deferred' | 'eager';
useHash?: boolean; useHash?: boolean;
} }
@ -323,6 +324,7 @@ export declare class Router {
readonly routerState: RouterState; readonly routerState: RouterState;
readonly url: string; readonly url: string;
urlHandlingStrategy: UrlHandlingStrategy; urlHandlingStrategy: UrlHandlingStrategy;
urlUpdateStrategy: 'deferred' | 'eager';
constructor(rootComponentType: Type<any> | null, urlSerializer: UrlSerializer, rootContexts: ChildrenOutletContexts, location: Location, injector: Injector, loader: NgModuleFactoryLoader, compiler: Compiler, config: Routes); 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; createUrlTree(commands: any[], navigationExtras?: NavigationExtras): UrlTree;
dispose(): void; dispose(): void;