Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add force-static handling for app dir #43061

Merged
merged 1 commit into from Nov 18, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 12 additions & 3 deletions packages/next/client/components/headers.ts
@@ -1,8 +1,12 @@
import { RequestCookies } from '../../server/web/spec-extension/cookies'
import { requestAsyncStorage } from './request-async-storage'
import { staticGenerationBailout } from './static-generation-bailout'

export function headers() {
staticGenerationBailout('headers')
if (staticGenerationBailout('headers')) {
return new Headers({})
}

const requestStore =
requestAsyncStorage && 'getStore' in requestAsyncStorage
? requestAsyncStorage.getStore()!
Expand All @@ -12,7 +16,10 @@ export function headers() {
}

export function previewData() {
staticGenerationBailout('previewData')
if (staticGenerationBailout('previewData')) {
return {}
}

const requestStore =
requestAsyncStorage && 'getStore' in requestAsyncStorage
? requestAsyncStorage.getStore()!
Expand All @@ -21,7 +28,9 @@ export function previewData() {
}

export function cookies() {
staticGenerationBailout('cookies')
if (staticGenerationBailout('cookies')) {
return new RequestCookies(new Headers({}))
}
const requestStore =
requestAsyncStorage && 'getStore' in requestAsyncStorage
? requestAsyncStorage.getStore()!
Expand Down
14 changes: 9 additions & 5 deletions packages/next/client/components/navigation.ts
Expand Up @@ -70,16 +70,20 @@ class ReadonlyURLSearchParams {
* Learn more about URLSearchParams here: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
*/
export function useSearchParams() {
staticGenerationBailout('useSearchParams')
const searchParams = useContext(SearchParamsContext)
if (!searchParams) {
throw new Error('invariant expected search params to be mounted')
}

const readonlySearchParams = useMemo(() => {
return new ReadonlyURLSearchParams(searchParams)
return new ReadonlyURLSearchParams(searchParams || new URLSearchParams())
}, [searchParams])

if (staticGenerationBailout('useSearchParams')) {
return readonlySearchParams
}

if (!searchParams) {
throw new Error('invariant expected search params to be mounted')
}

return readonlySearchParams
}

Expand Down
Expand Up @@ -6,6 +6,7 @@ export interface StaticGenerationStore {
revalidate?: number
fetchRevalidate?: number
isStaticGeneration?: boolean
forceStatic?: boolean
}

export let staticGenerationAsyncStorage:
Expand Down
Expand Up @@ -7,8 +7,11 @@ export function staticGenerationBailout(reason: string) {
? staticGenerationAsyncStorage?.getStore()
: staticGenerationAsyncStorage

if (staticGenerationStore?.forceStatic) {
return true
}

if (staticGenerationStore?.isStaticGeneration) {
// TODO: honor the dynamic: 'force-static'
if (staticGenerationStore) {
staticGenerationStore.fetchRevalidate = 0
}
Expand Down
22 changes: 19 additions & 3 deletions packages/next/server/app-render.tsx
Expand Up @@ -43,6 +43,7 @@ import {
NEXT_ROUTER_STATE_TREE,
RSC,
} from '../client/components/app-router-headers'
import type { StaticGenerationStore } from '../client/components/static-generation-async-storage'

const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge'

Expand Down Expand Up @@ -284,9 +285,13 @@ function patchFetch(ComponentMod: any) {
(typeof fetchRevalidate === 'undefined' ||
next.revalidate < fetchRevalidate)
) {
staticGenerationStore.fetchRevalidate = next.revalidate
const forceDynamic = staticGenerationStore.forceDynamic

if (next.revalidate === 0) {
if (!forceDynamic || next.revalidate !== 0) {
staticGenerationStore.fetchRevalidate = next.revalidate
}

if (!forceDynamic && next.revalidate === 0) {
throw new DynamicServerError(
`revalidate: ${next.revalidate} fetch ${input}${
pathname ? ` ${pathname}` : ''
Expand Down Expand Up @@ -802,7 +807,7 @@ export async function renderToHTMLOrFlight(

// we wrap the render in an AsyncLocalStorage context
const wrappedRender = async () => {
const staticGenerationStore =
const staticGenerationStore: StaticGenerationStore =
'getStore' in staticGenerationAsyncStorage
? staticGenerationAsyncStorage.getStore()
: staticGenerationAsyncStorage
Expand Down Expand Up @@ -1112,6 +1117,17 @@ export async function renderToHTMLOrFlight(
? [DefaultNotFound]
: []

if (typeof layoutOrPageMod?.dynamic === 'string') {
// the nested most config wins so we only force-static
// if it's configured above any parent that configured
// otherwise
if (layoutOrPageMod.dynamic === 'force-static') {
staticGenerationStore.forceStatic = true
} else {
staticGenerationStore.forceStatic = false
}
}

if (typeof layoutOrPageMod?.revalidate === 'number') {
defaultRevalidate = layoutOrPageMod.revalidate

Expand Down
100 changes: 100 additions & 0 deletions test/e2e/app-dir/app-static.test.ts
Expand Up @@ -53,6 +53,12 @@ describe('app-dir static/dynamic handling', () => {
'blog/tim/first-post.rsc',
'dynamic-no-gen-params-ssr/[slug]/page.js',
'dynamic-no-gen-params/[slug]/page.js',
'force-static/[slug]/page.js',
'force-static/first.html',
'force-static/first.rsc',
'force-static/page.js',
'force-static/second.html',
'force-static/second.rsc',
'hooks/use-pathname/[slug]/page.js',
'hooks/use-pathname/slug.html',
'hooks/use-pathname/slug.rsc',
Expand Down Expand Up @@ -121,6 +127,16 @@ describe('app-dir static/dynamic handling', () => {
initialRevalidateSeconds: false,
srcRoute: '/hooks/use-pathname/[slug]',
},
'/force-static/first': {
dataRoute: '/force-static/first.rsc',
initialRevalidateSeconds: false,
srcRoute: '/force-static/[slug]',
},
'/force-static/second': {
dataRoute: '/force-static/second.rsc',
initialRevalidateSeconds: false,
srcRoute: '/force-static/[slug]',
},
})
expect(manifest.dynamicRoutes).toEqual({
'/blog/[author]/[slug]': {
Expand All @@ -141,10 +157,94 @@ describe('app-dir static/dynamic handling', () => {
fallback: null,
routeRegex: '^\\/hooks\\/use\\-pathname\\/([^\\/]+?)(?:\\/)?$',
},
'/force-static/[slug]': {
dataRoute: '/force-static/[slug].rsc',
dataRouteRegex: '^\\/force\\-static\\/([^\\/]+?)\\.rsc$',
fallback: null,
routeRegex: '^\\/force\\-static\\/([^\\/]+?)(?:\\/)?$',
},
})
})
}

it('should force SSR correctly for headers usage', async () => {
const res = await fetchViaHTTP(next.url, '/force-static', undefined, {
headers: {
Cookie: 'myCookie=cookieValue',
another: 'header',
},
})
expect(res.status).toBe(200)

const html = await res.text()
const $ = cheerio.load(html)

expect(JSON.parse($('#headers').text())).toIncludeAllMembers([
'cookie',
'another',
])
expect(JSON.parse($('#cookies').text())).toEqual([
{
name: 'myCookie',
value: 'cookieValue',
},
])

const firstTime = $('#now').text()

if (!(global as any).isNextDev) {
const res2 = await fetchViaHTTP(next.url, '/force-static')
expect(res2.status).toBe(200)

const $2 = cheerio.load(await res2.text())
expect(firstTime).not.toBe($2('#now').text())
}
})

it('should honor dynamic = "force-static" correctly', async () => {
const res = await fetchViaHTTP(next.url, '/force-static/first')
expect(res.status).toBe(200)

const html = await res.text()
const $ = cheerio.load(html)

expect(JSON.parse($('#params').text())).toEqual({ slug: 'first' })
expect(JSON.parse($('#headers').text())).toEqual([])
expect(JSON.parse($('#cookies').text())).toEqual([])

const firstTime = $('#now').text()

if (!(global as any).isNextDev) {
const res2 = await fetchViaHTTP(next.url, '/force-static/first')
expect(res2.status).toBe(200)

const $2 = cheerio.load(await res2.text())
expect(firstTime).toBe($2('#now').text())
}
})

it('should honor dynamic = "force-static" correctly (lazy)', async () => {
const res = await fetchViaHTTP(next.url, '/force-static/random')
expect(res.status).toBe(200)

const html = await res.text()
const $ = cheerio.load(html)

expect(JSON.parse($('#params').text())).toEqual({ slug: 'random' })
expect(JSON.parse($('#headers').text())).toEqual([])
expect(JSON.parse($('#cookies').text())).toEqual([])

const firstTime = $('#now').text()

if (!(global as any).isNextDev) {
const res2 = await fetchViaHTTP(next.url, '/force-static/random')
expect(res2.status).toBe(200)

const $2 = cheerio.load(await res2.text())
expect(firstTime).toBe($2('#now').text())
}
})

it('should handle dynamicParams: false correctly', async () => {
const validParams = ['tim', 'seb', 'styfle']

Expand Down
17 changes: 17 additions & 0 deletions test/e2e/app-dir/app-static/app/force-static/[slug]/page.js
@@ -0,0 +1,17 @@
// force-static should override the `headers()` usage
// in parent layout
export const dynamic = 'force-static'

export function generateStaticParams() {
return [{ slug: 'first' }, { slug: 'second' }]
}

export default function Page({ params }) {
return (
<>
<p id="page">/force-static/[slug]</p>
<p id="params">{JSON.stringify(params)}</p>
<p id="now">{Date.now()}</p>
</>
)
}
14 changes: 14 additions & 0 deletions test/e2e/app-dir/app-static/app/force-static/layout.js
@@ -0,0 +1,14 @@
import { cookies, headers } from 'next/headers'

export default function Layout({ children }) {
const curHeaders = headers()
const curCookies = cookies()

return (
<>
<p id="headers">{JSON.stringify([...curHeaders.keys()])}</p>
<p id="cookies">{JSON.stringify([...curCookies.getAll()])}</p>
{children}
</>
)
}
11 changes: 11 additions & 0 deletions test/e2e/app-dir/app-static/app/force-static/page.js
@@ -0,0 +1,11 @@
// this should be dynamic as it doesn't specify force-static
// and the parent layout uses `headers()`

export default function Page() {
return (
<>
<p id="page">/force-static</p>
<p id="now">{Date.now()}</p>
</>
)
}