Summary
When an async route action is superseded by a newer navigation while it is still awaiting (e.g. await import(...)), the superseded action is not cancelled or invalidated. When it resumes and calls this.render(...), that render is applied unconditionally — painting the stale route's layout/template over the route that is now current.
Version
The bug
async actions with await import(...) are the documented lazy-loading pattern:
FlowRouter.route('/puzzle', {
name: 'puzzle',
async action() {
await import('../pages/puzzle/puzzle'); // suspends here
this.render('masterLayout', { main: 'puzzle' });
},
});
If, while that action is suspended at the await, a second navigation occurs (FlowRouter.go('/other')), the second route renders. Then the first action resumes and calls this.render(...) — and the renderer applies it, replacing /other's view with /puzzle's. The URL and FlowRouter.getRouteName() report /other, but the DOM shows /puzzle.
Reproduction
- Route A —
async action() that does await import(...) then this.render(layoutA, ...).
- Route B — likewise, rendering
layoutB.
FlowRouter.go('/a'), then immediately FlowRouter.go('/b').
- Observed:
FlowRouter.getRouteName() === 'b', but the rendered view is A's — whichever action's this.render resolves last wins.
This bit us in production: a post-login redirect (go('/puzzle')) raced a higher-priority redirect to a legal-consent interstitial. The interstitial's route was active, but the app's main page rendered over it — a legal gate silently bypassed. A hard page reload renders correctly (only one action runs, so there is no race), which is what makes it easy to miss.
Root cause
Route binds this.render to the shared renderer: this.render = router.Renderer.render.bind(router.Renderer) (client/route.js:21).
callAction awaits the action (client/route.js:446 / :449), but nothing invalidates an action once a newer navigation supersedes it.
BlazeRenderer.render / proceed apply the render unconditionally — they have no notion of "which route requested this render, and is it still the current one."
Notably, the renderer already has the current route in hand: this.router._current.route is read inside proceed for the forceReRender check (client/renderer.js:166). The information needed to drop a stale render is already present; it just is not used for this.
Suggested fix
Thread the originating route into the render call (or capture it when Route binds this.render), and in BlazeRenderer.proceed — or at the head of render — drop the call when the originating route is no longer router._current.route. A superseded action's this.render() then becomes a no-op instead of clobbering the current route.
Workaround (for anyone else hitting this)
Guard each post-await this.render() with a route-name check:
async action() {
await import('...');
if (FlowRouter.getRouteName() !== 'puzzle') return; // superseded — skip the render
this.render('masterLayout', { main: 'puzzle' });
}
Thanks for maintaining flow-router-extra.
Summary
When an
asyncrouteactionis superseded by a newer navigation while it is stillawaiting (e.g.await import(...)), the superseded action is not cancelled or invalidated. When it resumes and callsthis.render(...), that render is applied unconditionally — painting the stale route's layout/template over the route that is now current.Version
ostrio:[email protected]BlazeRenderer)The bug
asyncactions withawait import(...)are the documented lazy-loading pattern:If, while that action is suspended at the
await, a second navigation occurs (FlowRouter.go('/other')), the second route renders. Then the first action resumes and callsthis.render(...)— and the renderer applies it, replacing/other's view with/puzzle's. The URL andFlowRouter.getRouteName()report/other, but the DOM shows/puzzle.Reproduction
async action()that doesawait import(...)thenthis.render(layoutA, ...).layoutB.FlowRouter.go('/a'), then immediatelyFlowRouter.go('/b').FlowRouter.getRouteName() === 'b', but the rendered view is A's — whichever action'sthis.renderresolves last wins.This bit us in production: a post-login redirect (
go('/puzzle')) raced a higher-priority redirect to a legal-consent interstitial. The interstitial's route was active, but the app's main page rendered over it — a legal gate silently bypassed. A hard page reload renders correctly (only one action runs, so there is no race), which is what makes it easy to miss.Root cause
Routebindsthis.renderto the shared renderer:this.render = router.Renderer.render.bind(router.Renderer)(client/route.js:21).callActionawaits the action (client/route.js:446/:449), but nothing invalidates an action once a newer navigation supersedes it.BlazeRenderer.render/proceedapply the render unconditionally — they have no notion of "which route requested this render, and is it still the current one."Notably, the renderer already has the current route in hand:
this.router._current.routeis read insideproceedfor theforceReRendercheck (client/renderer.js:166). The information needed to drop a stale render is already present; it just is not used for this.Suggested fix
Thread the originating route into the render call (or capture it when
Routebindsthis.render), and inBlazeRenderer.proceed— or at the head ofrender— drop the call when the originating route is no longerrouter._current.route. A superseded action'sthis.render()then becomes a no-op instead of clobbering the current route.Workaround (for anyone else hitting this)
Guard each post-
awaitthis.render()with a route-name check:Thanks for maintaining flow-router-extra.