Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 18 additions & 2 deletions src/javascript/content-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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({
Expand Down
5 changes: 5 additions & 0 deletions src/javascript/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>}): void;
}
Expand Down
45 changes: 45 additions & 0 deletions test/e2e/content-loading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<number> {
// 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.
*/
Expand Down