Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
14ee387
initial stab at the route manager and encapsulating classic route beh…
evoactivity Apr 2, 2026
2e36033
tests now pass but we still have work to do to use getInvokable inste…
evoactivity Apr 2, 2026
bf4a131
move classic route hooks (beforeModel, model, afterModel) into class-…
evoactivity Apr 7, 2026
de7b5c6
use RSVP for class-route-manager promises
evoactivity Apr 7, 2026
1274850
Remove classic-hook fallback paths now that all routes use a RouteMan…
evoactivity Apr 8, 2026
5fef2db
Add null check for _router in teardownViews method
evoactivity Apr 8, 2026
6b3b472
clean up some types and shapes of objects
evoactivity Apr 9, 2026
8689563
clean up fallback path for `routeEnteredOrUpdated`
evoactivity Apr 13, 2026
00b42fd
Handle setting up controller and model in the classic route manager.
evoactivity Apr 14, 2026
188c4fe
don't use resolver to instantiate route classes
evoactivity Apr 20, 2026
55e3f59
rename ownerManagerInstances to ownerRouteManagerInstances
evoactivity Apr 20, 2026
83e20f9
assert container has an owner when looking up routes and call manager…
evoactivity Apr 21, 2026
6fbdc2c
cleaner route bucket types
evoactivity Apr 22, 2026
08cc3cb
update engine route serialization check to use Object.getPrototypeOf
evoactivity Apr 22, 2026
3934d4d
tidying up a bit
evoactivity Apr 22, 2026
7b3101b
working on moving sub state, ancestor promises etc into route manager
evoactivity Apr 29, 2026
306c22e
simplify how resolve returns the fast path
evoactivity Apr 30, 2026
4159248
re-enable router_js tests
evoactivity Apr 30, 2026
496807d
- moves enterPromise from bucket to route info
evoactivity May 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,4 @@ npm-debug.log
# couple of the files. Once it is, we can switch this over to just ignoring
# `types/stable` entirely.
types/stable
_notes
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@
"@ember/-internals/metal/index.js": "ember-source/@ember/-internals/metal/index.js",
"@ember/-internals/owner/index.js": "ember-source/@ember/-internals/owner/index.js",
"@ember/-internals/routing/index.js": "ember-source/@ember/-internals/routing/index.js",
"@ember/-internals/routing/route-managers/classic-route-manager.js": "ember-source/@ember/-internals/routing/route-managers/classic-route-manager.js",
"@ember/-internals/routing/route-managers/route-manager.js": "ember-source/@ember/-internals/routing/route-managers/route-manager.js",
"@ember/-internals/routing/route-managers/utils.js": "ember-source/@ember/-internals/routing/route-managers/utils.js",
"@ember/-internals/runtime/index.js": "ember-source/@ember/-internals/runtime/index.js",
"@ember/-internals/runtime/lib/ext/rsvp.js": "ember-source/@ember/-internals/runtime/lib/ext/rsvp.js",
"@ember/-internals/runtime/lib/mixins/-proxy.js": "ember-source/@ember/-internals/runtime/lib/mixins/-proxy.js",
Expand Down
22 changes: 21 additions & 1 deletion packages/@ember/-internals/container/lib/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import type {
FactoryManager,
FullName,
} from '@ember/-internals/owner';
import { setOwner } from '@ember/-internals/owner';
import { getOwner, setOwner } from '@ember/-internals/owner';
import { dictionary } from '@ember/-internals/utils';
import { assert } from '@ember/debug';
import { DEBUG } from '@glimmer/env';
import type { DebugRegistry } from './registry';
import type Registry from './registry';
import type EmberRouter from '@ember/routing/router';

