fix(web): guard unguarded data derefs causing work-item and layout crashes (#9546)

* fix: filter out undefined label options in issue properties components

Updated the mapping of label IDs to ensure that only defined label options are included in the defaultLabelOptions array across multiple components. This change enhances the robustness of the label handling in the IssueProperties and SpreadsheetLabelColumn components, as well as in the PeekOverviewProperties component.

* fix: ensure array checks for results in various components

Updated multiple components to include checks for array types before accessing results. This change enhances stability by preventing potential runtime errors when results are undefined or not an array. Affected components include DescriptionVersionsRoot, PrevExports, SingleIntegrationCard, ProfileActivity, and IssueSubIssuesStore.

* fix: wrap children in LayoutErrorBoundary for improved error handling

Updated the IssueLayoutHOC component to include LayoutErrorBoundary, enhancing error handling by wrapping the children. This change aims to provide a more robust user experience by catching layout-related errors effectively.

* fix: optimize handleRefresh with useCallback in PrevExports component

Refactored the handleRefresh function in the PrevExports component to use useCallback, improving performance by memoizing the function. Additionally, updated the useEffect dependency array to include handleRefresh, ensuring the effect runs correctly when dependencies change. This change enhances the efficiency of the component's refresh logic.

* fix: enhance LayoutErrorBoundary with retry functionality and improved error messaging

Refactored the LayoutErrorBoundary component to include a dedicated LayoutErrorFallback for better error presentation. Added a retry mechanism that allows users to attempt to reload the content after an error occurs. This change improves user experience by providing clearer messaging and a more interactive way to recover from errors.

* fix: improve label option handling and array checks in various components

Refactored the defaultLabelOptions logic in multiple components to use flatMap for better handling of undefined labels. Additionally, updated array checks in the PrevExports component to ensure results are properly validated before access. These changes enhance the robustness and stability of the components, preventing potential runtime errors.

* fix: refactor ProfileActivity component for improved loading and data handling

Updated the ProfileActivity component to enhance the loading state management and streamline the rendering of user activity results. The refactor includes a more efficient check for userProfileActivity, ensuring that loading indicators and empty states are displayed correctly. This change improves the user experience by providing clearer feedback during data fetching and handling scenarios with no activity results.

* fix: improve type safety and array handling in integration card and sub-issues store

Updated the SingleIntegrationCard component to use a specific type for workspace integrations, enhancing type safety. Additionally, refactored the subIssues assignment in the IssueSubIssuesStore to ensure it correctly checks for an array before assignment, improving stability and preventing potential runtime errors.

* fix: enhance handleRefresh in PrevExports component with error handling

Refactored the handleRefresh function in the PrevExports component to include error handling during the refresh process. The function now uses async/await for better readability and ensures that any errors during the mutation are logged, improving the robustness of the component's refresh logic.

* style: fix oxfmt formatting flagged by CI check:format

Multi-line flatMap guard needed reformatting to satisfy oxfmt.

* style: reformat defaultLabelOptions logic for consistency

Adjusted the formatting of the defaultLabelOptions logic in the DraftIssueProperties component to maintain consistency with the project's coding standards. This change enhances readability without altering functionality.
This commit is contained in:
Atul Tameshwari
2026-08-05 00:21:19 +05:30
committed by GitHub
parent ed61f9925b
commit fa027167f6
12 changed files with 161 additions and 80 deletions

View File

@@ -28,7 +28,7 @@ export const User = observer(function User(props: TUser) {
return (
<>
{customUserName || actorDetail?.display_name.includes("-intake") ? (
{customUserName || actorDetail?.display_name?.includes("-intake") ? (
<span className="font-medium text-primary">{customUserName || "Plane"}</span>
) : (
<Link

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Component, Fragment } from "react";
import type { ErrorInfo, ReactNode } from "react";
import { AlertTriangle } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
type Props = {
children: ReactNode;
};
type State = {
hasError: boolean;
retryKey: number;
};
function LayoutErrorFallback({ onRetry }: { onRetry: () => void }) {
const { t } = useTranslation();
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-center">
<AlertTriangle className="size-8 text-tertiary" />
<p className="text-14 text-secondary">{t("something_went_wrong")}</p>
<Button variant="secondary" size="sm" onClick={onRetry}>
{t("common.retry")}
</Button>
</div>
);
}
// Catches render crashes from a single issue layout (list/kanban/spreadsheet/calendar/gantt)
// so a bad group/column shape degrades to a local fallback instead of taking down the whole page.
export class LayoutErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, retryKey: 0 };
static getDerivedStateFromError(): Partial<State> {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// eslint-disable-next-line no-console
console.error("Issue layout crashed", error, info);
}
handleRetry = () => {
this.setState((prev) => ({ hasError: false, retryKey: prev.retryKey + 1 }));
};
render() {
if (this.state.hasError) {
return <LayoutErrorFallback onRetry={this.handleRetry} />;
}
return <Fragment key={this.state.retryKey}>{this.props.children}</Fragment>;
}
}

View File

@@ -50,7 +50,7 @@ export const DescriptionVersionsRoot = observer(function DescriptionVersionsRoot
entityId && activeVersionId ? `DESCRIPTION_VERSION_DETAILS_${activeVersionId}` : null,
entityId && activeVersionId ? () => fetchHandlers.retrieveDescriptionVersion(entityId, activeVersionId) : null
);
const versions = versionsListResponse?.results;
const versions = Array.isArray(versionsListResponse?.results) ? versionsListResponse.results : undefined;
const versionsCount = versions?.length ?? 0;
const activeVersionDetails = versions?.find((version) => version.id === activeVersionId);
const activeVersionIndex = versions?.findIndex((version) => version.id === activeVersionId);

View File

@@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react";
import useSWR, { mutate } from "swr";
import { MoveLeft, MoveRight, RefreshCw } from "lucide-react";
@@ -46,14 +46,24 @@ export const PrevExports = observer(function PrevExports(props: Props) {
workspaceSlug && cursor ? () => integrationService.getExportsServicesList(workspaceSlug, cursor, per_page) : null
);
const handleRefresh = () => {
const handleRefresh = useCallback(async () => {
setRefreshing(true);
mutate(EXPORT_SERVICES_LIST(workspaceSlug, `${cursor}`, `${per_page}`)).then(() => setRefreshing(false));
};
try {
await mutate(EXPORT_SERVICES_LIST(workspaceSlug, `${cursor}`, `${per_page}`));
} catch (error) {
// eslint-disable-next-line no-console
console.error("Failed to refresh export services list", error);
} finally {
setRefreshing(false);
}
}, [workspaceSlug, cursor, per_page]);
useEffect(() => {
const interval = setInterval(() => {
if (exporterServices?.results?.some((service) => service.status === "processing")) {
if (
Array.isArray(exporterServices?.results) &&
exporterServices.results.some((service) => service.status === "processing")
) {
handleRefresh();
} else {
clearInterval(interval);
@@ -61,7 +71,7 @@ export const PrevExports = observer(function PrevExports(props: Props) {
}, 3000);
return () => clearInterval(interval);
}, [exporterServices]);
}, [exporterServices, handleRefresh]);
return (
<div>
@@ -73,7 +83,7 @@ export const PrevExports = observer(function PrevExports(props: Props) {
{refreshing ? t("refreshing") : t("refresh_status")}
</Button>
</div>
{!!exporterServices?.results?.length && (
{Array.isArray(exporterServices?.results) && exporterServices.results.length > 0 && (
<div className="flex items-center gap-2 text-11">
<Button
variant="secondary"
@@ -97,35 +107,33 @@ export const PrevExports = observer(function PrevExports(props: Props) {
)}
</div>
<div className="flex flex-col">
{exporterServices && exporterServices?.results ? (
exporterServices?.results?.length > 0 ? (
<div>
<div className="divide-y divide-subtle-1">
<Table
columns={columns}
data={exporterServices?.results ?? []}
keyExtractor={(rowData: RowData) => rowData?.id ?? ""}
tHeadClassName="border-b border-subtle"
thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0"
tBodyTrClassName="divide-x-0 p-4 h-[40px] text-secondary"
tHeadTrClassName="divide-x-0"
/>
</div>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<EmptyStateCompact
assetKey="export"
title={t("settings_empty_state.exports.title")}
description={t("settings_empty_state.exports.description")}
align="start"
rootClassName="py-20"
{!exporterServices ? (
<ImportExportSettingsLoader />
) : Array.isArray(exporterServices.results) && exporterServices.results.length > 0 ? (
<div>
<div className="divide-y divide-subtle-1">
<Table
columns={columns}
data={exporterServices.results}
keyExtractor={(rowData: RowData) => rowData?.id ?? ""}
tHeadClassName="border-b border-subtle"
thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0"
tBodyTrClassName="divide-x-0 p-4 h-[40px] text-secondary"
tHeadTrClassName="divide-x-0"
/>
</div>
)
</div>
) : (
<ImportExportSettingsLoader />
<div className="flex h-full w-full items-center justify-center">
<EmptyStateCompact
assetKey="export"
title={t("settings_empty_state.exports.title")}
description={t("settings_empty_state.exports.description")}
align="start"
rootClassName="py-20"
/>
</div>
)}
</div>
</div>

View File

@@ -73,7 +73,9 @@ export const SingleIntegrationCard = observer(function SingleIntegrationCard({ i
const handleRemoveIntegration = async () => {
if (!workspaceSlug || !integration || !workspaceIntegrations) return;
const workspaceIntegrationId = workspaceIntegrations?.find((i) => i.integration === integration.id)?.id;
const workspaceIntegrationId = Array.isArray(workspaceIntegrations)
? workspaceIntegrations.find((i) => i.integration === integration.id)?.id
: undefined;
setDeletingIntegration(true);
@@ -104,7 +106,9 @@ export const SingleIntegrationCard = observer(function SingleIntegrationCard({ i
});
};
const isInstalled = workspaceIntegrations?.find((i: any) => i.integration_detail.id === integration.id);
const isInstalled = Array.isArray(workspaceIntegrations)
? workspaceIntegrations.find((i: IWorkspaceIntegration) => i.integration_detail.id === integration.id)
: undefined;
return (
<div className="flex items-center justify-between gap-2 border-b border-subtle bg-surface-1 px-4 py-6">

View File

@@ -8,6 +8,7 @@ import { observer } from "mobx-react";
// plane imports
import { EIssueLayoutTypes } from "@plane/types";
// components
import { LayoutErrorBoundary } from "@/components/common/layout-error-boundary";
import { CalendarLayoutLoader } from "@/components/ui/loader/layouts/calendar-layout-loader";
import { GanttLayoutLoader } from "@/components/ui/loader/layouts/gantt-layout-loader";
import { KanbanLayoutLoader } from "@/components/ui/loader/layouts/kanban-layout-loader";
@@ -58,5 +59,5 @@ export const IssueLayoutHOC = observer(function IssueLayoutHOC(props: Props) {
return <IssueLayoutEmptyState storeType={storeType} />;
}
return <>{props.children}</>;
return <LayoutErrorBoundary key={layout}>{props.children}</LayoutErrorBoundary>;
});

View File

@@ -179,7 +179,11 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
issue.start_date && issue.target_date && displayProperties.start_date && displayProperties.due_date
);
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
const defaultLabelOptions =
issue?.label_ids?.flatMap((id) => {
const label = labelMap[id];
return label ? [label] : [];
}) || [];
const minDate = getDate(issue.start_date);
const maxDate = getDate(issue.target_date);

View File

@@ -25,7 +25,11 @@ export const SpreadsheetLabelColumn = observer(function SpreadsheetLabelColumn(p
// hooks
const { labelMap } = useLabel();
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
const defaultLabelOptions =
issue?.label_ids?.flatMap((id) => {
const label = labelMap[id];
return label ? [label] : [];
}) || [];
return (
<div className="h-11 w-full border-b-[0.5px] border-subtle">

View File

@@ -131,10 +131,10 @@ export const PeekOverviewProperties = observer(function PeekOverviewProperties(p
>
<ButtonAvatars
showTooltip
userIds={createdByDetails?.display_name.includes("-intake") ? null : createdByDetails?.id}
userIds={createdByDetails?.display_name?.includes("-intake") ? null : createdByDetails?.id}
/>
<span className="grow truncate text-body-xs-medium leading-5 text-secondary">
{createdByDetails?.display_name.includes("-intake") ? "Plane" : createdByDetails?.display_name}
{createdByDetails?.display_name?.includes("-intake") ? "Plane" : createdByDetails?.display_name}
</span>
</SidebarPropertyListItem>
)}

View File

@@ -122,7 +122,11 @@ export const DraftIssueProperties = observer(function DraftIssueProperties(props
if (!issue.project_id) return null;
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
const defaultLabelOptions =
issue?.label_ids?.flatMap((id) => {
const label = labelMap[id];
return label ? [label] : [];
}) || [];
const minDate = getDate(issue.start_date);
minDate?.setDate(minDate.getDate());

View File

@@ -45,41 +45,7 @@ export const ProfileActivity = observer(function ProfileActivity() {
<div className="space-y-2">
<h3 className="text-16 font-medium">{t("profile.stats.recent_activity.title")}</h3>
<Card>
{userProfileActivity ? (
userProfileActivity.results.length > 0 ? (
<div className="space-y-5">
{userProfileActivity.results.map((activity) => (
<div key={activity.id} className="flex gap-3">
<Avatar
name={activity.actor_detail?.display_name}
src={getFileURL(activity.actor_detail?.avatar_url)}
size="base"
shape="square"
/>
<div className="-mt-1 w-4/5 break-words">
<p className="inline text-13 text-secondary">
<span className="font-medium text-primary">
{currentUser?.id === activity.actor_detail?.id
? "You"
: activity.actor_detail?.display_name}{" "}
</span>
{activity.field ? (
<ActivityMessage activity={activity} showIssue />
) : (
<span>
created <IssueLink activity={activity} />
</span>
)}
</p>
<p className="text-11 whitespace-nowrap text-secondary">{calculateTimeAgo(activity.created_at)}</p>
</div>
</div>
))}
</div>
) : (
<EmptyStateCompact title={t("no_data_yet")} assetKey="unknown" assetClassName="size-20" />
)
) : (
{!userProfileActivity ? (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
@@ -87,6 +53,36 @@ export const ProfileActivity = observer(function ProfileActivity() {
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : Array.isArray(userProfileActivity.results) && userProfileActivity.results.length > 0 ? (
<div className="space-y-5">
{userProfileActivity.results.map((activity) => (
<div key={activity.id} className="flex gap-3">
<Avatar
name={activity.actor_detail?.display_name}
src={getFileURL(activity.actor_detail?.avatar_url)}
size="base"
shape="square"
/>
<div className="-mt-1 w-4/5 break-words">
<p className="inline text-13 text-secondary">
<span className="font-medium text-primary">
{currentUser?.id === activity.actor_detail?.id ? "You" : activity.actor_detail?.display_name}{" "}
</span>
{activity.field ? (
<ActivityMessage activity={activity} showIssue />
) : (
<span>
created <IssueLink activity={activity} />
</span>
)}
</p>
<p className="text-11 whitespace-nowrap text-secondary">{calculateTimeAgo(activity.created_at)}</p>
</div>
</div>
))}
</div>
) : (
<EmptyStateCompact title={t("no_data_yet")} assetKey="unknown" assetClassName="size-20" />
)}
</Card>
</div>

View File

@@ -166,8 +166,8 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
sub_issue_ids: issueIds,
});
const subIssuesStateDistribution = response?.state_distribution;
const subIssues = response.sub_issues as TIssue[];
const subIssuesStateDistribution = response?.state_distribution ?? {};
const subIssues = Array.isArray(response?.sub_issues) ? response.sub_issues : [];
// fetch other issues states and members when sub-issues are from different project
if (subIssues && subIssues.length > 0) {