Skip to content

Commit cae3dcb

Browse files
authored
Env vars page performance fix (#3829)
## Summary This PR improves performance across the Environment Variables page. ## Changes ### Targeted value loading - load only the non-secret (environmentId, key) pairs required by the page. Secret values continue to be redacted in the UI. ### SSR windowing + virtualization - SSR-render only the first 50 rows - hydrate those rows - virtualize the remaining dataset client-side - search is now URL-driven during SSR, ensuring deep links such as `?search=DATABASE_URL` ### Lightweight 'Create' flow - 'Create' page no longer loads the full Environment Variables dataset. ## Results Large projects no longer render thousands of rows during SSR. Example (~11k rendered rows): Metric | Before | After -- | -- | -- Document size | ~150 MB | ~5 MB SSR rows | ~11k | 50 Browser DOM rows | Thousands | ~26–38
1 parent 4ea3ef1 commit cae3dcb

15 files changed

Lines changed: 1080 additions & 308 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Fix `@trigger.dev/core` build: cast the underlying log record exporter when calling `forceFlush` so it typechecks against the updated OpenTelemetry `LogRecordExporter` type (which no longer declares `forceFlush`).
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Make the Environment Variables page fast for projects with many variables across many environments (windowed SSR + table virtualization, decrypt only non-secret values, lightweight create-page loaders)

apps/webapp/app/components/primitives/Table.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,13 @@ export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>
130130
type TableBodyProps = {
131131
className?: string;
132132
children?: ReactNode;
133+
style?: React.CSSProperties;
133134
};
134135

135136
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
136-
({ className, children }, ref) => {
137+
({ className, children, style }, ref) => {
137138
return (
138-
<tbody ref={ref} className={cn("relative overflow-y-auto", className)}>
139+
<tbody ref={ref} className={cn("relative overflow-y-auto", className)} style={style}>
139140
{children}
140141
</tbody>
141142
);

apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts

Lines changed: 35 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { PrismaClient, prisma } from "~/db.server";
22
import { Project } from "~/models/project.server";
33
import { User } from "~/models/user.server";
4-
import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort";
54
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
65
import type { EnvironmentVariableUpdater } from "~/v3/environmentVariables/repository";
76
import {
87
SyncEnvVarsMapping,
98
EnvSlug,
109
} from "~/v3/vercel/vercelProjectIntegrationSchema";
1110
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
11+
import { loadEnvironmentVariablesEnvironments } from "./environmentVariablesEnvironments.server";
1212

1313
type Result = Awaited<ReturnType<EnvironmentVariablesPresenter["call"]>>;
1414
export type EnvironmentVariableWithSetValues = Result["environmentVariables"][number];
@@ -62,16 +62,7 @@ export class EnvironmentVariablesPresenter {
6262
},
6363
},
6464
where: {
65-
project: {
66-
slug: projectSlug,
67-
organization: {
68-
members: {
69-
some: {
70-
userId,
71-
},
72-
},
73-
},
74-
},
65+
projectId: project.id,
7566
},
7667
});
7768

@@ -111,32 +102,29 @@ export class EnvironmentVariablesPresenter {
111102
const usersRecord: Record<string, { id: string; name: string | null; displayName: string | null; avatarUrl: string | null }> =
112103
Object.fromEntries(users.map((u) => [u.id, u]));
113104

114-
const environments = await this.#prismaClient.runtimeEnvironment.findMany({
115-
select: {
116-
id: true,
117-
type: true,
118-
isBranchableEnvironment: true,
119-
branchName: true,
120-
orgMember: {
121-
select: {
122-
userId: true,
123-
},
124-
},
125-
},
126-
where: {
127-
project: {
128-
slug: projectSlug,
129-
},
130-
archivedAt: null,
131-
},
132-
});
133-
134-
const sortedEnvironments = sortEnvironments(filterOrphanedEnvironments(environments)).filter(
135-
(e) => e.orgMember?.userId === userId || e.orgMember === null
136-
);
105+
const { environments: sortedEnvironments, hasStaging } =
106+
await loadEnvironmentVariablesEnvironments(
107+
this.#prismaClient,
108+
{ userId, projectId: project.id },
109+
{ skipProjectAccessCheck: true }
110+
);
137111

