From d162b70845a7cf3640e74cb6cb129872cf4cd99b Mon Sep 17 00:00:00 2001 From: Sam Van Campenhout Date: Sun, 8 Mar 2026 00:28:07 +0100 Subject: [PATCH 1/6] [failing test] @model should be stable when transitioning out of route When transitioning between routes, the @model argument on a Glimmer component becomes unstable during willDestroy - the model value changes before the component is properly destroyed. Requires @glimmer/component Vite alias to resolve in tests. Based on PR #20959 by @Windvis. --- .../integration/application/rendering-test.js | 93 +++++++++++++++++++ vite.config.mjs | 8 ++ 2 files changed, 101 insertions(+) diff --git a/packages/@ember/-internals/glimmer/tests/integration/application/rendering-test.js b/packages/@ember/-internals/glimmer/tests/integration/application/rendering-test.js index 3e32631ff08..d7563ea26da 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/application/rendering-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/application/rendering-test.js @@ -6,6 +6,7 @@ import { service } from '@ember/service'; import { Component } from '@ember/-internals/glimmer'; import { tracked } from '@ember/-internals/metal'; import { set } from '@ember/object'; +import GlimmerComponent from '@glimmer/component'; import { backtrackingMessageFor } from '../../utils/debug-stack'; import { runTask } from '../../../../../../internal-test-helpers/lib/run'; import { template } from '@ember/template-compiler'; @@ -398,6 +399,98 @@ moduleFor( }); } + async ['@test @model should be stable when transitioning out of the route']() { + let assert = this.assert; + + this.router.map(function () { + this.route('a', function () { + this.route('b'); + this.route('c'); + }); + this.route('d', function () { + this.route('e'); + }); + this.route('f'); + }); + + this.addComponent('foo', { + ComponentClass: class extends GlimmerComponent { + willDestroy() { + assert.step(this.args.model); + } + }, + }); + this.add( + 'route:a', + class extends Route { + model() { + return 'a'; + } + } + ); + this.add( + 'route:a.b', + class extends Route { + model() { + return 'b'; + } + } + ); + this.addTemplate('a.b', ''); + this.add( + 'route:a.c', + class extends Route { + model() { + return 'c'; + } + } + ); + this.add( + 'route:d', + class extends Route { + model() { + return 'd'; + } + } + ); + this.add( + 'route:d.e', + class extends Route { + model() { + return 'e'; + } + } + ); + this.add( + 'route:f', + class extends Route { + model() { + return 'f'; + } + } + ); + + await this.visit('/a/b'); + await this.visit('/a'); + + await this.visit('/a/b'); + await this.visit('/a/c'); + + await this.visit('/a/b'); + await this.visit('/d'); + + await this.visit('/a/b'); + await this.visit('/d/e'); + + await this.visit('/a/b'); + await this.visit('/f'); + + this.assert.verifySteps( + ['b', 'b', 'b', 'b', 'b'], + 'The @model property of the Foo component should be stable in the willDestroy hook' + ); + } + ['@test it should produce a stable DOM when the model changes']() { this.router.map(function () { this.route('color', { path: '/colors/:color' }); diff --git a/vite.config.mjs b/vite.config.mjs index 8d169d67e94..6933e10051d 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -46,6 +46,14 @@ export default defineConfig(({ mode }) => { viteResolverBug(), version(), ], + resolve: { + alias: { + '@glimmer/component': resolve( + dirname(fileURLToPath(import.meta.url)), + './packages/@glimmer/component/dist/index.js' + ), + }, + }, optimizeDeps: { noDiscovery: true, include: ['expect-type'] }, publicDir: 'tests/public', build, From 54b3e586bc8f2d5c130ab542fa81811ca81f475a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sun, 8 Mar 2026 01:17:48 +0100 Subject: [PATCH 2/6] [BUGFIX] Stabilize @model during route transitions When transitioning between routes, @model becomes undefined in Glimmer component willDestroy hooks. The existing guard (lastState === state) only detects outlet changes at the same level. When a parent outlet tears down first, the dynamic scope refs silently redirect to the new route's outlet state while the guard still passes. Add a controller identity check so the model ref detects when its outletRef has been redirected to a different route's data. Fixes #18987 --- .../-internals/glimmer/lib/syntax/outlet.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/@ember/-internals/glimmer/lib/syntax/outlet.ts b/packages/@ember/-internals/glimmer/lib/syntax/outlet.ts index c4f6909a579..4529a5364f5 100644 --- a/packages/@ember/-internals/glimmer/lib/syntax/outlet.ts +++ b/packages/@ember/-internals/glimmer/lib/syntax/outlet.ts @@ -149,15 +149,29 @@ export const outletHelper = internalHelper( // Store the value of the model let model = valueForRef(modelRef); + // The controller for this outlet, used to verify the outletRef + // still points to the correct route's data. + let outletController = state.controller; + // Create a compute ref which we pass in as the `{{@model}}` reference // for the outlet. This ref will update and return the value of the // model _until_ the outlet itself changes. Once the outlet changes, // dynamic scope also changes, and so the original model ref would not // provide the correct updated value. So we stop updating and return // the _last_ model value for that outlet. + // + // We also verify that the outletRef still resolves to this route's + // data by comparing controller identity. This handles the case where + // a parent outlet is torn down first: the dynamic scope refs now + // point to the new route's outlet state, but this outlet's outer + // compute ref hasn't re-evaluated yet, so `lastState === state` is + // still true. The controller check catches this case. named['model'] = createComputeRef(() => { if (lastState === state) { - model = valueForRef(modelRef); + let currentOutlet = valueForRef(outletRef); + if (currentOutlet?.render?.controller === outletController) { + model = valueForRef(modelRef); + } } return model; From 4441932af803327ed5d1853f653ecb93c7781151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sun, 8 Mar 2026 01:42:41 +0100 Subject: [PATCH 3/6] move internal test from rendering-test.js to smoke test so that the internal @glimmer/component import isn't necessary in vite.config --- .../integration/application/rendering-test.js | 93 ------------------ smoke-tests/scenarios/basic-test.ts | 94 ++++++++++++++++++- .../app/components/model-probe.gjs | 20 ++++ smoke-tests/v2-app-template/app/router.js | 11 ++- smoke-tests/v2-app-template/app/routes/a.js | 7 ++ smoke-tests/v2-app-template/app/routes/a/b.js | 7 ++ smoke-tests/v2-app-template/app/routes/a/c.js | 7 ++ smoke-tests/v2-app-template/app/routes/d.js | 7 ++ smoke-tests/v2-app-template/app/routes/d/e.js | 7 ++ smoke-tests/v2-app-template/app/routes/f.js | 7 ++ .../v2-app-template/app/templates/a.gjs | 1 + .../v2-app-template/app/templates/a/b.gjs | 3 + .../tests/acceptance/model-stability-test.gjs | 43 +++++++++ vite.config.mjs | 8 -- 14 files changed, 212 insertions(+), 103 deletions(-) create mode 100644 smoke-tests/v2-app-template/app/components/model-probe.gjs create mode 100644 smoke-tests/v2-app-template/app/routes/a.js create mode 100644 smoke-tests/v2-app-template/app/routes/a/b.js create mode 100644 smoke-tests/v2-app-template/app/routes/a/c.js create mode 100644 smoke-tests/v2-app-template/app/routes/d.js create mode 100644 smoke-tests/v2-app-template/app/routes/d/e.js create mode 100644 smoke-tests/v2-app-template/app/routes/f.js create mode 100644 smoke-tests/v2-app-template/app/templates/a.gjs create mode 100644 smoke-tests/v2-app-template/app/templates/a/b.gjs create mode 100644 smoke-tests/v2-app-template/tests/acceptance/model-stability-test.gjs diff --git a/packages/@ember/-internals/glimmer/tests/integration/application/rendering-test.js b/packages/@ember/-internals/glimmer/tests/integration/application/rendering-test.js index d7563ea26da..3e32631ff08 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/application/rendering-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/application/rendering-test.js @@ -6,7 +6,6 @@ import { service } from '@ember/service'; import { Component } from '@ember/-internals/glimmer'; import { tracked } from '@ember/-internals/metal'; import { set } from '@ember/object'; -import GlimmerComponent from '@glimmer/component'; import { backtrackingMessageFor } from '../../utils/debug-stack'; import { runTask } from '../../../../../../internal-test-helpers/lib/run'; import { template } from '@ember/template-compiler'; @@ -399,98 +398,6 @@ moduleFor( }); } - async ['@test @model should be stable when transitioning out of the route']() { - let assert = this.assert; - - this.router.map(function () { - this.route('a', function () { - this.route('b'); - this.route('c'); - }); - this.route('d', function () { - this.route('e'); - }); - this.route('f'); - }); - - this.addComponent('foo', { - ComponentClass: class extends GlimmerComponent { - willDestroy() { - assert.step(this.args.model); - } - }, - }); - this.add( - 'route:a', - class extends Route { - model() { - return 'a'; - } - } - ); - this.add( - 'route:a.b', - class extends Route { - model() { - return 'b'; - } - } - ); - this.addTemplate('a.b', ''); - this.add( - 'route:a.c', - class extends Route { - model() { - return 'c'; - } - } - ); - this.add( - 'route:d', - class extends Route { - model() { - return 'd'; - } - } - ); - this.add( - 'route:d.e', - class extends Route { - model() { - return 'e'; - } - } - ); - this.add( - 'route:f', - class extends Route { - model() { - return 'f'; - } - } - ); - - await this.visit('/a/b'); - await this.visit('/a'); - - await this.visit('/a/b'); - await this.visit('/a/c'); - - await this.visit('/a/b'); - await this.visit('/d'); - - await this.visit('/a/b'); - await this.visit('/d/e'); - - await this.visit('/a/b'); - await this.visit('/f'); - - this.assert.verifySteps( - ['b', 'b', 'b', 'b', 'b'], - 'The @model property of the Foo component should be stable in the willDestroy hook' - ); - } - ['@test it should produce a stable DOM when the model changes']() { this.router.map(function () { this.route('color', { path: '/colors/:color' }); diff --git a/smoke-tests/scenarios/basic-test.ts b/smoke-tests/scenarios/basic-test.ts index 1e575338e0e..0082dcebe40 100644 --- a/smoke-tests/scenarios/basic-test.ts +++ b/smoke-tests/scenarios/basic-test.ts @@ -18,10 +18,33 @@ function basicTest(scenarios: Scenarios, appName: string) { } Router.map(function () { - this.route('example-gjs-route') + this.route('example-gjs-route'); + this.route('a', function () { + this.route('b'); + this.route('c'); + }); + this.route('d', function () { + this.route('e'); + }); + this.route('f'); }); `, components: { + 'model-probe.gjs': ` + import Component from '@glimmer/component'; + + const destroyedModels = []; + export function getDestroyedModels() { return destroyedModels; } + export function clearDestroyedModels() { destroyedModels.length = 0; } + + export default class ModelProbe extends Component { + willDestroy() { + super.willDestroy(...arguments); + destroyedModels.push(this.args.model); + } + + } + `, 'interactive-example.js': ` import { template } from '@ember/template-compiler'; import Component from '@glimmer/component'; @@ -66,6 +89,34 @@ function basicTest(scenarios: Scenarios, appName: string) { } } `, + 'a.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { model() { return 'a'; } } + `, + a: { + 'b.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { model() { return 'b'; } } + `, + 'c.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { model() { return 'c'; } } + `, + }, + 'd.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { model() { return 'd'; } } + `, + d: { + 'e.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { model() { return 'e'; } } + `, + }, + 'f.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { model() { return 'f'; } } + `, }, templates: { 'example-gjs-route.gjs': ` @@ -83,6 +134,13 @@ function basicTest(scenarios: Scenarios, appName: string) { } `, + 'a.gjs': ``, + a: { + 'b.gjs': ` + import ModelProbe from '${appName}/components/model-probe'; + + `, + }, }, }, tests: { @@ -104,6 +162,40 @@ function basicTest(scenarios: Scenarios, appName: string) { }); }); `, + 'model-stability-test.js': ` + import { module, test } from 'qunit'; + import { visit } from '@ember/test-helpers'; + import { setupApplicationTest } from '${appName}/tests/helpers'; + import { getDestroyedModels, clearDestroyedModels } from '${appName}/components/model-probe'; + + module('Acceptance | @model stability during route transitions', function (hooks) { + setupApplicationTest(hooks); + hooks.beforeEach(function () { clearDestroyedModels(); }); + + test('@model should be stable when transitioning out of the route', async function (assert) { + await visit('/a/b'); + await visit('/a'); + + await visit('/a/b'); + await visit('/a/c'); + + await visit('/a/b'); + await visit('/d'); + + await visit('/a/b'); + await visit('/d/e'); + + await visit('/a/b'); + await visit('/f'); + + assert.deepEqual( + getDestroyedModels(), + ['b', 'b', 'b', 'b', 'b'], + 'The @model value should remain stable in willDestroy for all transition types' + ); + }); + }); + `, }, integration: { 'tracked-built-ins-macro-test.gjs': ` diff --git a/smoke-tests/v2-app-template/app/components/model-probe.gjs b/smoke-tests/v2-app-template/app/components/model-probe.gjs new file mode 100644 index 00000000000..0ecc23b3b33 --- /dev/null +++ b/smoke-tests/v2-app-template/app/components/model-probe.gjs @@ -0,0 +1,20 @@ +import Component from '@glimmer/component'; + +const destroyedModels = []; + +export function getDestroyedModels() { + return destroyedModels; +} + +export function clearDestroyedModels() { + destroyedModels.length = 0; +} + +export default class ModelProbe extends Component { + willDestroy() { + super.willDestroy(...arguments); + destroyedModels.push(this.args.model); + } + + +} diff --git a/smoke-tests/v2-app-template/app/router.js b/smoke-tests/v2-app-template/app/router.js index bd2399136e0..c3530812b85 100644 --- a/smoke-tests/v2-app-template/app/router.js +++ b/smoke-tests/v2-app-template/app/router.js @@ -6,4 +6,13 @@ export default class Router extends EmberRouter { rootURL = config.rootURL; } -Router.map(function () {}); +Router.map(function () { + this.route('a', function () { + this.route('b'); + this.route('c'); + }); + this.route('d', function () { + this.route('e'); + }); + this.route('f'); +}); diff --git a/smoke-tests/v2-app-template/app/routes/a.js b/smoke-tests/v2-app-template/app/routes/a.js new file mode 100644 index 00000000000..61c4decff1e --- /dev/null +++ b/smoke-tests/v2-app-template/app/routes/a.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class ARoute extends Route { + model() { + return 'a'; + } +} diff --git a/smoke-tests/v2-app-template/app/routes/a/b.js b/smoke-tests/v2-app-template/app/routes/a/b.js new file mode 100644 index 00000000000..47d9db3a5c2 --- /dev/null +++ b/smoke-tests/v2-app-template/app/routes/a/b.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class ABRoute extends Route { + model() { + return 'b'; + } +} diff --git a/smoke-tests/v2-app-template/app/routes/a/c.js b/smoke-tests/v2-app-template/app/routes/a/c.js new file mode 100644 index 00000000000..ea986674079 --- /dev/null +++ b/smoke-tests/v2-app-template/app/routes/a/c.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class ACRoute extends Route { + model() { + return 'c'; + } +} diff --git a/smoke-tests/v2-app-template/app/routes/d.js b/smoke-tests/v2-app-template/app/routes/d.js new file mode 100644 index 00000000000..7ff9834f947 --- /dev/null +++ b/smoke-tests/v2-app-template/app/routes/d.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class DRoute extends Route { + model() { + return 'd'; + } +} diff --git a/smoke-tests/v2-app-template/app/routes/d/e.js b/smoke-tests/v2-app-template/app/routes/d/e.js new file mode 100644 index 00000000000..04ace684fef --- /dev/null +++ b/smoke-tests/v2-app-template/app/routes/d/e.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class DERoute extends Route { + model() { + return 'e'; + } +} diff --git a/smoke-tests/v2-app-template/app/routes/f.js b/smoke-tests/v2-app-template/app/routes/f.js new file mode 100644 index 00000000000..7df7eb2ddcc --- /dev/null +++ b/smoke-tests/v2-app-template/app/routes/f.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class FRoute extends Route { + model() { + return 'f'; + } +} diff --git a/smoke-tests/v2-app-template/app/templates/a.gjs b/smoke-tests/v2-app-template/app/templates/a.gjs new file mode 100644 index 00000000000..f3b67262940 --- /dev/null +++ b/smoke-tests/v2-app-template/app/templates/a.gjs @@ -0,0 +1 @@ + diff --git a/smoke-tests/v2-app-template/app/templates/a/b.gjs b/smoke-tests/v2-app-template/app/templates/a/b.gjs new file mode 100644 index 00000000000..db4f434482b --- /dev/null +++ b/smoke-tests/v2-app-template/app/templates/a/b.gjs @@ -0,0 +1,3 @@ +import ModelProbe from 'v2-app-template/components/model-probe'; + + diff --git a/smoke-tests/v2-app-template/tests/acceptance/model-stability-test.gjs b/smoke-tests/v2-app-template/tests/acceptance/model-stability-test.gjs new file mode 100644 index 00000000000..487cf5aa597 --- /dev/null +++ b/smoke-tests/v2-app-template/tests/acceptance/model-stability-test.gjs @@ -0,0 +1,43 @@ +import { module, test } from 'qunit'; +import { visit } from '@ember/test-helpers'; +import { setupApplicationTest } from 'ember-qunit'; +import { + getDestroyedModels, + clearDestroyedModels, +} from 'v2-app-template/components/model-probe'; + +module('Acceptance | @model stability during route transitions', function (hooks) { + setupApplicationTest(hooks); + + hooks.beforeEach(function () { + clearDestroyedModels(); + }); + + test('@model should be stable when transitioning out of the route', async function (assert) { + // Transition to sibling's parent (up one level) + await visit('/a/b'); + await visit('/a'); + + // Transition to sibling route + await visit('/a/b'); + await visit('/a/c'); + + // Transition to unrelated parent route + await visit('/a/b'); + await visit('/d'); + + // Transition to unrelated nested route + await visit('/a/b'); + await visit('/d/e'); + + // Transition to unrelated leaf route + await visit('/a/b'); + await visit('/f'); + + assert.deepEqual( + getDestroyedModels(), + ['b', 'b', 'b', 'b', 'b'], + 'The @model value should remain stable in willDestroy for all transition types' + ); + }); +}); diff --git a/vite.config.mjs b/vite.config.mjs index 6933e10051d..8d169d67e94 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -46,14 +46,6 @@ export default defineConfig(({ mode }) => { viteResolverBug(), version(), ], - resolve: { - alias: { - '@glimmer/component': resolve( - dirname(fileURLToPath(import.meta.url)), - './packages/@glimmer/component/dist/index.js' - ), - }, - }, optimizeDeps: { noDiscovery: true, include: ['expect-type'] }, publicDir: 'tests/public', build, From 0f244bed25a5345b285f7d1da8fa5656e73b1771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sun, 8 Mar 2026 01:57:34 +0100 Subject: [PATCH 4/6] cleanup: move from v2-app-template to scenario/basic-test --- smoke-tests/scenarios/basic-test.ts | 2 +- .../app/components/model-probe.gjs | 20 --------- smoke-tests/v2-app-template/app/routes/a.js | 7 --- smoke-tests/v2-app-template/app/routes/a/b.js | 7 --- smoke-tests/v2-app-template/app/routes/a/c.js | 7 --- smoke-tests/v2-app-template/app/routes/d.js | 7 --- smoke-tests/v2-app-template/app/routes/d/e.js | 7 --- smoke-tests/v2-app-template/app/routes/f.js | 7 --- .../v2-app-template/app/templates/a.gjs | 1 - .../v2-app-template/app/templates/a/b.gjs | 3 -- .../tests/acceptance/model-stability-test.gjs | 43 ------------------- 11 files changed, 1 insertion(+), 110 deletions(-) delete mode 100644 smoke-tests/v2-app-template/app/components/model-probe.gjs delete mode 100644 smoke-tests/v2-app-template/app/routes/a.js delete mode 100644 smoke-tests/v2-app-template/app/routes/a/b.js delete mode 100644 smoke-tests/v2-app-template/app/routes/a/c.js delete mode 100644 smoke-tests/v2-app-template/app/routes/d.js delete mode 100644 smoke-tests/v2-app-template/app/routes/d/e.js delete mode 100644 smoke-tests/v2-app-template/app/routes/f.js delete mode 100644 smoke-tests/v2-app-template/app/templates/a.gjs delete mode 100644 smoke-tests/v2-app-template/app/templates/a/b.gjs delete mode 100644 smoke-tests/v2-app-template/tests/acceptance/model-stability-test.gjs diff --git a/smoke-tests/scenarios/basic-test.ts b/smoke-tests/scenarios/basic-test.ts index 0082dcebe40..55d901608eb 100644 --- a/smoke-tests/scenarios/basic-test.ts +++ b/smoke-tests/scenarios/basic-test.ts @@ -39,7 +39,7 @@ function basicTest(scenarios: Scenarios, appName: string) { export default class ModelProbe extends Component { willDestroy() { - super.willDestroy(...arguments); + super.willDestroy(); destroyedModels.push(this.args.model); } diff --git a/smoke-tests/v2-app-template/app/components/model-probe.gjs b/smoke-tests/v2-app-template/app/components/model-probe.gjs deleted file mode 100644 index 0ecc23b3b33..00000000000 --- a/smoke-tests/v2-app-template/app/components/model-probe.gjs +++ /dev/null @@ -1,20 +0,0 @@ -import Component from '@glimmer/component'; - -const destroyedModels = []; - -export function getDestroyedModels() { - return destroyedModels; -} - -export function clearDestroyedModels() { - destroyedModels.length = 0; -} - -export default class ModelProbe extends Component { - willDestroy() { - super.willDestroy(...arguments); - destroyedModels.push(this.args.model); - } - - -} diff --git a/smoke-tests/v2-app-template/app/routes/a.js b/smoke-tests/v2-app-template/app/routes/a.js deleted file mode 100644 index 61c4decff1e..00000000000 --- a/smoke-tests/v2-app-template/app/routes/a.js +++ /dev/null @@ -1,7 +0,0 @@ -import Route from '@ember/routing/route'; - -export default class ARoute extends Route { - model() { - return 'a'; - } -} diff --git a/smoke-tests/v2-app-template/app/routes/a/b.js b/smoke-tests/v2-app-template/app/routes/a/b.js deleted file mode 100644 index 47d9db3a5c2..00000000000 --- a/smoke-tests/v2-app-template/app/routes/a/b.js +++ /dev/null @@ -1,7 +0,0 @@ -import Route from '@ember/routing/route'; - -export default class ABRoute extends Route { - model() { - return 'b'; - } -} diff --git a/smoke-tests/v2-app-template/app/routes/a/c.js b/smoke-tests/v2-app-template/app/routes/a/c.js deleted file mode 100644 index ea986674079..00000000000 --- a/smoke-tests/v2-app-template/app/routes/a/c.js +++ /dev/null @@ -1,7 +0,0 @@ -import Route from '@ember/routing/route'; - -export default class ACRoute extends Route { - model() { - return 'c'; - } -} diff --git a/smoke-tests/v2-app-template/app/routes/d.js b/smoke-tests/v2-app-template/app/routes/d.js deleted file mode 100644 index 7ff9834f947..00000000000 --- a/smoke-tests/v2-app-template/app/routes/d.js +++ /dev/null @@ -1,7 +0,0 @@ -import Route from '@ember/routing/route'; - -export default class DRoute extends Route { - model() { - return 'd'; - } -} diff --git a/smoke-tests/v2-app-template/app/routes/d/e.js b/smoke-tests/v2-app-template/app/routes/d/e.js deleted file mode 100644 index 04ace684fef..00000000000 --- a/smoke-tests/v2-app-template/app/routes/d/e.js +++ /dev/null @@ -1,7 +0,0 @@ -import Route from '@ember/routing/route'; - -export default class DERoute extends Route { - model() { - return 'e'; - } -} diff --git a/smoke-tests/v2-app-template/app/routes/f.js b/smoke-tests/v2-app-template/app/routes/f.js deleted file mode 100644 index 7df7eb2ddcc..00000000000 --- a/smoke-tests/v2-app-template/app/routes/f.js +++ /dev/null @@ -1,7 +0,0 @@ -import Route from '@ember/routing/route'; - -export default class FRoute extends Route { - model() { - return 'f'; - } -} diff --git a/smoke-tests/v2-app-template/app/templates/a.gjs b/smoke-tests/v2-app-template/app/templates/a.gjs deleted file mode 100644 index f3b67262940..00000000000 --- a/smoke-tests/v2-app-template/app/templates/a.gjs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/smoke-tests/v2-app-template/app/templates/a/b.gjs b/smoke-tests/v2-app-template/app/templates/a/b.gjs deleted file mode 100644 index db4f434482b..00000000000 --- a/smoke-tests/v2-app-template/app/templates/a/b.gjs +++ /dev/null @@ -1,3 +0,0 @@ -import ModelProbe from 'v2-app-template/components/model-probe'; - - diff --git a/smoke-tests/v2-app-template/tests/acceptance/model-stability-test.gjs b/smoke-tests/v2-app-template/tests/acceptance/model-stability-test.gjs deleted file mode 100644 index 487cf5aa597..00000000000 --- a/smoke-tests/v2-app-template/tests/acceptance/model-stability-test.gjs +++ /dev/null @@ -1,43 +0,0 @@ -import { module, test } from 'qunit'; -import { visit } from '@ember/test-helpers'; -import { setupApplicationTest } from 'ember-qunit'; -import { - getDestroyedModels, - clearDestroyedModels, -} from 'v2-app-template/components/model-probe'; - -module('Acceptance | @model stability during route transitions', function (hooks) { - setupApplicationTest(hooks); - - hooks.beforeEach(function () { - clearDestroyedModels(); - }); - - test('@model should be stable when transitioning out of the route', async function (assert) { - // Transition to sibling's parent (up one level) - await visit('/a/b'); - await visit('/a'); - - // Transition to sibling route - await visit('/a/b'); - await visit('/a/c'); - - // Transition to unrelated parent route - await visit('/a/b'); - await visit('/d'); - - // Transition to unrelated nested route - await visit('/a/b'); - await visit('/d/e'); - - // Transition to unrelated leaf route - await visit('/a/b'); - await visit('/f'); - - assert.deepEqual( - getDestroyedModels(), - ['b', 'b', 'b', 'b', 'b'], - 'The @model value should remain stable in willDestroy for all transition types' - ); - }); -}); From 963c40f31b56b176fc7d11b06642b53c34a63aeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sun, 8 Mar 2026 06:58:03 +0100 Subject: [PATCH 5/6] cleanup traces of tests in v2-app-template after implementation moved to basic-test --- smoke-tests/v2-app-template/app/router.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/smoke-tests/v2-app-template/app/router.js b/smoke-tests/v2-app-template/app/router.js index c3530812b85..bd2399136e0 100644 --- a/smoke-tests/v2-app-template/app/router.js +++ b/smoke-tests/v2-app-template/app/router.js @@ -6,13 +6,4 @@ export default class Router extends EmberRouter { rootURL = config.rootURL; } -Router.map(function () { - this.route('a', function () { - this.route('b'); - this.route('c'); - }); - this.route('d', function () { - this.route('e'); - }); - this.route('f'); -}); +Router.map(function () {}); From c1129f262b06c610040f42aec59602d1ab37e9c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sun, 8 Mar 2026 09:35:38 +0100 Subject: [PATCH 6/6] add grandparent test case and same route test case --- smoke-tests/scenarios/basic-test.ts | 71 +++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/smoke-tests/scenarios/basic-test.ts b/smoke-tests/scenarios/basic-test.ts index 55d901608eb..a03c35a689d 100644 --- a/smoke-tests/scenarios/basic-test.ts +++ b/smoke-tests/scenarios/basic-test.ts @@ -27,6 +27,12 @@ function basicTest(scenarios: Scenarios, appName: string) { this.route('e'); }); this.route('f'); + this.route('item', { path: '/item/:item_id' }); + this.route('g', function () { + this.route('h', function () { + this.route('i'); + }); + }); }); `, components: { @@ -117,6 +123,26 @@ function basicTest(scenarios: Scenarios, appName: string) { import Route from '@ember/routing/route'; export default class extends Route { model() { return 'f'; } } `, + 'item.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { model(params) { return params.item_id; } } + `, + 'g.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { model() { return 'g'; } } + `, + g: { + 'h.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { model() { return 'h'; } } + `, + h: { + 'i.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { model() { return 'i'; } } + `, + }, + }, }, templates: { 'example-gjs-route.gjs': ` @@ -141,6 +167,20 @@ function basicTest(scenarios: Scenarios, appName: string) { `, }, + 'item.gjs': ` + import ModelProbe from '${appName}/components/model-probe'; + + `, + 'g.gjs': ``, + g: { + 'h.gjs': ``, + h: { + 'i.gjs': ` + import ModelProbe from '${appName}/components/model-probe'; + + `, + }, + }, }, }, tests: { @@ -194,6 +234,37 @@ function basicTest(scenarios: Scenarios, appName: string) { 'The @model value should remain stable in willDestroy for all transition types' ); }); + + test('@model should update when the model changes on the same route', async function (assert) { + await visit('/item/first'); + assert.dom().containsText('first'); + + await visit('/item/second'); + assert.dom().containsText('second'); + + await visit('/item/third'); + assert.dom().containsText('third'); + + // Leave the route entirely — the destroyed model should be the latest one + await visit('/f'); + + assert.deepEqual( + getDestroyedModels(), + ['third'], + 'The @model value should be the latest model when finally destroyed' + ); + }); + + test('@model should be stable when grandparent outlet tears down', async function (assert) { + await visit('/g/h/i'); + await visit('/f'); + + assert.deepEqual( + getDestroyedModels(), + ['i'], + 'The @model value should remain stable when grandparent outlet tears down' + ); + }); }); `, },