Browser Back/Forward causes an infinite popstate loop (v1.2.0)
- Dominant language
- TypeScript
- Stars
- 8
- Forks
- 2
- Avg merge
- 13h 44m
- Merged PRs (30d)
- 1
Description
### Summary
Pressing the browser Back button puts the router into an infinite `popstate` loop: the POP is reverted and immediately re-applied, alternating between the two paths indefinitely. In-app navigation via route `open()` works correctly, so the problem is easy to miss until someone presses Back.
### Versions
| Package | Version |
|---|---|
| `@effector/router` | 1.2.0 |
| `effector` | 23.4.4 |
| `history` | 5.3.0 |
| Vite | 8.2.0 |
| Svelte | 5 |
Browser: Chrome on Windows 10.
### Reproduction
```ts
import { createRoute, createRouter, historyAdapter } from '@effector/router';
import { createBrowserHistory } from 'history';
export const homeRoute = createRoute({ path: '/' });
export const profileRoute = createRoute({ path: '/profile' });
export const router = createRouter({ routes: [homeRoute, profileRoute] });
router.setHistory(historyAdapter(createBrowserHistory()));
```
1. Load `/`.
2. `profileRoute.open()` — URL becomes `/profile`. Correct.
3. Press Back (or call `history.back()`).
### Expected
One `popstate`, URL returns to `/`, `homeRoute.$isOpened` becomes `true`.
### Actual
An unbounded stream of `popstate` events alternating `/` → `/profile` → `/` → …
Measured with:
```js
let count = 0;
window.addEventListener('popstate', () => count++);
history.back();
await new Promise(r => setTimeout(r, 1500));
console.log(count);
```
| App configuration | popstate events from one Back press |
|---|---|
| implicit controls, `block` omitted from the adapter | **112** |
| explicit `createRouterControls()`, `block` present | **251** |
In the second configuration the loop also left **state and URL desynchronised** — the address bar settled on `/` while the UI still rendered the Profile route.
### Investigation
Two candidate causes were ruled out:
**1. The history blocker.** `historyAdapter` always exposes a `block` method, and the router registers it unconditionally during controls initialisation (`P.block?.call(...)` in `subscribeHistoryFx`). Since `RouterAdapter.block` is optional, I passed an adapter without it:
```ts
const base = historyAdapter(createBrowserHistory());
router.setHistory({
get location() { return base.location; }, // `location` is a getter — spreading freezes it
push: base.push,
replace: base.replace,
goBack: base.goBack,
goForward: base.goForward,
listen: base.listen,
// block intentionally omitted
});
```
This **does** remove a second, separate symptom — a spurious "Leave site?" `beforeunload` prompt that appears on every navigation once a blocker is registered — but the POP loop persists unchanged.
**2. Application wiring.** Reproduced in two independently written apps with different configurations (see the table above). Same failure.
That points at the router's own handling of POP transitions rather than at the blocker or at user configuration.
### Possible secondary issue
Registering a history blocker unconditionally, even when no `beforeNavigate` guard exists, means every app gets a `beforeunload` handler and the "Leave site?" prompt it produces. It may be worth registering the blocker lazily, only once a guard is actually declared.
Contributor guide
Research direction
Start with the POP handling around subscribeHistoryFx and the historyAdapter/createRouter entry points; reproduce the issue with the supplied createBrowserHistory example and a Back press. Done means one Back produces one popstate, the URL and route state stay synchronized, and navigation without a guard does not show a spurious beforeunload prompt.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript, vite
- Domain
- frontend, web-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100