Skip to content

fix(pages): replace history for same-page action re-renders - #566

Merged
cpojer merged 1 commit into
voidzero-dev:mainfrom
0froq:fix/action-url-history
Oct 26, 2026
Merged

cpojer merged 1 commit into
voidzero-dev:mainfrom
0froq:fix/action-url-history

Conversation

@0froq

@0froq 0froq commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Fixes #527

Summary

Named actions no longer pollute history when they re-render the current page. #530 already removed ?actionName from the committed URL; #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. 🐸

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 cpojer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@cpojer
cpojer merged commit 4f3dcec into voidzero-dev:main Oct 26, 2026
2 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pages: actions commit the ?actionName URL to history

3 participants