From 0cb73ca92238abf11b9a1b2a7411692bdf9e7486 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 29 Jan 2026 09:47:51 +0000
Subject: [PATCH 01/18] Initial plan
From b7d93d20c25c6e2d1018478d10890a165a62a5a0 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 29 Jan 2026 09:56:17 +0000
Subject: [PATCH 02/18] feat(next): add support for custom collection views
- Update CollectionAdminOptions type to allow custom views at collection level
- Create getCustomCollectionViewByRoute helper function
- Modify getRouteData.ts to check for custom collection views before defaulting to edit views
- Add test collection with custom grid view
- Add test to verify custom collection view routing
Co-authored-by: robinscholz <8195463+robinscholz@users.noreply.github.com>
---
.../Root/getCustomCollectionViewByRoute.ts | 65 +++++++++++++++++++
packages/next/src/views/Root/getRouteData.ts | 22 ++++++-
.../payload/src/collections/config/types.ts | 3 +
.../admin/collections/CustomCollectionView.ts | 24 +++++++
.../views/CustomCollectionView/index.js | 28 ++++++++
test/admin/config.ts | 2 +
test/admin/e2e/general/e2e.spec.ts | 15 +++++
test/admin/shared.ts | 2 +
test/admin/slugs.ts | 2 +
9 files changed, 162 insertions(+), 1 deletion(-)
create mode 100644 packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
create mode 100644 test/admin/collections/CustomCollectionView.ts
create mode 100644 test/admin/components/views/CustomCollectionView/index.js
diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
new file mode 100644
index 00000000000..7a5132c4c7a
--- /dev/null
+++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
@@ -0,0 +1,65 @@
+import type { SanitizedCollectionConfig } from 'payload'
+
+import type { ViewFromConfig } from './getRouteData.js'
+
+import { isPathMatchingRoute } from './isPathMatchingRoute.js'
+
+export const getCustomCollectionViewByRoute = ({
+ baseRoute,
+ currentRoute,
+ views,
+}: {
+ baseRoute: string
+ currentRoute: string
+ views: SanitizedCollectionConfig['admin']['components']['views']
+}): {
+ view: ViewFromConfig
+ viewKey?: string
+} => {
+ if (typeof views === 'object') {
+ let viewKey: string
+
+ const foundViewConfig = Object.entries(views).find(([key, view]) => {
+ // Skip the known collection view types: edit and list
+ if (key === 'edit' || key === 'list') {
+ return false
+ }
+
+ if (typeof view === 'object' && 'path' in view) {
+ const viewPath = `${baseRoute}${view.path}`
+
+ const isMatching = isPathMatchingRoute({
+ currentRoute,
+ exact: view.exact,
+ path: viewPath,
+ sensitive: view.sensitive,
+ strict: view.strict,
+ })
+
+ if (isMatching) {
+ viewKey = key
+ }
+
+ return isMatching
+ }
+
+ return false
+ })?.[1]
+
+ if (foundViewConfig && 'Component' in foundViewConfig) {
+ return {
+ view: {
+ payloadComponent: foundViewConfig.Component,
+ },
+ viewKey,
+ }
+ }
+ }
+
+ return {
+ view: {
+ Component: null,
+ },
+ viewKey: null,
+ }
+}
diff --git a/packages/next/src/views/Root/getRouteData.ts b/packages/next/src/views/Root/getRouteData.ts
index 3a04aeca73f..30c93e6ffc4 100644
--- a/packages/next/src/views/Root/getRouteData.ts
+++ b/packages/next/src/views/Root/getRouteData.ts
@@ -31,6 +31,7 @@ import { ResetPassword, resetPasswordBaseClass } from '../ResetPassword/index.js
import { UnauthorizedView } from '../Unauthorized/index.js'
import { Verify, verifyBaseClass } from '../Verify/index.js'
import { getSubViewActions, getViewActions } from './attachViewActions.js'
+import { getCustomCollectionViewByRoute } from './getCustomCollectionViewByRoute.js'
import { getCustomViewByKey } from './getCustomViewByKey.js'
import { getCustomViewByRoute } from './getCustomViewByRoute.js'
import { getDocumentViewInfo } from './getDocumentViewInfo.js'
@@ -353,7 +354,26 @@ export const getRouteData = ({
viewActions.push(...(collectionConfig.admin.components?.views?.list?.actions || []))
} else {
- if (config.folders && segmentThree === config.folders.slug && collectionConfig.folders) {
+ // Check for custom collection views before assuming it's an edit view
+ const baseRoute = `/${segmentOne}/${segmentTwo}`
+ const customCollectionView = getCustomCollectionViewByRoute({
+ baseRoute,
+ currentRoute,
+ views: collectionConfig.admin.components?.views,
+ })
+
+ if (customCollectionView.viewKey && customCollectionView.view.payloadComponent) {
+ // --> /collections/:collectionSlug/:customViewPath
+ ViewToRender = customCollectionView.view
+
+ templateClassName = `collection-${customCollectionView.viewKey}`
+ templateType = 'default'
+ viewType = customCollectionView.viewKey as ViewTypes
+ } else if (
+ config.folders &&
+ segmentThree === config.folders.slug &&
+ collectionConfig.folders
+ ) {
// Collection Folder Views
// --> /collections/:collectionSlug/:folderCollectionSlug
// --> /collections/:collectionSlug/:folderCollectionSlug/:folderID
diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts
index b5eeeb79af1..3fa93c5c74b 100644
--- a/packages/payload/src/collections/config/types.ts
+++ b/packages/payload/src/collections/config/types.ts
@@ -2,6 +2,7 @@
import type { GraphQLInputObjectType, GraphQLNonNull, GraphQLObjectType } from 'graphql'
import type { DeepRequired, IsAny, MarkOptional } from 'ts-essentials'
+import type { AdminViewConfig } from '../../admin/views/index.js'
import type { CustomStatus, CustomUpload, ViewTypes } from '../../admin/types.js'
import type { Arguments as MeArguments } from '../../auth/operations/me.js'
import type {
@@ -382,6 +383,8 @@ export type CollectionAdminOptions = {
}
listMenuItems?: CustomComponent[]
views?: {
+ /** Add custom collection views */
+ [key: string]: AdminViewConfig
/**
* Replace, modify, or add new "document" views.
* @link https://payloadcms.com/docs/custom-components/document-views
diff --git a/test/admin/collections/CustomCollectionView.ts b/test/admin/collections/CustomCollectionView.ts
new file mode 100644
index 00000000000..233a9ef39aa
--- /dev/null
+++ b/test/admin/collections/CustomCollectionView.ts
@@ -0,0 +1,24 @@
+import type { CollectionConfig } from 'payload'
+
+import { customCollectionViewSlug } from '../slugs.js'
+
+export const CustomCollectionView: CollectionConfig = {
+ slug: customCollectionViewSlug,
+ admin: {
+ components: {
+ views: {
+ grid: {
+ Component: '/components/views/CustomCollectionView/index.js#CustomCollectionView',
+ path: '/grid',
+ exact: true,
+ },
+ },
+ },
+ },
+ fields: [
+ {
+ name: 'title',
+ type: 'text',
+ },
+ ],
+}
diff --git a/test/admin/components/views/CustomCollectionView/index.js b/test/admin/components/views/CustomCollectionView/index.js
new file mode 100644
index 00000000000..b6827e41260
--- /dev/null
+++ b/test/admin/components/views/CustomCollectionView/index.js
@@ -0,0 +1,28 @@
+import type { AdminViewServerProps } from 'payload'
+
+import React from 'react'
+
+import { customCollectionViewTitle } from '../../../shared.js'
+
+export function CustomCollectionView({ initPageResult }: AdminViewServerProps) {
+ return (
+
+
{customCollectionViewTitle}
+
This is a custom collection-level view (e.g., grid view) added via:
+
+ -
+
admin.components.views[key].Component
+
+ -
+
admin.components.views[key].path
+
+
+
+ )
+}
diff --git a/test/admin/config.ts b/test/admin/config.ts
index 1e889fe4cdc..4d5da943dc6 100644
--- a/test/admin/config.ts
+++ b/test/admin/config.ts
@@ -4,6 +4,7 @@ import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { Array } from './collections/Array.js'
import { BaseListFilter } from './collections/BaseListFilter.js'
+import { CustomCollectionView } from './collections/CustomCollectionView.js'
import { CollectionCustomDocumentControls } from './collections/CustomDocumentControls.js'
import { CustomFields } from './collections/CustomFields/index.js'
import { CustomListDrawer } from './collections/CustomListDrawer/index.js'
@@ -183,6 +184,7 @@ export default buildConfigWithDefaults({
CollectionCustomDocumentControls,
CustomViews1,
CustomViews2,
+ CustomCollectionView,
ReorderTabs,
CustomFields,
CollectionGroup1A,
diff --git a/test/admin/e2e/general/e2e.spec.ts b/test/admin/e2e/general/e2e.spec.ts
index 002e5c03edc..84b0f8da193 100644
--- a/test/admin/e2e/general/e2e.spec.ts
+++ b/test/admin/e2e/general/e2e.spec.ts
@@ -770,6 +770,21 @@ describe('General', () => {
})
})
+ describe('custom collection views', () => {
+ test('should render custom collection view at custom path', async () => {
+ await page.goto(
+ formatAdminURL({
+ adminRoute,
+ path: '/collections/custom-collection-view/grid',
+ serverURL,
+ }),
+ )
+ await expect(page.locator('h1#custom-collection-view-title')).toContainText(
+ 'Custom Collection View',
+ )
+ })
+ })
+
describe('header actions', () => {
test('should show admin level action in admin panel', async () => {
await page.goto(postsUrl.admin)
diff --git a/test/admin/shared.ts b/test/admin/shared.ts
index 5a9d146f737..60f1f308b55 100644
--- a/test/admin/shared.ts
+++ b/test/admin/shared.ts
@@ -18,6 +18,8 @@ export const customParamViewPath = `${customParamViewPathBase}/:id`
export const customViewTitle = 'Custom View'
+export const customCollectionViewTitle = 'Custom Collection View'
+
export const customParamViewTitle = 'Custom Param View'
export const customNestedViewTitle = 'Custom Nested View'
diff --git a/test/admin/slugs.ts b/test/admin/slugs.ts
index 19acda19793..12440c4da93 100644
--- a/test/admin/slugs.ts
+++ b/test/admin/slugs.ts
@@ -1,6 +1,7 @@
export const usersCollectionSlug = 'users'
export const customViews1CollectionSlug = 'custom-views-one'
export const customViews2CollectionSlug = 'custom-views-two'
+export const customCollectionViewSlug = 'custom-collection-view'
export const reorderTabsSlug = 'reorder-tabs'
export const geoCollectionSlug = 'geo'
export const arrayCollectionSlug = 'array'
@@ -32,6 +33,7 @@ export const collectionSlugs = [
usersCollectionSlug,
customViews1CollectionSlug,
customViews2CollectionSlug,
+ customCollectionViewSlug,
geoCollectionSlug,
arrayCollectionSlug,
postsCollectionSlug,
From f089c8c8f1cf2b338afd50345ee4eae72d50eb36 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 29 Jan 2026 10:04:56 +0000
Subject: [PATCH 03/18] fix(next): fix TypeScript errors in custom collection
view helper
Co-authored-by: robinscholz <8195463+robinscholz@users.noreply.github.com>
---
.../Root/getCustomCollectionViewByRoute.ts | 24 +++++++++++++------
.../payload/src/collections/config/types.ts | 4 +++-
2 files changed, 20 insertions(+), 8 deletions(-)
diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
index 7a5132c4c7a..9e241c484f8 100644
--- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
+++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
@@ -1,4 +1,4 @@
-import type { SanitizedCollectionConfig } from 'payload'
+import type { AdminViewConfig, AdminViewServerProps, PayloadComponent, SanitizedCollectionConfig } from 'payload'
import type { ViewFromConfig } from './getRouteData.js'
@@ -25,15 +25,24 @@ export const getCustomCollectionViewByRoute = ({
return false
}
- if (typeof view === 'object' && 'path' in view) {
- const viewPath = `${baseRoute}${view.path}`
+ // Type guard: custom views should be AdminViewConfig with path and Component
+ const isAdminViewConfig =
+ typeof view === 'object' &&
+ view !== null &&
+ 'path' in view &&
+ 'Component' in view &&
+ typeof view.path === 'string'
+
+ if (isAdminViewConfig) {
+ const adminView = view as AdminViewConfig
+ const viewPath = `${baseRoute}${adminView.path}`
const isMatching = isPathMatchingRoute({
currentRoute,
- exact: view.exact,
+ exact: adminView.exact,
path: viewPath,
- sensitive: view.sensitive,
- strict: view.strict,
+ sensitive: adminView.sensitive,
+ strict: adminView.strict,
})
if (isMatching) {
@@ -47,9 +56,10 @@ export const getCustomCollectionViewByRoute = ({
})?.[1]
if (foundViewConfig && 'Component' in foundViewConfig) {
+ const adminView = foundViewConfig as AdminViewConfig
return {
view: {
- payloadComponent: foundViewConfig.Component,
+ payloadComponent: adminView.Component as PayloadComponent,
},
viewKey,
}
diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts
index 3fa93c5c74b..b087b416897 100644
--- a/packages/payload/src/collections/config/types.ts
+++ b/packages/payload/src/collections/config/types.ts
@@ -2,8 +2,8 @@
import type { GraphQLInputObjectType, GraphQLNonNull, GraphQLObjectType } from 'graphql'
import type { DeepRequired, IsAny, MarkOptional } from 'ts-essentials'
-import type { AdminViewConfig } from '../../admin/views/index.js'
import type { CustomStatus, CustomUpload, ViewTypes } from '../../admin/types.js'
+import type { AdminViewConfig } from '../../admin/views/index.js'
import type { Arguments as MeArguments } from '../../auth/operations/me.js'
import type {
Arguments as RefreshArguments,
@@ -389,11 +389,13 @@ export type CollectionAdminOptions = {
* Replace, modify, or add new "document" views.
* @link https://payloadcms.com/docs/custom-components/document-views
*/
+ // @ts-expect-error - edit allows more complex nested structure than AdminViewConfig
edit?: EditConfig
/**
* Replace or modify the "list" view.
* @link https://payloadcms.com/docs/custom-components/list-view
*/
+ // @ts-expect-error - list allows actions array which doesn't match AdminViewConfig
list?: {
actions?: CustomComponent[]
Component?: PayloadComponent
From 693be751811cf651e0685180a70d5f3b7a0a56f6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 29 Jan 2026 10:05:39 +0000
Subject: [PATCH 04/18] docs: update progress
Co-authored-by: robinscholz <8195463+robinscholz@users.noreply.github.com>
---
tsconfig.base.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 2df7499ba51..2e13e9eb8c0 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -32,7 +32,7 @@
],
"paths": {
"@payloadcms/figma": ["../enterprise-plugins/packages/figma/src/index.ts"],
- "@payload-config": ["./test/plugin-import-export/config.ts"],
+ "@payload-config": ["./test/_community/config.ts"],
"@payloadcms/admin-bar": ["./packages/admin-bar/src"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
From c7fbb3a84d31f83731abab436ac0d86b5ccbfb5c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 29 Jan 2026 10:16:29 +0000
Subject: [PATCH 05/18] fix(next): address code review feedback
- Add null check for views object
- Change viewKey return value from null to undefined for consistency
- Address code cleanliness issues
Co-authored-by: robinscholz <8195463+robinscholz@users.noreply.github.com>
---
.../Root/getCustomCollectionViewByRoute.ts | 11 +++++---
test/admin/payload-types.ts | 25 +++++++++++++++++++
2 files changed, 33 insertions(+), 3 deletions(-)
diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
index 9e241c484f8..8733adcc016 100644
--- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
+++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
@@ -1,4 +1,9 @@
-import type { AdminViewConfig, AdminViewServerProps, PayloadComponent, SanitizedCollectionConfig } from 'payload'
+import type {
+ AdminViewConfig,
+ AdminViewServerProps,
+ PayloadComponent,
+ SanitizedCollectionConfig,
+} from 'payload'
import type { ViewFromConfig } from './getRouteData.js'
@@ -16,7 +21,7 @@ export const getCustomCollectionViewByRoute = ({
view: ViewFromConfig
viewKey?: string
} => {
- if (typeof views === 'object') {
+ if (views && typeof views === 'object') {
let viewKey: string
const foundViewConfig = Object.entries(views).find(([key, view]) => {
@@ -70,6 +75,6 @@ export const getCustomCollectionViewByRoute = ({
view: {
Component: null,
},
- viewKey: null,
+ viewKey: undefined,
}
}
diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts
index e0953f269f3..bba3861dc5e 100644
--- a/test/admin/payload-types.ts
+++ b/test/admin/payload-types.ts
@@ -77,6 +77,7 @@ export interface Config {
'custom-document-controls': CustomDocumentControl;
'custom-views-one': CustomViewsOne;
'custom-views-two': CustomViewsTwo;
+ 'custom-collection-view': CustomCollectionView;
'reorder-tabs': ReorderTab;
'custom-fields': CustomField;
'group-one-collection-ones': GroupOneCollectionOne;
@@ -117,6 +118,7 @@ export interface Config {
'custom-document-controls': CustomDocumentControlsSelect | CustomDocumentControlsSelect;
'custom-views-one': CustomViewsOneSelect | CustomViewsOneSelect;
'custom-views-two': CustomViewsTwoSelect | CustomViewsTwoSelect;
+ 'custom-collection-view': CustomCollectionViewSelect | CustomCollectionViewSelect;
'reorder-tabs': ReorderTabsSelect | ReorderTabsSelect;
'custom-fields': CustomFieldsSelect | CustomFieldsSelect;
'group-one-collection-ones': GroupOneCollectionOnesSelect | GroupOneCollectionOnesSelect;
@@ -386,6 +388,16 @@ export interface CustomViewsTwo {
updatedAt: string;
createdAt: string;
}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "custom-collection-view".
+ */
+export interface CustomCollectionView {
+ id: string;
+ title?: string | null;
+ updatedAt: string;
+ createdAt: string;
+}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "reorder-tabs".
@@ -733,6 +745,10 @@ export interface PayloadLockedDocument {
relationTo: 'custom-views-two';
value: string | CustomViewsTwo;
} | null)
+ | ({
+ relationTo: 'custom-collection-view';
+ value: string | CustomCollectionView;
+ } | null)
| ({
relationTo: 'reorder-tabs';
value: string | ReorderTab;
@@ -1045,6 +1061,15 @@ export interface CustomViewsTwoSelect {
updatedAt?: T;
createdAt?: T;
}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "custom-collection-view_select".
+ */
+export interface CustomCollectionViewSelect {
+ title?: T;
+ updatedAt?: T;
+ createdAt?: T;
+}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "reorder-tabs_select".
From 88cfdb7870e1dd9f293ec8b5276f2b794199a3e4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 29 Jan 2026 10:34:59 +0000
Subject: [PATCH 06/18] fix(next): ensure folders take precedence over custom
collection views
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reordered the routing logic so that folder views are checked before custom collection views.
Order is now: trash → folders → custom views → edit views
Co-authored-by: robinscholz <8195463+robinscholz@users.noreply.github.com>
---
packages/next/src/views/Root/getRouteData.ts | 38 ++++++++++----------
1 file changed, 18 insertions(+), 20 deletions(-)
diff --git a/packages/next/src/views/Root/getRouteData.ts b/packages/next/src/views/Root/getRouteData.ts
index 30c93e6ffc4..2431ba4c57d 100644
--- a/packages/next/src/views/Root/getRouteData.ts
+++ b/packages/next/src/views/Root/getRouteData.ts
@@ -354,26 +354,7 @@ export const getRouteData = ({
viewActions.push(...(collectionConfig.admin.components?.views?.list?.actions || []))
} else {
- // Check for custom collection views before assuming it's an edit view
- const baseRoute = `/${segmentOne}/${segmentTwo}`
- const customCollectionView = getCustomCollectionViewByRoute({
- baseRoute,
- currentRoute,
- views: collectionConfig.admin.components?.views,
- })
-
- if (customCollectionView.viewKey && customCollectionView.view.payloadComponent) {
- // --> /collections/:collectionSlug/:customViewPath
- ViewToRender = customCollectionView.view
-
- templateClassName = `collection-${customCollectionView.viewKey}`
- templateType = 'default'
- viewType = customCollectionView.viewKey as ViewTypes
- } else if (
- config.folders &&
- segmentThree === config.folders.slug &&
- collectionConfig.folders
- ) {
+ if (config.folders && segmentThree === config.folders.slug && collectionConfig.folders) {
// Collection Folder Views
// --> /collections/:collectionSlug/:folderCollectionSlug
// --> /collections/:collectionSlug/:folderCollectionSlug/:folderID
@@ -390,6 +371,22 @@ export const getRouteData = ({
viewActions.push(...(collectionConfig.admin.components?.views?.list?.actions || []))
} else {
+ // Check for custom collection views before assuming it's an edit view
+ const baseRoute = `/${segmentOne}/${segmentTwo}`
+ const customCollectionView = getCustomCollectionViewByRoute({
+ baseRoute,
+ currentRoute,
+ views: collectionConfig.admin.components?.views,
+ })
+
+ if (customCollectionView.viewKey && customCollectionView.view.payloadComponent) {
+ // --> /collections/:collectionSlug/:customViewPath
+ ViewToRender = customCollectionView.view
+
+ templateClassName = `collection-${customCollectionView.viewKey}`
+ templateType = 'default'
+ viewType = customCollectionView.viewKey as ViewTypes
+ } else {
// Collection Edit Views
// --> /collections/:collectionSlug/create
// --> /collections/:collectionSlug/:id
@@ -416,6 +413,7 @@ export const getRouteData = ({
viewKeyArg: documentSubViewType,
}),
)
+ }
}
}
} else if (globalConfig) {
From 55bad886c76c6498488e1a03aa205bc913a37834 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 29 Jan 2026 11:03:32 +0000
Subject: [PATCH 07/18] fix(payload): resolve TypeScript error with collection
views type definition
Changed the index signature to use a union type that includes undefined, allowing it to be compatible with optional properties like edit and list.
This fixes the build error in @payloadcms/plugin-search and other packages.
Co-authored-by: robinscholz <8195463+robinscholz@users.noreply.github.com>
---
.../payload/src/collections/config/types.ts | 24 +++++++++++++++----
1 file changed, 20 insertions(+), 4 deletions(-)
diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts
index b087b416897..3b83090bbf7 100644
--- a/packages/payload/src/collections/config/types.ts
+++ b/packages/payload/src/collections/config/types.ts
@@ -383,19 +383,35 @@ export type CollectionAdminOptions = {
}
listMenuItems?: CustomComponent[]
views?: {
- /** Add custom collection views */
- [key: string]: AdminViewConfig
+ /**
+ * Add custom collection views.
+ * Any additional keys define custom collection views that are matched by path and rendered at the collection level.
+ * @link https://payloadcms.com/docs/custom-components/custom-views
+ * @example
+ * ```ts
+ * views: {
+ * grid: {
+ * Component: '/path/to/GridView',
+ * path: '/grid',
+ * exact: true,
+ * }
+ * }
+ * ```
+ */
+ [key: string]:
+ | { actions?: CustomComponent[]; Component?: PayloadComponent }
+ | AdminViewConfig
+ | EditConfig
+ | undefined
/**
* Replace, modify, or add new "document" views.
* @link https://payloadcms.com/docs/custom-components/document-views
*/
- // @ts-expect-error - edit allows more complex nested structure than AdminViewConfig
edit?: EditConfig
/**
* Replace or modify the "list" view.
* @link https://payloadcms.com/docs/custom-components/list-view
*/
- // @ts-expect-error - list allows actions array which doesn't match AdminViewConfig
list?: {
actions?: CustomComponent[]
Component?: PayloadComponent
From c7a02ca73475dc4a17643d7737d2fec3a3e4a142 Mon Sep 17 00:00:00 2001
From: Paul Popus
Date: Fri, 10 Apr 2026 18:40:25 +0100
Subject: [PATCH 08/18] update test coverage and fix some issues in config
---
.../getCustomCollectionViewByRoute.spec.ts | 153 ++++++++++++++++++
.../Root/getCustomCollectionViewByRoute.ts | 9 +-
packages/next/src/views/Root/getRouteData.ts | 55 +++----
packages/payload/src/admin/views/index.ts | 2 +-
.../payload/src/collections/config/types.ts | 1 -
packages/payload/src/config/types.ts | 2 +-
.../{index.js => index.tsx} | 0
7 files changed, 190 insertions(+), 32 deletions(-)
create mode 100644 packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts
rename test/admin/components/views/CustomCollectionView/{index.js => index.tsx} (100%)
diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts
new file mode 100644
index 00000000000..a8d71e2eee4
--- /dev/null
+++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts
@@ -0,0 +1,153 @@
+import type { SanitizedCollectionConfig } from 'payload'
+
+import { describe, expect, it } from 'vitest'
+
+import { getCustomCollectionViewByRoute } from './getCustomCollectionViewByRoute.js'
+
+type Views = SanitizedCollectionConfig['admin']['components']['views']
+
+const gridView: Views = {
+ grid: {
+ Component: '/components/views/GridView/index.js#GridView',
+ exact: true,
+ path: '/grid',
+ },
+}
+
+describe('getCustomCollectionViewByRoute', () => {
+ describe('route matching with default /admin prefix', () => {
+ it('should match a custom view at the correct path', () => {
+ const result = getCustomCollectionViewByRoute({
+ adminRoute: '/admin',
+ baseRoute: '/collections/my-collection',
+ currentRoute: '/admin/collections/my-collection/grid',
+ views: gridView,
+ })
+
+ expect(result.viewKey).toBe('grid')
+ expect(result.view.payloadComponent).toBeDefined()
+ })
+
+ it('should not match when the path segment does not correspond to any custom view', () => {
+ const result = getCustomCollectionViewByRoute({
+ adminRoute: '/admin',
+ baseRoute: '/collections/my-collection',
+ currentRoute: '/admin/collections/my-collection/abc123',
+ views: gridView,
+ })
+
+ expect(result.viewKey).toBeUndefined()
+ expect(result.view.payloadComponent).toBeUndefined()
+ })
+ })
+
+ describe('route matching with custom adminRoute prefix', () => {
+ it('should match when adminRoute is a non-default prefix', () => {
+ const result = getCustomCollectionViewByRoute({
+ adminRoute: '/cms',
+ baseRoute: '/collections/my-collection',
+ currentRoute: '/cms/collections/my-collection/grid',
+ views: gridView,
+ })
+
+ expect(result.viewKey).toBe('grid')
+ expect(result.view.payloadComponent).toBeDefined()
+ })
+
+ it('should match when adminRoute is /', () => {
+ const result = getCustomCollectionViewByRoute({
+ adminRoute: '/',
+ baseRoute: '/collections/my-collection',
+ currentRoute: '/collections/my-collection/grid',
+ views: gridView,
+ })
+
+ expect(result.viewKey).toBe('grid')
+ expect(result.view.payloadComponent).toBeDefined()
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should return no match when views is undefined', () => {
+ const result = getCustomCollectionViewByRoute({
+ adminRoute: '/admin',
+ baseRoute: '/collections/my-collection',
+ currentRoute: '/admin/collections/my-collection/grid',
+ views: undefined,
+ })
+
+ expect(result.viewKey).toBeUndefined()
+ expect(result.view.payloadComponent).toBeUndefined()
+ })
+
+ it('should not match built-in "edit" or "list" keys', () => {
+ const viewsWithBuiltins: Views = {
+ edit: {
+ default: { Component: '/components/views/Edit/index.js#EditView' },
+ },
+ list: {
+ Component: '/components/views/List/index.js#ListView',
+ },
+ }
+
+ const result = getCustomCollectionViewByRoute({
+ adminRoute: '/admin',
+ baseRoute: '/collections/my-collection',
+ currentRoute: '/admin/collections/my-collection/edit',
+ views: viewsWithBuiltins,
+ })
+
+ expect(result.viewKey).toBeUndefined()
+ })
+
+ it('should not match a custom view that has no path defined', () => {
+ const viewsWithNoPath: Views = {
+ grid: {
+ Component: '/components/views/GridView/index.js#GridView',
+ } as any,
+ }
+
+ const result = getCustomCollectionViewByRoute({
+ adminRoute: '/admin',
+ baseRoute: '/collections/my-collection',
+ currentRoute: '/admin/collections/my-collection/grid',
+ views: viewsWithNoPath,
+ })
+
+ expect(result.viewKey).toBeUndefined()
+ })
+
+ it('should match the correct view when multiple custom views are defined', () => {
+ const multipleViews: Views = {
+ grid: {
+ Component: '/components/views/GridView/index.js#GridView',
+ exact: true,
+ path: '/grid',
+ },
+ map: {
+ Component: '/components/views/MapView/index.js#MapView',
+ exact: true,
+ path: '/map',
+ },
+ }
+
+ const gridResult = getCustomCollectionViewByRoute({
+ adminRoute: '/admin',
+ baseRoute: '/collections/my-collection',
+ currentRoute: '/admin/collections/my-collection/grid',
+ views: multipleViews,
+ })
+
+ expect(gridResult.viewKey).toBe('grid')
+
+ const mapResult = getCustomCollectionViewByRoute({
+ adminRoute: '/admin',
+ baseRoute: '/collections/my-collection',
+ currentRoute: '/admin/collections/my-collection/map',
+ views: multipleViews,
+ })
+
+ expect(mapResult.viewKey).toBe('map')
+ })
+ })
+})
diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
index 8733adcc016..52a0d4898e3 100644
--- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
+++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
@@ -10,10 +10,12 @@ import type { ViewFromConfig } from './getRouteData.js'
import { isPathMatchingRoute } from './isPathMatchingRoute.js'
export const getCustomCollectionViewByRoute = ({
+ adminRoute,
baseRoute,
- currentRoute,
+ currentRoute: currentRouteWithAdmin,
views,
}: {
+ adminRoute: string
baseRoute: string
currentRoute: string
views: SanitizedCollectionConfig['admin']['components']['views']
@@ -21,6 +23,9 @@ export const getCustomCollectionViewByRoute = ({
view: ViewFromConfig
viewKey?: string
} => {
+ const currentRoute =
+ adminRoute === '/' ? currentRouteWithAdmin : currentRouteWithAdmin.replace(adminRoute, '')
+
if (views && typeof views === 'object') {
let viewKey: string
@@ -60,7 +65,7 @@ export const getCustomCollectionViewByRoute = ({
return false
})?.[1]
- if (foundViewConfig && 'Component' in foundViewConfig) {
+ if (foundViewConfig) {
const adminView = foundViewConfig as AdminViewConfig
return {
view: {
diff --git a/packages/next/src/views/Root/getRouteData.ts b/packages/next/src/views/Root/getRouteData.ts
index 2431ba4c57d..1160a20270a 100644
--- a/packages/next/src/views/Root/getRouteData.ts
+++ b/packages/next/src/views/Root/getRouteData.ts
@@ -84,7 +84,7 @@ type GetRouteDataResult = {
templateClassName: string
templateType: 'default' | 'minimal'
viewActions?: CustomComponent[]
- viewType?: ViewTypes
+ viewType?: string | ViewTypes
}
type GetRouteDataArgs = {
@@ -121,7 +121,7 @@ export const getRouteData = ({
let templateClassName: string
let templateType: 'default' | 'minimal' | undefined
let documentSubViewType: DocumentSubViewTypes
- let viewType: ViewTypes
+ let viewType: string | ViewTypes
const routeParams: GetRouteDataResult['routeParams'] = {}
const [segmentOne, segmentTwo, segmentThree, segmentFour, segmentFive, segmentSix] = segments
@@ -374,6 +374,7 @@ export const getRouteData = ({
// Check for custom collection views before assuming it's an edit view
const baseRoute = `/${segmentOne}/${segmentTwo}`
const customCollectionView = getCustomCollectionViewByRoute({
+ adminRoute,
baseRoute,
currentRoute,
views: collectionConfig.admin.components?.views,
@@ -385,34 +386,34 @@ export const getRouteData = ({
templateClassName = `collection-${customCollectionView.viewKey}`
templateType = 'default'
- viewType = customCollectionView.viewKey as ViewTypes
+ viewType = customCollectionView.viewKey
} else {
- // Collection Edit Views
- // --> /collections/:collectionSlug/create
- // --> /collections/:collectionSlug/:id
- // --> /collections/:collectionSlug/:id/api
- // --> /collections/:collectionSlug/:id/versions
- // --> /collections/:collectionSlug/:id/versions/:versionID
- routeParams.id = segmentThree === 'create' ? undefined : segmentThree
- routeParams.versionID = segmentFive
-
- ViewToRender = {
- Component: DocumentView,
- }
-
- templateClassName = `collection-default-edit`
- templateType = 'default'
+ // Collection Edit Views
+ // --> /collections/:collectionSlug/create
+ // --> /collections/:collectionSlug/:id
+ // --> /collections/:collectionSlug/:id/api
+ // --> /collections/:collectionSlug/:id/versions
+ // --> /collections/:collectionSlug/:id/versions/:versionID
+ routeParams.id = segmentThree === 'create' ? undefined : segmentThree
+ routeParams.versionID = segmentFive
+
+ ViewToRender = {
+ Component: DocumentView,
+ }
+
+ templateClassName = `collection-default-edit`
+ templateType = 'default'
- const viewInfo = getDocumentViewInfo([segmentFour, segmentFive])
- viewType = viewInfo.viewType
- documentSubViewType = viewInfo.documentSubViewType
+ const viewInfo = getDocumentViewInfo([segmentFour, segmentFive])
+ viewType = viewInfo.viewType
+ documentSubViewType = viewInfo.documentSubViewType
- viewActions.push(
- ...getSubViewActions({
- collectionOrGlobal: collectionConfig,
- viewKeyArg: documentSubViewType,
- }),
- )
+ viewActions.push(
+ ...getSubViewActions({
+ collectionOrGlobal: collectionConfig,
+ viewKeyArg: documentSubViewType,
+ }),
+ )
}
}
}
diff --git a/packages/payload/src/admin/views/index.ts b/packages/payload/src/admin/views/index.ts
index 028abc29e0e..96e91080b28 100644
--- a/packages/payload/src/admin/views/index.ts
+++ b/packages/payload/src/admin/views/index.ts
@@ -35,7 +35,7 @@ export type AdminViewClientProps = {
browseByFolderSlugs?: SanitizedCollectionConfig['slug'][]
clientConfig: ClientConfig
documentSubViewType?: DocumentSubViewTypes
- viewType: ViewTypes
+ viewType: string | ViewTypes
}
export type AdminViewServerPropsOnly = {
diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts
index dee1a217b8e..1e54b5a50ab 100644
--- a/packages/payload/src/collections/config/types.ts
+++ b/packages/payload/src/collections/config/types.ts
@@ -461,7 +461,6 @@ export type CollectionAdminOptions = {
[key: string]:
| { actions?: CustomComponent[]; Component?: PayloadComponent }
| AdminViewConfig
- | EditConfig
| undefined
/**
* Replace, modify, or add new "document" views.
diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts
index b31bf29fdb8..f3852ba0523 100644
--- a/packages/payload/src/config/types.ts
+++ b/packages/payload/src/config/types.ts
@@ -425,7 +425,7 @@ export type ServerProps = {
readonly permissions?: SanitizedPermissions
readonly searchParams?: Params
readonly user?: TypedUser
- readonly viewType?: ViewTypes
+ readonly viewType?: string | ViewTypes
readonly visibleEntities?: VisibleEntities
}
diff --git a/test/admin/components/views/CustomCollectionView/index.js b/test/admin/components/views/CustomCollectionView/index.tsx
similarity index 100%
rename from test/admin/components/views/CustomCollectionView/index.js
rename to test/admin/components/views/CustomCollectionView/index.tsx
From 614fc11a4a989f0b5d88d41abede9f2d2931f32f Mon Sep 17 00:00:00 2001
From: Paul Popus
Date: Fri, 10 Apr 2026 19:37:22 +0100
Subject: [PATCH 09/18] added tests
---
.../src/collections/config/sanitize.spec.ts | 109 ++++++++++++++++++
.../src/collections/config/sanitize.ts | 25 ++++
test/folders/collections/Media/index.ts | 11 ++
.../components/ConflictingView/index.tsx | 5 +
test/folders/e2e.spec.ts | 18 +++
5 files changed, 168 insertions(+)
create mode 100644 packages/payload/src/collections/config/sanitize.spec.ts
create mode 100644 test/folders/components/ConflictingView/index.tsx
diff --git a/packages/payload/src/collections/config/sanitize.spec.ts b/packages/payload/src/collections/config/sanitize.spec.ts
new file mode 100644
index 00000000000..2d2e199cf0e
--- /dev/null
+++ b/packages/payload/src/collections/config/sanitize.spec.ts
@@ -0,0 +1,109 @@
+import type { CollectionConfig } from './types.js'
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { warnOnInvalidCustomViews } from './sanitize.js'
+
+describe('warnOnInvalidCustomViews', () => {
+ let warnSpy: ReturnType
+
+ beforeEach(() => {
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ warnSpy.mockRestore()
+ })
+
+ it('should warn when a custom view is missing path', () => {
+ const collection: CollectionConfig = {
+ slug: 'my-collection',
+ fields: [],
+ admin: {
+ components: {
+ views: {
+ grid: {
+ Component: '/components/GridView/index.js#GridView',
+ } as any,
+ },
+ },
+ },
+ }
+
+ warnOnInvalidCustomViews(collection)
+
+ expect(warnSpy).toHaveBeenCalledOnce()
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"grid"'))
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"my-collection"'))
+ })
+
+ it('should not warn when a custom view has a path', () => {
+ const collection: CollectionConfig = {
+ slug: 'my-collection',
+ fields: [],
+ admin: {
+ components: {
+ views: {
+ grid: {
+ Component: '/components/GridView/index.js#GridView',
+ path: '/grid',
+ },
+ },
+ },
+ },
+ }
+
+ warnOnInvalidCustomViews(collection)
+
+ expect(warnSpy).not.toHaveBeenCalled()
+ })
+
+ it('should not warn for built-in "edit" or "list" keys even without path', () => {
+ const collection: CollectionConfig = {
+ slug: 'my-collection',
+ fields: [],
+ admin: {
+ components: {
+ views: {
+ edit: { default: { Component: '/components/Edit/index.js#Edit' } },
+ list: { Component: '/components/List/index.js#List' },
+ },
+ },
+ },
+ }
+
+ warnOnInvalidCustomViews(collection)
+
+ expect(warnSpy).not.toHaveBeenCalled()
+ })
+
+ it('should warn for each custom view missing path independently', () => {
+ const collection: CollectionConfig = {
+ slug: 'my-collection',
+ fields: [],
+ admin: {
+ components: {
+ views: {
+ grid: { Component: '/components/GridView/index.js#GridView' } as any,
+ map: { Component: '/components/MapView/index.js#MapView' } as any,
+ },
+ },
+ },
+ }
+
+ warnOnInvalidCustomViews(collection)
+
+ expect(warnSpy).toHaveBeenCalledTimes(2)
+ })
+
+ it('should not warn when views is undefined', () => {
+ const collection: CollectionConfig = {
+ slug: 'my-collection',
+ fields: [],
+ }
+
+ warnOnInvalidCustomViews(collection)
+
+ expect(warnSpy).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts
index 77152325344..171a3e25c02 100644
--- a/packages/payload/src/collections/config/sanitize.ts
+++ b/packages/payload/src/collections/config/sanitize.ts
@@ -30,6 +30,29 @@ import {
import { sanitizeCompoundIndexes } from './sanitizeCompoundIndexes.js'
import { validateUseAsTitle } from './useAsTitle.js'
+/**
+ * Warns at startup when custom collection views are misconfigured with a missing `path`.
+ * Views without `path` will never be matched by the router and are silently ignored.
+ */
+export const warnOnInvalidCustomViews = (collection: CollectionConfig): void => {
+ const views = collection.admin?.components?.views
+ if (!views || typeof views !== 'object') {
+ return
+ }
+
+ for (const [key, view] of Object.entries(views)) {
+ if (key === 'edit' || key === 'list') {
+ continue
+ }
+
+ if (view && typeof view === 'object' && 'Component' in view && !('path' in view)) {
+ console.warn(
+ `[Payload] Custom collection view "${key}" in collection "${collection.slug}" is missing a "path" property. The view will never be rendered.`,
+ )
+ }
+ }
+}
+
export const sanitizeCollection = async (
config: Config,
collection: CollectionConfig,
@@ -50,6 +73,8 @@ export const sanitizeCollection = async (
collection._sanitized = true
+ warnOnInvalidCustomViews(collection)
+
// /////////////////////////////////
// Make copy of collection config
// /////////////////////////////////
diff --git a/test/folders/collections/Media/index.ts b/test/folders/collections/Media/index.ts
index b6306375ef6..4fa187d35a4 100644
--- a/test/folders/collections/Media/index.ts
+++ b/test/folders/collections/Media/index.ts
@@ -11,6 +11,17 @@ export const Media: CollectionConfig = {
},
},
folders: true,
+ admin: {
+ components: {
+ views: {
+ conflictingView: {
+ Component: '/components/ConflictingView/index.js#ConflictingView',
+ exact: true,
+ path: '/payload-folders',
+ },
+ },
+ },
+ },
fields: [
{
name: 'testAdminThumbnail',
diff --git a/test/folders/components/ConflictingView/index.tsx b/test/folders/components/ConflictingView/index.tsx
new file mode 100644
index 00000000000..ed607707dad
--- /dev/null
+++ b/test/folders/components/ConflictingView/index.tsx
@@ -0,0 +1,5 @@
+import React from 'react'
+
+export function ConflictingView() {
+ return Conflicting Custom View
+}
diff --git a/test/folders/e2e.spec.ts b/test/folders/e2e.spec.ts
index a3474468740..e63bda25f86 100644
--- a/test/folders/e2e.spec.ts
+++ b/test/folders/e2e.spec.ts
@@ -798,6 +798,24 @@ test.describe('Folders', () => {
}
})
+test.describe('custom view / folder routing precedence', () => {
+ test('should render folder list view, not conflicting custom view, at the folders slug route', async () => {
+ await page.goto(
+ formatAdminURL({
+ adminRoute,
+ path: '/collections/media/payload-folders',
+ serverURL,
+ }),
+ )
+
+ // The folder list view renders a create-folder button (even with no folders seeded)
+ await expect(page.locator('.create-new-doc-in-folder__button').first()).toBeVisible()
+
+ // The conflicting custom view must NOT be rendered
+ await expect(page.locator('[data-testid="conflicting-custom-view"]')).not.toBeVisible()
+ })
+})
+
// tests to write
// ------ NICE TO HAVE -------
// - check copy is correct in the confirm modal and toast notifications when moving docs / folders
From 8b24e3897b61fd937db1c4e7a181f83b5fedf2c4 Mon Sep 17 00:00:00 2001
From: Paul Popus
Date: Fri, 10 Apr 2026 19:37:50 +0100
Subject: [PATCH 10/18] fix linting on test assertion
---
test/folders/e2e.spec.ts | 22 +++++++++++-----------
1 file changed, 11 insertions(+), 11 deletions(-)
diff --git a/test/folders/e2e.spec.ts b/test/folders/e2e.spec.ts
index e63bda25f86..9d52c0854ef 100644
--- a/test/folders/e2e.spec.ts
+++ b/test/folders/e2e.spec.ts
@@ -5,6 +5,14 @@ import * as path from 'path'
import { formatAdminURL } from 'payload/shared'
import { fileURLToPath } from 'url'
+import { applyBrowseByFolderTypeFilter } from '../__helpers/e2e/folders/applyBrowseByFolderTypeFilter.js'
+import { clickFolderCard } from '../__helpers/e2e/folders/clickFolderCard.js'
+import { createFolder } from '../__helpers/e2e/folders/createFolder.js'
+import { createFolderDoc } from '../__helpers/e2e/folders/createFolderDoc.js'
+import { createFolderFromDoc } from '../__helpers/e2e/folders/createFolderFromDoc.js'
+import { expectNoResultsAndCreateFolderButton } from '../__helpers/e2e/folders/expectNoResultsAndCreateFolderButton.js'
+import { selectFolderAndConfirmMove } from '../__helpers/e2e/folders/selectFolderAndConfirmMove.js'
+import { selectFolderAndConfirmMoveFromList } from '../__helpers/e2e/folders/selectFolderAndConfirmMoveFromList.js'
import {
closeAllToasts,
ensureCompilationIsDone,
@@ -12,22 +20,14 @@ import {
initPageConsoleErrorCatch,
saveDocAndAssert,
} from '../__helpers/e2e/helpers.js'
-import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js'
import {
getSelectInputOptions,
getSelectInputValue,
openSelectMenu,
} from '../__helpers/e2e/selectInput.js'
-import { applyBrowseByFolderTypeFilter } from '../__helpers/e2e/folders/applyBrowseByFolderTypeFilter.js'
-import { clickFolderCard } from '../__helpers/e2e/folders/clickFolderCard.js'
-import { createFolder } from '../__helpers/e2e/folders/createFolder.js'
-import { createFolderDoc } from '../__helpers/e2e/folders/createFolderDoc.js'
-import { createFolderFromDoc } from '../__helpers/e2e/folders/createFolderFromDoc.js'
-import { expectNoResultsAndCreateFolderButton } from '../__helpers/e2e/folders/expectNoResultsAndCreateFolderButton.js'
-import { selectFolderAndConfirmMove } from '../__helpers/e2e/folders/selectFolderAndConfirmMove.js'
-import { selectFolderAndConfirmMoveFromList } from '../__helpers/e2e/folders/selectFolderAndConfirmMoveFromList.js'
-import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js'
+import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js'
import { reInitializeDB } from '../__helpers/shared/clearAndSeed/reInitializeDB.js'
+import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { omittedFromBrowseBySlug, postSlug } from './shared.js'
@@ -812,7 +812,7 @@ test.describe('custom view / folder routing precedence', () => {
await expect(page.locator('.create-new-doc-in-folder__button').first()).toBeVisible()
// The conflicting custom view must NOT be rendered
- await expect(page.locator('[data-testid="conflicting-custom-view"]')).not.toBeVisible()
+ await expect(page.locator('[data-testid="conflicting-custom-view"]')).toBeHidden()
})
})
From 13274f3819ca8fb3e849782d3a19c7153fe27663 Mon Sep 17 00:00:00 2001
From: Paul Popus
Date: Sat, 11 Apr 2026 21:31:05 +0100
Subject: [PATCH 11/18] fix builds
---
packages/payload/src/admin/views/index.ts | 3 ++-
packages/payload/src/collections/config/types.ts | 2 ++
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/packages/payload/src/admin/views/index.ts b/packages/payload/src/admin/views/index.ts
index 96e91080b28..be9a9d0560f 100644
--- a/packages/payload/src/admin/views/index.ts
+++ b/packages/payload/src/admin/views/index.ts
@@ -35,7 +35,7 @@ export type AdminViewClientProps = {
browseByFolderSlugs?: SanitizedCollectionConfig['slug'][]
clientConfig: ClientConfig
documentSubViewType?: DocumentSubViewTypes
- viewType: string | ViewTypes
+ viewType: ViewTypes
}
export type AdminViewServerPropsOnly = {
@@ -100,6 +100,7 @@ export type ViewTypes =
| 'trash'
| 'verify'
| 'version'
+ | ({} & string)
export type ServerPropsFromView = {
collectionConfig?: SanitizedConfig['collections'][number]
diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts
index 1e54b5a50ab..128ee802901 100644
--- a/packages/payload/src/collections/config/types.ts
+++ b/packages/payload/src/collections/config/types.ts
@@ -3,6 +3,7 @@ import type { GraphQLInputObjectType, GraphQLNonNull, GraphQLObjectType } from '
import type { DeepRequired, IsAny, MarkOptional } from 'ts-essentials'
import type {
+ AdminViewConfig,
CustomStatus,
CustomUpload,
PublishButtonClientProps,
@@ -461,6 +462,7 @@ export type CollectionAdminOptions = {
[key: string]:
| { actions?: CustomComponent[]; Component?: PayloadComponent }
| AdminViewConfig
+ | EditConfig
| undefined
/**
* Replace, modify, or add new "document" views.
From 8ad157b637353769562fc92ffa46a190a3a11dd7 Mon Sep 17 00:00:00 2001
From: Paul Popus
Date: Sun, 12 Apr 2026 21:34:59 +0100
Subject: [PATCH 12/18] updates
---
.../Root/getCustomCollectionViewByRoute.ts | 17 +++++------------
.../bin/generateImportMap/iterateCollections.ts | 13 +++++++++++++
.../payload/src/collections/config/sanitize.ts | 13 +++++++++++++
packages/payload/src/config/types.ts | 2 +-
4 files changed, 32 insertions(+), 13 deletions(-)
diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
index 52a0d4898e3..6bf44ce5c6d 100644
--- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
+++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
@@ -27,9 +27,7 @@ export const getCustomCollectionViewByRoute = ({
adminRoute === '/' ? currentRouteWithAdmin : currentRouteWithAdmin.replace(adminRoute, '')
if (views && typeof views === 'object') {
- let viewKey: string
-
- const foundViewConfig = Object.entries(views).find(([key, view]) => {
+ const foundEntry = Object.entries(views).find(([key, view]) => {
// Skip the known collection view types: edit and list
if (key === 'edit' || key === 'list') {
return false
@@ -47,25 +45,20 @@ export const getCustomCollectionViewByRoute = ({
const adminView = view as AdminViewConfig
const viewPath = `${baseRoute}${adminView.path}`
- const isMatching = isPathMatchingRoute({
+ return isPathMatchingRoute({
currentRoute,
exact: adminView.exact,
path: viewPath,
sensitive: adminView.sensitive,
strict: adminView.strict,
})
-
- if (isMatching) {
- viewKey = key
- }
-
- return isMatching
}
return false
- })?.[1]
+ })
- if (foundViewConfig) {
+ if (foundEntry) {
+ const [viewKey, foundViewConfig] = foundEntry
const adminView = foundViewConfig as AdminViewConfig
return {
view: {
diff --git a/packages/payload/src/bin/generateImportMap/iterateCollections.ts b/packages/payload/src/bin/generateImportMap/iterateCollections.ts
index 28501f878ec..99749cd876c 100644
--- a/packages/payload/src/bin/generateImportMap/iterateCollections.ts
+++ b/packages/payload/src/bin/generateImportMap/iterateCollections.ts
@@ -1,3 +1,4 @@
+import type { AdminViewConfig } from '../../admin/views/index.js'
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
import type { SanitizedConfig } from '../../config/types.js'
import type { AddToImportMap, Imports, InternalImportMap } from './index.js'
@@ -69,5 +70,17 @@ export function iterateCollections({
addToImportMap(collection.admin?.components?.views?.list?.Component)
addToImportMap(collection.admin?.components?.views?.list?.actions)
+
+ // Register custom collection view components (any key other than 'edit' and 'list')
+ if (collection.admin?.components?.views) {
+ for (const [key, view] of Object.entries(collection.admin.components.views)) {
+ if (key === 'edit' || key === 'list') {
+ continue
+ }
+ if (view && typeof view === 'object' && 'Component' in view && 'path' in view) {
+ addToImportMap((view as AdminViewConfig).Component)
+ }
+ }
+ }
}
}
diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts
index 171a3e25c02..3ead521e80b 100644
--- a/packages/payload/src/collections/config/sanitize.ts
+++ b/packages/payload/src/collections/config/sanitize.ts
@@ -50,6 +50,19 @@ export const warnOnInvalidCustomViews = (collection: CollectionConfig): void =>
`[Payload] Custom collection view "${key}" in collection "${collection.slug}" is missing a "path" property. The view will never be rendered.`,
)
}
+
+ const reservedPaths = ['/create', '/trash']
+ if (
+ view &&
+ typeof view === 'object' &&
+ 'path' in view &&
+ typeof view.path === 'string' &&
+ reservedPaths.includes(view.path)
+ ) {
+ console.warn(
+ `[Payload] Custom collection view "${key}" in collection "${collection.slug}" uses the reserved path "${view.path}". This will shadow the built-in route and may break document creation or trash functionality.`,
+ )
+ }
}
}
diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts
index f3852ba0523..b31bf29fdb8 100644
--- a/packages/payload/src/config/types.ts
+++ b/packages/payload/src/config/types.ts
@@ -425,7 +425,7 @@ export type ServerProps = {
readonly permissions?: SanitizedPermissions
readonly searchParams?: Params
readonly user?: TypedUser
- readonly viewType?: string | ViewTypes
+ readonly viewType?: ViewTypes
readonly visibleEntities?: VisibleEntities
}
From c8c279d3ccabeb6b10eeb93a3b2b68d2423c5323 Mon Sep 17 00:00:00 2001
From: Paul Popus
Date: Mon, 13 Apr 2026 00:46:37 +0100
Subject: [PATCH 13/18] remove reservedPaths
---
.../getCustomCollectionViewByRoute.spec.ts | 12 +++++-----
.../Root/getCustomCollectionViewByRoute.ts | 4 ++--
packages/payload/src/admin/views/index.ts | 3 +--
.../src/collections/config/sanitize.spec.ts | 23 +++++++++++++++++++
.../src/collections/config/sanitize.ts | 11 ++-------
5 files changed, 34 insertions(+), 19 deletions(-)
diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts
index a8d71e2eee4..9e029436b38 100644
--- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts
+++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts
@@ -1,4 +1,4 @@
-import type { SanitizedCollectionConfig } from 'payload'
+import type { AdminViewConfig, SanitizedCollectionConfig } from 'payload'
import { describe, expect, it } from 'vitest'
@@ -36,7 +36,7 @@ describe('getCustomCollectionViewByRoute', () => {
views: gridView,
})
- expect(result.viewKey).toBeUndefined()
+ expect(result.viewKey).toBeNull()
expect(result.view.payloadComponent).toBeUndefined()
})
})
@@ -76,7 +76,7 @@ describe('getCustomCollectionViewByRoute', () => {
views: undefined,
})
- expect(result.viewKey).toBeUndefined()
+ expect(result.viewKey).toBeNull()
expect(result.view.payloadComponent).toBeUndefined()
})
@@ -97,14 +97,14 @@ describe('getCustomCollectionViewByRoute', () => {
views: viewsWithBuiltins,
})
- expect(result.viewKey).toBeUndefined()
+ expect(result.viewKey).toBeNull()
})
it('should not match a custom view that has no path defined', () => {
const viewsWithNoPath: Views = {
grid: {
Component: '/components/views/GridView/index.js#GridView',
- } as any,
+ } as unknown as AdminViewConfig,
}
const result = getCustomCollectionViewByRoute({
@@ -114,7 +114,7 @@ describe('getCustomCollectionViewByRoute', () => {
views: viewsWithNoPath,
})
- expect(result.viewKey).toBeUndefined()
+ expect(result.viewKey).toBeNull()
})
it('should match the correct view when multiple custom views are defined', () => {
diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
index 6bf44ce5c6d..58effccef3b 100644
--- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
+++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
@@ -21,7 +21,7 @@ export const getCustomCollectionViewByRoute = ({
views: SanitizedCollectionConfig['admin']['components']['views']
}): {
view: ViewFromConfig
- viewKey?: string
+ viewKey: null | string
} => {
const currentRoute =
adminRoute === '/' ? currentRouteWithAdmin : currentRouteWithAdmin.replace(adminRoute, '')
@@ -73,6 +73,6 @@ export const getCustomCollectionViewByRoute = ({
view: {
Component: null,
},
- viewKey: undefined,
+ viewKey: null,
}
}
diff --git a/packages/payload/src/admin/views/index.ts b/packages/payload/src/admin/views/index.ts
index be9a9d0560f..ed82f8e6103 100644
--- a/packages/payload/src/admin/views/index.ts
+++ b/packages/payload/src/admin/views/index.ts
@@ -35,7 +35,7 @@ export type AdminViewClientProps = {
browseByFolderSlugs?: SanitizedCollectionConfig['slug'][]
clientConfig: ClientConfig
documentSubViewType?: DocumentSubViewTypes
- viewType: ViewTypes
+ viewType: string
}
export type AdminViewServerPropsOnly = {
@@ -100,7 +100,6 @@ export type ViewTypes =
| 'trash'
| 'verify'
| 'version'
- | ({} & string)
export type ServerPropsFromView = {
collectionConfig?: SanitizedConfig['collections'][number]
diff --git a/packages/payload/src/collections/config/sanitize.spec.ts b/packages/payload/src/collections/config/sanitize.spec.ts
index 2d2e199cf0e..20238f3c4e0 100644
--- a/packages/payload/src/collections/config/sanitize.spec.ts
+++ b/packages/payload/src/collections/config/sanitize.spec.ts
@@ -96,6 +96,29 @@ describe('warnOnInvalidCustomViews', () => {
expect(warnSpy).toHaveBeenCalledTimes(2)
})
+ it('should warn when a custom view has a path but is missing Component', () => {
+ const collection: CollectionConfig = {
+ slug: 'my-collection',
+ fields: [],
+ admin: {
+ components: {
+ views: {
+ grid: {
+ path: '/grid',
+ } as any,
+ },
+ },
+ },
+ }
+
+ warnOnInvalidCustomViews(collection)
+
+ expect(warnSpy).toHaveBeenCalledOnce()
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"grid"'))
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"my-collection"'))
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"Component"'))
+ })
+
it('should not warn when views is undefined', () => {
const collection: CollectionConfig = {
slug: 'my-collection',
diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts
index 3ead521e80b..4c980ec1f56 100644
--- a/packages/payload/src/collections/config/sanitize.ts
+++ b/packages/payload/src/collections/config/sanitize.ts
@@ -51,16 +51,9 @@ export const warnOnInvalidCustomViews = (collection: CollectionConfig): void =>
)
}
- const reservedPaths = ['/create', '/trash']
- if (
- view &&
- typeof view === 'object' &&
- 'path' in view &&
- typeof view.path === 'string' &&
- reservedPaths.includes(view.path)
- ) {
+ if (view && typeof view === 'object' && 'path' in view && !('Component' in view)) {
console.warn(
- `[Payload] Custom collection view "${key}" in collection "${collection.slug}" uses the reserved path "${view.path}". This will shadow the built-in route and may break document creation or trash functionality.`,
+ `[Payload] Custom collection view "${key}" in collection "${collection.slug}" has a "path" but is missing a "Component". The view will never be rendered.`,
)
}
}
From 9af48ee75d499b3f36d6bba57218d49f1e2d86c9 Mon Sep 17 00:00:00 2001
From: Paul Popus
Date: Mon, 13 Apr 2026 00:47:57 +0100
Subject: [PATCH 14/18] e2e
---
test/folders/e2e.spec.ts | 32 ++++++++++++++++----------------
1 file changed, 16 insertions(+), 16 deletions(-)
diff --git a/test/folders/e2e.spec.ts b/test/folders/e2e.spec.ts
index 9d52c0854ef..67542f1c15d 100644
--- a/test/folders/e2e.spec.ts
+++ b/test/folders/e2e.spec.ts
@@ -796,23 +796,23 @@ test.describe('Folders', () => {
.filter({ hasText: 'Select' })
await selectButton.click()
}
-})
-test.describe('custom view / folder routing precedence', () => {
- test('should render folder list view, not conflicting custom view, at the folders slug route', async () => {
- await page.goto(
- formatAdminURL({
- adminRoute,
- path: '/collections/media/payload-folders',
- serverURL,
- }),
- )
-
- // The folder list view renders a create-folder button (even with no folders seeded)
- await expect(page.locator('.create-new-doc-in-folder__button').first()).toBeVisible()
-
- // The conflicting custom view must NOT be rendered
- await expect(page.locator('[data-testid="conflicting-custom-view"]')).toBeHidden()
+ test.describe('custom view / folder routing precedence', () => {
+ test('should render folder list view, not conflicting custom view, at the folders slug route', async () => {
+ await page.goto(
+ formatAdminURL({
+ adminRoute,
+ path: '/collections/media/payload-folders',
+ serverURL,
+ }),
+ )
+
+ // The folder list view renders a create-folder button (even with no folders seeded)
+ await expect(page.locator('.create-new-doc-in-folder__button').first()).toBeVisible()
+
+ // The conflicting custom view must NOT be rendered
+ await expect(page.locator('[data-testid="conflicting-custom-view"]')).toBeHidden()
+ })
})
})
From 75099cd4e2979590ae9c9744c433cf3b8a83c9f6 Mon Sep 17 00:00:00 2001
From: Paul Popus
Date: Mon, 13 Apr 2026 01:59:24 +0100
Subject: [PATCH 15/18] re-add generic string union
---
packages/payload/src/admin/views/index.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/payload/src/admin/views/index.ts b/packages/payload/src/admin/views/index.ts
index ed82f8e6103..be9a9d0560f 100644
--- a/packages/payload/src/admin/views/index.ts
+++ b/packages/payload/src/admin/views/index.ts
@@ -35,7 +35,7 @@ export type AdminViewClientProps = {
browseByFolderSlugs?: SanitizedCollectionConfig['slug'][]
clientConfig: ClientConfig
documentSubViewType?: DocumentSubViewTypes
- viewType: string
+ viewType: ViewTypes
}
export type AdminViewServerPropsOnly = {
@@ -100,6 +100,7 @@ export type ViewTypes =
| 'trash'
| 'verify'
| 'version'
+ | ({} & string)
export type ServerPropsFromView = {
collectionConfig?: SanitizedConfig['collections'][number]
From 26cf6a66dff28fc92c64d079bec00c336dcdbed6 Mon Sep 17 00:00:00 2001
From: Paul Popus
Date: Mon, 13 Apr 2026 16:40:49 +0100
Subject: [PATCH 16/18] address some comments
---
.../getCustomCollectionViewByRoute.spec.ts | 46 +++++++++++++++++++
.../Root/getCustomCollectionViewByRoute.ts | 6 ++-
.../src/views/Root/isPathMatchingRoute.ts | 2 +-
3 files changed, 52 insertions(+), 2 deletions(-)
diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts
index 9e029436b38..ff54067293f 100644
--- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts
+++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts
@@ -14,6 +14,14 @@ const gridView: Views = {
},
}
+const gridViewPrefixMatch: Views = {
+ grid: {
+ Component: '/components/views/GridView/index.js#GridView',
+ exact: false,
+ path: '/grid',
+ },
+}
+
describe('getCustomCollectionViewByRoute', () => {
describe('route matching with default /admin prefix', () => {
it('should match a custom view at the correct path', () => {
@@ -67,6 +75,44 @@ describe('getCustomCollectionViewByRoute', () => {
})
})
+ describe('route matching with exact: false (prefix matching)', () => {
+ it('should match a sub-path when exact is false', () => {
+ const result = getCustomCollectionViewByRoute({
+ adminRoute: '/admin',
+ baseRoute: '/collections/my-collection',
+ currentRoute: '/admin/collections/my-collection/grid/detail',
+ views: gridViewPrefixMatch,
+ })
+
+ expect(result.viewKey).toBe('grid')
+ expect(result.view.payloadComponent).toBeDefined()
+ })
+
+ it('should match the exact path when exact is false', () => {
+ const result = getCustomCollectionViewByRoute({
+ adminRoute: '/admin',
+ baseRoute: '/collections/my-collection',
+ currentRoute: '/admin/collections/my-collection/grid',
+ views: gridViewPrefixMatch,
+ })
+
+ expect(result.viewKey).toBe('grid')
+ expect(result.view.payloadComponent).toBeDefined()
+ })
+
+ it('should not match an unrelated path when exact is false', () => {
+ const result = getCustomCollectionViewByRoute({
+ adminRoute: '/admin',
+ baseRoute: '/collections/my-collection',
+ currentRoute: '/admin/collections/my-collection/map',
+ views: gridViewPrefixMatch,
+ })
+
+ expect(result.viewKey).toBeNull()
+ expect(result.view.payloadComponent).toBeUndefined()
+ })
+ })
+
describe('edge cases', () => {
it('should return no match when views is undefined', () => {
const result = getCustomCollectionViewByRoute({
diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
index 58effccef3b..be8e9887cbb 100644
--- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
+++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts
@@ -24,7 +24,11 @@ export const getCustomCollectionViewByRoute = ({
viewKey: null | string
} => {
const currentRoute =
- adminRoute === '/' ? currentRouteWithAdmin : currentRouteWithAdmin.replace(adminRoute, '')
+ adminRoute === '/'
+ ? currentRouteWithAdmin
+ : currentRouteWithAdmin.startsWith(adminRoute)
+ ? currentRouteWithAdmin.slice(adminRoute.length)
+ : currentRouteWithAdmin
if (views && typeof views === 'object') {
const foundEntry = Object.entries(views).find(([key, view]) => {
diff --git a/packages/next/src/views/Root/isPathMatchingRoute.ts b/packages/next/src/views/Root/isPathMatchingRoute.ts
index 193fc0042a8..b8447518549 100644
--- a/packages/next/src/views/Root/isPathMatchingRoute.ts
+++ b/packages/next/src/views/Root/isPathMatchingRoute.ts
@@ -35,6 +35,6 @@ export const isPathMatchingRoute = ({
}
if (!exact) {
- return viewRoute.startsWith(currentRoute)
+ return currentRoute.startsWith(viewRoute)
}
}
From 09f0c4541ab4e4586ff873bc82462ab22d510a98 Mon Sep 17 00:00:00 2001
From: Paul Popus
Date: Mon, 13 Apr 2026 16:41:44 +0100
Subject: [PATCH 17/18] remove the string | ViewTypes pattern that was left
over
---
packages/next/src/views/Root/getRouteData.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/next/src/views/Root/getRouteData.ts b/packages/next/src/views/Root/getRouteData.ts
index 1160a20270a..e9cb4761d09 100644
--- a/packages/next/src/views/Root/getRouteData.ts
+++ b/packages/next/src/views/Root/getRouteData.ts
@@ -84,7 +84,7 @@ type GetRouteDataResult = {
templateClassName: string
templateType: 'default' | 'minimal'
viewActions?: CustomComponent[]
- viewType?: string | ViewTypes
+ viewType?: ViewTypes
}
type GetRouteDataArgs = {
@@ -121,7 +121,7 @@ export const getRouteData = ({
let templateClassName: string
let templateType: 'default' | 'minimal' | undefined
let documentSubViewType: DocumentSubViewTypes
- let viewType: string | ViewTypes
+ let viewType: ViewTypes
const routeParams: GetRouteDataResult['routeParams'] = {}
const [segmentOne, segmentTwo, segmentThree, segmentFour, segmentFive, segmentSix] = segments
From d17a7208965bfdbde47d4680e5bece3fe978bc9e Mon Sep 17 00:00:00 2001
From: Paul Popus
Date: Wed, 15 Apr 2026 15:52:59 +0100
Subject: [PATCH 18/18] adjust ispathmatching route for non exact matches
---
.../views/Root/isPathMatchingRoute.spec.ts | 184 ++++++++++++++++++
.../src/views/Root/isPathMatchingRoute.ts | 8 +-
2 files changed, 191 insertions(+), 1 deletion(-)
create mode 100644 packages/next/src/views/Root/isPathMatchingRoute.spec.ts
diff --git a/packages/next/src/views/Root/isPathMatchingRoute.spec.ts b/packages/next/src/views/Root/isPathMatchingRoute.spec.ts
new file mode 100644
index 00000000000..369a5e39923
--- /dev/null
+++ b/packages/next/src/views/Root/isPathMatchingRoute.spec.ts
@@ -0,0 +1,184 @@
+import { describe, expect, it } from 'vitest'
+
+import { isPathMatchingRoute } from './isPathMatchingRoute.js'
+
+describe('isPathMatchingRoute', () => {
+ describe('no path defined', () => {
+ it('should return false when path is undefined', () => {
+ expect(isPathMatchingRoute({ currentRoute: '/anything', path: undefined })).toBe(false)
+ })
+
+ it('should return false when path is empty string', () => {
+ expect(isPathMatchingRoute({ currentRoute: '/anything', path: '' })).toBe(false)
+ })
+ })
+
+ describe('exact matching', () => {
+ it('should match when currentRoute exactly equals the static path', () => {
+ expect(
+ isPathMatchingRoute({ currentRoute: '/dashboard', exact: true, path: '/dashboard' }),
+ ).toBe(true)
+ })
+
+ it('should not match when currentRoute differs from the path', () => {
+ expect(
+ isPathMatchingRoute({ currentRoute: '/settings', exact: true, path: '/dashboard' }),
+ ).toBe(false)
+ })
+
+ it('should not match a sub-path when exact is true', () => {
+ expect(
+ isPathMatchingRoute({
+ currentRoute: '/dashboard/settings',
+ exact: true,
+ path: '/dashboard',
+ }),
+ ).toBe(false)
+ })
+
+ it('should match a parameterized path exactly', () => {
+ expect(
+ isPathMatchingRoute({
+ currentRoute: '/custom/123',
+ exact: true,
+ path: '/custom/:id',
+ }),
+ ).toBe(true)
+ })
+
+ it('should not match a parameterized path with extra segments', () => {
+ expect(
+ isPathMatchingRoute({
+ currentRoute: '/custom/123/edit',
+ exact: true,
+ path: '/custom/:id',
+ }),
+ ).toBe(false)
+ })
+
+ it('should match root path exactly', () => {
+ expect(isPathMatchingRoute({ currentRoute: '/', exact: true, path: '/' })).toBe(true)
+ })
+
+ it('should not match root path against other routes when exact', () => {
+ expect(isPathMatchingRoute({ currentRoute: '/login', exact: true, path: '/' })).toBe(false)
+ })
+ })
+
+ describe('non-exact (prefix) matching', () => {
+ it('should match when currentRoute equals the path', () => {
+ expect(isPathMatchingRoute({ currentRoute: '/dashboard', path: '/dashboard' })).toBe(true)
+ })
+
+ it('should match a sub-path at a segment boundary', () => {
+ expect(isPathMatchingRoute({ currentRoute: '/dashboard/settings', path: '/dashboard' })).toBe(
+ true,
+ )
+ })
+
+ it('should match deeply nested sub-paths', () => {
+ expect(
+ isPathMatchingRoute({
+ currentRoute: '/dashboard/settings/advanced/debug',
+ path: '/dashboard',
+ }),
+ ).toBe(true)
+ })
+
+ it('should not match when route shares a prefix but not at a segment boundary', () => {
+ expect(isPathMatchingRoute({ currentRoute: '/dashboard-extra', path: '/dashboard' })).toBe(
+ false,
+ )
+ })
+
+ it('should not match a completely different route', () => {
+ expect(isPathMatchingRoute({ currentRoute: '/settings', path: '/dashboard' })).toBe(false)
+ })
+
+ it('should match a parameterized path with a sub-path', () => {
+ expect(
+ isPathMatchingRoute({
+ currentRoute: '/custom/123/edit',
+ path: '/custom/:id',
+ }),
+ ).toBe(false)
+ // pathToRegexp is end-anchored by default, so /custom/:id does not match /custom/123/edit.
+ // The literal fallback '/custom/:id' also won't startsWith-match.
+ // Parameterized views should use exact: true or include all segments in the pattern.
+ })
+
+ it('should match when :id captures the full segment including non-slash characters', () => {
+ expect(
+ isPathMatchingRoute({
+ currentRoute: '/custom/123extra',
+ path: '/custom/:id',
+ }),
+ ).toBe(true)
+ // :id captures everything up to the next / — '123extra' is a valid :id value
+ })
+ })
+
+ describe('root path / without exact — regression for route shadowing bug', () => {
+ it('should match root path against itself', () => {
+ expect(isPathMatchingRoute({ currentRoute: '/', path: '/' })).toBe(true)
+ })
+
+ it('should not match root path against /login', () => {
+ expect(isPathMatchingRoute({ currentRoute: '/login', path: '/' })).toBe(false)
+ })
+
+ it('should not match root path against /collections/posts', () => {
+ expect(isPathMatchingRoute({ currentRoute: '/collections/posts', path: '/' })).toBe(false)
+ })
+
+ it('should not match root path against /collections/posts/123', () => {
+ expect(isPathMatchingRoute({ currentRoute: '/collections/posts/123', path: '/' })).toBe(false)
+ })
+ })
+
+ describe('sensitive option', () => {
+ it('should match case-insensitively by default', () => {
+ expect(
+ isPathMatchingRoute({
+ currentRoute: '/Dashboard',
+ exact: true,
+ path: '/dashboard',
+ }),
+ ).toBe(true)
+ })
+
+ it('should not match different cases when sensitive is true', () => {
+ expect(
+ isPathMatchingRoute({
+ currentRoute: '/Dashboard',
+ exact: true,
+ path: '/dashboard',
+ sensitive: true,
+ }),
+ ).toBe(false)
+ })
+ })
+
+ describe('strict option', () => {
+ it('should match with or without trailing slash by default', () => {
+ expect(
+ isPathMatchingRoute({
+ currentRoute: '/dashboard/',
+ exact: true,
+ path: '/dashboard',
+ }),
+ ).toBe(true)
+ })
+
+ it('should not match trailing slash mismatch when strict is true', () => {
+ expect(
+ isPathMatchingRoute({
+ currentRoute: '/dashboard/',
+ exact: true,
+ path: '/dashboard',
+ strict: true,
+ }),
+ ).toBe(false)
+ })
+ })
+})
diff --git a/packages/next/src/views/Root/isPathMatchingRoute.ts b/packages/next/src/views/Root/isPathMatchingRoute.ts
index b8447518549..cd0e7d5d0a8 100644
--- a/packages/next/src/views/Root/isPathMatchingRoute.ts
+++ b/packages/next/src/views/Root/isPathMatchingRoute.ts
@@ -35,6 +35,12 @@ export const isPathMatchingRoute = ({
}
if (!exact) {
- return currentRoute.startsWith(viewRoute)
+ if (!currentRoute.startsWith(viewRoute)) {
+ return false
+ }
+
+ const remainingPath = currentRoute.slice(viewRoute.length)
+
+ return remainingPath === '' || remainingPath.startsWith('/')
}
}