Skip to content
Merged
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
149 changes: 149 additions & 0 deletions apps/local-edge-demo/e2e/local-edge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2169,6 +2169,155 @@ test.describe('Local Edge v0', () => {
}
})

test('a reset in another tab leaves no release cache behind', async ({
browser,
}) => {
const releaseServer = await startReleaseUpdateServer()
const context = await browser.newContext()
const previousPage = await context.newPage()
const updatedPage = await context.newPage()
const releaseCaches = (page: typeof previousPage) =>
page.evaluate(async () =>
(await caches.keys()).filter((cacheName) =>
cacheName.startsWith('fwa-local-edge:fwa-local-edge-demo:release:'),
),
)

try {
await previousPage.goto(releaseServer.baseUrl)
await expect(previousPage.locator('[data-local-edge-status]')).toHaveAttribute(
'data-local-edge-status',
'ready',
{ timeout: 20_000 },
)
await previousPage.evaluate(() =>
fetch('/__test/switch-release', { method: 'POST' }),
)
await updatedPage.goto(releaseServer.baseUrl)
await reloadAvailableUpdate(updatedPage)
await expect(
updatedPage.locator('meta[name="fwa-test-release"]'),
).toHaveAttribute('content', 'app-update', { timeout: 20_000 })

// The client reset also unregisters and reopens the tab on the network.
await Promise.all([
updatedPage.waitForURL(/[?&]__fwa=network/u, { timeout: 20_000 }),
updatedPage.evaluate(() =>
(
globalThis as typeof globalThis & {
__fwa: { localEdge: { reset(): Promise<void> } }
}
).__fwa.localEdge.reset(),
),
])
expect(
await updatedPage.evaluate(
async () => (await navigator.serviceWorker.getRegistrations()).length,
),
).toBe(0)

// The previous tab is still controlled by the reset worker instance and
// keeps issuing requests through it: a lazy asset of its pinned release,
// a descriptor revalidation, and a navigation-free state read.
await previousPage.evaluate(async (assetPath) => {
await import(assetPath).catch(() => undefined)
await fetch('/__fwa/revalidate', {
method: 'POST',
headers: { 'X-FWA-Control': 'revalidate' },
}).catch(() => undefined)
await fetch('/__fwa/state').catch(() => undefined)
}, releaseServer.initialLazyAssetPath)
await previousPage.waitForTimeout(2_000)

expect(await releaseCaches(previousPage)).toEqual([])
expect(await releaseCaches(updatedPage)).toEqual([])
} finally {
await context.close()
await releaseServer.close()
}
})

test('finds a new worker on a release check and applies it with the update', async ({
browser,
}) => {
const releaseServer = await startReleaseUpdateServer()
const context = await browser.newContext()
const page = await context.newPage()
const workerGeneration = () =>
page.evaluate(() =>
fetch('/__test/worker-generation', { cache: 'no-store' }).then(
(response) => response.text(),
),
)
type LocalEdgeTestWindow = typeof globalThis & {
__fwa: {
localEdge: {
revalidate(): Promise<unknown>
getState(): { updateAvailable: boolean }
applyUpdate(): boolean
}
}
}

try {
await page.goto(releaseServer.baseUrl)
await expect(page.locator('[data-local-edge-status]')).toHaveAttribute(
'data-local-edge-status',
'ready',
{ timeout: 20_000 },
)
expect(await workerGeneration()).toBe('1')

// One deploy changes both the release and the worker script, while the
// document stays open: no navigation reaches the browser's own check.
await page.evaluate(async () => {
await fetch('/__test/switch-release', { method: 'POST' })
await fetch('/__test/switch-worker', { method: 'POST' })
})
await page.evaluate(() =>
(globalThis as LocalEdgeTestWindow).__fwa.localEdge.revalidate(),
)
await expect
.poll(() =>
page.evaluate(async () => {
const registration =
await navigator.serviceWorker.getRegistration()
return {
updateAvailable: (
globalThis as LocalEdgeTestWindow
).__fwa.localEdge.getState().updateAvailable,
workerWaiting: Boolean(registration?.waiting),
}
}),
)
.toEqual({ updateAvailable: true, workerWaiting: true })
// Until the user applies the update, the open document keeps its worker.
expect(await workerGeneration()).toBe('1')

await Promise.all([
page.waitForEvent('load'),
page.evaluate(() =>
(globalThis as LocalEdgeTestWindow).__fwa.localEdge.applyUpdate(),
),
])
await expect(page.locator('meta[name="fwa-test-release"]')).toHaveAttribute(
'content',
'app-update',
{ timeout: 20_000 },
)
expect(await workerGeneration()).toBe('2')
expect(
await page.evaluate(
async () =>
Boolean((await navigator.serviceWorker.getRegistration())?.waiting),
),
).toBe(false)
} finally {
await context.close()
await releaseServer.close()
}
})

