From 61ff68033a0db32cc0ea9192b13d96b368bc0c27 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 4 Jun 2026 16:05:28 +0200 Subject: [PATCH 1/4] perf(webapp): fetch and decrypt only non-secret env var values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Environment Variables page presenter loaded the entire project secret store via a prefix scan and decrypted every value on each render — including secret values that are immediately masked in the UI — then matched rows with nested O(N×M²) `.find()` lookups. - Collect only the non-secret (environmentId, key) pairs and fetch them with a targeted `key IN (...)` query; decrypt only those. - Add `getSecretsByKeys` to the secret store and `getVariableValuesForKeys` to the repository for this access path. - Replace the nested `.find()` lookups with O(1) Map lookups keyed by `${environmentId}:${key}`. Cuts per-render decryption and server CPU for projects with many variables and environments; secret values stay masked as before. --- .../EnvironmentVariablesPresenter.server.ts | 31 ++- .../services/secrets/secretStore.server.ts | 104 +++++----- .../environmentVariablesRepository.server.ts | 36 ++++ .../app/v3/environmentVariables/repository.ts | 8 + .../EnvironmentVariablesPresenter.test.ts | 72 +++++++ .../environmentVariablesRepository.test.ts | 178 ++++++++++++++++++ .../fixtures/environmentVariablesFixtures.ts | 104 ++++++++++ 7 files changed, 485 insertions(+), 48 deletions(-) create mode 100644 apps/webapp/test/EnvironmentVariablesPresenter.test.ts create mode 100644 apps/webapp/test/environmentVariablesRepository.test.ts create mode 100644 apps/webapp/test/fixtures/environmentVariablesFixtures.ts diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index aff55263fec..f429c8517fa 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -136,7 +136,21 @@ export class EnvironmentVariablesPresenter { ); const repository = new EnvironmentVariablesRepository(this.#prismaClient); - const variables = await repository.getProject(project.id); + + const nonSecretItems: Array<{ environmentId: string; key: string }> = []; + for (const environmentVariable of environmentVariables) { + for (const env of sortedEnvironments) { + const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id); + if (valueRecord && !valueRecord.isSecret) { + nonSecretItems.push({ environmentId: env.id, key: environmentVariable.key }); + } + } + } + + const variableValuesByEnvAndKey = await repository.getVariableValuesForKeys( + project.id, + nonSecretItems + ); // Get Vercel integration data if it exists const vercelService = new VercelIntegrationService(this.#prismaClient); @@ -153,14 +167,19 @@ export class EnvironmentVariablesPresenter { return { environmentVariables: environmentVariables .flatMap((environmentVariable) => { - const variable = variables.find((v) => v.key === environmentVariable.key); - return sortedEnvironments.flatMap((env) => { - const val = variable?.values.find((v) => v.environment.id === env.id); const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id); const isSecret = valueRecord?.isSecret ?? false; - if (!val || !valueRecord) { + if (!valueRecord) { + return []; + } + + const val = isSecret + ? undefined + : variableValuesByEnvAndKey.get(`${env.id}:${environmentVariable.key}`); + + if (!isSecret && val === undefined) { return []; } @@ -185,7 +204,7 @@ export class EnvironmentVariablesPresenter { id: environmentVariable.id, key: environmentVariable.key, environment: { type: env.type, id: env.id, branchName: env.branchName }, - value: isSecret ? "" : val.value, + value: isSecret ? "" : val!, isSecret, version: valueRecord.version, lastUpdatedBy, diff --git a/apps/webapp/app/services/secrets/secretStore.server.ts b/apps/webapp/app/services/secrets/secretStore.server.ts index 855463fcd13..58af8e86f76 100644 --- a/apps/webapp/app/services/secrets/secretStore.server.ts +++ b/apps/webapp/app/services/secrets/secretStore.server.ts @@ -18,6 +18,7 @@ type ProviderInitializationOptions = { export interface SecretStoreProvider { getSecret(schema: z.Schema, key: string): Promise; getSecrets(schema: z.Schema, keyPrefix: string): Promise<{ key: string; value: T }[]>; + getSecretsByKeys(schema: z.Schema, keys: string[]): Promise<{ key: string; value: T }[]>; setSecret(key: string, value: T): Promise; deleteSecret(key: string): Promise; } @@ -48,6 +49,10 @@ export class SecretStore { return this.provider.getSecrets(schema, keyPrefix); } + getSecretsByKeys(schema: z.Schema, keys: string[]): Promise<{ key: string; value: T }[]> { + return this.provider.getSecretsByKeys(schema, keys); + } + deleteSecret(key: string): Promise { return this.provider.deleteSecret(key); } @@ -83,29 +88,7 @@ class PrismaSecretStore implements SecretStoreProvider { return undefined; } - if (secret.version === "1") { - return schema.parse(secret.value); - } - - const encryptedData = EncryptedSecretValueSchema.safeParse(secret.value); - - if (!encryptedData.success) { - throw new Error(`Unable to parse encrypted secret ${key}: ${encryptedData.error.message}`); - } - - const decrypted = await this.#decrypt( - encryptedData.data.nonce, - encryptedData.data.ciphertext, - encryptedData.data.tag - ); - - const parsedDecrypted = safeJsonParse(decrypted); - - if (!parsedDecrypted) { - return; - } - - return schema.parse(parsedDecrypted); + return this.#parseStoredSecret(schema, secret); } async getSecrets( @@ -120,37 +103,74 @@ class PrismaSecretStore implements SecretStoreProvider { }, }); + return this.#parseStoredSecrets(schema, secrets); + } + + async getSecretsByKeys( + schema: z.Schema, + keys: string[] + ): Promise<{ key: string; value: T }[]> { + if (keys.length === 0) { + return []; + } + + const secrets = await this.#prismaClient.secretStore.findMany({ + where: { + key: { + in: keys, + }, + }, + }); + + return this.#parseStoredSecrets(schema, secrets); + } + + async #parseStoredSecrets( + schema: z.Schema, + secrets: Array<{ key: string; value: unknown; version: string }> + ): Promise<{ key: string; value: T }[]> { const results = [] as { key: string; value: T }[]; for (const secret of secrets) { - if (secret.version === "1") { - results.push({ key: secret.key, value: schema.parse(secret.value) }); + const value = await this.#parseStoredSecret(schema, secret); + if (value !== undefined) { + results.push({ key: secret.key, value }); } + } - const encryptedData = EncryptedSecretValueSchema.safeParse(secret.value); + return results; + } - if (!encryptedData.success) { - throw new Error( - `Unable to parse encrypted secret ${secret.key}: ${encryptedData.error.message}` - ); - } + async #parseStoredSecret( + schema: z.Schema, + secret: { key: string; value: unknown; version: string } + ): Promise { + if (secret.version === "1") { + return schema.parse(secret.value); + } - const decrypted = await this.#decrypt( - encryptedData.data.nonce, - encryptedData.data.ciphertext, - encryptedData.data.tag + const encryptedData = EncryptedSecretValueSchema.safeParse(secret.value); + + if (!encryptedData.success) { + throw new Error( + `Unable to parse encrypted secret ${secret.key}: ${encryptedData.error.message}` ); + } - const parsedDecrypted = safeJsonParse(decrypted); - if (!parsedDecrypted) { - logger.error(`Secret isn't JSON ${secret.key}`); - continue; - } + const decrypted = await this.#decrypt( + encryptedData.data.nonce, + encryptedData.data.ciphertext, + encryptedData.data.tag + ); + + const parsedDecrypted = safeJsonParse(decrypted); - results.push({ key: secret.key, value: schema.parse(parsedDecrypted) }); + if (!parsedDecrypted) { + logger.error(`Secret isn't JSON ${secret.key}`); + return undefined; } - return results; + return schema.parse(parsedDecrypted); } async setSecret(key: string, value: T): Promise { diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 9cc41995664..4a8e0e36c0a 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -577,6 +577,42 @@ export class EnvironmentVariablesRepository implements Repository { return results; } + async getVariableValuesForKeys( + projectId: string, + items: Array<{ environmentId: string; key: string }> + ): Promise> { + if (items.length === 0) { + return new Map(); + } + + const uniqueItems = new Map(); + for (const item of items) { + uniqueItems.set(`${item.environmentId}:${item.key}`, item); + } + + const secretStore = getSecretStore("DATABASE", { + prismaClient: this.prismaClient, + }); + + const storeKeys = Array.from(uniqueItems.values()).map((item) => + secretKey(projectId, item.environmentId, item.key) + ); + + const secrets = await secretStore.getSecretsByKeys(SecretValue, storeKeys); + const secretsByStoreKey = new Map(secrets.map((secret) => [secret.key, secret.value.secret])); + + const values = new Map(); + for (const item of uniqueItems.values()) { + const storeKey = secretKey(projectId, item.environmentId, item.key); + const value = secretsByStoreKey.get(storeKey); + if (value !== undefined) { + values.set(`${item.environmentId}:${item.key}`, value); + } + } + + return values; + } + async getEnvironmentWithRedactedSecrets( projectId: string, environmentId: string, diff --git a/apps/webapp/app/v3/environmentVariables/repository.ts b/apps/webapp/app/v3/environmentVariables/repository.ts index ea027bc2ca8..84dee8beaf3 100644 --- a/apps/webapp/app/v3/environmentVariables/repository.ts +++ b/apps/webapp/app/v3/environmentVariables/repository.ts @@ -106,6 +106,14 @@ export interface Repository { edit(projectId: string, options: EditEnvironmentVariable): Promise; editValue(projectId: string, options: EditEnvironmentVariableValue): Promise; getProject(projectId: string): Promise; + /** + * Fetch and decrypt only the given env var values (for dashboard display of non-secret rows). + * Map keys are `${environmentId}:${variableKey}`. + */ + getVariableValuesForKeys( + projectId: string, + items: Array<{ environmentId: string; key: string }> + ): Promise>; /** * Get the environment variables for a given environment, it does NOT return values for secret variables */ diff --git a/apps/webapp/test/EnvironmentVariablesPresenter.test.ts b/apps/webapp/test/EnvironmentVariablesPresenter.test.ts new file mode 100644 index 00000000000..1fd2c9e9619 --- /dev/null +++ b/apps/webapp/test/EnvironmentVariablesPresenter.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, vi } from "vitest"; + +vi.mock("~/db.server", () => ({ + prisma: {}, + $replica: {}, + $transaction: async ( + prismaClient: { + $transaction: (fn: (tx: unknown) => Promise) => Promise; + }, + nameOrFn: string | ((tx: unknown) => Promise), + fnOrOptions?: ((tx: unknown) => Promise) | unknown + ) => { + const fn = + typeof nameOrFn === "string" + ? (fnOrOptions as (tx: unknown) => Promise) + : nameOrFn; + + return prismaClient.$transaction(fn); + }, +})); + +import { postgresTest } from "@internal/testcontainers"; +import { EnvironmentVariablesPresenter } from "~/presenters/v3/EnvironmentVariablesPresenter.server"; +import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; +import { + createEnvironmentVariable, + createRuntimeEnvironment, + createTestOrgProjectWithMember, +} from "./fixtures/environmentVariablesFixtures"; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("EnvironmentVariablesPresenter", () => { + postgresTest("keeps secret values redacted while returning non-secret values", async ({ prisma }) => { + const { user, organization, project, projectSlug } = await createTestOrgProjectWithMember(prisma); + const production = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + }); + + const repository = new EnvironmentVariablesRepository(prisma, prisma); + + await createEnvironmentVariable(repository, project.id, { + environmentId: production.id, + key: "SECRET_VAR", + value: "super-secret", + isSecret: true, + userId: user.id, + }); + await createEnvironmentVariable(repository, project.id, { + environmentId: production.id, + key: "PLAIN_VAR", + value: "plain-value", + isSecret: false, + userId: user.id, + }); + + const result = await new EnvironmentVariablesPresenter(prisma).call({ + userId: user.id, + projectSlug, + }); + + const secretVariable = result.environmentVariables.find((variable) => variable.key === "SECRET_VAR"); + const nonSecretVariable = result.environmentVariables.find((variable) => variable.key === "PLAIN_VAR"); + + expect(secretVariable).toBeDefined(); + expect(nonSecretVariable).toBeDefined(); + expect(secretVariable!.value).toBe(""); + expect(nonSecretVariable!.value).toBe("plain-value"); + }); +}); diff --git a/apps/webapp/test/environmentVariablesRepository.test.ts b/apps/webapp/test/environmentVariablesRepository.test.ts new file mode 100644 index 00000000000..ead865eb5da --- /dev/null +++ b/apps/webapp/test/environmentVariablesRepository.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, vi } from "vitest"; + +vi.mock("~/db.server", () => ({ + prisma: {}, + $replica: {}, + $transaction: async ( + prismaClient: { + $transaction: (fn: (tx: unknown) => Promise) => Promise; + }, + nameOrFn: string | ((tx: unknown) => Promise), + fnOrOptions?: ((tx: unknown) => Promise) | unknown + ) => { + const fn = + typeof nameOrFn === "string" + ? (fnOrOptions as (tx: unknown) => Promise) + : nameOrFn; + + return prismaClient.$transaction(fn); + }, +})); + +import { postgresTest } from "@internal/testcontainers"; +import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; +import { + createEnvironmentVariable, + createRuntimeEnvironment, + createTestOrgProjectWithMember, +} from "./fixtures/environmentVariablesFixtures"; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("EnvironmentVariablesRepository.getVariableValuesForKeys", () => { + postgresTest("returns an empty map for an empty items array", async ({ prisma }) => { + const { project } = await createTestOrgProjectWithMember(prisma); + const repository = new EnvironmentVariablesRepository(prisma, prisma); + + const result = await repository.getVariableValuesForKeys(project.id, []); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + + postgresTest("omits missing keys from the result without throwing", async ({ prisma }) => { + const { organization, project } = await createTestOrgProjectWithMember(prisma); + const environment = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + }); + + const repository = new EnvironmentVariablesRepository(prisma, prisma); + + const result = await repository.getVariableValuesForKeys(project.id, [ + { environmentId: environment.id, key: "DOES_NOT_EXIST" }, + ]); + + expect(result.size).toBe(0); + expect(result.has(`${environment.id}:DOES_NOT_EXIST`)).toBe(false); + }); + + postgresTest("returns requested values with correct map keys and decrypted values", async ({ prisma }) => { + const { user, organization, project } = await createTestOrgProjectWithMember(prisma); + const environment = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + }); + + const repository = new EnvironmentVariablesRepository(prisma, prisma); + + await createEnvironmentVariable(repository, project.id, { + environmentId: environment.id, + key: "VAR_A", + value: "value-a", + userId: user.id, + }); + await createEnvironmentVariable(repository, project.id, { + environmentId: environment.id, + key: "VAR_B", + value: "value-b", + userId: user.id, + }); + await createEnvironmentVariable(repository, project.id, { + environmentId: environment.id, + key: "VAR_C", + value: "value-c", + userId: user.id, + }); + + const result = await repository.getVariableValuesForKeys(project.id, [ + { environmentId: environment.id, key: "VAR_A" }, + { environmentId: environment.id, key: "VAR_C" }, + ]); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(2); + expect(result.get(`${environment.id}:VAR_A`)).toBe("value-a"); + expect(result.get(`${environment.id}:VAR_C`)).toBe("value-c"); + expect(result.has(`${environment.id}:VAR_B`)).toBe(false); + }); + + postgresTest("deduplicates duplicate environmentId and key requests", async ({ prisma }) => { + const { user, organization, project } = await createTestOrgProjectWithMember(prisma); + const environment = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + }); + + const repository = new EnvironmentVariablesRepository(prisma, prisma); + + await createEnvironmentVariable(repository, project.id, { + environmentId: environment.id, + key: "DEDUP_KEY", + value: "dedup-value", + userId: user.id, + }); + + const request = { environmentId: environment.id, key: "DEDUP_KEY" }; + const result = await repository.getVariableValuesForKeys(project.id, [request, request, request]); + + expect(result.size).toBe(1); + expect(result.get(`${environment.id}:DEDUP_KEY`)).toBe("dedup-value"); + }); + + postgresTest("isolates values by project", async ({ prisma }) => { + const { user, organization, project: projectA } = await createTestOrgProjectWithMember(prisma); + + const projectB = await prisma.project.create({ + data: { + name: "Project B", + slug: `proj-b-${Date.now()}`, + organizationId: organization.id, + externalRef: `ext-b-${Date.now()}`, + }, + }); + + const envA = await createRuntimeEnvironment(prisma, { + projectId: projectA.id, + organizationId: organization.id, + type: "PRODUCTION", + }); + const envB = await createRuntimeEnvironment(prisma, { + projectId: projectB.id, + organizationId: organization.id, + type: "PRODUCTION", + }); + + const repository = new EnvironmentVariablesRepository(prisma, prisma); + + await createEnvironmentVariable(repository, projectA.id, { + environmentId: envA.id, + key: "SHARED_KEY", + value: "project-a-value", + userId: user.id, + }); + await createEnvironmentVariable(repository, projectB.id, { + environmentId: envB.id, + key: "SHARED_KEY", + value: "project-b-value", + userId: user.id, + }); + + const resultForProjectA = await repository.getVariableValuesForKeys(projectA.id, [ + { environmentId: envA.id, key: "SHARED_KEY" }, + ]); + + expect(resultForProjectA.size).toBe(1); + expect(resultForProjectA.get(`${envA.id}:SHARED_KEY`)).toBe("project-a-value"); + expect(resultForProjectA.get(`${envB.id}:SHARED_KEY`)).toBeUndefined(); + + const crossProjectRequest = await repository.getVariableValuesForKeys(projectA.id, [ + { environmentId: envB.id, key: "SHARED_KEY" }, + ]); + + expect(crossProjectRequest.size).toBe(0); + }); +}); diff --git a/apps/webapp/test/fixtures/environmentVariablesFixtures.ts b/apps/webapp/test/fixtures/environmentVariablesFixtures.ts new file mode 100644 index 00000000000..8e358e1295b --- /dev/null +++ b/apps/webapp/test/fixtures/environmentVariablesFixtures.ts @@ -0,0 +1,104 @@ +import type { PrismaClient, RuntimeEnvironmentType } from "@trigger.dev/database"; +import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; + +let idCounter = 0; + +export function uniqueId(prefix: string) { + idCounter += 1; + return `${prefix}-${idCounter}-${Date.now()}`; +} + +export async function createTestUser(prisma: PrismaClient, email?: string) { + return prisma.user.create({ + data: { + email: email ?? `${uniqueId("user")}@test.com`, + authenticationMethod: "MAGIC_LINK", + }, + }); +} + +export async function createTestOrgProjectWithMember( + prisma: PrismaClient, + options?: { userId?: string } +) { + const user = options?.userId + ? await prisma.user.findUniqueOrThrow({ where: { id: options.userId } }) + : await createTestUser(prisma); + + const orgSlug = uniqueId("org"); + const organization = await prisma.organization.create({ + data: { + title: "Test Org", + slug: orgSlug, + members: { create: { userId: user.id, role: "ADMIN" } }, + }, + include: { members: true }, + }); + + const projectSlug = uniqueId("proj"); + const project = await prisma.project.create({ + data: { + name: "Test Project", + slug: projectSlug, + organizationId: organization.id, + externalRef: uniqueId("ext"), + }, + }); + + return { + user, + organization, + project, + orgMember: organization.members[0]!, + projectSlug, + }; +} + +export async function createRuntimeEnvironment( + prisma: PrismaClient, + options: { + projectId: string; + organizationId: string; + type: RuntimeEnvironmentType; + orgMemberId?: string | null; + slug?: string; + } +) { + const slug = options.slug ?? uniqueId("env"); + return prisma.runtimeEnvironment.create({ + data: { + slug, + type: options.type, + projectId: options.projectId, + organizationId: options.organizationId, + orgMemberId: options.orgMemberId ?? null, + apiKey: uniqueId("api"), + pkApiKey: uniqueId("pk"), + shortcode: uniqueId("sc"), + }, + }); +} + +export async function createEnvironmentVariable( + repository: EnvironmentVariablesRepository, + projectId: string, + options: { + environmentId: string; + key: string; + value: string; + isSecret?: boolean; + userId: string; + } +) { + const result = await repository.create(projectId, { + override: true, + environmentIds: [options.environmentId], + variables: [{ key: options.key, value: options.value }], + isSecret: options.isSecret ?? false, + lastUpdatedBy: { type: "user", userId: options.userId }, + }); + + if (!result.success) { + throw new Error(result.error); + } +} From e92fccd4dbc5a119c78cfa6bef266bd63195b82d Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 4 Jun 2026 16:05:29 +0200 Subject: [PATCH 2/4] perf(webapp): SSR-window and virtualize the env vars table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page server-rendered every row (~13 KB of markup each), producing a tens-of-MB HTML document and mounting thousands of row components on hydration, which froze the browser for projects with many variables across many environments. - Server-render only the first 50 rows, hydrate those, then switch to @tanstack/react-virtual over the full dataset after mount via useLayoutEffect (server and first client render match — no hydration mismatch). - Virtualize with a spacer-row technique inside the existing so column widths and the sticky header are preserved; extract a shared EnvironmentVariableTableRow used by both the SSR and virtual paths to avoid drift. - Seed useFuzzyFilter from the URL `search` param (controlled mode, matching the Tasks page) so filtering happens at SSR and deep links render the correct rows in the initial window. For ~11k rows the document drops from ~150 MB to ~5 MB with 50 SSR rows; the load freeze is gone. --- .../app/components/primitives/Table.tsx | 5 +- .../route.tsx | 482 +++++++++++------- .../measure-environment-variables-html.mts | 219 ++++++++ .../spike-environment-variables-table-dom.mts | 180 +++++++ 4 files changed, 706 insertions(+), 180 deletions(-) create mode 100644 apps/webapp/scripts/measure-environment-variables-html.mts create mode 100644 apps/webapp/scripts/spike-environment-variables-table-dom.mts diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index d69e1201035..bd3a27868aa 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -130,12 +130,13 @@ export const TableHeader = forwardRef type TableBodyProps = { className?: string; children?: ReactNode; + style?: React.CSSProperties; }; export const TableBody = forwardRef( - ({ className, children }, ref) => { + ({ className, children, style }, ref) => { return ( - + {children} ); 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 9ab76ed49b7..ef3d2779263 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 @@ -18,7 +18,15 @@ import { useRevalidator, } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; -import { useEffect, useMemo, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type RefObject, +} from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; @@ -258,6 +266,20 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } }; +const SSR_ROW_WINDOW = 50; +const ROW_ESTIMATE_HEIGHT = 44; +const VIRTUAL_OVERSCAN = 10; + +type GroupedEnvironmentVariable = EnvironmentVariableWithSetValues & { + isFirstTime: boolean; + isLastTime: boolean; + occurences: number; +}; + +type PageVercelIntegration = NonNullable< + Awaited>["vercelIntegration"] +>; + export default function Page() { const [revealAll, setRevealAll] = useState(false); const { environmentVariables, environments, vercelIntegration } = @@ -267,18 +289,17 @@ export default function Page() { const environment = useEnvironment(); const { value } = useSearchParams(); const urlSearch = value("search") ?? ""; - const { setFilterText, filteredItems } = useFuzzyFilter({ + const { filteredItems } = useFuzzyFilter({ items: environmentVariables, keys: ["key", "value", "environment.type", "environment.branchName"], + filterText: urlSearch, }); - useEffect(() => { - setFilterText(urlSearch); - }, [urlSearch, setFilterText]); + const tableScrollRef = useRef(null); // Add isFirst and isLast to each environment variable // They're set based on if they're the first or last time that `key` has been seen in the list - const groupedEnvironmentVariables = useMemo(() => { + const groupedEnvironmentVariables = useMemo((): GroupedEnvironmentVariable[] => { // Create a map to track occurrences of each key const keyOccurrences = new Map(); @@ -313,6 +334,22 @@ export default function Page() { }); }, [filteredItems]); + const shouldVirtualize = groupedEnvironmentVariables.length > SSR_ROW_WINDOW; + const [isVirtualized, setIsVirtualized] = useState(false); + + useLayoutEffect(() => { + setIsVirtualized(shouldVirtualize); + }, [shouldVirtualize]); + + const staticRows = useMemo(() => { + if (shouldVirtualize) { + return groupedEnvironmentVariables.slice(0, SSR_ROW_WINDOW); + } + return groupedEnvironmentVariables; + }, [groupedEnvironmentVariables, shouldVirtualize]); + + const vercelColumnCount = vercelIntegration?.enabled ? 6 : 5; + return ( @@ -328,7 +365,7 @@ export default function Page() { -
+
{environmentVariables.length > 0 && (
@@ -350,189 +387,105 @@ export default function Page() {
)} -
- - - - Key - - - Value - - - - Environment - - - } - content="Dev environment variables specified here will be overridden by ones in your .env file when running locally." - className="max-w-60" - /> - - {vercelIntegration?.enabled && ( - +
+
+ + + + Key + + + Value + + - Sync + Environment } - content="When enabled, this variable will be pulled from Vercel during builds. Requires 'Pull env vars before build' to be enabled in settings." + content="Dev environment variables specified here will be overridden by ones in your .env file when running locally." + className="max-w-60" /> - )} - - Updated - - - Actions - - - - - {groupedEnvironmentVariables.length > 0 ? ( - groupedEnvironmentVariables.map((variable) => { - const cellClassName = "py-2"; - let borderedCellClassName = ""; - - if (variable.occurences > 1) { - borderedCellClassName = - "relative after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright group-hover/table-row:after:bg-grid-bright group-hover/table-row:before:bg-grid-bright"; - if (variable.isLastTime) { - borderedCellClassName = ""; - } else if (variable.isFirstTime) { - } - } else { - } - - return ( - - - {variable.isFirstTime ? ( - - ) : null} - - - {variable.isSecret ? ( - - - Secret - - } - content="This variable is secret and cannot be revealed." - /> - ) : ( - - )} - - - - - - {vercelIntegration?.enabled && ( - - {variable.environment.type !== "DEVELOPMENT" && ( - - )} - - )} - -
- {variable.updatedByUser ? ( -
- - {variable.updatedByUser.name} -
- ) : variable.lastUpdatedBy?.type === "integration" && - variable.lastUpdatedBy?.integration === "vercel" ? ( -
- - - {variable.lastUpdatedBy.integration} - -
- ) : null} - {variable.updatedAt ? ( - - - - ) : null} -
-
- - - - + {vercelIntegration?.enabled && ( + + + Sync + + } + content="When enabled, this variable will be pulled from Vercel during builds. Requires 'Pull env vars before build' to be enabled in settings." /> -
- ); - }) - ) : ( - - - {environmentVariables.length === 0 ? ( -
- You haven't set any environment variables yet. - - Add new - -
- ) : ( -
- No variables match your search. -
- )} -
+ + )} + + Updated + + + Actions +
+ + {groupedEnvironmentVariables.length > 0 ? ( + isVirtualized && shouldVirtualize ? ( + + ) : ( + + {staticRows.map((variable) => ( + + ))} + + ) + ) : ( + + + + {environmentVariables.length === 0 ? ( +
+ You haven't set any environment variables yet. + + Add new + +
+ ) : ( +
+ No variables match your search. +
+ )} +
+
+
)} -
-
+ + @@ -540,6 +493,179 @@ export default function Page() { ); } +function getBorderedCellClassName(variable: GroupedEnvironmentVariable) { + if (variable.occurences <= 1) { + return ""; + } + + if (variable.isLastTime) { + return ""; + } + + return "relative after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright group-hover/table-row:after:bg-grid-bright group-hover/table-row:before:bg-grid-bright"; +} + +function EnvironmentVariableTableRow({ + variable, + revealAll, + vercelIntegration, +}: { + variable: GroupedEnvironmentVariable; + revealAll: boolean; + vercelIntegration: PageVercelIntegration | null; +}) { + const cellClassName = "py-2"; + const borderedCellClassName = getBorderedCellClassName(variable); + + return ( + + + {variable.isFirstTime ? ( + + ) : null} + + + {variable.isSecret ? ( + + + Secret + + } + content="This variable is secret and cannot be revealed." + /> + ) : ( + + )} + + + + + + {vercelIntegration?.enabled && ( + + {variable.environment.type !== "DEVELOPMENT" && ( + + )} + + )} + +
+ {variable.updatedAt ? ( + + + + ) : null} + {variable.updatedByUser ? ( +
+ + {variable.updatedByUser.name} +
+ ) : variable.lastUpdatedBy?.type === "integration" && + variable.lastUpdatedBy?.integration === "vercel" ? ( +
+ + + {variable.lastUpdatedBy.integration} + +
+ ) : null} +
+
+ + + + + } + /> +
+ ); +} + +function EnvironmentVariablesVirtualTableBody({ + groupedEnvironmentVariables, + scrollRef, + revealAll, + vercelIntegration, + columnCount, +}: { + groupedEnvironmentVariables: GroupedEnvironmentVariable[]; + scrollRef: RefObject; + revealAll: boolean; + vercelIntegration: PageVercelIntegration | null; + columnCount: number; +}) { + const rowVirtualizer = useVirtualizer({ + count: groupedEnvironmentVariables.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_ESTIMATE_HEIGHT, + overscan: VIRTUAL_OVERSCAN, + }); + + const virtualItems = rowVirtualizer.getVirtualItems(); + const topSpacerHeight = virtualItems[0]?.start ?? 0; + const bottomSpacerHeight = + rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0); + + return ( + + {topSpacerHeight > 0 && ( + + + + )} + {virtualItems.map((virtualRow) => { + const variable = groupedEnvironmentVariables[virtualRow.index]; + if (!variable) { + return null; + } + + return ( + + ); + })} + {bottomSpacerHeight > 0 && ( + + + + )} + + ); +} + function EditEnvironmentVariablePanel({ variable, revealAll, diff --git a/apps/webapp/scripts/measure-environment-variables-html.mts b/apps/webapp/scripts/measure-environment-variables-html.mts new file mode 100644 index 00000000000..2fd3605e53f --- /dev/null +++ b/apps/webapp/scripts/measure-environment-variables-html.mts @@ -0,0 +1,219 @@ +/** + * Measures Environment Variables page response breakdown: + * - total document size + * - __remixContext / loader script payload + * - table tbody markup + * + * Usage (webapp dev server running on :3030): + * pnpm exec tsx scripts/measure-environment-variables-html.mts + */ +import { prisma } from "../app/db.server"; +import { EnvironmentVariablesPresenter } from "../app/presenters/v3/EnvironmentVariablesPresenter.server"; +import { authenticator } from "../app/services/auth.server"; +import { sessionStorage } from "../app/services/sessionStorage.server"; + +const BASE_URL = process.env.MEASURE_BASE_URL ?? "http://localhost:3030"; + +async function createSessionCookie(userId: string): Promise { + const session = await sessionStorage.getSession(); + session.set(authenticator.sessionKey, { userId }); + return sessionStorage.commitSession(session); +} + +function extractBetween(html: string, startMarker: string, endMarker: string): string | null { + const start = html.indexOf(startMarker); + if (start === -1) return null; + const end = html.indexOf(endMarker, start + startMarker.length); + if (end === -1) return null; + return html.slice(start, end + endMarker.length); +} + +function measureRemixContext(html: string): { bytes: number; snippet: string } { + const markers = [ + 'window.__remixContext = ', + 'window.__remixContext=', + '__remixContext = ', + '__remixContext=', + ]; + for (const marker of markers) { + const idx = html.indexOf(marker); + if (idx === -1) continue; + const scriptStart = html.lastIndexOf("", idx); + if (scriptStart === -1 || scriptEnd === -1) continue; + const script = html.slice(scriptStart, scriptEnd + "".length); + return { bytes: Buffer.byteLength(script, "utf8"), snippet: marker }; + } + // Fallback: sum all script tags containing route loader keys + let total = 0; + const re = /]*>([\s\S]*?)<\/script>/gi; + let match: RegExpExecArray | null; + while ((match = re.exec(html)) !== null) { + const body = match[1] ?? ""; + if ( + body.includes("environmentVariables") || + body.includes("__remixContext") || + body.includes("remixRouteModules") + ) { + total += Buffer.byteLength(match[0], "utf8"); + } + } + return { bytes: total, snippet: "script-tags-fallback" }; +} + +function measureTableBody(html: string): { bytes: number; trCount: number } { + const tbodyStart = html.indexOf("", tbodyStart); + if (tbodyStart === -1 || tbodyEnd === -1) { + return { bytes: 0, trCount: 0 }; + } + const tbody = html.slice(tbodyStart, tbodyEnd + "".length); + const trCount = (tbody.match(//); + if (remixMatch) { + const ctx = JSON.parse(remixMatch[1]) as { + state?: { loaderData?: Record }; + loaderData?: Record; + }; + const routes = ctx.state?.loaderData ?? ctx.loaderData ?? {}; + for (const [id, data] of Object.entries(routes)) { + const serialized = JSON.stringify(data); + allRoutesLoaderBytes += Buffer.byteLength(serialized, "utf8"); + if (data && typeof data === "object" && "environmentVariables" in data) { + envRouteId = id; + const d = data as { + environmentVariables: unknown; + environments: unknown; + hasStaging: unknown; + vercelIntegration: unknown; + }; + envRouteLoaderBytes = Buffer.byteLength( + JSON.stringify({ + environmentVariables: d.environmentVariables, + environments: d.environments, + hasStaging: d.hasStaging, + vercelIntegration: d.vercelIntegration, + }), + "utf8" + ); + } + } + } + + const inputValues = [...html.matchAll(/]*value="([^"]*)"/g)]; + const maskedInputs = inputValues.filter((m) => m[1]?.includes("•")).length; + + // Estimate env-vars slice inside embedded JSON (string search, approximate) + const envVarKeyIdx = html.indexOf('"environmentVariables"'); + let envVarsJsonApprox = 0; + if (envVarKeyIdx !== -1) { + const slice = html.slice(envVarKeyIdx, envVarKeyIdx + pageLoaderJson.length + 500_000); + envVarsJsonApprox = Math.min(slice.length, pageLoaderJson.length + 50_000); + } + + console.log("\n=== Environment Variables page size breakdown ===\n"); + console.log(`URL: ${url}`); + console.log(`HTTP: ${res.status}`); + console.log(`Presenter rows: ${presenterData.environmentVariables.length}`); + console.log(""); + console.log(`Total document: ${formatMb(docBytes)}`); + console.log(`Presenter page loader JSON: ${formatMb(Buffer.byteLength(pageLoaderJson, "utf8"))}`); + console.log(`Remix context script(s): ${formatMb(remixContext.bytes)} (${remixContext.snippet})`); + if (envRouteId) { + console.log(` env-vars route loader: ${formatMb(envRouteLoaderBytes)} (route: ${envRouteId})`); + console.log(` all matched loaders sum: ${formatMb(allRoutesLoaderBytes)}`); + } + console.log(` markup: ${formatMb(tableBody.bytes)} (${tableBody.trCount} in tbody)`); + console.log(`All in document: ${allTrCount}`); + console.log( + `Value attrs: ${inputValues.length} total, ${maskedInputs} masked (reveal off)` + ); + console.log(""); + console.log( + `Table share of document: ${((tableBody.bytes / docBytes) * 100).toFixed(1)}% (tbody only)` + ); + console.log( + `Loader scripts share: ${((remixContext.bytes / docBytes) * 100).toFixed(1)}% (all route scripts in remix context)` + ); + const nonTableBytes = docBytes - tableBody.bytes; + console.log(`Non-tbody document: ${formatMb(nonTableBytes)} (shell + scripts + header)`); + console.log(""); + console.log("=== SSR window estimate (50 rows, loader unchanged) ===\n"); + + const rows = presenterData.environmentVariables.length; + const ssrWindow = 50; + const tbodyPerRow = tableBody.trCount > 0 ? tableBody.bytes / tableBody.trCount : 0; + const estimatedTbody50 = tbodyPerRow * ssrWindow + 200; // header row in tbody if any + const estimatedDoc50 = docBytes - tableBody.bytes + estimatedTbody50; + + console.log(`Per-row tbody bytes (measured): ${Math.round(tbodyPerRow).toLocaleString()} B`); + console.log(`Estimated tbody @ 50 rows: ${formatMb(estimatedTbody50)}`); + console.log(`Estimated document @ 50 rows: ${formatMb(estimatedDoc50)}`); + console.log( + `Document reduction factor: ${(docBytes / estimatedDoc50).toFixed(1)}× (${(((docBytes - estimatedDoc50) / docBytes) * 100).toFixed(1)}% smaller)` + ); + console.log( + `Hydration row reduction: ${tableBody.trCount} → ${ssrWindow} (${((1 - ssrWindow / tableBody.trCount) * 100).toFixed(1)}% fewer row components)` + ); + + await prisma.$disconnect(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/webapp/scripts/spike-environment-variables-table-dom.mts b/apps/webapp/scripts/spike-environment-variables-table-dom.mts new file mode 100644 index 00000000000..9c30de7023a --- /dev/null +++ b/apps/webapp/scripts/spike-environment-variables-table-dom.mts @@ -0,0 +1,180 @@ +/** + * DOM spike for Environment Variables table virtualization. + * Requires: local webapp on :3030, db seeded (local@trigger.dev). + * + * pnpm exec tsx scripts/spike-environment-variables-table-dom.mts + */ +import { prisma } from "../app/db.server"; +import { authenticator } from "../app/services/auth.server"; +import { sessionStorage } from "../app/services/sessionStorage.server"; + +const BASE_URL = process.env.SPIKE_BASE_URL ?? "http://localhost:3030"; + +async function createSessionCookie(userId: string): Promise { + const session = await sessionStorage.getSession(); + session.set(authenticator.sessionKey, { userId }); + const setCookie = await sessionStorage.commitSession(session); + return setCookie.split(";")[0]!; +} + +function formatSelector(el: Element): string { + const tag = el.tagName.toLowerCase(); + const id = el.id ? `#${el.id}` : ""; + const classes = el.className && typeof el.className === "string" + ? "." + el.className.trim().split(/\s+/).slice(0, 4).join(".") + : ""; + return `${tag}${id}${classes}`; +} + +async function main() { + const user = await prisma.user.findUnique({ where: { email: "local@trigger.dev" } }); + if (!user) throw new Error("Seed DB first (local@trigger.dev)"); + + const org = await prisma.organization.findFirst({ where: { title: "References" }, select: { slug: true } }); + const project = await prisma.project.findFirst({ + where: { slug: { contains: "hello-world" } }, + select: { slug: true }, + }); + const env = await prisma.runtimeEnvironment.findFirst({ + where: { project: { slug: project!.slug }, type: "DEVELOPMENT" }, + select: { slug: true }, + }); + + const path = `/orgs/${org!.slug}/projects/${project!.slug}/env/${env!.slug}/environment-variables`; + const url = `${BASE_URL}${path}`; + const cookie = await createSessionCookie(user.id); + + const { chromium } = await import("@playwright/test"); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + await context.addCookies([ + { + name: cookie.split("=")[0]!, + value: cookie.split("=")[1]!, + domain: "localhost", + path: "/", + httpOnly: true, + sameSite: "Lax", + }, + ]); + + const page = await context.newPage(); + await page.goto(url, { waitUntil: "networkidle", timeout: 120_000 }); + + // Plain string evaluate avoids tsx injecting __name into browser context + const domReport = await page.evaluate(` + (function() { + function findVerticalScrollParent(start) { + var el = start; + while (el) { + var s = getComputedStyle(el); + var oy = s.overflowY; + if ((oy === "auto" || oy === "scroll") && el.scrollHeight > el.clientHeight + 1) { + return el; + } + el = el.parentElement; + } + return null; + } + + var firstBodyRow = document.querySelector("tbody tr"); + var scrollParent = findVerticalScrollParent(firstBodyRow); + var rows = Array.from(document.querySelectorAll("tbody > tr")).filter(function(tr) { + return !tr.querySelector("td[colspan]"); + }); + + var heights = rows.slice(0, 200).map(function(tr) { + return tr.getBoundingClientRect().height; + }); + var min = heights.length ? Math.min.apply(null, heights) : 0; + var max = heights.length ? Math.max.apply(null, heights) : 0; + var sum = heights.reduce(function(a, b) { return a + b; }, 0); + var avg = heights.length ? sum / heights.length : 0; + + var table = document.querySelector("table"); + var tableWrapper = table ? table.parentElement : null; + var thead = document.querySelector("thead"); + + var pageBodyEl = null; + var divs = document.querySelectorAll("div"); + for (var i = 0; i < divs.length; i++) { + var d = divs[i]; + if (String(d.className).indexOf("overflow-hidden") >= 0 && d.querySelector("table")) { + pageBodyEl = d; + break; + } + } + + return { + rowCount: rows.length, + heightsSampled: heights.length, + rowHeight: { min: min, max: max, avg: avg }, + scrollParent: scrollParent ? { + tag: scrollParent.tagName, + className: String(scrollParent.className).slice(0, 120), + overflowY: getComputedStyle(scrollParent).overflowY, + clientHeight: scrollParent.clientHeight, + scrollHeight: scrollParent.scrollHeight + } : null, + documentScrollingElement: { + clientHeight: document.documentElement.clientHeight, + scrollHeight: document.documentElement.scrollHeight + }, + window: { innerHeight: window.innerHeight, scrollY: window.scrollY }, + tableWrapper: tableWrapper ? { + tag: tableWrapper.tagName, + className: String(tableWrapper.className).slice(0, 120), + overflowX: getComputedStyle(tableWrapper).overflowX, + overflowY: getComputedStyle(tableWrapper).overflowY, + clientHeight: tableWrapper.clientHeight, + scrollHeight: tableWrapper.scrollHeight + } : null, + theadPosition: thead ? getComputedStyle(thead).position : null, + hasStickyActionTd: !!document.querySelector("tbody tr td[class*='sticky']"), + pageBody: pageBodyEl ? { + className: String(pageBodyEl.className).slice(0, 120), + overflowY: getComputedStyle(pageBodyEl).overflowY, + clientHeight: pageBodyEl.clientHeight, + scrollHeight: pageBodyEl.scrollHeight + } : null + }; + })() + `); + + console.log("\n=== 1. Scroll container ===\n"); + console.log(JSON.stringify(domReport.scrollParent, null, 2)); + console.log("\nDocument scrollingElement:", domReport.documentScrollingElement); + console.log("\nTable wrapper:", domReport.tableWrapper); + console.log("\nPageBody-like ancestor:", domReport.pageBody); + console.log("\nScroll candidates (overflow content):", domReport.scrollCandidates); + + console.log("\n=== 2. Row heights (first 200 rows) ===\n"); + console.log(`Total tbody data rows: ${domReport.rowCount}`); + console.log( + `min=${domReport.rowHeight.min.toFixed(1)}px max=${domReport.rowHeight.max.toFixed(1)}px avg=${domReport.rowHeight.avg.toFixed(1)}px` + ); + + console.log("\n=== 3. Header / sticky ===\n"); + console.log(`thead position: ${domReport.theadPosition}`); + console.log(`sticky action td in tbody: ${domReport.hasStickyActionTd}`); + + // Scroll test: can window scroll? + const beforeScroll = await page.evaluate(() => window.scrollY); + await page.keyboard.press("PageDown"); + await page.waitForTimeout(300); + const afterScroll = await page.evaluate(() => ({ + scrollY: window.scrollY, + docScroll: document.scrollingElement?.scrollTop ?? 0, + })); + console.log("\n=== Scroll behavior (PageDown) ===\n"); + console.log(`window.scrollY: ${beforeScroll} -> ${afterScroll.scrollY}`); + console.log(`document scrollTop: ${afterScroll.docScroll}`); + + await browser.close(); + await prisma.$disconnect(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); From a456e19a4a906f1c2d9f066c643b95560c90c64b Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 4 Jun 2026 16:05:30 +0200 Subject: [PATCH 3/4] perf(webapp): use lightweight loaders for the env var create flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening /environment-variables/new ran the full list presenter twice — once in the parent route loader and once in the child loader — fetching and decrypting every variable value just to show the create form, which only needs the list of environments. - Short-circuit the parent route loader on the /new path so it skips the list presenter entirely and renders only the create outlet. - Load just the environment list in the child route via a new CreateEnvironmentVariablesPresenter. - Extract the shared environment-loading logic into loadEnvironmentVariablesEnvironments, preserving the project access check and environment filtering for both presenters. Removes the heavy presenter work (full fetch + decrypt) when opening the create form. --- .../environment-variables-page-performance.md | 6 + .../EnvironmentVariablesPresenter.server.ts | 51 +--- ...environmentVariablesEnvironments.server.ts | 75 ++++++ .../route.tsx | 53 ++--- .../route.tsx | 34 ++- .../measure-environment-variables-html.mts | 219 ------------------ .../spike-environment-variables-table-dom.mts | 180 -------------- .../environmentVariablesEnvironments.test.ts | 144 ++++++++++++ packages/core/src/v3/otel/tracingSDK.ts | 8 +- 9 files changed, 287 insertions(+), 483 deletions(-) create mode 100644 .server-changes/environment-variables-page-performance.md create mode 100644 apps/webapp/app/presenters/v3/environmentVariablesEnvironments.server.ts delete mode 100644 apps/webapp/scripts/measure-environment-variables-html.mts delete mode 100644 apps/webapp/scripts/spike-environment-variables-table-dom.mts create mode 100644 apps/webapp/test/environmentVariablesEnvironments.test.ts diff --git a/.server-changes/environment-variables-page-performance.md b/.server-changes/environment-variables-page-performance.md new file mode 100644 index 00000000000..2bd802cccba --- /dev/null +++ b/.server-changes/environment-variables-page-performance.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +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) diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index f429c8517fa..2ab7f1f5a1b 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -1,7 +1,6 @@ import { PrismaClient, prisma } from "~/db.server"; import { Project } from "~/models/project.server"; import { User } from "~/models/user.server"; -import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import type { EnvironmentVariableUpdater } from "~/v3/environmentVariables/repository"; import { @@ -9,6 +8,7 @@ import { EnvSlug, } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { VercelIntegrationService } from "~/services/vercelIntegration.server"; +import { loadEnvironmentVariablesEnvironments } from "./environmentVariablesEnvironments.server"; type Result = Awaited>; export type EnvironmentVariableWithSetValues = Result["environmentVariables"][number]; @@ -62,16 +62,7 @@ export class EnvironmentVariablesPresenter { }, }, where: { - project: { - slug: projectSlug, - organization: { - members: { - some: { - userId, - }, - }, - }, - }, + projectId: project.id, }, }); @@ -111,29 +102,12 @@ export class EnvironmentVariablesPresenter { const usersRecord: Record = Object.fromEntries(users.map((u) => [u.id, u])); - const environments = await this.#prismaClient.runtimeEnvironment.findMany({ - select: { - id: true, - type: true, - isBranchableEnvironment: true, - branchName: true, - orgMember: { - select: { - userId: true, - }, - }, - }, - where: { - project: { - slug: projectSlug, - }, - archivedAt: null, - }, - }); - - const sortedEnvironments = sortEnvironments(filterOrphanedEnvironments(environments)).filter( - (e) => e.orgMember?.userId === userId || e.orgMember === null - ); + const { environments: sortedEnvironments, hasStaging } = + await loadEnvironmentVariablesEnvironments( + this.#prismaClient, + { userId, projectId: project.id }, + { skipProjectAccessCheck: true } + ); const repository = new EnvironmentVariablesRepository(this.#prismaClient); @@ -215,13 +189,8 @@ export class EnvironmentVariablesPresenter { }); }) .sort((a, b) => a.key.localeCompare(b.key)), - environments: sortedEnvironments.map((environment) => ({ - id: environment.id, - type: environment.type, - isBranchableEnvironment: environment.isBranchableEnvironment, - branchName: environment.branchName, - })), - hasStaging: environments.some((environment) => environment.type === "STAGING"), + environments: sortedEnvironments, + hasStaging, // Vercel integration data vercelIntegration: vercelIntegration ? { diff --git a/apps/webapp/app/presenters/v3/environmentVariablesEnvironments.server.ts b/apps/webapp/app/presenters/v3/environmentVariablesEnvironments.server.ts new file mode 100644 index 00000000000..218d0be7eb6 --- /dev/null +++ b/apps/webapp/app/presenters/v3/environmentVariablesEnvironments.server.ts @@ -0,0 +1,75 @@ +import type { RuntimeEnvironmentType } from "@trigger.dev/database"; +import { type PrismaClient } from "~/db.server"; +import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort"; + +export type EnvironmentVariablesEnvironment = { + id: string; + type: RuntimeEnvironmentType; + isBranchableEnvironment: boolean; + branchName: string | null; +}; + +export type EnvironmentVariablesEnvironmentsResult = { + environments: EnvironmentVariablesEnvironment[]; + hasStaging: boolean; +}; + +export async function loadEnvironmentVariablesEnvironments( + prismaClient: PrismaClient, + { userId, projectId }: { userId: string; projectId: string }, + options?: { skipProjectAccessCheck?: boolean } +): Promise { + if (!options?.skipProjectAccessCheck) { + const project = await prismaClient.project.findFirst({ + select: { + id: true, + }, + where: { + id: projectId, + organization: { + members: { + some: { + userId, + }, + }, + }, + }, + }); + + if (!project) { + throw new Error("Project not found"); + } + } + + const environments = await prismaClient.runtimeEnvironment.findMany({ + select: { + id: true, + type: true, + isBranchableEnvironment: true, + branchName: true, + orgMember: { + select: { + userId: true, + }, + }, + }, + where: { + projectId, + archivedAt: null, + }, + }); + + const sortedEnvironments = sortEnvironments(filterOrphanedEnvironments(environments)).filter( + (environment) => environment.orgMember?.userId === userId || environment.orgMember === null + ); + + return { + environments: sortedEnvironments.map((environment) => ({ + id: environment.id, + type: environment.type, + isBranchableEnvironment: environment.isBranchableEnvironment, + branchName: environment.branchName, + })), + hasStaging: environments.some((environment) => environment.type === "STAGING"), + }; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index 86bd5bbc95d..4c318323b58 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -9,10 +9,11 @@ import { import { parse } from "@conform-to/zod"; import { LockClosedIcon, LockOpenIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import dotenv from "dotenv"; -import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; -import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { type RefObject, useCallback, useRef, useState } from "react"; +import { redirect } from "remix-typedjson"; +import invariant from "tiny-invariant"; import { z } from "zod"; import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; import { Button, LinkButton } from "~/components/primitives/Buttons"; @@ -39,13 +40,15 @@ import { useEnvironment } from "~/hooks/useEnvironment"; import { useList } from "~/hooks/useList"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; -import { EnvironmentVariablesPresenter } from "~/presenters/v3/EnvironmentVariablesPresenter.server"; -import { logger } from "~/services/logger.server"; +import { useTypedMatchesData } from "~/hooks/useTypedMatchData"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; +import { + environmentVariablesRouteId, + type loader as environmentVariablesLoader, +} from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route"; import { EnvironmentParamSchema, - ProjectParamSchema, v3BillingPath, v3EnvironmentVariablesPath, } from "~/utils/pathBuilder"; @@ -53,29 +56,6 @@ import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/enviro import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository"; import { Select, SelectItem } from "~/components/primitives/Select"; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { projectParam } = ProjectParamSchema.parse(params); - - try { - const presenter = new EnvironmentVariablesPresenter(); - const { environments, hasStaging } = await presenter.call({ - userId, - projectSlug: projectParam, - }); - - return typedjson({ - environments, - hasStaging, - }); - } catch (error) { - throw new Response(undefined, { - status: 400, - statusText: "Something went wrong, if this problem persists please contact support.", - }); - } -}; - const Variable = z.object({ key: EnvironmentVariableKey, value: z.string().nonempty("Value is required"), @@ -185,8 +165,15 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }; export default function Page() { - const [isOpen, setIsOpen] = useState(false); - const { environments, hasStaging } = useTypedLoaderData(); + const [isOpen, setIsOpen] = useState(true); + const parentData = useTypedMatchesData({ + id: environmentVariablesRouteId, + }); + invariant( + parentData, + "Environment variables page loader data must be defined when rendering the create dialog" + ); + const { environments, hasStaging } = parentData; const lastSubmission = useActionData(); const navigation = useNavigation(); const navigate = useNavigate(); @@ -259,10 +246,6 @@ export default function Page() { const [revealAll, setRevealAll] = useState(true); - useEffect(() => { - setIsOpen(true); - }, []); - return ( { ]; }; +type PageVercelIntegration = NonNullable< + Awaited>["vercelIntegration"] +>; + +export type EnvironmentVariablesPageLoaderData = { + environmentVariables: EnvironmentVariableWithSetValues[]; + environments: EnvironmentVariablesEnvironment[]; + hasStaging: boolean; + vercelIntegration: PageVercelIntegration | null; +}; + +export const environmentVariablesRouteId = + "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables"; + export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const { projectParam } = ProjectParamSchema.parse(params); @@ -276,14 +291,19 @@ type GroupedEnvironmentVariable = EnvironmentVariableWithSetValues & { occurences: number; }; -type PageVercelIntegration = NonNullable< - Awaited>["vercelIntegration"] ->; - export default function Page() { + const loaderData = useTypedLoaderData(); + + return ; +} + +function EnvironmentVariablesListPage({ + loaderData, +}: { + loaderData: EnvironmentVariablesPageLoaderData; +}) { const [revealAll, setRevealAll] = useState(false); - const { environmentVariables, environments, vercelIntegration } = - useTypedLoaderData(); + const { environmentVariables, vercelIntegration } = loaderData; const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -487,8 +507,8 @@ export default function Page() { - + ); } diff --git a/apps/webapp/scripts/measure-environment-variables-html.mts b/apps/webapp/scripts/measure-environment-variables-html.mts deleted file mode 100644 index 2fd3605e53f..00000000000 --- a/apps/webapp/scripts/measure-environment-variables-html.mts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Measures Environment Variables page response breakdown: - * - total document size - * - __remixContext / loader script payload - * - table tbody markup - * - * Usage (webapp dev server running on :3030): - * pnpm exec tsx scripts/measure-environment-variables-html.mts - */ -import { prisma } from "../app/db.server"; -import { EnvironmentVariablesPresenter } from "../app/presenters/v3/EnvironmentVariablesPresenter.server"; -import { authenticator } from "../app/services/auth.server"; -import { sessionStorage } from "../app/services/sessionStorage.server"; - -const BASE_URL = process.env.MEASURE_BASE_URL ?? "http://localhost:3030"; - -async function createSessionCookie(userId: string): Promise { - const session = await sessionStorage.getSession(); - session.set(authenticator.sessionKey, { userId }); - return sessionStorage.commitSession(session); -} - -function extractBetween(html: string, startMarker: string, endMarker: string): string | null { - const start = html.indexOf(startMarker); - if (start === -1) return null; - const end = html.indexOf(endMarker, start + startMarker.length); - if (end === -1) return null; - return html.slice(start, end + endMarker.length); -} - -function measureRemixContext(html: string): { bytes: number; snippet: string } { - const markers = [ - 'window.__remixContext = ', - 'window.__remixContext=', - '__remixContext = ', - '__remixContext=', - ]; - for (const marker of markers) { - const idx = html.indexOf(marker); - if (idx === -1) continue; - const scriptStart = html.lastIndexOf("", idx); - if (scriptStart === -1 || scriptEnd === -1) continue; - const script = html.slice(scriptStart, scriptEnd + "".length); - return { bytes: Buffer.byteLength(script, "utf8"), snippet: marker }; - } - // Fallback: sum all script tags containing route loader keys - let total = 0; - const re = /]*>([\s\S]*?)<\/script>/gi; - let match: RegExpExecArray | null; - while ((match = re.exec(html)) !== null) { - const body = match[1] ?? ""; - if ( - body.includes("environmentVariables") || - body.includes("__remixContext") || - body.includes("remixRouteModules") - ) { - total += Buffer.byteLength(match[0], "utf8"); - } - } - return { bytes: total, snippet: "script-tags-fallback" }; -} - -function measureTableBody(html: string): { bytes: number; trCount: number } { - const tbodyStart = html.indexOf("", tbodyStart); - if (tbodyStart === -1 || tbodyEnd === -1) { - return { bytes: 0, trCount: 0 }; - } - const tbody = html.slice(tbodyStart, tbodyEnd + "".length); - const trCount = (tbody.match(//); - if (remixMatch) { - const ctx = JSON.parse(remixMatch[1]) as { - state?: { loaderData?: Record }; - loaderData?: Record; - }; - const routes = ctx.state?.loaderData ?? ctx.loaderData ?? {}; - for (const [id, data] of Object.entries(routes)) { - const serialized = JSON.stringify(data); - allRoutesLoaderBytes += Buffer.byteLength(serialized, "utf8"); - if (data && typeof data === "object" && "environmentVariables" in data) { - envRouteId = id; - const d = data as { - environmentVariables: unknown; - environments: unknown; - hasStaging: unknown; - vercelIntegration: unknown; - }; - envRouteLoaderBytes = Buffer.byteLength( - JSON.stringify({ - environmentVariables: d.environmentVariables, - environments: d.environments, - hasStaging: d.hasStaging, - vercelIntegration: d.vercelIntegration, - }), - "utf8" - ); - } - } - } - - const inputValues = [...html.matchAll(/]*value="([^"]*)"/g)]; - const maskedInputs = inputValues.filter((m) => m[1]?.includes("•")).length; - - // Estimate env-vars slice inside embedded JSON (string search, approximate) - const envVarKeyIdx = html.indexOf('"environmentVariables"'); - let envVarsJsonApprox = 0; - if (envVarKeyIdx !== -1) { - const slice = html.slice(envVarKeyIdx, envVarKeyIdx + pageLoaderJson.length + 500_000); - envVarsJsonApprox = Math.min(slice.length, pageLoaderJson.length + 50_000); - } - - console.log("\n=== Environment Variables page size breakdown ===\n"); - console.log(`URL: ${url}`); - console.log(`HTTP: ${res.status}`); - console.log(`Presenter rows: ${presenterData.environmentVariables.length}`); - console.log(""); - console.log(`Total document: ${formatMb(docBytes)}`); - console.log(`Presenter page loader JSON: ${formatMb(Buffer.byteLength(pageLoaderJson, "utf8"))}`); - console.log(`Remix context script(s): ${formatMb(remixContext.bytes)} (${remixContext.snippet})`); - if (envRouteId) { - console.log(` env-vars route loader: ${formatMb(envRouteLoaderBytes)} (route: ${envRouteId})`); - console.log(` all matched loaders sum: ${formatMb(allRoutesLoaderBytes)}`); - } - console.log(` markup: ${formatMb(tableBody.bytes)} (${tableBody.trCount} in tbody)`); - console.log(`All in document: ${allTrCount}`); - console.log( - `Value attrs: ${inputValues.length} total, ${maskedInputs} masked (reveal off)` - ); - console.log(""); - console.log( - `Table share of document: ${((tableBody.bytes / docBytes) * 100).toFixed(1)}% (tbody only)` - ); - console.log( - `Loader scripts share: ${((remixContext.bytes / docBytes) * 100).toFixed(1)}% (all route scripts in remix context)` - ); - const nonTableBytes = docBytes - tableBody.bytes; - console.log(`Non-tbody document: ${formatMb(nonTableBytes)} (shell + scripts + header)`); - console.log(""); - console.log("=== SSR window estimate (50 rows, loader unchanged) ===\n"); - - const rows = presenterData.environmentVariables.length; - const ssrWindow = 50; - const tbodyPerRow = tableBody.trCount > 0 ? tableBody.bytes / tableBody.trCount : 0; - const estimatedTbody50 = tbodyPerRow * ssrWindow + 200; // header row in tbody if any - const estimatedDoc50 = docBytes - tableBody.bytes + estimatedTbody50; - - console.log(`Per-row tbody bytes (measured): ${Math.round(tbodyPerRow).toLocaleString()} B`); - console.log(`Estimated tbody @ 50 rows: ${formatMb(estimatedTbody50)}`); - console.log(`Estimated document @ 50 rows: ${formatMb(estimatedDoc50)}`); - console.log( - `Document reduction factor: ${(docBytes / estimatedDoc50).toFixed(1)}× (${(((docBytes - estimatedDoc50) / docBytes) * 100).toFixed(1)}% smaller)` - ); - console.log( - `Hydration row reduction: ${tableBody.trCount} → ${ssrWindow} (${((1 - ssrWindow / tableBody.trCount) * 100).toFixed(1)}% fewer row components)` - ); - - await prisma.$disconnect(); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/apps/webapp/scripts/spike-environment-variables-table-dom.mts b/apps/webapp/scripts/spike-environment-variables-table-dom.mts deleted file mode 100644 index 9c30de7023a..00000000000 --- a/apps/webapp/scripts/spike-environment-variables-table-dom.mts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * DOM spike for Environment Variables table virtualization. - * Requires: local webapp on :3030, db seeded (local@trigger.dev). - * - * pnpm exec tsx scripts/spike-environment-variables-table-dom.mts - */ -import { prisma } from "../app/db.server"; -import { authenticator } from "../app/services/auth.server"; -import { sessionStorage } from "../app/services/sessionStorage.server"; - -const BASE_URL = process.env.SPIKE_BASE_URL ?? "http://localhost:3030"; - -async function createSessionCookie(userId: string): Promise { - const session = await sessionStorage.getSession(); - session.set(authenticator.sessionKey, { userId }); - const setCookie = await sessionStorage.commitSession(session); - return setCookie.split(";")[0]!; -} - -function formatSelector(el: Element): string { - const tag = el.tagName.toLowerCase(); - const id = el.id ? `#${el.id}` : ""; - const classes = el.className && typeof el.className === "string" - ? "." + el.className.trim().split(/\s+/).slice(0, 4).join(".") - : ""; - return `${tag}${id}${classes}`; -} - -async function main() { - const user = await prisma.user.findUnique({ where: { email: "local@trigger.dev" } }); - if (!user) throw new Error("Seed DB first (local@trigger.dev)"); - - const org = await prisma.organization.findFirst({ where: { title: "References" }, select: { slug: true } }); - const project = await prisma.project.findFirst({ - where: { slug: { contains: "hello-world" } }, - select: { slug: true }, - }); - const env = await prisma.runtimeEnvironment.findFirst({ - where: { project: { slug: project!.slug }, type: "DEVELOPMENT" }, - select: { slug: true }, - }); - - const path = `/orgs/${org!.slug}/projects/${project!.slug}/env/${env!.slug}/environment-variables`; - const url = `${BASE_URL}${path}`; - const cookie = await createSessionCookie(user.id); - - const { chromium } = await import("@playwright/test"); - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext(); - await context.addCookies([ - { - name: cookie.split("=")[0]!, - value: cookie.split("=")[1]!, - domain: "localhost", - path: "/", - httpOnly: true, - sameSite: "Lax", - }, - ]); - - const page = await context.newPage(); - await page.goto(url, { waitUntil: "networkidle", timeout: 120_000 }); - - // Plain string evaluate avoids tsx injecting __name into browser context - const domReport = await page.evaluate(` - (function() { - function findVerticalScrollParent(start) { - var el = start; - while (el) { - var s = getComputedStyle(el); - var oy = s.overflowY; - if ((oy === "auto" || oy === "scroll") && el.scrollHeight > el.clientHeight + 1) { - return el; - } - el = el.parentElement; - } - return null; - } - - var firstBodyRow = document.querySelector("tbody tr"); - var scrollParent = findVerticalScrollParent(firstBodyRow); - var rows = Array.from(document.querySelectorAll("tbody > tr")).filter(function(tr) { - return !tr.querySelector("td[colspan]"); - }); - - var heights = rows.slice(0, 200).map(function(tr) { - return tr.getBoundingClientRect().height; - }); - var min = heights.length ? Math.min.apply(null, heights) : 0; - var max = heights.length ? Math.max.apply(null, heights) : 0; - var sum = heights.reduce(function(a, b) { return a + b; }, 0); - var avg = heights.length ? sum / heights.length : 0; - - var table = document.querySelector("table"); - var tableWrapper = table ? table.parentElement : null; - var thead = document.querySelector("thead"); - - var pageBodyEl = null; - var divs = document.querySelectorAll("div"); - for (var i = 0; i < divs.length; i++) { - var d = divs[i]; - if (String(d.className).indexOf("overflow-hidden") >= 0 && d.querySelector("table")) { - pageBodyEl = d; - break; - } - } - - return { - rowCount: rows.length, - heightsSampled: heights.length, - rowHeight: { min: min, max: max, avg: avg }, - scrollParent: scrollParent ? { - tag: scrollParent.tagName, - className: String(scrollParent.className).slice(0, 120), - overflowY: getComputedStyle(scrollParent).overflowY, - clientHeight: scrollParent.clientHeight, - scrollHeight: scrollParent.scrollHeight - } : null, - documentScrollingElement: { - clientHeight: document.documentElement.clientHeight, - scrollHeight: document.documentElement.scrollHeight - }, - window: { innerHeight: window.innerHeight, scrollY: window.scrollY }, - tableWrapper: tableWrapper ? { - tag: tableWrapper.tagName, - className: String(tableWrapper.className).slice(0, 120), - overflowX: getComputedStyle(tableWrapper).overflowX, - overflowY: getComputedStyle(tableWrapper).overflowY, - clientHeight: tableWrapper.clientHeight, - scrollHeight: tableWrapper.scrollHeight - } : null, - theadPosition: thead ? getComputedStyle(thead).position : null, - hasStickyActionTd: !!document.querySelector("tbody tr td[class*='sticky']"), - pageBody: pageBodyEl ? { - className: String(pageBodyEl.className).slice(0, 120), - overflowY: getComputedStyle(pageBodyEl).overflowY, - clientHeight: pageBodyEl.clientHeight, - scrollHeight: pageBodyEl.scrollHeight - } : null - }; - })() - `); - - console.log("\n=== 1. Scroll container ===\n"); - console.log(JSON.stringify(domReport.scrollParent, null, 2)); - console.log("\nDocument scrollingElement:", domReport.documentScrollingElement); - console.log("\nTable wrapper:", domReport.tableWrapper); - console.log("\nPageBody-like ancestor:", domReport.pageBody); - console.log("\nScroll candidates (overflow content):", domReport.scrollCandidates); - - console.log("\n=== 2. Row heights (first 200 rows) ===\n"); - console.log(`Total tbody data rows: ${domReport.rowCount}`); - console.log( - `min=${domReport.rowHeight.min.toFixed(1)}px max=${domReport.rowHeight.max.toFixed(1)}px avg=${domReport.rowHeight.avg.toFixed(1)}px` - ); - - console.log("\n=== 3. Header / sticky ===\n"); - console.log(`thead position: ${domReport.theadPosition}`); - console.log(`sticky action td in tbody: ${domReport.hasStickyActionTd}`); - - // Scroll test: can window scroll? - const beforeScroll = await page.evaluate(() => window.scrollY); - await page.keyboard.press("PageDown"); - await page.waitForTimeout(300); - const afterScroll = await page.evaluate(() => ({ - scrollY: window.scrollY, - docScroll: document.scrollingElement?.scrollTop ?? 0, - })); - console.log("\n=== Scroll behavior (PageDown) ===\n"); - console.log(`window.scrollY: ${beforeScroll} -> ${afterScroll.scrollY}`); - console.log(`document scrollTop: ${afterScroll.docScroll}`); - - await browser.close(); - await prisma.$disconnect(); -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/apps/webapp/test/environmentVariablesEnvironments.test.ts b/apps/webapp/test/environmentVariablesEnvironments.test.ts new file mode 100644 index 00000000000..2555bcae0f7 --- /dev/null +++ b/apps/webapp/test/environmentVariablesEnvironments.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, vi } from "vitest"; + +vi.mock("~/db.server", () => ({ + prisma: {}, + $replica: {}, + $transaction: async ( + prismaClient: { + $transaction: (fn: (tx: unknown) => Promise) => Promise; + }, + nameOrFn: string | ((tx: unknown) => Promise), + fnOrOptions?: ((tx: unknown) => Promise) | unknown + ) => { + const fn = + typeof nameOrFn === "string" + ? (fnOrOptions as (tx: unknown) => Promise) + : nameOrFn; + + return prismaClient.$transaction(fn); + }, +})); + +import { postgresTest } from "@internal/testcontainers"; +import { loadEnvironmentVariablesEnvironments } from "~/presenters/v3/environmentVariablesEnvironments.server"; +import { + createRuntimeEnvironment, + createTestOrgProjectWithMember, + createTestUser, +} from "./fixtures/environmentVariablesFixtures"; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("loadEnvironmentVariablesEnvironments", () => { + postgresTest("returns environments for a project member", async ({ prisma }) => { + const { user, organization, project } = await createTestOrgProjectWithMember(prisma); + + const production = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + }); + + const result = await loadEnvironmentVariablesEnvironments(prisma, { + userId: user.id, + projectId: project.id, + }); + + expect(result.environments.map((environment) => environment.id)).toContain(production.id); + expect(result.environments.every((environment) => typeof environment.id === "string")).toBe(true); + }); + + postgresTest("rejects users who are not project members", async ({ prisma }) => { + const { project } = await createTestOrgProjectWithMember(prisma); + const outsider = await createTestUser(prisma); + + await expect( + loadEnvironmentVariablesEnvironments(prisma, { + userId: outsider.id, + projectId: project.id, + }) + ).rejects.toThrow("Project not found"); + }); + + postgresTest("filters shared, personal, and inaccessible environments", async ({ prisma }) => { + const { user, organization, project, orgMember } = await createTestOrgProjectWithMember(prisma); + const otherUser = await createTestUser(prisma); + const otherOrgMember = await prisma.orgMember.create({ + data: { + organizationId: organization.id, + userId: otherUser.id, + role: "MEMBER", + }, + }); + + const sharedProduction = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + }); + const currentUserDev = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "DEVELOPMENT", + orgMemberId: orgMember.id, + }); + const otherUserDev = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "DEVELOPMENT", + orgMemberId: otherOrgMember.id, + }); + const orphanedDev = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "DEVELOPMENT", + orgMemberId: null, + }); + + const result = await loadEnvironmentVariablesEnvironments(prisma, { + userId: user.id, + projectId: project.id, + }); + + const environmentIds = result.environments.map((environment) => environment.id); + + expect(environmentIds).toContain(sharedProduction.id); + expect(environmentIds).toContain(currentUserDev.id); + expect(environmentIds).not.toContain(otherUserDev.id); + expect(environmentIds).not.toContain(orphanedDev.id); + }); + + postgresTest("returns hasStaging true when a staging environment exists", async ({ prisma }) => { + const { user, organization, project } = await createTestOrgProjectWithMember(prisma); + + await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "STAGING", + }); + + const result = await loadEnvironmentVariablesEnvironments(prisma, { + userId: user.id, + projectId: project.id, + }); + + expect(result.hasStaging).toBe(true); + }); + + postgresTest("returns hasStaging false when no staging environment exists", async ({ prisma }) => { + const { user, organization, project } = await createTestOrgProjectWithMember(prisma); + + await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + }); + + const result = await loadEnvironmentVariablesEnvironments(prisma, { + userId: user.id, + projectId: project.id, + }); + + expect(result.hasStaging).toBe(false); + }); +}); diff --git a/packages/core/src/v3/otel/tracingSDK.ts b/packages/core/src/v3/otel/tracingSDK.ts index 435921d50f6..f03bd2fc3a6 100644 --- a/packages/core/src/v3/otel/tracingSDK.ts +++ b/packages/core/src/v3/otel/tracingSDK.ts @@ -523,7 +523,13 @@ class ExternalLogRecordExporterWrapper { } forceFlush(): Promise { - return this.underlyingExporter.forceFlush?.() ?? Promise.resolve(); + const underlyingExporter = this.underlyingExporter as LogRecordExporter & { + forceFlush?: () => Promise; + }; + + return underlyingExporter.forceFlush + ? underlyingExporter.forceFlush() + : Promise.resolve(); } transformLogRecord( From f118832af50157f47e60ad3a9728f45c75c81b16 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 4 Jun 2026 16:05:31 +0200 Subject: [PATCH 4/4] chore(core): add changeset for forceFlush typecheck build fix --- .changeset/env-vars-tracing-forceflush-typecheck.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/env-vars-tracing-forceflush-typecheck.md diff --git a/.changeset/env-vars-tracing-forceflush-typecheck.md b/.changeset/env-vars-tracing-forceflush-typecheck.md new file mode 100644 index 00000000000..9d90c0383d7 --- /dev/null +++ b/.changeset/env-vars-tracing-forceflush-typecheck.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +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`).