Commit 146c5f29 authored by Zach Pomerantz's avatar Zach Pomerantz Committed by GitHub

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
parent 66a3475b
...@@ -9,7 +9,7 @@ describe('Service Worker', () => { ...@@ -9,7 +9,7 @@ describe('Service Worker', () => {
throw new Error( throw new Error(
'\n' + '\n' +
'Service Worker tests must be run on a production-like build\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', () => { ...@@ -20,66 +20,78 @@ describe('Service Worker', () => {
} }
}) })
function unregister() { function unregisterServiceWorker() {
return cy.log('unregister service worker').then(async () => { return cy.log('unregisters 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)
}
const sw = await window.navigator.serviceWorker.getRegistration(Cypress.config().baseUrl ?? undefined) const sw = await window.navigator.serviceWorker.getRegistration(Cypress.config().baseUrl ?? undefined)
await sw?.unregister() await sw?.unregister()
}) })
} }
before(unregister) before(unregisterServiceWorker)
after(unregister) after(unregisterServiceWorker)
beforeEach(() => { beforeEach(() => {
cy.intercept({ hostname: 'www.google-analytics.com' }, (req) => { cy.intercept('https://api.uniswap.org/v1/amplitude-proxy', (req) => {
const body = req.body.toString() const body = JSON.stringify(req.body)
if (req.query['ep.event_category'] === 'Service Worker' || body.includes('Service%20Worker')) { const serviceWorkerStatus = body.match(/"service_worker":"(\w+)"/)?.[1]
if (req.query['en'] === 'Not Installed' || body.includes('Not%20Installed')) { if (serviceWorkerStatus) {
req.alias = 'NotInstalled' req.alias = `ServiceWorker:${serviceWorkerStatus}`
} 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'
}
} }
}) })
}) })
it('installs a ServiceWorker', () => { it('installs a ServiceWorker and reports the uninstalled status to analytics', () => {
cy.visit('/', { serviceWorker: true }) cy.visit('/', { serviceWorker: true })
.get('#swap-page') cy.wait('@ServiceWorker:uninstalled')
// This is emitted after caching the entry file, which takes some time to load. cy.window().should(
.wait('@NotInstalled', { timeout: 60000 }) 'have.nested.property',
.window() // The parent is checked instead of the AUT because it is on the same origin,
.and((win) => { // and the AUT will not be considered "activated" until the parent is idle.
expect(win.navigator.serviceWorker.controller?.state).to.equal('activated') 'parent.navigator.serviceWorker.controller.state',
}) 'activated'
)
}) })
it('records a cache hit', () => { describe('cache hit', () => {
cy.visit('/', { serviceWorker: true }).get('#swap-page').wait('@CacheHit', { timeout: 20000 }) it('reports the hit to analytics', () => {
cy.visit('/', { serviceWorker: true })
cy.wait('@ServiceWorker:hit')
})
}) })
it('records a cache miss', () => { describe('cache miss', () => {
cy.then(async () => { let cache: Cache | undefined
const cacheKeys = await window.caches.keys() let request: Request | undefined
const cacheKey = cacheKeys.find((key) => key.match(/precache/)) let response: Response | undefined
assert(cacheKey) 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)
const cache = await window.caches.open(cacheKey) response = await cache.match(request)
const keys = await cache.keys() assert(response)
const key = keys.find((key) => key.url.match(/index/))
assert(key)
await cache.put(key, new 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() { ...@@ -165,7 +165,12 @@ export default function App() {
user.set(CustomUserProperties.SCREEN_RESOLUTION_HEIGHT, window.screen.height) user.set(CustomUserProperties.SCREEN_RESOLUTION_HEIGHT, window.screen.height)
user.set(CustomUserProperties.SCREEN_RESOLUTION_WIDTH, window.screen.width) 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 })) getCLS(({ delta }: Metric) => sendAnalyticsEvent(SharedEventName.WEB_VITALS, { cumulative_layout_shift: delta }))
getFCP(({ delta }: Metric) => sendAnalyticsEvent(SharedEventName.WEB_VITALS, { first_contentful_paint_ms: 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 })) getFID(({ delta }: Metric) => sendAnalyticsEvent(SharedEventName.WEB_VITALS, { first_input_delay_ms: delta }))
......
...@@ -19,6 +19,10 @@ describe('document', () => { ...@@ -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: '' } }, true],
[{ request: { mode: 'navigate' }, url: { hostname: 'app.uniswap.org', pathname: '/#/swap' } }, 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: { 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: {}, url: { hostname: 'localhost', pathname: '' } }, false],
[{ request: { mode: 'navigate' }, url: { hostname: 'localhost', pathname: '' } }, true], [{ request: { mode: 'navigate' }, url: { hostname: 'localhost', pathname: '' } }, true],
[{ request: { mode: 'navigate' }, url: { hostname: 'localhost', pathname: '/#/swap' } }, true], [{ request: { mode: 'navigate' }, url: { hostname: 'localhost', pathname: '/#/swap' } }, true],
......
import { isAppUniswapOrg } from 'utils/env' import { isAppUniswapOrg, isAppUniswapStagingOrg } from 'utils/env'
import { RouteHandlerCallbackOptions, RouteMatchCallbackOptions } from 'workbox-core' import { RouteHandlerCallbackOptions, RouteMatchCallbackOptions } from 'workbox-core'
import { getCacheKeyForURL, matchPrecache } from 'workbox-precaching' import { getCacheKeyForURL, matchPrecache } from 'workbox-precaching'
import { Route } from 'workbox-routing' import { Route } from 'workbox-routing'
...@@ -25,7 +25,7 @@ export function matchDocument({ request, url }: RouteMatchCallbackOptions) { ...@@ -25,7 +25,7 @@ export function matchDocument({ request, url }: RouteMatchCallbackOptions) {
// If this isn't app.uniswap.org (or a local build), skip. // 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. // 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 return false
} }
......
import 'workbox-precaching' // defines __WB_MANIFEST import 'workbox-precaching' // defines __WB_MANIFEST
import { clientsClaim } from 'workbox-core' import { cacheNames, clientsClaim } from 'workbox-core'
import { ExpirationPlugin } from 'workbox-expiration' import { ExpirationPlugin } from 'workbox-expiration'
import { precacheAndRoute } from 'workbox-precaching' import { precacheAndRoute } from 'workbox-precaching'
import { registerRoute, Route } from 'workbox-routing' import { registerRoute, Route } from 'workbox-routing'
import { CacheFirst } from 'workbox-strategies' import { CacheFirst } from 'workbox-strategies'
import { DocumentRoute } from './document' import { DocumentRoute } from './document'
import { groupEntries } from './utils' import { deleteUnusedCaches, groupEntries } from './utils'
declare const self: ServiceWorkerGlobalScope declare const self: ServiceWorkerGlobalScope
...@@ -21,14 +21,21 @@ registerRoute(new DocumentRoute()) ...@@ -21,14 +21,21 @@ registerRoute(new DocumentRoute())
const { onDemandEntries, precacheEntries } = groupEntries(self.__WB_MANIFEST) const { onDemandEntries, precacheEntries } = groupEntries(self.__WB_MANIFEST)
const onDemandURLs = onDemandEntries.map((entry) => (typeof entry === 'string' ? entry : entry.url)) const onDemandURLs = onDemandEntries.map((entry) => (typeof entry === 'string' ? entry : entry.url))
const onDemandCacheName = `${cacheNames.prefix}-on-demand-${cacheNames.suffix}`
registerRoute( registerRoute(
new Route( new Route(
({ url }) => onDemandURLs.includes('.' + url.pathname), ({ url }) => onDemandURLs.includes('.' + url.pathname),
new CacheFirst({ new CacheFirst({
cacheName: 'media', cacheName: onDemandCacheName,
plugins: [new ExpirationPlugin({ maxEntries: 16 })], 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] })
)
import { groupEntries } from './utils' import { deleteUnusedCaches, groupEntries } from './utils'
describe('groupEntries', () => { describe('groupEntries', () => {
test('splits resources into onDemandEntries and precacheEntries', () => { test('splits resources into onDemandEntries and precacheEntries', () => {
const resources = [ const resources = [
'./static/whitepaper.pdf', './static/whitepaper.pdf',
{ url: './static/js/main.js', revision: 'abc123' }, { url: './index.html', revision: 'abcd1234' },
{ url: './static/css/styles.css', revision: 'def456' }, { url: './static/css/1234.abcd1234.chunk.css', revision: null },
{ url: './static/media/image.jpg', revision: 'ghi789' }, { url: './static/js/1234.abcd1234.chunk.js', revision: null },
{ url: './static/media/image.jpg', revision: null },
] ]
const result = groupEntries(resources) const result = groupEntries(resources)
expect(result).toEqual({ expect(result).toEqual({
onDemandEntries: ['./static/whitepaper.pdf', { url: './static/media/image.jpg', revision: 'ghi789' }], onDemandEntries: [
precacheEntries: [ './static/whitepaper.pdf',
{ url: './static/js/main.js', revision: 'abc123' }, { url: './static/css/1234.abcd1234.chunk.css', revision: null },
{ url: './static/css/styles.css', revision: 'def456' }, { 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() { ...@@ -17,15 +17,28 @@ export function isDevelopment() {
} }
type GroupedEntries = { onDemandEntries: (string | PrecacheEntry)[]; precacheEntries: PrecacheEntry[] } 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 { export function groupEntries(entries: (string | PrecacheEntry)[]): GroupedEntries {
return entries.reduce<GroupedEntries>( return entries.reduce<GroupedEntries>(
({ onDemandEntries, precacheEntries }, entry) => { ({ onDemandEntries, precacheEntries }, entry) => {
if (typeof entry === 'string' || entry.url.includes('/media/')) { if (typeof entry === 'string' || entry.url.includes('/media/')) {
return { precacheEntries, onDemandEntries: [...onDemandEntries, entry] } 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 } return { precacheEntries: [...precacheEntries, entry], onDemandEntries }
} else {
return { precacheEntries, onDemandEntries: [...onDemandEntries, entry] }
} }
}, },
{ onDemandEntries: [], precacheEntries: [] } { 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 { ...@@ -19,7 +19,7 @@ export function isAppUniswapOrg({ hostname }: { hostname: string }): boolean {
return hostname === 'app.uniswap.org' return hostname === 'app.uniswap.org'
} }
function isAppUniswapStagingOrg({ hostname }: { hostname: string }): boolean { export function isAppUniswapStagingOrg({ hostname }: { hostname: string }): boolean {
return hostname === 'app.uniswap-staging.org' return hostname === 'app.uniswap-staging.org'
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment