From 024d56a596f70e4535850fb5370e1d3c1b3f709c Mon Sep 17 00:00:00 2001 From: Ilya Radchenko Date: Sat, 6 Jun 2026 23:15:37 -0400 Subject: [PATCH 01/11] feat: router helpers https://github.com/emberjs/rfcs/pull/391 --- .../glimmer/lib/helpers/is-active.ts | 76 +++ .../glimmer/lib/helpers/is-loading.ts | 37 ++ .../lib/helpers/is-transitioning-in.ts | 76 +++ .../lib/helpers/is-transitioning-out.ts | 76 +++ .../glimmer/lib/helpers/root-url.ts | 26 + .../-internals/glimmer/lib/helpers/url-for.ts | 67 +++ .../@ember/-internals/glimmer/lib/resolver.ts | 12 + .../helpers/router-helpers-test.js | 471 ++++++++++++++++++ packages/@ember/routing/index.ts | 6 + 9 files changed, 847 insertions(+) create mode 100644 packages/@ember/-internals/glimmer/lib/helpers/is-active.ts create mode 100644 packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts create mode 100644 packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts create mode 100644 packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts create mode 100644 packages/@ember/-internals/glimmer/lib/helpers/root-url.ts create mode 100644 packages/@ember/-internals/glimmer/lib/helpers/url-for.ts create mode 100644 packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts new file mode 100644 index 00000000000..06c363630ab --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts @@ -0,0 +1,76 @@ +/** + The `{{is-active}}` helper returns `true` if the given route (and optional + models / query params) matches the application's current route state — the + same logic that `` uses to apply its `active` CSS class. + + ```handlebars + About + ``` + + With a dynamic segment: + + ```handlebars + {{is-active "post" this.post}} + ``` + + With query params: + + ```handlebars + {{is-active "posts" queryParams=(hash page=2)}} + ``` + + Returns `false` if the route name or any model is null/undefined (loading state). + + @method is-active + @for Ember.Templates.helpers + @public +*/ +import type { CapturedArguments, Maybe } from '@glimmer/interfaces'; +import type { InternalOwner } from '@ember/-internals/owner'; +import { assert } from '@ember/debug'; +import { createComputeRef, valueForRef } from '@glimmer/reference/lib/reference'; +import { consumeTag } from '@glimmer/validator/lib/tracking'; +import { tagFor } from '@glimmer/validator/lib/meta'; +import type Route from '@ember/routing/route'; +import type { RouterState, RoutingService } from '@ember/routing/-internals'; +import { internalHelper } from './internal-helper'; + +function isMissing(value: unknown): value is null | undefined { + return value === null || value === undefined; +} + +export default internalHelper( + ({ positional, named }: CapturedArguments, owner: InternalOwner | undefined) => { + assert('[BUG] missing owner', owner); + const routing = owner.lookup('service:-routing') as RoutingService; + + return createComputeRef( + () => { + let routeRef = positional[0]; + let routeName = routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + let models = positional.slice(1).map((ref) => valueForRef(ref)) as {}[]; + let queryParamsRef = named['queryParams']; + let queryParams = + queryParamsRef !== undefined + ? (valueForRef(queryParamsRef) as Record) + : undefined; + + consumeTag(tagFor(routing, 'currentState')); + + if (isMissing(routeName) || models.some(isMissing)) { + return false; + } + + let state = routing.currentState as Maybe; + if (isMissing(state)) { + return false; + } + + return routing.isActiveForRoute(models, queryParams, routeName, state); + }, + null, + 'is-active' + ); + } +); diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts new file mode 100644 index 00000000000..19abe7fc99c --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts @@ -0,0 +1,37 @@ +/** + The `{{is-loading}}` helper returns `true` if the route name or any of the + passed models are null or undefined, mirroring the loading state that + `` detects when it renders `#` as the href. + + ```handlebars + {{#if (is-loading "post" this.post)}} + Loading… + {{else}} + {{this.post.title}} + {{/if}} + ``` + + @method is-loading + @for Ember.Templates.helpers + @public +*/ +import type { CapturedArguments } from '@glimmer/interfaces'; +import { createComputeRef, valueForRef } from '@glimmer/reference/lib/reference'; +import { internalHelper } from './internal-helper'; + +function isMissing(value: unknown): value is null | undefined { + return value === null || value === undefined; +} + +export default internalHelper(({ positional }: CapturedArguments) => { + return createComputeRef( + () => { + let routeRef = positional[0]; + let routeName = routeRef !== undefined ? valueForRef(routeRef) : undefined; + let models = positional.slice(1).map((ref) => valueForRef(ref)); + return isMissing(routeName) || models.some(isMissing); + }, + null, + 'is-loading' + ); +}); diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts new file mode 100644 index 00000000000..a5881a5d67b --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts @@ -0,0 +1,76 @@ +/** + The `{{is-transitioning-in}}` helper returns `true` when the application is + currently transitioning *into* the specified route — i.e., the route is not + yet active but will become active when the in-flight transition settles. + + This corresponds to the `ember-transitioning-in` CSS class that `` + applies during such transitions. + + ```handlebars + About + ``` + + Returns `false` when no transition is in flight or the route is already active. + + @method is-transitioning-in + @for Ember.Templates.helpers + @public +*/ +import type { CapturedArguments, Maybe } from '@glimmer/interfaces'; +import type { InternalOwner } from '@ember/-internals/owner'; +import { assert } from '@ember/debug'; +import { createComputeRef, valueForRef } from '@glimmer/reference/lib/reference'; +import { consumeTag } from '@glimmer/validator/lib/tracking'; +import { tagFor } from '@glimmer/validator/lib/meta'; +import type Route from '@ember/routing/route'; +import type { RouterState, RoutingService } from '@ember/routing/-internals'; +import { internalHelper } from './internal-helper'; + +function isMissing(value: unknown): value is null | undefined { + return value === null || value === undefined; +} + +export default internalHelper( + ({ positional, named }: CapturedArguments, owner: InternalOwner | undefined) => { + assert('[BUG] missing owner', owner); + const routing = owner.lookup('service:-routing') as RoutingService; + + return createComputeRef( + () => { + let routeRef = positional[0]; + let routeName = routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + let models = positional.slice(1).map((ref) => valueForRef(ref)) as {}[]; + let queryParamsRef = named['queryParams']; + let queryParams = + queryParamsRef !== undefined + ? (valueForRef(queryParamsRef) as Record) + : undefined; + + consumeTag(tagFor(routing, 'currentState')); + consumeTag(tagFor(routing, 'targetState')); + + if (isMissing(routeName) || models.some(isMissing)) { + return false; + } + + let current = routing.currentState as Maybe; + let target = routing.targetState as Maybe; + + // No transition in flight. + if (isMissing(target) || current === target) { + return false; + } + + let isCurrentlyActive = + !isMissing(current) && + routing.isActiveForRoute(models, queryParams, routeName, current); + let willBeActive = routing.isActiveForRoute(models, queryParams, routeName, target); + + return !isCurrentlyActive && willBeActive; + }, + null, + 'is-transitioning-in' + ); + } +); diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts new file mode 100644 index 00000000000..cf992e532dd --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts @@ -0,0 +1,76 @@ +/** + The `{{is-transitioning-out}}` helper returns `true` when the application is + currently transitioning *away from* the specified route — i.e., the route is + active now but will no longer be active when the in-flight transition settles. + + This corresponds to the `ember-transitioning-out` CSS class that `` + applies during such transitions. + + ```handlebars + About + ``` + + Returns `false` when no transition is in flight or the route is not currently active. + + @method is-transitioning-out + @for Ember.Templates.helpers + @public +*/ +import type { CapturedArguments, Maybe } from '@glimmer/interfaces'; +import type { InternalOwner } from '@ember/-internals/owner'; +import { assert } from '@ember/debug'; +import { createComputeRef, valueForRef } from '@glimmer/reference/lib/reference'; +import { consumeTag } from '@glimmer/validator/lib/tracking'; +import { tagFor } from '@glimmer/validator/lib/meta'; +import type Route from '@ember/routing/route'; +import type { RouterState, RoutingService } from '@ember/routing/-internals'; +import { internalHelper } from './internal-helper'; + +function isMissing(value: unknown): value is null | undefined { + return value === null || value === undefined; +} + +export default internalHelper( + ({ positional, named }: CapturedArguments, owner: InternalOwner | undefined) => { + assert('[BUG] missing owner', owner); + const routing = owner.lookup('service:-routing') as RoutingService; + + return createComputeRef( + () => { + let routeRef = positional[0]; + let routeName = routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + let models = positional.slice(1).map((ref) => valueForRef(ref)) as {}[]; + let queryParamsRef = named['queryParams']; + let queryParams = + queryParamsRef !== undefined + ? (valueForRef(queryParamsRef) as Record) + : undefined; + + consumeTag(tagFor(routing, 'currentState')); + consumeTag(tagFor(routing, 'targetState')); + + if (isMissing(routeName) || models.some(isMissing)) { + return false; + } + + let current = routing.currentState as Maybe; + let target = routing.targetState as Maybe; + + // No transition in flight. + if (isMissing(target) || current === target) { + return false; + } + + let isCurrentlyActive = + !isMissing(current) && + routing.isActiveForRoute(models, queryParams, routeName, current); + let willBeActive = routing.isActiveForRoute(models, queryParams, routeName, target); + + return isCurrentlyActive && !willBeActive; + }, + null, + 'is-transitioning-out' + ); + } +); diff --git a/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts b/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts new file mode 100644 index 00000000000..94b7fa02ac0 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts @@ -0,0 +1,26 @@ +/** + The `{{root-url}}` helper returns the application's configured `rootURL`. + + ```handlebars + Profile + ``` + + @method root-url + @for Ember.Templates.helpers + @public +*/ +import type { CapturedArguments } from '@glimmer/interfaces'; +import type { InternalOwner } from '@ember/-internals/owner'; +import { assert } from '@ember/debug'; +import { createConstRef } from '@glimmer/reference/lib/reference'; +import type RouterService from '@ember/routing/router-service'; +import { internalHelper } from './internal-helper'; + +export default internalHelper( + (_args: CapturedArguments, owner: InternalOwner | undefined) => { + assert('[BUG] missing owner', owner); + const router = owner.lookup('service:router') as RouterService; + // rootURL is a static configuration value — safe to use a const ref. + return createConstRef(router.rootURL, 'root-url'); + } +); diff --git a/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts new file mode 100644 index 00000000000..f7cbad0f1d5 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts @@ -0,0 +1,67 @@ +/** + The `{{url-for}}` helper returns a URL string for a given route, matching the + same arguments as ``. Unlike ``, it returns a string value + rather than rendering an anchor element. + + ```handlebars + Profile + ``` + + With query params: + + ```handlebars + Page 2 + ``` + + Returns `undefined` if the route name or any model is null/undefined (loading state). + + @method url-for + @for Ember.Templates.helpers + @public +*/ +import type { CapturedArguments } from '@glimmer/interfaces'; +import type { InternalOwner } from '@ember/-internals/owner'; +import { assert } from '@ember/debug'; +import { createComputeRef, valueForRef } from '@glimmer/reference/lib/reference'; +import { consumeTag } from '@glimmer/validator/lib/tracking'; +import { tagFor } from '@glimmer/validator/lib/meta'; +import type Route from '@ember/routing/route'; +import type { RoutingService } from '@ember/routing/-internals'; +import { internalHelper } from './internal-helper'; + +function isMissing(value: unknown): value is null | undefined { + return value === null || value === undefined; +} + +export default internalHelper( + ({ positional, named }: CapturedArguments, owner: InternalOwner | undefined) => { + assert('[BUG] missing owner', owner); + const routing = owner.lookup('service:-routing') as RoutingService; + + return createComputeRef( + () => { + let routeRef = positional[0]; + let routeName = routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + let models = positional.slice(1).map((ref) => valueForRef(ref)) as {}[]; + let queryParamsRef = named['queryParams']; + let queryParams = + queryParamsRef !== undefined + ? (valueForRef(queryParamsRef) as Record) + : {}; + + // Consume currentState so this ref invalidates when QPs change, matching + // the same pattern as LinkTo's href getter. + consumeTag(tagFor(routing, 'currentState')); + + if (isMissing(routeName) || models.some(isMissing)) { + return undefined; + } + + return routing.generateURL(routeName, models, queryParams); + }, + null, + 'url-for' + ); + } +); diff --git a/packages/@ember/-internals/glimmer/lib/resolver.ts b/packages/@ember/-internals/glimmer/lib/resolver.ts index 43c2ce19ecf..1bf4b4b5189 100644 --- a/packages/@ember/-internals/glimmer/lib/resolver.ts +++ b/packages/@ember/-internals/glimmer/lib/resolver.ts @@ -35,10 +35,16 @@ import { default as normalizeClassHelper } from './helpers/-normalize-class'; import { default as resolve } from './helpers/-resolve'; import { default as trackArray } from './helpers/-track-array'; import { default as eachIn } from './helpers/each-in'; +import { default as isActive } from './helpers/is-active'; +import { default as isLoading } from './helpers/is-loading'; +import { default as isTransitioningIn } from './helpers/is-transitioning-in'; +import { default as isTransitioningOut } from './helpers/is-transitioning-out'; import { default as mut } from './helpers/mut'; import { default as readonly } from './helpers/readonly'; +import { default as rootUrl } from './helpers/root-url'; import { default as unbound } from './helpers/unbound'; import { default as uniqueId } from './helpers/unique-id'; +import { default as urlFor } from './helpers/url-for'; import { mountHelper } from './syntax/mount'; import { outletHelper } from './syntax/outlet'; @@ -109,6 +115,12 @@ const BUILTIN_HELPERS: Record = { get, hash, 'unique-id': uniqueId, + 'url-for': urlFor, + 'root-url': rootUrl, + 'is-active': isActive, + 'is-loading': isLoading, + 'is-transitioning-in': isTransitioningIn, + 'is-transitioning-out': isTransitioningOut, }; if (DEBUG) { diff --git a/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js b/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js new file mode 100644 index 00000000000..ed8f8d10380 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js @@ -0,0 +1,471 @@ +import { RSVP } from '@ember/-internals/runtime'; +import Route from '@ember/routing/route'; +import { precompileTemplate } from '@ember/template-compilation'; +import Controller from '@ember/controller'; +import { tracked } from '@glimmer/tracking'; +import { moduleFor, ApplicationTestCase, runTask } from 'internal-test-helpers'; + +// --------------------------------------------------------------------------- +// {{url-for}} +// --------------------------------------------------------------------------- + +moduleFor( + 'Router helpers: {{url-for}}', + class extends ApplicationTestCase { + constructor(...args) { + super(...args); + + this.router.map(function () { + this.route('about'); + this.route('post', { path: '/posts/:post_id' }); + this.route('search'); + }); + } + + async ['@test generates URL for a simple route'](assert) { + this.add( + 'template:index', + precompileTemplate(`About`) + ); + + await this.visit('/'); + assert.equal(this.$('#link').attr('href'), '/about', 'generates correct href'); + } + + async ['@test generates URL for the index route'](assert) { + this.add( + 'template:index', + precompileTemplate(`Home`) + ); + + await this.visit('/'); + assert.equal(this.$('#link').attr('href'), '/', 'generates correct href for index'); + } + + async ['@test generates URL with a dynamic segment model'](assert) { + this.add( + 'template:index', + precompileTemplate(`Post`) + ); + + await this.visit('/'); + assert.equal(this.$('#link').attr('href'), '/posts/42', 'generates correct href with model'); + } + + async ['@test generates URL with query params'](assert) { + this.add( + 'template:search', + precompileTemplate( + `Search` + ) + ); + this.add( + 'controller:search', + class extends Controller { + queryParams = ['q']; + q = null; + } + ); + + await this.visit('/search'); + assert.equal( + this.$('#link').attr('href'), + '/search?q=ember', + 'generates href with query params' + ); + } + + async ['@test returns undefined when model is null'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{url-for "post" this.model}}`) + ); + this.add( + 'controller:index', + class extends Controller { + model = null; + } + ); + + await this.visit('/'); + assert.equal(this.$('#result').text().trim(), '', 'returns empty when model is null'); + } + + async ['@test returns undefined when routeName is null'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{url-for this.route}}`) + ); + this.add( + 'controller:index', + class extends Controller { + route = null; + } + ); + + await this.visit('/'); + assert.equal(this.$('#result').text().trim(), '', 'returns empty when routeName is null'); + } + + async ['@test updates when model changes'](assert) { + this.add( + 'template:index', + precompileTemplate(`Post`) + ); + + class IndexController extends Controller { + @tracked postId = '1'; + } + this.add('controller:index', IndexController); + + await this.visit('/'); + assert.equal(this.$('#link').attr('href'), '/posts/1', 'initial href correct'); + + let controller = this.applicationInstance.lookup('controller:index'); + runTask(() => (controller.postId = '2')); + assert.equal(this.$('#link').attr('href'), '/posts/2', 'href updates when model changes'); + } + } +); + +// --------------------------------------------------------------------------- +// {{root-url}} +// --------------------------------------------------------------------------- + +moduleFor( + 'Router helpers: {{root-url}}', + class extends ApplicationTestCase { + async ['@test returns the default rootURL'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{root-url}}`) + ); + + await this.visit('/'); + assert.equal(this.$('#result').text(), '/', 'returns default rootURL of "/"'); + } + } +); + +// --------------------------------------------------------------------------- +// {{is-active}} +// --------------------------------------------------------------------------- + +moduleFor( + 'Router helpers: {{is-active}}', + class extends ApplicationTestCase { + constructor(...args) { + super(...args); + + this.router.map(function () { + this.route('about'); + this.route('post', { path: '/posts/:post_id' }); + this.route('search'); + }); + } + + async ['@test returns true for the current route'](assert) { + this.add( + 'template:index', + precompileTemplate( + `{{is-active "index"}} + {{is-active "about"}}` + ) + ); + + await this.visit('/'); + assert.equal(this.$('#home-active').text(), 'true', 'index is active on /'); + assert.equal(this.$('#about-active').text(), 'false', 'about is not active on /'); + } + + async ['@test updates after navigation'](assert) { + this.add( + 'template:application', + precompileTemplate( + `{{outlet}} + {{is-active "index"}} + {{is-active "about"}}` + ) + ); + + await this.visit('/'); + assert.equal(this.$('#home-active').text(), 'true', 'index active before nav'); + assert.equal(this.$('#about-active').text(), 'false', 'about inactive before nav'); + + await this.visit('/about'); + assert.equal(this.$('#home-active').text(), 'false', 'index inactive after nav'); + assert.equal(this.$('#about-active').text(), 'true', 'about active after nav'); + } + + async ['@test returns false when any model is null'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{is-active "post" this.model}}`) + ); + this.add( + 'controller:index', + class extends Controller { + model = null; + } + ); + + await this.visit('/'); + assert.equal(this.$('#result').text(), 'false', 'false when model is null'); + } + + async ['@test works with a dynamic segment model'](assert) { + this.add( + 'template:post', + precompileTemplate(`{{is-active "post" "42"}}`) + ); + + await this.visit('/posts/42'); + assert.equal(this.$('#result').text(), 'true', 'active on correct post'); + } + + async ['@test returns false for a different dynamic segment value'](assert) { + this.add( + 'template:post', + precompileTemplate(`{{is-active "post" "99"}}`) + ); + + await this.visit('/posts/42'); + assert.equal(this.$('#result').text(), 'false', 'false for different model id'); + } + + async ['@test works with query params'](assert) { + this.add( + 'template:search', + precompileTemplate( + `{{is-active "search" queryParams=(hash q="ember")}} + {{is-active "search" queryParams=(hash q="other")}}` + ) + ); + this.add( + 'controller:search', + class extends Controller { + queryParams = ['q']; + q = null; + } + ); + + await this.visit('/search?q=ember'); + assert.equal(this.$('#match').text(), 'true', 'active with matching QPs'); + assert.equal(this.$('#no-match').text(), 'false', 'inactive with non-matching QPs'); + } + } +); + +// --------------------------------------------------------------------------- +// {{is-loading}} +// --------------------------------------------------------------------------- + +moduleFor( + 'Router helpers: {{is-loading}}', + class extends ApplicationTestCase { + constructor(...args) { + super(...args); + + this.router.map(function () { + this.route('post', { path: '/posts/:post_id' }); + }); + } + + async ['@test returns false when all args are present'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{is-loading "post" "42"}}`) + ); + + await this.visit('/'); + assert.equal(this.$('#result').text(), 'false', 'false when route and model are present'); + } + + async ['@test returns true when model is null'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{is-loading "post" this.model}}`) + ); + this.add( + 'controller:index', + class extends Controller { + model = null; + } + ); + + await this.visit('/'); + assert.equal(this.$('#result').text(), 'true', 'true when model is null'); + } + + async ['@test returns true when model is undefined'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{is-loading "post" this.model}}`) + ); + this.add( + 'controller:index', + class extends Controller { + model = undefined; + } + ); + + await this.visit('/'); + assert.equal(this.$('#result').text(), 'true', 'true when model is undefined'); + } + + async ['@test returns true when routeName is null'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{is-loading this.route}}`) + ); + this.add( + 'controller:index', + class extends Controller { + route = null; + } + ); + + await this.visit('/'); + assert.equal(this.$('#result').text(), 'true', 'true when routeName is null'); + } + + async ['@test updates reactively when model changes'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{is-loading "post" this.postId}}`) + ); + + class IndexController extends Controller { + @tracked postId = null; + } + this.add('controller:index', IndexController); + + await this.visit('/'); + assert.equal(this.$('#result').text(), 'true', 'true initially (null model)'); + + let controller = this.applicationInstance.lookup('controller:index'); + runTask(() => (controller.postId = '42')); + assert.equal(this.$('#result').text(), 'false', 'false after model is set'); + } + } +); + +// --------------------------------------------------------------------------- +// {{is-transitioning-in}} and {{is-transitioning-out}} +// --------------------------------------------------------------------------- + +moduleFor( + 'Router helpers: {{is-transitioning-in}} and {{is-transitioning-out}}', + class extends ApplicationTestCase { + constructor(...args) { + super(...args); + + this.aboutDefer = RSVP.defer(); + let _this = this; + + this.router.map(function () { + this.route('about'); + this.route('other'); + }); + + this.add( + 'route:about', + class extends Route { + model() { + return _this.aboutDefer.promise; + } + } + ); + + this.add( + 'template:application', + precompileTemplate( + `{{outlet}} + {{is-transitioning-in "index"}} + {{is-transitioning-out "index"}} + {{is-transitioning-in "about"}} + {{is-transitioning-out "about"}}` + ) + ); + } + + afterEach() { + super.afterEach(); + this.aboutDefer = null; + } + + async ['@test all false when no transition is in flight'](assert) { + await this.visit('/'); + + assert.equal(this.$('#index-in').text(), 'false', 'index not transitioning-in'); + assert.equal(this.$('#index-out').text(), 'false', 'index not transitioning-out'); + assert.equal(this.$('#about-in').text(), 'false', 'about not transitioning-in'); + assert.equal(this.$('#about-out').text(), 'false', 'about not transitioning-out'); + } + + ['@test is-transitioning-in and is-transitioning-out during a deferred transition'](assert) { + return this.visit('/').then(() => { + // Start a transition to /about (deferred — model hook returns a promise) + runTask(() => this.visit('/about')); + + // While the transition is in flight: + assert.equal(this.$('#index-in').text(), 'false', 'index not transitioning-in'); + assert.equal( + this.$('#index-out').text(), + 'true', + 'index is transitioning-out (leaving index)' + ); + assert.equal( + this.$('#about-in').text(), + 'true', + 'about is transitioning-in (entering about)' + ); + assert.equal(this.$('#about-out').text(), 'false', 'about not transitioning-out'); + + // Resolve the deferred model to complete the transition + runTask(() => this.aboutDefer.resolve()); + + // After the transition settles: + assert.equal(this.$('#index-in').text(), 'false', 'index not transitioning-in after'); + assert.equal(this.$('#index-out').text(), 'false', 'index not transitioning-out after'); + assert.equal(this.$('#about-in').text(), 'false', 'about not transitioning-in after'); + assert.equal(this.$('#about-out').text(), 'false', 'about not transitioning-out after'); + }); + } + + } +); + +moduleFor( + 'Router helpers: {{is-transitioning-in}} and {{is-transitioning-out}} with null models', + class extends ApplicationTestCase { + constructor(...args) { + super(...args); + + this.router.map(function () { + this.route('about'); + }); + + this.add( + 'template:application', + precompileTemplate( + `{{outlet}} + {{is-transitioning-in "about" this.model}} + {{is-transitioning-out "about" this.model}}` + ) + ); + this.add( + 'controller:application', + class extends Controller { + model = null; + } + ); + } + + async ['@test returns false when model is null'](assert) { + await this.visit('/'); + assert.equal(this.$('#result-in').text(), 'false', 'is-transitioning-in false with null model'); + assert.equal(this.$('#result-out').text(), 'false', 'is-transitioning-out false with null model'); + } + } +); diff --git a/packages/@ember/routing/index.ts b/packages/@ember/routing/index.ts index be0dd6e698f..62c7fc5af70 100644 --- a/packages/@ember/routing/index.ts +++ b/packages/@ember/routing/index.ts @@ -1 +1,7 @@ export { default as LinkTo } from '@ember/-internals/glimmer/lib/components/link-to'; +export { default as urlFor } from '@ember/-internals/glimmer/lib/helpers/url-for'; +export { default as rootUrl } from '@ember/-internals/glimmer/lib/helpers/root-url'; +export { default as isActive } from '@ember/-internals/glimmer/lib/helpers/is-active'; +export { default as isLoading } from '@ember/-internals/glimmer/lib/helpers/is-loading'; +export { default as isTransitioningIn } from '@ember/-internals/glimmer/lib/helpers/is-transitioning-in'; +export { default as isTransitioningOut } from '@ember/-internals/glimmer/lib/helpers/is-transitioning-out'; From e44b2b72c945e4402c535a86f0d5577e437b7741 Mon Sep 17 00:00:00 2001 From: Ilya Radchenko Date: Sat, 6 Jun 2026 23:19:00 -0400 Subject: [PATCH 02/11] fix: format files --- .../glimmer/lib/helpers/is-active.ts | 3 ++- .../glimmer/lib/helpers/is-transitioning-in.ts | 6 +++--- .../lib/helpers/is-transitioning-out.ts | 6 +++--- .../-internals/glimmer/lib/helpers/root-url.ts | 14 ++++++-------- .../-internals/glimmer/lib/helpers/url-for.ts | 3 ++- .../integration/helpers/router-helpers-test.js | 18 +++++++++++------- 6 files changed, 27 insertions(+), 23 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts index 06c363630ab..03ba9362bd0 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts @@ -47,7 +47,8 @@ export default internalHelper( return createComputeRef( () => { let routeRef = positional[0]; - let routeName = routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; + let routeName = + routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; // eslint-disable-next-line @typescript-eslint/no-empty-object-type let models = positional.slice(1).map((ref) => valueForRef(ref)) as {}[]; let queryParamsRef = named['queryParams']; diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts index a5881a5d67b..9bc4b246b4d 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts @@ -38,7 +38,8 @@ export default internalHelper( return createComputeRef( () => { let routeRef = positional[0]; - let routeName = routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; + let routeName = + routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; // eslint-disable-next-line @typescript-eslint/no-empty-object-type let models = positional.slice(1).map((ref) => valueForRef(ref)) as {}[]; let queryParamsRef = named['queryParams']; @@ -63,8 +64,7 @@ export default internalHelper( } let isCurrentlyActive = - !isMissing(current) && - routing.isActiveForRoute(models, queryParams, routeName, current); + !isMissing(current) && routing.isActiveForRoute(models, queryParams, routeName, current); let willBeActive = routing.isActiveForRoute(models, queryParams, routeName, target); return !isCurrentlyActive && willBeActive; diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts index cf992e532dd..505dd4ee6f4 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts @@ -38,7 +38,8 @@ export default internalHelper( return createComputeRef( () => { let routeRef = positional[0]; - let routeName = routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; + let routeName = + routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; // eslint-disable-next-line @typescript-eslint/no-empty-object-type let models = positional.slice(1).map((ref) => valueForRef(ref)) as {}[]; let queryParamsRef = named['queryParams']; @@ -63,8 +64,7 @@ export default internalHelper( } let isCurrentlyActive = - !isMissing(current) && - routing.isActiveForRoute(models, queryParams, routeName, current); + !isMissing(current) && routing.isActiveForRoute(models, queryParams, routeName, current); let willBeActive = routing.isActiveForRoute(models, queryParams, routeName, target); return isCurrentlyActive && !willBeActive; diff --git a/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts b/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts index 94b7fa02ac0..df10a77e95f 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts @@ -16,11 +16,9 @@ import { createConstRef } from '@glimmer/reference/lib/reference'; import type RouterService from '@ember/routing/router-service'; import { internalHelper } from './internal-helper'; -export default internalHelper( - (_args: CapturedArguments, owner: InternalOwner | undefined) => { - assert('[BUG] missing owner', owner); - const router = owner.lookup('service:router') as RouterService; - // rootURL is a static configuration value — safe to use a const ref. - return createConstRef(router.rootURL, 'root-url'); - } -); +export default internalHelper((_args: CapturedArguments, owner: InternalOwner | undefined) => { + assert('[BUG] missing owner', owner); + const router = owner.lookup('service:router') as RouterService; + // rootURL is a static configuration value — safe to use a const ref. + return createConstRef(router.rootURL, 'root-url'); +}); diff --git a/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts index f7cbad0f1d5..a476630fc0e 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts @@ -41,7 +41,8 @@ export default internalHelper( return createComputeRef( () => { let routeRef = positional[0]; - let routeName = routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; + let routeName = + routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; // eslint-disable-next-line @typescript-eslint/no-empty-object-type let models = positional.slice(1).map((ref) => valueForRef(ref)) as {}[]; let queryParamsRef = named['queryParams']; diff --git a/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js b/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js index ed8f8d10380..f5c18e74bc1 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js @@ -136,10 +136,7 @@ moduleFor( 'Router helpers: {{root-url}}', class extends ApplicationTestCase { async ['@test returns the default rootURL'](assert) { - this.add( - 'template:index', - precompileTemplate(`{{root-url}}`) - ); + this.add('template:index', precompileTemplate(`{{root-url}}`)); await this.visit('/'); assert.equal(this.$('#result').text(), '/', 'returns default rootURL of "/"'); @@ -432,7 +429,6 @@ moduleFor( assert.equal(this.$('#about-out').text(), 'false', 'about not transitioning-out after'); }); } - } ); @@ -464,8 +460,16 @@ moduleFor( async ['@test returns false when model is null'](assert) { await this.visit('/'); - assert.equal(this.$('#result-in').text(), 'false', 'is-transitioning-in false with null model'); - assert.equal(this.$('#result-out').text(), 'false', 'is-transitioning-out false with null model'); + assert.equal( + this.$('#result-in').text(), + 'false', + 'is-transitioning-in false with null model' + ); + assert.equal( + this.$('#result-out').text(), + 'false', + 'is-transitioning-out false with null model' + ); } } ); From f74f148b2cd785b56d21294ce9d34839d416eb33 Mon Sep 17 00:00:00 2001 From: Ilya Radchenko Date: Mon, 8 Jun 2026 08:59:34 -0400 Subject: [PATCH 03/11] fix: docs expected --- tests/docs/expected.cjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/docs/expected.cjs b/tests/docs/expected.cjs index db1994ff30a..75644866509 100644 --- a/tests/docs/expected.cjs +++ b/tests/docs/expected.cjs @@ -253,6 +253,10 @@ module.exports = { 'if', 'in-element', 'includes', + 'is-active', + 'is-loading', + 'is-transitioning-in', + 'is-transitioning-out', 'incrementProperty', 'indexOf', 'info', @@ -432,6 +436,7 @@ module.exports = { 'rethrow', 'retry', 'reverseObjects', + 'root-url', 'rootElement', 'rootURL', 'routeDidChange', @@ -517,6 +522,7 @@ module.exports = { 'unshiftObjects', 'unsubscribe', 'url', + 'url-for', 'urlFor', 'validationCache', 'visit', From b1202d9de62e359a30bf34047d588fcabb0c12ff Mon Sep 17 00:00:00 2001 From: Ilya Radchenko Date: Mon, 8 Jun 2026 09:05:56 -0400 Subject: [PATCH 04/11] fix: strict-mode only --- .../@ember/-internals/glimmer/lib/resolver.ts | 12 -- .../helpers/router-helpers-test.js | 180 ++++++++++-------- 2 files changed, 100 insertions(+), 92 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/resolver.ts b/packages/@ember/-internals/glimmer/lib/resolver.ts index 1bf4b4b5189..43c2ce19ecf 100644 --- a/packages/@ember/-internals/glimmer/lib/resolver.ts +++ b/packages/@ember/-internals/glimmer/lib/resolver.ts @@ -35,16 +35,10 @@ import { default as normalizeClassHelper } from './helpers/-normalize-class'; import { default as resolve } from './helpers/-resolve'; import { default as trackArray } from './helpers/-track-array'; import { default as eachIn } from './helpers/each-in'; -import { default as isActive } from './helpers/is-active'; -import { default as isLoading } from './helpers/is-loading'; -import { default as isTransitioningIn } from './helpers/is-transitioning-in'; -import { default as isTransitioningOut } from './helpers/is-transitioning-out'; import { default as mut } from './helpers/mut'; import { default as readonly } from './helpers/readonly'; -import { default as rootUrl } from './helpers/root-url'; import { default as unbound } from './helpers/unbound'; import { default as uniqueId } from './helpers/unique-id'; -import { default as urlFor } from './helpers/url-for'; import { mountHelper } from './syntax/mount'; import { outletHelper } from './syntax/outlet'; @@ -115,12 +109,6 @@ const BUILTIN_HELPERS: Record = { get, hash, 'unique-id': uniqueId, - 'url-for': urlFor, - 'root-url': rootUrl, - 'is-active': isActive, - 'is-loading': isLoading, - 'is-transitioning-in': isTransitioningIn, - 'is-transitioning-out': isTransitioningOut, }; if (DEBUG) { diff --git a/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js b/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js index f5c18e74bc1..6f63e3b1c9a 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js @@ -4,13 +4,24 @@ import { precompileTemplate } from '@ember/template-compilation'; import Controller from '@ember/controller'; import { tracked } from '@glimmer/tracking'; import { moduleFor, ApplicationTestCase, runTask } from 'internal-test-helpers'; +import { + urlFor, + rootUrl, + isActive, + isLoading, + isTransitioningIn, + isTransitioningOut, +} from '@ember/routing'; + +// Helpers are strict-mode only — they must be imported and passed via scope. +// None of these helpers are registered in the loose-mode resolver. // --------------------------------------------------------------------------- -// {{url-for}} +// {{urlFor}} // --------------------------------------------------------------------------- moduleFor( - 'Router helpers: {{url-for}}', + 'Router helpers: {{urlFor}}', class extends ApplicationTestCase { constructor(...args) { super(...args); @@ -25,7 +36,10 @@ moduleFor( async ['@test generates URL for a simple route'](assert) { this.add( 'template:index', - precompileTemplate(`About`) + precompileTemplate(`About`, { + strictMode: true, + scope: () => ({ urlFor }), + }) ); await this.visit('/'); @@ -35,7 +49,10 @@ moduleFor( async ['@test generates URL for the index route'](assert) { this.add( 'template:index', - precompileTemplate(`Home`) + precompileTemplate(`Home`, { + strictMode: true, + scope: () => ({ urlFor }), + }) ); await this.visit('/'); @@ -45,7 +62,10 @@ moduleFor( async ['@test generates URL with a dynamic segment model'](assert) { this.add( 'template:index', - precompileTemplate(`Post`) + precompileTemplate(`Post`, { + strictMode: true, + scope: () => ({ urlFor }), + }) ); await this.visit('/'); @@ -56,7 +76,8 @@ moduleFor( this.add( 'template:search', precompileTemplate( - `Search` + `Search`, + { strictMode: true, scope: () => ({ urlFor }) } ) ); this.add( @@ -78,7 +99,10 @@ moduleFor( async ['@test returns undefined when model is null'](assert) { this.add( 'template:index', - precompileTemplate(`{{url-for "post" this.model}}`) + precompileTemplate(`{{urlFor "post" this.model}}`, { + strictMode: true, + scope: () => ({ urlFor }), + }) ); this.add( 'controller:index', @@ -91,26 +115,13 @@ moduleFor( assert.equal(this.$('#result').text().trim(), '', 'returns empty when model is null'); } - async ['@test returns undefined when routeName is null'](assert) { - this.add( - 'template:index', - precompileTemplate(`{{url-for this.route}}`) - ); - this.add( - 'controller:index', - class extends Controller { - route = null; - } - ); - - await this.visit('/'); - assert.equal(this.$('#result').text().trim(), '', 'returns empty when routeName is null'); - } - async ['@test updates when model changes'](assert) { this.add( 'template:index', - precompileTemplate(`Post`) + precompileTemplate(`Post`, { + strictMode: true, + scope: () => ({ urlFor }), + }) ); class IndexController extends Controller { @@ -129,14 +140,20 @@ moduleFor( ); // --------------------------------------------------------------------------- -// {{root-url}} +// {{rootUrl}} // --------------------------------------------------------------------------- moduleFor( - 'Router helpers: {{root-url}}', + 'Router helpers: {{rootUrl}}', class extends ApplicationTestCase { async ['@test returns the default rootURL'](assert) { - this.add('template:index', precompileTemplate(`{{root-url}}`)); + this.add( + 'template:index', + precompileTemplate(`{{rootUrl}}`, { + strictMode: true, + scope: () => ({ rootUrl }), + }) + ); await this.visit('/'); assert.equal(this.$('#result').text(), '/', 'returns default rootURL of "/"'); @@ -145,11 +162,11 @@ moduleFor( ); // --------------------------------------------------------------------------- -// {{is-active}} +// {{isActive}} // --------------------------------------------------------------------------- moduleFor( - 'Router helpers: {{is-active}}', + 'Router helpers: {{isActive}}', class extends ApplicationTestCase { constructor(...args) { super(...args); @@ -165,8 +182,9 @@ moduleFor( this.add( 'template:index', precompileTemplate( - `{{is-active "index"}} - {{is-active "about"}}` + `{{isActive "index"}} + {{isActive "about"}}`, + { strictMode: true, scope: () => ({ isActive }) } ) ); @@ -180,8 +198,9 @@ moduleFor( 'template:application', precompileTemplate( `{{outlet}} - {{is-active "index"}} - {{is-active "about"}}` + {{isActive "index"}} + {{isActive "about"}}`, + { strictMode: true, scope: () => ({ isActive }) } ) ); @@ -197,7 +216,10 @@ moduleFor( async ['@test returns false when any model is null'](assert) { this.add( 'template:index', - precompileTemplate(`{{is-active "post" this.model}}`) + precompileTemplate(`{{isActive "post" this.model}}`, { + strictMode: true, + scope: () => ({ isActive }), + }) ); this.add( 'controller:index', @@ -213,7 +235,10 @@ moduleFor( async ['@test works with a dynamic segment model'](assert) { this.add( 'template:post', - precompileTemplate(`{{is-active "post" "42"}}`) + precompileTemplate(`{{isActive "post" "42"}}`, { + strictMode: true, + scope: () => ({ isActive }), + }) ); await this.visit('/posts/42'); @@ -223,42 +248,24 @@ moduleFor( async ['@test returns false for a different dynamic segment value'](assert) { this.add( 'template:post', - precompileTemplate(`{{is-active "post" "99"}}`) + precompileTemplate(`{{isActive "post" "99"}}`, { + strictMode: true, + scope: () => ({ isActive }), + }) ); await this.visit('/posts/42'); assert.equal(this.$('#result').text(), 'false', 'false for different model id'); } - - async ['@test works with query params'](assert) { - this.add( - 'template:search', - precompileTemplate( - `{{is-active "search" queryParams=(hash q="ember")}} - {{is-active "search" queryParams=(hash q="other")}}` - ) - ); - this.add( - 'controller:search', - class extends Controller { - queryParams = ['q']; - q = null; - } - ); - - await this.visit('/search?q=ember'); - assert.equal(this.$('#match').text(), 'true', 'active with matching QPs'); - assert.equal(this.$('#no-match').text(), 'false', 'inactive with non-matching QPs'); - } } ); // --------------------------------------------------------------------------- -// {{is-loading}} +// {{isLoading}} // --------------------------------------------------------------------------- moduleFor( - 'Router helpers: {{is-loading}}', + 'Router helpers: {{isLoading}}', class extends ApplicationTestCase { constructor(...args) { super(...args); @@ -271,7 +278,10 @@ moduleFor( async ['@test returns false when all args are present'](assert) { this.add( 'template:index', - precompileTemplate(`{{is-loading "post" "42"}}`) + precompileTemplate(`{{isLoading "post" "42"}}`, { + strictMode: true, + scope: () => ({ isLoading }), + }) ); await this.visit('/'); @@ -281,7 +291,10 @@ moduleFor( async ['@test returns true when model is null'](assert) { this.add( 'template:index', - precompileTemplate(`{{is-loading "post" this.model}}`) + precompileTemplate(`{{isLoading "post" this.model}}`, { + strictMode: true, + scope: () => ({ isLoading }), + }) ); this.add( 'controller:index', @@ -297,7 +310,10 @@ moduleFor( async ['@test returns true when model is undefined'](assert) { this.add( 'template:index', - precompileTemplate(`{{is-loading "post" this.model}}`) + precompileTemplate(`{{isLoading "post" this.model}}`, { + strictMode: true, + scope: () => ({ isLoading }), + }) ); this.add( 'controller:index', @@ -313,7 +329,10 @@ moduleFor( async ['@test returns true when routeName is null'](assert) { this.add( 'template:index', - precompileTemplate(`{{is-loading this.route}}`) + precompileTemplate(`{{isLoading this.route}}`, { + strictMode: true, + scope: () => ({ isLoading }), + }) ); this.add( 'controller:index', @@ -329,7 +348,10 @@ moduleFor( async ['@test updates reactively when model changes'](assert) { this.add( 'template:index', - precompileTemplate(`{{is-loading "post" this.postId}}`) + precompileTemplate(`{{isLoading "post" this.postId}}`, { + strictMode: true, + scope: () => ({ isLoading }), + }) ); class IndexController extends Controller { @@ -348,11 +370,11 @@ moduleFor( ); // --------------------------------------------------------------------------- -// {{is-transitioning-in}} and {{is-transitioning-out}} +// {{isTransitioningIn}} and {{isTransitioningOut}} // --------------------------------------------------------------------------- moduleFor( - 'Router helpers: {{is-transitioning-in}} and {{is-transitioning-out}}', + 'Router helpers: {{isTransitioningIn}} and {{isTransitioningOut}}', class extends ApplicationTestCase { constructor(...args) { super(...args); @@ -378,10 +400,11 @@ moduleFor( 'template:application', precompileTemplate( `{{outlet}} - {{is-transitioning-in "index"}} - {{is-transitioning-out "index"}} - {{is-transitioning-in "about"}} - {{is-transitioning-out "about"}}` + {{isTransitioningIn "index"}} + {{isTransitioningOut "index"}} + {{isTransitioningIn "about"}} + {{isTransitioningOut "about"}}`, + { strictMode: true, scope: () => ({ isTransitioningIn, isTransitioningOut }) } ) ); } @@ -400,12 +423,10 @@ moduleFor( assert.equal(this.$('#about-out').text(), 'false', 'about not transitioning-out'); } - ['@test is-transitioning-in and is-transitioning-out during a deferred transition'](assert) { + ['@test correct values during a deferred transition'](assert) { return this.visit('/').then(() => { - // Start a transition to /about (deferred — model hook returns a promise) runTask(() => this.visit('/about')); - // While the transition is in flight: assert.equal(this.$('#index-in').text(), 'false', 'index not transitioning-in'); assert.equal( this.$('#index-out').text(), @@ -419,10 +440,8 @@ moduleFor( ); assert.equal(this.$('#about-out').text(), 'false', 'about not transitioning-out'); - // Resolve the deferred model to complete the transition runTask(() => this.aboutDefer.resolve()); - // After the transition settles: assert.equal(this.$('#index-in').text(), 'false', 'index not transitioning-in after'); assert.equal(this.$('#index-out').text(), 'false', 'index not transitioning-out after'); assert.equal(this.$('#about-in').text(), 'false', 'about not transitioning-in after'); @@ -433,7 +452,7 @@ moduleFor( ); moduleFor( - 'Router helpers: {{is-transitioning-in}} and {{is-transitioning-out}} with null models', + 'Router helpers: {{isTransitioningIn}} and {{isTransitioningOut}} with null models', class extends ApplicationTestCase { constructor(...args) { super(...args); @@ -446,8 +465,9 @@ moduleFor( 'template:application', precompileTemplate( `{{outlet}} - {{is-transitioning-in "about" this.model}} - {{is-transitioning-out "about" this.model}}` + {{isTransitioningIn "about" this.model}} + {{isTransitioningOut "about" this.model}}`, + { strictMode: true, scope: () => ({ isTransitioningIn, isTransitioningOut }) } ) ); this.add( @@ -463,12 +483,12 @@ moduleFor( assert.equal( this.$('#result-in').text(), 'false', - 'is-transitioning-in false with null model' + 'isTransitioningIn false with null model' ); assert.equal( this.$('#result-out').text(), 'false', - 'is-transitioning-out false with null model' + 'isTransitioningOut false with null model' ); } } From 4d43a2d51a44ed86f34a9ee379f9941677ba2796 Mon Sep 17 00:00:00 2001 From: Ilya Radchenko Date: Mon, 8 Jun 2026 09:21:24 -0400 Subject: [PATCH 05/11] fix: format --- .../tests/integration/helpers/router-helpers-test.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js b/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js index 6f63e3b1c9a..218202ab9d3 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js @@ -480,11 +480,7 @@ moduleFor( async ['@test returns false when model is null'](assert) { await this.visit('/'); - assert.equal( - this.$('#result-in').text(), - 'false', - 'isTransitioningIn false with null model' - ); + assert.equal(this.$('#result-in').text(), 'false', 'isTransitioningIn false with null model'); assert.equal( this.$('#result-out').text(), 'false', From 9726040214c592cb39732f446fbacabbc089936f Mon Sep 17 00:00:00 2001 From: Ilya Radchenko Date: Mon, 8 Jun 2026 09:36:44 -0400 Subject: [PATCH 06/11] docs: update to camelcase usage and add import examples --- .../-internals/glimmer/lib/helpers/is-active.ts | 12 ++++++++---- .../-internals/glimmer/lib/helpers/is-loading.ts | 10 +++++++--- .../glimmer/lib/helpers/is-transitioning-in.ts | 8 ++++++-- .../glimmer/lib/helpers/is-transitioning-out.ts | 8 ++++++-- .../-internals/glimmer/lib/helpers/root-url.ts | 8 ++++++-- .../@ember/-internals/glimmer/lib/helpers/url-for.ts | 10 +++++++--- 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts index 03ba9362bd0..13a2c78e1ff 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts @@ -1,22 +1,26 @@ /** - The `{{is-active}}` helper returns `true` if the given route (and optional + The `{{isActive}}` helper returns `true` if the given route (and optional models / query params) matches the application's current route state — the same logic that `` uses to apply its `active` CSS class. + ```javascript + import { isActive } from '@ember/routing'; + ``` + ```handlebars - About + About ``` With a dynamic segment: ```handlebars - {{is-active "post" this.post}} + {{isActive "post" this.post}} ``` With query params: ```handlebars - {{is-active "posts" queryParams=(hash page=2)}} + {{isActive "posts" queryParams=(hash page=2)}} ``` Returns `false` if the route name or any model is null/undefined (loading state). diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts index 19abe7fc99c..695456cd4af 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts @@ -1,13 +1,17 @@ /** - The `{{is-loading}}` helper returns `true` if the route name or any of the + The `{{isLoading}}` helper returns `true` if the route name or any of the passed models are null or undefined, mirroring the loading state that `` detects when it renders `#` as the href. + ```javascript + import { isLoading, urlFor } from '@ember/routing'; + ``` + ```handlebars - {{#if (is-loading "post" this.post)}} + {{#if (isLoading "post" this.post)}} Loading… {{else}} - {{this.post.title}} + {{this.post.title}} {{/if}} ``` diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts index 9bc4b246b4d..bd303099764 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts @@ -1,13 +1,17 @@ /** - The `{{is-transitioning-in}}` helper returns `true` when the application is + The `{{isTransitioningIn}}` helper returns `true` when the application is currently transitioning *into* the specified route — i.e., the route is not yet active but will become active when the in-flight transition settles. This corresponds to the `ember-transitioning-in` CSS class that `` applies during such transitions. + ```javascript + import { isTransitioningIn } from '@ember/routing'; + ``` + ```handlebars - About + About ``` Returns `false` when no transition is in flight or the route is already active. diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts index 505dd4ee6f4..dc76e64b62c 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts @@ -1,13 +1,17 @@ /** - The `{{is-transitioning-out}}` helper returns `true` when the application is + The `{{isTransitioningOut}}` helper returns `true` when the application is currently transitioning *away from* the specified route — i.e., the route is active now but will no longer be active when the in-flight transition settles. This corresponds to the `ember-transitioning-out` CSS class that `` applies during such transitions. + ```javascript + import { isTransitioningOut } from '@ember/routing'; + ``` + ```handlebars - About + About ``` Returns `false` when no transition is in flight or the route is not currently active. diff --git a/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts b/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts index df10a77e95f..9e7e186bd45 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts @@ -1,8 +1,12 @@ /** - The `{{root-url}}` helper returns the application's configured `rootURL`. + The `{{rootUrl}}` helper returns the application's configured `rootURL`. + + ```javascript + import { rootUrl } from '@ember/routing'; + ``` ```handlebars - Profile + Profile ``` @method root-url diff --git a/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts index a476630fc0e..7060fe7995d 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts @@ -1,16 +1,20 @@ /** - The `{{url-for}}` helper returns a URL string for a given route, matching the + The `{{urlFor}}` helper returns a URL string for a given route, matching the same arguments as ``. Unlike ``, it returns a string value rather than rendering an anchor element. + ```javascript + import { urlFor } from '@ember/routing'; + ``` + ```handlebars - Profile + Profile ``` With query params: ```handlebars - Page 2 + Page 2 ``` Returns `undefined` if the route name or any model is null/undefined (loading state). From 3b66c954c13ebe4dc8cfb8ee97b260594e124150 Mon Sep 17 00:00:00 2001 From: Ilya Radchenko Date: Mon, 8 Jun 2026 10:43:50 -0400 Subject: [PATCH 07/11] chore: move isMissing util --- .../-internals/glimmer/lib/helpers/-router-helpers-utils.ts | 3 +++ packages/@ember/-internals/glimmer/lib/helpers/is-active.ts | 5 +---- packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts | 5 +---- .../-internals/glimmer/lib/helpers/is-transitioning-in.ts | 5 +---- .../-internals/glimmer/lib/helpers/is-transitioning-out.ts | 5 +---- packages/@ember/-internals/glimmer/lib/helpers/url-for.ts | 5 +---- 6 files changed, 8 insertions(+), 20 deletions(-) create mode 100644 packages/@ember/-internals/glimmer/lib/helpers/-router-helpers-utils.ts diff --git a/packages/@ember/-internals/glimmer/lib/helpers/-router-helpers-utils.ts b/packages/@ember/-internals/glimmer/lib/helpers/-router-helpers-utils.ts new file mode 100644 index 00000000000..63a3e819513 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/-router-helpers-utils.ts @@ -0,0 +1,3 @@ +export function isMissing(value: unknown): value is null | undefined { + return value === null || value === undefined; +} diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts index 13a2c78e1ff..4f790bf0296 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts @@ -38,10 +38,7 @@ import { tagFor } from '@glimmer/validator/lib/meta'; import type Route from '@ember/routing/route'; import type { RouterState, RoutingService } from '@ember/routing/-internals'; import { internalHelper } from './internal-helper'; - -function isMissing(value: unknown): value is null | undefined { - return value === null || value === undefined; -} +import { isMissing } from './-router-helpers-utils'; export default internalHelper( ({ positional, named }: CapturedArguments, owner: InternalOwner | undefined) => { diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts index 695456cd4af..56f08a48f5b 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts @@ -22,10 +22,7 @@ import type { CapturedArguments } from '@glimmer/interfaces'; import { createComputeRef, valueForRef } from '@glimmer/reference/lib/reference'; import { internalHelper } from './internal-helper'; - -function isMissing(value: unknown): value is null | undefined { - return value === null || value === undefined; -} +import { isMissing } from './-router-helpers-utils'; export default internalHelper(({ positional }: CapturedArguments) => { return createComputeRef( diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts index bd303099764..ab9764cdad5 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts @@ -29,10 +29,7 @@ import { tagFor } from '@glimmer/validator/lib/meta'; import type Route from '@ember/routing/route'; import type { RouterState, RoutingService } from '@ember/routing/-internals'; import { internalHelper } from './internal-helper'; - -function isMissing(value: unknown): value is null | undefined { - return value === null || value === undefined; -} +import { isMissing } from './-router-helpers-utils'; export default internalHelper( ({ positional, named }: CapturedArguments, owner: InternalOwner | undefined) => { diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts index dc76e64b62c..df95b9f4708 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts @@ -29,10 +29,7 @@ import { tagFor } from '@glimmer/validator/lib/meta'; import type Route from '@ember/routing/route'; import type { RouterState, RoutingService } from '@ember/routing/-internals'; import { internalHelper } from './internal-helper'; - -function isMissing(value: unknown): value is null | undefined { - return value === null || value === undefined; -} +import { isMissing } from './-router-helpers-utils'; export default internalHelper( ({ positional, named }: CapturedArguments, owner: InternalOwner | undefined) => { diff --git a/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts index 7060fe7995d..f0ba9c8c34e 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts @@ -32,10 +32,7 @@ import { tagFor } from '@glimmer/validator/lib/meta'; import type Route from '@ember/routing/route'; import type { RoutingService } from '@ember/routing/-internals'; import { internalHelper } from './internal-helper'; - -function isMissing(value: unknown): value is null | undefined { - return value === null || value === undefined; -} +import { isMissing } from './-router-helpers-utils'; export default internalHelper( ({ positional, named }: CapturedArguments, owner: InternalOwner | undefined) => { From 7d760c1684cdf69d5c681a219f1df04609988daa Mon Sep 17 00:00:00 2001 From: Ilya Radchenko Date: Mon, 8 Jun 2026 12:34:21 -0400 Subject: [PATCH 08/11] fix: plain public api helpers --- .../glimmer/lib/helpers/is-active.ts | 58 +++++-------- .../glimmer/lib/helpers/is-loading.ts | 18 +---- .../lib/helpers/is-transitioning-in.ts | 81 +++++++------------ .../lib/helpers/is-transitioning-out.ts | 81 +++++++------------ .../glimmer/lib/helpers/root-url.ts | 20 +++-- .../-internals/glimmer/lib/helpers/url-for.ts | 54 +++++-------- 6 files changed, 111 insertions(+), 201 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts index 4f790bf0296..9df55699adf 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts @@ -29,50 +29,30 @@ @for Ember.Templates.helpers @public */ -import type { CapturedArguments, Maybe } from '@glimmer/interfaces'; -import type { InternalOwner } from '@ember/-internals/owner'; -import { assert } from '@ember/debug'; -import { createComputeRef, valueForRef } from '@glimmer/reference/lib/reference'; -import { consumeTag } from '@glimmer/validator/lib/tracking'; -import { tagFor } from '@glimmer/validator/lib/meta'; +import type { Maybe } from '@glimmer/interfaces'; import type Route from '@ember/routing/route'; import type { RouterState, RoutingService } from '@ember/routing/-internals'; -import { internalHelper } from './internal-helper'; +import { service } from '@ember/service'; +import Helper from '@ember/component/helper'; import { isMissing } from './-router-helpers-utils'; -export default internalHelper( - ({ positional, named }: CapturedArguments, owner: InternalOwner | undefined) => { - assert('[BUG] missing owner', owner); - const routing = owner.lookup('service:-routing') as RoutingService; +export default class IsActiveHelper extends Helper { + @service('-routing') declare private routing: RoutingService; - return createComputeRef( - () => { - let routeRef = positional[0]; - let routeName = - routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - let models = positional.slice(1).map((ref) => valueForRef(ref)) as {}[]; - let queryParamsRef = named['queryParams']; - let queryParams = - queryParamsRef !== undefined - ? (valueForRef(queryParamsRef) as Record) - : undefined; + compute( + [routeName, ...models]: [string | null | undefined, ...unknown[]], + { queryParams }: { queryParams?: Record } + ): boolean { + if (isMissing(routeName) || models.some(isMissing)) { + return false; + } - consumeTag(tagFor(routing, 'currentState')); + const state = this.routing.currentState as Maybe; + if (isMissing(state)) { + return false; + } - if (isMissing(routeName) || models.some(isMissing)) { - return false; - } - - let state = routing.currentState as Maybe; - if (isMissing(state)) { - return false; - } - - return routing.isActiveForRoute(models, queryParams, routeName, state); - }, - null, - 'is-active' - ); + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + return this.routing.isActiveForRoute(models as {}[], queryParams, routeName, state); } -); +} diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts index 56f08a48f5b..b1a0e6ee4ba 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts @@ -19,20 +19,8 @@ @for Ember.Templates.helpers @public */ -import type { CapturedArguments } from '@glimmer/interfaces'; -import { createComputeRef, valueForRef } from '@glimmer/reference/lib/reference'; -import { internalHelper } from './internal-helper'; import { isMissing } from './-router-helpers-utils'; -export default internalHelper(({ positional }: CapturedArguments) => { - return createComputeRef( - () => { - let routeRef = positional[0]; - let routeName = routeRef !== undefined ? valueForRef(routeRef) : undefined; - let models = positional.slice(1).map((ref) => valueForRef(ref)); - return isMissing(routeName) || models.some(isMissing); - }, - null, - 'is-loading' - ); -}); +export default function isLoading(routeName: unknown, ...models: unknown[]): boolean { + return isMissing(routeName) || models.some(isMissing); +} diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts index ab9764cdad5..e1d1805803c 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts @@ -20,58 +20,39 @@ @for Ember.Templates.helpers @public */ -import type { CapturedArguments, Maybe } from '@glimmer/interfaces'; -import type { InternalOwner } from '@ember/-internals/owner'; -import { assert } from '@ember/debug'; -import { createComputeRef, valueForRef } from '@glimmer/reference/lib/reference'; -import { consumeTag } from '@glimmer/validator/lib/tracking'; -import { tagFor } from '@glimmer/validator/lib/meta'; +import type { Maybe } from '@glimmer/interfaces'; import type Route from '@ember/routing/route'; import type { RouterState, RoutingService } from '@ember/routing/-internals'; -import { internalHelper } from './internal-helper'; +import { service } from '@ember/service'; +import Helper from '@ember/component/helper'; import { isMissing } from './-router-helpers-utils'; -export default internalHelper( - ({ positional, named }: CapturedArguments, owner: InternalOwner | undefined) => { - assert('[BUG] missing owner', owner); - const routing = owner.lookup('service:-routing') as RoutingService; - - return createComputeRef( - () => { - let routeRef = positional[0]; - let routeName = - routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - let models = positional.slice(1).map((ref) => valueForRef(ref)) as {}[]; - let queryParamsRef = named['queryParams']; - let queryParams = - queryParamsRef !== undefined - ? (valueForRef(queryParamsRef) as Record) - : undefined; - - consumeTag(tagFor(routing, 'currentState')); - consumeTag(tagFor(routing, 'targetState')); - - if (isMissing(routeName) || models.some(isMissing)) { - return false; - } - - let current = routing.currentState as Maybe; - let target = routing.targetState as Maybe; - - // No transition in flight. - if (isMissing(target) || current === target) { - return false; - } - - let isCurrentlyActive = - !isMissing(current) && routing.isActiveForRoute(models, queryParams, routeName, current); - let willBeActive = routing.isActiveForRoute(models, queryParams, routeName, target); - - return !isCurrentlyActive && willBeActive; - }, - null, - 'is-transitioning-in' - ); +export default class IsTransitioningInHelper extends Helper { + @service('-routing') declare private routing: RoutingService; + + compute( + [routeName, ...models]: [string | null | undefined, ...unknown[]], + { queryParams }: { queryParams?: Record } + ): boolean { + if (isMissing(routeName) || models.some(isMissing)) { + return false; + } + + const current = this.routing.currentState as Maybe; + const target = this.routing.targetState as Maybe; + + // No transition in flight. + if (isMissing(target) || current === target) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + const castedModels = models as {}[]; + const isCurrentlyActive = + !isMissing(current) && + this.routing.isActiveForRoute(castedModels, queryParams, routeName, current); + const willBeActive = this.routing.isActiveForRoute(castedModels, queryParams, routeName, target); + + return !isCurrentlyActive && willBeActive; } -); +} diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts index df95b9f4708..08d9a523c2d 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts @@ -20,58 +20,39 @@ @for Ember.Templates.helpers @public */ -import type { CapturedArguments, Maybe } from '@glimmer/interfaces'; -import type { InternalOwner } from '@ember/-internals/owner'; -import { assert } from '@ember/debug'; -import { createComputeRef, valueForRef } from '@glimmer/reference/lib/reference'; -import { consumeTag } from '@glimmer/validator/lib/tracking'; -import { tagFor } from '@glimmer/validator/lib/meta'; +import type { Maybe } from '@glimmer/interfaces'; import type Route from '@ember/routing/route'; import type { RouterState, RoutingService } from '@ember/routing/-internals'; -import { internalHelper } from './internal-helper'; +import { service } from '@ember/service'; +import Helper from '@ember/component/helper'; import { isMissing } from './-router-helpers-utils'; -export default internalHelper( - ({ positional, named }: CapturedArguments, owner: InternalOwner | undefined) => { - assert('[BUG] missing owner', owner); - const routing = owner.lookup('service:-routing') as RoutingService; - - return createComputeRef( - () => { - let routeRef = positional[0]; - let routeName = - routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - let models = positional.slice(1).map((ref) => valueForRef(ref)) as {}[]; - let queryParamsRef = named['queryParams']; - let queryParams = - queryParamsRef !== undefined - ? (valueForRef(queryParamsRef) as Record) - : undefined; - - consumeTag(tagFor(routing, 'currentState')); - consumeTag(tagFor(routing, 'targetState')); - - if (isMissing(routeName) || models.some(isMissing)) { - return false; - } - - let current = routing.currentState as Maybe; - let target = routing.targetState as Maybe; - - // No transition in flight. - if (isMissing(target) || current === target) { - return false; - } - - let isCurrentlyActive = - !isMissing(current) && routing.isActiveForRoute(models, queryParams, routeName, current); - let willBeActive = routing.isActiveForRoute(models, queryParams, routeName, target); - - return isCurrentlyActive && !willBeActive; - }, - null, - 'is-transitioning-out' - ); +export default class IsTransitioningOutHelper extends Helper { + @service('-routing') declare private routing: RoutingService; + + compute( + [routeName, ...models]: [string | null | undefined, ...unknown[]], + { queryParams }: { queryParams?: Record } + ): boolean { + if (isMissing(routeName) || models.some(isMissing)) { + return false; + } + + const current = this.routing.currentState as Maybe; + const target = this.routing.targetState as Maybe; + + // No transition in flight. + if (isMissing(target) || current === target) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + const castedModels = models as {}[]; + const isCurrentlyActive = + !isMissing(current) && + this.routing.isActiveForRoute(castedModels, queryParams, routeName, current); + const willBeActive = this.routing.isActiveForRoute(castedModels, queryParams, routeName, target); + + return isCurrentlyActive && !willBeActive; } -); +} diff --git a/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts b/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts index 9e7e186bd45..018af837881 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts @@ -13,16 +13,14 @@ @for Ember.Templates.helpers @public */ -import type { CapturedArguments } from '@glimmer/interfaces'; -import type { InternalOwner } from '@ember/-internals/owner'; -import { assert } from '@ember/debug'; -import { createConstRef } from '@glimmer/reference/lib/reference'; import type RouterService from '@ember/routing/router-service'; -import { internalHelper } from './internal-helper'; +import { service } from '@ember/service'; +import Helper from '@ember/component/helper'; -export default internalHelper((_args: CapturedArguments, owner: InternalOwner | undefined) => { - assert('[BUG] missing owner', owner); - const router = owner.lookup('service:router') as RouterService; - // rootURL is a static configuration value — safe to use a const ref. - return createConstRef(router.rootURL, 'root-url'); -}); +export default class RootUrlHelper extends Helper { + @service('router') declare private router: RouterService; + + compute(): string { + return this.router.rootURL; + } +} diff --git a/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts index f0ba9c8c34e..9781503bc1c 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts @@ -23,47 +23,29 @@ @for Ember.Templates.helpers @public */ -import type { CapturedArguments } from '@glimmer/interfaces'; -import type { InternalOwner } from '@ember/-internals/owner'; -import { assert } from '@ember/debug'; -import { createComputeRef, valueForRef } from '@glimmer/reference/lib/reference'; -import { consumeTag } from '@glimmer/validator/lib/tracking'; -import { tagFor } from '@glimmer/validator/lib/meta'; import type Route from '@ember/routing/route'; import type { RoutingService } from '@ember/routing/-internals'; -import { internalHelper } from './internal-helper'; +import { service } from '@ember/service'; +import Helper from '@ember/component/helper'; import { isMissing } from './-router-helpers-utils'; -export default internalHelper( - ({ positional, named }: CapturedArguments, owner: InternalOwner | undefined) => { - assert('[BUG] missing owner', owner); - const routing = owner.lookup('service:-routing') as RoutingService; +export default class UrlForHelper extends Helper { + @service('-routing') declare private routing: RoutingService; - return createComputeRef( - () => { - let routeRef = positional[0]; - let routeName = - routeRef !== undefined ? (valueForRef(routeRef) as string | null | undefined) : undefined; - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - let models = positional.slice(1).map((ref) => valueForRef(ref)) as {}[]; - let queryParamsRef = named['queryParams']; - let queryParams = - queryParamsRef !== undefined - ? (valueForRef(queryParamsRef) as Record) - : {}; + compute( + [routeName, ...models]: [string | null | undefined, ...unknown[]], + { queryParams }: { queryParams?: Record } + ): string | undefined { + if (isMissing(routeName) || models.some(isMissing)) { + return undefined; + } - // Consume currentState so this ref invalidates when QPs change, matching - // the same pattern as LinkTo's href getter. - consumeTag(tagFor(routing, 'currentState')); + // Access currentState to track route state changes (e.g. QP updates), + // mirroring LinkTo's href behavior. + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + this.routing.currentState; - if (isMissing(routeName) || models.some(isMissing)) { - return undefined; - } - - return routing.generateURL(routeName, models, queryParams); - }, - null, - 'url-for' - ); + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + return this.routing.generateURL(routeName, models as {}[], queryParams ?? {}); } -); +} From 758d5527a3618830a30ee48eaf6992c045bcdbe3 Mon Sep 17 00:00:00 2001 From: Ilya Radchenko Date: Mon, 8 Jun 2026 14:35:02 -0400 Subject: [PATCH 09/11] fix: public service and other feedback --- .../-internals/glimmer/lib/helpers/is-active.ts | 15 ++++----------- .../-internals/glimmer/lib/helpers/is-loading.ts | 2 +- .../-internals/glimmer/lib/helpers/url-for.ts | 15 +++++---------- 3 files changed, 10 insertions(+), 22 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts index 9df55699adf..273ea970522 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts @@ -29,15 +29,13 @@ @for Ember.Templates.helpers @public */ -import type { Maybe } from '@glimmer/interfaces'; -import type Route from '@ember/routing/route'; -import type { RouterState, RoutingService } from '@ember/routing/-internals'; +import type RouterService from '@ember/routing/router-service'; import { service } from '@ember/service'; import Helper from '@ember/component/helper'; import { isMissing } from './-router-helpers-utils'; export default class IsActiveHelper extends Helper { - @service('-routing') declare private routing: RoutingService; + @service('router') declare private router: RouterService; compute( [routeName, ...models]: [string | null | undefined, ...unknown[]], @@ -47,12 +45,7 @@ export default class IsActiveHelper extends Helper { return false; } - const state = this.routing.currentState as Maybe; - if (isMissing(state)) { - return false; - } - - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - return this.routing.isActiveForRoute(models as {}[], queryParams, routeName, state); + const args = queryParams ? [...models, { queryParams }] : [...models]; + return this.router.isActive(routeName, ...args); } } diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts index b1a0e6ee4ba..d93c2b97955 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts @@ -22,5 +22,5 @@ import { isMissing } from './-router-helpers-utils'; export default function isLoading(routeName: unknown, ...models: unknown[]): boolean { - return isMissing(routeName) || models.some(isMissing); + return !routeName || models.some(isMissing); } diff --git a/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts index 9781503bc1c..23766868a21 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts @@ -23,14 +23,13 @@ @for Ember.Templates.helpers @public */ -import type Route from '@ember/routing/route'; -import type { RoutingService } from '@ember/routing/-internals'; +import type RouterService from '@ember/routing/router-service'; import { service } from '@ember/service'; import Helper from '@ember/component/helper'; import { isMissing } from './-router-helpers-utils'; export default class UrlForHelper extends Helper { - @service('-routing') declare private routing: RoutingService; + @service('router') declare private router: RouterService; compute( [routeName, ...models]: [string | null | undefined, ...unknown[]], @@ -40,12 +39,8 @@ export default class UrlForHelper extends Helper { return undefined; } - // Access currentState to track route state changes (e.g. QP updates), - // mirroring LinkTo's href behavior. - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - this.routing.currentState; - - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - return this.routing.generateURL(routeName, models as {}[], queryParams ?? {}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const args: any[] = queryParams ? [...models, { queryParams }] : [...models]; + return this.router.urlFor(routeName, ...args); } } From c6dc22be7d0b71de8a0a8bd9391028f386b41931 Mon Sep 17 00:00:00 2001 From: Ilya Radchenko Date: Mon, 8 Jun 2026 14:43:19 -0400 Subject: [PATCH 10/11] fix: add another test --- .../glimmer/lib/helpers/is-active.ts | 5 +++ .../helpers/router-helpers-test.js | 44 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts index 273ea970522..d252a9db0c9 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts @@ -45,6 +45,11 @@ export default class IsActiveHelper extends Helper { return false; } + // Also track currentRouteName: during loading/error substates the URL + // doesn't change but the active route name does, so isActive() alone + // (which only consumes currentURL) would miss those transitions. + void this.router.currentRouteName; + const args = queryParams ? [...models, { queryParams }] : [...models]; return this.router.isActive(routeName, ...args); } diff --git a/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js b/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js index 218202ab9d3..ea16f27af64 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js @@ -257,6 +257,50 @@ moduleFor( await this.visit('/posts/42'); assert.equal(this.$('#result').text(), 'false', 'false for different model id'); } + + async ['@test stays false during loading substate (URL unchanged, route name changes)']( + assert + ) { + let aboutDefer = RSVP.defer(); + + this.add( + 'route:about', + class extends Route { + model() { + return aboutDefer.promise; + } + } + ); + + this.add( + 'template:about-loading', + precompileTemplate(`
`, { strictMode: true, scope: () => ({}) }) + ); + + this.add( + 'template:application', + precompileTemplate( + `{{outlet}}{{isActive "about"}}`, + { strictMode: true, scope: () => ({ isActive }) } + ) + ); + + await this.visit('/'); + assert.equal(this.$('#about-active').text(), 'false', 'false before navigation'); + + let visitPromise = this.visit('/about'); + // While in the loading substate, currentURL is still '/' but + // currentRouteName has changed — isActive must still return false. + assert.equal( + this.$('#about-active').text(), + 'false', + 'false during loading substate' + ); + + aboutDefer.resolve(); + await visitPromise; + assert.equal(this.$('#about-active').text(), 'true', 'true after model resolves'); + } } ); From a0559f3d847dd914a1798fc5b82168650b2f28a6 Mon Sep 17 00:00:00 2001 From: Ilya Radchenko Date: Wed, 10 Jun 2026 22:16:49 -0400 Subject: [PATCH 11/11] chore: use gjs highlighting --- packages/@ember/-internals/glimmer/lib/helpers/is-active.ts | 6 +++--- .../@ember/-internals/glimmer/lib/helpers/is-loading.ts | 2 +- .../-internals/glimmer/lib/helpers/is-transitioning-in.ts | 2 +- .../-internals/glimmer/lib/helpers/is-transitioning-out.ts | 2 +- packages/@ember/-internals/glimmer/lib/helpers/root-url.ts | 2 +- packages/@ember/-internals/glimmer/lib/helpers/url-for.ts | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts index d252a9db0c9..18e1f69b796 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts @@ -7,19 +7,19 @@ import { isActive } from '@ember/routing'; ``` - ```handlebars + ```gjs About ``` With a dynamic segment: - ```handlebars + ```gjs {{isActive "post" this.post}} ``` With query params: - ```handlebars + ```gjs {{isActive "posts" queryParams=(hash page=2)}} ``` diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts index d93c2b97955..9eddb7bb18a 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts @@ -7,7 +7,7 @@ import { isLoading, urlFor } from '@ember/routing'; ``` - ```handlebars + ```gjs {{#if (isLoading "post" this.post)}} Loading… {{else}} diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts index e1d1805803c..0dc5e674efb 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts @@ -10,7 +10,7 @@ import { isTransitioningIn } from '@ember/routing'; ``` - ```handlebars + ```gjs About ``` diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts index 08d9a523c2d..5e9346e7375 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts @@ -10,7 +10,7 @@ import { isTransitioningOut } from '@ember/routing'; ``` - ```handlebars + ```gjs About ``` diff --git a/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts b/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts index 018af837881..47005a102db 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts @@ -5,7 +5,7 @@ import { rootUrl } from '@ember/routing'; ``` - ```handlebars + ```gjs Profile ``` diff --git a/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts index 23766868a21..64f14f98349 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts @@ -7,13 +7,13 @@ import { urlFor } from '@ember/routing'; ``` - ```handlebars + ```gjs Profile ``` With query params: - ```handlebars + ```gjs Page 2 ```