From b582c672577ae3855d3788e4fe2b84671b6159f8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:06:17 +0000 Subject: [PATCH 1/5] perf(web): resolve dashboard thumbnail URLs during the page render --- apps/web/app/(org)/dashboard/caps/Caps.tsx | 68 ++++++++------- apps/web/app/(org)/dashboard/caps/page.tsx | 8 ++ .../dashboard/spaces/[spaceId]/SharedCaps.tsx | 77 +++++++++-------- .../(org)/dashboard/spaces/[spaceId]/page.tsx | 13 +++ apps/web/components/VideoThumbnail.tsx | 82 +++++++++++++++++-- apps/web/lib/seeded-thumbnails.ts | 38 +++++++++ 6 files changed, 218 insertions(+), 68 deletions(-) create mode 100644 apps/web/lib/seeded-thumbnails.ts diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index d6721cded0..2af455a53c 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -12,6 +12,10 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { ChromeRecorderButton } from "@/components/ChromeRecorderButton"; +import { + type SeededThumbnails, + SeededThumbnailsProvider, +} from "@/components/VideoThumbnail"; import { CHROME_EXTENSION_BUTTON_CLASS } from "@/lib/chrome-extension"; import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { useVideosAnalyticsQuery } from "@/lib/Queries/Analytics"; @@ -70,11 +74,13 @@ export const Caps = ({ count, analyticsEnabled, folders, + thumbnails, }: { data: VideoData; count: number; folders: FolderDataType[]; analyticsEnabled: boolean; + thumbnails?: SeededThumbnails; }) => { const router = useRouter(); const params = useSearchParams(); @@ -286,36 +292,38 @@ export const Caps = ({ -
- {isUploading && ( - - )} - {visibleVideos.map((video) => { - const videoAnalytics = analytics[video.id]; - return ( - { - if (selectedCaps.length > 0) { - deleteCaps(selectedCaps); - } else { - deleteCap(video.id); - } - }} - userId={user?.id} - isLoadingAnalytics={isLoadingAnalytics} - isSelected={selectedCaps.includes(video.id)} - anyCapSelected={anyCapSelected} - onSelectToggle={() => handleCapSelection(video.id)} - canMove - moveLocation={moveLocation} - moveRootLabel="My Caps" - /> - ); - })} -
+ +
+ {isUploading && ( + + )} + {visibleVideos.map((video) => { + const videoAnalytics = analytics[video.id]; + return ( + { + if (selectedCaps.length > 0) { + deleteCaps(selectedCaps); + } else { + deleteCap(video.id); + } + }} + userId={user?.id} + isLoadingAnalytics={isLoadingAnalytics} + isSelected={selectedCaps.includes(video.id)} + anyCapSelected={anyCapSelected} + onSelectToggle={() => handleCapSelection(video.id)} + canMove + moveLocation={moveLocation} + moveRootLabel="My Caps" + /> + ); + })} +
+
)} {(data.length > limit || data.length === limit || page !== 1) && ( diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index 52fcf06a30..51696e1eb1 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -22,6 +22,7 @@ import { and, count, desc, eq, inArray, isNull, sql } from "drizzle-orm"; import { type Array, Effect } from "effect"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; +import { seedThumbnailUrls } from "@/lib/seeded-thumbnails"; import { runPromise } from "@/lib/server"; import { Caps } from "./Caps"; @@ -272,6 +273,12 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { const organizationSettings = organizationSettingsRow?.settings ?? null; const videoIds = videoData.map((video) => video.id); + const thumbnails = seedThumbnailUrls( + user, + videoData + .filter((video) => !video.hasActiveUpload) + .map((video) => Video.VideoId.make(video.id)), + ); const sharedSpacesMap = await getSharedSpacesForVideos(videoIds).pipe(runPromise); @@ -340,6 +347,7 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { data={processedVideoData} folders={foldersData} count={totalCount} + thumbnails={thumbnails} analyticsEnabled={Boolean( serverEnv().TINYBIRD_TOKEN && serverEnv().TINYBIRD_HOST, )} diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx index b2079583bd..c2bc3d0196 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx @@ -19,6 +19,10 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; +import { + type SeededThumbnails, + SeededThumbnailsProvider, +} from "@/components/VideoThumbnail"; import { canManageOrganizationMembers, canManageSpace, @@ -99,6 +103,7 @@ export const SharedCaps = ({ folders, analyticsEnabled, organizationData, + thumbnails, }: { data: SharedVideoData; count: number; @@ -115,6 +120,7 @@ export const SharedCaps = ({ name: string; ownerId: User.UserId; }; + thumbnails?: SeededThumbnails; }) => { const params = useSearchParams(); const pathname = usePathname(); @@ -434,39 +440,44 @@ export const SharedCaps = ({

Videos and screenshots

-
- {data.map((cap) => { - return ( - 0 - } - onSelectToggle={ - canManageCurrentSharedCollection - ? () => handleCapSelection(cap.id) - : undefined - } - onDragStart={() => setIsDraggingCap(true)} - onDragEnd={() => setIsDraggingCap(false)} - /> - ); - })} -
+ +
+ {data.map((cap) => { + return ( + 0 + } + onSelectToggle={ + canManageCurrentSharedCollection + ? () => handleCapSelection(cap.id) + : undefined + } + onDragStart={() => setIsDraggingCap(true)} + onDragEnd={() => setIsDraggingCap(false)} + /> + ); + })} +
+
{(data.length > limit || data.length === limit || page !== 1) && (
!video.hasActiveUpload) + .map((video) => video.id), + )} /> ); } @@ -571,6 +578,12 @@ export default async function SharedCapsPage(props: { organizationMembers={organizationMembersData} currentUserId={user.id} folders={foldersData} + thumbnails={seedThumbnailUrls( + user, + processedVideoData + .filter((video) => !video.hasActiveUpload) + .map((video) => video.id), + )} /> ); } diff --git a/apps/web/components/VideoThumbnail.tsx b/apps/web/components/VideoThumbnail.tsx index 59e4ad4059..1b7bd73a98 100644 --- a/apps/web/components/VideoThumbnail.tsx +++ b/apps/web/components/VideoThumbnail.tsx @@ -4,13 +4,44 @@ import clsx from "clsx"; import { Effect } from "effect"; import moment from "moment"; import Image from "next/image"; -import type { CSSProperties } from "react"; -import { memo, useEffect, useRef, useState } from "react"; +import type { CSSProperties, ReactNode } from "react"; +import { + createContext, + memo, + Suspense, + use, + useContext, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { useEffectQuery } from "@/lib/EffectRuntime"; import { ThumbnailRequest } from "@/lib/Requests/ThumbnailRequest"; export type ImageLoadingStatus = "loading" | "success" | "error"; +export type SeededThumbnails = { + videoIds: readonly Video.VideoId[]; + urls: Promise>; +}; + +const SeededThumbnailsContext = createContext(null); + +export function SeededThumbnailsProvider({ + value, + children, +}: { + value: SeededThumbnails | undefined; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + type PreviewState = { videoId: Video.VideoId; hovered: boolean; @@ -64,6 +95,7 @@ function getPreviewGifSrc(videoId: Video.VideoId) { export const useThumnailQuery = ( videoId: Video.VideoId, enabled: boolean = true, + seededUrl?: string, ) => { return useEffectQuery({ queryKey: ThumbnailRequest.queryKey(videoId), @@ -74,10 +106,44 @@ export const useThumnailQuery = ( ); }), enabled, + initialData: seededUrl, + refetchOnMount: seededUrl === undefined, }); }; -export const VideoThumbnail: React.FC = memo( +const subscribeToNothing = () => () => {}; + +export const VideoThumbnail: React.FC = memo((props) => { + const seeded = useContext(SeededThumbnailsContext); + // The server HTML keeps the loading state and the image only mounts after + // hydration, as it did with the client RPC. Rendering it in the HTML made the + // thumbnails compete with the page's JS for bandwidth and delayed hydration. + const hydrated = useSyncExternalStore( + subscribeToNothing, + () => true, + () => false, + ); + if (!seeded || !seeded.videoIds.includes(props.videoId)) { + return ; + } + const pending = ; + if (!hydrated) return pending; + return ( + + + + ); +}); + +function SeededVideoThumbnail({ + urls, + ...props +}: VideoThumbnailProps & { urls: SeededThumbnails["urls"] }) { + const seededUrl = use(urls)[props.videoId]; + return ; +} + +const VideoThumbnailView = memo( ({ videoId, alt, @@ -89,8 +155,14 @@ export const VideoThumbnail: React.FC = memo( setImageStatus, hasActiveUpload = false, showPreview = true, - }) => { - const thumbnailUrl = useThumnailQuery(videoId, !hasActiveUpload); + awaitingSeed = false, + seededUrl, + }: VideoThumbnailProps & { awaitingSeed?: boolean; seededUrl?: string }) => { + const thumbnailUrl = useThumnailQuery( + videoId, + !hasActiveUpload && !awaitingSeed, + seededUrl, + ); const containerRef = useRef(null); const imageRef = useRef(null); const latestVideoId = useRef(videoId); diff --git a/apps/web/lib/seeded-thumbnails.ts b/apps/web/lib/seeded-thumbnails.ts new file mode 100644 index 0000000000..87fc478f5e --- /dev/null +++ b/apps/web/lib/seeded-thumbnails.ts @@ -0,0 +1,38 @@ +import { makeCurrentUserLayer, Videos } from "@cap/web-backend"; +import type { Video } from "@cap/web-domain"; +import { Effect, Option } from "effect"; +import type { SeededThumbnails } from "@/components/VideoThumbnail"; +import { runPromise } from "@/lib/server"; + +// Starts the same lookup the VideosGetThumbnails RPC runs, without awaiting it, +// so the page streams the URLs and thumbnails stop waiting for hydration plus +// an RPC round trip. Ids that fail or have no thumbnail are left out and fall +// back to the RPC on the client. +export function seedThumbnailUrls( + user: Parameters[0], + videoIds: readonly Video.VideoId[], +): SeededThumbnails | undefined { + if (videoIds.length === 0) return undefined; + + const urls = Effect.gen(function* () { + const videos = yield* Videos; + const results = yield* Effect.forEach( + videoIds, + (videoId) => videos.getThumbnailURL(videoId).pipe(Effect.option), + { concurrency: 10 }, + ); + + const seeded: Record = {}; + results.forEach((result, index) => { + const url = Option.flatten(result); + const videoId = videoIds[index]; + if (videoId && Option.isSome(url)) seeded[videoId] = url.value; + }); + return seeded; + }).pipe(Effect.provide(makeCurrentUserLayer(user)), runPromise); + + return { + videoIds, + urls: urls.catch(() => ({})), + }; +} From 8e35fac4a918f6489a0f738585c1f3c5d7e999cf Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:10:22 +0000 Subject: [PATCH 2/5] chore(web): drop narrating comment from seeded thumbnails helper --- apps/web/lib/seeded-thumbnails.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/web/lib/seeded-thumbnails.ts b/apps/web/lib/seeded-thumbnails.ts index 87fc478f5e..e9bfbb2e02 100644 --- a/apps/web/lib/seeded-thumbnails.ts +++ b/apps/web/lib/seeded-thumbnails.ts @@ -4,10 +4,6 @@ import { Effect, Option } from "effect"; import type { SeededThumbnails } from "@/components/VideoThumbnail"; import { runPromise } from "@/lib/server"; -// Starts the same lookup the VideosGetThumbnails RPC runs, without awaiting it, -// so the page streams the URLs and thumbnails stop waiting for hydration plus -// an RPC round trip. Ids that fail or have no thumbnail are left out and fall -// back to the RPC on the client. export function seedThumbnailUrls( user: Parameters[0], videoIds: readonly Video.VideoId[], From 90fd0a1590f677345e19d0ad172f6f0f5accf270 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:39:36 +0000 Subject: [PATCH 3/5] perf(web): start space page thumbnail lookups as soon as the rows load --- .../(org)/dashboard/spaces/[spaceId]/page.tsx | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx index 063964178f..df56336046 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -389,6 +389,12 @@ export default async function SharedCapsPage(props: { fetchSpaceVideos(space.id, page, limit), fetchOrganizationSettings(space.organizationId), ]); + const thumbnails = seedThumbnailUrls( + user, + spaceVideoData + .filter((video) => !video.hasActiveUpload) + .map((video) => Video.VideoId.make(video.id)), + ); const resolvedSpace = await Effect.gen(function* () { const imageUploads = yield* ImageUploads; return { @@ -440,12 +446,7 @@ export default async function SharedCapsPage(props: { organizationMembers={organizationMembersData} currentUserId={user.id} folders={foldersData} - thumbnails={seedThumbnailUrls( - user, - processedVideoData - .filter((video) => !video.hasActiveUpload) - .map((video) => video.id), - )} + thumbnails={thumbnails} /> ); } @@ -536,6 +537,12 @@ export default async function SharedCapsPage(props: { fetchFolders(organization.id, true), fetchOrganizationSettings(organization.id), ]); + const thumbnails = seedThumbnailUrls( + user, + orgVideoData + .filter((video) => !video.hasActiveUpload) + .map((video) => Video.VideoId.make(video.id)), + ); const sharedSpacesMap = await fetchSharedSpacesForVideos( orgVideoData.map((video) => Video.VideoId.make(video.id)), { userId: user.id, organizationId: organization.id }, @@ -578,12 +585,7 @@ export default async function SharedCapsPage(props: { organizationMembers={organizationMembersData} currentUserId={user.id} folders={foldersData} - thumbnails={seedThumbnailUrls( - user, - processedVideoData - .filter((video) => !video.hasActiveUpload) - .map((video) => video.id), - )} + thumbnails={thumbnails} /> ); } From b299252988700bdba7884608c04aec11b1b4164b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:58:45 +0000 Subject: [PATCH 4/5] fix(web): re-sign seeded thumbnail URLs on remount once they are five minutes old --- apps/web/components/VideoThumbnail.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/components/VideoThumbnail.tsx b/apps/web/components/VideoThumbnail.tsx index 1b7bd73a98..83a702118c 100644 --- a/apps/web/components/VideoThumbnail.tsx +++ b/apps/web/components/VideoThumbnail.tsx @@ -92,6 +92,10 @@ function getPreviewGifSrc(videoId: Video.VideoId) { return `/api/video/preview?videoId=${encodeURIComponent(videoId)}&fallback=none`; } +// Signed thumbnail URLs last an hour. A seeded URL skips the refetch on mount, +// and is re-signed on later mounts once it is older than this, as before. +const SEEDED_THUMBNAIL_STALE_TIME = 5 * 60 * 1000; + export const useThumnailQuery = ( videoId: Video.VideoId, enabled: boolean = true, @@ -107,7 +111,7 @@ export const useThumnailQuery = ( }), enabled, initialData: seededUrl, - refetchOnMount: seededUrl === undefined, + staleTime: seededUrl === undefined ? 0 : SEEDED_THUMBNAIL_STALE_TIME, }); }; From 6eb61541f7e6eee8399ec0a2352cb5c5e82a9c23 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:14:15 +0000 Subject: [PATCH 5/5] fix(web): seed at most 50 thumbnail URLs per page render --- apps/web/lib/seeded-thumbnails.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/seeded-thumbnails.ts b/apps/web/lib/seeded-thumbnails.ts index e9bfbb2e02..c5f9de73cb 100644 --- a/apps/web/lib/seeded-thumbnails.ts +++ b/apps/web/lib/seeded-thumbnails.ts @@ -4,10 +4,15 @@ import { Effect, Option } from "effect"; import type { SeededThumbnails } from "@/components/VideoThumbnail"; import { runPromise } from "@/lib/server"; +// Matches the VideosGetThumbnails batch limit. Pages take a `limit` query +// parameter, so ids past this fall back to the client RPC. +const MAX_SEEDED_THUMBNAILS = 50; + export function seedThumbnailUrls( user: Parameters[0], - videoIds: readonly Video.VideoId[], + allVideoIds: readonly Video.VideoId[], ): SeededThumbnails | undefined { + const videoIds = allVideoIds.slice(0, MAX_SEEDED_THUMBNAILS); if (videoIds.length === 0) return undefined; const urls = Effect.gen(function* () {