Skip to content

refactor(cards): simplify placeholder text + drive loading state from Realtime#951

Merged
navin-moorthy merged 8 commits intodevfrom
feat/simplify-card-loading-states
Apr 20, 2026
Merged

refactor(cards): simplify placeholder text + drive loading state from Realtime#951
navin-moorthy merged 8 commits intodevfrom
feat/simplify-card-loading-states

Conversation

@rogerantony-dev
Copy link
Copy Markdown
Contributor

@rogerantony-dev rogerantony-dev commented Apr 17, 2026

Summary

Stacks on #950. Two changes:

  1. Collapse bookmark-card placeholder text to two states. Replaces the four-way status ("Fetching data...", "Taking screenshot....", "Cannot fetch image for this bookmark") with a binary: while the image is on its way show "Getting screenshot"; once the pipeline is done with no image, render no text at all. Loader gif stays as the visual indicator.

  2. Drive loading state from the Realtime subscription manager instead of Zustand. The loadingBookmarkIds set in useLoadersStore was a parallel signal that had to be manually kept in sync with the enrichment lifecycle. The subscription manager from feat(realtime): live screenshot + enrichment updates for in-session adds #950 already tracks "is this bookmark being enriched?" — this PR plumbs it out as the single source of truth and deletes the Zustand set.

Why

Before #950, the only way to ask "is this bookmark loading?" was the manually-maintained Zustand set. After #950 we also had the subscription manager's internal active map tracking the same concept. Two sources of truth for one signal is a maintenance hazard. This PR collapses them into one.

What changed

  • Manager (bookmark-enrichment-subscription.ts)

    • subscribeToBookmarkEnrichmentChanges(listener) → unsubscribe
    • isBookmarkEnrichmentActive(id)true while active or queued
    • notifyListeners() fires on open, teardown, queue push
    • New teardown reason "not_applicable" for media paths that don't feed the pipeline
  • New hook (src/hooks/use-bookmark-enrichment-active.ts)

    • Wraps useSyncExternalStore with manager's subscribe + snapshot
    • Drop-in replacement for useLoadersStore((s) => s.loadingBookmarkIds.has(id))
  • Terminal condition is now media-aware (bookmark-realtime-payload.ts)

    • PDF (meta_data.mediaType === "application/pdf"): terminal = ogImage populated (PDF flow writes thumbnail URL to ogImage, never meta_data.screenshot)
    • Regular URL: unchanged — terminal = both meta_data.screenshot + ogImage set
  • Subscription lifecycle moved earlier (use-add-bookmark-min-data-optimistic-mutation.ts)

    • Opens in onSuccess right after the temp → real ID swap (was: inside the IIFE screenshot branch)
    • Closes the ~200–1000ms gap where the card had no loading signal while checkIfUrlAnImage + getMediaType probed content type
    • IIFE tears down via "not_applicable" on image/audio early-returns
    • PDF path keeps subscription alive through handlePdfThumbnailAndUpload; the terminal condition catches the DB write from uploadFileRemainingData
  • Screenshot mutation (use-add-bookmark-screenshot-mutation.ts)

    • Zustand writes removed; still tears down on error
  • Store cleanup

    • componentStore.ts — dropped loadingBookmarkIds, addLoadingBookmarkId, removeLoadingBookmarkId (−14 lines)
    • componentStoreTypes.ts — matching interface trim
  • Card placeholder (animatedBookmarkImage.tsx)

    • 4 states → 2 states: "Getting screenshot" while loading, null when done
    • <motion.p> skipped entirely when no text — no empty <p> on screen

Out of scope

