From aa092b1dd122ab6bf6b455d77b28ada5afcfdd9a Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 18 Mar 2026 00:55:51 -0400 Subject: [PATCH 1/4] Update stale Firefox comment on isCurrentServiceWorkerActive self.serviceWorker.state has been available in Firefox since v120 (Nov 2023). Update the misleading comment and make the @ts-ignore annotation more descriptive. The guard is kept as a safety net. --- .../playground/remote/src/lib/offline-mode-cache.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/playground/remote/src/lib/offline-mode-cache.ts b/packages/playground/remote/src/lib/offline-mode-cache.ts index a828dba383e..04271285766 100644 --- a/packages/playground/remote/src/lib/offline-mode-cache.ts +++ b/packages/playground/remote/src/lib/offline-mode-cache.ts @@ -245,9 +245,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 From e97877b94a24c9fb31b269c37cf081a998c4c387 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 18 Mar 2026 00:56:28 -0400 Subject: [PATCH 2/4] Fall back to old caches in cacheFirstFetch for multi-tab resilience After a service worker update, old tabs still hold references to hashed asset URLs from the previous build. These assets are gone from the current cache and the server, causing 404s and "Failed to initialize WordPress" errors. Search all previous playground-cache-* caches before going to the network so old tabs can still find their assets. Also add purgeExcessOldCaches() which keeps the most recent old cache instead of purging everything, bounding storage to ~3 versions. --- .../remote/src/lib/offline-mode-cache.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/playground/remote/src/lib/offline-mode-cache.ts b/packages/playground/remote/src/lib/offline-mode-cache.ts index 04271285766..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. From 9b5b26177c62f7cc69a0f35ef48c99c12a2cce4e Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 18 Mar 2026 00:57:01 -0400 Subject: [PATCH 3/4] Use purgeExcessOldCaches in service worker activate handler Switch from purgeEverythingFromPreviousRelease() to purgeExcessOldCaches() so old tabs can still resolve their hashed asset URLs from the previous build's cache. --- packages/playground/remote/service-worker.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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(); } } From 2912b06c27d614b0a750aae950338a6b6de342e5 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 18 Mar 2026 12:16:53 -0400 Subject: [PATCH 4/4] Re-enable and fix multi-tab deployment E2E test The test was skipped because it used an outdated UI flow (the "Add Playground" modal no longer exists). Rewrite the test to create a new site via URL query parameters instead, which is the current pattern for specifying WordPress version and language. --- .../website/playwright/e2e/deployment.spec.ts | 50 ++++++------------- 1 file changed, 16 insertions(+), 34 deletions(-) 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' + ); } );