diff options
| author | Fuwn <[email protected]> | 2026-02-09 23:24:17 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-09 23:24:17 -0800 |
| commit | 77d239084b85b82bac9e7c7910230351f07078bb (patch) | |
| tree | 340bb8c2109f5c4261b15b6146f8ce75ab7d4f8f /apps | |
| parent | feat: offline support tier 2 — prefetch entry content and SW runtime caching (diff) | |
| download | asa.news-77d239084b85b82bac9e7c7910230351f07078bb.tar.xz asa.news-77d239084b85b82bac9e7c7910230351f07078bb.zip | |
feat: offline support tier 3 — mutation queue and image caching
Paused mutations (read/save toggles) are now persisted to IndexedDB
and automatically resumed on reconnection or page reload via TanStack
Query's offlineFirst networkMode. Service worker caches images with
CacheFirst strategy (500 entries, 7-day expiry) for offline reading.
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/app/providers.tsx | 80 | ||||
| -rw-r--r-- | apps/web/app/sw.ts | 14 | ||||
| -rw-r--r-- | apps/web/lib/queries/use-entry-state-mutations.ts | 3 | ||||
| -rw-r--r-- | apps/web/lib/query-client.ts | 9 |
4 files changed, 103 insertions, 3 deletions
diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index 8551748..550b30c 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -1,10 +1,11 @@ "use client" -import { useState } from "react" +import { useState, useEffect } from "react" import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client" import { Toaster } from "sonner" -import { createQueryClient } from "@/lib/query-client" +import { createQueryClient, MUTATION_KEYS } from "@/lib/query-client" import { createIndexedDatabasePersister } from "@/lib/indexed-database-persister" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" const PERSISTABLE_QUERY_KEY_PREFIXES = [ "timeline", @@ -18,10 +19,82 @@ const PERSISTABLE_QUERY_KEY_PREFIXES = [ "custom-feed-timeline", ] +function useResumableMutations(queryClient: ReturnType<typeof createQueryClient>) { + useEffect(() => { + const supabaseClient = createSupabaseBrowserClient() + + queryClient.setMutationDefaults(MUTATION_KEYS.toggleEntryReadState, { + mutationFn: async ({ + entryIdentifier, + isRead, + }: { + entryIdentifier: string + isRead: boolean + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("not authenticated") + + const { error } = await supabaseClient + .from("user_entry_states") + .upsert( + { + user_id: user.id, + entry_id: entryIdentifier, + read: isRead, + read_at: isRead ? new Date().toISOString() : null, + }, + { onConflict: "user_id,entry_id" } + ) + + if (error) throw error + }, + }) + + queryClient.setMutationDefaults(MUTATION_KEYS.toggleEntrySavedState, { + mutationFn: async ({ + entryIdentifier, + isSaved, + }: { + entryIdentifier: string + isSaved: boolean + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("not authenticated") + + const { error } = await supabaseClient + .from("user_entry_states") + .upsert( + { + user_id: user.id, + entry_id: entryIdentifier, + saved: isSaved, + saved_at: isSaved ? new Date().toISOString() : null, + }, + { onConflict: "user_id,entry_id" } + ) + + if (error) throw error + }, + }) + + queryClient.resumePausedMutations().then(() => { + queryClient.invalidateQueries() + }) + }, [queryClient]) +} + export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState(createQueryClient) const [persister] = useState(createIndexedDatabasePersister) + useResumableMutations(queryClient) + return ( <PersistQueryClientProvider client={queryClient} @@ -33,6 +106,9 @@ export function Providers({ children }: { children: React.ReactNode }) { const prefix = query.queryKey[0] return typeof prefix === "string" && PERSISTABLE_QUERY_KEY_PREFIXES.includes(prefix) }, + shouldDehydrateMutation: (mutation) => { + return mutation.state.isPaused + }, }, }} > diff --git a/apps/web/app/sw.ts b/apps/web/app/sw.ts index e18cc5a..f3bb4a8 100644 --- a/apps/web/app/sw.ts +++ b/apps/web/app/sw.ts @@ -1,7 +1,7 @@ /// <reference lib="webworker" /> import { defaultCache } from "@serwist/next/worker" import type { PrecacheEntry, SerwistGlobalConfig } from "serwist" -import { Serwist, NetworkFirst, ExpirationPlugin } from "serwist" +import { Serwist, NetworkFirst, CacheFirst, ExpirationPlugin } from "serwist" declare global { interface WorkerGlobalScope extends SerwistGlobalConfig { @@ -44,6 +44,18 @@ const serwist = new Serwist({ ], }), }, + { + matcher: ({ request }) => request.destination === "image", + handler: new CacheFirst({ + cacheName: "entry-images", + plugins: [ + new ExpirationPlugin({ + maxEntries: 500, + maxAgeSeconds: 60 * 60 * 24 * 7, + }), + ], + }), + }, ...sameOriginCache, ], }) diff --git a/apps/web/lib/queries/use-entry-state-mutations.ts b/apps/web/lib/queries/use-entry-state-mutations.ts index 80bab79..b5f64e9 100644 --- a/apps/web/lib/queries/use-entry-state-mutations.ts +++ b/apps/web/lib/queries/use-entry-state-mutations.ts @@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query" import { createSupabaseBrowserClient } from "@/lib/supabase/client" import { queryKeys } from "./query-keys" +import { MUTATION_KEYS } from "@/lib/query-client" import type { TimelineEntry } from "@/lib/types/timeline" import type { InfiniteData } from "@tanstack/react-query" @@ -11,6 +12,7 @@ export function useToggleEntryReadState() { const queryClient = useQueryClient() return useMutation({ + mutationKey: MUTATION_KEYS.toggleEntryReadState, mutationFn: async ({ entryIdentifier, isRead, @@ -84,6 +86,7 @@ export function useToggleEntrySavedState() { const queryClient = useQueryClient() return useMutation({ + mutationKey: MUTATION_KEYS.toggleEntrySavedState, mutationFn: async ({ entryIdentifier, isSaved, diff --git a/apps/web/lib/query-client.ts b/apps/web/lib/query-client.ts index c64e4c7..1e500a0 100644 --- a/apps/web/lib/query-client.ts +++ b/apps/web/lib/query-client.ts @@ -2,6 +2,11 @@ import { QueryClient } from "@tanstack/react-query" const TWENTY_FOUR_HOURS_IN_MILLISECONDS = 1000 * 60 * 60 * 24 +export const MUTATION_KEYS = { + toggleEntryReadState: ["toggle-entry-read-state"] as const, + toggleEntrySavedState: ["toggle-entry-saved-state"] as const, +} + export function createQueryClient() { return new QueryClient({ defaultOptions: { @@ -10,6 +15,10 @@ export function createQueryClient() { gcTime: TWENTY_FOUR_HOURS_IN_MILLISECONDS, refetchOnWindowFocus: false, }, + mutations: { + networkMode: "offlineFirst", + gcTime: TWENTY_FOUR_HOURS_IN_MILLISECONDS, + }, }, }) } |