mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[perf] Fix SW image cache slowing cold thumbnail bursts (grid load regression)
"Recently Added" grids loaded visibly slower after the image cache shipped. The feed's data path was untouched — the regression was the thumbnails: the SW handler had three compounding costs on a cold cache: 1. The network fetch waited behind caches.open + cache.match, delaying every thumbnail's download start. 2. caches.open ran per request instead of once. 3. trimImageCache ran a full keys() enumeration after EVERY put — a grid burst of dozens of thumbnails did dozens of O(n) enumerations, all contending with the cache.match calls of later images. Fix: start the network fetch immediately (cold path is now as fast as no SW; a warm cache still serves the cached copy for first paint), memoize the cache open, and amortize trims (every 20 puts). SW_VERSION bumped so testers pick it up. Tests: sw-image-cache (7 — adds a regression test that gates cache.match behind a promise and asserts the download starts anyway; all prior behaviors re-verified against the real sw.min.js). Lint + build green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
c20938b4aa
commit
84cec15483
2 files changed
+92
-23
No files matched your search
@@ -1,7 +1,7 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const SW_VERSION = "bitvid-sw-2026-07-02";
|
||||
const SW_VERSION = "bitvid-sw-2026-07-02.1";
|
||||
const SW_METADATA = Object.freeze({
|
||||
version: SW_VERSION,
|
||||
runtimePathPrefix: "/webtorrent/",
|
||||
@@ -16,6 +16,23 @@
|
||||
// cache + the dist cache-purge pipeline). Bump the version to discard.
|
||||
const IMAGE_CACHE = "bitvid-images-v1";
|
||||
const IMAGE_CACHE_MAX_ENTRIES = 300;
|
||||
// Trim is O(entries) (a full keys() enumeration), so amortize it instead of
|
||||
// running it after every put — a cold grid load puts dozens of thumbnails in a
|
||||
// burst, and per-put trims made that burst contend with the cache.match calls
|
||||
// of later images (the "grid loads slowly" regression).
|
||||
const IMAGE_CACHE_TRIM_EVERY = 20;
|
||||
let imageCachePromise = null;
|
||||
let imagePutsSinceTrim = 0;
|
||||
|
||||
function openImageCache() {
|
||||
if (!imageCachePromise) {
|
||||
imageCachePromise = caches.open(IMAGE_CACHE);
|
||||
imageCachePromise.catch(() => {
|
||||
imageCachePromise = null;
|
||||
});
|
||||
}
|
||||
return imageCachePromise;
|
||||
}
|
||||
|
||||
function isCacheableImageRequest(request) {
|
||||
if (!request || request.method !== "GET") {
|
||||
@@ -50,14 +67,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function putImageInCache(cache, request, response) {
|
||||
async function putImageInCache(request, response) {
|
||||
try {
|
||||
const cache = await openImageCache();
|
||||
await cache.put(request, response);
|
||||
await trimImageCache(cache);
|
||||
imagePutsSinceTrim += 1;
|
||||
if (imagePutsSinceTrim >= IMAGE_CACHE_TRIM_EVERY) {
|
||||
imagePutsSinceTrim = 0;
|
||||
await trimImageCache(cache);
|
||||
}
|
||||
} catch (error) {
|
||||
// Quota pressure (opaque responses are padded heavily for quota
|
||||
// accounting) or storage failure: drop the whole image cache and keep
|
||||
// going — the page still got its network response.
|
||||
imageCachePromise = null;
|
||||
try {
|
||||
await caches.delete(IMAGE_CACHE);
|
||||
} catch (cleanupError) {
|
||||
@@ -68,31 +91,37 @@
|
||||
|
||||
async function handleImageRequest(event) {
|
||||
const request = event.request;
|
||||
const cache = await caches.open(IMAGE_CACHE);
|
||||
const cached = await cache.match(request, { ignoreVary: true });
|
||||
|
||||
const refresh = fetch(request)
|
||||
.then((response) => {
|
||||
// Cache good responses; opaque (cross-origin no-cors) counts as good —
|
||||
// its status is unreadable, and these are exactly the images we target.
|
||||
if (response && (response.ok || response.type === "opaque")) {
|
||||
event.waitUntil(putImageInCache(cache, request, response.clone()));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
// Start the network fetch IMMEDIATELY — never delay a download behind cache
|
||||
// I/O. On a cold cache this makes the SW path as fast as no SW at all; on a
|
||||
// warm cache the cached copy below still wins the race for first paint.
|
||||
const network = fetch(request).then((response) => {
|
||||
// Cache good responses; opaque (cross-origin no-cors) counts as good —
|
||||
// its status is unreadable, and these are exactly the images we target.
|
||||
if (response && (response.ok || response.type === "opaque")) {
|
||||
event.waitUntil(putImageInCache(request, response.clone()));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
// Mark handled so a network failure can't surface as an unhandled rejection
|
||||
// while we're still consulting the cache; the original promise stays
|
||||
// observable for the no-cache path below.
|
||||
network.catch(() => {});
|
||||
|
||||
let cached = null;
|
||||
try {
|
||||
const cache = await openImageCache();
|
||||
cached = await cache.match(request, { ignoreVary: true });
|
||||
} catch (error) {
|
||||
// Cache unavailable — plain network behavior.
|
||||
}
|
||||
|
||||
if (cached) {
|
||||
// Keep the background refresh alive after we respond.
|
||||
event.waitUntil(refresh.catch(() => {}));
|
||||
// Serve the cached copy instantly; the refresh continues in the background.
|
||||
event.waitUntil(network.catch(() => {}));
|
||||
return cached;
|
||||
}
|
||||
return refresh;
|
||||
return network;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -233,6 +233,46 @@ test("activate wipes other caches but preserves the CURRENT image cache", async
|
||||
);
|
||||
});
|
||||
|
||||
test("the network fetch starts immediately — never serialized behind cache I/O", async () => {
|
||||
// Regression: the first version awaited caches.open + cache.match BEFORE
|
||||
// starting the download, so a cold thumbnail burst loaded visibly slower than
|
||||
// with no SW at all ("Recently Added fetching slowly").
|
||||
let fetchStarted = false;
|
||||
let releaseMatch;
|
||||
const matchGate = new Promise((resolve) => {
|
||||
releaseMatch = resolve;
|
||||
});
|
||||
|
||||
const { listeners, sandbox } = loadSw({
|
||||
fetchImpl: async () => {
|
||||
fetchStarted = true;
|
||||
return makeResponse("net-1");
|
||||
},
|
||||
});
|
||||
// Make cache.match hang until released — the download must not wait for it.
|
||||
const realOpen = sandbox.caches.open.bind(sandbox.caches);
|
||||
sandbox.caches.open = async (name) => {
|
||||
const cache = await realOpen(name);
|
||||
return {
|
||||
...cache,
|
||||
async match(request) {
|
||||
await matchGate;
|
||||
return cache.match(request);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const event = makeFetchEvent(IMG);
|
||||
listeners.get("fetch")(event);
|
||||
await Promise.resolve(); // let the handler take its first steps
|
||||
assert.equal(fetchStarted, true, "download began while cache.match was still pending");
|
||||
|
||||
releaseMatch();
|
||||
const response = await event.response;
|
||||
assert.equal(response.body, "net-1");
|
||||
await Promise.all(event.pending);
|
||||
});
|
||||
|
||||
test("opaque (cross-origin no-cors) responses are cached; failed basic responses are not", async () => {
|
||||
let mode = "opaque";
|
||||
const { listeners, cacheStorage } = loadSw({
|
||||
|
||||
Reference in new issue
Block a user