Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
135 changes: 58 additions & 77 deletions app/route-managers/pioneer-manager.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,58 @@
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,
EnterState,
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;

Expand All @@ -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;
}

Expand All @@ -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}"`);
}
Expand All @@ -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;
}
Expand All @@ -151,34 +129,16 @@ export class PioneerRouteManager {
console.log(`PioneerRouteManager: did exit route "${_bucket.args.name}"`);
}

async getInvokable(bucket: RouteBucket): Promise<object | undefined> {
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}". ` +
Expand All @@ -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<void> {
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'}`
);
}
}
29 changes: 29 additions & 0 deletions app/route-managers/pioneer-outlet.gts
Original file line number Diff line number Diff line change
@@ -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<PioneerOutletSignature> = <template>
{{#if @bucket.renderable}}
<@bucket.renderable @context={{@context}} @outlet={{@outlet}} />
{{/if}}
</template>;
1 change: 0 additions & 1 deletion app/routes/BaseRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions app/routes/application.gts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default class ApplicationRoute extends BaseRoute {
}

<template>
<div class="pioneer">
<div class="pioneer" data-test-route-level="application">
<LinkTo @route="index">Home</LinkTo>
|
<LinkTo @route="classic">Classic Route</LinkTo>
Expand All @@ -37,7 +37,7 @@ export default class ApplicationRoute extends BaseRoute {
<p>This route is rendered using a route manager entirely defined inside
this app.</p>

<p>Model: {{if @model.message @model.message "Loading"}}</p>
<p>Model: {{if @context.message @context.message "Loading"}}</p>

{{outlet}}
</div>
Expand Down
4 changes: 2 additions & 2 deletions app/routes/classic/sub.gts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import BaseRoute from 'use-route-manager/routes/BaseRoute';

export default class ClassicSubRoute extends BaseRoute {
<template>
<div class="pioneer">
<div class="pioneer" data-test-route-level="classic.sub">
<h3>Hi from pioneer sub route</h3>

<p>No model: {{if @model "Unexpected" "Undefined"}}</p>
<p>No model: {{if @context "Unexpected" "Undefined"}}</p>
<p>No generated controller:
{{if @controller "Unexpected" "Undefined"}}</p>
{{outlet}}
Expand Down
4 changes: 2 additions & 2 deletions app/routes/pokemon.gts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ export default class ApplicationRoute extends BaseRoute {
}

<template>
<div class="pioneer">
<div class="pioneer" data-test-route-level="pokemon">

<h1>Pokemon be loaded</h1>

<p>
{{JSON.stringify @model.pokemon null 2}}
{{JSON.stringify @context.pokemon null 2}}
</p>

{{outlet}}
Expand Down
8 changes: 4 additions & 4 deletions app/routes/pokemon/pikachu.gts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@ export default class ApplicationRoute extends BaseRoute {
}

<template>
<div class="pioneer">
<div class="pioneer" data-test-route-level="pokemon.pikachu">

<h1>{{@model.pokemon.name}}</h1>
<h1>{{@context.pokemon.name}}</h1>

<img
src={{@model.pokemon.sprites.front_default}}
alt={{@model.pokemon.name}}
src={{@context.pokemon.sprites.front_default}}
alt={{@context.pokemon.name}}
/>

{{outlet}}
Expand Down
8 changes: 4 additions & 4 deletions app/routes/pokemon/pikachu/bulbasaur.gts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@ export default class ApplicationRoute extends BaseRoute {
}

<template>
<div class="pioneer">
<div class="pioneer" data-test-route-level="pokemon.pikachu.bulbasaur">

<h1>{{@model.pokemon.name}}</h1>
<h1>{{@context.pokemon.name}}</h1>

<img
src={{@model.pokemon.sprites.front_default}}
alt={{@model.pokemon.name}}
src={{@context.pokemon.sprites.front_default}}
alt={{@context.pokemon.name}}
/>

{{outlet}}
Expand Down
15 changes: 9 additions & 6 deletions app/routes/pokemon/pikachu/bulbasaur/charmander.gts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,20 @@ export default class ApplicationRoute extends BaseRoute {
}

<template>
<div class="pioneer">
<div
class="pioneer"
data-test-route-level="pokemon.pikachu.bulbasaur.charmander"
>

<h1>{{@model.pokemon.name}}</h1>
<h1>{{@context.pokemon.name}}</h1>

{{#if @model.parent}}
<h2>My parent is {{@model.parent.pokemon.name}}</h2>
{{#if @context.parent}}
<h2>My parent is {{@context.parent.pokemon.name}}</h2>
{{/if}}

<img
src={{@model.pokemon.sprites.front_default}}
alt={{@model.pokemon.name}}
src={{@context.pokemon.sprites.front_default}}
alt={{@context.pokemon.name}}
/>

{{outlet}}
Expand Down
11 changes: 7 additions & 4 deletions app/routes/pokemon/pikachu/bulbasaur/charmander/squirtle.gts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ export default class ApplicationRoute extends BaseRoute {
}

<template>
<div class="pioneer">
<div
class="pioneer"
data-test-route-level="pokemon.pikachu.bulbasaur.charmander.squirtle"
>

<h1>{{@model.pokemon.name}}</h1>
<h1>{{@context.pokemon.name}}</h1>

<img
src={{@model.pokemon.sprites.front_default}}
alt={{@model.pokemon.name}}
src={{@context.pokemon.sprites.front_default}}
alt={{@context.pokemon.name}}
/>

{{outlet}}
Expand Down
2 changes: 1 addition & 1 deletion app/templates/classic-pokemon.gts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div class="classic">
<div class="classic" data-test-route-level="classic-pokemon">
<h1>Classic Pokemon be loaded</h1>

<p>
Expand Down
2 changes: 1 addition & 1 deletion app/templates/classic-pokemon/pikachu.gts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div class="classic">
<div class="classic" data-test-route-level="classic-pokemon.pikachu">
<h1>{{@model.pokemon.name}}</h1>

<img
Expand Down
Loading
Loading