interface LeakTracking {
hasContainers(): boolean;
Expand Down Expand Up @@ -282,6 +283,25 @@ function lookup(
): InternalFactory<object> | object | undefined {
let normalizedName = fullName;

if (normalizedName.startsWith('route:')) {
assert('Expected container to have an owner when looking up a route', container.owner);

let router = container.owner.lookup('router:main') as EmberRouter;
let routeName = normalizedName.split('route:')[1];
if (routeName !== undefined) {
// For the main app container, pass the name directly — EmberRouter.getRoute
// resolves engine vs app routes internally via _engineInfoByRoute.
// For engine containers (owner !== router's owner), pass the engine owner
// explicitly so EmberRouter.getRoute uses it directly without a name lookup.
const isEngineContainer = getOwner(router) !== container.owner;
const engineOwner = isEngineContainer ? container.owner : undefined;
return router.getRoute(routeName, engineOwner) as
| InternalFactory<object>
| object
| undefined;
}
}

if (
options.singleton === true ||
(options.singleton === undefined && isSingleton(container, fullName))
Expand Down
1 change: 1 addition & 0 deletions packages/@ember/-internals/glimmer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,3 +498,4 @@ export {
} from './lib/utils/managers';
export { isSerializationFirstNode } from './lib/utils/serialization-first-node-helpers';
export { uniqueId } from './lib/helpers/unique-id';
export { makeRouteTemplate } from './lib/component-managers/route-template';
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import type { DynamicScope } from '../renderer';
import type { OutletState } from '../utils/outlet';
import type OutletView from '../views/outlet';
import { RouteInfo } from 'router_js';

Check failure on line 27 in packages/@ember/-internals/glimmer/lib/component-managers/outlet.ts

View workflow job for this annotation

GitHub Actions / tests / Linting

All imports in the declaration are only used as types. Use `import type`

function instrumentationPayload(def: OutletDefinitionState) {
// "main" used to be the outlet name, keeping it around for compatibility
Expand All @@ -41,8 +42,16 @@
export interface OutletDefinitionState {
ref: Reference<OutletState | undefined>;
name: string;
// The thing the outlet renders. For wrapper-driven routes this is the
// invokable returned from `manager.getInvokable` (used as `@Component` on
// the wrapper). For the legacy OutletState path (raw template from
// `setOutletState`) it is the makeRouteTemplate-wrapped component.
template: object;
controller: unknown;
// Set on the wrapper-driven path. The outlet helper curries `@Component`
// (the invokable) and `@routeInfo` onto `wrapper` at render time.
wrapper?: object;
invokable?: object;
routeInfo?: RouteInfo;
}

const CAPABILITIES: InternalComponentCapabilities = {
Expand Down Expand Up @@ -172,10 +181,7 @@

const OUTLET_MANAGER = new OutletComponentManager();

const OUTLET_COMPONENT_TEMPLATE = precompileTemplate(
'<@Component @controller={{@controller}} @model={{@model}} />',
{ strictMode: true }
);
const OUTLET_COMPONENT_TEMPLATE = precompileTemplate('<@Component />', { strictMode: true });

export class OutletComponent implements ComponentDefinition<
OutletDefinitionState,
Expand Down
14 changes: 1 addition & 13 deletions packages/@ember/-internals/glimmer/lib/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,7 +861,7 @@ export class Renderer extends BaseRenderer {
// we can refactor this to do something more direct/less convoluted
// and with less setup, but get it working first
let outlet = createRootOutlet(view);
let { name, /* controller, */ template } = view.state;
let { name, template } = view.state;

let named = dict<Reference>();

Expand All @@ -870,18 +870,6 @@ export class Renderer extends BaseRenderer {
'@Component'
);

// TODO: is this guaranteed to be undefined? It seems to be the
// case in the `OutletView` class. Investigate how much that class
// exists as an internal implementation detail only, or if it was
// used outside of core. As far as I can tell, test-helpers uses
// it but only for `setOutletState`.
// named['controller'] = createConstRef(controller, '@controller');
// Update: at least according to the debug render tree tests, we
// appear to always expect this to be undefined. Not a definitive
// source by any means, but is useful evidence
named['controller'] = UNDEFINED_REFERENCE;
named['model'] = UNDEFINED_REFERENCE;

let args = createCapturedArgs(named, EMPTY_POSITIONAL);

this._appendDefinition(
Expand Down
172 changes: 99 additions & 73 deletions packages/@ember/-internals/glimmer/lib/syntax/outlet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
childRefFromParts,
createComputeRef,
createConstRef,
createDebugAliasRef,
valueForRef,
} from '@glimmer/reference';
import type { CurriedValue } from '@glimmer/runtime';
Expand Down Expand Up @@ -88,85 +87,94 @@ export const outletHelper = internalHelper(

let named = dict<Reference>();

// Here we either have a raw template that needs to be normalized,
// or a component that we can render as-is. `RouteTemplate` upgrades
// the template into a component so we can have a unified code path.
// We still store the original `template` value, because we rely on
// its identity for the stability check, and the `RouteTemplate`
// wrapper doesn't dedup for us.
let template = state.template;
let component: object;

if (hasInternalComponentManager(template)) {
component = template;
// Wrapper-driven path: manager-provided wrapper + uncurried invokable.
// Curry the wrapper with @Component (the user's invokable), @routeInfo,
// @model, and @controller. The wrapper template uses these to render
// the route.
if (state.wrapper !== undefined && state.invokable !== undefined) {
let wrapperArgs = dict<Reference>();
wrapperArgs['Component'] = createConstRef(state.invokable, '@Component');
wrapperArgs['routeInfo'] = createConstRef(state.routeInfo as object, '@routeInfo');

// @controller must be a const ref because RouteTemplateManager
// uses it as the route template's `self`, which is then passed
// as `caller` to inner internal components (LinkTo, Input, etc.)
// and those assert isConstRef(caller). Resolve the controller
// eagerly here from the route on the routeInfo. The route has an
// idempotent _initController that creates or returns the cached
// controller instance.
let routeForController = (state.routeInfo as { route?: any } | undefined)?.route;
let controller = routeForController?._initController?.();
wrapperArgs['controller'] = createConstRef(controller, '@controller');

// @model is a compute ref over outletRef.render.routeInfo.context.
// The path-based ref consumes outletStateTag; when setOutletState
// dirties the tag (each transition / model update), the ref
// invalidates and re-reads the new context. Mirrors main's
// childRefFromParts(outletRef, ['render', 'model']) pattern with
// an extra hop for routeInfo.
let modelRef = childRefFromParts(outletRef, ['render', 'routeInfo', 'context']);
let model = valueForRef(modelRef);
let frozenState = state;
wrapperArgs['model'] = createComputeRef(() => {
if (lastState === frozenState) {
model = valueForRef(modelRef);
}
return model;
});

// isResolved=false because the wrapper is a "definition state" that
// the VM must resolve to a ComponentDefinition via its registered
// setComponentTemplate / setInternalComponentManager metadata.
component = curry(
0 as CurriedComponent,
state.wrapper,
outletOwner,
createCapturedArgs(wrapperArgs, EMPTY_POSITIONAL),
false
);
} else {
if (DEBUG) {
// We don't appear to have a standard way or a brand to check, but for the
// purpose of avoiding obvious user errors, this probably gets you close
// enough.
let isTemplate = (template: unknown): template is Template => {
if (template === null || typeof template !== 'object') {
return false;
} else {
let t = template as Partial<Template>;
return t.result === 'ok' || t.result === 'error';
}
};

// We made it past the `TemplateFactory` instantiation before
// getting here, so either we got unlucky where the invalid type
// happens to be a function that didn't mind taking owner as an
// argument, or this was directly set by something like test
// helpers.
if (!isTemplate(template)) {
let label: string;

try {
label = `\`${String(template)}\``;
} catch {
label = 'an unknown object';
// Legacy path: raw template or already-resolved component (e.g.
// from test helpers calling setOutletState directly).
let template = state.template;

if (hasInternalComponentManager(template)) {
component = template;
} else {
if (DEBUG) {
let isTemplate = (template: unknown): template is Template => {
if (template === null || typeof template !== 'object') {
return false;
} else {
let t = template as Partial<Template>;
return t.result === 'ok' || t.result === 'error';
}
};

if (!isTemplate(template)) {
let label: string;

try {
label = `\`${String(template)}\``;
} catch {
label = 'an unknown object';
}

assert(
`Failed to render the \`${state.name}\` route: expected ` +
`a component or Template object, but got ${label}.`
);
}

assert(
`Failed to render the \`${state.name}\` route: expected ` +
`a component or Template object, but got ${label}.`
);
}
}

component = makeRouteTemplate(outletOwner, state.name, template as Template);
component = makeRouteTemplate(outletOwner, state.name, template as Template);
}
}

// Component is stable for the lifetime of the outlet
named['Component'] = createConstRef(component, '@Component');

// Controller is stable for the lifetime of the outlet
named['controller'] = createConstRef(state.controller, '@controller');

// Create a ref for the model
let modelRef = childRefFromParts(outletRef, ['render', 'model']);

// Store the value of the model
let model = valueForRef(modelRef);

// Create a compute ref which we pass in as the `{{@model}}` reference
// for the outlet. This ref will update and return the value of the
// model _until_ the outlet itself changes. Once the outlet changes,
// dynamic scope also changes, and so the original model ref would not
// provide the correct updated value. So we stop updating and return
// the _last_ model value for that outlet.
named['model'] = createComputeRef(() => {
if (lastState === state) {
model = valueForRef(modelRef);
}

return model;
});

if (DEBUG) {
named['model'] = createDebugAliasRef!('@model', named['model']);
}

let args = createCapturedArgs(named, EMPTY_POSITIONAL);

// Package up everything
Expand Down Expand Up @@ -194,6 +202,20 @@ function stateFor(
if (outlet === undefined) return null;
let render = outlet.render;
if (render === undefined) return null;

// Wrapper-driven path: prefer wrapper + invokable + routeInfo when present.
if (render.wrapper !== undefined && render.invokable !== undefined) {
return {
ref,
name: render.name,
template: render.invokable,
wrapper: render.wrapper,
invokable: render.invokable,
routeInfo: render.routeInfo,
};
}

// Legacy path: raw template, e.g. from setOutletState in tests.
let template = render.template;
// The type doesn't actually allow for `null`, but if we make it past this
// point it is really important that we have _something_ to render. We could
Expand All @@ -204,7 +226,6 @@ function stateFor(
ref,
name: render.name,
template,
controller: render.controller,
};
}

Expand All @@ -215,6 +236,11 @@ function isStable(
if (state === null || lastState === null) {
return false;
}

return state.template === lastState.template && state.controller === lastState.controller;
// Stability hinges on the wrapper identity when wrapper-driven (same
// wrapper → outlet stays mounted, wrapper internally re-renders), or on
// the template identity for the legacy path.
if (state.wrapper !== undefined || lastState.wrapper !== undefined) {
return state.wrapper === lastState.wrapper;
}
return state.template === lastState.template;
}
31 changes: 24 additions & 7 deletions packages/@ember/-internals/glimmer/lib/utils/outlet.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { InternalOwner } from '@ember/-internals/owner';
import type { Template } from '@glimmer/interfaces';
import { RouteInfo } from 'router_js';

Check failure on line 3 in packages/@ember/-internals/glimmer/lib/utils/outlet.ts

View workflow job for this annotation

GitHub Actions / tests / Linting

All imports in the declaration are only used as types. Use `import type`

export interface RenderState {
/**
Expand All @@ -15,20 +16,36 @@
name: string;

/**
* The controller (the self of the outlet component)
* The wrapper component returned from `manager.getRouteWrapper`.
* The outlet curries `@Component` (the invokable below) and `@routeInfo`
* onto this wrapper at render time.
*
* Manager-driven routes set this. Code that builds OutletState manually
* (e.g. older versions of @ember/test-helpers, liquid-fire) leaves it
* undefined and relies on the `template` field below.
*/
wrapper?: object;

/**
* The per-render invokable returned from `manager.getInvokable`. The user's
* actual route template/component (uncurried). The outlet curries this onto
* the wrapper as `@Component`.
*/
controller: unknown;
invokable?: object;

/**
* The model (the resolved value of the model hook)
* The router's per-render handle for this route. The wrapper template can
* read whatever it needs (model, route, etc.) from this.
*/
model: unknown;
routeInfo?: RouteInfo;

/**
* The route's template – this is either a Template or a component, and it
* gets normalized during the render process.
* Raw template or already-resolved component for the legacy OutletState path.
* Only used when `wrapper`/`invokable` are not set, i.e. when external code
* (older @ember/test-helpers, liquid-fire-style addons) constructs
* OutletState manually rather than going through a route manager.
*/
template: Template | object | undefined;
template?: Template | object;
}

export interface OutletState {
Expand Down
Loading
Loading