diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ce55ad..c101254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [3.2.12] - 2026-09-03 + +- Set `args.page` when a navigation reuses an in-flight preload +- Fall back to a fresh fetch when an in-flight preload fails instead of aborting the visit + ## [3.2.11] - 2024-11-20 - Return cached data or pending promise for `swup.preload()` if already preloaded or preloading @@ -129,6 +134,7 @@ - Initial release +[3.2.12]: https://github.com/swup/preload-plugin/releases/tag/3.2.12 [3.2.11]: https://github.com/swup/preload-plugin/releases/tag/3.2.11 [3.2.10]: https://github.com/swup/preload-plugin/releases/tag/3.2.10 [3.2.9]: https://github.com/swup/preload-plugin/releases/tag/3.2.9 diff --git a/src/index.ts b/src/index.ts index 76ea559..f677375 100755 --- a/src/index.ts +++ b/src/index.ts @@ -181,12 +181,20 @@ export default class SwupPreloadPlugin extends Plugin { /** * Before core page load: return existing preload promise if available. */ - protected onPageLoad: HookDefaultHandler<'page:load'> = (visit, args, defaultHandler) => { + protected onPageLoad: HookDefaultHandler<'page:load'> = async (visit, args, defaultHandler) => { const { url } = visit.to; - if (url && this.preloadPromises.has(url)) { - return this.preloadPromises.get(url) as Promise; + + const preloadPromise = url ? this.preloadPromises.get(url) : undefined; + + // A preload can fail and end up with no page to reuse + const page = (await preloadPromise?.catch(() => undefined)) ?? null; + if (!page) { + return defaultHandler!(visit, args); } - return defaultHandler!(visit, args); + + args.page = page; + args.cache = false; + return page; }; /** diff --git a/tests/functional/inc/commands.ts b/tests/functional/inc/commands.ts index 8611207..3205f71 100644 --- a/tests/functional/inc/commands.ts +++ b/tests/functional/inc/commands.ts @@ -16,6 +16,20 @@ export function sleep(timeout = 0): Promise { return new Promise((resolve) => setTimeout(() => resolve(undefined), timeout)); } +function documentOrigin(page: Page) { + return page.evaluate(() => Math.floor(window.performance.timeOrigin)); +} + +export async function expectNoPageReload( + page: Page, + action: (page: Page) => Promise | unknown +) { + const before = await documentOrigin(page); + await action(page); + await sleep(500); + expect(await documentOrigin(page)).toEqual(before); +} + export async function clickOnLink(page: Page, url: string, options?: Parameters[1]) { await page.click(`a[href="${url}"]`, options); await expectToBeAt(page, url); diff --git a/tests/functional/preload-plugin.spec.ts b/tests/functional/preload-plugin.spec.ts index d750634..bb20c76 100644 --- a/tests/functional/preload-plugin.spec.ts +++ b/tests/functional/preload-plugin.spec.ts @@ -5,6 +5,7 @@ import { clickOnLink, waitForSwup, navigateWithSwup, + expectNoPageReload, expectSwupToHaveCacheEntry, expectSwupNotToHaveCacheEntry, expectSwupToHaveCacheEntries, @@ -282,3 +283,87 @@ test.describe('ignores external origins', () => { expect(urls).toEqual(['/page-1.html', '/page-2.html']); }); }); + +test.describe('page:load args', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/page-1.html'); + await waitForSwup(page); + await page.evaluate(() => { + window._swup.hooks.on('page:load', (visit, args) => { + window.data = { page: args.page, cache: args.cache }; + }); + }); + }); + + test('provides page data when reusing an in-flight preload', async ({ page }) => { + let releasePreload!: () => void; + let markRequestStarted!: () => void; + const requestStarted = new Promise((resolve) => (markRequestStarted = resolve)); + const release = new Promise((resolve) => (releasePreload = resolve)); + + await page.route('**/page-2.html', async (route) => { + markRequestStarted(); + await release; + await route.continue(); + }); + + await page.evaluate(() => { + window._swup.preload!('/page-2.html'); + }); + await requestStarted; + await clickOnLink(page, '/page-2.html'); + await sleep(500); + releasePreload(); + + await expect(page.locator('h1')).toHaveText('Page 2', { timeout: 5000 }); + + const pageArgs = await page.evaluate(() => window.data); + expect(pageArgs.page).toBeTruthy(); + expect(pageArgs.page.url).toContain('/page-2.html'); + expect(pageArgs.page.html).toContain('Page 2'); + expect(pageArgs.cache).toBe(false); + }); + + test('recovers when a reused in-flight preload fails', async ({ page }) => { + let requestCount = 0; + let releasePreload!: () => void; + let markRequestStarted!: () => void; + const requestStarted = new Promise((resolve) => (markRequestStarted = resolve)); + const release = new Promise((resolve) => (releasePreload = resolve)); + + await page.route('**/page-2.html', async (route) => { + requestCount++; + if (requestCount === 1) { + markRequestStarted(); + await release; + await route.abort(); + } else { + await route.continue(); + } + }); + + await expectNoPageReload(page, async () => { + await page.evaluate(() => { + window._swup.preload!('/page-2.html'); + }); + await requestStarted; + await clickOnLink(page, '/page-2.html'); + await sleep(500); + releasePreload(); + + await expect(page.locator('h1')).toHaveText('Page 2', { timeout: 5000 }); + }); + + const pageArgs = await page.evaluate(() => window.data); + expect(pageArgs?.page).toBeTruthy(); + }); + + test('provides page data when no preload is in flight', async ({ page }) => { + await navigateWithSwup(page, '/page-2.html'); + await expect(page.locator('h1')).toHaveText('Page 2'); + + const pageArgs = await page.evaluate(() => window.data); + expect(pageArgs?.page).toBeTruthy(); + expect(pageArgs.page.url).toContain('/page-2.html'); + }); +});