Testing

  • Add regular URL — card shows "Getting screenshot" until screenshot lands, then swaps to the image; no text once the final ogImage is in place
  • Add image URL (e.g., https://picsum.photos/300) — subscription briefly opens then tears down with "not_applicable"; card shows image from min-data response without flicker
  • Add audio URL — same as image path; fallback ogImage shows; subscription cleaned up
  • Add PDF URL — "Getting screenshot" persists through thumbnail generation; swaps to PDF thumbnail; subscription tears down via PDF terminal condition
  • Force screenshot API failure (network throttle) — toast shows; subscription torn down via "screenshot_failed"; card drops to no-text state
  • Existing bookmarks page-load — no "Cannot fetch" label anymore, just loader gif + no text for missing images
  • StrictMode dev — no double-open warnings, no leaked channels

Post-Deploy Monitoring & Validation

  • What to monitor/search
    • Sentry breadcrumbs (category: realtime-bookmark) — watch for a rise in { reason: "not_applicable" } (expected — new reason) and the overall ratio terminal : timeout staying >80:20
    • Axiom route v2-bookmark-add-url-screenshot — no change in success rate expected; the client-side change doesn't alter server behavior
  • Validation checks
    • In DevTools → Network → WS: add a regular URL, confirm exactly one channel join per add, clean close on terminal state
    • In DevTools → React Profiler: confirm card only re-renders on actual field changes (memo still effective)
  • Expected healthy behavior
    • Card placeholder text never flickers between states mid-load
    • PDF additions show "Getting screenshot" continuously until thumbnail appears
  • Failure signals / rollback trigger
    • Cards stuck on "Getting screenshot" for >90s → investigate subscription teardown path; may indicate PDF terminal condition regression
    • not_applicable teardown events not appearing for image URLs → IIFE teardown branch is broken
  • Validation window & owner
    • 24 hours post-deploy, owner: PR author

Compound Engineered 🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Fixed loading status display for bookmarks during enrichment and screenshot processing
    • Improved handling of different media types (PDFs, images, audio) to correctly display processing states
  • New Features

    • Enhanced bookmark enrichment status tracking for more accurate UI feedback
  • Refactor

    • Streamlined internal state management for bookmark processing workflows

Replaces the four-way status text ("Fetching data...", "Taking
screenshot....", "Cannot fetch image for this bookmark") with a simple
binary: while the image is on its way (optimistic insert, server
pipeline running, or preload crossfade) show "Getting screenshot"; once
the pipeline is done with no image, render no text at all. The loading
gif stays to indicate the placeholder state visually.
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
recollect Ready Ready Preview Apr 20, 2026 11:02am

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 17, 2026

Warning

Rate limit exceeded

@rogerantony-dev has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 32 minutes and 17 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 32 minutes and 17 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 24346ab8-fb81-43c2-9880-dbfa7927f72c

📥 Commits

Reviewing files that changed from the base of the PR and between e4d16ff and 958ee45.

📒 Files selected for processing (4)
  • src/async/mutationHooks/bookmarks/use-add-bookmark-min-data-optimistic-mutation.ts
  • src/lib/supabase/realtime/bookmark-enrichment-subscription.ts
  • src/lib/supabase/realtime/bookmark-realtime-payload.ts
  • src/pageComponents/dashboard/cardSection/animatedBookmarkImage.tsx
📝 Walkthrough

Walkthrough

The pull request migrates bookmark loading state management from a store-based system (useLoadersStore with loadingBookmarkIds) to a Supabase realtime subscription-based approach. It introduces new APIs for tracking enrichment active status, updates mutation hooks to control subscription lifecycle, and simplifies cache-update logic while making terminal state detection media-type aware.

Changes

Cohort / File(s) Summary
Enrichment Subscription Infrastructure
src/lib/supabase/realtime/bookmark-enrichment-subscription.ts, src/hooks/use-bookmark-enrichment-active.ts
Added listener notification mechanism and new exported APIs (subscribeToBookmarkEnrichmentChanges, isBookmarkEnrichmentActive); introduced useBookmarkEnrichmentActive hook using useSyncExternalStore for reactive enrichment state tracking.
Mutation Hooks
src/async/mutationHooks/bookmarks/use-add-bookmark-min-data-optimistic-mutation.ts, src/async/mutationHooks/bookmarks/use-add-bookmark-screenshot-mutation.ts
Reorganized enrichment subscription lifecycle: moved openBookmarkEnrichmentSubscription to onSuccess (after temp ID swap), added explicit teardownBookmarkEnrichmentSubscription calls for non-screenshot paths (images, audio, PDF failures); removed addLoadingBookmarkId/removeLoadingBookmarkId usage and simplified screenshot mutation's cache-update logic to use query invalidation instead of direct mutation.
Realtime Payload Parsing
src/lib/supabase/realtime/bookmark-realtime-payload.ts
Updated isRowTerminal to be media-type aware: PDF rows now require ogImage plus isBookmarkEnrichmentDone; non-PDF rows require screenshot plus isBookmarkEnrichmentDone.
UI Components
src/pageComponents/dashboard/cardSection/animatedBookmarkImage.tsx, src/pageComponents/dashboard/cardSection/imageCard.tsx
Replaced useLoadersStore loading state with useBookmarkEnrichmentActive hook; simplified status text logic in LoaderImgPlaceholder.
Store & Type Definitions
src/store/componentStore.ts, src/types/componentStoreTypes.ts
Removed addLoadingBookmarkId, removeLoadingBookmarkId actions and loadingBookmarkIds state from useLoadersStore; removed corresponding type members from LoadersStoreState.

Sequence Diagram

sequenceDiagram
    participant UI as UI Component
    participant Mutation as Add Bookmark Mutation
    participant Enrichment as Enrichment Subscription
    participant Realtime as Supabase Realtime
    participant Store as External Store
    
    UI->>Mutation: mutate(bookmarkData)
    Mutation->>Mutation: onSuccess (optimistic temp ID → real ID)
    Mutation->>Enrichment: openBookmarkEnrichmentSubscription(realID)
    Enrichment->>Store: notifyListeners()
    Store->>UI: subscribeToBookmarkEnrichmentChanges
    UI->>UI: useBookmarkEnrichmentActive(realID) → true
    
    alt Screenshot Path
        Mutation->>Mutation: trigger screenshot mutation
        Realtime->>Enrichment: screenshot/enrichment complete
        Enrichment->>Store: notifyListeners()
        Enrichment->>Enrichment: teardownBookmarkEnrichmentSubscription
        Store->>UI: subscribeToBookmarkEnrichmentChanges
        UI->>UI: useBookmarkEnrichmentActive(realID) → false
    else Non-Screenshot Path
        Mutation->>Enrichment: teardownBookmarkEnrichmentSubscription(not_applicable)
        Enrichment->>Store: notifyListeners()
        Store->>UI: subscribeToBookmarkEnrichmentChanges
        UI->>UI: useBookmarkEnrichmentActive(realID) → false
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • navin-moorthy

Poem

🐰 A rabbit hops with glee so bright,
✨ From store-based state to subscriptions light,
🔄 No loading IDs cluttering the way,
📡 Just realtime whispers, day by day,
🎯 The enrichment flows where it should be—
Clean, reactive, and subscription-free! 🐇

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the two main changes: simplifying placeholder text logic and refactoring loading state management to use the Realtime subscription system instead of Zustand.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/simplify-card-loading-states

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Removes the Zustand loadingBookmarkIds set in favor of the Realtime
subscription manager as the single source of truth for "is this bookmark
still being enriched?". The store was a parallel signal that had to be
manually kept in sync with the subscription lifecycle — now the card
reads the same state the manager already tracks.

Changes:

- Manager exposes subscribeToBookmarkEnrichmentChanges +
  isBookmarkEnrichmentActive; notifyListeners fires on open, teardown,
  queue push.
- useBookmarkEnrichmentActive(id) hook wraps useSyncExternalStore.
- Add-bookmark mutation opens the subscription in onSuccess (right after
  the temp → real ID swap) instead of waiting for the media-type checks.
  Closes the ~200-1000ms gap where the card showed no loading state while
  checkIfUrlAnImage + getMediaType resolved.
- IIFE tears down via new "not_applicable" reason on image/audio paths
  once the media check confirms no screenshot pipeline will run.
- PDF path keeps subscription alive through handlePdfThumbnailAndUpload;
  terminal condition now covers PDF (ogImage set when meta_data.mediaType
  is application/pdf). Only tears down if both thumbnail retries fail.
- Screenshot mutation drops Zustand writes; still tears down on error.
- componentStore loses loadingBookmarkIds, addLoadingBookmarkId,
  removeLoadingBookmarkId; LoadersStoreState interface trimmed to match.
Base automatically changed from feat/realtime-bookmark-enrichment to dev April 20, 2026 09:08
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/async/mutationHooks/bookmarks/use-add-bookmark-min-data-optimistic-mutation.ts (1)

205-249: ⚠️ Potential issue | 🟠 Major

Handle media-probe failures before leaving the subscription active.

If checkIfUrlAnImage() or getMediaType() rejects, this void IIFE exits before calling the screenshot mutation or tearing down the Realtime subscription, leaving the card in "Getting screenshot" until the 90s timeout. Fall back to the screenshot path or explicitly teardown in an outer catch.

🛠️ Proposed fix
       void (async () => {
-        const isUrlOfMimeType = await checkIfUrlAnImage(url);
-        if (isUrlOfMimeType) {
-          void teardownBookmarkEnrichmentSubscription(data.id, "not_applicable");
-          return;
-        }
-
-        const mediaType = await getMediaType(url);
-        // Audio URLs already have ogImage fallback set in add-bookmark-min-data
-        if (mediaType?.includes("audio")) {
-          void teardownBookmarkEnrichmentSubscription(data.id, "not_applicable");
-          return;
-        }
-
-        if (mediaType === PDF_MIME_TYPE || URL_PDF_CHECK_PATTERN.test(url)) {
-          try {
-            successToast("Generating thumbnail");
-            await handlePdfThumbnailAndUpload({
-              fileId: data.id,
-              fileUrl: data.url,
-              sessionUserId: session?.user?.id,
-            });
-          } catch {
-            try {
-              errorToast("retry thumbnail generation");
-              await handlePdfThumbnailAndUpload({
-                fileId: data.id,
-                fileUrl: data.url,
-                sessionUserId: session?.user?.id,
-              });
-            } catch (retryError) {
-              console.error("PDF thumbnail upload failed after retry:", retryError);
-              errorToast("thumbnail generation failed");
-              void teardownBookmarkEnrichmentSubscription(data.id, "screenshot_failed");
-            }
-          } finally {
-            void queryClient.invalidateQueries({
-              queryKey: [BOOKMARKS_KEY, session?.user?.id],
-            });
-          }
-          return;
-        }
-
-        addBookmarkScreenshotMutation.mutate({ id: data.id, url: data.url });
+        try {
+          const isUrlOfMimeType = await checkIfUrlAnImage(url);
+          if (isUrlOfMimeType) {
+            void teardownBookmarkEnrichmentSubscription(data.id, "not_applicable");
+            return;
+          }
+
+          const mediaType = await getMediaType(url);
+          // Audio URLs already have ogImage fallback set in add-bookmark-min-data
+          if (mediaType?.includes("audio")) {
+            void teardownBookmarkEnrichmentSubscription(data.id, "not_applicable");
+            return;
+          }
+
+          if (mediaType === PDF_MIME_TYPE || URL_PDF_CHECK_PATTERN.test(url)) {
+            try {
+              successToast("Generating thumbnail");
+              await handlePdfThumbnailAndUpload({
+                fileId: data.id,
+                fileUrl: data.url,
+                sessionUserId: session?.user?.id,
+              });
+            } catch {
+              try {
+                errorToast("retry thumbnail generation");
+                await handlePdfThumbnailAndUpload({
+                  fileId: data.id,
+                  fileUrl: data.url,
+                  sessionUserId: session?.user?.id,
+                });
+              } catch (retryError) {
+                console.error("PDF thumbnail upload failed after retry:", retryError);
+                errorToast("thumbnail generation failed");
+                void teardownBookmarkEnrichmentSubscription(data.id, "screenshot_failed");
+              }
+            } finally {
+              void queryClient.invalidateQueries({
+                queryKey: [BOOKMARKS_KEY, session?.user?.id],
+              });
+            }
+            return;
+          }
+        } catch (mediaProbeError) {
+          console.error("Bookmark media probing failed; falling back to screenshot:", mediaProbeError);
+        }
+
+        addBookmarkScreenshotMutation.mutate({ id: data.id, url: data.url });
       })();

As per coding guidelines, "Always include proper error handling and logging."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/async/mutationHooks/bookmarks/use-add-bookmark-min-data-optimistic-mutation.ts`
around lines 205 - 249, The IIFE can throw if checkIfUrlAnImage() or
getMediaType() rejects, leaving the realtime subscription active; wrap the
media-probing logic in a try/catch (or add an outer try/catch around the whole
async IIFE) so any rejection either calls
teardownBookmarkEnrichmentSubscription(data.id, "screenshot_failed") (or a
sensible fallback like "not_applicable") and logs the error, or falls back to
the screenshot path by calling addBookmarkScreenshotMutation.mutate({ id:
data.id, url: data.url }); ensure you still run
queryClient.invalidateQueries([BOOKMARKS_KEY, session?.user?.id]) where
appropriate and surface errors (e.g., console.error) when
handlePdfThumbnailAndUpload or probes fail so failures are not silently
swallowed.
src/lib/supabase/realtime/bookmark-enrichment-subscription.ts (1)

259-271: ⚠️ Potential issue | 🟠 Major

Notify listeners when dequeuing a waiting bookmark.

isBookmarkEnrichmentActive() returns true for queued entries, but this teardown path removes a queued bookmark without notifying subscribers. In a burst over MAX_CONCURRENT_CHANNELS, an image/audio URL can be dequeued via "not_applicable" and the card will keep showing active loading until another unrelated subscription event fires.

🛠️ Proposed fix
     if (queuedIndex !== -1) {
       waiting.splice(queuedIndex, 1);
       logEvent("dequeued before subscribe", bookmarkId, { reason });
+      notifyListeners();
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/supabase/realtime/bookmark-enrichment-subscription.ts` around lines
259 - 271, When removing a queued entry in teardown(bookmarkId, reason) you must
also notify subscribers so UI consumers relying on isBookmarkEnrichmentActive()
stop showing loading; after waiting.splice(...) and logEvent("dequeued before
subscribe", ...) invoke the same notification/event path used for active
teardowns in this module (the function that broadcasts subscription state
changes to listeners) with bookmarkId and a non-active status/reason (e.g.,
"not_applicable"), so listeners receive an update that the bookmark was dequeued
and is no longer active/queued.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In
`@src/async/mutationHooks/bookmarks/use-add-bookmark-min-data-optimistic-mutation.ts`:
- Around line 205-249: The IIFE can throw if checkIfUrlAnImage() or
getMediaType() rejects, leaving the realtime subscription active; wrap the
media-probing logic in a try/catch (or add an outer try/catch around the whole
async IIFE) so any rejection either calls
teardownBookmarkEnrichmentSubscription(data.id, "screenshot_failed") (or a
sensible fallback like "not_applicable") and logs the error, or falls back to
the screenshot path by calling addBookmarkScreenshotMutation.mutate({ id:
data.id, url: data.url }); ensure you still run
queryClient.invalidateQueries([BOOKMARKS_KEY, session?.user?.id]) where
appropriate and surface errors (e.g., console.error) when
handlePdfThumbnailAndUpload or probes fail so failures are not silently
swallowed.

In `@src/lib/supabase/realtime/bookmark-enrichment-subscription.ts`:
- Around line 259-271: When removing a queued entry in teardown(bookmarkId,
reason) you must also notify subscribers so UI consumers relying on
isBookmarkEnrichmentActive() stop showing loading; after waiting.splice(...) and
logEvent("dequeued before subscribe", ...) invoke the same notification/event
path used for active teardowns in this module (the function that broadcasts
subscription state changes to listeners) with bookmarkId and a non-active
status/reason (e.g., "not_applicable"), so listeners receive an update that the
bookmark was dequeued and is no longer active/queued.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 32e81fb2-d3f6-45af-b4ec-e7db3ccdc601

📥 Commits

Reviewing files that changed from the base of the PR and between 247f06f and e4d16ff.

📒 Files selected for processing (9)
  • src/async/mutationHooks/bookmarks/use-add-bookmark-min-data-optimistic-mutation.ts
  • src/async/mutationHooks/bookmarks/use-add-bookmark-screenshot-mutation.ts
  • src/hooks/use-bookmark-enrichment-active.ts
  • src/lib/supabase/realtime/bookmark-enrichment-subscription.ts
  • src/lib/supabase/realtime/bookmark-realtime-payload.ts
  • src/pageComponents/dashboard/cardSection/animatedBookmarkImage.tsx
  • src/pageComponents/dashboard/cardSection/imageCard.tsx
  • src/store/componentStore.ts
  • src/types/componentStoreTypes.ts
💤 Files with no reviewable changes (2)
  • src/types/componentStoreTypes.ts
  • src/store/componentStore.ts

`useSyncExternalStore` consumers (card loading indicator) went stale
when a bookmark id was dropped from `waiting` without passing through
`teardown()`'s notify call:

- Teardown of a queued-but-never-promoted id (rapid burst >5 adds,
  then non-enriching media type resolves) spliced the entry silently.
  Card stuck on "Getting screenshot" — queued ids don't hit the 90s
  timeout either, that only fires for active records.
- `teardownAllBookmarkEnrichmentSubscriptions` cleared `waiting` in
  place on sign-out without notifying.
The onSettled IIFE opened the subscription in onSuccess then did the
media-type probe + PDF path + screenshot mutation. Any unexpected
throw (network error in checkIfUrlAnImage/getMediaType, toast lib
failure, React Query internal throw) was swallowed by `void`, leaving
the subscription dangling until the 90s timeout — card stuck on
"Getting screenshot" with no actual pipeline running.

Wrap the body in try/catch with a "screenshot_failed" teardown so
the loading indicator always clears on unexpected failures.
Placing \`statusText &&\` outside \`AnimatePresence\` unmounted the
entire presence tree when the text went null, so the \`exit\`
transition on \`<motion.p>\` never ran — the text snapped out
instantly while the surrounding placeholder faded over 0.15s,
causing visible jank at the end of the loading state.

Move the conditional inside \`AnimatePresence\` so the exit
animation is driven by child removal, not parent unmount.
\`isRowTerminal\` branches on \`meta_data.mediaType\` to pick between
the PDF and regular-URL terminal conditions, but the field was not
listed in \`MetaDataRealtimeSchema\` — it passed through the catchall
as \`unknown\`, forcing a \`typeof === "string"\` narrow at the use
site. The field is as load-bearing as \`screenshot\` and \`ocr_status\`;
declare it in the schema so the contract is explicit.
@rogerantony-dev
Copy link
Copy Markdown
Contributor Author

updated loading placeholder:

Screen.Recording.2026-04-20.at.4.32.27.PM.mov

placeholder when image is not there:

CleanShot 2026-04-20 at 16 33 02@2x

Comment on lines -42 to -83
onSettled: (response) => {
if (response?.[0]) {
const [updated] = response;

// Inject the screenshot response into the paginated cache
// synchronously, in the SAME callback as removeLoadingBookmarkId.
// React batches the two updates into a single render — the card
// never sees (img=null && isLoading=false) and so never falls
// through the statusText ladder to TERMINAL.
//
// The prior approach (`await invalidateQueries` then
// removeLoadingBookmarkId) looked correct on paper, but in
// practice the cache notify and the Zustand store update
// committed in two separate React renders — the first render
// landed (img=null, isLoading=false) and produced the exit-side
// "Cannot fetch image..." flash. Synchronous injection
// eliminates that race. The screenshot response is the source
// of truth for ogImage at this point: the route handler just
// backfilled it from the captured screenshot (when the scraped
// ogImage was missing or malformed) and returned the updated
// row. A background refetch (below) still picks up later
// addRemainingBookmarkData() enrichments (img_caption, ocr,
// blurhash, additional cover images).
queryClient.setQueryData<PaginatedBookmarks>(
[BOOKMARKS_KEY, session?.user?.id, CATEGORY_ID, sortBy],
(old) => {
if (!old) {
return old;
}
return {
...old,
pages: old.pages.map((page) =>
page.map((bookmark) =>
bookmark.id === updated.id ? { ...bookmark, ...updated } : bookmark,
),
),
} as PaginatedBookmarks;
},
);

removeLoadingBookmarkId(updated.id);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did we removed this @rogerantony-dev ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The synchronous setQueryData + removeLoadingBookmarkId block you added in 047d1b7 existed to paper over the race between the cache notify and the Zustand loading flag — landing them in two separate renders meant the card briefly saw (img=null, isLoading=false) and flashed the Cannot fetch image... terminal of the 4-state statusText ladder.

Both legs of that race are gone in this PR:

  1. The statusText ladder collapsed to 2 states. animatedBookmarkImage.tsx now renders Getting screenshot while loading and null otherwise — there is no Cannot fetch image terminal branch to flash through, so the mid-render gap is visually inert even if it happened.
  2. Loading state is no longer driven by Zustand. The card reads useBookmarkEnrichmentActive(id) off the subscription manager (useSyncExternalStore). The subscription stays active until the Realtime terminal condition fires (meta_data.screenshot + ogImage both set, or PDF thumbnail write for PDFs). The screenshot mutation returning is not what flips isLoading to false anymore — the Realtime UPDATE is, and it carries the image with it.
  3. The cache splice already happens. When the screenshot route's first DB write lands, the Realtime UPDATE fires and applyBookmarkUpdate splices the row across every matching paginated / search / alt-category cache. The setQueryData in onSettled would re-do the same merge a few ms later.

So the synchronous injection became redundant once (1) the ladder only has loading / done, (2) loading comes from the subscription, not the mutation's return, and (3) the Realtime splice is the authoritative client-side cache write. The invalidateQueries is kept purely as a safety net for the downstream addRemainingBookmarkData enrichments (img_caption, OCR, blurhash, extra covers) that aren't on the Realtime publication.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ji since we are removing zustand state for loading completely, this fix is no longer needed

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it

@navin-moorthy navin-moorthy merged commit 9e8f6c9 into dev Apr 20, 2026
14 checks passed
@navin-moorthy navin-moorthy deleted the feat/simplify-card-loading-states branch April 20, 2026 11:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants