Skip to content
Closed
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
171 changes: 170 additions & 1 deletion packages/next/src/build/analysis/get-page-static-info.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { getMiddlewareMatchers } from './get-page-static-info'
import {
getMiddlewareMatchers,
getPagesPageStaticInfo,
} from './get-page-static-info'
import { join } from 'path'
import { writeFile, mkdir, rm } from 'fs/promises'
import { tmpdir } from 'os'

describe('get-page-static-infos', () => {
describe('getMiddlewareMatchers', () => {
Expand Down Expand Up @@ -42,4 +48,167 @@ describe('get-page-static-infos', () => {
expect(regex.test('/apple.json')).toBe(true)
})
})

describe('getPagesPageStaticInfo', () => {
let testDir: string

beforeEach(async () => {
// Create a unique temporary directory for each test
testDir = join(tmpdir(), `next-test-${Date.now()}-${Math.random()}`)
await mkdir(testDir, { recursive: true })
})

afterEach(async () => {
// Clean up test directory
try {
await rm(testDir, { recursive: true, force: true })
} catch (e) {
// Ignore cleanup errors
}
})

it('should throw error when "use server" directive is used in Pages Router', async () => {
const pageContent = `"use server"

export default function Page() {
return <div>Hello</div>
}`

const pageFilePath = join(testDir, 'page.tsx')
await writeFile(pageFilePath, pageContent)

await expect(
getPagesPageStaticInfo({
pageFilePath,
nextConfig: {} as any,
isDev: false,
page: '/test-page',
pageType: 'pages' as const,
})
).rejects.toThrow(
'Page "/test-page" cannot use "use server" directive. Server Actions are only supported in the App Router'
)
})

it('should throw error for "use server" even without other keywords (PARSE_PATTERN test)', async () => {
// This test verifies that PARSE_PATTERN includes "use\s" to trigger parsing
// Without this, files with only "use server" would skip AST parsing
const pageContent = `"use server"

export default function Page() {
return <div>Hello</div>
}`

const pageFilePath = join(testDir, 'page-minimal.tsx')
await writeFile(pageFilePath, pageContent)

// Should throw even though file has no getStaticProps, export const, etc.
await expect(
getPagesPageStaticInfo({
pageFilePath,
nextConfig: {} as any,
isDev: false,
page: '/minimal-page',
pageType: 'pages' as const,
})
).rejects.toThrow(
'Page "/minimal-page" cannot use "use server" directive. Server Actions are only supported in the App Router'
)
})

it('should allow "use client" directive in Pages Router', async () => {
const pageContent = `"use client"

export default function Page() {
return <div>Hello</div>
}`

const pageFilePath = join(testDir, 'page.tsx')
await writeFile(pageFilePath, pageContent)

// Should not throw - "use client" is allowed in Pages Router
const result = await getPagesPageStaticInfo({
pageFilePath,
nextConfig: {} as any,
isDev: false,
page: '/test-page',
pageType: 'pages' as const,
})

expect(result).toBeDefined()
expect(result.type).toBe('pages')
})

it('should not throw error for Pages Router pages without directives', async () => {
const pageContent = `export default function Page() {
return <div>Hello</div>
}

export async function getStaticProps() {
return { props: {} }
}`

const pageFilePath = join(testDir, 'page.tsx')
await writeFile(pageFilePath, pageContent)

const result = await getPagesPageStaticInfo({
pageFilePath,
nextConfig: {} as any,
isDev: false,
page: '/test-page',
pageType: 'pages' as const,
})

expect(result).toBeDefined()
expect(result.type).toBe('pages')
expect(result.getStaticProps).toBe(true)
})

it('should not throw error for directives in comments or strings', async () => {
const pageContent = `// "use server" in a comment
export default function Page() {
const str = "use client"
return <div>Hello</div>
}`

const pageFilePath = join(testDir, 'page.tsx')
await writeFile(pageFilePath, pageContent)

const result = await getPagesPageStaticInfo({
pageFilePath,
nextConfig: {} as any,
isDev: false,
page: '/test-page',
pageType: 'pages' as const,
})

expect(result).toBeDefined()
expect(result.type).toBe('pages')
})

it('should not trigger on React hooks or variables containing "use"', async () => {
const pageContent = `import { useState, useEffect } from 'react'

export default function Page() {
const [state, setState] = useState(0)
const useServer = () => {}
useEffect(() => {}, [])
return <div>Hello</div>
}`

const pageFilePath = join(testDir, 'page.tsx')
await writeFile(pageFilePath, pageContent)

const result = await getPagesPageStaticInfo({
pageFilePath,
nextConfig: {} as any,
isDev: false,
page: '/test-page',
pageType: 'pages' as const,
})

expect(result).toBeDefined()
expect(result.type).toBe('pages')
})
})
})
26 changes: 20 additions & 6 deletions packages/next/src/build/analysis/get-page-static-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,12 @@ import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path'
import { isProxyFile } from '../utils'

