Skip to content

Commit 9f07663

Browse files
committed
#346 Improve run dashboard perf when tasks have large outputs
Performance degradation came from the syntax highlighting of large code blocks and from doing that on the server and the client, so fixed this in a couple of ways: 1. Stream the task details data using defer and Suspense/Await 2. Skipped syntax highlighting code blocks with more than 1k lines
1 parent 8fd68e3 commit 9f07663

File tree

7 files changed

+199
-133
lines changed

7 files changed

+199
-133
lines changed

apps/webapp/app/components/code/CodeBlock.tsx

Lines changed: 103 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1+
import { Clipboard, ClipboardCheck } from "lucide-react";
12
import type { Language, PrismTheme } from "prism-react-renderer";
23
import Highlight, { defaultProps } from "prism-react-renderer";
34
import { forwardRef, useCallback, useState } from "react";
45
import { cn } from "~/utils/cn";
56
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
6-
import { ClipboardDocumentCheckIcon, ClipboardIcon } from "@heroicons/react/24/solid";
7-
import { Clipboard, ClipboardCheck, ClipboardCheckIcon } from "lucide-react";
87

98
//This is a fork of https://github.com/mantinedev/mantine/blob/master/src/mantine-prism/src/Prism/Prism.tsx
109
//it didn't support highlighting lines by dimming the rest of the code, or animations on the highlighting
@@ -192,6 +191,9 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
192191
Array.from({ length: end - start + 1 }, (_, i) => start + i)
193192
);
194193

194+
// if there are more than 1000 lines, don't highlight
195+
const shouldHighlight = lineCount <= 1000;
196+
195197
return (
196198
<div
197199
className={cn("relative overflow-hidden rounded-md border border-slate-800", className)}
@@ -229,99 +231,113 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
229231
</TooltipProvider>
230232
)}
231233

232-
<Highlight {...defaultProps} theme={theme} code={code} language={language}>
233-
{({
234-
className: inheritedClassName,
235-
style: inheritedStyle,
236-
tokens,
237-
getLineProps,
238-
getTokenProps,
239-
}) => (
240-
<div
241-
dir="ltr"
242-
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
243-
style={{
244-
maxHeight,
245-
}}
246-
>
247-
<pre
248-
className={cn(
249-
"relative mr-2 font-mono text-xs leading-relaxed",
250-
inheritedClassName
251-
)}
252-
style={inheritedStyle}
234+
{shouldHighlight ? (
235+
<Highlight {...defaultProps} theme={theme} code={code} language={language}>
236+
{({
237+
className: inheritedClassName,
238+
style: inheritedStyle,
239+
tokens,
240+
getLineProps,
241+
getTokenProps,
242+
}) => (
243+
<div
253244
dir="ltr"
245+
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
246+
style={{
247+
maxHeight,
248+
}}
254249
>
255-
{tokens
256-
.map((line, index) => {
257-
if (
258-
index === tokens.length - 1 &&
259-
line.length === 1 &&
260-
line[0].content === "\n"
261-
) {
262-
return null;
263-
}
250+
<pre
251+
className={cn(
252+
"relative mr-2 font-mono text-xs leading-relaxed",
253+
inheritedClassName
254+
)}
255+
style={inheritedStyle}
256+
dir="ltr"
257+
>
258+
{tokens
259+
.map((line, index) => {
260+
if (
261+
index === tokens.length - 1 &&
262+
line.length === 1 &&
263+
line[0].content === "\n"
264+
) {
265+
return null;
266+
}
264267

265-
const lineNumber = index + 1;
266-
const lineProps = getLineProps({ line, key: index });
268+
const lineNumber = index + 1;
269+
const lineProps = getLineProps({ line, key: index });
267270

268-
let hasAnyHighlights = highlightLines ? highlightLines.length > 0 : false;
271+
let hasAnyHighlights = highlightLines ? highlightLines.length > 0 : false;
269272

270-
let shouldDim = hasAnyHighlights;
271-
if (hasAnyHighlights && highlightLines?.includes(lineNumber)) {
272-
shouldDim = false;
273-
}
273+
let shouldDim = hasAnyHighlights;
274+
if (hasAnyHighlights && highlightLines?.includes(lineNumber)) {
275+
shouldDim = false;
276+
}
274277

275-
return (
276-
<div
277-
key={lineProps.key}
278-
{...lineProps}
279-
className={cn(
280-
"flex w-full justify-start transition-opacity duration-500",
281-
lineProps.className
282-
)}
283-
style={{
284-
opacity: shouldDim ? dimAmount : undefined,
285-
...lineProps.style,
286-
}}
287-
>
288-
{showLineNumbers && (
289-
<div
290-
className={
291-
"mr-2 flex-none select-none text-right text-slate-500 transition-opacity duration-500"
292-
}
293-
style={{
294-
width: `calc(8 * ${maxLineWidth / 16}rem)`,
295-
}}
296-
>
297-
{lineNumber}
298-
</div>
299-
)}
278+
return (
279+
<div
280+
key={lineProps.key}
281+
{...lineProps}
282+
className={cn(
283+
"flex w-full justify-start transition-opacity duration-500",
284+
lineProps.className
285+
)}
286+
style={{
287+
opacity: shouldDim ? dimAmount : undefined,
288+
...lineProps.style,
289+
}}
290+
>
291+
{showLineNumbers && (
292+
<div
293+
className={
294+
"mr-2 flex-none select-none text-right text-slate-500 transition-opacity duration-500"
295+
}
296+
style={{
297+
width: `calc(8 * ${maxLineWidth / 16}rem)`,
298+
}}
299+
>
300+
{lineNumber}
301+
</div>
302+
)}
300303

301-
<div className="flex-1">
302-
{line.map((token, key) => {
303-
const tokenProps = getTokenProps({ token, key });
304-
return (
305-
<span
306-
key={tokenProps.key}
307-
{...tokenProps}
308-
style={{
309-
color: tokenProps?.style?.color as string,
310-
...tokenProps.style,
311-
}}
312-
/>
313-
);
314-
})}
304+
<div className="flex-1">
305+
{line.map((token, key) => {
306+
const tokenProps = getTokenProps({ token, key });
307+
return (
308+
<span
309+
key={tokenProps.key}
310+
{...tokenProps}
311+
style={{
312+
color: tokenProps?.style?.color as string,
313+
...tokenProps.style,
314+
}}
315+
/>
316+
);
317+
})}
318+
</div>
319+
<div className="w-4 flex-none" />
315320
</div>
316-
<div className="w-4 flex-none" />
317-
</div>
318-
);
319-
})
320-
.filter(Boolean)}
321-
</pre>
322-
</div>
323-
)}
324-
</Highlight>
321+
);
322+
})
323+
.filter(Boolean)}
324+
</pre>
325+
</div>
326+
)}
327+
</Highlight>
328+
) : (
329+
<div
330+
dir="ltr"
331+
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
332+
style={{
333+
maxHeight,
334+
}}
335+
>
336+
<pre className="relative mr-2 p-2 font-mono text-xs leading-relaxed" dir="ltr">
337+
{code}
338+
</pre>
339+
</div>
340+
)}
325341
</div>
326342
);
327343
}

