diff --git a/__tests__/components/waves/drop/useSingleWaveDropData.test.tsx b/__tests__/components/waves/drop/useSingleWaveDropData.test.tsx index 0eeb523500..3f6e1e0be8 100644 --- a/__tests__/components/waves/drop/useSingleWaveDropData.test.tsx +++ b/__tests__/components/waves/drop/useSingleWaveDropData.test.tsx @@ -1,91 +1,118 @@ -import { renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import React from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; import { useSingleWaveDropData } from "@/components/waves/drop/useSingleWaveDropData"; -import { fetchDropV2ById } from "@/services/api/wave-drops-v2-api"; +import { DropSize } from "@/helpers/waves/drop.helpers"; +import { + fetchDropMetadataByIdV2, + fetchDropV2ById, +} from "@/services/api/wave-drops-v2-api"; jest.mock("@/services/api/wave-drops-v2-api", () => ({ + fetchDropMetadataByIdV2: jest.fn(), fetchDropV2ById: jest.fn(), })); +const useWaveDataMock = jest.fn(() => ({ data: { id: "wave-1" } })); jest.mock("@/hooks/useWaveData", () => ({ - useWaveData: () => ({ data: { id: "wave-1" } }), + useWaveData: (props: unknown) => useWaveDataMock(props), })); const fetchDropV2ByIdMock = fetchDropV2ById as jest.MockedFunction< typeof fetchDropV2ById >; +const fetchDropMetadataByIdV2Mock = + fetchDropMetadataByIdV2 as jest.MockedFunction< + typeof fetchDropMetadataByIdV2 + >; const createWrapper = () => { const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, + defaultOptions: { + queries: { + retry: false, + }, + }, }); - return ({ children }: { children: React.ReactNode }) => ( - {children} - ); + return function Wrapper({ children }: { readonly children: ReactNode }) { + return ( + {children} + ); + }; }; const createInitialDrop = (id: string) => ({ id, wave: { id: "wave-1" }, + metadata: [{ data_key: "priority", data_value: id }], stableHash: `${id}-hash`, stableKey: `${id}-key`, + type: DropSize.FULL, }) as any; -const createDeferred = () => { - let resolve!: (value: T) => void; - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve; - }); - - return { promise, resolve }; -}; - describe("useSingleWaveDropData", () => { beforeEach(() => { jest.resetAllMocks(); + useWaveDataMock.mockReturnValue({ data: { id: "wave-1" } }); + fetchDropMetadataByIdV2Mock.mockResolvedValue([ + { data_key: "priority", data_value: "drop-1" }, + { data_key: "title", data_value: "Full Title" }, + ]); }); - it("fetches single-drop detail without eager top raters", async () => { - fetchDropV2ByIdMock.mockResolvedValue({ - id: "drop-1", - wave: { id: "wave-1" }, - } as any); - - renderHook( - () => - useSingleWaveDropData( - { - id: "drop-1", - wave: { id: "wave-1" }, - stableHash: "hash", - stableKey: "key", - } as any, - jest.fn() - ), + it("fetches detail metadata without fetching single-drop detail", async () => { + const initialDrop = createInitialDrop("drop-1"); + + const { result } = renderHook( + () => useSingleWaveDropData(initialDrop, jest.fn()), { wrapper: createWrapper() } ); + expect(fetchDropV2ByIdMock).not.toHaveBeenCalled(); + expect(fetchDropMetadataByIdV2Mock).toHaveBeenCalledWith( + expect.objectContaining({ + dropId: "drop-1", + priorityMetadata: initialDrop.metadata, + }) + ); + expect(useWaveDataMock).toHaveBeenCalledWith( + expect.objectContaining({ waveId: "wave-1" }) + ); + expect(result.current.drop).toEqual( + expect.objectContaining({ + id: "drop-1", + metadata: initialDrop.metadata, + }) + ); + await waitFor(() => { - expect(fetchDropV2ByIdMock).toHaveBeenCalledWith( - "drop-1", - expect.objectContaining({ aborted: false }), - { includeTopRaters: false } - ); + expect(result.current.drop.metadata).toEqual([ + { data_key: "priority", data_value: "drop-1" }, + { data_key: "title", data_value: "Full Title" }, + ]); }); - }); - it("does not expose the previous drop while a new drop id is loading", async () => { - const secondDrop = createDeferred(); - fetchDropV2ByIdMock - .mockResolvedValueOnce({ + expect(result.current.extendedDrop).toEqual( + expect.objectContaining({ id: "drop-1", - wave: { id: "wave-1" }, - } as any) - .mockReturnValueOnce(secondDrop.promise); + type: DropSize.FULL, + stableHash: "drop-1-hash", + stableKey: "drop-1-key", + metadata: [ + { data_key: "priority", data_value: "drop-1" }, + { data_key: "title", data_value: "Full Title" }, + ], + }) + ); + }); + + it("switches directly to a new initial drop without exposing stale detail data", async () => { + fetchDropMetadataByIdV2Mock.mockImplementation(async ({ dropId }) => [ + { data_key: "full", data_value: dropId }, + ]); const { result, rerender } = renderHook( ({ initialDrop }) => useSingleWaveDropData(initialDrop, jest.fn()), @@ -95,22 +122,34 @@ describe("useSingleWaveDropData", () => { } ); + expect(result.current.drop.id).toBe("drop-1"); + await waitFor(() => { - expect(result.current.drop?.id).toBe("drop-1"); + expect(result.current.drop.metadata).toEqual([ + { data_key: "full", data_value: "drop-1" }, + ]); }); rerender({ initialDrop: createInitialDrop("drop-2") }); - expect(result.current.drop).toBeUndefined(); - expect(result.current.extendedDrop).toBeNull(); - - secondDrop.resolve({ - id: "drop-2", - wave: { id: "wave-1" }, - }); + expect(result.current.drop.id).toBe("drop-2"); + expect(result.current.extendedDrop.id).toBe("drop-2"); + expect(result.current.drop.metadata).toEqual([ + { data_key: "priority", data_value: "drop-2" }, + ]); await waitFor(() => { - expect(result.current.drop?.id).toBe("drop-2"); + expect(result.current.drop.metadata).toEqual([ + { data_key: "full", data_value: "drop-2" }, + ]); }); + + expect(fetchDropMetadataByIdV2Mock).toHaveBeenCalledWith( + expect.objectContaining({ + dropId: "drop-2", + priorityMetadata: [{ data_key: "priority", data_value: "drop-2" }], + }) + ); + expect(fetchDropV2ByIdMock).not.toHaveBeenCalled(); }); }); diff --git a/__tests__/components/waves/drops/WaveDropQuoteWithDropId.test.tsx b/__tests__/components/waves/drops/WaveDropQuoteWithDropId.test.tsx index fc9f967fde..1ed3409765 100644 --- a/__tests__/components/waves/drops/WaveDropQuoteWithDropId.test.tsx +++ b/__tests__/components/waves/drops/WaveDropQuoteWithDropId.test.tsx @@ -11,11 +11,18 @@ jest.mock("@/components/waves/drops/WaveDropQuote", () => (props: any) => { }); const useQuery = jest.fn(); +const getQueryData = jest.fn(); jest.mock("@tanstack/react-query", () => ({ useQuery: (opts: any) => useQuery(opts), + useQueryClient: () => ({ getQueryData }), keepPreviousData: "keep", })); +const useMyStreamOptional = jest.fn(); +jest.mock("@/contexts/wave/MyStreamContext", () => ({ + useMyStreamOptional: () => useMyStreamOptional(), +})); + jest.mock("@/services/api/drop-api", () => { const { QueryKey: ActualQueryKey } = jest.requireActual( "@/components/react-query-wrapper/ReactQueryWrapper" @@ -36,6 +43,8 @@ describe("WaveDropQuoteWithDropId", () => { beforeEach(() => { capturedProps = undefined; jest.clearAllMocks(); + getQueryData.mockReturnValue(undefined); + useMyStreamOptional.mockReturnValue(null); }); it("fetches drop by drop ID and renders quote when no maybeDrop exists", async () => { @@ -62,7 +71,7 @@ describe("WaveDropQuoteWithDropId", () => { expect(fetchDropByIdBatchedMock).toHaveBeenCalledWith("d1"); }); - it("treats maybeDrop as stale initial data and fetches fresh data", async () => { + it("uses maybeDrop without fetching fresh data", () => { const maybeDrop = { id: "d1", wave: { id: "old-wave" } }; useQuery.mockImplementation((opts: any) => { return { data: opts.initialData }; @@ -81,11 +90,66 @@ describe("WaveDropQuoteWithDropId", () => { expect(capturedProps.isNotFound).toBe(false); const call = useQuery.mock.calls[0][0]; expect(call.queryKey).toEqual([QueryKey.DROP, { drop_id: "d1" }]); - expect(call.enabled).toBe(true); + expect(call.enabled).toBe(false); expect(call.initialData).toBe(maybeDrop); - expect(call.initialDataUpdatedAt).toBe(0); - await call.queryFn(); - expect(fetchDropByIdBatchedMock).toHaveBeenCalledWith("d1"); + expect(call).not.toHaveProperty("initialDataUpdatedAt"); + expect(fetchDropByIdBatchedMock).not.toHaveBeenCalled(); + }); + + it("uses an already cached drop without fetching fresh data", () => { + const cachedDrop = { id: "d1", wave: { id: "cached-wave" } }; + getQueryData.mockReturnValue(cachedDrop); + useQuery.mockImplementation((opts: any) => { + return { data: opts.initialData }; + }); + + render( + + ); + + expect(capturedProps.drop).toBe(cachedDrop); + const call = useQuery.mock.calls[0][0]; + expect(call.enabled).toBe(false); + expect(call.initialData).toBe(cachedDrop); + }); + + it("uses a full drop from wave messages without fetching by id", () => { + const waveDrop = { + id: "d1", + wave: { id: "w1" }, + type: "FULL", + stableKey: "d1", + stableHash: "d1", + }; + useMyStreamOptional.mockReturnValue({ + activeWave: { id: "w1" }, + waveMessagesStore: { + getData: jest.fn(() => ({ drops: [waveDrop] })), + }, + }); + useQuery.mockImplementation((opts: any) => { + return { data: opts.initialData }; + }); + + render( + + ); + + expect(capturedProps.drop).toBe(waveDrop); + const call = useQuery.mock.calls[0][0]; + expect(call.enabled).toBe(false); + expect(call.initialData).toBe(waveDrop); }); it("passes not-found state when the refresh returns the not-found message", () => { @@ -130,7 +194,7 @@ describe("WaveDropQuoteWithDropId", () => { expect(capturedProps.drop).toBeNull(); expect(capturedProps.isNotFound).toBe(true); const call = useQuery.mock.calls[0][0]; - expect(call.enabled).toBe(true); + expect(call.enabled).toBe(false); }); it("passes not-found state when the refresh returns a 404", () => { diff --git a/__tests__/services/api/drop-api.test.ts b/__tests__/services/api/drop-api.test.ts index 740f8d2856..a549c8573b 100644 --- a/__tests__/services/api/drop-api.test.ts +++ b/__tests__/services/api/drop-api.test.ts @@ -18,14 +18,17 @@ afterEach(() => { }); describe("fetchDropsByIds", () => { - it("fetches drops with the v2 drop detail endpoint", async () => { + it("fetches drops with lean v2 drop detail hydration", async () => { const replyDrop = { id: "reply-1" } as ApiDrop; fetchDropV2ByIdMock.mockResolvedValue(replyDrop); const result = await fetchDropsByIds(["reply-1"]); expect(fetchDropV2ByIdMock).toHaveBeenCalledTimes(1); - expect(fetchDropV2ByIdMock).toHaveBeenCalledWith("reply-1"); + expect(fetchDropV2ByIdMock).toHaveBeenCalledWith("reply-1", undefined, { + includeFullMetadata: false, + includeTopRaters: false, + }); expect(result).toEqual([replyDrop]); }); diff --git a/__tests__/services/api/wave-drops-v2-api.test.ts b/__tests__/services/api/wave-drops-v2-api.test.ts index 8211df3b38..a14c4cfab5 100644 --- a/__tests__/services/api/wave-drops-v2-api.test.ts +++ b/__tests__/services/api/wave-drops-v2-api.test.ts @@ -6,6 +6,7 @@ import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; import { commonApiFetch } from "@/services/api/common-api"; import { fetchBoostedDropsV2, + fetchDropMetadataByIdV2, fetchDropRepliesV2, fetchDropV2ById, fetchWaveDropsFeedV2, @@ -385,7 +386,49 @@ describe("fetchDropV2ById", () => { jest.clearAllMocks(); }); - it("keeps full metadata hydration for single drop details while allowing top raters to be skipped", async () => { + it("fetches drop metadata by id without fetching the drop detail", async () => { + const fullMetadata = [{ data_key: "artist", data_value: "Alice" }]; + commonApiFetchMock.mockResolvedValueOnce(fullMetadata); + + const result = await fetchDropMetadataByIdV2({ + dropId: "drop-1", + priorityMetadata, + }); + + expect(commonApiFetchMock).toHaveBeenCalledTimes(1); + expect(commonApiFetchMock).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: "v2/drops/drop-1/metadata", + }) + ); + expect(commonApiFetchMock).not.toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: "v2/drops/drop-1", + }) + ); + expect(result).toEqual([...priorityMetadata, ...fullMetadata]); + }); + + it("keeps by-id drop hydration lean by default", async () => { + commonApiFetchMock.mockResolvedValueOnce({ + wave, + drop: createEnrichableDrop(), + }); + + const result = await fetchDropV2ById("drop-1"); + + expect(commonApiFetchMock).toHaveBeenCalledTimes(1); + expect(commonApiFetchMock).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: "v2/drops/drop-1", + }) + ); + expectNoListEnrichmentCalls(); + expect(result.metadata).toEqual(priorityMetadata); + expect(result.top_raters).toEqual([]); + }); + + it("allows explicit full metadata hydration while top raters are skipped", async () => { const fullMetadata = [{ data_key: "artist", data_value: "Alice" }]; commonApiFetchMock .mockResolvedValueOnce({ @@ -395,6 +438,7 @@ describe("fetchDropV2ById", () => { .mockResolvedValueOnce(fullMetadata); const result = await fetchDropV2ById("drop-1", undefined, { + includeFullMetadata: true, includeTopRaters: false, }); diff --git a/components/drops/view/part/dropPartMarkdown/renderers.tsx b/components/drops/view/part/dropPartMarkdown/renderers.tsx index 5e83adffc2..733d528da8 100644 --- a/components/drops/view/part/dropPartMarkdown/renderers.tsx +++ b/components/drops/view/part/dropPartMarkdown/renderers.tsx @@ -120,6 +120,7 @@ const renderSeizeQuote = ( dropId={dropId} partId={1} maybeDrop={null} + waveId={waveId} onQuoteClick={onQuoteClick} embedPath={options?.embedPath} quotePath={options?.quotePath} diff --git a/components/waves/drop/useSingleWaveDropData.ts b/components/waves/drop/useSingleWaveDropData.ts index b9536d2649..012a664369 100644 --- a/components/waves/drop/useSingleWaveDropData.ts +++ b/components/waves/drop/useSingleWaveDropData.ts @@ -1,51 +1,61 @@ "use client"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; import { useCallback, useMemo } from "react"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { DropSize } from "@/helpers/waves/drop.helpers"; -import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { useWaveData } from "@/hooks/useWaveData"; -import { useQuery } from "@tanstack/react-query"; -import type { ApiDrop } from "@/generated/models/ApiDrop"; import { DROP_DETAIL_STALE_TIME_MS } from "@/services/api/drop-api"; -import { fetchDropV2ById } from "@/services/api/wave-drops-v2-api"; +import { fetchDropMetadataByIdV2 } from "@/services/api/wave-drops-v2-api"; +import { useQuery } from "@tanstack/react-query"; export const useSingleWaveDropData = ( initialDrop: ExtendedDrop, onClose: () => void ) => { - const { data: drop } = useQuery({ + const onWaveNotFound = useCallback(() => { + onClose(); + }, [onClose]); + + const { data: wave } = useWaveData({ + waveId: initialDrop.wave.id, + onWaveNotFound, + }); + + const { data: hydratedMetadata } = useQuery({ queryKey: [ QueryKey.DROP, { drop_id: initialDrop.id, - view: "single-wave-drop", + view: "metadata", }, ], queryFn: ({ signal }) => - fetchDropV2ById(initialDrop.id, signal, { includeTopRaters: false }), + fetchDropMetadataByIdV2({ + dropId: initialDrop.id, + priorityMetadata: initialDrop.metadata, + signal, + }), + enabled: initialDrop.id.trim().length > 0, staleTime: DROP_DETAIL_STALE_TIME_MS, }); - const onWaveNotFound = useCallback(() => { - onClose(); - }, [onClose]); - - const { data: wave } = useWaveData({ - waveId: drop?.wave.id ?? null, - onWaveNotFound, - }); + const drop = useMemo( + () => ({ + ...initialDrop, + metadata: hydratedMetadata ?? initialDrop.metadata, + }), + [hydratedMetadata, initialDrop] + ); const extendedDrop = useMemo( - () => - drop - ? { - type: DropSize.FULL as const, - ...drop, - stableHash: initialDrop.stableHash, - stableKey: initialDrop.stableKey, - } - : null, + () => ({ + ...drop, + type: DropSize.FULL as const, + stableHash: initialDrop.stableHash, + stableKey: initialDrop.stableKey, + }), [drop, initialDrop.stableHash, initialDrop.stableKey] ); diff --git a/components/waves/drops/WaveDropPartContentMarkdown.tsx b/components/waves/drops/WaveDropPartContentMarkdown.tsx index 679e5c83a9..548cb9eba4 100644 --- a/components/waves/drops/WaveDropPartContentMarkdown.tsx +++ b/components/waves/drops/WaveDropPartContentMarkdown.tsx @@ -182,6 +182,7 @@ const WaveDropPartContentMarkdown: React.FC< ? { ...part.quoted_drop.drop, wave: wave } : null } + waveId={wave.id} onQuoteClick={onQuoteClick} embedPath={currentDropEmbedPath} quotePath={currentQuotePath} diff --git a/components/waves/drops/WaveDropQuoteWithDropId.tsx b/components/waves/drops/WaveDropQuoteWithDropId.tsx index 84214beb8b..b0b3f4c349 100644 --- a/components/waves/drops/WaveDropQuoteWithDropId.tsx +++ b/components/waves/drops/WaveDropQuoteWithDropId.tsx @@ -1,8 +1,14 @@ "use client"; import React from "react"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import type { ApiDrop } from "@/generated/models/ApiDrop"; +import { useMyStreamOptional } from "@/contexts/wave/MyStreamContext"; +import { DropSize, type ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { DROP_DETAIL_STALE_TIME_MS, fetchDropByIdBatched, @@ -14,6 +20,7 @@ interface WaveDropQuoteWithDropIdProps { readonly dropId: string; readonly partId: number; readonly maybeDrop: ApiDrop | null; + readonly waveId?: string | undefined; readonly onQuoteClick: (drop: ApiDrop) => void; readonly embedPath?: readonly string[] | undefined; readonly quotePath?: readonly string[] | undefined; @@ -63,6 +70,7 @@ const WaveDropQuoteWithDropId: React.FC = ({ dropId, partId, maybeDrop, + waveId, onQuoteClick, embedPath, quotePath, @@ -71,16 +79,29 @@ const WaveDropQuoteWithDropId: React.FC = ({ onLinkCardActionsActiveChange, }) => { const normalizedDropId = dropId.trim(); + const queryClient = useQueryClient(); + const myStream = useMyStreamOptional(); + const cachedDrop = queryClient.getQueryData( + getDropQueryKey(normalizedDropId) + ); + const targetWaveId = waveId ?? myStream?.activeWave.id ?? null; + const waveMessagesDrop = targetWaveId + ? myStream?.waveMessagesStore + .getData(targetWaveId) + ?.drops.find( + (drop): drop is ExtendedDrop => + drop.type === DropSize.FULL && drop.id === normalizedDropId + ) + : null; + const initialDrop = maybeDrop ?? cachedDrop ?? waveMessagesDrop ?? null; const { data: drop, error } = useQuery({ queryKey: getDropQueryKey(normalizedDropId), queryFn: () => fetchDropByIdBatched(normalizedDropId), placeholderData: keepPreviousData, - enabled: normalizedDropId.length > 0, + enabled: normalizedDropId.length > 0 && initialDrop === null, staleTime: DROP_DETAIL_STALE_TIME_MS, - ...(maybeDrop === null - ? {} - : { initialData: maybeDrop, initialDataUpdatedAt: 0 }), + ...(initialDrop === null ? {} : { initialData: initialDrop }), }); const isNotFound = isDropNotFoundError(error, normalizedDropId); diff --git a/services/api/drop-api.ts b/services/api/drop-api.ts index a598cd93ac..2b925ddcd1 100644 --- a/services/api/drop-api.ts +++ b/services/api/drop-api.ts @@ -66,7 +66,12 @@ const fetchDropResultsByIds = async ( } const results = await Promise.allSettled( - uniqueDropIds.map((dropId) => fetchDropV2ById(dropId)) + uniqueDropIds.map((dropId) => + fetchDropV2ById(dropId, undefined, { + includeFullMetadata: false, + includeTopRaters: false, + }) + ) ); return results.map((result, index) => { diff --git a/services/api/wave-drops-v2-api.ts b/services/api/wave-drops-v2-api.ts index cd1a7f77c9..d710b4c3de 100644 --- a/services/api/wave-drops-v2-api.ts +++ b/services/api/wave-drops-v2-api.ts @@ -194,8 +194,8 @@ export const fetchDropReactionDetailsV2 = async ( }; const mergeMetadata = ( - priorityMetadata: ApiDropMetadataResponse[], - metadata: ApiDropMetadataResponse[] + priorityMetadata: readonly ApiDropMetadataResponse[], + metadata: readonly ApiDropMetadataResponse[] ): ApiDropMetadataResponse[] => { const priorityKeys = new Set( priorityMetadata.map((item) => item.data_key.trim()).filter(Boolean) @@ -207,6 +207,27 @@ const mergeMetadata = ( ]; }; +export const fetchDropMetadataByIdV2 = async ({ + dropId, + priorityMetadata = [], + signal, +}: { + readonly dropId: string; + readonly priorityMetadata?: readonly ApiDropMetadataResponse[] | undefined; + readonly signal?: AbortSignal | undefined; +}): Promise => { + try { + const metadata = await commonApiFetch({ + endpoint: `v2/drops/${getDropEndpointId(getNormalizedDropId(dropId))}/metadata`, + signal, + }); + return mergeMetadata(priorityMetadata, metadata); + } catch (error) { + rethrowAbortFetchError(error); + return [...priorityMetadata]; + } +}; + const fetchDropMetadataV2 = async ( drop: ApiDropV2, signal?: AbortSignal, @@ -218,16 +239,11 @@ const fetchDropMetadataV2 = async ( return priorityMetadata; } - try { - const metadata = await commonApiFetch({ - endpoint: `v2/drops/${getDropEndpointId(drop.id)}/metadata`, - signal, - }); - return mergeMetadata(priorityMetadata, metadata); - } catch (error) { - rethrowAbortFetchError(error); - return priorityMetadata; - } + return fetchDropMetadataByIdV2({ + dropId: drop.id, + priorityMetadata, + signal, + }); }; const fetchTopRatersV2 = async ( @@ -548,8 +564,8 @@ export async function fetchDropV2ById( drop: data.drop, wave, signal, - includeFullMetadata: options?.includeFullMetadata, - includeTopRaters: options?.includeTopRaters, + includeFullMetadata: options?.includeFullMetadata ?? false, + includeTopRaters: options?.includeTopRaters ?? false, }); }