Skip to content
Merged
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
19 changes: 10 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,15 +169,16 @@ FlowRouter.triggers.enter([(context, redirect) => {

Order matches **`docs/hooks/README.md`**:

1. **`whileWaiting`**
2. **`waitOn`**
3. **`waitOnResources`**
4. **`endWaiting`**
5. **`data`**
6. **`onNoData`**
7. **`triggersEnter`** (after global **`FlowRouter.triggers.enter`** concatenation)
8. **`action`**
9. **`triggersExit`**
1. **`guard`** (parent group → child group → route; may be async)
2. **`whileWaiting`**
3. **`waitOn`**
4. **`waitOnResources`**
5. **`endWaiting`**
6. **`data`**
7. **`onNoData`**
8. **`triggersEnter`** (after global **`FlowRouter.triggers.enter`** concatenation)
9. **`action`**
10. **`triggersExit`**

**Per-file docs:** `docs/hooks/*.md`. **Implementation:** `client/route.js` (`waitOn`, `callAction`, etc.).

Expand Down
25 changes: 25 additions & 0 deletions client/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ class Route {
this._onNoData = options.onNoData || null;
this._endWaiting = options.endWaiting || null;
this._currentData = null;
this._guards = _helpers.isArray(options.guards)
? options.guards
: (_helpers.isFunction(options.guard) ? [options.guard] : (_helpers.isArray(options.guard) ? options.guard : []));
this._triggersExit = options.triggersExit ? makeTriggers(options.triggersExit) : [];
this._whileWaiting = options.whileWaiting || null;
this._triggersEnter = options.triggersEnter ? makeTriggers(options.triggersEnter) : [];
Expand Down Expand Up @@ -87,6 +90,28 @@ class Route {
return !results.includes(false);
}

async runGuards(current, redirectFn) {
for (const guard of this._guards) {
let redirected = false;
const redirect = (pathDef, fields, queryParams) => {
if (redirected) {
throw new Error('guard already redirected');
}
if (!pathDef) {
throw new Error('guard redirect requires a path');
}
redirected = true;
redirectFn(pathDef, fields, queryParams);
};

await guard(current, redirect);
if (redirected) {
return true;
}
}
return false;
}

async waitOn(current = {}, next) {
let _data = null;
let _isWaiting = false;
Expand Down
38 changes: 30 additions & 8 deletions client/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,36 @@ class Router {
this._invalidateTracker();
};

route.waitOn(this._current, (_current, data) => {
Triggers.runTriggers(
this._triggersEnter.concat(route._triggersEnter),
this._current,
this._redirectFn,
afterAllTriggersRan,
data
);
const current = this._current;
const proceed = () => {
route.waitOn(current, (_current, data) => {
Triggers.runTriggers(
this._triggersEnter.concat(route._triggersEnter),
current,
this._redirectFn,
afterAllTriggersRan,
data
);
});
};

if (route._guards.length === 0) {
proceed();
return;
}

const redirectFromGuard = (...args) => {
if (this._current === current) {
this._redirectFn(...args);
}
};

route.runGuards(current, redirectFromGuard).then((redirected) => {
if (!redirected && this._current === current) {
proceed();
}
}).catch((error) => {
Meteor._debug('[ostrio:flow-router-extra] route guard rejected', error);
});
};

Expand Down
1 change: 1 addition & 0 deletions docs/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

*Hooks below are listed in execution order*

- [`.guard()` hook](https://github.com/veliovgroup/flow-router/blob/master/docs/hooks/guard.md)
- [`.whileWaiting()` hook](https://github.com/veliovgroup/flow-router/blob/master/docs/hooks/whileWaiting.md)
- [`.waitOn()` hook](https://github.com/veliovgroup/flow-router/blob/master/docs/hooks/waitOn.md)
- [`.waitOnResources()` hook](https://github.com/veliovgroup/flow-router/blob/master/docs/hooks/waitOnResources.md)
Expand Down
45 changes: 45 additions & 0 deletions docs/hooks/guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
### guard

`guard` accepts a function or an array of functions. Guards run sequentially before `waitOn` and may be asynchronous.

Each guard receives:

- `context` {*Route*} - Output of `FlowRouter.current()` for the navigation target
- `redirect` {*Function*} - Redirect to another route, with the same arguments as [`FlowRouter.go()`](https://github.com/veliovgroup/flow-router/blob/master/docs/api/go.md)
- Return: {*void|Promise<void>*}

Calling `redirect()` stops the remaining guards and skips the guarded route's hooks and action. If another navigation starts while an asynchronous guard is pending, the stale guard cannot resume or redirect the abandoned navigation.

#### Route guard

```js
FlowRouter.route('/account', {
async guard(context, redirect) {
const allowed = await canViewAccount();
if (!allowed) {
redirect('/sign-in');
}
},
action() {
// Runs only after the guard resolves without redirecting.
}
});
```

#### Group guards

Group guards are inherited by nested groups and routes. They run from parent to child, followed by the route guard.

```js
const authenticated = FlowRouter.group({
guard(context, redirect) {
if (!Meteor.userId()) {
redirect('/sign-in');
}
}
});

authenticated.route('/settings', {
action() {}
});
```
5 changes: 4 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { Tracker } from "meteor/tracker";

type Trigger = (context: ReturnType<Router["current"]>, redirect: Router["go"], stop: () => void, data: any) => void;

type Guard = (context: ReturnType<Router["current"]>, redirect: Router["go"]) => void | Promise<void>;

type TriggerFilterParam = { only: string[] } | { except: string[] };

type DynamicImport = Promise<string>;
Expand Down Expand Up @@ -56,6 +58,7 @@ export interface Router {
path: string,
options?: {
name?: string;
guard?: Guard | Guard[];
whileWaiting?: Hook;
waitOn?: waitOn;
waitOnResources?: waitOnResources;
Expand All @@ -71,7 +74,7 @@ export interface Router {
[key: string]: any;
}
) => Route;
group: (options: { name: string; prefix?: string; [key: string]: any }) => any;
group: (options: { name?: string; prefix?: string; guard?: Guard | Guard[]; [key: string]: any }) => any;
render: (layout: string, template: string, data?: { [key: string]: any }, callback?: () => void) => void;

refresh: (layout: string, template: string) => void;
Expand Down
8 changes: 8 additions & 0 deletions index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ FlowRouter.initialize({ hashbang: false, maxWaitFor: 60_000 });

FlowRouter.route('/', {
name: 'home',
guard: async (context, redirect) => {
expectType<string>(context.path);
redirect('home');
},
});

FlowRouter.group({
guard: [() => undefined, async () => undefined],
});

FlowRouter.go('/');
Expand Down
14 changes: 13 additions & 1 deletion lib/group-base.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ const makeWaitFor = (func) => {
return [];
};

const makeGuards = (guard) => {
if (_helpers.isFunction(guard)) {
return [guard];
} else if (_helpers.isArray(guard)) {
return guard;
}
return [];
};

const makeTriggers = (_base, _triggers) => {
if (!_base && !_triggers) {
return [];
Expand All @@ -30,6 +39,7 @@ class GroupBase {
}

this._waitFor = makeWaitFor(options.waitOn);
this._guards = makeGuards(options.guard);
this._router = router;
this.prefix = options.prefix || '';
this.name = options.name;
Expand All @@ -46,6 +56,7 @@ class GroupBase {
this._triggersEnter = makeTriggers(parent._triggersEnter, this._triggersEnter);
this._triggersExit = makeTriggers(this._triggersExit, parent._triggersExit);
this._waitFor = this.parent._waitFor.concat(this._waitFor);
this._guards = this.parent._guards.concat(this._guards);
}
}

Expand All @@ -60,11 +71,12 @@ class GroupBase {
options.triggersEnter = makeTriggers(this._triggersEnter, options.triggersEnter);
options.triggersExit = makeTriggers(options.triggersExit, this._triggersExit);
options.waitFor = this._waitFor.concat([]);
options.guards = this._guards.concat(makeGuards(options.guard));

return this._router.route(
pathDef,
_helpers.extend(
_helpers.omit(this.options, ['triggersEnter', 'triggersExit', 'subscriptions', 'prefix', 'waitOn', 'name', 'title', 'titlePrefix', 'link', 'script', 'meta']),
_helpers.omit(this.options, ['triggersEnter', 'triggersExit', 'subscriptions', 'prefix', 'waitOn', 'guard', 'name', 'title', 'titlePrefix', 'link', 'script', 'meta']),
options
),
group
Expand Down
1 change: 1 addition & 0 deletions package.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Package.onTest((api) => {
api.addFiles('test/client/router.subs_ready.spec.js', 'client');
api.addFiles('test/client/router.reactivity.spec.js', 'client');
api.addFiles('test/client/group.spec.js', 'client');
api.addFiles('test/client/guard.spec.js', 'client');
api.addFiles('test/client/trigger.spec.js', 'client');
api.addFiles('test/client/triggers.js', 'client');

Expand Down
137 changes: 137 additions & 0 deletions test/client/guard.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { Meteor } from 'meteor/meteor';
import { Random } from 'meteor/random';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';

Tinytest.addAsync('Client - Guard - awaits guards before waitOn and action', (test, next) => {
const rand = Random.id();
const events = [];
let releaseGuard;

FlowRouter.route('/' + rand, {
guard: async () => {
events.push('guard:start');
await new Promise((resolve) => {
releaseGuard = resolve;
});
events.push('guard:end');
},
waitOn() {
events.push('waitOn');
},
action() {
events.push('action');
}
});

FlowRouter.go('/' + rand);
Meteor.setTimeout(() => {
test.equal(events, ['guard:start']);
releaseGuard();
Meteor.setTimeout(() => {
test.equal(events, ['guard:start', 'guard:end', 'waitOn', 'action']);
next();
}, 50);
}, 20);
});

Tinytest.addAsync('Client - Guard - redirect skips remaining route work', (test, next) => {
const source = Random.id();
const target = Random.id();
const events = [];

FlowRouter.route('/' + source, {
guard(_context, redirect) {
events.push('guard');
redirect('/' + target);
},
waitOn() {
events.push('waitOn');
},
action() {
events.push('source');
}
});
FlowRouter.route('/' + target, {
action() {
events.push('target');
}
});

FlowRouter.go('/' + source);
Meteor.setTimeout(() => {
test.equal(events, ['guard', 'target']);
next();
}, 50);
});

Tinytest.addAsync('Client - Guard - nested group guards run parent to child to route', (test, next) => {
const rand = Random.id();
const events = [];
const parent = FlowRouter.group({
prefix: '/' + rand,
guard() {
events.push('parent');
}
});
const child = parent.group({
prefix: '/child',
guard: async () => {
events.push('child');
}
});

child.route('/route', {
guard() {
events.push('route');
},
action() {
events.push('action');
}
});

FlowRouter.go('/' + rand + '/child/route');
Meteor.setTimeout(() => {
test.equal(events, ['parent', 'child', 'route', 'action']);
next();
}, 50);
});

Tinytest.addAsync('Client - Guard - stale async guard cannot resume or redirect', (test, next) => {
const source = Random.id();
const target = Random.id();
const unwanted = Random.id();
const events = [];
let releaseGuard;

FlowRouter.route('/' + source, {
async guard(_context, redirect) {
await new Promise((resolve) => {
releaseGuard = resolve;
});
redirect('/' + unwanted);
},
action() {
events.push('source');
}
});
FlowRouter.route('/' + target, {
action() {
events.push('target');
}
});
FlowRouter.route('/' + unwanted, {
action() {
events.push('unwanted');
}
});

FlowRouter.go('/' + source);
Meteor.setTimeout(() => {
FlowRouter.go('/' + target);
releaseGuard();
Meteor.setTimeout(() => {
test.equal(events, ['target']);
next();
}, 50);
}, 20);
});
Loading