diff --git a/app/route-managers/pioneer-manager.ts b/app/route-managers/pioneer-manager.ts index d15b29e..46dcbf5 100644 --- a/app/route-managers/pioneer-manager.ts +++ b/app/route-managers/pioneer-manager.ts @@ -1,7 +1,5 @@ import { makeRouteTemplate } from '@ember/-internals/glimmer'; import type { InternalOwner } from '@ember/-internals/owner'; -import templateOnly from '@ember/component/template-only'; -import { assert } from '@ember/debug'; import type Owner from '@ember/owner'; import type { CreateRouteArgs, @@ -9,57 +7,52 @@ import type { RouteStateBucket, } from '@ember/routing'; import { routeCapabilities } from '@ember/routing'; -import { precompileTemplate } from '@ember/template-compilation'; -import { getComponentTemplate, setComponentTemplate } from '@glimmer/manager'; +import { getComponentTemplate } from '@glimmer/manager'; import { getOwner } from '@glimmer/owner'; -import { createComputeRef } from '@glimmer/reference'; import { tracked } from '@glimmer/tracking'; +import type { ComponentLike } from '@glint/template'; +import { PioneerOutlet } from 'use-route-manager/route-managers/pioneer-outlet'; import type BaseRoute from 'use-route-manager/routes/BaseRoute'; const routes = import.meta.glob('../routes/**/*.gts'); -// Wrapper template-only component that switches between the route's loading -// state and its main template based on @isLoading. The route's resolved invokable -// is passed in as @Component and the optional loading template as -// @bucket.LoadingState. @model is forwarded to the route component once loading is done. -const RouteShell = templateOnly(); -setComponentTemplate( - precompileTemplate( - ` - {{#if @bucket.LoadingState}} - {{#if @bucket.isLoading}} - <@bucket.LoadingState /> - {{else}} - <@Component @model={{@context}} @outlet={{@outlet}} /> - {{/if}} - {{else}} - <@Component @model={{@context}} @outlet={{@outlet}} /> - {{/if}}`, - { strictMode: true } - ), - RouteShell -); +/** What a route renders: its own template, or its `LoadingState`. */ +export type RouteComponent = ComponentLike<{ + Args: { context: unknown; outlet: unknown }; +}>; export class RouteBucket implements RouteStateBucket { route: BaseRoute; + RouteClass: typeof BaseRoute; args: CreateRouteArgs; - invokable: object | undefined = undefined; + @tracked invokable: RouteComponent | undefined = undefined; - LoadingState: object | undefined = undefined; - - @tracked context: unknown = undefined; + @tracked LoadingState: RouteComponent | undefined = undefined; @tracked isLoading = true; - constructor(route: BaseRoute, args: CreateRouteArgs) { + get renderable(): RouteComponent | undefined { + if (this.LoadingState !== undefined && this.isLoading) { + return this.LoadingState; + } + + return this.invokable; + } + + constructor( + route: BaseRoute, + RouteClass: typeof BaseRoute, + args: CreateRouteArgs + ) { this.route = route; + this.RouteClass = RouteClass; this.args = args; } } export class PioneerRouteManager { - capabilities = routeCapabilities('1.0'); + capabilities = routeCapabilities('1.0', { awaitEnter: false }); #owner: Owner; @@ -74,9 +67,10 @@ export class PioneerRouteManager { // Instantiate the plain class route using `new`, passing the owner. // Key difference from ClassicRouteManager — no EmberObject.create(). const route = new RouteClass(this.#owner); - const bucket = new RouteBucket(route, args); + const bucket = new RouteBucket(route, RouteClass, args); route.bucket = bucket; route.manager = this; + void this.#loadLoadingState(bucket); return bucket; } @@ -89,26 +83,10 @@ export class PioneerRouteManager { } getRouteWrapper(): object { - return RouteShell; - } - - getRenderState(bucket: RouteBucket) { - return { - owner: this.#owner, - name: bucket.args.name, - controller: undefined, - model: bucket.context, - wrapper: this.getRouteWrapper(), - invokable: bucket.invokable, - bucket, - // @TODO: This will likely be gone. For now it's used here in classic as the "@model stability" provider - produceContext: () => createComputeRef(() => bucket.context), - }; + return PioneerOutlet; } willEnter(bucket: RouteBucket): void { - // Mark loading at the start of every enter so re-entries (same route, new - // params) flip the wrapper back to the loading state. bucket.isLoading = true; console.log(`PioneerRouteManager: will enter route "${bucket.args.name}"`); } @@ -127,9 +105,9 @@ export class PioneerRouteManager { : Promise.resolve(undefined); console.log('ancestor promise', parentContext); - const context = await bucket.route.model(parentContext); - bucket.context = context; - return context; + // The framework puts what this resolves with on the route info and + // hands it to the wrapper as `@context`. + return await bucket.route.model(parentContext); } finally { bucket.isLoading = false; } @@ -151,34 +129,16 @@ export class PioneerRouteManager { console.log(`PioneerRouteManager: did exit route "${_bucket.args.name}"`); } - async getInvokable(bucket: RouteBucket): Promise { + getInvokable(bucket: RouteBucket): object { console.log( `PioneerRouteManager: getInvokable for route "${bucket.args.name}"` ); - if (bucket.invokable !== undefined) { - return bucket.invokable; - } const owner = getOwner(bucket.route)! as InternalOwner; - // Pull the named LoadingState export off the route module if it has one. - // Routes that omit it will render the route template immediately. - const routePath = `../routes/${bucket.args.name.replace(/\./g, '/')}.gts`; - const routeModule = (await routes[routePath]?.()) as - | { LoadingState?: object; default: object } - | undefined; - const LoadingState = routeModule?.LoadingState; - const RouteClass = routeModule?.default; - - assert( - `PioneerRouteManager: failed to load route class for "${bucket.args.name}". ` + - `Make sure the route file is named correctly and exports a route class as default.`, - RouteClass - ); - // Retrieve the template factory from the co-located .gts class and wrap it // in a RouteTemplate so it can be rendered as a component. - const templateFactory = getComponentTemplate(RouteClass); + const templateFactory = getComponentTemplate(bucket.RouteClass); if (!templateFactory) { throw new Error( `PioneerRouteManager: no template found for route "${bucket.args.name}". ` + @@ -187,10 +147,31 @@ export class PioneerRouteManager { } const template = templateFactory(owner); - const RouteComponent = makeRouteTemplate(owner, bucket.args.name, template); + return makeRouteTemplate( + owner, + bucket.args.name, + template + ) as unknown as RouteComponent; + } + + // Pull the named LoadingState export off the route module if it has one. + // Routes that omit it will render the route template immediately. + async #loadLoadingState(bucket: RouteBucket): Promise { + const routePath = `../routes/${bucket.args.name.replace(/\./g, '/')}.gts`; + const loader = routes[routePath]; - bucket.LoadingState = LoadingState; - bucket.invokable = RouteComponent; - return RouteComponent; + if (!loader) { + return; + } + + const routeModule = (await loader()) as + | { LoadingState?: RouteComponent } + | undefined; + + bucket.LoadingState = routeModule?.LoadingState; + console.log( + `PioneerRouteManager: loading state for route "${bucket.args.name}" is ` + + `${bucket.LoadingState ? 'available' : 'not defined'}` + ); } } diff --git a/app/route-managers/pioneer-outlet.gts b/app/route-managers/pioneer-outlet.gts new file mode 100644 index 0000000..bc860e1 --- /dev/null +++ b/app/route-managers/pioneer-outlet.gts @@ -0,0 +1,29 @@ +import type { TOC } from '@ember/component/template-only'; +import type { RouteBucket } from 'use-route-manager/route-managers/pioneer-manager'; + +interface PioneerOutletSignature { + Args: { + bucket: RouteBucket; + context: unknown; + outlet: unknown; + }; +} + +/** + * The wrapper this manager renders every one of its routes through. + * + * Module-stable per RFC-1169: the framework curries this level's state onto it + * as `@Component` (the invokable), `@context`, `@bucket` and `@outlet`, so an + * ordinary component is enough — no component manager, no references, nothing + * to curry. The context is handed down; it is never read off `@bucket`. + * + * It deliberately renders `@bucket.renderable` rather than `@Component`: + * `renderable` returns the `LoadingState` while the route module is still in + * flight, whereas `@Component` is always the final invokable. Collapsing them + * would discard this manager's loading policy. + */ +export const PioneerOutlet: TOC = ; diff --git a/app/routes/BaseRoute.ts b/app/routes/BaseRoute.ts index e54b53c..430aa50 100644 --- a/app/routes/BaseRoute.ts +++ b/app/routes/BaseRoute.ts @@ -9,7 +9,6 @@ import { export default class BaseRoute { _router!: EmberRouter; - _stashNames() {} // used by ember/router for QP's but not relevant to this demo manager!: PioneerRouteManager; bucket!: RouteBucket; diff --git a/app/routes/application.gts b/app/routes/application.gts index 2bef911..2532e35 100644 --- a/app/routes/application.gts +++ b/app/routes/application.gts @@ -16,7 +16,7 @@ export default class ApplicationRoute extends BaseRoute { }