Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Extracts the layout value from a URL query string.
* Returns null if no valid layout is present.
*/
export function getLayoutFromUrl(search: string, validLayouts: string[]): string | null {
try {
const params = new URLSearchParams(search);
const layout = params.get("layout");

if (!layout) return null;
if (!validLayouts.includes(layout)) return null;

return layout;
} catch {
return null;
}
}

/**
* Returns a new query string with the layout updated.
* Does not touch window or history — pure logic only.
*/
export function setLayoutInQuery(search: string, layout: string): string {
try {
const params = new URLSearchParams(search);
params.set("layout", layout);
return params.toString();
} catch {
return search;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { cn } from "@plane/utils";
import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
import { useEffect } from "react";
import { getLayoutFromUrl, setLayoutInQuery } from "./layout-selection.logic";

type Props = {
layouts: EIssueLayoutTypes[];
Expand All @@ -25,9 +27,27 @@ export function LayoutSelection(props: Props) {
const { layouts, onChange, selectedLayout } = props;
const { isMobile } = usePlatformOS();
const { t } = useTranslation();

// Read layout from URL once on mount and apply if valid
useEffect(() => {
if (typeof window === "undefined") return;

const layout = getLayoutFromUrl(window.location.search, layouts);
if (layout && (layout as EIssueLayoutTypes) !== selectedLayout) {
onChange(layout as EIssueLayoutTypes);
}
}, [layouts, onChange, selectedLayout]);
Comment on lines +31 to +39
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

useEffect runs on every selectedLayout change, not just on mount.

The comment states "Read layout from URL once on mount," but including selectedLayout in the dependency array causes the effect to run on every layout change. This is unnecessary overhead since the URL is updated synchronously in handleOnChange.

To run truly only on mount, consider removing selectedLayout from dependencies. Alternatively, use a ref to track whether initialization has occurred.

♻️ Option 1: Run only on mount (suppress lint warning)
   useEffect(() => {
     if (typeof window === "undefined") return;

     const layout = getLayoutFromUrl(window.location.search, layouts);
     if (layout && (layout as EIssueLayoutTypes) !== selectedLayout) {
       onChange(layout as EIssueLayoutTypes);
     }
+    // eslint-disable-next-line react-hooks/exhaustive-deps -- Initialize from URL once on mount only
-  }, [layouts, onChange, selectedLayout]);
+  }, []);
♻️ Option 2: Use a ref to track initialization
+import { useEffect, useRef } from "react";
-import { useEffect } from "react";

 export function LayoutSelection(props: Props) {
   const { layouts, onChange, selectedLayout } = props;
+  const initializedRef = useRef(false);

   useEffect(() => {
+    if (initializedRef.current) return;
     if (typeof window === "undefined") return;

     const layout = getLayoutFromUrl(window.location.search, layouts);
     if (layout && (layout as EIssueLayoutTypes) !== selectedLayout) {
       onChange(layout as EIssueLayoutTypes);
     }
+    initializedRef.current = true;
   }, [layouts, onChange, selectedLayout]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Read layout from URL once on mount and apply if valid
useEffect(() => {
if (typeof window === "undefined") return;
const layout = getLayoutFromUrl(window.location.search, layouts);
if (layout && (layout as EIssueLayoutTypes) !== selectedLayout) {
onChange(layout as EIssueLayoutTypes);
}
}, [layouts, onChange, selectedLayout]);
// Read layout from URL once on mount and apply if valid
useEffect(() => {
if (typeof window === "undefined") return;
const layout = getLayoutFromUrl(window.location.search, layouts);
if (layout && (layout as EIssueLayoutTypes) !== selectedLayout) {
onChange(layout as EIssueLayoutTypes);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Initialize from URL once on mount only
}, []);
Suggested change
// Read layout from URL once on mount and apply if valid
useEffect(() => {
if (typeof window === "undefined") return;
const layout = getLayoutFromUrl(window.location.search, layouts);
if (layout && (layout as EIssueLayoutTypes) !== selectedLayout) {
onChange(layout as EIssueLayoutTypes);
}
}, [layouts, onChange, selectedLayout]);
import { useEffect, useRef } from "react";
export function LayoutSelection(props: Props) {
const { layouts, onChange, selectedLayout } = props;
const initializedRef = useRef(false);
useEffect(() => {
if (initializedRef.current) return;
if (typeof window === "undefined") return;
const layout = getLayoutFromUrl(window.location.search, layouts);
if (layout && (layout as EIssueLayoutTypes) !== selectedLayout) {
onChange(layout as EIssueLayoutTypes);
}
initializedRef.current = true;
}, [layouts, onChange, selectedLayout]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsx`
around lines 31 - 39, The useEffect in layout-selection.tsx is intended to run
only on mount but currently lists selectedLayout in its dependency array causing
repeated runs; fix by removing selectedLayout from the dependency array so it
becomes useEffect(() => { ... }, [layouts, onChange]) and, if ESLint complains,
either add a // eslint-disable-next-line react-hooks/exhaustive-deps comment
above the effect or implement a simple initialized ref (e.g., const
initializedRef = useRef(false>) and guard the body with if
(initializedRef.current) return; initializedRef.current = true; while keeping
layouts and onChange as needed; reference getLayoutFromUrl, selectedLayout, and
onChange to locate the effect.


const handleOnChange = (layoutKey: EIssueLayoutTypes) => {
if (selectedLayout !== layoutKey) {
onChange(layoutKey);

if (typeof window !== "undefined") {
const newQuery = setLayoutInQuery(window.location.search, layoutKey);
const newUrl = newQuery ? `${window.location.pathname}?${newQuery}` : window.location.pathname;

window.history.replaceState({}, "", newUrl);
}
Comment on lines +45 to +50
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how other components in this codebase handle URL query parameter updates
rg -n "history\.replaceState|history\.pushState" apps/web/ --type ts --type tsx -C 2 | head -50

# Check if there's an established pattern using Next.js router
rg -n "router\.replace|router\.push.*query" apps/web/core/components/ --type ts --type tsx | head -30

Repository: makeplane/plane

Length of output: 114


🏁 Script executed:

#!/bin/bash
# Search for history.replaceState and history.pushState without file type specification
rg "history\.(replaceState|pushState)" apps/web/ -C 2

# Also search the specific file to understand its context
cat apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsx

Repository: makeplane/plane

Length of output: 3428


🏁 Script executed:

#!/bin/bash
# Search for useRouter usage in similar layout/filter components
rg "useRouter\|useSearchParams" apps/web/core/components/issues/ -C 1 | head -50

# Check if layout-selection is used in route components
rg "LayoutSelection" apps/web/ -C 1 | head -30

# Look for similar patterns of URL query updates in the codebase
rg "router\.(push|replace).*query|useSearchParams" apps/web/core/components/ -C 2 | head -50

Repository: makeplane/plane

Length of output: 6186


🏁 Script executed:

#!/bin/bash
# Find the useQueryParams utility and its usage pattern
rg "useQueryParams" apps/web/core/components/ -B 2 -A 3 | head -80

# Check how LayoutSelection is integrated in filters.tsx
cat apps/web/core/components/issues/filters.tsx | head -100

Repository: makeplane/plane

Length of output: 8562


🏁 Script executed:

#!/bin/bash
# Check mobile-layout-selection.tsx for the same pattern
cat apps/web/core/components/issues/issue-layouts/filters/header/mobile-layout-selection.tsx

# Check the logic file to understand the intent
cat apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.logic.ts

Repository: makeplane/plane

Length of output: 2688


Use Next.js routing hooks instead of direct window.history manipulation.

This component directly uses window.history.replaceState, which bypasses Next.js router's internal state and can cause desync if users navigate via Next.js methods (Link, router.push). The codebase has an established pattern using useRouter and useSearchParams (seen in pages/version/root.tsx, navigation-pane/root.tsx, and other routing-aware components).

Either refactor this component to use Next.js routing hooks if it owns URL state, or move URL synchronization responsibility to the parent component (HeaderFilters) which should coordinate both state updates and route updates. Note that MobileLayoutSelection correctly handles this by only exposing the onChange callback without manipulating history.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsx`
around lines 45 - 50, This component mutates browser history directly via
window.history.replaceState after calling setLayoutInQuery, which can desync
Next.js routing; replace that logic to use Next.js routing hooks (useRouter
and/or useSearchParams) or remove history handling and delegate URL updates to
the parent HeaderFilters — specifically, stop calling
window.history.replaceState in layout-selection.tsx, instead call
router.replace({ pathname: router.pathname, query: updatedQuery }, undefined, {
shallow: true }) using useRouter (or accept an onChange callback like
MobileLayoutSelection and let HeaderFilters call router.replace). Locate
setLayoutInQuery and the block that does window.history.replaceState and convert
it to useRouter.replace or move the synchronization responsibility to
HeaderFilters so this component only emits onChange.

}
};

Expand Down
36 changes: 36 additions & 0 deletions packages/codemods/tests/layout-selection.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import {
getLayoutFromUrl,
setLayoutInQuery,
} from "../../../apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.logic";
Comment on lines +2 to +5
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the import path resolves correctly
fd -t f "layout-selection.logic.ts" --exec echo "Found: {}"

# Verify the test file location
fd -t f "layout-selection.logic.test.ts" --exec echo "Test at: {}"

# Check if there are other tests in codemods that import from apps/web
rg -l "from.*\.\./.*apps/web" packages/codemods/

Repository: makeplane/plane

Length of output: 275


🏁 Script executed:

# Check tsconfig configurations for both locations
find . -name "tsconfig.json" -type f | head -20

Repository: makeplane/plane

Length of output: 843


🏁 Script executed:

# Look at the packages/codemods structure and its tsconfig
cat packages/codemods/tsconfig.json 2>/dev/null || echo "tsconfig.json not found in packages/codemods"

Repository: makeplane/plane

Length of output: 352


🏁 Script executed:

# Check if there are other imports from apps/web in the codebase tests
rg "from.*apps/web" --type ts --type tsx | grep -E "(test|spec)" | head -20

Repository: makeplane/plane

Length of output: 86


🏁 Script executed:

# Check the root tsconfig and monorepo structure
cat tsconfig.json 2>/dev/null || cat tsconfig.base.json 2>/dev/null || echo "Base tsconfig not found"

Repository: makeplane/plane

Length of output: 82


🏁 Script executed:

# Examine the actual import in the test file
cat -n packages/codemods/tests/layout-selection.logic.test.ts | head -10

Repository: makeplane/plane

Length of output: 533


🏁 Script executed:

# Check monorepo configuration
cat pnpm-workspace.yaml 2>/dev/null || cat package.json | head -30

Repository: makeplane/plane

Length of output: 994


🏁 Script executed:

# Check if there are exports configured in apps/web that would allow external imports
cat apps/web/package.json | grep -A 10 "exports"

Repository: makeplane/plane

Length of output: 41


🏁 Script executed:

# Look for any documentation about module imports in the codebase
find . -maxdepth 2 -name "*.md" -type f | xargs grep -l "import\|path\|package" 2>/dev/null | head -10

Repository: makeplane/plane

Length of output: 147


🏁 Script executed:

# Check if codemods package has any path or dependency configuration that might allow this import
cat packages/codemods/package.json

Repository: makeplane/plane

Length of output: 1046


🏁 Script executed:

# Search for similar import patterns (without the test/spec filter)
rg "from.*\.\./.*apps/web" --type ts | head -10

Repository: makeplane/plane

Length of output: 216


Cross-package import violates monorepo structure.

The test file imports from apps/web/ without declaring it as a dependency. In a monorepo using pnpm workspaces, cross-package imports should either rely on declared dependencies or be avoided entirely. Additionally, apps/web does not export these functions for external use.

Move the test file alongside the logic file in apps/web/core/components/issues/issue-layouts/filters/header/, or extract the shared logic to a proper package (e.g., packages/utils or similar) that both can depend on.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/codemods/tests/layout-selection.logic.test.ts` around lines 2 - 5,
The test imports getLayoutFromUrl and setLayoutInQuery from another app which
violates the monorepo dependency rules; either relocate the test next to the
implementation (move this test file into the same directory as the
implementation so it imports locally) or extract getLayoutFromUrl and
setLayoutInQuery into a shared package (e.g., packages/utils) and add that
package as a dependency for both the app and the test package; update imports to
reference the local relative module or the new package export and ensure
package.json workspace dependencies are updated accordingly.


describe("layout-selection.logic", () => {
describe("getLayoutFromUrl", () => {
it("returns the layout when valid", () => {
const result = getLayoutFromUrl("?layout=list", ["list", "kanban"]);
expect(result).toBe("list");
});

it("returns null when layout is missing", () => {
const result = getLayoutFromUrl("?foo=bar", ["list"]);
expect(result).toBeNull();
});

it("returns null when layout is invalid", () => {
const result = getLayoutFromUrl("?layout=unknown", ["list"]);
expect(result).toBeNull();
});
});

describe("setLayoutInQuery", () => {
it("updates the layout in the query string", () => {
const result = setLayoutInQuery("?foo=bar", "list");
expect(result).toBe("foo=bar&layout=list");
});

it("overwrites an existing layout", () => {
const result = setLayoutInQuery("?layout=kanban", "list");
expect(result).toBe("layout=list");
});
});
});