Repository navigation
fix(pages): replace history for same-page action re-renders - #566
Merged
Merged
Conversation
Fixes voidzero-dev#527 ## Summary Named actions no longer pollute history when they re-render the current page. voidzero-dev#530 already removed `?actionName` from the committed URL; voidzero-dev#531 still used `pushState` for every mutation, so each `useForm('/db?create')` / `action()` submit added another back-stack entry at `/db`. - Same-document mutations use `replaceState` instead of `pushState`. - `mutationCommitUrl()` prefers the server `pageData.url` when the action updates query params on the same path (e.g. `POST /books?delete` → `/books?page=2` still pushes once). ## Tests Updated POST history expectations to `replaceState` when the browser URL already matches the committed page URL. Added coverage for URL-changing POSTs and repeated same-page named-action submits. `vp test run packages/void/test/unit/pages-client.test.ts` — 69/69 passed. Co-authored-by: froQ <sayhola@froq.me>
cpojer
approved these changes
Oct 26, 2026
Brooooooklyn
added a commit
that referenced
this pull request
Oct 26, 2026
Fixes #562. A Pages navigation wrote history last. When `pushState` or `replaceState` threw (Safari's rate limit, a `SecurityError`), the new page had already taken over, but the address bar still showed the old URL and the visit never went idle. ``` before after ────── ───── render B render B commit B: commit B: dispose A, hand over Deferred values write history ── throws ─┐ head, title, scroll key → B (nothing else changed) │ write history ── throws dispose B only │ show A again ◄────────────┘ result: page B, URL A, head B, result: page A, URL A, head A, A's Deferred values gone, visit rejects with the error, stuck on "loading" navigation goes idle ``` ## What changed ``` navigation-engine.ts write history first, right after the #507 guard on throw: dispose only the new page, rethrow scroll-restoration.ts take the new scroll key only once the entry exists client.ts (Vue, Svelte, Solid) put the committed page back when the last visit to settle finds a failed one on screen react pages-client.ts on a failed commit: end the visit, dispatch 'fail' (shows the committed page), reject router.visit() hash visits a failed write rejects the visit (loading → idle) instead of throwing out of router.visit() ``` The engine has to write first. Disposing the old page, handing over its Deferred values and updating the head can't be undone, so a rollback after the fact isn't possible. Overlapping visits: ``` B renders ── B's write fails C still loading → B stays until C settles C commits → C shown (normal) C fails too → A shown again hash visit settles while B renders, then B's write fails → A shown again ``` Kept as before: the #507 guard runs first, #506 (history for every visit that isn't back/forward), and #527/#566 (a same-URL form submission replaces its entry). ## Tests - `pages-client.test.ts`, new `failed history writes (#562)` block. It covers a failed push, a failed replace, a same-URL POST (form state kept), a working visit after a failure, an older visit failing while a newer one loads (the newer one commits, or fails too), a hash visit settling first, a failed hash visit, and the engine called the way React calls it (a second `commit()` is a no-op, and the old page still gets its streamed Deferred values). - `pages-scroll.test.ts`: after a failed write, positions are saved under the current entry's key. - `react-pages-client.test.ts` (browser): a Link push and a `replace` Link whose write fails. The page stays, the URL stays, state goes idle, and the next click works. - Mutation checks: each source change was put back to `main` one at a time (engine, core client, the hash-visit-first branch, hash shell, scroll key, React adapter). Each one made a test fail. - `vp run check`, `vp lint`, `vp fmt`, `vp run test` and one full `vp test run` pass. ## Choices to check - **React `useForm` has already reported success** when the shell arrives, before the commit. If the write then fails, the form keeps its result, the page rolls back, and the error goes to `reportError` so it isn't dropped silently. Making `useForm` wait for the commit would change its timing for every submission. - **The head and title now change after the history entry is written**, not before. Browsers ignore the `title` argument, so this shouldn't show. - **Not in this PR:** Vue, Svelte and Solid `<Form>`/Link don't catch a rejected visit, so a failed write there is an unhandled rejection (as with any other visit error today). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #527
Summary
Named actions no longer pollute history when they re-render the current page. #530 already removed
?actionNamefrom the committed URL; #531 still usedpushStatefor every mutation, so eachuseForm('/db?create')/action()submit added another back-stack entry at/db.replaceStateinstead ofpushState.mutationCommitUrl()prefers the serverpageData.urlwhen the action updates query params on the same path (e.g.POST /books?delete→/books?page=2still pushes once).Tests
Updated POST history expectations to
replaceStatewhen the browser URL already matches the committed page URL. Added coverage for URL-changing POSTs and repeated same-page named-action submits.vp test run packages/void/test/unit/pages-client.test.ts— 69/69 passed. 🐸