Skip to content

Commit 9af54bf

Browse files
committed
fix(router): skip non-HTML responses and extensions; document data-no-router
Three gaps in the same-origin interception logic that produced a broken/hung/blank page: 1. Non-HTML extensions — /foo.pdf, /data.zip, /feed.xml, /posts.json, /avatar.png, /clip.mp4. The router would fetch-and-swap binary or non-HTML bytes as if they were a page. Now onClick() short-circuits when the pathname matches a conservative NON_HTML_EXTENSIONS regex covering documents, archives, feeds, images, media, fonts. 2. JSON / SSE / PDF served to same-origin paths without a telltale extension — /api/posts, /events, /docs/report. After the fetch, the router now rejects any response whose Content-Type isn't text/html and falls back to location.href. SSE responses (text/event-stream) no longer hang on resp.text(). 3. Auth flows (/logout, /auth/google) — documented data-no-router as the explicit escape hatch for semantics (module-level state must be wiped by a full reload, which SPA nav leaves behind). AGENTS.md Client Router section + docs/client-router now call this out. New internal export `_isNonHtmlPath(pathname)` so the extension list can be unit-tested. Eight regression tests added to test/router-client.test.js covering the extension predicate and Content-Type guard (JSON, SSE, PDF, missing CT, and a negative control for text/html). Tests: 246 → 255 unit, 21 browser.
1 parent 490daed commit 9af54bf

4 files changed

Lines changed: 225 additions & 14 deletions

File tree

