effector / effector/router

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

Open
#109 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.