Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d71ce45
fix: back-button restore survives late layout growth
vivek7405 Aug 6, 2026
c61cec3
test: point the back-scroll e2e back at the gallery
vivek7405 Aug 6, 2026
ba79b92
test: keep the anchor-restore case inside its test-runner session
vivek7405 Aug 6, 2026
41b251d
fix: floor the restore window so a fast revalidation cannot close it …
vivek7405 Aug 6, 2026
a43ef15
test: prove the restore window is temporary and narrowly scoped
vivek7405 Aug 6, 2026
1328836
fix: leave a clamped restore alone rather than freezing it
vivek7405 Aug 6, 2026
7b17bcf
fix: close the restore window when a new navigation starts
vivek7405 Aug 6, 2026
6f143c2
fix: correct a stale comment and cover the nav close at the unit layer
vivek7405 Aug 6, 2026
e5d05f9
test: cover the submission call site, and finish the docs sentence
vivek7405 Aug 6, 2026
faac312
test: drop a wrong explanation and release the form in finally
vivek7405 Aug 6, 2026
5d1b228
fix: chase a clamped restore to the exact recorded offset
vivek7405 Aug 6, 2026
99e068f
test: drive the clamped-restore growth from the test, not a timer
vivek7405 Aug 6, 2026
0e586f3
fix: order the restore correctly under a view transition
vivek7405 Aug 6, 2026
3c0d656
fix: guard the deferred restore, bound its chase, and keep the CI log
vivek7405 Aug 6, 2026
6b7e362
fix: key the deferred restore to its own supersede, not the nav token
vivek7405 Aug 6, 2026
7f336a5
test: pin the restore keying, and make the code match its own rule
vivek7405 Aug 6, 2026
8af4eb8
fix: exempt a frame nav from all three cancels, not just the counter
vivek7405 Aug 6, 2026
f9d937a
docs: record the frame carve-out, and prove two cases are not vacuous
vivek7405 Aug 6, 2026
ed59998
fix: state the frame carve-out correctly, and pin its submission half
vivek7405 Aug 6, 2026
0d7d707
fix: hold the offset the catch-up landed on, and state its cost corre…
vivek7405 Aug 6, 2026
5ed7c5e
fix: share one deadline with the landed window, and describe it truth…
vivek7405 Aug 7, 2026
180419d
test: discriminate the shared deadline, and drop a clause it falsified
vivek7405 Aug 7, 2026
a42f6a6
test: give the deadline case real margin on both sides
vivek7405 Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .agents/skills/webjs/references/client-router-and-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ revalidate(); // clear the entire snapshot cache

The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restores instantly, then refetches in the background. Call `revalidate(path)` after a server action mutates data a cached page depends on. Wire bytes are minimized by an `X-Webjs-Have` header, so the server returns only the divergent layout fragment. Concurrent navigations abort the prior in-flight fetch, and scroll is restored on Back/Forward.

**Back/Forward scroll restore vs late layout growth.** The router SUPPRESSES the browser's scroll anchoring (`overflow-anchor`) for the duration of a Back/Forward restore, then puts it back. The saved offset was recorded against the page at its SETTLED height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser treats that late growth as content appearing above a reader and adds it to the offset the router just replayed, so the reader lands BELOW where they left (the reported case was 763px, exactly the height a page gained after its swap). What follows for an app:

- **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code.
- **An app that sets `overflow-anchor` on `<html>` itself sees it overridden during a restore and restored afterwards**, including a value set inline by your own script. Setting it in a stylesheet is unaffected between restores. Nothing else on the page is touched, and the router never sets `overflow-anchor` anywhere but the root element.
- **A new PAGE navigation ends an open window.** The window outlives its own restore on purpose (a floor, then a ceiling), so a page navigation or a page-level form submission starting inside that span closes it first, and reopens only if it earns one. Otherwise a second Back, or a click, would inherit suppressed anchoring on a page it was never meant for. A FRAME-TARGETED navigation or submission is the exception, on exactly the rule that decides frame targeting everywhere else (the enclosing frame, an explicit `data-webjs-frame="<id>"` from anywhere, or the frame's own `src`; `_top` and an unresolvable id are page navigations and do close the window). It swaps one region and leaves the page, and so the restored offset, intact, so it leaves the restore running. Closing there would hand anchoring back mid-restore and bring the double count straight back, and it needs no user input to happen, since a component upgrading in the just-restored page can drive a frame on its own.
- **Suppression is conditional on the offset being reachable, and follows the chase onto it.** A page that has not grown yet can be too short to scroll that far, so the browser clamps to its current maximum. There the shortfall IS the growth still to come, and anchoring adding it is what carries the reader back down, so the router leaves anchoring alone. Suppressing in that case would freeze the clamp and strand the reader a full page-growth above where they left, which is this same defect pointing the other way. That case is not left to anchoring alone, though, because anchoring adds the FULL growth however far short the clamp fell, so by itself it only lands a reader who left at the very bottom. The router also CHASES the recorded offset there, re-asserting it the moment the page is tall enough to hold it, and then stopping. That is the one place the router writes scroll after the initial restore, it is scoped to the clamped path, and it stops on the same inputs that close a suppression window. It is also time-boxed, and more tightly than the window a landed restore gets: a few hundred milliseconds from the RESTORE, not the 2s ceiling, and the suppression it installs on landing shares that same deadline rather than starting a fresh one. That bound is what keeps it from moving a reader who has landed and started reading, since such a reader generates no input to cancel it and the chase cannot tell the restore settling apart from any other growth. Anchoring is left on only WHILE the offset is out of reach, which is the part that heals the clamp. The moment the chase lands on the offset it suppresses anchoring too, because the growth that made the offset reachable is rarely all of it and every later stage would otherwise be added on top of what was just written. Both halves end together on the bound. After it, the router writes no more scroll and anchoring is back on, so a component that reaches its final height later than the bound (a chart, an embed measured from its content) has its growth added and the reader drifts BELOW the offset, the same way they would without this fix at all, rather than sitting at the clamp.
- **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), so a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Absent that it closes once the restore is over, which is the LATER of the restore's own background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor is load-bearing: waiting on the revalidation alone ties the window's length to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it early and the reader would land low again. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over.

Components that reach their final size only after they render (a chart, a media embed with no intrinsic dimensions, anything sized from measured content) are exactly the shape that triggers this, and they need no special handling: give them a placeholder height where you can, and let the router own the restore.

**Error recovery.** A 2xx/3xx swap applies in place, and an HTML error body of any status (a 422 re-rendered form, a 5xx error page) is ALSO applied in place with no reload. For a non-HTML error or a transport failure the router dispatches a cancelable `webjs:navigation-error` on `document` (detail `{ url, status, error }`). Call `preventDefault()` to own recovery, otherwise the router renders a minimal in-place alert into the layout slot.