apps/webapp/app/components/run/RunCompletedDetail.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export function RunCompletedDetail({ run }: { run: MatchedRun }) {
5454
<RunPanelDivider />
5555
{run.error && <RunPanelError text={run.error.message} stackTrace={run.error.stack} />}
5656
{run.output ? (
57-
<CodeBlock language="json" code={run.output} />
57+
<CodeBlock language="json" code={run.output} maxLines={8} />
5858
) : (
5959
run.output === null && <Paragraph variant="small">This run returned nothing</Paragraph>
6060
)}

apps/webapp/app/components/run/TaskDetail.tsx

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { DetailedTask } from "~/presenters/TaskDetailsPresenter.server";
21
import {
32
RunPanel,
43
RunPanelBody,
@@ -29,22 +28,16 @@ import {
2928
} from "../primitives/Table";
3029
import { TaskAttemptStatusLabel } from "./TaskAttemptStatus";
3130
import { TaskStatusIcon } from "./TaskStatus";
31+
import { ClientOnly } from "remix-utils";
32+
import { Spinner } from "../primitives/Spinner";
33+
import type { DetailedTask } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route";
3234

3335
export function TaskDetail({ task }: { task: DetailedTask }) {
34-
const {
35-
name,
36-
description,
37-
icon,
38-
startedAt,
39-
completedAt,
40-
status,
41-
delayUntil,
42-
params,
43-
properties,
44-
output,
45-
style,
46-
attempts,
47-
} = task;
36+
const { name, description, icon, status, params, properties, output, style, attempts } = task;
37+
38+
const startedAt = task.startedAt ? new Date(task.startedAt) : undefined;
39+
const completedAt = task.completedAt ? new Date(task.completedAt) : undefined;
40+
const delayUntil = task.delayUntil ? new Date(task.delayUntil) : undefined;
4841

4942
return (
5043
<RunPanel selected={false}>
@@ -150,7 +143,9 @@ export function TaskDetail({ task }: { task: DetailedTask }) {
150143
<div className="mt-4 flex flex-col gap-2">
151144
<Header3>Output</Header3>
152145
{output ? (
153-
<CodeBlock code={JSON.stringify(output, null, 2)} />
146+
<ClientOnly fallback={<Spinner />}>
147+
{() => <CodeBlock code={output} maxLines={35} />}
148+
</ClientOnly>
154149
) : (
155150
<Paragraph variant="small">No output</Paragraph>
156151
)}

apps/webapp/app/presenters/TaskDetailsPresenter.server.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DisplayPropertiesSchema, StyleSchema } from "@trigger.dev/core";
1+
import { StyleSchema } from "@trigger.dev/core";
22
import { PrismaClient, prisma } from "~/db.server";
33
import { mergeProperties } from "~/utils/mergeProperties.server";
44

@@ -7,8 +7,6 @@ type DetailsProps = {
77
userId: string;
88
};
99

10-
export type DetailedTask = NonNullable<Awaited<ReturnType<TaskDetailsPresenter["call"]>>>;
11-
1210
export class TaskDetailsPresenter {
1311
#prismaClient: PrismaClient;
1412

@@ -87,6 +85,7 @@ export class TaskDetailsPresenter {
8785

8886
return {
8987
...task,
88+
output: task.output ? JSON.stringify(task.output, null, 2) : undefined,
9089
connection: task.runConnection,
9190
params: task.params as Record<string, any>,
9291
properties: mergeProperties(task.properties, task.outputProperties),
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { LoaderArgs } from "@remix-run/server-runtime";
2-
import { typedjson, useTypedLoaderData } from "remix-typedjson";
1+
import { Await, useLoaderData } from "@remix-run/react";
2+
import { LoaderArgs, SerializeFrom, defer } from "@remix-run/server-runtime";
3+
import { Suspense } from "react";
4+
import { Spinner } from "~/components/primitives/Spinner";
35
import { TaskDetail } from "~/components/run/TaskDetail";
46
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
57
import { requireUserId } from "~/services/session.server";
@@ -10,23 +12,26 @@ export const loader = async ({ request, params }: LoaderArgs) => {
1012
const { taskParam } = TaskParamsSchema.parse(params);
1113

1214
const presenter = new TaskDetailsPresenter();
13-
const task = await presenter.call({
15+
const taskPromise = presenter.call({
1416
userId,
1517
id: taskParam,
1618
});
1719

18-
if (!task) {
19-
throw new Response(null, {
20-
status: 404,
21-
});
22-
}
23-
24-
return typedjson({
25-
task,
20+
return defer({
21+
taskPromise,
2622
});
2723
};
2824

25+
export type DetailedTask = NonNullable<Awaited<SerializeFrom<typeof loader>["taskPromise"]>>;
26+
2927
export default function Page() {
30-
const { task } = useTypedLoaderData<typeof loader>();
31-
return <TaskDetail task={task} />;
28+
const { taskPromise } = useLoaderData<typeof loader>();
29+
30+
return (
31+
<Suspense fallback={<Spinner />}>
32+
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
33+
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
34+
</Await>
35+
</Suspense>
36+
);
3237
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { LoaderArgs } from "@remix-run/server-runtime";
2-
import { typedjson, useTypedLoaderData } from "remix-typedjson";
1+
import { Await, useLoaderData } from "@remix-run/react";
2+
import { LoaderArgs, defer } from "@remix-run/server-runtime";
3+
import { Suspense } from "react";
4+
import { Spinner } from "~/components/primitives/Spinner";
35
import { TaskDetail } from "~/components/run/TaskDetail";
46
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
57
import { requireUserId } from "~/services/session.server";
@@ -10,23 +12,24 @@ export const loader = async ({ request, params }: LoaderArgs) => {
1012
const { taskParam } = TriggerSourceRunTaskParamsSchema.parse(params);
1113

1214
const presenter = new TaskDetailsPresenter();
13-
const task = await presenter.call({
15+
const taskPromise = presenter.call({
1416
userId,
1517
id: taskParam,
1618
});
1719

18-
if (!task) {
19-
throw new Response(null, {
20-
status: 404,
21-
});
22-
}
23-
24-
return typedjson({
25-
task,
20+
return defer({
21+
taskPromise,
2622
});
2723
};
2824

2925
export default function Page() {
30-
const { task } = useTypedLoaderData<typeof loader>();
31-
return <TaskDetail task={task} />;
26+
const { taskPromise } = useLoaderData<typeof loader>();
27+
28+
return (
29+
<Suspense fallback={<Spinner />}>
30+
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
31+
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
32+
</Await>
33+
</Suspense>
34+
);
3235
}

0 commit comments

Comments
 (0)