Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import type {
} from "../../../types/apiTypes";

import { api } from "@/lib/api-helpers/api-v2";
import { openBookmarkEnrichmentSubscription } from "@/lib/supabase/realtime/bookmark-enrichment-subscription";
import {
openBookmarkEnrichmentSubscription,
teardownBookmarkEnrichmentSubscription,
} from "@/lib/supabase/realtime/bookmark-enrichment-subscription";

import useGetCurrentCategoryId from "../../../hooks/useGetCurrentCategoryId";
import useGetSortBy from "../../../hooks/useGetSortBy";
Expand Down Expand Up @@ -44,7 +47,7 @@ export default function useAddBookmarkMinDataOptimisticMutation() {
// We'll initialize the mutation with a default value and update it when we have the actual ID
const { addBookmarkScreenshotMutation } = useAddBookmarkScreenshotMutation();
const { sortBy } = useGetSortBy();
const { addLoadingBookmarkId, removeLoadingBookmarkId, setIsBookmarkAdding } = useLoadersStore();
const { setIsBookmarkAdding } = useLoadersStore();

const addBookmarkMinDataOptimisticMutation = useMutation<
SingleListData[],
Expand Down Expand Up @@ -191,80 +194,73 @@ export default function useAddBookmarkMinDataOptimisticMutation() {
const url = data?.url;

// Heavy processing (media check, PDF thumbnail, screenshot) runs as
// fire-and-forget so it doesn't block the render cycle. Loading id was
// set in onSuccess (before setQueryData) — see comment there.
// fire-and-forget so it doesn't block the render cycle.
//
// The enrichment subscription was opened in onSuccess (right after the
// temp → real ID swap) so the loading state is live for the full
// pipeline. Here we tear it down on paths that don't feed the
// screenshot/enrichment flow (plain image / audio URLs) and leave it
// alive for PDF (DB write happens inside handlePdfThumbnailAndUpload)
// and the regular screenshot path.
void (async () => {
const isUrlOfMimeType = await checkIfUrlAnImage(url);
if (isUrlOfMimeType) {
removeLoadingBookmarkId(data.id);
return;
}
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")) {
removeLoadingBookmarkId(data.id);
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 {
if (mediaType === PDF_MIME_TYPE || URL_PDF_CHECK_PATTERN.test(url)) {
try {
errorToast("retry thumbnail generation");
successToast("Generating thumbnail");
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");
} 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],
});
}
} finally {
void queryClient.invalidateQueries({
queryKey: [BOOKMARKS_KEY, session?.user?.id],
});
removeLoadingBookmarkId(data.id);
return;
}
return;
}

if (data?.id) {
addLoadingBookmarkId(data.id);
if (session?.user?.id) {
openBookmarkEnrichmentSubscription({
bookmarkId: data.id,
queryClient,
userId: session.user.id,
});
}
addBookmarkScreenshotMutation.mutate({ id: data.id, url: data.url });
} catch (pipelineError) {
// Guarantee teardown on any unexpected throw from the media-type
// probe, toast calls, or mutation.mutate — without this the
// subscription opened in onSuccess would dangle until the 90s
// timeout, keeping the card stuck on "Getting screenshot".
console.error("add-bookmark post-success pipeline failed:", pipelineError);
void teardownBookmarkEnrichmentSubscription(data.id, "screenshot_failed");
}
addBookmarkScreenshotMutation.mutate({ id: data.id, url: data.url });
})();
},
onSuccess: (apiResponse, _variables, context) => {
if (apiResponse?.[0]) {
const [serverBookmark] = apiResponse;

// Set loading id BEFORE swapping the temp id for the real id in cache.
// setQueryData triggers a React Query subscription notify which renders
// the card with the real id. If isLoading is still false at that
// render, the statusText ladder falls through to TERMINAL ("Cannot
// fetch image..."). The prior placement (in onSettled) was one
// callback too late: onSuccess + setQueryData fire first, React
// commits, only then onSettled runs. The image/audio branches in the
// IIFE below still clear it eagerly when no screenshot follow-up runs.
if (serverBookmark.id) {
addLoadingBookmarkId(serverBookmark.id);
}

// Re-add URL for animation continuity across key change (temp → real id).
// The remounted component consumes this via recentlyAddedUrls.delete().
recentlyAddedUrls.add(serverBookmark.url);
Expand Down Expand Up @@ -295,6 +291,20 @@ export default function useAddBookmarkMinDataOptimisticMutation() {
} as PaginatedBookmarks;
},
);

