From bd35899a42e84a4a20c649e80adaebff057de43d Mon Sep 17 00:00:00 2001 From: Fuwn Date: Mon, 9 Feb 2026 23:15:58 -0800 Subject: =?UTF-8?q?feat:=20offline=20support=20tier=202=20=E2=80=94=20pref?= =?UTF-8?q?etch=20entry=20content=20and=20SW=20runtime=20caching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prefetch content_html for the first 10 timeline entries in the background so they are available offline without needing to click each one. Add NetworkFirst runtime caching in service worker for Supabase REST GET requests (24h expiry, 200 entry limit). --- apps/web/app/reader/_components/entry-list.tsx | 15 +++++--- apps/web/app/sw.ts | 22 ++++++++++-- apps/web/lib/hooks/use-prefetch-entry-details.ts | 45 ++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 apps/web/lib/hooks/use-prefetch-entry-details.ts (limited to 'apps/web') diff --git a/apps/web/app/reader/_components/entry-list.tsx b/apps/web/app/reader/_components/entry-list.tsx index acc0990..65e35f7 100644 --- a/apps/web/app/reader/_components/entry-list.tsx +++ b/apps/web/app/reader/_components/entry-list.tsx @@ -1,12 +1,13 @@ "use client" -import { useRef, useEffect } from "react" +import { useRef, useEffect, useMemo } from "react" import { useVirtualizer } from "@tanstack/react-virtual" import { useTimeline } from "@/lib/queries/use-timeline" import { useSavedEntries } from "@/lib/queries/use-saved-entries" import { useCustomFeedTimeline } from "@/lib/queries/use-custom-feed-timeline" import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" import { EntryListItem } from "./entry-list-item" +import { usePrefetchEntryDetails } from "@/lib/hooks/use-prefetch-entry-details" interface EntryListProperties { feedFilter: "all" | "saved" @@ -77,11 +78,15 @@ export function EntryList({ const firstEntryIdentifier = allEntries[0]?.entryIdentifier const lastEntryIdentifier = allEntries[allEntries.length - 1]?.entryIdentifier + const prefetchIdentifiers = useMemo( + () => allEntries.map((entry) => entry.entryIdentifier), + [firstEntryIdentifier, lastEntryIdentifier, allEntries.length] + ) + usePrefetchEntryDetails(prefetchIdentifiers) + useEffect(() => { - setNavigableEntryIdentifiers( - allEntries.map((entry) => entry.entryIdentifier) - ) - }, [firstEntryIdentifier, lastEntryIdentifier, allEntries.length, setNavigableEntryIdentifiers]) + setNavigableEntryIdentifiers(prefetchIdentifiers) + }, [prefetchIdentifiers, setNavigableEntryIdentifiers]) function getEstimatedItemSize() { switch (entryListViewMode) { diff --git a/apps/web/app/sw.ts b/apps/web/app/sw.ts index 0469377..e18cc5a 100644 --- a/apps/web/app/sw.ts +++ b/apps/web/app/sw.ts @@ -1,7 +1,7 @@ /// import { defaultCache } from "@serwist/next/worker" import type { PrecacheEntry, SerwistGlobalConfig } from "serwist" -import { Serwist } from "serwist" +import { Serwist, NetworkFirst, ExpirationPlugin } from "serwist" declare global { interface WorkerGlobalScope extends SerwistGlobalConfig { @@ -27,7 +27,25 @@ const serwist = new Serwist({ skipWaiting: true, clientsClaim: true, navigationPreload: true, - runtimeCaching: sameOriginCache, + runtimeCaching: [ + { + matcher: ({ url, request }) => + url.hostname.endsWith(".supabase.co") && + url.pathname.startsWith("/rest/v1/") && + request.method === "GET", + handler: new NetworkFirst({ + cacheName: "supabase-rest-get", + networkTimeoutSeconds: 10, + plugins: [ + new ExpirationPlugin({ + maxEntries: 200, + maxAgeSeconds: 60 * 60 * 24, + }), + ], + }), + }, + ...sameOriginCache, + ], }) serwist.addEventListeners() diff --git a/apps/web/lib/hooks/use-prefetch-entry-details.ts b/apps/web/lib/hooks/use-prefetch-entry-details.ts new file mode 100644 index 0000000..8b88bb8 --- /dev/null +++ b/apps/web/lib/hooks/use-prefetch-entry-details.ts @@ -0,0 +1,45 @@ +"use client" + +import { useEffect } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "@/lib/queries/query-keys" + +const PREFETCH_BATCH_SIZE = 10 + +export function usePrefetchEntryDetails(entryIdentifiers: string[]) { + const queryClient = useQueryClient() + + useEffect(() => { + if (entryIdentifiers.length === 0) return + + const supabaseClient = createSupabaseBrowserClient() + const identifiersToPrefetch = entryIdentifiers.slice(0, PREFETCH_BATCH_SIZE) + + for (const entryIdentifier of identifiersToPrefetch) { + const existingData = queryClient.getQueryData( + queryKeys.entryDetail.single(entryIdentifier) + ) + + if (existingData) continue + + queryClient.prefetchQuery({ + queryKey: queryKeys.entryDetail.single(entryIdentifier), + queryFn: async () => { + const { data, error } = await supabaseClient + .from("entries") + .select( + "id, title, url, author, content_html, summary, published_at, enclosure_url, feeds!inner(title)" + ) + .eq("id", entryIdentifier) + .single() + + if (error) throw error + + return data + }, + staleTime: 5 * 60 * 1000, + }) + } + }, [entryIdentifiers, queryClient]) +} -- cgit v1.2.3