Skip to content

Superseded async route action's delayed this.render() clobbers the new route's view #131

@a4xrbj1

Description

@a4xrbj1

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

  1. Route A — async action() that does await import(...) then this.render(layoutA, ...).
  2. Route B — likewise, rendering layoutB.
  3. FlowRouter.go('/a'), then immediately FlowRouter.go('/b').
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions