the fallback cannot see, so the walk is the only
+ // way to reach it: with `closest()` gone this returns false.
+ //
+ // It is also a real authoring shape rather than a contrivance. Marking one
+ // section of a long form is exactly the case the feature exists for.
+ const f = frameFixture(
+ ''
+ );
+ try {
+ const form = f.get('plain-form');
+ assert.equal(_resolvePreserveScroll(f.get('in-region'), form), true,
+ 'the walk reaches the marked fieldset, which the unmarked form cannot supply');
+ assert.equal(_resolvePreserveScroll(f.get('out-of-region'), form), false,
+ 'a sibling button outside the marked region is unaffected, so the mark is scoped');
+ } finally { f.cleanup(); }
+});
+
/* ====================================================================
* keyOf
* ==================================================================== */
@@ -1555,6 +1697,43 @@ test('navigate: text/html response proceeds with router swap (no fallback)', asy
}
});
+test('navigate: { scroll: false } issues no scroll-to-top, and the default still does (#1436)', async () => {
+ // The programmatic twin of `data-preserve-scroll`, spelled the way Next
+ // spells it. linkedom has no layout, so the offset itself cannot be read
+ // here; what IS observable is whether the router wrote a scroll at all, and
+ // that is the thing the option controls. The reader-visible half is asserted
+ // with a real offset in `browser/nav-preserve-scroll.test.js`.
+ const page =
+ 'ok ' +
+ 'content' +
+ '';
+
+ /** @param {{ scroll?: boolean } | undefined} opts */
+ async function scrollWritesFor(opts) {
+ const { restore } = installNavigationMocks({ contentType: 'text/html; charset=utf-8', body: page });
+ // Installed AFTER the mocks, which stub `scrollTo` as a bare no-op.
+ /** @type {unknown[]} */
+ const writes = [];
+ globalThis.scrollTo = /** @type any */ ((...args) => { writes.push(args[0]); });
+ try {
+ document.body.innerHTML = 'old';
+ await navigate('http://localhost/target', opts);
+ return writes;
+ } finally { restore(); document.body.innerHTML = ''; }
+ }
+
+ assert.deepEqual(await scrollWritesFor({ scroll: false }), [],
+ '{ scroll: false } must suppress the scroll-to-top write');
+ // Read as `opts?.scroll === false`, so an omitted option and an explicit
+ // `true` both keep today's behaviour. Asserting both is what catches a
+ // `!opts?.scroll` regression, which would silently preserve on every
+ // optionless navigate.
+ assert.deepEqual(await scrollWritesFor(undefined), [{ left: 0, top: 0, behavior: 'instant' }],
+ 'an optionless navigate still scrolls to top');
+ assert.deepEqual(await scrollWritesFor({ scroll: true }), [{ left: 0, top: 0, behavior: 'instant' }],
+ 'an explicit { scroll: true } still scrolls to top');
+});
+
test('navigate: response without content-type falls back safely', async () => {
const { redirect, restore } = installNavigationMocks({ contentType: '', body: '' });
try {
diff --git a/test/architecture/barrel-surface.test.mjs b/test/architecture/barrel-surface.test.mjs
index 207464ae2..d77faf43f 100644
--- a/test/architecture/barrel-surface.test.mjs
+++ b/test/architecture/barrel-surface.test.mjs
@@ -2,7 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
const BARREL_FLOORS = [
- { path: '../../packages/core/src/router-client.js', floor: 69 },
+ { path: '../../packages/core/src/router-client.js', floor: 70 },
{ path: '../../packages/core/src/slot.js', floor: 31 },
{ path: '../../packages/server/src/vendor.js', floor: 26 },
{ path: '../../packages/server/src/ssr.js', floor: 21 },
diff --git a/test/types/dts-export-coverage.test.mjs b/test/types/dts-export-coverage.test.mjs
index cd8640d9f..2c9b03f7d 100644
--- a/test/types/dts-export-coverage.test.mjs
+++ b/test/types/dts-export-coverage.test.mjs
@@ -38,10 +38,16 @@ const tscBin = join(ROOT, 'node_modules', 'typescript', 'bin', 'tsc');
// regression), the run FAILS loudly instead of silently checking almost nothing.
// `minNames` is the per-package total of CHECKED export names, which catches the
// failure the entry count cannot see: an entry that still resolves, but to a
-// SMALLER module than intended, quietly shrinking the check. Today's totals are
-// 169 for core (232 runtime names minus the 63 exempt `_` seams) and 146 for
-// server; the floors sit just below. Raising an export count only makes both
-// floors stricter, which is the same rationale recorded on the reverse guard.
+// SMALLER module than intended, quietly shrinking the check. Each floor sits
+// just below its package's real total, and raising an export count only makes
+// the floor stricter, which is the same rationale recorded on the reverse guard.
+//
+// The real totals are deliberately NOT written down here. They move with every
+// export, a frozen copy of them is wrong the next time anyone adds one, and a
+// number in a comment fails nothing when it rots, so nothing catches it. Two of
+// the three that used to sit on this line had already drifted before anyone
+// noticed. Read the current totals off a failure message, or compute them with
+// `entryPairs` + `checkedNames` below, which is what the assertions do.
const PACKAGES = [
{ name: '@webjsdev/core', dir: 'packages/core', minEntries: 12, minNames: 160 },
{ name: '@webjsdev/server', dir: 'packages/server', minEntries: 3, minNames: 140 },
@@ -69,9 +75,11 @@ function entryPairs(pkgDir) {
/**
* Runtime export names the overlay is REQUIRED to declare. A leading `_` marks a
- * test-only seam that is deliberately NOT part of the published API (the
- * `Internal exports for unit testing` block in `src/router-client.js` is 63 such
- * names), so declaring them would publish a test seam as editor autocomplete.
+ * test-only seam that is deliberately NOT part of the published API (the bulk of
+ * them are the `Internal exports for unit testing` block in
+ * `src/router-client.js`), so declaring them would publish a test seam as editor
+ * autocomplete. The count is not stated here on purpose; see the note on
+ * `minNames` above.
*
* The convention is expressed as a RULE rather than an ignore list, because a
* list would grow with every new unit test, get edited on unrelated PRs, and rot
@@ -215,7 +223,8 @@ for (const { name, dir, minEntries, minNames } of PACKAGES) {
assert.ok(
exemptTotal >= 1,
`${name}: no underscore-prefixed export was exempted anywhere, so the ` +
- `test-only-seam rule in checkedNames() is dead code (63 such names exist today)`,
+ `test-only-seam rule in checkedNames() is dead code (it exempted ` +
+ `${exemptTotal} today, and this fires only at 0)`,
);
}
});
diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts
index b34942225..84b4fccc9 100644
--- a/website/app/docs/client-router/page.ts
+++ b/website/app/docs/client-router/page.ts
@@ -202,6 +202,23 @@ revalidate('/products/123');
revalidate();
Mutating form submissions (POST / PUT / PATCH / DELETE) clear the cache automatically on success. You only need revalidate() when the mutation happens via JS / RPC and didn't go through a form.
+
+ A forward navigation scrolls to the top, the way a browser does. data-preserve-scroll is the per-link escape hatch, for a navigation that changes only part of what the reader is looking at: a filter, sort, or tab link whose control sits below the fold, a pager, or a form that re-renders in place with validation errors. WebJs wants it more than most frameworks do, because a searchParams-only navigation already morphs the deepest shared boundary and keeps the hydrated state of every component around it, so the scroll is the only thing such a navigation still throws away.
+ <!-- one link -->
+<a href="?sort=new" data-preserve-scroll>Newest</a>
+
+<!-- or a whole region, resolved with closest() -->
+<nav data-preserve-scroll>
+ <a href="?sort=new">Newest</a>
+ <a href="?sort=top">Top</a>
+ <a href="/" data-preserve-scroll="false">Home</a> <!-- opts back out -->
+</nav>
+
+<!-- forms too: resolved from the submitter, falling back to the form itself -->
+<form method="post" action="${'${saveDraft}'}" data-preserve-scroll>...</form>
+ The attribute resolves through closest(), so one mark on a wrapping element covers every link inside it, and data-preserve-scroll="false" on something nearer opts back out. On a form the lookup starts at the submitter and falls back to the form, so a marked form covers its buttons even when one is attached from elsewhere with form="id" rather than nested inside it. A hash link still scrolls to its anchor, because the reader named a target and a named target beats a blanket preference. It is inert on a frame-targeted link, since a frame swap never writes a scroll to begin with, and inert with JS off, where the link is a plain <a>, so nothing about a page's correctness may depend on it.
+ It carries the reader's current offset onto the destination. It does not restore the offset they once had there, which is a different feature and not one WebJs ships, so this is the wrong tool for a "back to the list" link.
+
Link prefetch (on by default)
Same-origin in-app links are prefetched speculatively, so a click resolves from a warm cache with no round-trip. No attribute is needed; it is on for every internal <a href>, the way Next, Nuxt, and SvelteKit ship auto-prefetch, and the prefetch sends the same headers a real navigation does so the click consumes the fragment.
The default strategy is device-adaptive , because one strategy cannot serve both input modalities. On a hover-capable pointer (mouse / trackpad) the default is intent (warm on hover or focus, a real head-start before the click). On touch the default is viewport (warm as links settle on-screen), because touch has no hover and touchstart fires at tap time, too late to help. The modality is detected with matchMedia('(hover: hover) and (pointer: fine)'), not a user-agent sniff, and a per-link data-prefetch always overrides it.
@@ -250,7 +267,10 @@ revalidate();
await navigate('/about');
// Replace current history entry
-await navigate('/login', { replace: true });
+await navigate('/login', { replace: true });
+
+// Keep the reader's scroll offset (the twin of data-preserve-scroll)
+await navigate('/products?sort=new', { scroll: false });
Refreshing the page you are on
refreshPage() re-renders the current URL on the server and applies it in place, with no page load. It records no history entry and never scrolls, so the reader keeps their place and Back still goes to the previous page.
@@ -275,6 +295,7 @@ await refreshPage('shell');
Print views / embed pages : anywhere you want a clean-slate render without the existing layout.
Experimental routes backed by a different client runtime that needs a full boot.