Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cursor/rules/webapp.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ globs: apps/webapp/**/*.tsx,apps/webapp/**/*.ts
alwaysApply: false
---

The main trigger.dev webapp, which powers it's API and dashboard and makes up the docker image that is produced as an OSS image, is a Remix 2.1.0 app that uses an express server, written in TypeScript. The following subsystems are either included in the webapp or are used by the webapp in another part of the monorepo:
The main trigger.dev webapp, which powers it's API and dashboard and makes up the docker image that is produced as an OSS image, is a Remix 2.17.4 app that uses an express server, written in TypeScript. The following subsystems are either included in the webapp or are used by the webapp in another part of the monorepo:

- `@trigger.dev/database` exports a Prisma 6.14.0 client that is used extensively in the webapp to access a PostgreSQL instance. The schema file is [schema.prisma](mdc:internal-packages/database/prisma/schema.prisma)
- `@trigger.dev/core` is a published package and is used to share code between the `@trigger.dev/sdk` and the webapp. It includes functionality but also a load of Zod schemas for data validation. When importing from `@trigger.dev/core` in the webapp, we never import the root `@trigger.dev/core` path, instead we favor one of the subpath exports that you can find in [package.json](mdc:packages/core/package.json)
Expand Down
6 changes: 6 additions & 0 deletions .server-changes/upgrade-remix-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Upgrade Remix packages from 2.1.0 to 2.17.4 to address security vulnerabilities in React Router
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ User API call -> Webapp routes -> Services -> RunEngine -> Redis Queue -> Superv

### Apps

- **apps/webapp**: Remix 2.1.0 app - main API, dashboard, orchestration. Uses Express server.
- **apps/webapp**: Remix 2.17.4 app - main API, dashboard, orchestration. Uses Express server.
- **apps/supervisor**: Manages task execution containers (Docker/Kubernetes).

### Public Packages
Expand Down
2 changes: 1 addition & 1 deletion apps/webapp/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Webapp

Remix 2.1.0 app serving as the main API, dashboard, and orchestration engine. Uses an Express server (`server.ts`).
Remix 2.17.4 app serving as the main API, dashboard, and orchestration engine. Uses an Express server (`server.ts`).

## Verifying Changes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type GenerationRow, PromptPresenter } from "~/presenters/v3/PromptPresenter.server";
import { SpanView } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { getResizableSnapshot } from "~/services/resizablePanel.server";
import { requireUserId } from "~/services/session.server";
import { PromptService } from "~/v3/services/promptService.server";

Expand Down Expand Up @@ -271,7 +270,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
console.error("Prompt generations query exception:", e);
}

// Load distinct filter values and resizable snapshots in parallel
// Load distinct filter values in parallel
const distinctQuery = (col: string, name: string) =>
clickhouseClient.reader.query({
name,
Expand All @@ -281,16 +280,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
})({ environmentId: environment.id, promptSlug: prompt.slug });

