Skip to content

[Bug]: Native POP is silently dropped by a concurrent replace during a held back() (multiple routers, one history) #109

Description

@warbr1ng3r

Package

@effector/router (core)

What happened?

Когда несколько роутеров (адаптеров) работают с одним экземпляром history, нативный POP от router.back() молча теряется, если в этот момент другой роутер выполняет navigate({ replace: true }), пока переход по POP ещё удерживается (in-flight).

POP не доходит до history.listen(POP ...) — вместо него роутеры получают уже REPLACE (first.updated(/r), second.updated(/r)), а history остаётся на текущем индексе. В реальном приложении, где «назад» завязан на этот POP, это проявляется как зависшая навигация: URL меняется, но нужного перехода назад не происходит.

Ожидается: конкурирующая команда роутера не должна молча отбрасывать удерживаемую нативную транзакцию — POP должен либо завершиться и уведомить history.listen(POP ...), либо оставаться в ожидании, пока все участники его явно не разрешат.

Фактически: нативный POP теряется, до history.listen доходит только REPLACE, а history.index остаётся прежним.

Замечание: ломается именно этот тайминг — когда другой роутер синхронно делает replace во время удержанного POP. Варианты, где replace выполняет тот же роутер или он приходит уже после POP, транзакцию не теряют. Баг проявляется только при нескольких роутерах/адаптерах на одном history.

Root cause (предположение)

Общий blocker-обёртка, добавленная в 1.2.0: historyAdapter() ставит общий blocker на каждый history; нативный POP становится удерживаемой (pending) транзакцией; push/replace идут через runWithoutBlocking(...), который сбрасывает pending-транзакцию в null перед выполнением замены. Поэтому replace во время in-flight POP отбрасывает её до завершения retry().

Related

Reproduction

Минимальный самостоятельный репро на публичном API (без monkey-patch внутренностей), createMemoryHistory:

import {
  beforeNavigate, createRoute, createRouter, createRouterControls, historyAdapter,
} from '@effector/router';
import { createMemoryHistory } from 'history';

const lines = [];
const log = (m) => { lines.push(m); console.log(m); };
const wait = () => new Promise((r) => setTimeout(r, 0));

function makeRouter(name) {
  const controls = createRouterControls();
  const routeA = createRoute({ path: '/a' });
  const routeB = createRoute({ path: '/b' });
  const routeR = createRoute({ path: '/r' });
  const router = createRouter({ controls, routes: [routeA, routeB, routeR] });
  router.updated.watch(({ path }) => log(`${name}.updated(${path})`));
  return { controls, router, routeA, routeB, routeR };
}

const history = createMemoryHistory({ initialEntries: ['/a', '/b'], initialIndex: 1 });
history.listen(({ action, location }) => log(`history.listen(${action} ${location.pathname})`));

const first = makeRouter('first');
const second = makeRouter('second');
first.router.setHistory(historyAdapter(history));
second.router.setHistory(historyAdapter(history));

const firstGate = beforeNavigate({ controls: first.controls, from: first.routeB, to: first.routeA });
const secondGate = beforeNavigate({ controls: second.controls, from: second.routeB, to: second.routeA });

// Конкурирующий replace от ДРУГОГО роутера во время удержанного нативного POP:
firstGate.started.watch(() => {
  second.router.navigate({ path: '/r', replace: true });
  firstGate.proceed();
});
secondGate.started.watch(() => secondGate.proceed());

await wait(); await wait();
log(`before back: location=${history.location.pathname} index=${history.index}`);
first.router.back();
await wait(); await wait(); await wait();
log(`after back: location=${history.location.pathname} index=${history.index}`);
log(`summary: saw POP listen = ${lines.some((l) => l.startsWith('history.listen(POP '))}`);

На @effector/router@1.1.0 у historyAdapter() нет метода block, путь перехвата нативного POP отсутствует, и обычный back() штатно доходит до history.listen(POP ...) (индекс 1 → 0).

Environment

  • @effector/router: 1.2.0
  • @effector/router-react: 1.0.2 (в приложении back идёт через useRouter().onBack; репро выше — на core)
  • history: 5.3.0
  • effector: 23.4.4
  • node: v25.8.0

Logs / screenshots

history.block(register)
first.initialized(/b)
second.initialized(/b)
first.routeB.opened
second.routeB.opened
before back: location=/b index=1
history.back()
history.block(callback POP /a)
firstGate.started
firstGate -> second.router.navigate({ path: /r, replace: true })
firstGate -> proceed()
history.replace(/r)
history.listen(REPLACE /r)
history.block(register)
first.updated(/r)
second.updated(/r)
first.routeR.opened
second.routeR.opened
after back: location=/r index=1
summary: saw POP listen = false

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions