serve loader data from the cache first, update the UI when there is something new
This commit is contained in:
parent
5943044509
commit
724348cff4
23
app/features/core/hooks/use-service-worker-revalidate.ts
Normal file
23
app/features/core/hooks/use-service-worker-revalidate.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useFetcher } from "@remix-run/react";
|
||||||
|
|
||||||
|
export default function useServiceWorkerRevalidate() {
|
||||||
|
const fetcher = useFetcher();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const channel = new BroadcastChannel("sw-messages");
|
||||||
|
function onMessage(event: MessageEvent) {
|
||||||
|
const isRefresh = event.data === "revalidateLoaderData";
|
||||||
|
if (isRefresh) {
|
||||||
|
console.debug("Revalidating loaders data");
|
||||||
|
fetcher.submit({}, { method: "post", action: "/dev/null" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.addEventListener("message", onMessage);
|
||||||
|
return () => {
|
||||||
|
channel.removeEventListener("message", onMessage);
|
||||||
|
channel.close();
|
||||||
|
};
|
||||||
|
}, [fetcher]);
|
||||||
|
}
|
@ -4,6 +4,7 @@ import { Outlet, useCatch, useMatches } from "@remix-run/react";
|
|||||||
import serverConfig from "~/config/config.server";
|
import serverConfig from "~/config/config.server";
|
||||||
import { type SessionData, requireLoggedIn } from "~/utils/auth.server";
|
import { type SessionData, requireLoggedIn } from "~/utils/auth.server";
|
||||||
import Footer from "~/features/core/components/footer";
|
import Footer from "~/features/core/components/footer";
|
||||||
|
import useServiceWorkerRevalidate from "~/features/core/hooks/use-service-worker-revalidate";
|
||||||
import footerStyles from "~/features/core/components/footer.css";
|
import footerStyles from "~/features/core/components/footer.css";
|
||||||
import appStyles from "~/styles/app.css";
|
import appStyles from "~/styles/app.css";
|
||||||
|
|
||||||
@ -29,6 +30,7 @@ export const loader: LoaderFunction = async ({ request }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function __App() {
|
export default function __App() {
|
||||||
|
useServiceWorkerRevalidate();
|
||||||
const matches = useMatches();
|
const matches = useMatches();
|
||||||
const hideFooter = matches.some((match) => match.handle?.hideFooter === true);
|
const hideFooter = matches.some((match) => match.handle?.hideFooter === true);
|
||||||
|
|
||||||
|
5
app/routes/dev.null.ts
Normal file
5
app/routes/dev.null.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import type { ActionFunction } from "@remix-run/node";
|
||||||
|
|
||||||
|
export const action: ActionFunction = async ({ request }) => {
|
||||||
|
return null;
|
||||||
|
};
|
@ -1,3 +1,5 @@
|
|||||||
|
import { deleteCaches } from "./cache-utils";
|
||||||
|
|
||||||
declare let self: ServiceWorkerGlobalScope;
|
declare let self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
export default async function handleActivate(event: ExtendableEvent) {
|
export default async function handleActivate(event: ExtendableEvent) {
|
||||||
@ -7,4 +9,6 @@ export default async function handleActivate(event: ExtendableEvent) {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await self.registration.navigationPreload.enable();
|
await self.registration.navigationPreload.enable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await deleteCaches();
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,7 @@ export function isDocumentGetRequest(request: Request) {
|
|||||||
return request.method.toLowerCase() === "get" && request.mode === "navigate";
|
return request.method.toLowerCase() === "get" && request.mode === "navigate";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cacheAsset(event: FetchEvent) {
|
export function cacheAsset(event: FetchEvent): Promise<Response> {
|
||||||
// stale-while-revalidate
|
// stale-while-revalidate
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
return caches
|
return caches
|
||||||
@ -50,36 +50,183 @@ export function cacheAsset(event: FetchEvent) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cacheLoaderData(event: FetchEvent) {
|
// stores the timestamp for when each URL's cached response has been revalidated
|
||||||
// network-first
|
const lastTimeRevalidated: Record<string, number> = {};
|
||||||
const url = new URL(event.request.url);
|
|
||||||
console.debug("Serving data from network", url.pathname + url.search);
|
|
||||||
|
|
||||||
return event.preloadResponse
|
export function cacheLoaderData(event: FetchEvent): Promise<Response> {
|
||||||
.then((preloadedResponse?: Response) => preloadedResponse || fetch(event.request.clone()))
|
/*if (searchParams.get("_refresh") === "groot") {
|
||||||
.then((response) =>
|
console.debug("Serving refreshed data from network", url.pathname + url.search);
|
||||||
caches
|
return event.preloadResponse
|
||||||
.open(DATA_CACHE)
|
.then((preloadedResponse?: Response) => preloadedResponse || fetch(event.request.clone()))
|
||||||
.then((cache) => cache.put(event.request, response.clone()))
|
.then((response) =>
|
||||||
.then(() => response),
|
caches
|
||||||
)
|
.open(DATA_CACHE)
|
||||||
.catch(() => {
|
.then((cache) => cache.put(event.request, response.clone()))
|
||||||
console.debug("Serving data from network failed, falling back to cache", url.pathname + url.search);
|
.then(() =>
|
||||||
return caches.match(event.request).then((response) => {
|
response
|
||||||
if (!response) {
|
.clone()
|
||||||
return json(
|
.json()
|
||||||
{ message: "Network Error" },
|
.then(({ json }) => console.debug("ddd", json?.phoneCalls?.[0]?.recipient)),
|
||||||
{
|
)
|
||||||
status: 500,
|
.then(() => {
|
||||||
headers: { "X-Remix-Catch": "yes", "X-Remix-Worker": "yes" },
|
console.debug("returned latest", Date.now());
|
||||||
},
|
return response;
|
||||||
);
|
}),
|
||||||
}
|
)
|
||||||
|
.catch(() => {
|
||||||
|
console.debug("Serving data from network failed, falling back to cache", url.pathname + url.search);
|
||||||
|
return caches.match(url).then((response) => {
|
||||||
|
if (!response) {
|
||||||
|
return json(
|
||||||
|
{ message: "Network Error" },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "X-Remix-Catch": "yes", "X-Remix-Worker": "yes" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
response.headers.set("X-Remix-Worker", "yes");
|
response.headers.set("X-Remix-Worker", "yes");
|
||||||
return response;
|
return response;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}*/
|
||||||
|
|
||||||
|
/*return caches.match(event.request, { cacheName: DATA_CACHE }).then((cachedResponse) => {
|
||||||
|
console.debug(`Serving data from ${cachedResponse ? "cache" : "network"}`, url.pathname + url.search);
|
||||||
|
cachedResponse?.headers.set("X-Remix-Worker", "yes");
|
||||||
|
|
||||||
|
const fetchPromise = event.preloadResponse
|
||||||
|
.then((preloadedResponse?: Response) => preloadedResponse || fetch(event.request.clone()))
|
||||||
|
.then((response) =>
|
||||||
|
caches.open(DATA_CACHE).then((cache) => {
|
||||||
|
response.text().then(rrr => console.log(response.ok, url.pathname + url.search, rrr));
|
||||||
|
if (!response.ok) {
|
||||||
|
return json(
|
||||||
|
{ message: "Network Error" },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "X-Remix-Catch": "yes", "X-Remix-Worker": "yes" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.put(event.request, response.clone());
|
||||||
|
const timestamp = lastTimeResponded[url.pathname + url.search];
|
||||||
|
console.log("timestamp - Date.now()", Date.now() - timestamp);
|
||||||
|
|
||||||
|
/!*if (timestamp && (Date.now() - timestamp > 10 * 1000)) {
|
||||||
|
console.debug("update UI with latest", Date.now());
|
||||||
|
// we already returned the cached response
|
||||||
|
// we need to update the UI with the latest data
|
||||||
|
const message = {
|
||||||
|
type: "revalidateLoaderData",
|
||||||
|
// href: url.pathname + "?_refresh=groot",
|
||||||
|
};
|
||||||
|
const channel = new BroadcastChannel("sw-messages");
|
||||||
|
channel.postMessage(JSON.stringify(message));
|
||||||
|
}*!/
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
console.debug("returned cached", Date.now());
|
||||||
|
lastTimeResponded[url.pathname + url.search] = Date.now();
|
||||||
|
}
|
||||||
|
return fetchPromise.then(response => {
|
||||||
|
console.debug("returned networked", Date.now());
|
||||||
|
lastTimeResponded[url.pathname + url.search] = Date.now();
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
});*/
|
||||||
|
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
const path = url.pathname + url.search;
|
||||||
|
|
||||||
|
return caches.match(event.request, { cacheName: DATA_CACHE }).then((cachedResponse) => {
|
||||||
|
console.debug(`Serving data from ${cachedResponse ? "cache" : "network"}`, path);
|
||||||
|
cachedResponse?.headers.set("X-Remix-Worker", "yes");
|
||||||
|
|
||||||
|
const timestamp = lastTimeRevalidated[path] ?? 0;
|
||||||
|
const diff = Date.now() - timestamp;
|
||||||
|
const TEN_SECONDS = 10 * 1000;
|
||||||
|
if (cachedResponse && diff < TEN_SECONDS) {
|
||||||
|
console.debug("Returned response from cache after a revalidation no older than 10s");
|
||||||
|
// TODO: see if we can check a header or something to see if the requests comes from the revalidation thing
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPromise = event.preloadResponse
|
||||||
|
.then((preloadedResponse?: Response) => preloadedResponse || fetch(event.request.clone()))
|
||||||
|
.then((response) =>
|
||||||
|
caches.open(DATA_CACHE).then((cache) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
return json(
|
||||||
|
{ message: "Network Error" },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "X-Remix-Catch": "yes", "X-Remix-Worker": "yes" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clonedResponse = response.clone();
|
||||||
|
cache.match(event.request).then(async (cached) => {
|
||||||
|
if (!cached) {
|
||||||
|
// we had nothing cached, simply cache what we got
|
||||||
|
await cache.put(event.request, clonedResponse.clone());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await areResponsesEqual(cached.clone(), clonedResponse.clone())) {
|
||||||
|
// if what we have in the cache is up-to-date, we don't have to do anything
|
||||||
|
console.debug("Responses are the same, no need to revalidate", path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise, cache the new response
|
||||||
|
await cache.put(event.request, clonedResponse.clone());
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
// and if we had returned a cached response
|
||||||
|
// tell the UI to fetch the latest data
|
||||||
|
console.debug("Revalidate loader data", path);
|
||||||
|
const channel = new BroadcastChannel("sw-messages");
|
||||||
|
channel.postMessage("revalidateLoaderData");
|
||||||
|
lastTimeRevalidated[path] = Date.now();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return cachedResponse || fetchPromise;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function areResponsesEqual(a: Response, b: Response): Promise<boolean> {
|
||||||
|
const viewA = new DataView(await a.arrayBuffer());
|
||||||
|
const viewB = new DataView(await b.arrayBuffer());
|
||||||
|
|
||||||
|
if (viewA === viewB) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewA.byteLength !== viewB.byteLength) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let i = viewA.byteLength;
|
||||||
|
while (i--) {
|
||||||
|
if (viewA.getUint8(i) !== viewB.getUint8(i)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cacheDocument(event: FetchEvent): Promise<Response> {
|
export function cacheDocument(event: FetchEvent): Promise<Response> {
|
||||||
@ -104,3 +251,9 @@ export function cacheDocument(event: FetchEvent): Promise<Response> {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteCaches() {
|
||||||
|
console.debug("Caches deleted");
|
||||||
|
const allCaches = await caches.keys();
|
||||||
|
await Promise.all(allCaches.map((cacheName) => caches.delete(cacheName)));
|
||||||
|
}
|
||||||
|
@ -5,7 +5,7 @@ import {
|
|||||||
isAssetRequest,
|
isAssetRequest,
|
||||||
isDocumentGetRequest,
|
isDocumentGetRequest,
|
||||||
isLoaderRequest,
|
isLoaderRequest,
|
||||||
} from "~/service-worker/cache-utils";
|
} from "./cache-utils";
|
||||||
|
|
||||||
declare let self: ServiceWorkerGlobalScope;
|
declare let self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user