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* () {