test('installs a third release while older clients stay pinned', async ({
browser,
}) => {
Expand Down
43 changes: 41 additions & 2 deletions apps/local-edge-demo/e2e/release-update-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,41 @@ export async function startReleaseUpdateServer(
allowCandidateAsset = resolveAsset
})

// A second worker generation differs only by an appended fetch listener
// that answers the generation probe; the first generation leaves the probe
// to the network, which answers 1.
let workerGeneration = 1
const workerGenerationProbePath = '/__test/worker-generation'
const secondWorkerGenerationSuffix = Buffer.from(
`\nself.addEventListener('fetch', (event) => {\n` +
` if (new URL(event.request.url).pathname === '${workerGenerationProbePath}') {\n` +
` event.respondWith(new Response('2', { headers: { 'Cache-Control': 'no-store' } }))\n` +
` }\n` +
`})\n`,
)

const server = createServer(async (request, response) => {
const requestUrl = new URL(request.url ?? '/', 'http://localhost')

if (
request.method === 'POST' &&
requestUrl.pathname === '/__test/switch-worker'
) {
workerGeneration = 2
respond(response, Buffer.from('{}'), {
contentType: 'application/json; charset=utf-8',
cacheControl: 'no-store',
})
return
}
if (requestUrl.pathname === workerGenerationProbePath) {
respond(response, Buffer.from('1'), {
contentType: 'text/plain; charset=utf-8',
cacheControl: 'no-store',
})
return
}

if (
request.method === 'POST' &&
requestUrl.pathname === '/__test/switch-release'
Expand Down Expand Up @@ -305,15 +337,22 @@ export async function startReleaseUpdateServer(
throw new Error('path escapes dist root')
}

respond(response, await readFile(filePath), {
const fileBody = await readFile(filePath)
respond(
response,
requestUrl.pathname === localEdgeConfig.workerPath && workerGeneration === 2
? Buffer.concat([fileBody, secondWorkerGenerationSuffix])
: fileBody,
{
contentType: contentTypeFor(filePath),
cacheControl:
requestUrl.pathname === localEdgeConfig.workerPath ||
requestUrl.pathname === loaderPath
? 'no-cache'
: 'public, max-age=31536000, immutable',
headOnly: request.method === 'HEAD',
})
},
)
} catch {
response
.writeHead(404, {
Expand Down
2 changes: 2 additions & 0 deletions packages/local-edge/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ The metadata epoch fences durable authority across worker globals and reset. Whe

During a candidate install the worker keeps an in-memory progress counter and broadcasts best-effort `__fwa:revalidation-progress` messages to window clients, plus a single `__fwa:revalidation-committed` message after the active pointer is committed or a `__fwa:revalidation-failed` message when the install attempt does not commit. Progress stays in memory only; a worker restart clears it and clients fall back to activity UI. The kernel-issued instance and revision ordering shared by messages, snapshots, and responses is defined in [Revalidation observation ordering](revalidation-observation.md).

Worker script updates follow the browser's lifecycle and are never forced into an open document: the document runtime only speeds up discovery on its release checks, and a waiting worker takes over when every controlled document closes or when the user applies an update. Kernel Cache Storage reads go through `caches.match` with a cache name, so a worker read never recreates a release cache that reset or cleanup deleted; the only kernel `caches.open` creates a candidate cache under the candidate lock. The diagnostics panel still lists cache entries through `caches.keys()` and `caches.open()` and is not covered by this guarantee.

This is release-level stale-while-revalidate. It is not per-request HTTP stale-while-revalidate and does not promise the newest release while offline.

## Dependency direction
Expand Down
8 changes: 8 additions & 0 deletions packages/local-edge/docs/config-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ publishes the material runtime transition out of network-only mode. Switching
from an already active release remains owned by the next navigation or
`applyUpdate()`.

Each check from a controlled document also asks the browser to re-fetch the
worker script (`registration.update()`, best-effort). A long-lived document
does not navigate, and browsers otherwise check for a new worker only on
navigations, or on a subresource request once the registration is more than 24
hours stale. A new worker found this way still waits for every controlled
document to close; `applyUpdate()` lets it take over before reloading, so the
user-applied update reaches both the new release and the new worker.

`window.__fwa.localEdge.setUpdateCheck()` can enable, disable, or change the
interval for the current document. This override is not written to browser
storage and does not change `fwa.config.json`.
Expand Down
2 changes: 1 addition & 1 deletion packages/local-edge/docs/loader-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ unsubscribe?.()
| `subscribe(listener)` | Emit immediately and on later state changes |
| `revalidate()` | Check and install a candidate; return `current`, `updated`, `failed`, or `disabled` |
| `setUpdateCheck(config)` | Change the enabled state or interval (1–35,791 minutes) for the current document without persistence |
| `applyUpdate()` | Reload only when a complete update is available |
| `applyUpdate()` | Reload only when a complete update is available; a new worker waiting to activate takes over first (bounded to 3 seconds) |
| `reset()` | Clear Local Edge-owned state and enter network mode |
| `networkUrl(url?)` | Preserve the URL while adding `__fwa=network` |
| `openNetwork()` | Navigate to `networkUrl()` |
Expand Down
Loading
Loading