Skip to content

Commit

Permalink
feat(router): stateful routes
Browse files Browse the repository at this point in the history
Adds stateful routes so that route configurations can specify a module or a module in a viewport as `stateful: true`. A stateful module that's loaded in a viewport is never unloaded when navigating away, it's just not shown, and is displayed with the same state whenever a route places it in the same viewport again.

Depending on aurelia/templating-router#64 and aurelia#536.
Closes aurelia#534.
  • Loading branch information
jwx committed Sep 19, 2018
1 parent 8d20410 commit 76e0f91
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 18 deletions.
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
"aurelia-history": "^1.1.0",
"aurelia-logging": "^1.0.0",
"aurelia-path": "^1.0.0",
"aurelia-route-recognizer": "^1.2.0"
"aurelia-route-recognizer": "^1.2.0",
"aurelia-templating": "^1.6.0"
},
"devDependencies": {
"aurelia-pal-browser": "^1.0.0-rc.1.0.0",
Expand All @@ -59,7 +60,8 @@
"aurelia-history": "^1.1.0",
"aurelia-logging": "^1.0.0",
"aurelia-path": "^1.0.0",
"aurelia-route-recognizer": "^1.2.0"
"aurelia-route-recognizer": "^1.2.0",
"aurelia-templating": "^1.6.0"
},
"devDependencies": {
"aurelia-tools": "0.2.4",
Expand Down
8 changes: 6 additions & 2 deletions src/navigation-instruction.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,16 @@ export class NavigationInstruction {
/**
* Adds a viewPort instruction.
*/
addViewPortInstruction(viewPortName: string, strategy: string, moduleId: string, component: any): any {
addViewPortInstruction(viewPortName: string, strategy: string, moduleId: string, component: any, active: boolean): any {
const config = Object.assign({}, this.lifecycleArgs[1], { currentViewPort: viewPortName });
let viewportInstruction = this.viewPortInstructions[viewPortName] = {
name: viewPortName,
strategy: strategy,
moduleId: moduleId,
component: component,
childRouter: component.childRouter,
lifecycleArgs: [].concat(this.lifecycleArgs[0], config, this.lifecycleArgs[2])
lifecycleArgs: [].concat(this.lifecycleArgs[0], config, this.lifecycleArgs[2]),
active: active
};

return viewportInstruction;
Expand Down Expand Up @@ -224,6 +225,9 @@ export class NavigationInstruction {
}
}));
}
}
else if (viewPortInstruction.active && !viewPortInstruction.childNavigationInstruction) {
delaySwaps.push({viewPort, viewPortInstruction});
} else {
if (viewPortInstruction.childNavigationInstruction) {
loads.push(viewPortInstruction.childNavigationInstruction._commitChanges(waitToSwap));
Expand Down
104 changes: 101 additions & 3 deletions src/navigation-plan.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Redirect } from './navigation-commands';
import {Redirect} from './navigation-commands';
import {_resolveUrl} from './util';

/**
* The strategy to use when activating modules during navigation.
Expand Down Expand Up @@ -109,7 +110,19 @@ export function _buildNavigationPlan(instruction: NavigationInstruction, forceLi
}
}

return Promise.all(pending).then(() => plan);
if (config.viewPorts) {
for (let viewPortName in config.viewPorts) {
if (config.viewPorts[viewPortName] === null || config.viewPorts[viewPortName].moduleId === null) {
config.viewPorts[viewPortName] = null;
}
if (config.viewPorts[viewPortName] !== undefined || !viewPorts[viewPortName]) {
if (config.stateful || (config.viewPorts[viewPortName] && config.viewPorts[viewPortName].stateful)) {
config.viewPorts[viewPortName].stateful = true;
viewPortName = instruction.router._ensureStatefulViewPort(viewPortName, config.viewPorts[viewPortName].moduleId);
}
viewPorts[viewPortName] = config.viewPorts[viewPortName.split('.')[0]];
}
}
}

for (let viewPortName in config.viewPorts) {
Expand All @@ -124,7 +137,92 @@ export function _buildNavigationPlan(instruction: NavigationInstruction, forceLi
};
}

return Promise.resolve(plan);
return Promise.all(pending).then(() => {
for (let viewPortName in plan) {
if (viewPortName.indexOf('.') != -1) {
let shortName = viewPortName.split('.')[0];
if (!plan[shortName]) {
plan[shortName] = {
name: shortName,
strategy: activationStrategy.replace,
config: null
}
}
}
}
return plan;
});
}

function buildViewPortPlan(instruction: NavigationInstruction, viewPorts: any, forceLifecycleMinimum, newParams: boolean, viewPortName: string, previous: boolean) {
let plan = {};
let prev = instruction.previousInstruction;
let config = instruction.config;
let configViewPortName = viewPortName;
let prevViewPortInstruction = prev ? prev.viewPortInstructions[viewPortName] : undefined;
let nextViewPortConfig = !previous ? viewPorts[configViewPortName] : undefined;

if (config.explicitViewPorts && nextViewPortConfig === undefined) {
nextViewPortConfig = null;
}

plan['name'] = viewPortName;
let viewPortPlan = plan['plan'] = {
name: viewPortName
};
if (prevViewPortInstruction) {
viewPortPlan.prevComponent = prevViewPortInstruction.component;
viewPortPlan.prevModuleId = prevViewPortInstruction.moduleId;
}
if (nextViewPortConfig !== undefined) {
viewPortPlan.config = nextViewPortConfig;
viewPortPlan.active = true;
}
else {
viewPortPlan.config = prevViewPortInstruction.config;
}

if (!prevViewPortInstruction) {
viewPortPlan.strategy = activationStrategy.replace;
} else if (nextViewPortConfig === null) { // null value means deliberately cleared!
viewPortPlan.strategy = activationStrategy.replace;
} else if (nextViewPortConfig === undefined) { // undefined (left out in config) means keep same
viewPortPlan.strategy = activationStrategy.noChange;
}
else if (prevViewPortInstruction.moduleId !== nextViewPortConfig.moduleId) {
viewPortPlan.strategy = activationStrategy.replace;
}
else if (!nextViewPortConfig.stateful && !prevViewPortInstruction.active) {
viewPortPlan.strategy = activationStrategy.replace;
} else if ('determineActivationStrategy' in prevViewPortInstruction.component.viewModel) {
viewPortPlan.strategy = prevViewPortInstruction.component.viewModel
.determineActivationStrategy(...instruction.lifecycleArgs);
} else if (config.activationStrategy) {
viewPortPlan.strategy = config.activationStrategy;
} else if (newParams || forceLifecycleMinimum) {
viewPortPlan.strategy = activationStrategy.invokeLifecycle;
} else {
viewPortPlan.strategy = activationStrategy.noChange;
}

if (viewPortPlan.strategy !== activationStrategy.replace && prevViewPortInstruction.childRouter) {
let path = instruction.getWildcardPath();
let task = prevViewPortInstruction.childRouter
._createNavigationInstruction(path, instruction).then(childInstruction => { // eslint-disable-line no-loop-func
viewPortPlan.childNavigationInstruction = childInstruction;

return _buildNavigationPlan(
childInstruction,
viewPortPlan.strategy === activationStrategy.invokeLifecycle)
.then(childPlan => {
childInstruction.plan = childPlan;
});
});

plan['task'] = task;
}

return plan;
}

function hasDifferentParameterValues(prev: NavigationInstruction, next: NavigationInstruction): boolean {
Expand Down
6 changes: 4 additions & 2 deletions src/route-loading.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ function determineWhatToLoad(navigationInstruction: NavigationInstruction, toLoa
viewPortName,
viewPortPlan.strategy,
viewPortPlan.prevModuleId,
viewPortPlan.prevComponent);
viewPortPlan.prevComponent,
viewPortPlan.active);

if (viewPortPlan.childNavigationInstruction) {
viewPortInstruction.childNavigationInstruction = viewPortPlan.childNavigationInstruction;
Expand All @@ -69,7 +70,8 @@ function loadRoute(routeLoader: RouteLoader, navigationInstruction: NavigationIn
viewPortPlan.name,
viewPortPlan.strategy,
moduleId,
component);
component,
viewPortPlan.active);

let childRouter = component.childRouter;
if (childRouter) {
Expand Down
20 changes: 20 additions & 0 deletions src/router.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {RouteRecognizer} from 'aurelia-route-recognizer';
import {Container} from 'aurelia-dependency-injection';
import {History, NavigationOptions} from 'aurelia-history';
import {TemplatingEngine} from 'aurelia-templating';
import {NavigationInstruction} from './navigation-instruction';
import {NavModel} from './nav-model';
import {RouterConfiguration} from './router-configuration';
Expand Down Expand Up @@ -584,6 +585,25 @@ export class Router {
return c;
});
}

_ensureStatefulViewPort(name, moduleId) {
let viewPort = this.viewPorts[name];
let viewPortName = `${name}.${moduleId}`;

if (!this.viewPorts[viewPortName]) {
let newElement = viewPort.element.ownerDocument.createElement('router-view');
newElement.setAttribute('name', viewPortName);
viewPort.element.insertAdjacentElement('afterend', newElement);
let templatingEngine = viewPort.container.get(TemplatingEngine);
templatingEngine.enhance({
element: newElement,
container: viewPort.container,
resources: viewPort.resources
});
}

return viewPortName;
}
}

function generateBaseUrl(router: Router, instruction: NavigationInstruction) {
Expand Down
18 changes: 9 additions & 9 deletions test/navigation-plan.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,22 @@ describe('NavigationPlanStep', () => {
fragment: 'first',
config: { viewPorts: { default: { moduleId: './first' }}},
params: { id: '1' },
router
router: {}
});

sameAsFirstInstruction = new NavigationInstruction({
fragment: 'first',
config: { viewPorts: { default: { moduleId: './first' }}},
previousInstruction: firstInstruction,
params: { id: '1' },
router
router: {}
});

secondInstruction = new NavigationInstruction({
fragment: 'second',
config: { viewPorts: { default: { moduleId: './second' }}},
previousInstruction: firstInstruction,
router
router: {}
});
});

Expand Down Expand Up @@ -204,8 +204,8 @@ describe('NavigationPlanStep', () => {
});

it('is no-change when nothing changes', (done) => {
firstInstruction.addViewPortInstruction('default', 'ignored', './first', { viewModel: {}});

firstInstruction.addViewPortInstruction('default', 'ignored', './first', { viewModel: {}}, true);
step.run(sameAsFirstInstruction, state.next)
.then(() => {
expect(state.result).toBe(true);
Expand All @@ -216,7 +216,7 @@ describe('NavigationPlanStep', () => {

it('can be determined by route config', (done) => {
sameAsFirstInstruction.config.activationStrategy = 'fake-strategy';
firstInstruction.addViewPortInstruction('default', 'ignored', './first', { viewModel: {}});
firstInstruction.addViewPortInstruction('default', 'ignored', './first', { viewModel: {}}, true);

step.run(sameAsFirstInstruction, state.next)
.then(() => {
Expand All @@ -228,7 +228,7 @@ describe('NavigationPlanStep', () => {

it('can be determined by view model', (done) => {
let viewModel = { determineActivationStrategy: () => 'vm-strategy'};
firstInstruction.addViewPortInstruction('default', 'ignored', './first', { viewModel });
firstInstruction.addViewPortInstruction('default', 'ignored', './first', { viewModel }, true);

step.run(sameAsFirstInstruction, state.next)
.then(() => {
Expand All @@ -241,7 +241,7 @@ describe('NavigationPlanStep', () => {
it('is invoke-lifecycle when only params change', (done) => {
firstInstruction.params = { id: '1' };
sameAsFirstInstruction.params = { id: '2' };
firstInstruction.addViewPortInstruction('default', 'ignored', './first', { viewModel: {}});
firstInstruction.addViewPortInstruction('default', 'ignored', './first', { viewModel: {}}, true);

step.run(sameAsFirstInstruction, state.next)
.then(() => {
Expand All @@ -255,7 +255,7 @@ describe('NavigationPlanStep', () => {
firstInstruction.queryParams = { param: 'foo' };
sameAsFirstInstruction.queryParams = { param: 'bar' };
sameAsFirstInstruction.options.compareQueryParams = true;
firstInstruction.addViewPortInstruction('default', 'ignored', './first', { viewModel: {}});
firstInstruction.addViewPortInstruction('default', 'ignored', './first', { viewModel: {}}, true);

step.run(sameAsFirstInstruction, state.next)
.then(() => {
Expand Down

0 comments on commit 76e0f91

Please sign in to comment.