From 82fed3c1e7d7283bdbae6f3892737f57e299e1cc Mon Sep 17 00:00:00 2001 From: Frederik Bosch Date: Tue, 12 May 2026 10:02:42 +0200 Subject: [PATCH] add Transition.waitFor() method --- packages/router_js/lib/route-info.ts | 1 + packages/router_js/lib/router.ts | 6 +- packages/router_js/lib/transition.ts | 5 + .../router_js/tests/async_get_handler_test.ts | 194 +++++++++++++++++- 4 files changed, 201 insertions(+), 5 deletions(-) diff --git a/packages/router_js/lib/route-info.ts b/packages/router_js/lib/route-info.ts index ecd4875b1a8..5bf866fb049 100644 --- a/packages/router_js/lib/route-info.ts +++ b/packages/router_js/lib/route-info.ts @@ -248,6 +248,7 @@ export default class InternalRouteInfo { throwIfAborted(transition); return route; }) + .then(() => transition._pausingPromise || Promise.resolve(null)) .then(() => this.runBeforeModelHook(transition)) .then(() => throwIfAborted(transition)) .then(() => this.getModel(transition)) diff --git a/packages/router_js/lib/router.ts b/packages/router_js/lib/router.ts index 0dd1300ae8c..447e013d187 100644 --- a/packages/router_js/lib/router.ts +++ b/packages/router_js/lib/router.ts @@ -123,8 +123,10 @@ export default abstract class Router { this.routeWillChange(newTransition); - newTransition.promise = newTransition.promise!.then( - (result: TransitionState | Route | Error | undefined) => { + let transitionPromise = newTransition.promise!; + newTransition.promise = (newTransition._pausingPromise || Promise.resolve(null)) + .then(() => transitionPromise) + .then((result: TransitionState | Route | Error | undefined) => { if (!newTransition.isAborted) { this._updateURL(newTransition, oldState); this.didTransition(this.currentRouteInfos!); diff --git a/packages/router_js/lib/transition.ts b/packages/router_js/lib/transition.ts index b4c0e26634c..3122e65835b 100644 --- a/packages/router_js/lib/transition.ts +++ b/packages/router_js/lib/transition.ts @@ -71,6 +71,7 @@ export default class Transition implements Partial> _visibleQueryParams: Dict = {}; isIntermediate = false; [REDIRECT_DESTINATION_SYMBOL]?: Transition; + _pausingPromise?: Promise; /** In non-production builds, this function will return the stack that this Transition was @@ -309,6 +310,10 @@ export default class Transition implements Partial> } } + waitFor(promise: Promise) { + this._pausingPromise = promise; + } + redirect(newTransition: Transition) { this[REDIRECT_DESTINATION_SYMBOL] = newTransition; this.rollback(); diff --git a/packages/router_js/tests/async_get_handler_test.ts b/packages/router_js/tests/async_get_handler_test.ts index 73637c8610a..b3c4018ff25 100644 --- a/packages/router_js/tests/async_get_handler_test.ts +++ b/packages/router_js/tests/async_get_handler_test.ts @@ -1,11 +1,13 @@ -import type { Route } from '../index'; +import Router, { Route, Transition, TransitionError } from '../index'; +import RouteInfo from '../lib/route-info'; import type { Dict } from '../lib/core'; import { Promise } from 'rsvp'; -import { createHandler, TestRouter } from './test_helpers'; +import { createHandler, TestRouter, trigger } from './test_helpers'; -function map(router: TestRouter) { +function map(router: Router) { router.map(function (match) { match('/index').to('index'); + match('/query').to('query'); match('/foo').to('foo', function (match) { match('/').to('fooIndex'); match('/bar').to('fooBar'); @@ -13,6 +15,22 @@ function map(router: TestRouter) { }); } +function createDeferred(): { + promise: Promise; + resolve: (value?: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value?: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + // Intentionally use QUnit.module instead of module from test_helpers // so that we avoid using Backburner to handle the async portions of // the test suite @@ -111,3 +129,173 @@ QUnit.test('calls hooks of lazily-resolved routes in order', function (assert) { done(); }, null); }); + +QUnit.test('pause transitions', function (assert) { + let done = assert.async(); + let operations: string[] = []; + let enteredWillChange = 0; + let enteredDidError = 0; + + class PauseRouter extends TestRouter { + getRoute(name: string) { + operations.push('resolved ' + name); + return routes[name] || (routes[name] = createHandler('empty')); + } + } + + let router: Router = new PauseRouter(); + + router.routeWillChange = (transition: Transition) => { + enteredWillChange++; + + const { promise, resolve, reject } = createDeferred(); + transition.waitFor(promise); + setTimeout(() => { + operations.push('paused transition'); + if (enteredWillChange === 1) { + resolve(); + operations.push('resolved pause'); + } else { + reject('reject'); + operations.push('rejected pause'); + } + }, 1); + }; + + router.transitionDidError = (error: TransitionError, transition: Transition) => { + enteredDidError++; + assert.equal('reject', error.error); + transition.trigger(false, 'error', error.error, transition, error.route); + transition.abort(); + return error.error; + }; + + map(router); + + routes['index'] = createHandler('index', { + model: function () { + operations.push('model index'); + }, + }); + routes['foo'] = createHandler('foo', { + model: function () { + operations.push('model foo'); + }, + }); + routes['fooBar'] = createHandler('fooBar', { + model: function () { + operations.push('model fooBar'); + }, + }); + + router.transitionTo('/index').then(function () { + assert.deepEqual( + operations, + [ + 'resolved index', + 'paused transition', + 'resolved pause', + 'model index', + ], + 'order of /index operations is correct' + ); + + operations = []; + + router.transitionTo('/foo/bar').catch(function () { + assert.deepEqual( + operations, + [ + 'resolved foo', + 'resolved fooBar', + 'paused transition', + 'rejected pause', + ], + 'order of /foo/bar operations is correct' + ); + done(); + }); + + }, null); +}); + +QUnit.test('pause transitions query params only', function (assert) { + let done = assert.async(); + let operations: string[] = []; + let enteredWillChange = 0; + + class QpPauseRouter extends TestRouter { + getRoute(name: string) { + operations.push('resolved ' + name); + return routes[name] || (routes[name] = createHandler('empty')); + } + triggerEvent( + handlerInfos: RouteInfo[], + ignoreFailure: boolean, + name: string, + args: any[] + ) { + trigger(handlerInfos, ignoreFailure, name, ...args); + } + } + + let router: Router = new QpPauseRouter(); + + router.routeWillChange = (transition: Transition) => { + enteredWillChange++; + + const { promise, resolve } = createDeferred(); + transition.waitFor(promise); + setTimeout(() => { + operations.push('paused transition'); + resolve(); + operations.push('resolved pause'); + }, 1); + }; + + map(router); + + routes['query'] = createHandler('query', { + model: function () { + operations.push('model query'); + }, + + events: { + finalizeQueryParamChange: function ({ param }: { param: string }) { + operations.push('param is now ' + param); + } + }, + }); + + router.transitionTo('/query').then(function () { + operations = []; + router.transitionTo('/query?param=1').then(function () { + assert.deepEqual( + operations, + [ + 'resolved query', + 'param is now 1', + 'paused transition', + 'resolved pause', + ], + 'order of /query?param=1 operations is correct' + ); + + operations = []; + router.transitionTo('/query?param=2').then(function () { + assert.deepEqual( + operations, + [ + 'resolved query', + 'param is now 2', + 'paused transition', + 'resolved pause', + ], + 'order of /query?param=2 operations is correct' + ); + done(); + }); + + }, null); + }); +});