fix(web,ui): fix hydration error from nested button descendants and MobX strict-mode warnings
Some checks failed
Deploy Plane Project Tracker / Deploy to cisystem (push) Failing after 5m16s

- Replace nested <Button> with styled <div> in DropdownButton (BorderButton, BackgroundButton, TransparentButton) across all issue dropdowns
- Support 'div' variant and element in AppSidebarItem, DragHandle, and CustomMenu triggers/items to avoid invalid HTML button nesting
- Fix MobX strict-mode observable mutations in IssueRootStore autorun and BaseWorkspaceRootStore fetchWorkspaces with runInAction
- Defer WorkItemFilterStore filter registration via queueMicrotask to eliminate cross-component render updates on WorkItemFiltersToggle
- Remove redundant e.preventDefault() in Kanban card actions to prevent blocking native/synthetic event propagation
This commit is contained in:
vickytechkey 2026-11-06 17:24:20 +05:30
parent 31110e92c5
commit e67f0cf15d
18 changed files with 138 additions and 131 deletions

View file

@ -6,7 +6,7 @@
import React from "react";
// helpers
import { Button } from "@plane/propel/button";
import { getButtonStyling } from "@plane/propel/button";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { cn } from "@plane/utils";
// types
@ -76,10 +76,9 @@ function BorderButton(props: ButtonProps) {
layout="stacked"
disabled={!showTooltip || isMobile}
>
<Button
variant="ghost"
size="sm"
<div
className={cn(
getButtonStyling("ghost", "sm"),
"flex h-full w-full items-center justify-start gap-1.5 border-[0.5px] border-strong",
{
"bg-layer-transparent-active": isActive,
@ -88,7 +87,7 @@ function BorderButton(props: ButtonProps) {
)}
>
{children}
</Button>
</div>
</Tooltip>
);
}
@ -102,16 +101,15 @@ function BackgroundButton(props: ButtonProps) {
layout="stacked"
disabled={!showTooltip || isMobile}
>
<Button
variant="ghost"
size="sm"
<div
className={cn(
getButtonStyling("ghost", "sm"),
"flex h-full w-full items-center justify-between gap-1.5 bg-layer-3 hover:bg-layer-1-hover",
className
)}
>
{children}
</Button>
</div>
</Tooltip>
);
}
@ -125,10 +123,9 @@ function TransparentButton(props: ButtonProps) {
layout="stacked"
disabled={!showTooltip || isMobile}
>
<Button
variant="ghost"
size="sm"
<div
className={cn(
getButtonStyling("ghost", "sm"),
"flex h-full w-full items-center justify-between gap-1.5",
{
"bg-layer-transparent-active": isActive,
@ -137,7 +134,7 @@ function TransparentButton(props: ButtonProps) {
)}
>
{children}
</Button>
</div>
</Tooltip>
);
}

View file

@ -47,9 +47,6 @@ export const SubIssuesListItemProperties = observer(function SubIssuesListItemPr
// oxlint-disable-next-line unicorn/consistent-function-scoping
const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {
e.stopPropagation();
if (e.type !== "focus") {
e.preventDefault();
}
};
const handleStartDate = (date: Date | null) => {

View file

@ -87,7 +87,6 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props:
// oxlint-disable-next-line unicorn/consistent-function-scoping
const handleEventPropagation = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
};
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));

View file

