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
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-flightPOPотбрасывает её до завершенияretry().Related
[Bug]: Browser history block: та же всегда-активная подсистемаhistory.blockв1.2.0.RFC: block or confirm route transitions: API блокировки/подтверждения переходов ещё на стадии RFC.Reproduction
Минимальный самостоятельный репро на публичном API (без monkey-patch внутренностей),
createMemoryHistory:На
@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.0effector:23.4.4node:v25.8.0Logs / screenshots