diff --git a/packages/playground/remote/service-worker.ts b/packages/playground/remote/service-worker.ts index 11dfb0f84e9..eb091942386 100644 --- a/packages/playground/remote/service-worker.ts +++ b/packages/playground/remote/service-worker.ts @@ -6,9 +6,10 @@ * ## Playground must be upgraded as early as possible after a new release * * New service workers call .skipWaiting(), immediately claim all the clients - * that were controlled by the previous service worker and clears the offline - * cache. The claimed clients are not forcibly refreshed. They just continue - * running under the new service worker. + * that were controlled by the previous service worker, and purge excess old + * caches (keeping the most recent one so old tabs can still find their + * hashed assets). The claimed clients are not forcibly refreshed. They just + * continue running under the new service worker. * * Why? * @@ -122,7 +123,7 @@ import { networkFirstFetch, cacheOfflineModeAssetsForCurrentRelease, isCurrentServiceWorkerActive, - purgeEverythingFromPreviousRelease, + purgeExcessOldCaches, shouldCacheUrl, } from './src/lib/offline-mode-cache'; @@ -191,7 +192,7 @@ self.addEventListener('activate', function (event) { await self.clients.claim(); if (shouldCacheUrl(new URL(location.href))) { - await purgeEverythingFromPreviousRelease(); + await purgeExcessOldCaches(); cacheOfflineModeAssetsForCurrentRelease(); } } diff --git a/packages/playground/remote/src/lib/offline-mode-cache.ts b/packages/playground/remote/src/lib/offline-mode-cache.ts index a828dba383e..86413e54f14 100644 --- a/packages/playground/remote/src/lib/offline-mode-cache.ts +++ b/packages/playground/remote/src/lib/offline-mode-cache.ts @@ -18,6 +18,14 @@ export async function cacheFirstFetch(request: Request): Promise { return cachedResponse; } + // After a service worker update, old tabs may still reference + // hashed asset URLs from the previous build. Search older + // caches before going to the network so those tabs don't 404. + const previousVersionResponse = await matchInPreviousVersionCaches(request); + if (previousVersionResponse) { + return previousVersionResponse; + } + /** * Strip the Range header if present. Safari sometimes adds Range headers * to requests, which results in 206 Partial Content responses that can't @@ -153,6 +161,55 @@ export async function purgeEverythingFromPreviousRelease() { return Promise.all(oldKeys.map((key) => caches.delete(key))); } +/** + * Deletes all but the most recent old cache so that tabs still + * running the previous build can find their hashed assets. + * + * Bounds total cache storage to ~3 versions (current + up to 2 old). + * The single surviving old cache will be cleaned up on the *next* + * deploy's activation. + */ +export async function purgeExcessOldCaches() { + const allNames = await caches.keys(); + const oldNames = allNames.filter( + (name) => + name.startsWith(CACHE_NAME_PREFIX) && name !== LATEST_CACHE_NAME + ); + if (oldNames.length <= 1) { + return; + } + // Keep the last entry (typically the most recent old cache) + // and delete the rest. + const toDelete = oldNames.slice(0, -1); + await Promise.all(toDelete.map((name) => caches.delete(name))); +} + +/** + * Searches all previous-version playground caches for a matching + * response. Used as a fallback in `cacheFirstFetch` so that old + * tabs can still load hashed assets after a service worker update. + */ +async function matchInPreviousVersionCaches( + request: Request +): Promise { + const allCacheNames = await caches.keys(); + for (const cacheName of allCacheNames) { + if ( + cacheName.startsWith(CACHE_NAME_PREFIX) && + cacheName !== LATEST_CACHE_NAME + ) { + const cache = await caches.open(cacheName); + const response = await cache.match(request, { + ignoreSearch: true, + }); + if (response) { + return response; + } + } + } + return undefined; +} + /** * Answers whether a given URL has a response in the offline mode cache. * Ignores the search part of the URL by default. @@ -245,9 +302,15 @@ function fetchFresh(resource: RequestInfo | URL, init?: RequestInit) { } export function isCurrentServiceWorkerActive() { - // @ts-ignore - // Firefox doesn't support serviceWorker.state - if (!('serviceWorker' in self) || !('state' in self.serviceWorker)) { + // self.serviceWorker.state is available in all major browsers + // (Firefox added support in v120, Nov 2023). The guard below + // is kept as a safety net for unusual environments. + if ( + !('serviceWorker' in self) || + // @ts-ignore – ServiceWorkerGlobalScope.serviceWorker is + // typed without `state` in older lib definitions. + !('state' in self.serviceWorker) + ) { return true; } // @ts-ignore diff --git a/packages/playground/website/playwright/e2e/deployment.spec.ts b/packages/playground/website/playwright/e2e/deployment.spec.ts index aff7b4dc172..0b87804d1ad 100644 --- a/packages/playground/website/playwright/e2e/deployment.spec.ts +++ b/packages/playground/website/playwright/e2e/deployment.spec.ts @@ -81,51 +81,33 @@ for (const cachingEnabled of [true, false]) { }); } -/** - * This test is flaky and often fails on CI even after multiple retries. It - * lowers the confidence in the test suite so it's being skipped. It is still - * useful for manual testing when updating the service worker and may get - * improved the next time we change something in the service worker. - */ -test.skip( +test( 'When a new website version is deployed while the old version is still loaded, ' + 'creating a new site should still work.', async ({ website, page, wordpress }) => { server!.setHttpCacheEnabled(true); server!.switchToMidVersion(); - const urlWithWordPress65 = new URL(url); - urlWithWordPress65.searchParams.set('wp', '6.5'); - await page.goto(urlWithWordPress65.href); + // The mid version only bundles WordPress 6.5, so we must + // request it explicitly — the default WP version in the mid + // build's JS may not be available. + const midUrl = new URL(url); + midUrl.searchParams.set('wp', '6.5'); + await page.goto(midUrl.href); await website.waitForNestedIframes(); - // Switching to the new app version does not trigger a page reload, - // but it deletes all the stale assets from the server. + // Switching to the new app version does not trigger a page + // reload, but it removes the old assets from the server. server!.switchToNewVersion(); - // The non-reloaded tab should still work. The remote.html iframes - // that are already loaded should continue to work, and the newly - // loaded remote.html iframes should pull in the latest Playground version. - const siteManagerHeading = website.page.locator( - '[class*="_site-manager-site-info"]' - ); - if (await siteManagerHeading.isHidden({ timeout: 5000 })) { - await website.page.getByLabel('Open Site Manager').click(); - } - await expect(siteManagerHeading).toBeVisible(); - - await website.page.getByText('Add Playground').click(); - - const modal = website.page.locator('.components-modal__frame'); - await modal.getByLabel('PHP version').selectOption('7.4'); - await modal.getByLabel('WordPress version').selectOption('6.5'); - await modal.getByLabel('Language').selectOption('pl_PL'); - await website.page.getByText('Create an Unsaved Playground').click(); - + // Navigate to a new temporary site. This forces the app shell + // to fetch the new version's remote.html (network-first) and + // boot a fresh WordPress instance using the new assets. + await page.goto(url.href); await website.waitForNestedIframes(); - - // Confirm we're looking at the Polish site. - expect(wordpress.locator('body')).toContainText('Edytuj witrynę'); + await expect(wordpress.locator('body')).toContainText( + 'My WordPress Website' + ); } );