Skip to content
Draft
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
12 changes: 10 additions & 2 deletions packages/next/src/views/Root/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,14 @@ export const RootPage = async ({

const params = await paramsPromise

const currentRoute = formatAdminURL({
const rawCurrentRoute = formatAdminURL({
adminRoute,
path: Array.isArray(params.segments) ? `/${params.segments.join('/')}` : null,
})
const currentRoute =
rawCurrentRoute.length > 1 && rawCurrentRoute.endsWith('/')
? rawCurrentRoute.slice(0, -1)
: rawCurrentRoute

const segments = Array.isArray(params.segments) ? params.segments : []
const isCollectionRoute = segments[0] === 'collections'
Expand Down Expand Up @@ -223,10 +227,14 @@ export const RootPage = async ({
const usersCollection = config.collections.find(({ slug }) => slug === userSlug)
const disableLocalStrategy = usersCollection?.auth?.disableLocalStrategy

const createFirstUserRoute = formatAdminURL({
const rawCreateFirstUserRoute = formatAdminURL({
adminRoute,
path: _createFirstUserRoute,
})
const createFirstUserRoute =
rawCreateFirstUserRoute.length > 1 && rawCreateFirstUserRoute.endsWith('/')
? rawCreateFirstUserRoute.slice(0, -1)
: rawCreateFirstUserRoute

if (disableLocalStrategy && currentRoute === createFirstUserRoute) {
redirect(adminRoute)
Expand Down
5 changes: 5 additions & 0 deletions packages/next/src/withPayload/withPayload.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@ export const withPayload = (nextConfig = {}, options = {}) => {
baseConfig.env.NEXT_BASE_PATH = nextConfig.basePath
}

if (nextConfig.trailingSlash === true) {
process.env.NEXT_TRAILING_SLASH = 'true'
baseConfig.env.NEXT_TRAILING_SLASH = 'true'
}

if (!supportsTurbopackBuild) {
return withPayloadLegacy(baseConfig)
} else {
Expand Down
117 changes: 116 additions & 1 deletion packages/payload/src/utilities/formatAdminURL.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'
import { afterEach, beforeEach, describe, it, expect } from 'vitest'
import { formatAdminURL } from './formatAdminURL.js'

describe('formatAdminURL', () => {
Expand Down Expand Up @@ -214,4 +214,119 @@ describe('formatAdminURL', () => {
expect(result).toBe('/')
})
})

describe('trailing slash handling', () => {
const originalTrailingSlash = process.env.NEXT_TRAILING_SLASH

beforeEach(() => {
process.env.NEXT_TRAILING_SLASH = 'true'
})

afterEach(() => {
if (originalTrailingSlash === undefined) {
delete process.env.NEXT_TRAILING_SLASH
} else {
process.env.NEXT_TRAILING_SLASH = originalTrailingSlash
}
})

it('should append trailing slash to relative admin URL', () => {
const result = formatAdminURL({
adminRoute: defaultAdminRoute,
path: dummyPath,
relative: true,
})

expect(result).toBe(`${defaultAdminRoute}${dummyPath}/`)
})

it('should append trailing slash to relative api URL', () => {
const result = formatAdminURL({
apiRoute: '/api',
path: '/users',
relative: true,
})

expect(result).toBe(`${process.env.NEXT_BASE_PATH || ''}/api/users/`)
})

it('should append trailing slash to absolute URL', () => {
const result = formatAdminURL({
adminRoute: defaultAdminRoute,
path: dummyPath,
serverURL,
})

expect(result).toBe(
`${serverURL}${process.env.NEXT_BASE_PATH || ''}${defaultAdminRoute}${dummyPath}/`,
)
})

it('should append trailing slash when basePath is set', () => {
const result = formatAdminURL({
apiRoute: '/api',
basePath: '/v1',
path: '/users',
serverURL,
})

expect(result).toBe(`${serverURL}${process.env.NEXT_BASE_PATH || ''}/v1/api/users/`)
})

it('should not append trailing slash to root "/"', () => {
const result = formatAdminURL({
adminRoute: rootAdminRoute,
relative: true,
})

expect(result).toBe('/')
})

it('should not double-slash when path already ends with /', () => {
const result = formatAdminURL({
adminRoute: defaultAdminRoute,
path: '/collections/posts',
relative: true,
})

expect(result.endsWith('//')).toBe(false)
expect(result).toBe(`${defaultAdminRoute}/collections/posts/`)
})

it('should place trailing slash before query string', () => {
const path = `${dummyPath}?page=2`

const result = formatAdminURL({
adminRoute: defaultAdminRoute,
path,
relative: true,
})

expect(result).toBe(`${defaultAdminRoute}${dummyPath}/?page=2`)
})

it('should leave URLs unchanged when env var is not set', () => {
delete process.env.NEXT_TRAILING_SLASH

const result = formatAdminURL({
adminRoute: defaultAdminRoute,
path: dummyPath,
relative: true,
})

expect(result).toBe(`${defaultAdminRoute}${dummyPath}`)
})

it('should leave URLs unchanged when env var is "false"', () => {
process.env.NEXT_TRAILING_SLASH = 'false'

const result = formatAdminURL({
adminRoute: defaultAdminRoute,
path: dummyPath,
relative: true,
})

expect(result).toBe(`${defaultAdminRoute}${dummyPath}`)
})
})
})
19 changes: 16 additions & 3 deletions packages/payload/src/utilities/formatAdminURL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,24 @@ export const formatAdminURL = (args: FormatURLArgs): string => {

if (relative || !serverURL) {
if (includeBasePath && basePath) {
return pathnameWithBase
return applyTrailingSlash(pathnameWithBase)
}
return pathname
return applyTrailingSlash(pathname)
}

const serverURLObj = new URL(serverURL)
return new URL(pathnameWithBase, serverURLObj.origin).toString()
return applyTrailingSlash(new URL(pathnameWithBase, serverURLObj.origin).toString())
}

const applyTrailingSlash = (url: string): string => {
if (process.env.NEXT_TRAILING_SLASH !== 'true') {
return url
}
const queryIndex = url.search(/[?#]/)
const pathPart = queryIndex === -1 ? url : url.slice(0, queryIndex)
const queryPart = queryIndex === -1 ? '' : url.slice(queryIndex)
if (pathPart.endsWith('/')) {
return url
}
return `${pathPart}/${queryPart}`
}
7 changes: 5 additions & 2 deletions packages/payload/src/utilities/handleEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,14 @@ export const handleEndpoints = async ({
const { payload } = req
const { config } = payload

const pathname = path ?? new URL(req.url!).pathname
const baseAPIPath = formatAdminURL({
const rawPathname = path ?? new URL(req.url!).pathname
const pathname = rawPathname.length > 1 ? rawPathname.replace(/\/$/, '') : rawPathname
const rawBaseAPIPath = formatAdminURL({
apiRoute: config.routes.api,
path: '',
})
const baseAPIPath =
rawBaseAPIPath.length > 1 ? rawBaseAPIPath.replace(/\/$/, '') : rawBaseAPIPath

if (!pathname.startsWith(baseAPIPath)) {
return notFoundResponse(req, pathname)
Expand Down
2 changes: 2 additions & 0 deletions test/trailing-slash/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/media
/media-gif
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'

import config from '@payload-config'
import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views'

import { importMap } from '../importMap.js'

type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}

export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })

const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, importMap, params, searchParams })

export default NotFound
25 changes: 25 additions & 0 deletions test/trailing-slash/app/(payload)/admin/[[...segments]]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'

import config from '@payload-config'
import { generatePageMetadata, RootPage } from '@payloadcms/next/views'

import { importMap } from '../importMap.js'

type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}

export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })

const Page = ({ params, searchParams }: Args) =>
RootPage({ config, importMap, params, searchParams })

export default Page
6 changes: 6 additions & 0 deletions test/trailing-slash/app/(payload)/admin/importMap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'

/** @type import('payload').ImportMap */
export const importMap = {
'@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
}
10 changes: 10 additions & 0 deletions test/trailing-slash/app/(payload)/api/[...slug]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes'

export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const OPTIONS = REST_OPTIONS(config)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes/index.js'

export const GET = GRAPHQL_PLAYGROUND_GET(config)
8 changes: 8 additions & 0 deletions test/trailing-slash/app/(payload)/api/graphql/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'

export const POST = GRAPHQL_POST(config)

export const OPTIONS = REST_OPTIONS(config)
7 changes: 7 additions & 0 deletions test/trailing-slash/app/(payload)/custom.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#custom-css {
font-family: monospace;
}

#custom-css::after {
content: 'custom-css';
}
31 changes: 31 additions & 0 deletions test/trailing-slash/app/(payload)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { ServerFunctionClient } from 'payload'

import config from '@payload-config'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'

import { importMap } from './admin/importMap.js'
import './custom.scss'

type Args = {
children: React.ReactNode
}

const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}

const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)

export default Layout
19 changes: 19 additions & 0 deletions test/trailing-slash/collections/Posts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'content',
type: 'textarea',
},
],
}
14 changes: 14 additions & 0 deletions test/trailing-slash/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { Posts } from './collections/Posts.js'
import { seed } from './seed/index.js'

process.env.NEXT_TRAILING_SLASH = 'true'

export default buildConfigWithDefaults({
admin: {
autoLogin: false,
},
collections: [Posts],
onInit: seed,
serverURL: `http://localhost:${process.env.PORT || 3000}`,
})
Loading
Loading