From 328971ffcc6f327a9dfa74f215df4fdefd8d85ee Mon Sep 17 00:00:00 2001 From: Jason Aden Date: Tue, 10 Jul 2018 09:44:15 -0700 Subject: [PATCH] feat(router): add urlUpdateStrategy allow updating the browser URL at the beginning of navigation (#24820) Fixes #24616 PR Close #24820 --- packages/router/src/router.ts | 33 ++++- packages/router/src/router_module.ts | 16 +++ packages/router/test/integration.spec.ts | 163 +++++++++++++--------- tools/public_api_guard/router/router.d.ts | 2 + 4 files changed, 141 insertions(+), 73 deletions(-) diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 962ffab889..cd8fb98aa4 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -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. */ @@ -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) .next(new NavigationStart(id, this.serializeUrl(url), source, state)); Promise.resolve() @@ -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( @@ -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; diff --git a/packages/router/src/router_module.ts b/packages/router/src/router_module.ts index e1ea750a94..73995ef7d3 100644 --- a/packages/router/src/router_module.ts +++ b/packages/router/src/router_module.ts @@ -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( @@ -449,6 +461,10 @@ export function setupRouter( router.paramsInheritanceStrategy = opts.paramsInheritanceStrategy; } + if (opts.urlUpdateStrategy) { + router.urlUpdateStrategy = opts.urlUpdateStrategy; + } + return router; } diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index 6797458a45..f4977e38db 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -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); @@ -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( diff --git a/tools/public_api_guard/router/router.d.ts b/tools/public_api_guard/router/router.d.ts index 1236127df2..8965f82018 100644 --- a/tools/public_api_guard/router/router.d.ts +++ b/tools/public_api_guard/router/router.d.ts @@ -121,6 +121,7 @@ export interface ExtraOptions { preloadingStrategy?: any; scrollOffset?: [number, number] | (() => [number, number]); scrollPositionRestoration?: 'disabled' | 'enabled' | 'top'; + urlUpdateStrategy?: 'deferred' | 'eager'; useHash?: boolean; } @@ -323,6 +324,7 @@ export declare class Router { readonly routerState: RouterState; readonly url: string; urlHandlingStrategy: UrlHandlingStrategy; + urlUpdateStrategy: 'deferred' | 'eager'; constructor(rootComponentType: Type | null, urlSerializer: UrlSerializer, rootContexts: ChildrenOutletContexts, location: Location, injector: Injector, loader: NgModuleFactoryLoader, compiler: Compiler, config: Routes); createUrlTree(commands: any[], navigationExtras?: NavigationExtras): UrlTree; dispose(): void;