[Bug]: Native POP is silently dropped by a concurrent replace during a held back() (multiple routers, one history)
- Dominant language
- TypeScript
- Stars
- 8
- Forks
- 2
- Avg merge
- 13h 44m
- Merged PRs (30d)
- 1
Description
### 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
- #108 — `[Bug]: Browser history block`: та же всегда-активная подсистема `history.block` в `1.2.0`.
- #39 — `RFC: block or confirm route transitions`: API блокировки/подтверждения переходов ещё на стадии RFC.
### Reproduction
Минимальный самостоятельный репро на публичном API (без monkey-patch внутренностей), `createMemoryHistory`:
```js
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
```text
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
```
Contributor guide
Research direction
Inspect historyAdapter() and run the supplied createMemoryHistory reproduction first, comparing the 1.2.0 behavior with 1.1.0. Done means the held native POP reaches history.listen(POP ...), is not lost when another router replaces, and history.index advances from 1 to 0.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100