```ts
Expand Down
6 changes: 6 additions & 0 deletions .agents/skills/webjs/references/muscle-memory-gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middlewa

Navigation is automatic. The client router auto-enables when `@webjsdev/core` loads (any page with a component), so a plain `<a href>` gets soft navigation for free. There is no `<Link>` to import and no `useRouter`. For programmatic navigation import `navigate()` / `revalidate()` from `@webjsdev/core`. There is no `next/image`, `next/font`, `next/script`, or `next/dynamic`. WebJs is no-build: use a plain `<img>`, a `<link>` / `@font-face`, a component's `static lazy = true` for viewport lazy-loading, and a dynamic `import()` where code should load lazily.

### No `<ScrollRestoration>`, and no scroll restore of your own

Remix ships a `<ScrollRestoration />` component, Next has a `scrollRestoration` flag and a pile of community `useEffect` + `scrollTo` recipes, and every one of them is a thing to NOT port. WebJs restores scroll on Back/Forward automatically: the router sets `history.scrollRestoration = 'manual'` on boot and is the sole authority on scroll for the whole navigation. There is no component to render and no option to enable. An app-level `popstate` listener that calls `scrollTo`, a remembered offset in `sessionStorage`, or a `scrollIntoView` on a saved element all race the router and win sometimes, which is worse than losing consistently.

This includes the case that most tempts a hand-rolled fix: Back landing BELOW where the reader left, on a page whose components size themselves after they render. The router already handles it, by suppressing the browser's scroll anchoring across the restore so late growth above the viewport is not added to the offset it just replayed (see `client-router-and-streaming.md`). If a restore still lands wrong, report it rather than patching around it in app code.

### Server-only code: the `.server.ts` boundary, not a `server-only` package

Next poisons a client-imported module with the `server-only` package. WebJs uses the file extension: `*.server.ts` is the path-level boundary (the file router refuses to serve the source). A `'use server'` file's exports are RPC-callable; a `.server.ts` file WITHOUT `'use server'` is a server-only utility whose browser import throws at load. Reach a no-`'use server'` utility through a `'use server'` action, `route.ts`, or `middleware`, never by direct import into a shipping page or component.
Expand Down
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,49 @@ jobs:
env:
WEBJS_E2E: '1'
run: node --test test/e2e/dev-overlay-nav.test.mjs
# Form-submission, concurrent-nav and scroll-restoration e2e (#1310). This
# one runs against the WEBSITE rather than the blog, because the scroll
# case needs a page whose content settles taller after the swap, which is
# what a /ui/<name> gallery page does and a blog page does not. It was
# outside CI entirely until now, so the one assertion covering a Back
# restore on a real growing page never ran on a PR.
#
# `webjs dev` runs the website's own `webjs.dev.before` tasks (the registry
# copy and the Tailwind build), so no extra setup step is needed here.
- name: Run form-submission + scroll-restoration e2e (#1310)
env:
WEBJS_E2E: '1'
run: |
# Redirected to a FILE, not left on the step's stdout. A background
# child that keeps the log pipe open outlives the script and stalls
# the step: run locally, the identical script hung for ten minutes
# after the tests had already passed, with the server still listening.
setsid npm run dev --workspace=@webjsdev/website > /tmp/website-dev.log 2>&1 &
server_pid=$!
# Stop the server however this step ends. `trap ... EXIT` keeps the
# test's exit code, which a trailing `kill` would not: the step runs
# under `bash -e`, so a failing test aborts the script before any
# cleanup line placed after it could run. The pid is captured into a
# variable rather than read as `$!` inside the trap, which would
# resolve at FIRE time against whatever job was most recent then.
# `setsid` puts the server in its own process GROUP and the trap
# signals the whole group. `webjs dev` spawns a watcher which spawns
# the listener, so killing only the npm pid leaves the port held; that
# was measured, the step exited cleanly and :5001 stayed up.
# On any failure the server log goes BACK to the step log. This is a
# required check and the server runs the website's own before-tasks
# (registry copy, Tailwind build), so a boot failure, a build failure
# or a 500 on /ui/button must not fail the check with its cause in a
# file nobody reads. `$?` is captured first, since the trap body would
# otherwise clobber it.
trap 'rc=$?; if [ "$rc" -ne 0 ]; then echo "--- website dev server log ---"; tail -n 200 /tmp/website-dev.log || true; fi; kill -- -"$server_pid" 2>/dev/null || true; exit "$rc"' EXIT
for i in $(seq 1 60); do
curl -sf -o /dev/null http://localhost:5001/ui/button && break
sleep 2
done
curl -sf -o /dev/null http://localhost:5001/ui/button \
|| { echo "website dev server never came up"; exit 1; }
node --test test/e2e/form-submission-and-race.test.mjs
# Touch-emulation e2e for interactive Tier-2 ui components (#745/#747):
# boots the site serving the gallery and taps hover-card / dropdown-submenu / sonner
# under a Chromium iPhone context (faithful touch events, no real device).
Expand Down
Loading