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
163 changes: 50 additions & 113 deletions app/route-managers/pioneer-manager.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,22 @@
import { makeRouteTemplate } from '@ember/-internals/glimmer';
import type { InternalOwner } from '@ember/-internals/owner';
import type { RouteStateBucket } from '@ember/-internals/routing';
import templateOnly from '@ember/component/template-only';
import type { EnterState, RouteStateBucket } from '@ember/routing';
import { assert } from '@ember/debug';
import type Owner from '@ember/owner';
import { routeCapabilities } from '@ember/routing';
import { precompileTemplate } from '@ember/template-compilation';
import type { CurriedComponent, Destroyable } from '@glimmer/interfaces';
import { getComponentTemplate, setComponentTemplate } from '@glimmer/manager';
import { getOwner } from '@glimmer/owner';
import type { Reference } from '@glimmer/reference';
import { createComputeRef, createConstRef } from '@glimmer/reference';
import { createCapturedArgs, curry, EMPTY_POSITIONAL } from '@glimmer/runtime';
import { tracked } from '@glimmer/tracking';
import { dict } from '@glimmer/util';
import type { Destroyable } from '@glimmer/interfaces';
import RouteShell from 'use-route-manager/route-managers/route-shell';
import type BaseRoute from 'use-route-manager/routes/BaseRoute';
import { getOwner } from '@ember/owner';

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 @RouteComponent and the optional loading template as
// @LoadingState. @model is forwarded to the route component once loading is done.
const RouteShell = templateOnly();
setComponentTemplate(
precompileTemplate(
`
{{#if @LoadingState}}
{{#if @isLoading}}
<@LoadingState />
{{else}}
<@RouteComponent @model={{@model}} />
{{/if}}
{{else}}
<@RouteComponent @model={{@model}} />
{{/if}}`,
{ strictMode: true }
),
RouteShell
);

