perf(webapp): stabilize nested component identities (#4689)
## Summary Keep component and renderer identities stable across dashboard renders. Inline icon components, chart renderers, table cells, and select render callbacks now use module-level implementations. Oxlint enforces the pattern across the dashboard. Base: [#4688](https://github.com/triggerdotdev/trigger.dev/pull/4688)
This commit is contained in:
@@ -241,6 +241,7 @@ const DebouncedInput = forwardRef<
|
||||
interface ColumnMeta {
|
||||
outputColumn: OutputColumnMetadata;
|
||||
alignment: "left" | "right";
|
||||
prettyFormatting: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,6 +490,19 @@ function CellValueWrapper({
|
||||
/**
|
||||
* Render a cell value based on its type and optional customRenderType
|
||||
*/
|
||||
function TSQLResultsCell(info: CellContext<RowData, unknown>) {
|
||||
const meta = info.column.columnDef.meta as ColumnMeta;
|
||||
|
||||
return (
|
||||
<CellValueWrapper
|
||||
value={info.getValue()}
|
||||
column={meta.outputColumn}
|
||||
prettyFormatting={meta.prettyFormatting}
|
||||
row={info.row.original}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CellValue({
|
||||
value,
|
||||
column,
|
||||
@@ -1053,17 +1067,11 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
id: col.name,
|
||||
accessorKey: col.name,
|
||||
header: () => col.name,
|
||||
cell: (info: CellContext<RowData, unknown>) => (
|
||||
<CellValueWrapper
|
||||
value={info.getValue()}
|
||||
column={col}
|
||||
prettyFormatting={prettyFormatting}
|
||||
row={info.row.original}
|
||||
/>
|
||||
),
|
||||
cell: TSQLResultsCell,
|
||||
meta: {
|
||||
outputColumn: col,
|
||||
alignment: isRightAlignedColumn(col) ? "right" : "left",
|
||||
prettyFormatting,
|
||||
} as ColumnMeta,
|
||||
size: calculateColumnWidth(col.name, rows, col),
|
||||
filterFn: fuzzyFilter,
|
||||
|
||||
@@ -42,6 +42,19 @@ export const ErrorAlertsFormSchema = z.object({
|
||||
}, z.string().url().array()),
|
||||
});
|
||||
|
||||
type SlackChannel = { id?: string; name?: string; is_private?: boolean };
|
||||
|
||||
function renderSlackChannel(channels: SlackChannel[], value: string) {
|
||||
const channel = channels.find((channel) => value === `${channel.id}/${channel.name}`);
|
||||
if (!channel) return;
|
||||
|
||||
return (
|
||||
<span className="text-text-bright">
|
||||
<SlackChannelTitle {...channel} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type ConfigureErrorAlertsProps = ErrorAlertChannelData & {
|
||||
connectToSlackHref?: string;
|
||||
formAction: string;
|
||||
@@ -196,15 +209,7 @@ export function ConfigureErrorAlerts({
|
||||
filter={(channel, search) =>
|
||||
channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false
|
||||
}
|
||||
text={(value) => {
|
||||
const channel = slack.channels.find((s) => value === `${s.id}/${s.name}`);
|
||||
if (!channel) return;
|
||||
return (
|
||||
<span className="text-text-bright">
|
||||
<SlackChannelTitle {...channel} />
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
text={(value) => renderSlackChannel(slack.channels, value)}
|
||||
>
|
||||
{(matches) => (
|
||||
<>
|
||||
|
||||
@@ -578,7 +578,8 @@ export function ChartLineRenderer({
|
||||
// own dot on top where it's active.
|
||||
activeDot={
|
||||
gradientLine
|
||||
? (props: ActiveDotProps) => (
|
||||
? // oxlint-disable-next-line react/no-unstable-nested-components -- Recharts invokes this renderer with hover coordinates; an element would rely on cloneElement prop injection.
|
||||
(props: ActiveDotProps) => (
|
||||
<ThresholdActiveDot
|
||||
{...props}
|
||||
dataKey={key}
|
||||
|
||||
@@ -124,6 +124,22 @@ function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps)
|
||||
const startingJson = "{\n\n}";
|
||||
const machinePresets = Object.values(MachinePresetName.enum);
|
||||
|
||||
type ReplayEnvironment = UseDataFunctionReturn<typeof loader>["environments"][number];
|
||||
|
||||
function renderReplayEnvironment(
|
||||
environments: ReplayEnvironment[],
|
||||
value: string
|
||||
): React.ReactNode {
|
||||
const environment = environments.find((environment) => environment.id === value);
|
||||
if (!environment) return;
|
||||
|
||||
return (
|
||||
<div className="flex items-center pl-1 pr-2">
|
||||
<EnvironmentCombo environment={environment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplayForm({
|
||||
failedRedirect,
|
||||
runFriendlyId,
|
||||
@@ -572,14 +588,7 @@ function ReplayForm({
|
||||
(item) => item.branchName?.replace(/\//g, " ").replace(/_/g, " ") ?? "",
|
||||
],
|
||||
}}
|
||||
text={(value) => {
|
||||
const env = replayData.environments.find((env) => env.id === value)!;
|
||||
return (
|
||||
<div className="flex items-center pl-1 pr-2">
|
||||
<EnvironmentCombo environment={env} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
text={(value) => renderReplayEnvironment(replayData.environments, value)}
|
||||
>
|
||||
{(matches) =>
|
||||
matches.map((env) => (
|
||||
|
||||
+9
-5
@@ -48,6 +48,14 @@ import {
|
||||
} from "~/v3/services/alerts/safeWebhookUrl.server";
|
||||
import { pageMeta } from "~/utils/pageTitle";
|
||||
|
||||
type SlackChannel = { id?: string; name?: string; is_private?: boolean };
|
||||
|
||||
function renderSlackChannel(channels: SlackChannel[], value: string | string[]) {
|
||||
if (typeof value !== "string") return;
|
||||
const channel = channels.find((channel) => value === `${channel.id}/${channel.name}`);
|
||||
return channel ? <SlackChannelTitle {...channel} /> : undefined;
|
||||
}
|
||||
|
||||
export const meta = pageMeta("New alert");
|
||||
|
||||
const FormSchema = z
|
||||
@@ -342,11 +350,7 @@ export default function Page() {
|
||||
filter={(channel, search) =>
|
||||
channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false
|
||||
}
|
||||
text={(value) => {
|
||||
const channel = slack.channels.find((s) => value === `${s.id}/${s.name}`);
|
||||
if (!channel) return;
|
||||
return <SlackChannelTitle {...channel} />;
|
||||
}}
|
||||
text={(value) => renderSlackChannel(slack.channels, value)}
|
||||
>
|
||||
{(matches) => (
|
||||
<>
|
||||
|
||||
+36
-21
@@ -4,7 +4,16 @@ import { Form, useFetcher, useRevalidator } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { type ErrorGroupStatus } from "@trigger.dev/database";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@@ -628,6 +637,17 @@ function ErrorGroupRow({
|
||||
);
|
||||
}
|
||||
|
||||
function renderErrorActionsPopoverContent(props: ComponentProps<typeof ErrorStatusMenuItems>) {
|
||||
return (
|
||||
<>
|
||||
<PopoverSectionHeader title="Mark error as…" />
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
<ErrorStatusMenuItems {...props} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorActionsCell({
|
||||
errorGroup,
|
||||
organizationSlug,
|
||||
@@ -664,26 +684,21 @@ function ErrorActionsCell({
|
||||
<>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={(close) => (
|
||||
<>
|
||||
<PopoverSectionHeader title="Mark error as…" />
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
<ErrorStatusMenuItems
|
||||
status={errorGroup.status}
|
||||
taskIdentifier={errorGroup.taskIdentifier}
|
||||
onAction={(data) => {
|
||||
close();
|
||||
pendingToast.current = statusActionToastMessage(data);
|
||||
fetcher.submit(data, { method: "post", action: actionUrl });
|
||||
}}
|
||||
onCustomIgnore={() => {
|
||||
close();
|
||||
setCustomIgnoreOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
popoverContent={(close) =>
|
||||
renderErrorActionsPopoverContent({
|
||||
status: errorGroup.status,
|
||||
taskIdentifier: errorGroup.taskIdentifier,
|
||||
onAction: (data) => {
|
||||
close();
|
||||
pendingToast.current = statusActionToastMessage(data);
|
||||
fetcher.submit(data, { method: "post", action: actionUrl });
|
||||
},
|
||||
onCustomIgnore: () => {
|
||||
close();
|
||||
setCustomIgnoreOpen(true);
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<CustomIgnoreDialog
|
||||
open={customIgnoreOpen}
|
||||
|
||||
@@ -76,6 +76,17 @@ function shuffleArray<T>(arr: T[]): T[] {
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
function renderMultiSelectValue(value: string[]) {
|
||||
if (value.length === 0) return;
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 items-center text-text-bright">
|
||||
<span className="truncate">{value.slice(0, 2).join(", ")}</span>
|
||||
{value.length > 2 && <span className="ml-1 flex-none">+{value.length - 2} more</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelectField({
|
||||
value,
|
||||
setValue,
|
||||
@@ -97,14 +108,7 @@ function MultiSelectField({
|
||||
icon={icon}
|
||||
items={items}
|
||||
className="h-8 min-w-0 border-0 bg-background-hover pl-2 text-sm text-text-dimmed ring-border-bright transition hover:bg-secondary hover:text-text-dimmed hover:ring-1"
|
||||
text={(v) =>
|
||||
v.length === 0 ? undefined : (
|
||||
<span className="flex min-w-0 items-center text-text-bright">
|
||||
<span className="truncate">{v.slice(0, 2).join(", ")}</span>
|
||||
{v.length > 2 && <span className="ml-1 flex-none">+{v.length - 2} more</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
text={renderMultiSelectValue}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
|
||||
@@ -70,6 +70,15 @@ function themeIcon(value: ThemePreference) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderTheme(value: ThemePreference) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{themeIcon(value)}
|
||||
{themeLabel(value)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta = pageMeta("Your profile");
|
||||
|
||||
function createSchema(
|
||||
@@ -320,12 +329,7 @@ export default function Page() {
|
||||
variant="secondary/small"
|
||||
dropdownIcon
|
||||
items={["classic", "system", "dark", "light"]}
|
||||
text={(value) => (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{themeIcon(value)}
|
||||
{themeLabel(value)}
|
||||
</span>
|
||||
)}
|
||||
text={renderTheme}
|
||||
className="w-44"
|
||||
>
|
||||
{(items) =>
|
||||
|
||||
@@ -207,6 +207,10 @@ const HandIcon = forwardRef<HTMLDivElement, {}>(({}, ref) => {
|
||||
});
|
||||
const MotionHand = motion(HandIcon);
|
||||
|
||||
function renderRole(value: string) {
|
||||
return value ? <span className="text-text-bright">{value}</span> : undefined;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const user = useUser();
|
||||
const lastSubmission = useActionData();
|
||||
@@ -390,7 +394,7 @@ export default function Page() {
|
||||
icon={<UserGroupIcon className="mr-1 size-4.5 text-text-dimmed" />}
|
||||
items={shuffledRoles}
|
||||
className="h-8 min-w-0 border-0 bg-background-hover pl-2 text-sm text-text-dimmed ring-border-bright transition hover:bg-secondary hover:text-text-dimmed hover:ring-1"
|
||||
text={(v) => (v ? <span className="text-text-bright">{v}</span> : undefined)}
|
||||
text={renderRole}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
|
||||
+10
-6
@@ -541,6 +541,14 @@ function VercelAppInstalledRow() {
|
||||
);
|
||||
}
|
||||
|
||||
function VercelLeadingIcon() {
|
||||
return <VercelLogo className="-mx-1 size-3.5 text-text-bright" />;
|
||||
}
|
||||
|
||||
function VercelLoadingIcon() {
|
||||
return <Spinner color="blue" className="size-4" />;
|
||||
}
|
||||
|
||||
function VercelSettingsRows({
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
@@ -577,7 +585,7 @@ function VercelSettingsRows({
|
||||
noPermissionTooltip={noPermissionTooltip}
|
||||
to={vercelAppInstallPath(organizationSlug, projectSlug)}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={() => <VercelLogo className="-mx-1 size-3.5 text-text-bright" />}
|
||||
LeadingIcon={VercelLeadingIcon}
|
||||
>
|
||||
Install Vercel app
|
||||
</PermissionLink>
|
||||
@@ -595,11 +603,7 @@ function VercelSettingsRows({
|
||||
onClick={() => onOpenModal?.()}
|
||||
disabled={isLoadingProjects || !onOpenModal || !canManageVercel}
|
||||
tooltip={canManageVercel ? undefined : noPermissionTooltip}
|
||||
LeadingIcon={
|
||||
isLoadingProjects
|
||||
? () => <Spinner color="blue" className="size-4" />
|
||||
: () => <VercelLogo className="-mx-1 size-3.5 text-text-bright" />
|
||||
}
|
||||
LeadingIcon={isLoadingProjects ? VercelLoadingIcon : VercelLeadingIcon}
|
||||
>
|
||||
{isLoadingProjects ? "Loading projects…" : "Connect Vercel project"}
|
||||
</Button>
|
||||
|
||||
@@ -43,6 +43,10 @@ import { sendToPlain } from "~/utils/plain.server";
|
||||
import { formatCurrency } from "~/utils/numberFormatter";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
|
||||
function WhiteSpinnerIcon() {
|
||||
return <Spinner color="white" />;
|
||||
}
|
||||
|
||||
const Params = z.object({
|
||||
organizationSlug: z.string(),
|
||||
});
|
||||
@@ -399,7 +403,7 @@ export function TierFree({
|
||||
<Button
|
||||
variant="danger/medium"
|
||||
disabled={isLoading}
|
||||
LeadingIcon={isLoading ? () => <Spinner color="white" /> : undefined}
|
||||
LeadingIcon={isLoading ? WhiteSpinnerIcon : undefined}
|
||||
type="submit"
|
||||
>
|
||||
Downgrade plan
|
||||
@@ -527,7 +531,7 @@ export function TierHobby({
|
||||
<Button
|
||||
variant="secondary/medium"
|
||||
disabled={isLoading}
|
||||
LeadingIcon={isLoading ? () => <Spinner color="white" /> : undefined}
|
||||
LeadingIcon={isLoading ? WhiteSpinnerIcon : undefined}
|
||||
form="subscribe-hobby"
|
||||
>
|
||||
{`Downgrade to ${plan.title}`}
|
||||
@@ -670,7 +674,7 @@ export function TierPro({
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
disabled={isLoading}
|
||||
LeadingIcon={isLoading ? () => <Spinner color="white" /> : undefined}
|
||||
LeadingIcon={isLoading ? WhiteSpinnerIcon : undefined}
|
||||
form="subscribe-pro"
|
||||
>
|
||||
{`Upgrade to ${plan.title}`}
|
||||
|
||||
Reference in New Issue
Block a user