@ -22,7 +22,7 @@ interface AppSidebarItemData {
}
interface AppSidebarItemProps {
variant?: "link" | "button";
variant?: "link" | "button" | "div";
item?: AppSidebarItemData;
}
@ -47,6 +47,7 @@ interface AppSidebarButtonItemProps {
onClick?: () => void;
disabled?: boolean;
className?: string;
as?: "button" | "div";
}
// ============================================================================
@ -107,7 +108,15 @@ function AppSidebarLinkItem({ href, children, className }: AppSidebarLinkItemPro
);
}
function AppSidebarButtonItem({ children, onClick, disabled = false, className }: AppSidebarButtonItemProps) {
function AppSidebarButtonItem({ as: Component = "button", children, onClick, disabled = false, className }: AppSidebarButtonItemProps) {
if (Component === "div") {
return (
<div className={cn(styles.base, className)} onClick={onClick}>
{children}
</div>
);
}
return (
<button className={cn(styles.base, className)} onClick={onClick} disabled={disabled} type="button">
{children}
@ -142,6 +151,14 @@ function AppSidebarItem({ variant = "link", item }: AppSidebarItemProps) {
return <AppSidebarLinkItem href={href}>{commonItems}</AppSidebarLinkItem>;
}
if (variant === "div") {
return (
<AppSidebarButtonItem as="div" onClick={onClick} disabled={disabled}>
{commonItems}
</AppSidebarButtonItem>
);
}
return (
<AppSidebarButtonItem onClick={onClick} disabled={disabled}>
{commonItems}

View file

@ -172,8 +172,7 @@ export const ExtendedSidebarItem = observer(function ExtendedSidebarItem(props:
>
{!disableDrag && (
<Tooltip label={t("drag_to_rearrange")} align="start" disabled={isDragging}>
<button
type="button"
<div
className={cn(
"absolute top-1/2 -left-3 flex -translate-y-1/2 cursor-grab items-center justify-center rounded text-placeholder opacity-0 group-hover/project-item:opacity-100",
{
@ -183,8 +182,8 @@ export const ExtendedSidebarItem = observer(function ExtendedSidebarItem(props:
)}
ref={dragHandleRef}
>
<DragHandle className="bg-transparent" />
</button>
<DragHandle className="bg-transparent" as="div" />
</div>
</Tooltip>
)}
<SidebarNavItem isActive={isActive}>

View file

@ -191,8 +191,7 @@ export function FavoriteFolder(props: Props) {
align="end"
disabled={isDragging || isMobile}
>
<button
type="button"
<div
className={cn(
"absolute top-1/2 -left-3 hidden -translate-y-1/2 cursor-grab items-center justify-center rounded-sm text-placeholder group-hover/project-item:flex",
{
@ -201,8 +200,8 @@ export function FavoriteFolder(props: Props) {
}
)}
>
<DragHandle className="bg-transparent" />
</button>
<DragHandle className="bg-transparent" as="div" />
</div>
</Tooltip>
<div className="grid size-5 flex-shrink-0 place-items-center">
<FavoriteFolderIcon />

View file

@ -32,7 +32,7 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() {
<CustomMenu
customButton={
<AppSidebarItem
variant="button"
variant="div"
item={{
icon: <HelpOutline className="size-5" />,
isActive: isNeedHelpOpen,
@ -59,23 +59,11 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() {
</div>
</CustomMenu.MenuItem>
<div className="my-1 border-t border-subtle" />
<CustomMenu.MenuItem>
<button
type="button"
onClick={() => toggleShortcutsListModal(true)}
className="justify-sbg-layer-211 flex w-full items-center hover:bg-layer-1"
>
<span className="text-11">{t("keyboard_shortcuts")}</span>
</button>
<CustomMenu.MenuItem onClick={() => toggleShortcutsListModal(true)}>
<span className="text-11">{t("keyboard_shortcuts")}</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>
<button
type="button"
onClick={() => setProductUpdatesModalOpen(true)}
className="justify-sbg-layer-211 flex w-full items-center hover:bg-layer-1"
>
<span className="text-11">{t("whats_new")}</span>
</button>
<CustomMenu.MenuItem onClick={() => setProductUpdatesModalOpen(true)}>
<span className="text-11">{t("whats_new")}</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => window.open("https://forum.plane.so", "_blank", "noopener,noreferrer")}>
<div className="flex items-center gap-x-2 rounded-sm text-11">

View file

@ -313,8 +313,7 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
align="end"
disabled={isDragging || isMobile}
>
<button
type="button"
<div
className={cn(
"absolute top-1/2 -left-3 hidden -translate-y-1/2 cursor-grab items-center justify-center rounded-sm text-placeholder group-hover/project-item:flex",
{
@ -325,8 +324,8 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
)}
ref={dragHandleRef}
>
<DragHandle className="bg-transparent" />
</button>
<DragHandle className="bg-transparent" as="div" />
</div>
</Tooltip>
)}
<>

View file

@ -59,7 +59,7 @@ export const UserMenuRoot = observer(function UserMenuRoot() {
className="flex items-center"
customButton={
<AppSidebarItem
variant="button"
variant="div"
item={{
icon: (
<Avatar

View file

@ -96,7 +96,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
})}
>
<AppSidebarItem
variant="button"
variant="div"
item={{
icon: (
<WorkspaceLogo

View file

@ -5,7 +5,7 @@
*/
import { isEmpty } from "lodash-es";
import { autorun, makeObservable, observable } from "mobx";
import { autorun, makeObservable, observable, runInAction } from "mobx";
// types
import type { ICycle, IIssueLabel, IModule, IProject, IState, IUserLite, TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
@ -200,26 +200,28 @@ export class IssueRootStore implements IIssueRootStore {
this.rootStore = rootStore;
autorun(() => {
if (rootStore?.user?.data?.id) this.currentUserId = rootStore?.user?.data?.id;
if (this.workspaceSlug !== rootStore.router.workspaceSlug) this.workspaceSlug = rootStore.router.workspaceSlug;
if (this.teamspaceId !== rootStore.router.teamspaceId) this.teamspaceId = rootStore.router.teamspaceId;
if (this.projectId !== rootStore.router.projectId) this.projectId = rootStore.router.projectId;
if (this.cycleId !== rootStore.router.cycleId) this.cycleId = rootStore.router.cycleId;
if (this.moduleId !== rootStore.router.moduleId) this.moduleId = rootStore.router.moduleId;
if (this.viewId !== rootStore.router.viewId) this.viewId = rootStore.router.viewId;
if (this.globalViewId !== rootStore.router.globalViewId) this.globalViewId = rootStore.router.globalViewId;
if (this.userId !== rootStore.router.userId) this.userId = rootStore.router.userId;
if (!isEmpty(rootStore?.state?.stateMap)) this.stateMap = rootStore?.state?.stateMap;
if (!isEmpty(rootStore?.state?.projectStates)) this.stateDetails = rootStore?.state?.projectStates;
if (!isEmpty(rootStore?.state?.workspaceStates)) this.workspaceStateDetails = rootStore?.state?.workspaceStates;
if (!isEmpty(rootStore?.label?.labelMap)) this.labelMap = rootStore?.label?.labelMap;
if (!isEmpty(rootStore?.memberRoot?.workspace?.workspaceMemberMap))
this.workSpaceMemberRolesMap = rootStore?.memberRoot?.workspace?.memberMap || undefined;
if (!isEmpty(rootStore?.memberRoot?.memberMap)) this.memberMap = rootStore?.memberRoot?.memberMap || undefined;
if (!isEmpty(rootStore?.projectRoot?.project?.projectMap))
this.projectMap = rootStore?.projectRoot?.project?.projectMap;
if (!isEmpty(rootStore?.module?.moduleMap)) this.moduleMap = rootStore?.module?.moduleMap;
if (!isEmpty(rootStore?.cycle?.cycleMap)) this.cycleMap = rootStore?.cycle?.cycleMap;
runInAction(() => {
if (rootStore?.user?.data?.id) this.currentUserId = rootStore?.user?.data?.id;
if (this.workspaceSlug !== rootStore.router.workspaceSlug) this.workspaceSlug = rootStore.router.workspaceSlug;
if (this.teamspaceId !== rootStore.router.teamspaceId) this.teamspaceId = rootStore.router.teamspaceId;
if (this.projectId !== rootStore.router.projectId) this.projectId = rootStore.router.projectId;
if (this.cycleId !== rootStore.router.cycleId) this.cycleId = rootStore.router.cycleId;
if (this.moduleId !== rootStore.router.moduleId) this.moduleId = rootStore.router.moduleId;
if (this.viewId !== rootStore.router.viewId) this.viewId = rootStore.router.viewId;
if (this.globalViewId !== rootStore.router.globalViewId) this.globalViewId = rootStore.router.globalViewId;
if (this.userId !== rootStore.router.userId) this.userId = rootStore.router.userId;
if (!isEmpty(rootStore?.state?.stateMap)) this.stateMap = rootStore?.state?.stateMap;
if (!isEmpty(rootStore?.state?.projectStates)) this.stateDetails = rootStore?.state?.projectStates;
if (!isEmpty(rootStore?.state?.workspaceStates)) this.workspaceStateDetails = rootStore?.state?.workspaceStates;
if (!isEmpty(rootStore?.label?.labelMap)) this.labelMap = rootStore?.label?.labelMap;
if (!isEmpty(rootStore?.memberRoot?.workspace?.workspaceMemberMap))
this.workSpaceMemberRolesMap = rootStore?.memberRoot?.workspace?.memberMap || undefined;
if (!isEmpty(rootStore?.memberRoot?.memberMap)) this.memberMap = rootStore?.memberRoot?.memberMap || undefined;
if (!isEmpty(rootStore?.projectRoot?.project?.projectMap))
this.projectMap = rootStore?.projectRoot?.project?.projectMap;
if (!isEmpty(rootStore?.module?.moduleMap)) this.moduleMap = rootStore?.module?.moduleMap;
if (!isEmpty(rootStore?.cycle?.cycleMap)) this.cycleMap = rootStore?.cycle?.cycleMap;
});
});
this.issues = new IssueStore();

View file

@ -190,7 +190,9 @@ export class BaseWorkspaceRootStore implements IWorkspaceRootStore {
});
return workspaceResponse;
} finally {
this.loader = false;
runInAction(() => {
this.loader = false;
});
}
};

View file

@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import { action, makeObservable, observable } from "mobx";
import { action, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// plane imports
import type { TExpressionOptions } from "@plane/constants";
@ -49,6 +49,7 @@ export interface IWorkItemFilterStore {
export class WorkItemFilterStore implements IWorkItemFilterStore {
// observable
filters: IWorkItemFilterStore["filters"];
private _pendingFilters = new Map<TWorkItemFilterKey, IWorkItemFilterInstance>();
constructor() {
this.filters = new Map<TWorkItemFilterKey, IWorkItemFilterInstance>();
@ -69,9 +70,10 @@ export class WorkItemFilterStore implements IWorkItemFilterStore {
* @param entityId - The entity id.
* @returns The filter instance.
*/
getFilter: IWorkItemFilterStore["getFilter"] = computedFn((entityType, entityId) =>
this.filters.get(this._getFilterKey(entityType, entityId))
);
getFilter: IWorkItemFilterStore["getFilter"] = computedFn((entityType, entityId) => {
const key = this._getFilterKey(entityType, entityId);
return this.filters.get(key) ?? this._pendingFilters.get(key);
});
// ------------ actions ------------
@ -80,7 +82,8 @@ export class WorkItemFilterStore implements IWorkItemFilterStore {
* If the instance already exists, updates its expression options to ensure they're current.
*/
getOrCreateFilter: IWorkItemFilterStore["getOrCreateFilter"] = action((params) => {
const existingFilter = this.getFilter(params.entityType, params.entityId);
const filterKey = this._getFilterKey(params.entityType, params.entityId);
const existingFilter = this._pendingFilters.get(filterKey) ?? this.getFilter(params.entityType, params.entityId);
if (existingFilter) {
// Update expression options on existing filter to ensure they're current
if (params.expressionOptions) {
@ -99,8 +102,13 @@ export class WorkItemFilterStore implements IWorkItemFilterStore {
// create new filter instance
const newFilter = this._initializeFilterInstance(params);
const filterKey = this._getFilterKey(params.entityType, params.entityId);
this.filters.set(filterKey, newFilter);
this._pendingFilters.set(filterKey, newFilter);
queueMicrotask(() => {
runInAction(() => {
this._pendingFilters.delete(filterKey);
this.filters.set(filterKey, newFilter);
});
});
return newFilter;
});
@ -193,7 +201,9 @@ export class WorkItemFilterStore implements IWorkItemFilterStore {
* @param entityId - The entity id.
*/
deleteFilter: IWorkItemFilterStore["deleteFilter"] = action((entityType, entityId) => {
this.filters.delete(this._getFilterKey(entityType, entityId));
const key = this._getFilterKey(entityType, entityId);
this._pendingFilters.delete(key);
this.filters.delete(key);
});
// ------------ private helpers ------------

View file

@ -12,23 +12,24 @@ import { cn } from "./utils";
interface IDragHandle {
className?: string;
disabled?: boolean;
as?: "button" | "div";
}
export const DragHandle = forwardRef(function DragHandle(
props: IDragHandle,
ref: React.ForwardedRef<HTMLButtonElement | null>
ref: React.ForwardedRef<any>
) {
const { className, disabled = false } = props;
const { className, disabled = false, as: Component = "button" } = props;
if (disabled) {
return <div className="h-[18px] w-[14px]" />;
}
return (
<button
type="button"
<Component
type={Component === "button" ? "button" : undefined}
className={cn("flex flex-shrink-0 cursor-grab rounded-sm bg-surface-2 p-0.5 text-secondary", className)}
onContextMenu={(e) => {
onContextMenu={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}}
@ -36,7 +37,7 @@ export const DragHandle = forwardRef(function DragHandle(
>
<MoreVerticalOutline className="h-3.5 w-3.5 text-placeholder" />
<MoreVerticalOutline className="-ml-5 h-3.5 w-3.5 text-placeholder" />
</button>
</Component>
);
});

View file

@ -246,18 +246,17 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
{({ open }) => (
<>
{customButton ? (
<Menu.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
onClick={handleMenuButtonClick}
className={customButtonClassName}
tabIndex={customButtonTabIndex}
disabled={disabled}
aria-label={ariaLabel}
>
{customButton}
</button>
// oxlint-disable-next-line jsx_a11y/click-events-have-key-events oxlint-disable-next-line jsx_a11y/no-static-element-interactions
<Menu.Button
as="div"
ref={setReferenceElement}
onClick={handleMenuButtonClick}
className={customButtonClassName}
tabIndex={customButtonTabIndex}
disabled={disabled}
aria-label={ariaLabel}
>
{customButton}
</Menu.Button>
) : (
<>
@ -462,32 +461,33 @@ function SubMenu(props: ICustomSubMenuProps) {
}
function MenuItem(props: ICustomMenuItemProps) {
const { children, disabled = false, onClick, className } = props;
const { children, disabled = false, onClick, className, as: Component = "button" } = props;
const submenuContext = useSubMenu();
return (
<Menu.Item as="div" disabled={disabled}>
{({ active, close }) => (
<button
type="button"
<Component
type={Component === "button" ? "button" : undefined}
className={cn(
"w-full truncate rounded-sm px-1 py-1.5 text-left text-secondary select-none",
{
"bg-layer-transparent-hover": active && !disabled,
"text-placeholder": disabled,
"cursor-pointer": Component === "div" && !disabled,
},
className
)}
onClick={(e) => {
onClick={(e: React.MouseEvent) => {
close();
onClick?.(e);
// Close submenu if this item is inside a submenu
submenuContext?.closeSubmenu();
}}
disabled={disabled}
disabled={Component === "button" ? disabled : undefined}
>
{children}
</button>
</Component>
)}
</Menu.Item>
);

View file

@ -99,22 +99,20 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
return (
<>
{customButton ? (
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={cn(
"flex w-full items-center justify-between gap-1 text-11",
{
"cursor-not-allowed text-secondary": disabled,
"cursor-pointer hover:bg-layer-transparent-hover": !disabled,
},
customButtonClassName
)}
onClick={toggleDropdown}
>
{customButton}
</button>
<Combobox.Button
as="div"
ref={setReferenceElement}
className={cn(
"flex w-full items-center justify-between gap-1 text-11",
{
"cursor-not-allowed text-secondary": disabled,
"cursor-pointer hover:bg-layer-transparent-hover": !disabled,
},
customButtonClassName
)}
onClick={toggleDropdown}
>
{customButton}
</Combobox.Button>
) : (
<Combobox.Button as={React.Fragment}>

View file

@ -82,17 +82,15 @@ function CustomSelect(props: ICustomSelectProps) {
>
<>
{customButton ? (
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 rounded text-11 ${
disabled ? "cursor-not-allowed text-secondary" : "cursor-pointer hover:bg-layer-transparent-hover"
} ${customButtonClassName}`}
onClick={toggleDropdown}
>
{customButton}
</button>
<Combobox.Button
as="div"
ref={setReferenceElement}
className={`flex items-center justify-between gap-1 rounded text-11 ${
disabled ? "cursor-not-allowed text-secondary" : "cursor-pointer hover:bg-layer-transparent-hover"
} ${customButtonClassName}`}
onClick={toggleDropdown}
>
{customButton}
</Combobox.Button>
) : (
<Combobox.Button as={React.Fragment}>

View file

@ -94,6 +94,7 @@ export interface ICustomMenuItemProps {
disabled?: boolean;
onClick?: (args?: any) => void;
className?: string;
as?: "button" | "div";
}
export interface ICustomSelectItemProps {