From 20bec7578624e8c0ab208311f2f71e241265126a Mon Sep 17 00:00:00 2001 From: Nick Schot Date: Thu, 26 Feb 2026 16:09:45 +0100 Subject: [PATCH 01/26] Initial RFC draft --- text/0000-route-manager-api.md | 452 +++++++++++++++++++++++++++++++++ 1 file changed, 452 insertions(+) create mode 100644 text/0000-route-manager-api.md diff --git a/text/0000-route-manager-api.md b/text/0000-route-manager-api.md new file mode 100644 index 0000000000..b580f56f4b --- /dev/null +++ b/text/0000-route-manager-api.md @@ -0,0 +1,452 @@ +--- +stage: accepted +start-date: 2026-02-26T00:00:00.000Z +release-date: # In format YYYY-MM-DDT00:00:00.000Z +release-versions: +teams: # delete teams that aren't relevant + - framework +prs: + accepted: # Fill this in with the URL for the Proposal RFC PR +project-link: +suite: +--- + + + + + +# Route Manager API + +## Summary + +> One paragraph explanation of the feature. + +Define a generic Route Manager concept that can be used to implement new Route base classes as a stepping stone towards a new router. + +## Motivation + +> Why are we doing this? What use cases does it support? What is the expected +outcome? + +The intent of this RFC is to implement a generic Route Manager concept so that we’re able to provide room for experimentation and migration to a new router solution. It aims to provide a well-defined interface between the Router and Route concepts. Well-defined in this case means both API and lifecycle. + +This will unlock the possibility of implementing new Route base classes while also making it easier to replace the current router. + +A concrete example: since it’s the Route that brings in the Controller, it will also, for example, become possible to implement a Route Manager that exposes a Routable Component without the need for a Controller. + +This RFC is **not** intended to describe APIs that Ember app developers would generally use, but it describes the low-level API intended for framework developers to develop next generation routing and for the rare ecosystem developer wanting to write their own Route base classes with an accompanying Route Manager implementation. + + +## Detailed design + +> This is the bulk of the RFC. + +> Explain the design in enough detail for somebody +familiar with the framework to understand, and for somebody familiar with the +implementation to implement. This should get into specifics and corner-cases, +and include examples of how the feature is used. Any new terminology should be +defined here. + +> Please keep in mind any implications within the Ember ecosystem, such as: +> - Lint rules (ember-template-lint, eslint-plugin-ember) that should be added, modified or removed +> - Features that are replaced or made obsolete by this feature and should eventually be deprecated +> - Ember Inspector and debuggability +> - Server-side Rendering +> - Ember Engines +> - The Addon Ecosystem +> - IDE Support +> - Blueprints that should be added or modified + +### Route Manager basics + +The minimal API for a Route Manager consists of `capabilities`, `createRoute` and a `getDestroyable` method. + +```ts +interface RouteManager { + capabilities: Capabilities; + + // Responsible for the creation of a RouteStateBucket. Returns a RouteStateBucket, defined by the manager implementation. + createRoute: (factory, args: CreateRouteArgs) => RouteStateBucket; + + // Returns the destroyable (if any) for the RouteStateBucket + getDestroyable: (bucket: RouteStateBucket) => Destroyable | null; +} + +interface CreateRouteArgs { + // By convention this is currently the dot separated route path. + name: typeof RouteInfo.name +} +``` + +#### `createRoute` + +The `createRoute` method on the Route Manager is responsible for taking the Route’s factory and arguments and based on that return a `RouteStateBucket` . + +***Note:** It is up to the manager to decide whether or not this method actually instantiates the factory or if that happens at a later time, depending on the specific lifecycle the manager implementation wants to provide.* + +#### `RouteStateBucket` + +The `RouteStateBucket` is a stable reference provided by the manager’s `createRoute` method. All interaction through the Route Manager API will require passing this same stable reference as an argument. The shape and contents of `RouteStateBucket` is defined by the specific Route Manager implementation. + +#### `getDestroyable` + +The `getDestroyable` method takes a `RouteStateBucket` and will return the `Destroyable` if applicable. This can be used by the manager implementation to wire up the lifetime of the route. + +### Determining which route manager to use + +This follows the same pattern as existing manager implementations. This method will be used by the framework for the Route Base Classes it provides as well as by non-framework code wanting to provide their own Route Manager implementation. + +```ts +// Takes a Factory function for the Manager with an Owner argument and +// the Route base object/class/function for which the manager applies. +setRouteManager: ( + createManager: (owner: Owner) => MyRouteManager, + definition: object +) => void +``` + +### NavigationState interface + +The NavigationState is an interface for the router to pass information to the manager methods. This interface can be extended with capabilities in the future. + +```ts +// Passed in to the lifecycle methods +interface NavigationState { + from: RouteInfo | undefined; + to: RouteInfo; +} + +// Classic interoperability, only provided if manager requests classicInterop capability +interface NavigationStateWithTransition = NavigationState & { + transition: Transition; +} +``` + +The `RouteInfo` classes refer to the existing public API `RouteInfo` as specified in [the Ember API documentation](https://api.emberjs.com/ember/6.10/classes/routeinfo). + +### NavigationActions interface + +The `NavigationActions` interface defines any actions that some of the manager hooks are allowed to call. For now this is just the `cancel` action which stops the current navigation. The implementation details are left to the router, but will at least need to abort the `signal` defined in the `AsyncNavigationState` interface. + +```ts +interface NavigationActions { + // Cancels the current navigation + cancel: () => void; +} +``` + +### AsyncNavigationState interface + +The `AsyncNavigationState` interface allows Route Managers to have a certain amount of control over the navigation. + +The `signal` is an `AbortSignal` provided by the Router which can be used to react to a cancellation of the current navigation. It can be passed to, for example, a `fetch` call. + +`ancestorPromises` allows you to tie in to the asynchronous lifecycle of ancestor Routes. This opens the possibility for a RouteManager implementation for parallel resolution of the asynchronous lifecycle. The Classic Route Manager will rely on this behaviour to implement the current waterfall lifecycle. + +In addition, the Router will need to provide a method to the Route Managers to retrieve the `resolvedContext` (defined later in this document) of a Route based on its `RouteInfo`. + +```ts +// Exposes API used to interact with the active navigation, like awaiting ancestor's async behaviour. +interface AsyncNavigationState { + // Signal for the current navigation + signal: AbortSignal; + + // A WeakMap of ancestor promises that can be used to await async ancestor behaviour. + ancestorPromises: WeakMap> + + // Retrieve the resolvedContext of an ancestor route. + getResolvedContext: (routeInfo: RouteInfo) => ReturnType | undefined; +} +``` + +Since `RouteManager.resolvedContext` is part of an optional capability for Route Manager implementations, `getResolvedContext` could return `undefined`. + +### Route lifecycle + +This RFC proposes 3 groups of hooks for lifecycle management of a Route. + +- `enter` - called when a route is visited. +- `update` - called when the input for a route has changed, think dynamic segment or query param. +- `exit` - called when a route is exited. + +The main lifecycle methods are accompanied by synchronous will*/did* methods. This gives the possibility of implementing lifecycle features like cancelling/preventing a route change, cleaning up after a route branch was fully exited. `update` and `enter` are promise-aware and will be awaited. These methods give the option to do asynchronous work that needs to happen before rendering. + +```ts + +interface RouteManager { + // Lifecycle hook called when the Route is about to be entered. + willEnter: (bucket: RouteStateBucket, args: NavigationState & NavigationActions) => void; + // Main asynchronous entry point + enter: (bucket: RouteStateBucket, args: NavigationState & NavigationActions & AsyncNavigationState) => Promise; + // Called after all `enter` hooks for the current Route hierarchy have succesfully resolved. + didEnter: (bucket: RouteStateBucket, args: NavigationState) => void; + + // Similar to willEnter, but called on a Route that was also part of the previous navigation. + willUpdate: (bucket: RouteStateBucket, args: NavigationState & NavigationActions) => void; + // Called when the dynamic segments (and in the case of the classic router query parameters as well) for the Route have been updated. + update: (bucket: RouteStateBucket, args: NavigationState & NavigationActions & AsyncNavigationState) => Promise; + // Called when all updating Routes have updated. + didUpdate: (bucket: RouteStateBucket, args: NavigationState) => void; + + // Called when the Route is about to be exited. + willExit: (bucket: RouteStateBucket, args: NavigationState & NavigationActions) => void; + // Called when the Route is exited. + exit: (bucket: RouteStateBucket, args: NavigationState) => void; + // Called when all exiting routes have exited + didExit: (bucket: RouteStateBucket, args: NavigationState) => void; +} +``` + +We strongly considered not adding the `update` hooks, but decided against it for ergonomics and simplicity reasons. Not adding it would minimise the Route Manager API surface, but make implementing different behaviour significantly less ergonomic for most Route Manager implementations, whereas implement the `update` hooks with the same behaviour as `exit` + `enter` is simply calling those methods within the manager. + +The lifecycle of an example navigation between two nested routes looks as follows: + +```mermaid +sequenceDiagram + %% When putting this in github try using - participant Browser@{ "type" : "boundary" } + Actor Browser + participant R as Router + + box rgba(255, 255, 255, 0.2) RouteManagerAPI + %% participant I as Route "index" + participant A as Route "a" + participant AB as Route "a.b" + participant X as Route "x" + participant XY as Route "x.y" + end + + Browser-->>R: navigate from a.b to x.y + R->>AB: willExit() + R->>A: willExit() + R->>X: willEnter() + R->>XY: willEnter() + + R-->>Browser: location.href update if eager + + R-)+X: enter() + R-)+XY: enter() + X->>-R: Promise.resolve() + XY->>-R: Promise.resolve() + + R->>AB: exit() + R->>A: exit() + + R-->>Browser: location.href update if deferred + + R->>X: didEnter() + R->>XY: didEnter() + R->>AB: didExit() + R->>A: didExit() + +``` + +### Capabilities + +Route Managers are required to have a `capabilities` property. This property must be set to the result of calling the `capabilities` function provided by Ember. + +Any time the Classic Router interfaces with the RouteManager in a way we do not want in the future, we will shield this behind an optional capability. This capability or capabilities will at some point in the future be turned off by default through a deprecation. + +#### resolvedContext + +A separate optional capability will be introduced to access the last resolved value of the model hook (equivalent). This leaves open the possibility of asynchronous lifecycle combined with routes accessing ancestor model data through use of the `getResolvedContext` method. A route manager may choose not to implement this capability. + +```ts +interface RouteManagerWithResolvedContext = RouteManager & { + // Get the current resolved context (a.k.a. model) from the RouteStateBucket + resolvedContext(bucket: RouteStateBucket) => unknown; +} +``` + +#### Classic Router interoperability + +When the `classicInterop` capability is set to `true` the Route Manager will have to provide an implementation for the methods that cross the Route Manager boundary to recreate the current Classic Router behaviour. The following list is a best-effort to find those methods, but it may need to change during implementation. The capability that opts in to these functions is not intended to be implemented by any other future Route Manager. + +```ts +// Classic Router interoperability +interface RouteManagerWithClassicInterop = RouteManager & { + getRouteName(bucket: RouteStateBucket) => string; + getFullRouteName(bucket: RouteStateBucket) => string; + + // Query Parameter handling + stashNames(bucket: RouteStateBucket, routeInfo: ExtendedInternalRouteInfo, dynamicParent: ExtendedInternalRouteInfo) => void; + qp(bucket: RouteStateBucket): it's complicated + + serializeQueryParam(bucket: RouteStateBucket, value: unknown, urlKey: string, defaultValueType: string); + deserializeQueryParam(bucket: RouteStateBucket, value: unknown, urlKey: string, defaultValueType: string); + + // Actions/event handlers + queryParamsDidChange(bucket: RouteStateBucket, changed: {}, totalPresent: unknown, removed: {}) => boolean | void; + finalizeQueryParamChange(bucket: RouteStateBucket, params: Record, finalParams: {}[], transition: Transition) => boolean | void; +} +``` + +The necessary state will be taken from and stored in the passed `RouteStateBucket`. + +### Rendering + +With the Classic Router, rendering is handled through `RenderState` objects combined with a (scheduled once) call to `router._setOutlets` which updates the render state tree with the new `RenderState` objects from the current routes. This looks something like: + +```ts +let render: RenderState = { + owner, + name, + controller: undefined, // aliased as @controller argument + model: undefined, // aliased as @model argument + template, // template factory or component reference + }; +``` + +For the Route Manager API we will rework this structure to a generic invokable. This way the manager implementation can decide how render happens and what arguments are passed. Deferring render while waiting on asynchronous behaviour (like the Classic Route model hooks) will be a Route Manager concern. + +The return value of `getInvokable` is an object that needs to have an associated `ComponentManager`. + +```ts +interface RouteManager { + getInvokable: (bucket: RouteStateBucket) => object; +} +``` + +## How we teach this + +> What names and terminology work best for these concepts and why? How is this +idea best presented? As a continuation of existing Ember patterns, or as a +wholly new one? + +> Would the acceptance of this proposal mean the Ember guides must be +re-organized or altered? Does it change how Ember is taught to new users +at any level? + +> How should this feature be introduced and taught to existing Ember +users? + +> Keep in mind the variety of learning materials: API docs, guides, blog posts, tutorials, etc. + +Since this is not an Ember-user facing feature the guides don’t need adjustment. Documentation will live in the API docs. + +## Drawbacks + +> Why should we *not* do this? Please consider the impact on teaching Ember, +on the integration of this feature with other existing and planned features, +on the impact of the API churn on existing apps, etc. + +> There are tradeoffs to choosing any path, please attempt to identify them here. + +## Alternatives + +> What other designs have been considered? What is the impact of not doing this? + +> This section could also include prior art, that is, how other frameworks in the same domain have solved this problem. + +## Unresolved questions + +> Optional, but suggested for first drafts. What parts of the design are still +TBD? + +## Addenda + +### #1 What Classic Routes look like implemented with the Route Manager API + +The existing `@ember/route`  Route base class will be referred to as Classic Route. Below is a description of how the current Classic Route implementation could be supported by the proposed Route Manager API. + +#### Hooks & events + +Below an example of how the Classic Route class could map to manager events. **All** the classic hooks need access to the relevant `Transition` object. + +The model hooks are an RSVP Promise chain handled by router_js. We can put them in `enter` which is Promise-aware. + +--- + +#### Switching between routes: + +`willExit`: + +- `willTransition` event (currently sync), bubbles through RouteInfos as long as true is returned or no handler is present. **NOTE: THESE BUBBLE BOTTOM UP!** +- `routeWillChange` event (currently sync), router service event. + +`enter`: + +- `beforeModel` hook +- `model` hook +- `afterModel` hook + +`exit`: + +- `resetController` hook +- `deactivate` hook + +`didEnter`: + +- `activate` hook +- `setupController` hook +- `didTransition` event **NOTE: THESE BUBBLE BOTTOM UP!** +- `routeDidChange` event, router service event + +--- + +#### Updating the model for an existing route mapped to manager hooks: + +- `willUpdate` (leaf-most) + - `willTransition` event + - `routeWillChange` event, router service +- `update` + - `beforeModel` + - `model` + - `afterModel` +- `didUpdate` (leaf-most) + - `resetController` (conditionally, if model return value changed) + - `setupController` (conditionally, if model return value changed) + - `didTransition` (event, leafmost) + - `routeDidChange` event, router service + +#### Mapping of existing events and methods to the new API + +```mermaid +sequenceDiagram + %% When putting this in github try using - participant Browser@{ "type" : "boundary" } + Actor Browser + participant R as Router + + box rgba(255, 255, 255, 0.2) RouteManagerAPI + participant A as Route "a" + participant AB as Route "a.b" + participant X as Route "x" + participant XY as Route "x.y" + end + + Browser-->>R: navigate from a.b to x.y + R->>AB: willExit()
Event:willTransition + R->>A: willExit() + R->>X: willEnter() + R->>XY: willEnter() + + R-)+X: enter() + X->>X: beforeModel()
model()
afterModel() + X->>-R: Promise.resolve() + + R-)+XY: enter() + XY->>XY: beforeModel()
model()
afterModel() + XY->>-R: Promise.resolve() + + R->>AB: exit()
resetController()
deactivate() + R->>A: exit()
resetController()
deactivate() + + R-->>Browser: location.href update + + R->>X: didEnter()
activate()
setupController() + R->>XY: didEnter()
activate()
setupController()
Event:didTransition + R->>AB: didExit() + R->>A: didExit() + +``` \ No newline at end of file From f12641bd5eb3075880789991e5e004144fef4a9a Mon Sep 17 00:00:00 2001 From: Nick Schot Date: Thu, 26 Feb 2026 16:14:18 +0100 Subject: [PATCH 02/26] Cleanup --- text/0000-route-manager-api.md | 51 +++------------------------------- 1 file changed, 4 insertions(+), 47 deletions(-) diff --git a/text/0000-route-manager-api.md b/text/0000-route-manager-api.md index b580f56f4b..a26037ae9d 100644 --- a/text/0000-route-manager-api.md +++ b/text/0000-route-manager-api.md @@ -31,15 +31,10 @@ suite: Leave as is ## Summary -> One paragraph explanation of the feature. - Define a generic Route Manager concept that can be used to implement new Route base classes as a stepping stone towards a new router. ## Motivation -> Why are we doing this? What use cases does it support? What is the expected -outcome? - The intent of this RFC is to implement a generic Route Manager concept so that we’re able to provide room for experimentation and migration to a new router solution. It aims to provide a well-defined interface between the Router and Route concepts. Well-defined in this case means both API and lifecycle. This will unlock the possibility of implementing new Route base classes while also making it easier to replace the current router. @@ -51,24 +46,6 @@ This RFC is **not** intended to describe APIs that Ember app developers would ge ## Detailed design -> This is the bulk of the RFC. - -> Explain the design in enough detail for somebody -familiar with the framework to understand, and for somebody familiar with the -implementation to implement. This should get into specifics and corner-cases, -and include examples of how the feature is used. Any new terminology should be -defined here. - -> Please keep in mind any implications within the Ember ecosystem, such as: -> - Lint rules (ember-template-lint, eslint-plugin-ember) that should be added, modified or removed -> - Features that are replaced or made obsolete by this feature and should eventually be deprecated -> - Ember Inspector and debuggability -> - Server-side Rendering -> - Ember Engines -> - The Addon Ecosystem -> - IDE Support -> - Blueprints that should be added or modified - ### Route Manager basics The minimal API for a Route Manager consists of `capabilities`, `createRoute` and a `getDestroyable` method. @@ -320,39 +297,19 @@ interface RouteManager { ## How we teach this -> What names and terminology work best for these concepts and why? How is this -idea best presented? As a continuation of existing Ember patterns, or as a -wholly new one? - -> Would the acceptance of this proposal mean the Ember guides must be -re-organized or altered? Does it change how Ember is taught to new users -at any level? - -> How should this feature be introduced and taught to existing Ember -users? - -> Keep in mind the variety of learning materials: API docs, guides, blog posts, tutorials, etc. - -Since this is not an Ember-user facing feature the guides don’t need adjustment. Documentation will live in the API docs. +Since this is not an Ember app developer facing feature the guides don’t need adjustment. Documentation will live in the API docs. ## Drawbacks -> Why should we *not* do this? Please consider the impact on teaching Ember, -on the integration of this feature with other existing and planned features, -on the impact of the API churn on existing apps, etc. - -> There are tradeoffs to choosing any path, please attempt to identify them here. +TBD ## Alternatives -> What other designs have been considered? What is the impact of not doing this? - -> This section could also include prior art, that is, how other frameworks in the same domain have solved this problem. +TBD ## Unresolved questions -> Optional, but suggested for first drafts. What parts of the design are still -TBD? +TBD ## Addenda From f5eccb5b2bd5cfe44763bbbf067ce2dd35bb553f Mon Sep 17 00:00:00 2001 From: Nick Schot Date: Thu, 26 Feb 2026 16:20:21 +0100 Subject: [PATCH 03/26] Add proper relative links to sections specified later --- text/0000-route-manager-api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/text/0000-route-manager-api.md b/text/0000-route-manager-api.md index a26037ae9d..c222eb852d 100644 --- a/text/0000-route-manager-api.md +++ b/text/0000-route-manager-api.md @@ -115,7 +115,7 @@ The `RouteInfo` classes refer to the existing public API `RouteInfo` as specifie ### NavigationActions interface -The `NavigationActions` interface defines any actions that some of the manager hooks are allowed to call. For now this is just the `cancel` action which stops the current navigation. The implementation details are left to the router, but will at least need to abort the `signal` defined in the `AsyncNavigationState` interface. +The `NavigationActions` interface defines any actions that some of the manager hooks are allowed to call. For now this is just the `cancel` action which stops the current navigation. The implementation details are left to the router, but will at least need to abort the `signal` defined in the [`AsyncNavigationState` interface](#asyncnavigationstate-interface). ```ts interface NavigationActions { @@ -132,7 +132,7 @@ The `signal` is an `AbortSignal` provided by the Router which can be used to rea `ancestorPromises` allows you to tie in to the asynchronous lifecycle of ancestor Routes. This opens the possibility for a RouteManager implementation for parallel resolution of the asynchronous lifecycle. The Classic Route Manager will rely on this behaviour to implement the current waterfall lifecycle. -In addition, the Router will need to provide a method to the Route Managers to retrieve the `resolvedContext` (defined later in this document) of a Route based on its `RouteInfo`. +In addition, the Router will need to provide a method to the Route Managers to retrieve the [`resolvedContext`](#resolvedcontext) of a Route based on its `RouteInfo`. ```ts // Exposes API used to interact with the active navigation, like awaiting ancestor's async behaviour. From 39414bdf1144595be8c90e4611946f0a0da38f5c Mon Sep 17 00:00:00 2001 From: Nick Schot Date: Thu, 26 Feb 2026 16:21:47 +0100 Subject: [PATCH 04/26] Fix syntax highlighting --- text/0000-route-manager-api.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/text/0000-route-manager-api.md b/text/0000-route-manager-api.md index c222eb852d..1df8d4b8b7 100644 --- a/text/0000-route-manager-api.md +++ b/text/0000-route-manager-api.md @@ -50,7 +50,7 @@ This RFC is **not** intended to describe APIs that Ember app developers would ge The minimal API for a Route Manager consists of `capabilities`, `createRoute` and a `getDestroyable` method. -```ts +```typescript interface RouteManager { capabilities: Capabilities; @@ -85,7 +85,7 @@ The `getDestroyable` method takes a `RouteStateBucket` and will return the `Dest This follows the same pattern as existing manager implementations. This method will be used by the framework for the Route Base Classes it provides as well as by non-framework code wanting to provide their own Route Manager implementation. -```ts +```typescript // Takes a Factory function for the Manager with an Owner argument and // the Route base object/class/function for which the manager applies. setRouteManager: ( @@ -98,7 +98,7 @@ setRouteManager: ( The NavigationState is an interface for the router to pass information to the manager methods. This interface can be extended with capabilities in the future. -```ts +```typescript // Passed in to the lifecycle methods interface NavigationState { from: RouteInfo | undefined; @@ -117,7 +117,7 @@ The `RouteInfo` classes refer to the existing public API `RouteInfo` as specifie The `NavigationActions` interface defines any actions that some of the manager hooks are allowed to call. For now this is just the `cancel` action which stops the current navigation. The implementation details are left to the router, but will at least need to abort the `signal` defined in the [`AsyncNavigationState` interface](#asyncnavigationstate-interface). -```ts +```typescript interface NavigationActions { // Cancels the current navigation cancel: () => void; @@ -134,7 +134,7 @@ The `signal` is an `AbortSignal` provided by the Router which can be used to rea In addition, the Router will need to provide a method to the Route Managers to retrieve the [`resolvedContext`](#resolvedcontext) of a Route based on its `RouteInfo`. -```ts +```typescript // Exposes API used to interact with the active navigation, like awaiting ancestor's async behaviour. interface AsyncNavigationState { // Signal for the current navigation @@ -160,7 +160,7 @@ This RFC proposes 3 groups of hooks for lifecycle management of a Route. The main lifecycle methods are accompanied by synchronous will*/did* methods. This gives the possibility of implementing lifecycle features like cancelling/preventing a route change, cleaning up after a route branch was fully exited. `update` and `enter` are promise-aware and will be awaited. These methods give the option to do asynchronous work that needs to happen before rendering. -```ts +```typescript interface RouteManager { // Lifecycle hook called when the Route is about to be entered. @@ -239,7 +239,7 @@ Any time the Classic Router interfaces with the RouteManager in a way we do not A separate optional capability will be introduced to access the last resolved value of the model hook (equivalent). This leaves open the possibility of asynchronous lifecycle combined with routes accessing ancestor model data through use of the `getResolvedContext` method. A route manager may choose not to implement this capability. -```ts +```typescript interface RouteManagerWithResolvedContext = RouteManager & { // Get the current resolved context (a.k.a. model) from the RouteStateBucket resolvedContext(bucket: RouteStateBucket) => unknown; @@ -250,7 +250,7 @@ interface RouteManagerWithResolvedContext = RouteManager & { When the `classicInterop` capability is set to `true` the Route Manager will have to provide an implementation for the methods that cross the Route Manager boundary to recreate the current Classic Router behaviour. The following list is a best-effort to find those methods, but it may need to change during implementation. The capability that opts in to these functions is not intended to be implemented by any other future Route Manager. -```ts +```typescript // Classic Router interoperability interface RouteManagerWithClassicInterop = RouteManager & { getRouteName(bucket: RouteStateBucket) => string; @@ -275,7 +275,7 @@ The necessary state will be taken from and stored in the passed `RouteStateBucke With the Classic Router, rendering is handled through `RenderState` objects combined with a (scheduled once) call to `router._setOutlets` which updates the render state tree with the new `RenderState` objects from the current routes. This looks something like: -```ts +```typescript let render: RenderState = { owner, name, @@ -289,7 +289,7 @@ For the Route Manager API we will rework this structure to a generic invokable. The return value of `getInvokable` is an object that needs to have an associated `ComponentManager`. -```ts +```typescript interface RouteManager { getInvokable: (bucket: RouteStateBucket) => object; } From 4d5edf8274230fe6163947b79138add20cb847d3 Mon Sep 17 00:00:00 2001 From: Nick Schot Date: Thu, 26 Feb 2026 16:23:22 +0100 Subject: [PATCH 05/26] Less shouting --- text/0000-route-manager-api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/text/0000-route-manager-api.md b/text/0000-route-manager-api.md index 1df8d4b8b7..c58b2fd3d8 100644 --- a/text/0000-route-manager-api.md +++ b/text/0000-route-manager-api.md @@ -329,7 +329,7 @@ The model hooks are an RSVP Promise chain handled by router_js. We can put them `willExit`: -- `willTransition` event (currently sync), bubbles through RouteInfos as long as true is returned or no handler is present. **NOTE: THESE BUBBLE BOTTOM UP!** +- `willTransition` event (currently sync), bubbles through RouteInfos as long as true is returned or no handler is present. **NOTE:** these bubble. - `routeWillChange` event (currently sync), router service event. `enter`: @@ -347,7 +347,7 @@ The model hooks are an RSVP Promise chain handled by router_js. We can put them - `activate` hook - `setupController` hook -- `didTransition` event **NOTE: THESE BUBBLE BOTTOM UP!** +- `didTransition` event **NOTE:** these bubble. - `routeDidChange` event, router service event --- From f1eea8c257d9d0b9d2e9bd7d3ec7afb72cc47484 Mon Sep 17 00:00:00 2001 From: Nick Schot Date: Thu, 26 Feb 2026 16:28:46 +0100 Subject: [PATCH 06/26] Add PR URL to frontmatter --- text/0000-route-manager-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0000-route-manager-api.md b/text/0000-route-manager-api.md index c58b2fd3d8..3329cfe69b 100644 --- a/text/0000-route-manager-api.md +++ b/text/0000-route-manager-api.md @@ -6,7 +6,7 @@ release-versions: teams: # delete teams that aren't relevant - framework prs: - accepted: # Fill this in with the URL for the Proposal RFC PR + accepted: https://github.com/emberjs/rfcs/pull/1169 project-link: suite: --- From 43b48e74db42bae5f2e38c85c1c55dae742bca50 Mon Sep 17 00:00:00 2001 From: Nick Schot Date: Thu, 26 Feb 2026 16:30:32 +0100 Subject: [PATCH 07/26] Add PR number to filename --- text/{0000-route-manager-api.md => 1169-route-manager-api.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename text/{0000-route-manager-api.md => 1169-route-manager-api.md} (100%) diff --git a/text/0000-route-manager-api.md b/text/1169-route-manager-api.md similarity index 100% rename from text/0000-route-manager-api.md rename to text/1169-route-manager-api.md From b633f2e64e32e006e37280287a1292331aa5e958 Mon Sep 17 00:00:00 2001 From: Florian Pichler Date: Thu, 5 Mar 2026 14:55:54 +0100 Subject: [PATCH 08/26] Clarified phrasing and filled remaining TBDs --- text/1169-route-manager-api.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index 3329cfe69b..52c959c7c3 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -69,7 +69,7 @@ interface CreateRouteArgs { #### `createRoute` -The `createRoute` method on the Route Manager is responsible for taking the Route’s factory and arguments and based on that return a `RouteStateBucket` . +The `createRoute` method on the Route Manager is responsible for taking the Route’s factory and arguments and based on that return a `RouteStateBucket`. This is invoked by a Router. ***Note:** It is up to the manager to decide whether or not this method actually instantiates the factory or if that happens at a later time, depending on the specific lifecycle the manager implementation wants to provide.* @@ -79,7 +79,7 @@ The `RouteStateBucket` is a stable reference provided by the manager’s `create #### `getDestroyable` -The `getDestroyable` method takes a `RouteStateBucket` and will return the `Destroyable` if applicable. This can be used by the manager implementation to wire up the lifetime of the route. +The `getDestroyable` method takes a `RouteStateBucket` and will return the corresponding `Destroyable` if applicable. ### Determining which route manager to use @@ -301,15 +301,15 @@ Since this is not an Ember app developer facing feature the guides don’t need ## Drawbacks -TBD +This introduces a new layer that isn't strictly required, but experiments would be much harder without it. Splitting the existing implementation will not be trivial to separate, but it is worth the effort long term. ## Alternatives -TBD +The manager pattern is used across the Ember codebase with success and this is just the first step for formalizing improvements to the Router, alternatives were not explored. ## Unresolved questions -TBD +None beyond implementation details. ## Addenda @@ -406,4 +406,4 @@ sequenceDiagram R->>AB: didExit() R->>A: didExit() -``` \ No newline at end of file +``` From 4e399eb2c115e1db2ae2bfd3255b4b43ba86dbf0 Mon Sep 17 00:00:00 2001 From: Florian Pichler Date: Tue, 10 Mar 2026 18:44:29 +0100 Subject: [PATCH 09/26] Make update hooks optional --- text/1169-route-manager-api.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index 52c959c7c3..031ed774f3 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -170,13 +170,6 @@ interface RouteManager { // Called after all `enter` hooks for the current Route hierarchy have succesfully resolved. didEnter: (bucket: RouteStateBucket, args: NavigationState) => void; - // Similar to willEnter, but called on a Route that was also part of the previous navigation. - willUpdate: (bucket: RouteStateBucket, args: NavigationState & NavigationActions) => void; - // Called when the dynamic segments (and in the case of the classic router query parameters as well) for the Route have been updated. - update: (bucket: RouteStateBucket, args: NavigationState & NavigationActions & AsyncNavigationState) => Promise; - // Called when all updating Routes have updated. - didUpdate: (bucket: RouteStateBucket, args: NavigationState) => void; - // Called when the Route is about to be exited. willExit: (bucket: RouteStateBucket, args: NavigationState & NavigationActions) => void; // Called when the Route is exited. @@ -186,7 +179,7 @@ interface RouteManager { } ``` -We strongly considered not adding the `update` hooks, but decided against it for ergonomics and simplicity reasons. Not adding it would minimise the Route Manager API surface, but make implementing different behaviour significantly less ergonomic for most Route Manager implementations, whereas implement the `update` hooks with the same behaviour as `exit` + `enter` is simply calling those methods within the manager. +Capabilities could be used to implement optional `willUpdate`, `update`, and `didUpdate` hooks to increase developer ergonomics for dealing with entering a Route that was part of the previous navigation. They were dropped from the base manager to minimise the Route Manager API surface. The lifecycle of an example navigation between two nested routes looks as follows: From 6b68259b246d49e31138ec653dbe0d22cef0b1da Mon Sep 17 00:00:00 2001 From: Florian Pichler Date: Tue, 10 Mar 2026 18:45:03 +0100 Subject: [PATCH 10/26] Clarify class shape --- text/1169-route-manager-api.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index 031ed774f3..062bdae714 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -48,7 +48,7 @@ This RFC is **not** intended to describe APIs that Ember app developers would ge ### Route Manager basics -The minimal API for a Route Manager consists of `capabilities`, `createRoute` and a `getDestroyable` method. +A Route Manager always has `capabilities`, `createRoute` and a `getDestroyable` method. ```typescript interface RouteManager { @@ -59,6 +59,8 @@ interface RouteManager { // Returns the destroyable (if any) for the RouteStateBucket getDestroyable: (bucket: RouteStateBucket) => Destroyable | null; + + // ... see below } interface CreateRouteArgs { From bcd03f7a25dbe2edab1ed06d33d38be5ca928d7f Mon Sep 17 00:00:00 2001 From: Florian Pichler Date: Tue, 10 Mar 2026 18:45:19 +0100 Subject: [PATCH 11/26] Improve (pseudo-)typing --- text/1169-route-manager-api.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index 062bdae714..f4ec552082 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -55,7 +55,7 @@ interface RouteManager { capabilities: Capabilities; // Responsible for the creation of a RouteStateBucket. Returns a RouteStateBucket, defined by the manager implementation. - createRoute: (factory, args: CreateRouteArgs) => RouteStateBucket; + createRoute: (factory: object, args: CreateRouteArgs) => RouteStateBucket; // Returns the destroyable (if any) for the RouteStateBucket getDestroyable: (bucket: RouteStateBucket) => Destroyable | null; @@ -143,7 +143,9 @@ interface AsyncNavigationState { signal: AbortSignal; // A WeakMap of ancestor promises that can be used to await async ancestor behaviour. - ancestorPromises: WeakMap> + private ancestorPromises: WeakMap>; + + async getAncestorPromises(routeInfo: RouteInfo): ReturnType; // Retrieve the resolvedContext of an ancestor route. getResolvedContext: (routeInfo: RouteInfo) => ReturnType | undefined; @@ -285,8 +287,10 @@ For the Route Manager API we will rework this structure to a generic invokable. The return value of `getInvokable` is an object that needs to have an associated `ComponentManager`. ```typescript +import { ComponentLike } from '@glimmer/template'; + interface RouteManager { - getInvokable: (bucket: RouteStateBucket) => object; + getInvokable: (bucket: RouteStateBucket) => ComponentLike; } ``` From 86d710e515c151ca7c79641213afa2e22d81123b Mon Sep 17 00:00:00 2001 From: Chris Manson Date: Thu, 12 Mar 2026 13:41:39 +0000 Subject: [PATCH 12/26] add some clarifications to recent commits --- text/1169-route-manager-api.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index f4ec552082..48dddb1943 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -142,9 +142,7 @@ interface AsyncNavigationState { // Signal for the current navigation signal: AbortSignal; - // A WeakMap of ancestor promises that can be used to await async ancestor behaviour. - private ancestorPromises: WeakMap>; - + // Retrieve the ancestor promises for an ancestor route that can be used to await async ancestor behaviour. async getAncestorPromises(routeInfo: RouteInfo): ReturnType; // Retrieve the resolvedContext of an ancestor route. @@ -183,7 +181,7 @@ interface RouteManager { } ``` -Capabilities could be used to implement optional `willUpdate`, `update`, and `didUpdate` hooks to increase developer ergonomics for dealing with entering a Route that was part of the previous navigation. They were dropped from the base manager to minimise the Route Manager API surface. +Note: The current Route implementation has a different behaviour depending on if you are transitioning between two routes that are different, or if you are transitioning to the route you are currently on and changing any of the params for that route. This is an **internal concern** of the Route manager and will be implemented in the Classic Route Manager. We do not need to provide any `update()` hooks on the Route lifecycle to cater for this. The lifecycle of an example navigation between two nested routes looks as follows: @@ -287,7 +285,7 @@ For the Route Manager API we will rework this structure to a generic invokable. The return value of `getInvokable` is an object that needs to have an associated `ComponentManager`. ```typescript -import { ComponentLike } from '@glimmer/template'; +import type { ComponentLike } from '@glint/template'; interface RouteManager { getInvokable: (bucket: RouteStateBucket) => ComponentLike; @@ -306,6 +304,12 @@ This introduces a new layer that isn't strictly required, but experiments would The manager pattern is used across the Ember codebase with success and this is just the first step for formalizing improvements to the Router, alternatives were not explored. +### Route lifecycle update hooks + +A previous iteration of this RFC provided explicit `willUpdate()`, `update()`, and `didUpdate()` hooks in the Root Manager interface that were distinct to the `enter()` related hooks and would only be called when you are entering the same route you are currently on with a transition. This was added to simplify the implementation of the Classic Route Manager, which is intended to encapsulate the current behaviour of Ember's existing routes. In reality the trade-off between making the implementation easier and having a wider API surface area is most likely not worth it. + +This will require the Classic Route Manager to do some more elaborate internal work to provide the same lifecycle hooks that current Routes expect, this is an intentional decision to improve the interface of the Manager API and will not have a lasting impact on Ember as the Classic Route Manager is intended to be a compatibility-layer for existing applications and will be phased out. + ## Unresolved questions None beyond implementation details. From d985d044bea23090379e544f16f9afecafb5b798 Mon Sep 17 00:00:00 2001 From: Chris Manson Date: Thu, 12 Mar 2026 14:33:48 +0000 Subject: [PATCH 13/26] final tweaks for the feedback --- text/1169-route-manager-api.md | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index 48dddb1943..8d400cbab8 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -69,6 +69,8 @@ interface CreateRouteArgs { } ``` +Note: this does not represent the full interface, it is expanded upon further into the RFC. + #### `createRoute` The `createRoute` method on the Route Manager is responsible for taking the Route’s factory and arguments and based on that return a `RouteStateBucket`. This is invoked by a Router. @@ -132,9 +134,7 @@ The `AsyncNavigationState` interface allows Route Managers to have a certain amo The `signal` is an `AbortSignal` provided by the Router which can be used to react to a cancellation of the current navigation. It can be passed to, for example, a `fetch` call. -`ancestorPromises` allows you to tie in to the asynchronous lifecycle of ancestor Routes. This opens the possibility for a RouteManager implementation for parallel resolution of the asynchronous lifecycle. The Classic Route Manager will rely on this behaviour to implement the current waterfall lifecycle. - -In addition, the Router will need to provide a method to the Route Managers to retrieve the [`resolvedContext`](#resolvedcontext) of a Route based on its `RouteInfo`. +`ancestorPromises` allows you to tie in to the asynchronous lifecycle of ancestor Routes. This opens the possibility for a RouteManager implementation for parallel resolution of the asynchronous lifecycle. The Classic Route Manager will rely on this behaviour to implement the current waterfall lifecycle. The ancestor promise will resolve with the `context` for that route i.e. in the Classic Route Manager that would be the return value for the `model()` hook. ```typescript // Exposes API used to interact with the active navigation, like awaiting ancestor's async behaviour. @@ -143,15 +143,10 @@ interface AsyncNavigationState { signal: AbortSignal; // Retrieve the ancestor promises for an ancestor route that can be used to await async ancestor behaviour. - async getAncestorPromises(routeInfo: RouteInfo): ReturnType; - - // Retrieve the resolvedContext of an ancestor route. - getResolvedContext: (routeInfo: RouteInfo) => ReturnType | undefined; + async getAncestorPromise(routeInfo: RouteInfo): ReturnType; } ``` -Since `RouteManager.resolvedContext` is part of an optional capability for Route Manager implementations, `getResolvedContext` could return `undefined`. - ### Route lifecycle This RFC proposes 3 groups of hooks for lifecycle management of a Route. @@ -167,8 +162,8 @@ The main lifecycle methods are accompanied by synchronous will*/did* methods. Th interface RouteManager { // Lifecycle hook called when the Route is about to be entered. willEnter: (bucket: RouteStateBucket, args: NavigationState & NavigationActions) => void; - // Main asynchronous entry point - enter: (bucket: RouteStateBucket, args: NavigationState & NavigationActions & AsyncNavigationState) => Promise; + // Main asynchronous entry point - return value is the context (a.k.a model) for the current route + enter: (bucket: RouteStateBucket, args: NavigationState & NavigationActions & AsyncNavigationState) => Promise; // Called after all `enter` hooks for the current Route hierarchy have succesfully resolved. didEnter: (bucket: RouteStateBucket, args: NavigationState) => void; @@ -230,17 +225,6 @@ Route Managers are required to have a `capabilities` property. This property mu Any time the Classic Router interfaces with the RouteManager in a way we do not want in the future, we will shield this behind an optional capability. This capability or capabilities will at some point in the future be turned off by default through a deprecation. -#### resolvedContext - -A separate optional capability will be introduced to access the last resolved value of the model hook (equivalent). This leaves open the possibility of asynchronous lifecycle combined with routes accessing ancestor model data through use of the `getResolvedContext` method. A route manager may choose not to implement this capability. - -```typescript -interface RouteManagerWithResolvedContext = RouteManager & { - // Get the current resolved context (a.k.a. model) from the RouteStateBucket - resolvedContext(bucket: RouteStateBucket) => unknown; -} -``` - #### Classic Router interoperability When the `classicInterop` capability is set to `true` the Route Manager will have to provide an implementation for the methods that cross the Route Manager boundary to recreate the current Classic Router behaviour. The following list is a best-effort to find those methods, but it may need to change during implementation. The capability that opts in to these functions is not intended to be implemented by any other future Route Manager. @@ -258,6 +242,9 @@ interface RouteManagerWithClassicInterop = RouteManager & { serializeQueryParam(bucket: RouteStateBucket, value: unknown, urlKey: string, defaultValueType: string); deserializeQueryParam(bucket: RouteStateBucket, value: unknown, urlKey: string, defaultValueType: string); + // this allows for the implementation of Route.serialize() + serializeContext(bucket: RouteStateBucket, routeInfo: RouteInfo, value: unknown) => Record; + // Actions/event handlers queryParamsDidChange(bucket: RouteStateBucket, changed: {}, totalPresent: unknown, removed: {}) => boolean | void; finalizeQueryParamChange(bucket: RouteStateBucket, params: Record, finalParams: {}[], transition: Transition) => boolean | void; From 45d3aa0128382793628d2b68192a681f14917268 Mon Sep 17 00:00:00 2001 From: Chris Manson Date: Fri, 20 Mar 2026 17:47:31 +0000 Subject: [PATCH 14/26] update RFC from more feedback --- text/1169-route-manager-api.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index 8d400cbab8..d36951c45d 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -35,7 +35,7 @@ Define a generic Route Manager concept that can be used to implement new Route b ## Motivation -The intent of this RFC is to implement a generic Route Manager concept so that we’re able to provide room for experimentation and migration to a new router solution. It aims to provide a well-defined interface between the Router and Route concepts. Well-defined in this case means both API and lifecycle. +The intent of this RFC is to implement a generic Route Manager concept so that we’re able to provide room for experimentation and migration to a new router solution. It aims to provide a well-defined interface between the Router and Route concepts. Well-defined in this case means that we are specifying both the API that Route managers can provide and the order that those APIs are called (i.e. the lifecycle). This will unlock the possibility of implementing new Route base classes while also making it easier to replace the current router. @@ -87,7 +87,7 @@ The `getDestroyable` method takes a `RouteStateBucket` and will return the corre ### Determining which route manager to use -This follows the same pattern as existing manager implementations. This method will be used by the framework for the Route Base Classes it provides as well as by non-framework code wanting to provide their own Route Manager implementation. +The technique used to determine the correct route manager to invoke will follow the well-established examples of manager implementations that already exist in the codebase e.g. for the Component, Modifier, or Helper Managers used by Glimmer. This method will be used by the framework for the Route Base Classes it provides as well as by non-framework code wanting to provide their own Route Manager implementation. ```typescript // Takes a Factory function for the Manager with an Owner argument and @@ -134,7 +134,7 @@ The `AsyncNavigationState` interface allows Route Managers to have a certain amo The `signal` is an `AbortSignal` provided by the Router which can be used to react to a cancellation of the current navigation. It can be passed to, for example, a `fetch` call. -`ancestorPromises` allows you to tie in to the asynchronous lifecycle of ancestor Routes. This opens the possibility for a RouteManager implementation for parallel resolution of the asynchronous lifecycle. The Classic Route Manager will rely on this behaviour to implement the current waterfall lifecycle. The ancestor promise will resolve with the `context` for that route i.e. in the Classic Route Manager that would be the return value for the `model()` hook. +`ancestorPromises` allows a child-route to optionally tie in to the asynchronous lifecycle of ancestor Routes. This opens the possibility for a RouteManager implementation for parallel resolution of the asynchronous lifecycle. The Classic Route Manager will rely on this behaviour to implement the current waterfall lifecycle. The ancestor promise will resolve with the `context` for that route i.e. in the Classic Route Manager that would be the return value for the `model()` hook. ```typescript // Exposes API used to interact with the active navigation, like awaiting ancestor's async behaviour. @@ -149,13 +149,12 @@ interface AsyncNavigationState { ### Route lifecycle -This RFC proposes 3 groups of hooks for lifecycle management of a Route. +This RFC proposes 2 groups of hooks for lifecycle management of a Route. - `enter` - called when a route is visited. -- `update` - called when the input for a route has changed, think dynamic segment or query param. - `exit` - called when a route is exited. -The main lifecycle methods are accompanied by synchronous will*/did* methods. This gives the possibility of implementing lifecycle features like cancelling/preventing a route change, cleaning up after a route branch was fully exited. `update` and `enter` are promise-aware and will be awaited. These methods give the option to do asynchronous work that needs to happen before rendering. +The main lifecycle methods are accompanied by synchronous will*/did* methods. This gives the possibility of implementing lifecycle features like cancelling/preventing a route change, cleaning up after a route branch was fully exited. `enter` is promise-aware and will be awaited. This gives the option to do asynchronous work that needs to happen before rendering. ```typescript @@ -176,7 +175,7 @@ interface RouteManager { } ``` -Note: The current Route implementation has a different behaviour depending on if you are transitioning between two routes that are different, or if you are transitioning to the route you are currently on and changing any of the params for that route. This is an **internal concern** of the Route manager and will be implemented in the Classic Route Manager. We do not need to provide any `update()` hooks on the Route lifecycle to cater for this. +Note: The current Route implementation has a different behaviour depending on if you are transitioning between two routes that are different, or if you are transitioning to the route you are currently on and changing any of the params for that route. This is an **internal concern** of the Route manager and will be implemented in the Classic Route Manager, a Route Manager implementation that is designed to encapsulate the current behaviour of Ember's Routes. We do not need to provide any `update()` hooks on the Route lifecycle to cater for this. The lifecycle of an example navigation between two nested routes looks as follows: @@ -293,7 +292,7 @@ The manager pattern is used across the Ember codebase with success and this is j ### Route lifecycle update hooks -A previous iteration of this RFC provided explicit `willUpdate()`, `update()`, and `didUpdate()` hooks in the Root Manager interface that were distinct to the `enter()` related hooks and would only be called when you are entering the same route you are currently on with a transition. This was added to simplify the implementation of the Classic Route Manager, which is intended to encapsulate the current behaviour of Ember's existing routes. In reality the trade-off between making the implementation easier and having a wider API surface area is most likely not worth it. +A previous iteration of this RFC provided explicit `willUpdate()`, `update()`, and `didUpdate()` hooks in the Route Manager interface that were distinct to the `enter()` related hooks and would only be called when you are entering the same route you are currently on with a transition. This was added to simplify the implementation of the Classic Route Manager, which is intended to encapsulate the current behaviour of Ember's existing routes. In reality the trade-off between making the implementation easier and having a wider API surface area is most likely not worth it. This will require the Classic Route Manager to do some more elaborate internal work to provide the same lifecycle hooks that current Routes expect, this is an intentional decision to improve the interface of the Manager API and will not have a lasting impact on Ember as the Classic Route Manager is intended to be a compatibility-layer for existing applications and will be phased out. From 522820c73fd5d3f525b451ccd974b37f711981d6 Mon Sep 17 00:00:00 2001 From: Chris Manson Date: Fri, 20 Mar 2026 17:55:39 +0000 Subject: [PATCH 15/26] a small improvement to increase clarity --- text/1169-route-manager-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index d36951c45d..a08f62606a 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -266,7 +266,7 @@ let render: RenderState = { }; ``` -For the Route Manager API we will rework this structure to a generic invokable. This way the manager implementation can decide how render happens and what arguments are passed. Deferring render while waiting on asynchronous behaviour (like the Classic Route model hooks) will be a Route Manager concern. +For the Route Manager API we will rework this structure so that the manager returns a generic invokable via a specific API. This way the manager implementation can decide how render happens and what arguments are passed. Deferring render while waiting on asynchronous behaviour (like the Classic Route model hooks) will be a Route Manager concern. The return value of `getInvokable` is an object that needs to have an associated `ComponentManager`. From 33370857f783e28ab1a3ed9c03bf7d91736d3c64 Mon Sep 17 00:00:00 2001 From: Chris Manson Date: Tue, 24 Mar 2026 15:36:28 +0000 Subject: [PATCH 16/26] add some alternatives about sync getInvokable() --- text/1169-route-manager-api.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index a08f62606a..3acf934e06 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -268,16 +268,18 @@ let render: RenderState = { For the Route Manager API we will rework this structure so that the manager returns a generic invokable via a specific API. This way the manager implementation can decide how render happens and what arguments are passed. Deferring render while waiting on asynchronous behaviour (like the Classic Route model hooks) will be a Route Manager concern. -The return value of `getInvokable` is an object that needs to have an associated `ComponentManager`. +The return value of `getInvokable` is a Promise that resolves to an object that needs to have an associated `ComponentManager`. ```typescript import type { ComponentLike } from '@glint/template'; interface RouteManager { - getInvokable: (bucket: RouteStateBucket) => ComponentLike; + getInvokable: (bucket: RouteStateBucket) => Promise; } ``` +Note: `getInvokable()` is an async function so that it is able to absorb any potential `await import()` calls to load modules. + ## How we teach this Since this is not an Ember app developer facing feature the guides don’t need adjustment. Documentation will live in the API docs. @@ -296,6 +298,14 @@ A previous iteration of this RFC provided explicit `willUpdate()`, `update()`, a This will require the Classic Route Manager to do some more elaborate internal work to provide the same lifecycle hooks that current Routes expect, this is an intentional decision to improve the interface of the Manager API and will not have a lasting impact on Ember as the Classic Route Manager is intended to be a compatibility-layer for existing applications and will be phased out. +### Sync getInvokable() + +A previous version of this RFC had a sync version of the `getInvokable()` function on the Route Manager API. This was changed to give a slightly better developer experience to allow poeople to absorb asyncronous imports of modules. Note: this is not intended to have any implications on the `enter()` hook and the async data loading is never intended to happen during the `getInvokable()` promise lifecycle. + +### Merging enter() and getInvokable() hooks + +Comments on this RFC proposed that we could unify the `enter()` and the `getInvokable()` functions. We are explictly not merging those two functions because the `enter()` hook returns context (usually from data-loading) which is entirely separate from the concerns of `getInvokable()`. Also it's worth noting that the promise returned by the `getInvokable()` is never expoesed to any route via the Route Manager API, and will be an internal concern of the Router itself. The promise returned from `enter()` is exposed to child routes via the `getAncestorPromise()` function so they can await the result to get the context of parent routes. + ## Unresolved questions None beyond implementation details. From 29d7edf59adbfc83232ce4cf720e7a6175baf6b9 Mon Sep 17 00:00:00 2001 From: Chris Manson Date: Wed, 25 Mar 2026 15:52:58 +0000 Subject: [PATCH 17/26] improve clarity on the hook execution order --- text/1169-route-manager-api.md | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index 3acf934e06..4576f63fda 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -156,6 +156,8 @@ This RFC proposes 2 groups of hooks for lifecycle management of a Route. The main lifecycle methods are accompanied by synchronous will*/did* methods. This gives the possibility of implementing lifecycle features like cancelling/preventing a route change, cleaning up after a route branch was fully exited. `enter` is promise-aware and will be awaited. This gives the option to do asynchronous work that needs to happen before rendering. +Note: `willEnter()` **must** be sync so that, in the case that the URL update is synchronous, the user-feedback of the URL update is immediate and does not feel "laggy" to the end-user. + ```typescript interface RouteManager { @@ -177,7 +179,7 @@ interface RouteManager { Note: The current Route implementation has a different behaviour depending on if you are transitioning between two routes that are different, or if you are transitioning to the route you are currently on and changing any of the params for that route. This is an **internal concern** of the Route manager and will be implemented in the Classic Route Manager, a Route Manager implementation that is designed to encapsulate the current behaviour of Ember's Routes. We do not need to provide any `update()` hooks on the Route lifecycle to cater for this. -The lifecycle of an example navigation between two nested routes looks as follows: +The lifecycle of an example navigation between two routes 'a.b' and 'x.y' looks as follows: ```mermaid sequenceDiagram @@ -193,7 +195,7 @@ sequenceDiagram participant XY as Route "x.y" end - Browser-->>R: navigate from a.b to x.y + Browser->>R: navigate from a.b to x.y R->>AB: willExit() R->>A: willExit() R->>X: willEnter() @@ -202,7 +204,17 @@ sequenceDiagram R-->>Browser: location.href update if eager R-)+X: enter() + R-)+X: getInvokable() + X->>-R: Promise.resolve() + + R-->>Browser: render invokable returned fom Route "x" + R-)+XY: enter() + R-)+XY: getInvokable() + XY->>-R: Promise.resolve() + + R-->>Browser: render invokable returned fom Route "x.y" + X->>-R: Promise.resolve() XY->>-R: Promise.resolve() @@ -218,6 +230,12 @@ sequenceDiagram ``` +Note: this is the full list of lifecycle events in a single transition between 'a.b' and 'x.y' i.e. the time before this sequence diagram the application will be on route 'a.b', the sequence diagram starts with the action `navigate from a.b to x.y` which can be a user action clicking a `` or a `transitionTo()` event, and the time after this sequence diagram the application will be on route 'x.y'. + +This sequence diagram only specifies the order of the hooks that are called as part of the Route Manager API, the dotted lines from the Router to the Browser are there for illustrative purposes only and are not specified as part of this RFC. Individual Route managers might express substates (such as loading states) as part of their own APIs, but they would have to do that within the constraints of the Route Manager API hooks. + +In the above diagram the `enter()` is called before the `getInvokable()` for a given route. This doesn't really need to be done in this order, and logically they can be considered as happening "at the same time" since there is no awaiting between their respective promises. The only order that is important is **between routes** i.e. you cannot render a sub-route's invokable without all ancestor route invokables having been rendered, therefore you should not call `getInvokable()` on a sub-route until the parent's `getInvokable()` has resolved. + ### Capabilities Route Managers are required to have a `capabilities` property. This property must be set to the result of calling the `capabilities` function provided by Ember. @@ -278,7 +296,7 @@ interface RouteManager { } ``` -Note: `getInvokable()` is an async function so that it is able to absorb any potential `await import()` calls to load modules. +Note: `getInvokable()` is an async function so that it is able to absorb any potential `await import()` calls to load modules. This promise is never exposed to any other Route Manager APIs and is entirely managed by the Router. ## How we teach this @@ -300,11 +318,13 @@ This will require the Classic Route Manager to do some more elaborate internal w ### Sync getInvokable() -A previous version of this RFC had a sync version of the `getInvokable()` function on the Route Manager API. This was changed to give a slightly better developer experience to allow poeople to absorb asyncronous imports of modules. Note: this is not intended to have any implications on the `enter()` hook and the async data loading is never intended to happen during the `getInvokable()` promise lifecycle. +A previous version of this RFC had a sync version of the `getInvokable()` function on the Route Manager API. This was changed to give a slightly better developer experience to allow people to absorb asynchronous imports of modules. Note: this is not intended to have any implications on the `enter()` hook and the async data loading is never intended to happen during the `getInvokable()` promise lifecycle. + +We do not strictly need to have an async `getInvokable()` because you could always return a sync invokable that managed the async internally, i.e. using a resource-style pattern. As these APIs are quite low-level it doesn't really matter which way we lean on this decision since the complexity will never leak into Ember App Developer ergonomics. ### Merging enter() and getInvokable() hooks -Comments on this RFC proposed that we could unify the `enter()` and the `getInvokable()` functions. We are explictly not merging those two functions because the `enter()` hook returns context (usually from data-loading) which is entirely separate from the concerns of `getInvokable()`. Also it's worth noting that the promise returned by the `getInvokable()` is never expoesed to any route via the Route Manager API, and will be an internal concern of the Router itself. The promise returned from `enter()` is exposed to child routes via the `getAncestorPromise()` function so they can await the result to get the context of parent routes. +Comments on this RFC proposed that we could unify the `enter()` and the `getInvokable()` functions. We are explicitly not merging those two functions because the `enter()` hook returns context (usually from data-loading) which is entirely separate from the concerns of `getInvokable()`. Also, it's worth noting that the promise returned by the `getInvokable()` is never exposed to any route via the Route Manager API, and will be an internal concern of the Router itself. The promise returned from `enter()` is exposed to child routes via the `getAncestorPromise()` function so they can await the result to get the context of parent routes. ## Unresolved questions From d3c76a29ef36a2bad29f856050823c6c18d5775b Mon Sep 17 00:00:00 2001 From: Chris Manson Date: Tue, 14 Apr 2026 21:27:53 +0100 Subject: [PATCH 18/26] expand the summary --- text/1169-route-manager-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index 4576f63fda..332d4281f3 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -31,7 +31,7 @@ suite: Leave as is ## Summary -Define a generic Route Manager concept that can be used to implement new Route base classes as a stepping stone towards a new router. +This RFC Defines a generic Route Manager concept that can be used to implement new Route base classes as a stepping stone towards a new router. The Route Manager API proposed in this RFC is not intended to be consumed by Ember app developers, but will allow Framework authors and addon developers to create new Route base classes. ## Motivation From c92c5dfe9e769f2301fa91be78354f1d62896f1c Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Mon, 11 May 2026 11:25:47 +0100 Subject: [PATCH 19/26] formatter differences --- text/1169-route-manager-api.md | 203 +++++++++++++++++---------------- 1 file changed, 105 insertions(+), 98 deletions(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index 332d4281f3..b0b12d3b1b 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -8,11 +8,11 @@ teams: # delete teams that aren't relevant prs: accepted: https://github.com/emberjs/rfcs/pull/1169 project-link: -suite: +suite: --- ->Browser: location.href update if eager - + R-)+X: enter() R-)+X: getInvokable() X->>-R: Promise.resolve() @@ -220,9 +227,9 @@ sequenceDiagram R->>AB: exit() R->>A: exit() - + R-->>Browser: location.href update if deferred - + R->>X: didEnter() R->>XY: didEnter() R->>AB: didExit() @@ -238,7 +245,7 @@ In the above diagram the `enter()` is called before the `getInvokable()` for a g ### Capabilities -Route Managers are required to have a `capabilities` property. This property must be set to the result of calling the `capabilities` function provided by Ember. +Route Managers are required to have a `capabilities` property. This property must be set to the result of calling the `capabilities` function provided by Ember. Any time the Classic Router interfaces with the RouteManager in a way we do not want in the future, we will shield this behind an optional capability. This capability or capabilities will at some point in the future be turned off by default through a deprecation. @@ -249,22 +256,22 @@ When the `classicInterop` capability is set to `true` the Route Manager will hav ```typescript // Classic Router interoperability interface RouteManagerWithClassicInterop = RouteManager & { - getRouteName(bucket: RouteStateBucket) => string; - getFullRouteName(bucket: RouteStateBucket) => string; - - // Query Parameter handling - stashNames(bucket: RouteStateBucket, routeInfo: ExtendedInternalRouteInfo, dynamicParent: ExtendedInternalRouteInfo) => void; - qp(bucket: RouteStateBucket): it's complicated - - serializeQueryParam(bucket: RouteStateBucket, value: unknown, urlKey: string, defaultValueType: string); - deserializeQueryParam(bucket: RouteStateBucket, value: unknown, urlKey: string, defaultValueType: string); - - // this allows for the implementation of Route.serialize() - serializeContext(bucket: RouteStateBucket, routeInfo: RouteInfo, value: unknown) => Record; - - // Actions/event handlers - queryParamsDidChange(bucket: RouteStateBucket, changed: {}, totalPresent: unknown, removed: {}) => boolean | void; - finalizeQueryParamChange(bucket: RouteStateBucket, params: Record, finalParams: {}[], transition: Transition) => boolean | void; + getRouteName(bucket: RouteStateBucket) => string; + getFullRouteName(bucket: RouteStateBucket) => string; + + // Query Parameter handling + stashNames(bucket: RouteStateBucket, routeInfo: ExtendedInternalRouteInfo, dynamicParent: ExtendedInternalRouteInfo) => void; + qp(bucket: RouteStateBucket): it's complicated + + serializeQueryParam(bucket: RouteStateBucket, value: unknown, urlKey: string, defaultValueType: string); + deserializeQueryParam(bucket: RouteStateBucket, value: unknown, urlKey: string, defaultValueType: string); + + // this allows for the implementation of Route.serialize() + serializeContext(bucket: RouteStateBucket, routeInfo: RouteInfo, value: unknown) => Record; + + // Actions/event handlers + queryParamsDidChange(bucket: RouteStateBucket, changed: {}, totalPresent: unknown, removed: {}) => boolean | void; + finalizeQueryParamChange(bucket: RouteStateBucket, params: Record, finalParams: {}[], transition: Transition) => boolean | void; } ``` @@ -276,12 +283,12 @@ With the Classic Router, rendering is handled through `RenderState` objects comb ```typescript let render: RenderState = { - owner, - name, - controller: undefined, // aliased as @controller argument - model: undefined, // aliased as @model argument - template, // template factory or component reference - }; + owner, + name, + controller: undefined, // aliased as @controller argument + model: undefined, // aliased as @model argument + template, // template factory or component reference +}; ``` For the Route Manager API we will rework this structure so that the manager returns a generic invokable via a specific API. This way the manager implementation can decide how render happens and what arguments are passed. Deferring render while waiting on asynchronous behaviour (like the Classic Route model hooks) will be a Route Manager concern. @@ -292,11 +299,11 @@ The return value of `getInvokable` is a Promise that resolves to an object that import type { ComponentLike } from '@glint/template'; interface RouteManager { - getInvokable: (bucket: RouteStateBucket) => Promise; + getInvokable: (bucket: RouteStateBucket) => Promise; } ``` -Note: `getInvokable()` is an async function so that it is able to absorb any potential `await import()` calls to load modules. This promise is never exposed to any other Route Manager APIs and is entirely managed by the Router. +Note: `getInvokable()` is an async function so that it is able to absorb any potential `await import()` calls to load modules. This promise is never exposed to any other Route Manager APIs and is entirely managed by the Router. ## How we teach this @@ -308,7 +315,7 @@ This introduces a new layer that isn't strictly required, but experiments would ## Alternatives -The manager pattern is used across the Ember codebase with success and this is just the first step for formalizing improvements to the Router, alternatives were not explored. +The manager pattern is used across the Ember codebase with success and this is just the first step for formalizing improvements to the Router, alternatives were not explored. ### Route lifecycle update hooks @@ -316,7 +323,7 @@ A previous iteration of this RFC provided explicit `willUpdate()`, `update()`, a This will require the Classic Route Manager to do some more elaborate internal work to provide the same lifecycle hooks that current Routes expect, this is an intentional decision to improve the interface of the Manager API and will not have a lasting impact on Ember as the Classic Route Manager is intended to be a compatibility-layer for existing applications and will be phased out. -### Sync getInvokable() +### Sync getInvokable() A previous version of this RFC had a sync version of the `getInvokable()` function on the Route Manager API. This was changed to give a slightly better developer experience to allow people to absorb asynchronous imports of modules. Note: this is not intended to have any implications on the `enter()` hook and the async data loading is never intended to happen during the `getInvokable()` promise lifecycle. @@ -374,17 +381,17 @@ The model hooks are an RSVP Promise chain handled by router_js. We can put them #### Updating the model for an existing route mapped to manager hooks: - `willUpdate` (leaf-most) - - `willTransition` event - - `routeWillChange` event, router service + - `willTransition` event + - `routeWillChange` event, router service - `update` - - `beforeModel` - - `model` - - `afterModel` + - `beforeModel` + - `model` + - `afterModel` - `didUpdate` (leaf-most) - - `resetController` (conditionally, if model return value changed) - - `setupController` (conditionally, if model return value changed) - - `didTransition` (event, leafmost) - - `routeDidChange` event, router service + - `resetController` (conditionally, if model return value changed) + - `setupController` (conditionally, if model return value changed) + - `didTransition` (event, leafmost) + - `routeDidChange` event, router service #### Mapping of existing events and methods to the new API @@ -393,33 +400,33 @@ sequenceDiagram %% When putting this in github try using - participant Browser@{ "type" : "boundary" } Actor Browser participant R as Router - + box rgba(255, 255, 255, 0.2) RouteManagerAPI - participant A as Route "a" - participant AB as Route "a.b" - participant X as Route "x" - participant XY as Route "x.y" + participant A as Route "a" + participant AB as Route "a.b" + participant X as Route "x" + participant XY as Route "x.y" end - + Browser-->>R: navigate from a.b to x.y R->>AB: willExit()
Event:willTransition R->>A: willExit() R->>X: willEnter() R->>XY: willEnter() - + R-)+X: enter() X->>X: beforeModel()
model()
afterModel() X->>-R: Promise.resolve() - + R-)+XY: enter() XY->>XY: beforeModel()
model()
afterModel() XY->>-R: Promise.resolve() - + R->>AB: exit()
resetController()
deactivate() R->>A: exit()
resetController()
deactivate() - + R-->>Browser: location.href update - + R->>X: didEnter()
activate()
setupController() R->>XY: didEnter()
activate()
setupController()
Event:didTransition R->>AB: didExit() From aa1bd45ddb6b044fa9c37f2445c22ae70ee67d2e Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Mon, 11 May 2026 11:43:55 +0100 Subject: [PATCH 20/26] content changes --- text/1169-route-manager-api.md | 48 ++++++++++++++++------------------ 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index b0b12d3b1b..a7a58fa31d 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -80,6 +80,8 @@ The `createRoute` method on the Route Manager is responsible for taking the Rout The `RouteStateBucket` is a stable reference provided by the manager’s `createRoute` method. All interaction through the Route Manager API will require passing this same stable reference as an argument. The shape and contents of `RouteStateBucket` is defined by the specific Route Manager implementation. +The bucket carries stable identity for a route definition. Per-navigation state lives for the lifetime of each individual render of the route, with the current router provided `RouteInfo` serving this purpose. + #### `getDestroyable` The `getDestroyable` method takes a `RouteStateBucket` and will return the corresponding `Destroyable` if applicable. @@ -104,7 +106,7 @@ The NavigationState is an interface for the router to pass information to the ma ```typescript // Passed in to the lifecycle methods interface NavigationState { - from: RouteInfo | undefined; + from?: RouteInfo; to: RouteInfo; } @@ -133,16 +135,16 @@ The `AsyncNavigationState` interface allows Route Managers to have a certain amo The `signal` is an `AbortSignal` provided by the Router which can be used to react to a cancellation of the current navigation. It can be passed to, for example, a `fetch` call. -`ancestorPromises` allows a child-route to optionally tie in to the asynchronous lifecycle of ancestor Routes. This opens the possibility for a RouteManager implementation for parallel resolution of the asynchronous lifecycle. The Classic Route Manager will rely on this behaviour to implement the current waterfall lifecycle. The ancestor promise will resolve with the `context` for that route i.e. in the Classic Route Manager that would be the return value for the `model()` hook. +`getAncestorPromise` allows a child-route to optionally tie in to the asynchronous lifecycle of ancestor Routes. This opens the possibility for a RouteManager implementation for parallel resolution of the asynchronous lifecycle. The Classic Route Manager will rely on this behaviour to implement the current waterfall lifecycle. The ancestor promise will resolve with the `context` for that route i.e. in the Classic Route Manager that would be the return value for the `model()` hook. When called with no argument it returns the immediate parent route's promise. ```typescript // Exposes API used to interact with the active navigation, like awaiting ancestor's async behaviour. -interface AsyncNavigationState { +interface AsyncNavigationState { // Signal for the current navigation signal: AbortSignal; - // Retrieve the ancestor promises for an ancestor route that can be used to await async ancestor behaviour. - async getAncestorPromise(routeInfo: RouteInfo): ReturnType; + // Retrieve the ancestor promise for an ancestor route, used to await async ancestor behaviour. + getAncestorPromise(routeInfo?: RouteInfo): ReturnType; } ``` @@ -241,7 +243,7 @@ Note: this is the full list of lifecycle events in a single transition between ' This sequence diagram only specifies the order of the hooks that are called as part of the Route Manager API, the dotted lines from the Router to the Browser are there for illustrative purposes only and are not specified as part of this RFC. Individual Route managers might express substates (such as loading states) as part of their own APIs, but they would have to do that within the constraints of the Route Manager API hooks. -In the above diagram the `enter()` is called before the `getInvokable()` for a given route. This doesn't really need to be done in this order, and logically they can be considered as happening "at the same time" since there is no awaiting between their respective promises. The only order that is important is **between routes** i.e. you cannot render a sub-route's invokable without all ancestor route invokables having been rendered, therefore you should not call `getInvokable()` on a sub-route until the parent's `getInvokable()` has resolved. +In the above diagram the `enter()` is called before the `getInvokable()` for a given route. The promise returned from `enter()` is exposed to `getInvokable()`, so a manager may either await it (to gate rendering on data) or ignore it (to render immediately and coordinate loading inside its wrapper). ### Capabilities @@ -279,31 +281,23 @@ The necessary state will be taken from and stored in the passed `RouteStateBucke ### Rendering -With the Classic Router, rendering is handled through `RenderState` objects combined with a (scheduled once) call to `router._setOutlets` which updates the render state tree with the new `RenderState` objects from the current routes. This looks something like: - -```typescript -let render: RenderState = { - owner, - name, - controller: undefined, // aliased as @controller argument - model: undefined, // aliased as @model argument - template, // template factory or component reference -}; -``` - -For the Route Manager API we will rework this structure so that the manager returns a generic invokable via a specific API. This way the manager implementation can decide how render happens and what arguments are passed. Deferring render while waiting on asynchronous behaviour (like the Classic Route model hooks) will be a Route Manager concern. - -The return value of `getInvokable` is a Promise that resolves to an object that needs to have an associated `ComponentManager`. +For the Route Manager API rendering is split into two manager-provided invokables: a per-render `invokable` from `getInvokable`, and a module-stable `wrapper` from `getRouteWrapper`. The router renders the wrapper and curries the invokable, alongside per-render context, onto it. This keeps the rendering policy in the manager while letting the framework own the curried argument conventions. ```typescript import type { ComponentLike } from '@glint/template'; interface RouteManager { - getInvokable: (bucket: RouteStateBucket) => Promise; + getRouteWrapper(bucket: RouteStateBucket): ComponentLike; + getInvokable( + bucket: RouteStateBucket, + args: { enterPromise: Promise }, + ): Promise; } ``` -Note: `getInvokable()` is an async function so that it is able to absorb any potential `await import()` calls to load modules. This promise is never exposed to any other Route Manager APIs and is entirely managed by the Router. +`getRouteWrapper` returns a component that calls the route's invokable. The router curries `@Component` (the invokable), the routeInfo, the model, and the controller onto it. The wrapper should be stable across renders so that the rendering layer can use identity to determine when to tear it down. + +`getInvokable` returns the component for the current route. It receives the in-flight `enterPromise` so the manager can choose whether to await data before resolving, or to resolve immediately and defer loading-state handling to the wrapper. The promise is async to allow `await import()` for lazy-loaded route modules, and is never exposed elsewhere on the manager-facing API. ## How we teach this @@ -331,7 +325,11 @@ We do not strictly need to have an async `getInvokable()` because you could alwa ### Merging enter() and getInvokable() hooks -Comments on this RFC proposed that we could unify the `enter()` and the `getInvokable()` functions. We are explicitly not merging those two functions because the `enter()` hook returns context (usually from data-loading) which is entirely separate from the concerns of `getInvokable()`. Also, it's worth noting that the promise returned by the `getInvokable()` is never exposed to any route via the Route Manager API, and will be an internal concern of the Router itself. The promise returned from `enter()` is exposed to child routes via the `getAncestorPromise()` function so they can await the result to get the context of parent routes. +Comments on this RFC proposed that we could unify the `enter()` and the `getInvokable()` functions. We are explicitly not merging those two functions because the `enter()` hook returns context (usually from data-loading) which is entirely separate from the concerns of `getInvokable()`. + +Separate functions also allow for more flexible implementations of the manager lifecycle, for example you could have a manager that always resolves `getInvokable()` immediately and does not gate rendering on the result of `enter()`, or you could have a manager that waits for the result of `enter()` before resolving `getInvokable()`. + +Also, it's worth noting that the promise returned by the `getInvokable()` is never exposed to any route via the Route Manager API, and will be an internal concern of the Router itself. The promise returned from `enter()` is exposed to child routes via the `getAncestorPromise()` function so they can await the result to get the context of parent routes. ## Unresolved questions @@ -341,7 +339,7 @@ None beyond implementation details. ### #1 What Classic Routes look like implemented with the Route Manager API -The existing `@ember/route`  Route base class will be referred to as Classic Route. Below is a description of how the current Classic Route implementation could be supported by the proposed Route Manager API. +The existing `@ember/route` Route base class will be referred to as Classic Route. Below is a description of how the current Classic Route implementation could be supported by the proposed Route Manager API. #### Hooks & events From ddd607790d7db0e5a9e0d2ee6f5ba03d91241a1b Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Mon, 11 May 2026 13:14:51 +0100 Subject: [PATCH 21/26] getAncestorPromise must have routeInfo arg --- text/1169-route-manager-api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index a7a58fa31d..cc13a83469 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -135,7 +135,7 @@ The `AsyncNavigationState` interface allows Route Managers to have a certain amo The `signal` is an `AbortSignal` provided by the Router which can be used to react to a cancellation of the current navigation. It can be passed to, for example, a `fetch` call. -`getAncestorPromise` allows a child-route to optionally tie in to the asynchronous lifecycle of ancestor Routes. This opens the possibility for a RouteManager implementation for parallel resolution of the asynchronous lifecycle. The Classic Route Manager will rely on this behaviour to implement the current waterfall lifecycle. The ancestor promise will resolve with the `context` for that route i.e. in the Classic Route Manager that would be the return value for the `model()` hook. When called with no argument it returns the immediate parent route's promise. +`getAncestorPromise` allows a child-route to optionally tie in to the asynchronous lifecycle of ancestor Routes. This opens the possibility for a RouteManager implementation for parallel resolution of the asynchronous lifecycle. The Classic Route Manager will rely on this behaviour to implement the current waterfall lifecycle. The ancestor promise will resolve with the `context` for that route i.e. in the Classic Route Manager that would be the return value for the `model()` hook. ```typescript // Exposes API used to interact with the active navigation, like awaiting ancestor's async behaviour. @@ -144,7 +144,7 @@ interface AsyncNavigationState { signal: AbortSignal; // Retrieve the ancestor promise for an ancestor route, used to await async ancestor behaviour. - getAncestorPromise(routeInfo?: RouteInfo): ReturnType; + getAncestorPromise(routeInfo: RouteInfo): ReturnType; } ``` From 857f4419c0ccd2d60bff8b2ed951a1d458bb2e59 Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Mon, 11 May 2026 13:36:23 +0100 Subject: [PATCH 22/26] refine Route Manager API rendering details and update type definitions for getRouteWrapper --- text/1169-route-manager-api.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index cc13a83469..fefd5c3ced 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -281,21 +281,21 @@ The necessary state will be taken from and stored in the passed `RouteStateBucke ### Rendering -For the Route Manager API rendering is split into two manager-provided invokables: a per-render `invokable` from `getInvokable`, and a module-stable `wrapper` from `getRouteWrapper`. The router renders the wrapper and curries the invokable, alongside per-render context, onto it. This keeps the rendering policy in the manager while letting the framework own the curried argument conventions. +For the Route Manager API rendering is split into two manager-provided invokables: a per-route `invokable` from `getInvokable`, and a module-stable `wrapper` from `getRouteWrapper`. The router renders the wrapper and curries the invokable, alongside per-render context, onto it. This keeps the rendering policy in the manager while letting the framework own the curried argument conventions. ```typescript import type { ComponentLike } from '@glint/template'; interface RouteManager { - getRouteWrapper(bucket: RouteStateBucket): ComponentLike; + getRouteWrapper(bucket: RouteStateBucket): ComponentLike>; getInvokable( bucket: RouteStateBucket, args: { enterPromise: Promise }, - ): Promise; + ): Promise>; } ``` -`getRouteWrapper` returns a component that calls the route's invokable. The router curries `@Component` (the invokable), the routeInfo, the model, and the controller onto it. The wrapper should be stable across renders so that the rendering layer can use identity to determine when to tear it down. +`getRouteWrapper` returns a component that calls the route's invokable. The router curries `@Component` (the invokable), the `RouteInfo`, the context, and the controller onto it. The wrapper should be stable across renders so that the rendering layer can use identity to determine when to tear it down. `getInvokable` returns the component for the current route. It receives the in-flight `enterPromise` so the manager can choose whether to await data before resolving, or to resolve immediately and defer loading-state handling to the wrapper. The promise is async to allow `await import()` for lazy-loaded route modules, and is never exposed elsewhere on the manager-facing API. From c9c945779d0a90b17dacf29ae519302714a24c3c Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Mon, 11 May 2026 13:51:09 +0100 Subject: [PATCH 23/26] remove args argument from getInvokable --- text/1169-route-manager-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index fefd5c3ced..b7a4be6193 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -290,7 +290,7 @@ interface RouteManager { getRouteWrapper(bucket: RouteStateBucket): ComponentLike>; getInvokable( bucket: RouteStateBucket, - args: { enterPromise: Promise }, + enterPromise: Promise, ): Promise>; } ``` From 5b532fc267fc5b831d138b7fb6bad7508dcb5c67 Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Mon, 11 May 2026 15:20:28 +0100 Subject: [PATCH 24/26] improve the types of the getRouteWrapper/getInvokable --- text/1169-route-manager-api.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index b7a4be6193..a3528dd47a 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -286,12 +286,19 @@ For the Route Manager API rendering is split into two manager-provided invokable ```typescript import type { ComponentLike } from '@glint/template'; -interface RouteManager { - getRouteWrapper(bucket: RouteStateBucket): ComponentLike>; +interface RouteManager> = { + getRouteWrapper(bucket: RouteStateBucket): ComponentLike<{ + Args: { + Component: T; + context: ReturnType; + bucket: RouteStateBucket; + } + }>; + getInvokable( bucket: RouteStateBucket, enterPromise: Promise, - ): Promise>; + ): Promise; } ``` From 55f1c4f53b2c3c2e3ad99e33734895ec025fc44b Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Mon, 11 May 2026 16:07:48 +0100 Subject: [PATCH 25/26] update getRouteWrapper description to clarify context and bucket usage --- text/1169-route-manager-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index a3528dd47a..77c45c7a58 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -302,7 +302,7 @@ interface RouteManager> = { } ``` -`getRouteWrapper` returns a component that calls the route's invokable. The router curries `@Component` (the invokable), the `RouteInfo`, the context, and the controller onto it. The wrapper should be stable across renders so that the rendering layer can use identity to determine when to tear it down. +`getRouteWrapper` returns a component that calls the route's invokable. The router curries `@Component` (the invokable), the context, and the bucket onto it. The wrapper should be stable across renders so that the rendering layer can use identity to determine when to tear it down. `getInvokable` returns the component for the current route. It receives the in-flight `enterPromise` so the manager can choose whether to await data before resolving, or to resolve immediately and defer loading-state handling to the wrapper. The promise is async to allow `await import()` for lazy-loaded route modules, and is never exposed elsewhere on the manager-facing API. From de99e811e8478ea1aee1c335955ee79bebed5ca6 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 11 May 2026 16:34:44 +0100 Subject: [PATCH 26/26] remove typo-ed getRouteWrapper argument Co-authored-by: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> --- text/1169-route-manager-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/1169-route-manager-api.md b/text/1169-route-manager-api.md index 77c45c7a58..233275b278 100644 --- a/text/1169-route-manager-api.md +++ b/text/1169-route-manager-api.md @@ -287,7 +287,7 @@ For the Route Manager API rendering is split into two manager-provided invokable import type { ComponentLike } from '@glint/template'; interface RouteManager> = { - getRouteWrapper(bucket: RouteStateBucket): ComponentLike<{ + getRouteWrapper(): ComponentLike<{ Args: { Component: T; context: ReturnType;