// Pattern to detect if a file needs AST parsing for static analysis.
// Includes 'use\s' to catch "use server" and "use client" directives.
// The \s (whitespace) requirement prevents false matches on variable names
// like useState, useEffect, or useServer.
const PARSE_PATTERN =
/(?<!(_jsx|jsx-))runtime|preferredRegion|getStaticProps|getServerSideProps|generateStaticParams|export const|generateImageMetadata|generateSitemaps|middleware|proxy/
/(?<!(_jsx|jsx-))runtime|preferredRegion|getStaticProps|getServerSideProps|generateStaticParams|export const|generateImageMetadata|generateSitemaps|middleware|proxy|use\s/

export type ProxyMatcher = {
regexp: string
Expand Down Expand Up @@ -733,14 +737,24 @@ export async function getPagesPageStaticInfo({
isDev,
})

const { getServerSideProps, getStaticProps, exports } = checkExports(
ast,
PagesSegmentConfigSchemaKeys,
page
)
const { getServerSideProps, getStaticProps, exports, directives } =
checkExports(ast, PagesSegmentConfigSchemaKeys, page)

const { type: rsc } = getRSCModuleInformation(content, true)

// Validate that use server directive is not used in Pages Router.
// Server Actions are only supported in the App Router.
//
// Note: This validation only checks the page file itself, not nested components
// that are imported. For comprehensive server-only validation in nested components,
// use `import 'server-only'` which is validated during the build process regardless
// of whether the code is in a page file or a nested component.
if (directives?.has('server')) {
throw new Error(
`Page "${page}" cannot use "use server" directive. Server Actions are only supported in the App Router. To use Server Actions, migrate to the App Router: https://nextjs.org/docs/app/building-your-application/upgrading/app-router-migration`
)
}

const exportedConfig: Record<string, unknown> = {}
if (exports) {
for (const property of exports) {
Expand Down
84 changes: 84 additions & 0 deletions test/development/acceptance/use-directives-in-pages-router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/* eslint-env jest */
import { nextTestSetup } from 'e2e-utils'
import { createSandbox } from 'development-sandbox'
import { outdent } from 'outdent'

const initialFiles = new Map([
['app/_.js', ''], // app dir need to exists, otherwise the SWC RSC checks will not run
[
'pages/index.js',
outdent`
export default function Page() {
return <div>Hello</div>
}
`,
],
])

describe('Error for "use server" directive in Pages Router', () => {
const { next } = nextTestSetup({
files: {},
skipStart: true,
})

test('"use server" directive is not allowed in Pages Router', async () => {
await using sandbox = await createSandbox(
next,
new Map([
...initialFiles,
[
'pages/test-page.js',
outdent`
"use server"

export default function Page() {
return <div>Hello</div>
}
`,
],
])
)
const { session } = sandbox

await session.assertHasRedbox()
await expect(session.getRedboxSource()).resolves.toMatch(
/cannot use "use server" directive.*Server Actions are only supported in the App Router/
)
})

test('"use client" directive is allowed in Pages Router', async () => {
await using sandbox = await createSandbox(
next,
new Map([
...initialFiles,
[
'pages/test-page.js',
outdent`
"use client"

export default function Page() {
return <div>Hello</div>
}
`,
],
])
)
const { session, browser } = sandbox

// Should not have a redbox - "use client" is allowed
await session.assertNoRedbox()

const text = await browser.elementByCss('body').text()
expect(text).toContain('Hello')
})

test('Pages Router pages without directives work correctly', async () => {
await using sandbox = await createSandbox(next, initialFiles)
const { session, browser } = sandbox

await session.assertNoRedbox()

const text = await browser.elementByCss('body').text()
expect(text).toContain('Hello')
})
})
Loading