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
11 changes: 6 additions & 5 deletions packages/playground/remote/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?
*
Expand Down Expand Up @@ -122,7 +123,7 @@ import {
networkFirstFetch,
cacheOfflineModeAssetsForCurrentRelease,
isCurrentServiceWorkerActive,
purgeEverythingFromPreviousRelease,
purgeExcessOldCaches,
shouldCacheUrl,
} from './src/lib/offline-mode-cache';

Expand Down Expand Up @@ -191,7 +192,7 @@ self.addEventListener('activate', function (event) {
await self.clients.claim();

if (shouldCacheUrl(new URL(location.href))) {
await purgeEverythingFromPreviousRelease();
await purgeExcessOldCaches();
cacheOfflineModeAssetsForCurrentRelease();
}
}
Expand Down
69 changes: 66 additions & 3 deletions packages/playground/remote/src/lib/offline-mode-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export async function cacheFirstFetch(request: Request): Promise<Response> {
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) {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if all packages produce a hashed filename. I have a vague memory of at least one exception to the name-with-hash approach, but it might have been for Node.js deps consumed by Studio builds. We need to make sure.

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
Expand Down Expand Up @@ -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);
Comment on lines +181 to +183
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit weak to depend up on the last being "typically the most recent". We need to look more closely at this.

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<Response | undefined> {
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.
Expand Down Expand Up @@ -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
Expand Down
50 changes: 16 additions & 34 deletions packages/playground/website/playwright/e2e/deployment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
}
);

Expand Down
Loading