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:
parent
66a3475bf6
commit
146c5f29cf
@ -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'
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user