diff --git a/packages/@ember/routing/route.ts b/packages/@ember/routing/route.ts index ae3b59a1a59..e09bf00ec01 100644 --- a/packages/@ember/routing/route.ts +++ b/packages/@ember/routing/route.ts @@ -2114,6 +2114,17 @@ Route.reopen({ let options = this._optionsForQueryParam(qp); assert('options exists', options && typeof options === 'object'); if ((get(options, 'refreshModel') as boolean) && this._router.currentState) { + // `changed` is computed from router state query params, where default values + // may have been pruned during finalization. A later transition can reintroduce + // the same serialized value via sticky QP hydration or URL parsing, making the + // QP appear changed even though its finalized value is unchanged. Only refresh + // the model when the serialized value differs from the last finalized value. + if (change in changed) { + let newSerializedValue = (changed as Record)[change]; + if (newSerializedValue === qp.serializedValue) { + continue; + } + } this.refresh(); break; } diff --git a/packages/ember/tests/routing/router_service_test/transitionTo_test.js b/packages/ember/tests/routing/router_service_test/transitionTo_test.js index 0d0ab1db4cf..c36cc973e72 100644 --- a/packages/ember/tests/routing/router_service_test/transitionTo_test.js +++ b/packages/ember/tests/routing/router_service_test/transitionTo_test.js @@ -439,5 +439,121 @@ moduleFor( assert.equal(this.routerService.get('currentURL'), '/?url_sort=a'); }); } + + async ['@test RouterService#transitionTo with route name and unchanged application query params does not abort or re-run application model hook']( + assert + ) { + assert.expect(3); + + let applicationModelHookCallCount = 0; + + this.add( + 'route:application', + class extends Route { + queryParams = { + filter: { + refreshModel: true, + }, + sort: { + refreshModel: true, + }, + }; + + model() { + applicationModelHookCallCount++; + return {}; + } + } + ); + + this.add( + 'controller:application', + Controller.extend({ + queryParams: ['filter', 'sort'], + filter: '', + sort: '', + }) + ); + + await this.visit('/?filter=&sort='); + + assert.equal( + applicationModelHookCallCount, + 1, + 'application model hook called on initial visit' + ); + + await this.routerService.transitionTo('parent.child'); + + assert.equal( + this.routerService.get('currentRouteName'), + 'parent.child', + 'transitioned to child route' + ); + + assert.equal( + applicationModelHookCallCount, + 1, + 'application model hook not re-run when transitioning with unchanged sticky query params' + ); + } + + async ['@test RouterService#transitionTo with URL string and unchanged application query params does not abort or re-run application model hook']( + assert + ) { + assert.expect(3); + + let applicationModelHookCallCount = 0; + + this.add( + 'route:application', + class extends Route { + queryParams = { + filter: { + refreshModel: true, + }, + sort: { + refreshModel: true, + }, + }; + + model() { + applicationModelHookCallCount++; + return {}; + } + } + ); + + this.add( + 'controller:application', + Controller.extend({ + queryParams: ['filter', 'sort'], + filter: '', + sort: '', + }) + ); + + await this.visit('/?filter=&sort='); + + assert.equal( + applicationModelHookCallCount, + 1, + 'application model hook called on initial visit' + ); + + await this.routerService.transitionTo('/child?filter=&sort='); + + assert.equal( + this.routerService.get('currentRouteName'), + 'parent.child', + 'transitioned to child route' + ); + + assert.equal( + applicationModelHookCallCount, + 1, + 'application model hook not re-run when transitioning with unchanged query params' + ); + } } );