diff --git a/src/javascript/content-loader.ts b/src/javascript/content-loader.ts index 29be7a46..53d94937 100644 --- a/src/javascript/content-loader.ts +++ b/src/javascript/content-loader.ts @@ -136,6 +136,20 @@ export const init = () => { if (!self.navigation) return; self.navigation.addEventListener('navigate', (event: NavigateEvent) => { + // Don't intercept navigations that the browser says can't be + // intercepted (e.g. cross-origin or cross-document traversals). + if (!event.canIntercept) return; + + // Don't intercept same-page fragment navigations; let the browser + // handle scrolling to the target element. + if (event.hashChange) return; + + // Don't intercept navigations that will trigger a download. + if (event.downloadRequest !== null) return; + + // Don't intercept form submissions. + if (event.formData) return; + const url = new URL(event.destination.url); // Don't intercept cross-origin navigations. @@ -144,8 +158,10 @@ export const init = () => { // Don't navigate is cases where `isLoaderDisabled` is `true`. if (isLoaderDisabled) return; - // Ignore navigations to resources. - if (url.pathname.match(/\.(png|svg|webp)$/)) return; + // Only intercept navigations to pages, which all have directory-style + // paths ending in a slash. Anything else (images, feeds, and other + // resource files) falls back to a regular full-page navigation. + if (!url.pathname.endsWith('/')) return; // Store the current scroll position in the Navigation state. self.navigation.updateCurrentEntry({ diff --git a/src/javascript/types.d.ts b/src/javascript/types.d.ts index 81be83dc..6566f6de 100644 --- a/src/javascript/types.d.ts +++ b/src/javascript/types.d.ts @@ -21,8 +21,13 @@ declare global { updateCurrentEntry(options: {state?: any}): void; } + // https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-navigateevent-interface interface NavigateEvent { + canIntercept: boolean; destination: NavigationDestination; + downloadRequest: string | null; + formData: FormData | null; + hashChange: boolean; preventDefault(): void; intercept(options: {handler: () => void | Promise}): void; } diff --git a/test/e2e/content-loading.ts b/test/e2e/content-loading.ts index cd2a0776..36075ca8 100644 --- a/test/e2e/content-loading.ts +++ b/test/e2e/content-loading.ts @@ -172,6 +172,38 @@ describe('The content loader', async () => { await assertIsInitialPageLoad(); }); + it('should not re-fetch content for same-page fragment navigations', async () => { + // Navigates to an article (via SPA load) with linkable headings. + const articleLink = await $(`a[href="${articles[0]!.path}"]`); + await articleLink.click(); + + await browser.waitUntil(async () => { + const urlPath = await getUrlPath(); + return urlPath == articles[0]!.path; + }); + + const headingAnchor = await $('.LinkableHeading-anchor'); + await headingAnchor.waitForExist(); + + const partialFetchCount = await getPartialFetchCount(); + + // Clicks a heading anchor, triggering a same-page fragment navigation. + await headingAnchor.click(); + + await browser.waitUntil(async () => { + const url = new URL(await browser.getUrl()); + return url.hash.length > 0; + }); + + // Give any (incorrectly issued) fetch time to show up in the + // page's resource timing entries before asserting. + await browser.pause(500); + + assert.strictEqual(await getPartialFetchCount(), partialFetchCount); + + await assertIsInitialPageLoad(); + }); + it('should show an error if the content cannot be loaded', async () => { // Adds a hash fragments to an article URL. // Don't use an arrow function since this is eval'ed in test browsers. @@ -235,6 +267,19 @@ async function assertIsInitialPageLoad() { assert(isInitialPageLoad); } +/** + * Gets the number of page-partial fetches recorded in the page's + * resource timing entries. + */ +async function getPartialFetchCount(): Promise { + // Don't use an arrow function since this is eval'ed in test browsers. + return await browser.execute(function () { + return performance.getEntriesByType('resource').filter(function (entry) { + return entry.name.includes('index.prtl'); + }).length; + }); +} + /** * Gets the URL path for the given page. */