138112
const repository = new EnvironmentVariablesRepository(this.#prismaClient);
139-
const variables = await repository.getProject(project.id);
113+
114+
const nonSecretItems: Array<{ environmentId: string; key: string }> = [];
115+
for (const environmentVariable of environmentVariables) {
116+
for (const env of sortedEnvironments) {
117+
const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id);
118+
if (valueRecord && !valueRecord.isSecret) {
119+
nonSecretItems.push({ environmentId: env.id, key: environmentVariable.key });
120+
}
121+
}
122+
}
123+
124+
const variableValuesByEnvAndKey = await repository.getVariableValuesForKeys(
125+
project.id,
126+
nonSecretItems
127+
);
140128

141129
// Get Vercel integration data if it exists
142130
const vercelService = new VercelIntegrationService(this.#prismaClient);
@@ -153,14 +141,19 @@ export class EnvironmentVariablesPresenter {
153141
return {
154142
environmentVariables: environmentVariables
155143
.flatMap((environmentVariable) => {
156-
const variable = variables.find((v) => v.key === environmentVariable.key);
157-
158144
return sortedEnvironments.flatMap((env) => {
159-
const val = variable?.values.find((v) => v.environment.id === env.id);
160145
const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id);
161146
const isSecret = valueRecord?.isSecret ?? false;
162147

163-
if (!val || !valueRecord) {
148+
if (!valueRecord) {
149+
return [];
150+
}
151+
152+
const val = isSecret
153+
? undefined
154+
: variableValuesByEnvAndKey.get(`${env.id}:${environmentVariable.key}`);
155+
156+
if (!isSecret && val === undefined) {
164157
return [];
165158
}
166159

@@ -185,7 +178,7 @@ export class EnvironmentVariablesPresenter {
185178
id: environmentVariable.id,
186179
key: environmentVariable.key,
187180
environment: { type: env.type, id: env.id, branchName: env.branchName },
188-
value: isSecret ? "" : val.value,
181+
value: isSecret ? "" : val!,
189182
isSecret,
190183
version: valueRecord.version,
191184
lastUpdatedBy,
@@ -196,13 +189,8 @@ export class EnvironmentVariablesPresenter {
196189
});
197190
})
198191
.sort((a, b) => a.key.localeCompare(b.key)),
199-
environments: sortedEnvironments.map((environment) => ({
200-
id: environment.id,
201-
type: environment.type,
202-
isBranchableEnvironment: environment.isBranchableEnvironment,
203-
branchName: environment.branchName,
204-
})),
205-
hasStaging: environments.some((environment) => environment.type === "STAGING"),
192+
environments: sortedEnvironments,
193+
hasStaging,
206194
// Vercel integration data
207195
vercelIntegration: vercelIntegration
208196
? {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
2+
import { type PrismaClient } from "~/db.server";
3+
import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort";
4+
5+
export type EnvironmentVariablesEnvironment = {
6+
id: string;
7+
type: RuntimeEnvironmentType;
8+
isBranchableEnvironment: boolean;
9+
branchName: string | null;
10+
};
11+
12+
export type EnvironmentVariablesEnvironmentsResult = {
13+
environments: EnvironmentVariablesEnvironment[];
14+
hasStaging: boolean;
15+
};
16+
17+
export async function loadEnvironmentVariablesEnvironments(
18+
prismaClient: PrismaClient,
19+
{ userId, projectId }: { userId: string; projectId: string },
20+
options?: { skipProjectAccessCheck?: boolean }
21+
): Promise<EnvironmentVariablesEnvironmentsResult> {
22+
if (!options?.skipProjectAccessCheck) {
23+
const project = await prismaClient.project.findFirst({
24+
select: {
25+
id: true,
26+
},
27+
where: {
28+
id: projectId,
29+
organization: {
30+
members: {
31+
some: {
32+
userId,
33+
},
34+
},
35+
},
36+
},
37+
});
38+
39+
if (!project) {
40+
throw new Error("Project not found");
41+
}
42+
}
43+
44+
const environments = await prismaClient.runtimeEnvironment.findMany({
45+
select: {
46+
id: true,
47+
type: true,
48+
isBranchableEnvironment: true,
49+
branchName: true,
50+
orgMember: {
51+
select: {
52+
userId: true,
53+
},
54+
},
55+
},
56+
where: {
57+
projectId,
58+
archivedAt: null,
59+
},
60+
});
61+
62+
const sortedEnvironments = sortEnvironments(filterOrphanedEnvironments(environments)).filter(
63+
(environment) => environment.orgMember?.userId === userId || environment.orgMember === null
64+
);
65+
66+
return {
67+
environments: sortedEnvironments.map((environment) => ({
68+
id: environment.id,
69+
type: environment.type,
70+
isBranchableEnvironment: environment.isBranchableEnvironment,
71+
branchName: environment.branchName,
72+
})),
73+
hasStaging: environments.some((environment) => environment.type === "STAGING"),
74+
};
75+
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx

Lines changed: 18 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ import {
99
import { parse } from "@conform-to/zod";
1010
import { LockClosedIcon, LockOpenIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
1111
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
12-
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
12+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
1313
import dotenv from "dotenv";
14-
import { type RefObject, useCallback, useEffect, useRef, useState } from "react";
15-
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
14+
import { type RefObject, useCallback, useRef, useState } from "react";
15+
import { redirect } from "remix-typedjson";
16+
import invariant from "tiny-invariant";
1617
import { z } from "zod";
1718
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
1819
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -39,43 +40,22 @@ import { useEnvironment } from "~/hooks/useEnvironment";
3940
import { useList } from "~/hooks/useList";
4041
import { useOrganization } from "~/hooks/useOrganizations";
4142
import { useProject } from "~/hooks/useProject";
42-
import { EnvironmentVariablesPresenter } from "~/presenters/v3/EnvironmentVariablesPresenter.server";
43-
import { logger } from "~/services/logger.server";
43+
import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
4444
import { requireUserId } from "~/services/session.server";
4545
import { cn } from "~/utils/cn";
46+
import {
47+
environmentVariablesRouteId,
48+
type loader as environmentVariablesLoader,
49+
} from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route";
4650
import {
4751
EnvironmentParamSchema,
48-
ProjectParamSchema,
4952
v3BillingPath,
5053
v3EnvironmentVariablesPath,
5154
} from "~/utils/pathBuilder";
5255
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
5356
import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
5457
import { Select, SelectItem } from "~/components/primitives/Select";
5558

56-
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
57-
const userId = await requireUserId(request);
58-
const { projectParam } = ProjectParamSchema.parse(params);
59-
60-
try {
61-
const presenter = new EnvironmentVariablesPresenter();
62-
const { environments, hasStaging } = await presenter.call({
63-
userId,
64-
projectSlug: projectParam,
65-
});
66-
67-
return typedjson({
68-
environments,
69-
hasStaging,
70-
});
71-
} catch (error) {
72-
throw new Response(undefined, {
73-
status: 400,
74-
statusText: "Something went wrong, if this problem persists please contact support.",
75-
});
76-
}
77-
};
78-
7959
const Variable = z.object({
8060
key: EnvironmentVariableKey,
8161
value: z.string().nonempty("Value is required"),
@@ -185,8 +165,15 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
185165
};
186166

187167
export default function Page() {
188-
const [isOpen, setIsOpen] = useState(false);
189-
const { environments, hasStaging } = useTypedLoaderData<typeof loader>();
168+
const [isOpen, setIsOpen] = useState(true);
169+
const parentData = useTypedMatchesData<typeof environmentVariablesLoader>({
170+
id: environmentVariablesRouteId,
171+
});
172+
invariant(
173+
parentData,
174+
"Environment variables page loader data must be defined when rendering the create dialog"
175+
);
176+
const { environments, hasStaging } = parentData;
190177
const lastSubmission = useActionData();
191178
const navigation = useNavigation();
192179
const navigate = useNavigate();
@@ -259,10 +246,6 @@ export default function Page() {
259246

260247
const [revealAll, setRevealAll] = useState(true);
261248

262-
useEffect(() => {
263-
setIsOpen(true);
264-
}, []);
265-
266249
return (
267250
<Dialog
268251
open={isOpen}

0 commit comments

Comments
 (0)