AGENTS.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1469,9 +1469,21 @@ await navigate('/login', { replace: true }); // replace history entry
14691469
<a href="/legacy" data-no-router>Full reload</a>
14701470
```
14711471
1472+
**Use `data-no-router` for:**
1473+
- **Auth flows** — `/logout`, `/auth/google`, OAuth redirect chains. A full reload wipes in-memory module state (cached user data, auth tokens), which SPA navigation leaves behind. Module state surviving a "logout" is a real bug class.
1474+
- **Print views / embed pages** — anywhere you want a clean-slate render without the existing layout.
1475+
- **Experimental routes** backed by a different client runtime that needs a full boot.
1476+
1477+
**What the router auto-skips** (no `data-no-router` needed):
1478+
- Links with `download`, `target` other than `_self`, or a modifier-key click.
1479+
- Cross-origin hrefs.
1480+
- Pure hash fragments on the same page.
1481+
- Hrefs ending in non-HTML extensions (`.pdf`, `.zip`, `.json`, `.xml`, images, media, archives, documents) — the browser handles them natively.
1482+
- Responses whose `Content-Type` isn't `text/html` — falls back to a full navigation so JSON APIs, SSE streams, feeds, and mis-served downloads behave correctly.
1483+
14721484
**Loading indicator:** `<html>` gets `data-navigating` attribute during fetch — use CSS to show a progress bar.
14731485
1474-
**When to use:** always — it's enabled by default in the scaffold layout. **When NOT to use:** links with `download`, `target="_blank"`, external origins — these are automatically skipped.
1486+
**When to use:** always — it's enabled by default in the scaffold layout.
14751487
14761488
### Raw-text templates
14771489

docs/app/docs/client-router/page.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,25 @@ export default function ClientRouter() {
1010
<h2>When to use</h2>
1111
<p>It's enabled automatically when you import <code>webjs/client-router</code> in your layout (the scaffold does this for you). You don't need to do anything — every <code>&lt;a&gt;</code> link in your app becomes a client-side navigation.</p>
1212
13-
<h2>When NOT to use</h2>
13+
<h2>When the router auto-skips a link</h2>
14+
<p>These are handled natively by the browser — no fetch, no swap:</p>
1415
<ul>
15-
<li>For links to external sites — they're ignored automatically (different origin).</li>
16-
<li>For file downloads — links with <code>download</code> attribute are ignored.</li>
17-
<li>For links that should force a full reload — add <code>data-no-router</code> to the <code>&lt;a&gt;</code> tag.</li>
16+
<li>Cross-origin hrefs.</li>
17+
<li>Links with a <code>download</code> attribute, a <code>target</code> other than <code>_self</code>, or clicked with a modifier key (⌘/Ctrl/Shift/Alt).</li>
18+
<li>Pure hash fragments on the same page (lets the browser jump to the anchor).</li>
19+
<li>Hrefs whose path ends in a non-HTML extension: <code>.pdf</code>, <code>.zip</code>, <code>.json</code>, <code>.xml</code>, images, media, archives, documents. The browser opens them in a viewer, triggers a download, or renders the feed directly.</li>
20+
<li>Responses whose <code>Content-Type</code> isn't <code>text/html</code> (JSON APIs, SSE streams, mis-served downloads). The router notices after the fetch and falls back to a full navigation.</li>
1821
</ul>
1922
23+
<h2>Explicit opt-out with <code>data-no-router</code></h2>
24+
<p>Add <code>data-no-router</code> to force a full page navigation for links the router would otherwise intercept. Use this for:</p>
25+
<ul>
26+
<li><strong>Auth flows</strong><code>/logout</code>, <code>/auth/google</code>, OAuth redirect chains. A full reload wipes in-memory module state (cached user data, auth tokens); SPA navigation leaves it behind.</li>
27+
<li><strong>Print views / embed pages</strong> — anywhere you want a clean-slate render without the existing layout.</li>
28+
<li><strong>Experimental routes</strong> backed by a different client runtime that needs a full boot.</li>
29+
</ul>
30+
<pre>&lt;a href="/logout" data-no-router&gt;Log out&lt;/a&gt;</pre>
31+
2032
<h2>How it works</h2>
2133
<ol>
2234
<li>Intercepts clicks on <code>&lt;a&gt;</code> tags (same origin, no modifier keys, no <code>target</code>, no <code>download</code>). Works across shadow DOM boundaries via <code>composedPath()</code>.</li>
@@ -41,12 +53,7 @@ await navigate('/about');
4153
// Replace current history entry
4254
await navigate('/login', { replace: true });</pre>
4355
44-
<h2>Opting out</h2>
45-
<p>Add <code>data-no-router</code> to any link to force a full page navigation:</p>
46-
47-
<pre>&lt;a href="/legacy-page" data-no-router&gt;Full reload&lt;/a&gt;</pre>
48-
49-
<p>To disable the router entirely:</p>
56+
<h2>Disabling the router entirely</h2>
5057
<pre>import { disableClientRouter } from 'webjs/client-router';
5158
disableClientRouter();</pre>
5259

packages/core/src/router-client.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,17 @@ enableClientRouter();
112112
* Internal
113113
* ==================================================================== */
114114

115+
/**
116+
* Pathnames with these extensions are never HTML pages. The client router
117+
* should let the browser handle them natively — so PDFs open in a viewer,
118+
* JSON/XML feeds render as text, archives trigger the download prompt,
119+
* and images open in a new tab. Intercepting would fetch-and-swap binary
120+
* bytes as HTML, producing a blank/garbled page with no way to recover.
121+
*
122+
* This is intentionally conservative — anything not listed still routes.
123+
*/
124+
const NON_HTML_EXTENSIONS = /\.(?:pdf|zip|tar|gz|7z|rar|dmg|exe|msi|deb|rpm|apk|ipa|xlsx?|docx?|pptx?|csv|odt|ods|odp|rtf|epub|mobi|xml|json|rss|atom|txt|md|wasm|mp3|mp4|mov|avi|webm|ogg|flac|wav|m4a|m4v|mkv|png|jpe?g|gif|webp|avif|bmp|ico|svg|tiff?|heic)$/i;
125+
115126
/** @param {MouseEvent} e */
116127
function onClick(e) {
117128
if (!enabled) return;
@@ -134,6 +145,9 @@ function onClick(e) {
134145
if (url.origin !== location.origin) return;
135146
// Skip hash-only changes on the same page.
136147
if (url.pathname === location.pathname && url.search === location.search && url.hash) return;
148+
// Skip paths whose extension clearly isn't an HTML page — let the
149+
// browser handle downloads / feeds / images natively.
150+
if (NON_HTML_EXTENSIONS.test(url.pathname)) return;
137151

138152
e.preventDefault();
139153
performNavigation(href, false);
@@ -180,6 +194,19 @@ async function performNavigation(href, isPopState) {
180194
location.href = href;
181195
return;
182196
}
197+
// Content-Type guard — if the server didn't return HTML, the router
198+
// can't swap it safely. Common mismatches:
199+
// • API routes: application/json, application/vnd.api+json
200+
// • Feeds: application/xml, text/xml, application/rss+xml
201+
// • Downloads: application/pdf, application/zip, etc.
202+
// • SSE: text/event-stream (would hang on resp.text())
203+
// Fall back to a full navigation so the browser handles the MIME
204+
// natively (viewer, download prompt, JSON tree, etc.).
205+
const ctype = resp.headers.get('content-type') || '';
206+
if (!/^text\/html\b/i.test(ctype)) {
207+
location.href = href;
208+
return;
209+
}
183210
const html = await resp.text();
184211
const doc = parseHTML(html);
185212
if (!doc) {
@@ -472,3 +499,15 @@ export {
472499
addNewHeadElements as _addNewHeadElements,
473500
mergeHead as _mergeHead,
474501
};
502+
503+
/**
504+
* Predicate used by the onClick handler to decide whether a same-origin
505+
* href should bypass the router (let the browser handle it natively).
506+
* Exposed for unit testing.
507+
*
508+
* @param {string} pathname
509+
* @returns {boolean}
510+
*/
511+
export function _isNonHtmlPath(pathname) {
512+
return NON_HTML_EXTENSIONS.test(pathname);
513+
}

test/router-client.test.js

Lines changed: 156 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { test, before } from 'node:test';
1010
import assert from 'node:assert/strict';
1111
import { parseHTML } from 'linkedom';
1212

13-
let _find, _addNewHead, _merge;
13+
let _find, _addNewHead, _merge, _isNonHtmlPath, navigate;
1414

1515
before(async () => {
1616
const { window } = parseHTML('<!doctype html><html><head></head><body></body></html>');
@@ -31,8 +31,13 @@ before(async () => {
3131
globalThis.CustomEvent = window.CustomEvent;
3232
globalThis.DOMParser = window.DOMParser;
3333

34-
({ _findLayoutShell: _find, _addNewHeadElements: _addNewHead, _mergeHead: _merge } =
35-
await import('../packages/core/src/router-client.js'));
34+
({
35+
_findLayoutShell: _find,
36+
_addNewHeadElements: _addNewHead,
37+
_mergeHead: _merge,
38+
_isNonHtmlPath,
39+
navigate,
40+
} = await import('../packages/core/src/router-client.js'));
3641
});
3742

3843
test('findLayoutShell: detects data-layout element on direct body child', () => {
@@ -177,3 +182,151 @@ test('mergeHead: re-creates script elements so they execute', () => {
177182
assert.notStrictEqual(added, s, 'script re-created so browser executes it');
178183
assert.equal(added.getAttribute('type'), 'module');
179184
});
185+
186+
/* ------------ extension-based skip (pre-emptive) ------------ */
187+
188+
test('isNonHtmlPath: skips downloads and documents', () => {
189+
assert.equal(_isNonHtmlPath('/exports/report.pdf'), true);
190+
assert.equal(_isNonHtmlPath('/files/archive.zip'), true);
191+
assert.equal(_isNonHtmlPath('/data/records.csv'), true);
192+
assert.equal(_isNonHtmlPath('/Download.DOCX'), true, 'case-insensitive');
193+
});
194+
195+
test('isNonHtmlPath: skips feeds and api-like extensions', () => {
196+
assert.equal(_isNonHtmlPath('/feed.xml'), true);
197+
assert.equal(_isNonHtmlPath('/feed.rss'), true);
198+
assert.equal(_isNonHtmlPath('/posts.json'), true);
199+
assert.equal(_isNonHtmlPath('/robots.txt'), true);
200+
});
201+
202+
test('isNonHtmlPath: skips images and media', () => {
203+
assert.equal(_isNonHtmlPath('/avatar.png'), true);
204+
assert.equal(_isNonHtmlPath('/logo.svg'), true);
205+
assert.equal(_isNonHtmlPath('/hero.webp'), true);
206+
assert.equal(_isNonHtmlPath('/clip.mp4'), true);
207+
assert.equal(_isNonHtmlPath('/theme.mp3'), true);
208+
});
209+
210+
test('isNonHtmlPath: does NOT skip normal page paths', () => {
211+
assert.equal(_isNonHtmlPath('/'), false);
212+
assert.equal(_isNonHtmlPath('/blog/post-slug'), false);
213+
assert.equal(_isNonHtmlPath('/dashboard'), false);
214+
// A route that happens to include a dot in a segment but no extension.
215+
assert.equal(_isNonHtmlPath('/users/john.smith/profile'), false);
216+
});
217+
218+
/* ------------ Content-Type guard on navigate() ------------ */
219+
220+
function installNavigationMocks({ contentType, body = '', ok = true }) {
221+
const originalFetch = globalThis.fetch;
222+
const originalLocation = globalThis.location;
223+
const originalHistory = globalThis.history;
224+
const originalScrollTo = globalThis.scrollTo;
225+
/** @type {{ href: string | null, assigns: string[] }} */
226+
const redirect = { href: null, assigns: [] };
227+
228+
globalThis.fetch = async () => ({
229+
ok,
230+
status: ok ? 200 : 500,
231+
headers: { get: (k) => (k.toLowerCase() === 'content-type' ? contentType : null) },
232+
text: async () => body,
233+
});
234+
235+
// Replace location with a spy that captures href assignments.
236+
globalThis.location = /** @type any */ ({
237+
origin: 'http://localhost',
238+
href: 'http://localhost/',
239+
get pathname() { return '/'; },
240+
get search() { return ''; },
241+
});
242+
Object.defineProperty(globalThis.location, 'href', {
243+
configurable: true,
244+
get() { return 'http://localhost/'; },
245+
set(v) { redirect.href = v; redirect.assigns.push(v); },
246+
});
247+
248+
// Stubs for APIs the happy-path swap calls — without them the swap
249+
// throws, the catch-all falls back to location.href, and we can't
250+
// distinguish "Content-Type guard fired" from "environment is missing
251+
// browser APIs".
252+
globalThis.history = /** @type any */ ({ pushState: () => {}, replaceState: () => {} });
253+
globalThis.scrollTo = /** @type any */ (() => {});
254+
255+
return {
256+
redirect,
257+
restore() {
258+
globalThis.fetch = originalFetch;
259+
globalThis.location = originalLocation;
260+
globalThis.history = originalHistory;
261+
globalThis.scrollTo = originalScrollTo;
262+
},
263+
};
264+
}
265+
266+
test('navigate: JSON response triggers full-page fallback (no DOM swap)', async () => {
267+
const { redirect, restore } = installNavigationMocks({
268+
contentType: 'application/json; charset=utf-8',
269+
body: '{"posts":[]}',
270+
});
271+
try {
272+
await navigate('http://localhost/api/posts');
273+
assert.equal(redirect.href, 'http://localhost/api/posts',
274+
'JSON response should trigger location.href assignment');
275+
} finally {
276+
restore();
277+
}
278+
});
279+
280+
test('navigate: text/event-stream triggers full-page fallback', async () => {
281+
const { redirect, restore } = installNavigationMocks({
282+
contentType: 'text/event-stream',
283+
body: '',
284+
});
285+
try {
286+
await navigate('http://localhost/events');
287+
assert.equal(redirect.href, 'http://localhost/events');
288+
} finally {
289+
restore();
290+
}
291+
});
292+
293+
test('navigate: application/pdf triggers full-page fallback', async () => {
294+
const { redirect, restore } = installNavigationMocks({
295+
contentType: 'application/pdf',
296+
body: '%PDF-1.4\n...',
297+
});
298+
try {
299+
await navigate('http://localhost/docs/report');
300+
assert.equal(redirect.href, 'http://localhost/docs/report');
301+
} finally {
302+
restore();
303+
}
304+
});
305+
306+
test('navigate: text/html response proceeds with router swap (no fallback)', async () => {
307+
const { redirect, restore } = installNavigationMocks({
308+
contentType: 'text/html; charset=utf-8',
309+
body: '<!doctype html><html><head><title>ok</title></head><body><div data-layout="x">content</div></body></html>',
310+
});
311+
try {
312+
await navigate('http://localhost/ok');
313+
assert.equal(redirect.href, null,
314+
'text/html response should not trigger location.href fallback');
315+
} finally {
316+
restore();
317+
}
318+
});
319+
320+
test('navigate: response without content-type falls back safely', async () => {
321+
const { redirect, restore } = installNavigationMocks({
322+
contentType: '',
323+
body: '',
324+
});
325+
try {
326+
await navigate('http://localhost/weird');
327+
assert.equal(redirect.href, 'http://localhost/weird',
328+
'missing Content-Type is not assumed to be HTML');
329+
} finally {
330+
restore();
331+
}
332+
});

0 commit comments

Comments
 (0)