From 4f69315705dce58e58e6e5fc94fc54ba1e589b32 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 12 Aug 2026 22:48:57 +0100 Subject: [PATCH] perf(webapp): paginate the environment variables settings page The env-var settings page loaded every variable in the project with a nested values read plus an unused valueReference (SecretReference) sub-load, so a project with many variables pulled variables x environments rows (~18k for large projects) in one burst on each page load. Paginate the presenter by variable key (count + orderBy key + skip/take, page size 50) and drop the never-read valueReference include. This bounds the value read to pageSize x environments per page, removes the SecretReference query entirely, and scopes the secret-value and updater lookups to the current page. Search moves server-side (key, case-insensitive) and the page gains pagination controls. All queries are index-backed: the (projectId, key) unique serves both the count and the ordered pagination (no sort), and the value/secret/user reads use existing indexes with page-scoped IN lists. --- .../paginate-environment-variables.md | 6 + .../EnvironmentVariablesPresenter.server.ts | 145 +++++++++++------- .../route.tsx | 42 +++-- 3 files changed, 119 insertions(+), 74 deletions(-) create mode 100644 .server-changes/paginate-environment-variables.md diff --git a/.server-changes/paginate-environment-variables.md b/.server-changes/paginate-environment-variables.md new file mode 100644 index 0000000000..da6ec93a19 --- /dev/null +++ b/.server-changes/paginate-environment-variables.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +The environment variables page now loads a page at a time, keeping it fast for projects with a large number of variables. Search matches variable names across every page. diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index b6c22b9ab1..d1eb474004 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -8,10 +8,12 @@ import type { SyncEnvVarsMapping, EnvSlug } from "~/v3/vercel/vercelProjectInteg import { VercelIntegrationService } from "~/services/vercelIntegration.server"; import { loadEnvironmentVariablesEnvironments } from "./environmentVariablesEnvironments.server"; -import { boundedIn } from "@trigger.dev/database"; +import { boundedIn, type Prisma } from "@trigger.dev/database"; type Result = Awaited>; export type EnvironmentVariableWithSetValues = Result["environmentVariables"][number]; +export const DEFAULT_ENV_VARS_PAGE_SIZE = 50; + export class EnvironmentVariablesPresenter { #prismaClient: PrismaClient; #replicaClient: PrismaReplicaClient; @@ -21,7 +23,19 @@ export class EnvironmentVariablesPresenter { this.#replicaClient = replicaClient; } - public async call({ userId, projectSlug }: { userId: User["id"]; projectSlug: Project["slug"] }) { + public async call({ + userId, + projectSlug, + page = 1, + pageSize = DEFAULT_ENV_VARS_PAGE_SIZE, + search, + }: { + userId: User["id"]; + projectSlug: Project["slug"]; + page?: number; + pageSize?: number; + search?: string; + }) { const project = await this.#replicaClient.project.findFirst({ select: { id: true, @@ -53,6 +67,18 @@ export class EnvironmentVariablesPresenter { // values in archived branch environments, which would otherwise all be loaded here. const environmentIds = sortedEnvironments.map((env) => env.id); + const variableWhere: Prisma.EnvironmentVariableWhereInput = { + projectId: project.id, + values: { some: { environmentId: { in: boundedIn(environmentIds) } } }, + ...(search ? { key: { contains: search, mode: "insensitive" } } : {}), + }; + + const totalCount = await this.#replicaClient.environmentVariable.count({ + where: variableWhere, + }); + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + const currentPage = Math.min(Math.max(1, page), totalPages); + const environmentVariables = await this.#replicaClient.environmentVariable.findMany({ select: { id: true, @@ -64,11 +90,6 @@ export class EnvironmentVariablesPresenter { version: true, lastUpdatedBy: true, updatedAt: true, - valueReference: { - select: { - key: true, - }, - }, isSecret: true, }, where: { @@ -78,9 +99,12 @@ export class EnvironmentVariablesPresenter { }, }, }, - where: { - projectId: project.id, + where: variableWhere, + orderBy: { + key: "asc", }, + skip: (currentPage - 1) * pageSize, + take: pageSize, }); const userIds = new Set( @@ -152,58 +176,61 @@ export class EnvironmentVariablesPresenter { } return { - environmentVariables: environmentVariables - .flatMap((environmentVariable) => { - return sortedEnvironments.flatMap((env) => { - const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id); - const isSecret = valueRecord?.isSecret ?? false; - - if (!valueRecord) { - return []; - } - - const val = isSecret - ? undefined - : variableValuesByEnvAndKey.get(`${env.id}:${environmentVariable.key}`); - - if (!isSecret && val === undefined) { - return []; - } - - const lastUpdatedBy = valueRecord.lastUpdatedBy as EnvironmentVariableUpdater | null; - - const updatedByUser = - lastUpdatedBy?.type === "user" - ? (() => { - const user = usersRecord[lastUpdatedBy.userId]; - return user - ? { - id: user.id, - name: user.displayName || user.name || "Unknown", - avatarUrl: user.avatarUrl, - } - : null; - })() - : null; - - return [ - { - id: environmentVariable.id, - key: environmentVariable.key, - environment: { type: env.type, id: env.id, branchName: env.branchName }, - value: isSecret ? "" : val!, - isSecret, - version: valueRecord.version, - lastUpdatedBy, - updatedByUser, - updatedAt: valueRecord.updatedAt, - }, - ]; - }); - }) - .sort((a, b) => a.key.localeCompare(b.key)), + environmentVariables: environmentVariables.flatMap((environmentVariable) => { + return sortedEnvironments.flatMap((env) => { + const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id); + const isSecret = valueRecord?.isSecret ?? false; + + if (!valueRecord) { + return []; + } + + const val = isSecret + ? undefined + : variableValuesByEnvAndKey.get(`${env.id}:${environmentVariable.key}`); + + if (!isSecret && val === undefined) { + return []; + } + + const lastUpdatedBy = valueRecord.lastUpdatedBy as EnvironmentVariableUpdater | null; + + const updatedByUser = + lastUpdatedBy?.type === "user" + ? (() => { + const user = usersRecord[lastUpdatedBy.userId]; + return user + ? { + id: user.id, + name: user.displayName || user.name || "Unknown", + avatarUrl: user.avatarUrl, + } + : null; + })() + : null; + + return [ + { + id: environmentVariable.id, + key: environmentVariable.key, + environment: { type: env.type, id: env.id, branchName: env.branchName }, + value: isSecret ? "" : val!, + isSecret, + version: valueRecord.version, + lastUpdatedBy, + updatedByUser, + updatedAt: valueRecord.updatedAt, + }, + ]; + }); + }), environments: sortedEnvironments, hasStaging, + pagination: { + currentPage, + totalPages, + totalCount, + }, // Vercel integration data vercelIntegration: vercelIntegration ? { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index bafefb17e4..310f406e37 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -40,6 +40,7 @@ import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; import { SearchInput } from "~/components/primitives/SearchInput"; import { Switch } from "~/components/primitives/Switch"; @@ -55,10 +56,8 @@ import { import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { prisma } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; -import { useFuzzyFilter } from "~/hooks/useFuzzyFilter"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; -import { useSearchParams } from "~/hooks/useSearchParam"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { @@ -117,6 +116,8 @@ export type EnvironmentVariablesPageLoaderData = { accessibleEnvironmentIds: string[]; // Environment ids whose env vars the current role can write (create/edit/delete). writableEnvironmentIds: string[]; + pagination: { currentPage: number; totalPages: number; totalCount: number }; + search?: string; }; export const environmentVariablesRouteId = @@ -125,6 +126,10 @@ export const environmentVariablesRouteId = export const loader = dashboardLoader( { params: EnvironmentParamSchema, + searchParams: z.object({ + page: z.coerce.number().int().min(1).catch(1), + search: z.string().trim().min(1).optional().catch(undefined), + }), context: async (params) => { const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); return organizationId ? { organizationId } : {}; @@ -132,15 +137,17 @@ export const loader = dashboardLoader( // No hard authorization: the page lists every environment. Values in // environments the role can't read are masked per-tier below. }, - async ({ params, user, ability }) => { + async ({ params, searchParams, user, ability }) => { const { projectParam } = params; try { const presenter = new EnvironmentVariablesPresenter(); - const { environmentVariables, environments, hasStaging, vercelIntegration } = + const { environmentVariables, environments, hasStaging, vercelIntegration, pagination } = await presenter.call({ userId: user.id, projectSlug: projectParam, + page: searchParams.page, + search: searchParams.search, }); const accessibleEnvironmentIds = environments @@ -176,6 +183,8 @@ export const loader = dashboardLoader( vercelIntegration, accessibleEnvironmentIds, writableEnvironmentIds, + pagination, + search: searchParams.search, }); } catch (error) { console.error(error); @@ -392,17 +401,12 @@ function EnvironmentVariablesListPage({ loaderData: EnvironmentVariablesPageLoaderData; }) { const [revealAll, setRevealAll] = useState(false); - const { environmentVariables, vercelIntegration } = loaderData; + const { environmentVariables, vercelIntegration, pagination, search } = loaderData; + const hasSearch = Boolean(search); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const { value } = useSearchParams(); - const urlSearch = value("search") ?? ""; - const { filteredItems } = useFuzzyFilter({ - items: environmentVariables, - keys: ["key", "value", "environment.type", "environment.branchName"], - filterText: urlSearch, - }); + const filteredItems = environmentVariables; const tableScrollRef = useRef(null); @@ -477,9 +481,9 @@ function EnvironmentVariablesListPage({
- {environmentVariables.length > 0 && ( + {(environmentVariables.length > 0 || hasSearch) && (
- +
- {environmentVariables.length === 0 ? ( + {!hasSearch ? (
You haven't set any environment variables yet.
+ {pagination.totalPages > 1 && ( +
+ +
+ )}