// Open the enrichment subscription as soon as the real id is known.
// The onSettled IIFE tears it down on paths that don't feed the
// pipeline (image/audio URLs) once the async media type check
// resolves. Opening here (instead of after the media check) closes
// the ~200–1000ms gap where the card would otherwise show no
// loading indicator while we probe the URL's content type.
if (session?.user?.id) {
openBookmarkEnrichmentSubscription({
bookmarkId: serverBookmark.id,
queryClient,
userId: session.user.id,
});
}
}

if (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";

import type {
AddBookmarkScreenshotPayloadTypes,
PaginatedBookmarks,
SingleListData,
} from "../../../types/apiTypes";
import type { AddBookmarkScreenshotPayloadTypes, SingleListData } from "../../../types/apiTypes";

import useGetCurrentCategoryId from "../../../hooks/useGetCurrentCategoryId";
import useGetSortBy from "../../../hooks/useGetSortBy";
import { api } from "../../../lib/api-helpers/api-v2";
import { teardownBookmarkEnrichmentSubscription } from "../../../lib/supabase/realtime/bookmark-enrichment-subscription";
import { useLoadersStore, useSupabaseSession } from "../../../store/componentStore";
import { useSupabaseSession } from "../../../store/componentStore";
import { BOOKMARKS_KEY, V2_ADD_URL_SCREENSHOT_API } from "../../../utils/constants";
import { errorToast } from "../../../utils/toastMessages";

Expand All @@ -21,8 +17,6 @@ export default function useAddBookmarkScreenshotMutation() {
const { category_id: CATEGORY_ID } = useGetCurrentCategoryId();
const session = useSupabaseSession((state) => state.session);
const { sortBy } = useGetSortBy();
const { removeLoadingBookmarkId } = useLoadersStore();

const addBookmarkScreenshotMutation = useMutation({
mutationFn: (payload: AddBookmarkScreenshotPayloadTypes) =>
// ky default timeout is 10s, but the server-side screenshot capture has a
Expand All @@ -35,58 +29,10 @@ export default function useAddBookmarkScreenshotMutation() {
onError: (error, variables) => {
errorToast(`Screenshot error: ${error.message}`);
if (variables.id) {
removeLoadingBookmarkId(variables.id);
void teardownBookmarkEnrichmentSubscription(variables.id, "screenshot_failed");
}
},
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);
}
Comment on lines -42 to -83
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


// Background refetch picks up addRemainingBookmarkData() enrichments
// (img_caption, ocr, blurhash, additionalImages, additionalVideos).
// Fire-and-forget — does not block the loading-state transition,
// because the cache already has a valid ogImage from the
// setQueryData above.
onSettled: () => {
void queryClient.invalidateQueries({
queryKey: [BOOKMARKS_KEY, session?.user?.id, CATEGORY_ID, sortBy],
});
Expand Down
22 changes: 22 additions & 0 deletions src/hooks/use-bookmark-enrichment-active.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useCallback, useSyncExternalStore } from "react";

import {
isBookmarkEnrichmentActive,
subscribeToBookmarkEnrichmentChanges,
} from "@/lib/supabase/realtime/bookmark-enrichment-subscription";

/**
* Reactive read of the subscription manager's "is this bookmark still being
* enriched?" state. Used by the card placeholder to show a "Getting
* screenshot" label while the server pipeline (screenshot + AI enrichment or
* PDF thumbnail) is in flight.
*
* Returns `true` while a Realtime subscription is active or queued for the
* given bookmark id; `false` once the subscription tears down (terminal
* state, timeout, delete, sign-out, or explicit teardown on non-enriching
* media paths).
*/
export function useBookmarkEnrichmentActive(bookmarkId: number): boolean {
const getSnapshot = useCallback(() => isBookmarkEnrichmentActive(bookmarkId), [bookmarkId]);
return useSyncExternalStore(subscribeToBookmarkEnrichmentChanges, getSnapshot, getSnapshot);
}
51 changes: 47 additions & 4 deletions src/lib/supabase/realtime/bookmark-enrichment-subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type TeardownReason =
| "auth_error"
| "channel_error"
| "delete_event"
| "not_applicable"
| "screenshot_failed"
| "terminal"
| "timeout";
Expand All @@ -36,6 +37,38 @@ const LOG_OPERATION = "realtime_bookmark_subscribe";

const active = new Map<number, SubscriptionRecord>();
const waiting: OpenArgs[] = [];
const listeners = new Set<() => void>();

function notifyListeners(): void {
for (const listener of listeners) {
listener();
}
}

/**
* Subscribe to changes in the manager's active-subscription state. Called
* whenever a subscription is opened, torn down, or promoted from the waiting
* queue. Returns an unsubscribe function. Used by `useBookmarkEnrichmentActive`
* via React's `useSyncExternalStore`.
*/
export function subscribeToBookmarkEnrichmentChanges(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}

/**
* Reads whether an enrichment subscription is currently alive for a given
* bookmark id (either active and not torn-down, or queued waiting for a slot).
* Used as the "is this bookmark still loading?" signal in card UI.
*/
export function isBookmarkEnrichmentActive(bookmarkId: number): boolean {
if (isSubscriptionAlive(bookmarkId)) {
return true;
}
return waiting.some((queued) => queued.bookmarkId === bookmarkId);
}

function logEvent(message: string, bookmarkId: number, extra?: Record<string, unknown>): void {
clientLogger.info(`[realtime-bookmark] ${message}`, {
Expand All @@ -47,10 +80,12 @@ function logEvent(message: string, bookmarkId: number, extra?: Record<string, un

/**
* Open a Realtime subscription that watches a newly-added bookmark row for
* enrichment-pipeline UPDATEs and DELETE. Idempotent per bookmark id.
* Callers should only open for URLs that will trigger the screenshot mutation
* (i.e., not image / audio / PDF media URLs) — media paths never reach the
* terminal state and would always hit the 90s timeout.
* enrichment-pipeline UPDATEs and DELETE. Idempotent per bookmark id. May be
* called before the media type is known; the caller (the add-bookmark
* mutation) tears down via `teardownBookmarkEnrichmentSubscription` on
* non-enriching paths (plain image / audio URLs) once the media check
* resolves, so the subscription is always either productive or short-lived.
* Terminal conditions cover both PDF and regular-URL enrichment.
*/
export function openBookmarkEnrichmentSubscription(args: OpenArgs): void {
if (active.has(args.bookmarkId)) {
Expand All @@ -69,6 +104,7 @@ export function openBookmarkEnrichmentSubscription(args: OpenArgs): void {
activeCount: active.size,
waitingCount: waiting.length,
});
notifyListeners();
return;
}
openChannel(args);
Expand Down Expand Up @@ -113,6 +149,7 @@ function openChannel(args: OpenArgs): void {
});

logEvent("opened", args.bookmarkId, { activeCount: active.size });
notifyListeners();
}

function handleStatus(args: OpenArgs, status: string): void {
Expand Down Expand Up @@ -230,12 +267,14 @@ async function teardown(bookmarkId: number, reason: TeardownReason): Promise<voi
if (queuedIndex !== -1) {
waiting.splice(queuedIndex, 1);
logEvent("dequeued before subscribe", bookmarkId, { reason });
notifyListeners();
}
return;
}
record.tornDown = true;
clearTimeout(record.timeoutHandle);
active.delete(bookmarkId);
notifyListeners();

const supabase = createClient();
try {
Expand Down Expand Up @@ -283,6 +322,10 @@ export async function teardownAllBookmarkEnrichmentSubscriptions(
reason: TeardownReason = "auth_error",
): Promise<void> {
const ids = [...active.keys()];
const hadWaiting = waiting.length > 0;
waiting.length = 0;
if (hadWaiting) {
notifyListeners();
}
await Promise.all(ids.map((id) => teardown(id, reason)));
}
Loading
Loading