export class RouteBucket implements RouteStateBucket {
route: BaseRoute;
args: { name: string };

// Cached after the first call to getInvokable so subsequent calls return
// the same component definition.
invokable: object | undefined = undefined;

// Populated by the router synchronously after calling manager.enter(), so that
// child routes can await the parent's data resolution via getAncestorPromise.
enterPromise: Promise<unknown> | undefined = undefined;

// The resolved model returned from enter(). Tracked so that the @model ref
// in the curried invokable re-renders the template when data arrives.
@tracked context: unknown = undefined;

// True while enter() is in flight. The wrapper template reads this to
// switch between the loading state and the resolved route component.
@tracked isLoading = true;

constructor(route: BaseRoute, args: { name: string }) {
this.route = route;
this.args = args;
Expand All @@ -78,42 +37,52 @@ export class PioneerRouteManager {
args: { name: string }
): RouteBucket {
// Instantiate the plain class route using `new`, passing the owner.
// Key difference from ClassicRouteManager no EmberObject.create().
// Key difference from ClassicRouteManager, no EmberObject.create().
const route = new RouteClass(this.#owner);
const bucket = new RouteBucket(route, args);
route.bucket = bucket;
route.manager = this;
return bucket;
}

getRenderState(bucket: RouteBucket): object | undefined {
const route = bucket.route;
const wrapper = this.getRouteWrapper();
const invokable = bucket.invokable;

const owner = getOwner(route);
assert('Route is unexpectedly missing an owner', owner);

return {
owner,
name: bucket.args.name,
controller: undefined,
model: undefined,
wrapper,
invokable,
bucket,
};
}

getDestroyable(bucket: RouteBucket): Destroyable | null {
return bucket.route;
}

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}"`);
}

async enter(
bucket: RouteBucket,
{ getAncestorPromise }: { getAncestorPromise: () => Promise<unknown> }
): Promise<unknown> {
async enter(bucket: RouteBucket, state: EnterState): Promise<unknown> {
console.log(`PioneerRouteManager: entering route "${bucket.args.name}"`);
try {
const ancestorPromises = getAncestorPromise();
console.log('ancestor promises', ancestorPromises);
const context = await bucket.route.model(ancestorPromises);
bucket.context = context;
return context;
} finally {
// Tracked field, so the wrapper template re-renders to show the route
// component once data has arrived (or after a failure, to avoid getting
// stuck on the loading state).
bucket.isLoading = false;
}
const { to } = state;

const ancestorContext = to.parent
? state.getAncestorContext(to.parent)
: Promise.resolve(undefined);

const context = await bucket.route.model(ancestorContext, to.params ?? {});

return context;
}

didEnter(_bucket: RouteBucket): void {
Expand All @@ -132,71 +101,39 @@ export class PioneerRouteManager {
console.log(`PioneerRouteManager: did exit route "${_bucket.args.name}"`);
}

getRouteWrapper(): object {
// Module stable wrapper, the same definition is returned for every
// bucket. Per route data flows in via @routeInfo at render time.
return RouteShell;
}

async getInvokable(bucket: RouteBucket): Promise<object | undefined> {
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.
// Pull the named LoadingState export off the route module if it has one
// and stash it on the route instance so the wrapper can read it via
// @routeInfo.route.LoadingState. Routes that omit the export leave the
// field undefined and the wrapper renders the route component 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;
bucket.route.LoadingState = routeModule?.LoadingState;

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);
if (!templateFactory) {
throw new Error(
`PioneerRouteManager: no template found for route "${bucket.args.name}". ` +
`Make sure the route class is defined in a .gts file with a co-located <template>.`
);
}

const template = templateFactory(owner);
const RouteComponent = makeRouteTemplate(owner, bucket.args.name, template);

// Curry RouteShell with the three args it needs. @model is a compute ref
// over bucket.context (tracked) so the route template re-renders when
// model() resolves. @isLoading is a compute ref over bucket.isLoading
// (tracked) so the wrapper switches from LoadingState to RouteComponent
// when enter() finishes. The component args are const refs since they
// never change for the lifetime of the route.
const namedArgs = dict<Reference>();
namedArgs['model'] = createComputeRef(() => bucket.context);
namedArgs['isLoading'] = createComputeRef(() => bucket.isLoading);
namedArgs['RouteComponent'] = createConstRef(
RouteComponent,
'RouteComponent'
);
namedArgs['LoadingState'] = createConstRef(LoadingState, 'LoadingState');

const args = createCapturedArgs(namedArgs, EMPTY_POSITIONAL);
// isResolved=false because RouteShell is a raw template-only component
// class that the VM still needs to look up via its component manager.
const invokable = curry(
0 as CurriedComponent,
RouteShell,
owner,
args,
false
);

bucket.invokable = invokable;
return invokable;
bucket.invokable = RouteClass;
return RouteClass;
}
}
79 changes: 79 additions & 0 deletions app/route-managers/route-shell.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type Owner from '@ember/owner';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import type BaseRoute from 'use-route-manager/routes/BaseRoute';

// Module stable wrapper component returned by PioneerRouteManager.getRouteWrapper.
// The framework curries the per render args onto this template:
//
// @Component : the uncurried invokable from getInvokable
// @model : route.currentModel, unused by pioneer (which has no
// controller/currentModel); the model is sourced from the
// routeInfo instead
// @controller : eagerly resolved by the outlet helper, unused by pioneer
// @routeInfo : the InternalRouteInfo, exposes the route via `route`, the
// resolved `context`, and the per render `enterPromise`
//
// The pioneer manager renders immediately (its getInvokable does not await
// enter), so the model arrives asynchronously. We read it from the
// routeInfo's enterPromise: while the promise is pending isLoading is true
// and we render the route's LoadingState (if any); once it resolves we render
// the route component with the resolved context as @model. A new transition
// rebuilds the curried wrapper with a fresh enterPromise, so the loading flag
// resets naturally.

interface RouteInfoLike {
route: BaseRoute;
context?: unknown;
enterPromise?: Promise<unknown>;
}

interface RouteShellSignature {
Args: {
Component: object;
model: unknown;
controller: unknown;
routeInfo: RouteInfoLike;
};
}

export default class RouteShell extends Component<RouteShellSignature> {
@tracked isLoading = true;
@tracked model: unknown = undefined;

constructor(owner: Owner, args: RouteShellSignature['Args']) {
super(owner, args);

const promise = args.routeInfo.enterPromise;

// No enter promise means there is nothing to wait for, render the route's
// already-resolved context immediately.
if (promise === undefined) {
this.model = args.model;
this.isLoading = false;
return;
}

const settle = (context: unknown) => {
this.model = context;
this.isLoading = false;
};
promise.then(settle, () => settle(undefined));
}

get LoadingState(): object | undefined {
return this.args.routeInfo.route.LoadingState;
}

<template>
{{#if this.LoadingState}}
{{#if this.isLoading}}
<this.LoadingState />
{{else}}
<@Component @model={{this.model}} />
{{/if}}
{{else}}
<@Component @model={{this.model}} />
{{/if}}
</template>
}
2 changes: 2 additions & 0 deletions app/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Router.map(function () {
// Add route declarations here
this.route('classic', function () {
this.route('sub');
this.route('get', { path: '/get/:pokemon_id' });
});

this.route('pokemon', function () {
Expand All @@ -20,6 +21,7 @@ Router.map(function () {
});
});
});
this.route('get', { path: '/get/:pokemon_id' });
});

this.route('classic-pokemon', function () {
Expand Down
Loading
Loading