const [
resizableOuter,
resizableVertical,
resizableGenerations,
[modelsErr, modelsRows],
[opsErr, opsRows],
[provsErr, provsRows],
] = await Promise.all([
getResizableSnapshot(request, "prompt-detail"),
getResizableSnapshot(request, "prompt-vertical"),
getResizableSnapshot(request, "prompt-generations"),
distinctQuery("response_model", "promptDistinctModels"),
distinctQuery("operation_id", "promptDistinctOperations"),
distinctQuery("gen_ai_system", "promptDistinctProviders"),
Expand All @@ -302,9 +295,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {

return typedjson({
resizable: {
outer: resizableOuter,
vertical: resizableVertical,
generations: resizableGenerations,
outer: undefined as ResizableSnapshot | undefined,
vertical: undefined as ResizableSnapshot | undefined,
generations: undefined as ResizableSnapshot | undefined,
Comment on lines +298 to +300
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Prompts route missing clientLoader — resizable panel snapshots are always undefined

The PR removes the server-side getResizableSnapshot calls from the prompts route and replaces them with undefined, but unlike the runs route (route.tsx:317-327), no clientLoader was added to restore the values from localStorage. This means resizable.outer, resizable.vertical, and resizable.generations will always be undefined on the prompts page, so users' saved panel sizes will never be restored on page load — a regression from the previous behavior where they were read from cookies.

Prompt for agents
The prompts route needs the same clientLoader + localStorage migration that was applied to the runs route. Specifically:

1. Import ClientLoaderFunctionArgs from @remix-run/react (around line 4).
2. Add a getLocalStorageSnapshot helper function (same as the one in the runs route at lines 302-315).
3. Add an exported clientLoader that calls serverLoader, then overrides the resizable field with localStorage lookups for the three keys: "prompt-detail", "prompt-vertical", and "prompt-generations" (these match the autosaveId values used in the ResizablePanelGroup components at lines 582, 589, and 1432).
4. Set clientLoader.hydrate = true as const.

See the runs route file apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx lines 302-327 for the exact pattern to follow.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional, not a regression. When the snapshot prop is undefined, the react-window-splitter library reads from localStorage directly — see the library source at PanelGroupImpl lines 300-313:

if (typeof window !== "undefined" && autosaveId && !snapshot && autosaveStrategy === "localStorage") {
  const localSnapshot = localStorage.getItem(autosaveId);
  if (localSnapshot) { setSnapshot(JSON.parse(localSnapshot)); }
}

Since autosaveStrategy defaults to "localStorage" and the component already has autosaveId set, returning undefined from the server lets the library's built-in localStorage persistence handle everything on the client.

A clientLoader was not used here because this route uses typedjson/useTypedLoaderData from remix-typedjson, which has its own serialization layer that doesn't compose cleanly with clientLoader (which routes data through useLoaderData).

},
prompt: {
id: prompt.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
StopCircleIcon,
} from "@heroicons/react/20/solid";

import { useLoaderData, useRevalidator } from "@remix-run/react";
import { type ClientLoaderFunctionArgs, useLoaderData, useRevalidator } from "@remix-run/react";
import { type LoaderFunctionArgs, type SerializeFrom, json } from "@remix-run/server-runtime";
import { type Virtualizer } from "@tanstack/react-virtual";
import {
Expand Down Expand Up @@ -95,7 +95,6 @@ import { RunEnvironmentMismatchError, RunPresenter } from "~/presenters/v3/RunPr
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { getImpersonationId } from "~/services/impersonation.server";
import { logger } from "~/services/logger.server";
import { getResizableSnapshot } from "~/services/resizablePanel.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { lerp } from "~/utils/lerp";
Expand Down Expand Up @@ -279,10 +278,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw error;
}

//resizable settings
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId);

const runsList = await getRunsListFromTableState({
tableStateParam: url.searchParams.get("tableState"),
organizationSlug,
Expand All @@ -297,13 +292,40 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
trace: result.trace,
maximumLiveReloadingSetting: result.maximumLiveReloadingSetting,
resizable: {
parent,
tree,
parent: undefined as ResizableSnapshot | undefined,
tree: undefined as ResizableSnapshot | undefined,
},
runsList,
});
};

function getLocalStorageSnapshot(key: string): ResizableSnapshot | undefined {
try {
const raw = localStorage.getItem(key);
if (raw) {
const parsed: unknown = JSON.parse(raw);
if (parsed != null && typeof parsed === "object" && "status" in parsed) {
return parsed as ResizableSnapshot;
}
}
} catch {
// Silently ignore localStorage errors
}
return undefined;
}

export async function clientLoader({ serverLoader }: ClientLoaderFunctionArgs) {
const serverData = await serverLoader<typeof loader>();
return {
...serverData,
resizable: {
parent: getLocalStorageSnapshot(resizableSettings.parent.autosaveId),
tree: getLocalStorageSnapshot(resizableSettings.tree.autosaveId),
},
};
}
clientLoader.hydrate = true as const;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 clientLoader.hydrate = true changes initial render behavior

With clientLoader.hydrate = true as const at route.tsx:327, Remix will not render the page until the clientLoader has run, even on initial page load. This means the server-rendered HTML will show a fallback/loading state until the client loader completes (which includes the full serverLoader() fetch). This is a deliberate trade-off: the resizable panels will always have their correct snapshot on first render, but the initial page load may feel slightly slower compared to the previous SSR approach where the cookie-based snapshot was available during server rendering. This is likely acceptable for a dashboard app but worth being aware of.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good observation. One clarification: with clientLoader.hydrate = true, Remix still SSRs the page using the server loader data. On the client, it hydrates with that server data first, then runs clientLoader and re-renders with the localStorage snapshots. So the initial page load isn't blocked — the user sees the server-rendered page immediately, then panels adjust to saved sizes once clientLoader resolves (which is near-instant since localStorage.getItem is synchronous and serverLoader() reuses the already-fetched server data during hydration).

The net effect is: panels flash briefly with default sizes → saved sizes on first SSR load, but on subsequent client-side navigations the saved sizes are available immediately.


type LoaderData = SerializeFrom<typeof loader>;

export default function Page() {
Expand Down
18 changes: 9 additions & 9 deletions apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,12 @@
"@react-aria/datepicker": "^3.9.1",
"@react-stately/datepicker": "^3.9.1",
"@react-types/datepicker": "^3.7.1",
"@remix-run/express": "2.1.0",
"@remix-run/node": "2.1.0",
"@remix-run/react": "2.1.0",
"@remix-run/router": "^1.15.3",
"@remix-run/serve": "2.1.0",
"@remix-run/server-runtime": "2.1.0",
"@remix-run/express": "2.17.4",
"@remix-run/node": "2.17.4",
"@remix-run/react": "2.17.4",
"@remix-run/router": "^1.23.2",
"@remix-run/serve": "2.17.4",
"@remix-run/server-runtime": "2.17.4",
"@remix-run/v1-meta": "^0.1.3",
"@s2-dev/streamstore": "^0.22.5",
"@sentry/remix": "9.46.0",
Expand Down Expand Up @@ -237,9 +237,9 @@
"@internal/clickhouse": "workspace:*",
"@internal/replication": "workspace:*",
"@internal/testcontainers": "workspace:*",
"@remix-run/dev": "2.1.0",
"@remix-run/eslint-config": "2.1.0",
"@remix-run/testing": "^2.1.0",
"@remix-run/dev": "2.17.4",
"@remix-run/eslint-config": "2.17.4",
"@remix-run/testing": "^2.17.4",
"@sentry/cli": "2.50.2",
"@swc/core": "^1.3.4",
"@swc/helpers": "^0.4.11",
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@
"typescript": "5.5.4",
"@types/node": "20.14.14",
"express@^4>body-parser": "1.20.3",
"@remix-run/dev@2.1.0>tar-fs": "2.1.3",
"testcontainers@10.28.0>tar-fs": "3.0.9",
"@remix-run/dev@2.17.4>tar-fs": "2.1.4",
"testcontainers@10.28.0>tar-fs": "3.1.1",
"form-data@^2": "2.5.4",
"form-data@^3": "3.0.4",
"form-data@^4": "4.0.4",
Expand Down Expand Up @@ -120,4 +120,4 @@
"turbo"
]
}
}
}
Loading
Loading