feat: only pre-cache the document (#6580)

* test(e2e): de-flake service-worker

* feat: rm stale cache storage

* fix: put not del

* fix: staging and test

* test: include staging

* fix: log

* test: rm console.log

* fix: unregister before

* test: deflake by restoring state afterwards
This commit is contained in:
Zach Pomerantz 2023-05-17 12:10:28 -07:00 committed by GitHub
parent 66a3475bf6
commit 146c5f29cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 124 additions and 66 deletions

@ -9,7 +9,7 @@ describe('Service Worker', () => {
throw new Error(
'\n' +
'Service Worker tests must be run on a production-like build\n' +
'To test, build with `yarn build:e2e` and serve with `yarn serve`'
'To test, build with `yarn build` and serve with `yarn serve`'
)
}
})
@ -20,66 +20,78 @@ describe('Service Worker', () => {
}
})
function unregister() {
return cy.log('unregister service worker').then(async () => {
const cacheKeys = await window.caches.keys()
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
if (cacheKey) {
await window.caches.delete(cacheKey)
}
function unregisterServiceWorker() {
return cy.log('unregisters service worker').then(async () => {
const sw = await window.navigator.serviceWorker.getRegistration(Cypress.config().baseUrl ?? undefined)
await sw?.unregister()
})
}
before(unregister)
after(unregister)
before(unregisterServiceWorker)
after(unregisterServiceWorker)
beforeEach(() => {
cy.intercept({ hostname: 'www.google-analytics.com' }, (req) => {
const body = req.body.toString()
if (req.query['ep.event_category'] === 'Service Worker' || body.includes('Service%20Worker')) {
if (req.query['en'] === 'Not Installed' || body.includes('Not%20Installed')) {
req.alias = 'NotInstalled'
} else if (req.query['en'] === 'Cache Hit' || body.includes('Cache%20Hit')) {
req.alias = 'CacheHit'
} else if (req.query['en'] === 'Cache Miss' || body.includes('Cache%20Miss')) {
req.alias = 'CacheMiss'
}
cy.intercept('https://api.uniswap.org/v1/amplitude-proxy', (req) => {
const body = JSON.stringify(req.body)
const serviceWorkerStatus = body.match(/"service_worker":"(\w+)"/)?.[1]
if (serviceWorkerStatus) {
req.alias = `ServiceWorker:${serviceWorkerStatus}`
}
})
})
it('installs a ServiceWorker', () => {
it('installs a ServiceWorker and reports the uninstalled status to analytics', () => {
cy.visit('/', { serviceWorker: true })
.get('#swap-page')
// This is emitted after caching the entry file, which takes some time to load.
.wait('@NotInstalled', { timeout: 60000 })
.window()
.and((win) => {
expect(win.navigator.serviceWorker.controller?.state).to.equal('activated')
})
cy.wait('@ServiceWorker:uninstalled')
cy.window().should(
'have.nested.property',
// The parent is checked instead of the AUT because it is on the same origin,
// and the AUT will not be considered "activated" until the parent is idle.
'parent.navigator.serviceWorker.controller.state',
'activated'
)
})
it('records a cache hit', () => {
cy.visit('/', { serviceWorker: true }).get('#swap-page').wait('@CacheHit', { timeout: 20000 })
})
it('records a cache miss', () => {
cy.then(async () => {
const cacheKeys = await window.caches.keys()
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
assert(cacheKey)
const cache = await window.caches.open(cacheKey)
const keys = await cache.keys()
const key = keys.find((key) => key.url.match(/index/))
assert(key)
await cache.put(key, new Response())
describe('cache hit', () => {
it('reports the hit to analytics', () => {
cy.visit('/', { serviceWorker: true })
cy.wait('@ServiceWorker:hit')
})
})
describe('cache miss', () => {
let cache: Cache | undefined
let request: Request | undefined
let response: Response | undefined
before(() => {
// Mocks the index.html in the cache to force a cache miss.
cy.visit('/', { serviceWorker: true }).then(async () => {
const cacheKeys = await window.caches.keys()
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
assert(cacheKey)
cache = await window.caches.open(cacheKey)
const keys = await cache.keys()
request = keys.find((key) => key.url.match(/index/))
assert(request)
response = await cache.match(request)
assert(response)
await cache.put(request, new Response())
})
})
after(() => {
// Restores the index.html in the cache so that re-runs behave as expected.
// This is necessary because the Service Worker will not re-populate the cache.
cy.then(async () => {
if (cache && request && response) {
await cache.put(request, response)
}
})
})
it('reports the miss to analytics', () => {
cy.visit('/', { serviceWorker: true })
cy.wait('@ServiceWorker:miss')
})
.visit('/', { serviceWorker: true })
.get('#swap-page')
.wait('@CacheMiss', { timeout: 20000 })
})
})

@ -165,7 +165,12 @@ export default function App() {
user.set(CustomUserProperties.SCREEN_RESOLUTION_HEIGHT, window.screen.height)
user.set(CustomUserProperties.SCREEN_RESOLUTION_WIDTH, window.screen.width)
sendAnalyticsEvent(SharedEventName.APP_LOADED)
// Service Worker analytics
const isServiceWorkerInstalled = Boolean(window.navigator.serviceWorker?.controller)
const isServiceWorkerHit = Boolean((window as any).__isDocumentCached)
const serviceWorkerProperty = isServiceWorkerInstalled ? (isServiceWorkerHit ? 'hit' : 'miss') : 'uninstalled'
sendAnalyticsEvent(SharedEventName.APP_LOADED, { service_worker: serviceWorkerProperty })
getCLS(({ delta }: Metric) => sendAnalyticsEvent(SharedEventName.WEB_VITALS, { cumulative_layout_shift: delta }))
getFCP(({ delta }: Metric) => sendAnalyticsEvent(SharedEventName.WEB_VITALS, { first_contentful_paint_ms: delta }))
getFID(({ delta }: Metric) => sendAnalyticsEvent(SharedEventName.WEB_VITALS, { first_input_delay_ms: delta }))

@ -19,6 +19,10 @@ describe('document', () => {
[{ request: { mode: 'navigate' }, url: { hostname: 'app.uniswap.org', pathname: '' } }, true],
[{ request: { mode: 'navigate' }, url: { hostname: 'app.uniswap.org', pathname: '/#/swap' } }, true],
[{ request: { mode: 'navigate' }, url: { hostname: 'app.uniswap.org', pathname: '/asset.gif' } }, false],
[{ request: {}, url: { hostname: 'app.uniswap-staging.org', pathname: '' } }, false],
[{ request: { mode: 'navigate' }, url: { hostname: 'app.uniswap-staging.org', pathname: '' } }, true],
[{ request: { mode: 'navigate' }, url: { hostname: 'app.uniswap-staging.org', pathname: '/#/swap' } }, true],
[{ request: { mode: 'navigate' }, url: { hostname: 'app.uniswap-staging.org', pathname: '/asset.gif' } }, false],
[{ request: {}, url: { hostname: 'localhost', pathname: '' } }, false],
[{ request: { mode: 'navigate' }, url: { hostname: 'localhost', pathname: '' } }, true],
[{ request: { mode: 'navigate' }, url: { hostname: 'localhost', pathname: '/#/swap' } }, true],

@ -1,4 +1,4 @@
import { isAppUniswapOrg } from 'utils/env'
import { isAppUniswapOrg, isAppUniswapStagingOrg } from 'utils/env'
import { RouteHandlerCallbackOptions, RouteMatchCallbackOptions } from 'workbox-core'
import { getCacheKeyForURL, matchPrecache } from 'workbox-precaching'
import { Route } from 'workbox-routing'
@ -25,7 +25,7 @@ export function matchDocument({ request, url }: RouteMatchCallbackOptions) {
// If this isn't app.uniswap.org (or a local build), skip.
// IPFS gateways may not have domain separation, so they cannot use document caching.
if (!isAppUniswapOrg(url) && !isDevelopment()) {
if (!(isDevelopment() || isAppUniswapStagingOrg(url) || isAppUniswapOrg(url))) {
return false
}

@ -1,13 +1,13 @@
import 'workbox-precaching' // defines __WB_MANIFEST
import { clientsClaim } from 'workbox-core'
import { cacheNames, clientsClaim } from 'workbox-core'
import { ExpirationPlugin } from 'workbox-expiration'
import { precacheAndRoute } from 'workbox-precaching'
import { registerRoute, Route } from 'workbox-routing'
import { CacheFirst } from 'workbox-strategies'
import { DocumentRoute } from './document'
import { groupEntries } from './utils'
import { deleteUnusedCaches, groupEntries } from './utils'
declare const self: ServiceWorkerGlobalScope
@ -21,14 +21,21 @@ registerRoute(new DocumentRoute())
const { onDemandEntries, precacheEntries } = groupEntries(self.__WB_MANIFEST)
const onDemandURLs = onDemandEntries.map((entry) => (typeof entry === 'string' ? entry : entry.url))
const onDemandCacheName = `${cacheNames.prefix}-on-demand-${cacheNames.suffix}`
registerRoute(
new Route(
({ url }) => onDemandURLs.includes('.' + url.pathname),
new CacheFirst({
cacheName: 'media',
plugins: [new ExpirationPlugin({ maxEntries: 16 })],
cacheName: onDemandCacheName,
plugins: [new ExpirationPlugin({ maxEntries: 64 })],
})
)
)
precacheAndRoute(precacheEntries)
precacheAndRoute(precacheEntries) // precache cache
// We only use the precache and runtime caches, so we delete the rest to avoid taking space.
// Wait to do so until 'activate' in case activation fails.
self.addEventListener('activate', () =>
deleteUnusedCaches(self.caches, { usedCaches: [cacheNames.precache, onDemandCacheName] })
)

@ -1,22 +1,39 @@
import { groupEntries } from './utils'
import { deleteUnusedCaches, groupEntries } from './utils'
describe('groupEntries', () => {
test('splits resources into onDemandEntries and precacheEntries', () => {
const resources = [
'./static/whitepaper.pdf',
{ url: './static/js/main.js', revision: 'abc123' },
{ url: './static/css/styles.css', revision: 'def456' },
{ url: './static/media/image.jpg', revision: 'ghi789' },
{ url: './index.html', revision: 'abcd1234' },
{ url: './static/css/1234.abcd1234.chunk.css', revision: null },
{ url: './static/js/1234.abcd1234.chunk.js', revision: null },
{ url: './static/media/image.jpg', revision: null },
]
const result = groupEntries(resources)
expect(result).toEqual({
onDemandEntries: ['./static/whitepaper.pdf', { url: './static/media/image.jpg', revision: 'ghi789' }],
precacheEntries: [
{ url: './static/js/main.js', revision: 'abc123' },
{ url: './static/css/styles.css', revision: 'def456' },
onDemandEntries: [
'./static/whitepaper.pdf',
{ url: './static/css/1234.abcd1234.chunk.css', revision: null },
{ url: './static/js/1234.abcd1234.chunk.js', revision: null },
{ url: './static/media/image.jpg', revision: null },
],
precacheEntries: [{ url: './index.html', revision: 'abcd1234' }],
})
})
})
describe('deleteUnusedCaches', () => {
test('deletes unused caches', async () => {
const caches = {
keys: jest.fn().mockResolvedValue(['a', 'b', 'c']),
delete: jest.fn(),
} as unknown as CacheStorage
await deleteUnusedCaches(caches, { usedCaches: ['a', 'b'] })
expect(caches.delete).not.toHaveBeenCalledWith('a')
expect(caches.delete).not.toHaveBeenCalledWith('b')
expect(caches.delete).toHaveBeenCalledWith('c')
})
})

@ -17,15 +17,28 @@ export function isDevelopment() {
}
type GroupedEntries = { onDemandEntries: (string | PrecacheEntry)[]; precacheEntries: PrecacheEntry[] }
/**
* Splits entries into on-demand and precachable entries.
* Effectively, splits out index.html as the only precachable entry.
*/
export function groupEntries(entries: (string | PrecacheEntry)[]): GroupedEntries {
return entries.reduce<GroupedEntries>(
({ onDemandEntries, precacheEntries }, entry) => {
if (typeof entry === 'string' || entry.url.includes('/media/')) {
return { precacheEntries, onDemandEntries: [...onDemandEntries, entry] }
} else {
} else if (entry.revision) {
// index.html should be the only non-media entry with a revision, as code chunks have a hashed URL.
return { precacheEntries: [...precacheEntries, entry], onDemandEntries }
} else {
return { precacheEntries, onDemandEntries: [...onDemandEntries, entry] }
}
},
{ onDemandEntries: [], precacheEntries: [] }
)
}
export async function deleteUnusedCaches(caches: CacheStorage, { usedCaches }: { usedCaches: string[] }) {
const cacheKeys = await caches.keys()
cacheKeys.filter((key) => !usedCaches.includes(key)).forEach((key) => caches.delete(key))
}

@ -19,7 +19,7 @@ export function isAppUniswapOrg({ hostname }: { hostname: string }): boolean {
return hostname === 'app.uniswap.org'
}
function isAppUniswapStagingOrg({ hostname }: { hostname: string }): boolean {
export function isAppUniswapStagingOrg({ hostname }: { hostname: string }): boolean {
return hostname === 'app.uniswap-staging.org'
}