From fabf4ea9a1f833088e8f31072512779bdfc5ae1f Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 17 Jan 2024 15:31:13 -0800 Subject: [PATCH] Adds tests to cover some of the new erroring semantics around dynamically tracked data reads --- .../app-dir/dynamic-data/dynamic-data.test.ts | 295 ++++++++++++++++++ .../fixtures/cache-scoped/app/cookies/page.js | 35 +++ .../fixtures/cache-scoped/app/headers/page.js | 29 ++ .../fixtures/cache-scoped/app/layout.js | 23 ++ .../fixtures/main/app/client-page/page.js | 34 ++ .../fixtures/main/app/force-dynamic/page.js | 59 ++++ .../fixtures/main/app/force-static/page.js | 59 ++++ .../dynamic-data/fixtures/main/app/layout.js | 25 ++ .../fixtures/main/app/setenv/route.js | 4 + .../fixtures/main/app/top-level/page.js | 57 ++++ .../require-static/app/cookies/page.js | 33 ++ .../require-static/app/headers/page.js | 26 ++ .../fixtures/require-static/app/layout.js | 23 ++ .../require-static/app/search/page.js | 23 ++ 14 files changed, 725 insertions(+) create mode 100644 test/e2e/app-dir/dynamic-data/dynamic-data.test.ts create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/cookies/page.js create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/headers/page.js create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/layout.js create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/main/app/client-page/page.js create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/main/app/layout.js create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/main/app/setenv/route.js create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/require-static/app/cookies/page.js create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/require-static/app/headers/page.js create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/require-static/app/layout.js create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/require-static/app/search/page.js diff --git a/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts b/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts new file mode 100644 index 00000000000000..3bbe77e0e66622 --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts @@ -0,0 +1,295 @@ +import { createNextDescribe } from 'e2e-utils' +import { getRedboxHeader, hasRedbox } from 'next-test-utils' + +process.env.__TEST_SENTINEL = 'build' + +createNextDescribe( + 'dynamic-data', + { + files: __dirname + '/fixtures/main', + skipStart: true, + }, + ({ next, isNextDev, isNextDeploy }) => { + if (isNextDeploy) { + it.skip('should not run in next deploy', () => {}) + return + } + + beforeAll(async () => { + await next.start() + // This will update the __TEST_SENTINEL value to "run" + await next.render('/setenv?value=run') + }) + + it('should render the dynamic apis dynamically when used in a top-level scope', async () => { + const $ = await next.render$( + '/top-level?foo=foosearch', + {}, + { + headers: { + fooheader: 'foo header value', + cookie: 'foocookie=foo cookie value', + }, + } + ) + if (isNextDev) { + // in dev we expect the entire page to be rendered at runtime + expect($('#layout').text()).toBe('run') + expect($('#page').text()).toBe('run') + // we expect there to be no susupense boundary in fallback state + expect($('#boundary').html()).toBeNull() + } else if (process.env.__NEXT_EXPERIMENTAL_PPR) { + // in PPR we expect the shell to be rendered at build and the page to be rendered at runtime + expect($('#layout').text()).toBe('build') + expect($('#page').text()).toBe('run') + // we expect there to be a suspense boundary in fallback state + expect($('#boundary').html()).not.toBeNull() + } else { + // in static generation we expect the entire page to be rendered at runtime + expect($('#layout').text()).toBe('run') + expect($('#page').text()).toBe('run') + // we expect there to be no susupense boundary in fallback state + expect($('#boundary').html()).toBeNull() + } + + expect($('#headers .fooheader').text()).toBe('foo header value') + expect($('#cookies .foocookie').text()).toBe('foo cookie value') + expect($('#searchparams .foo').text()).toBe('foosearch') + }) + + it('should render the dynamic apis dynamically when used in a top-level scope with force dynamic', async () => { + const $ = await next.render$( + '/force-dynamic?foo=foosearch', + {}, + { + headers: { + fooheader: 'foo header value', + cookie: 'foocookie=foo cookie value', + }, + } + ) + if (isNextDev) { + // in dev we expect the entire page to be rendered at runtime + expect($('#layout').text()).toBe('run') + expect($('#page').text()).toBe('run') + // we expect there to be no susupense boundary in fallback state + expect($('#boundary').html()).toBeNull() + } else if (process.env.__NEXT_EXPERIMENTAL_PPR) { + // in PPR with force + // @TODO this should actually be build but there is a bug in how we do segment level dynamic in PPR at the moment + // see not in create-component-tree + expect($('#layout').text()).toBe('run') + expect($('#page').text()).toBe('run') + // we expect there to be a suspense boundary in fallback state + expect($('#boundary').html()).toBeNull() + } else { + // in static generation we expect the entire page to be rendered at runtime + expect($('#layout').text()).toBe('run') + expect($('#page').text()).toBe('run') + // we expect there to be no susupense boundary in fallback state + expect($('#boundary').html()).toBeNull() + } + + expect($('#headers .fooheader').text()).toBe('foo header value') + expect($('#cookies .foocookie').text()).toBe('foo cookie value') + expect($('#searchparams .foo').text()).toBe('foosearch') + }) + + it('should render empty objects for dynamic APIs when rendering with force-static', async () => { + const $ = await next.render$( + '/force-static?foo=foosearch', + {}, + { + headers: { + fooheader: 'foo header value', + cookie: 'foocookie=foo cookie value', + }, + } + ) + if (isNextDev) { + // in dev we expect the entire page to be rendered at runtime + expect($('#layout').text()).toBe('run') + expect($('#page').text()).toBe('run') + // we expect there to be no susupense boundary in fallback state + expect($('#boundary').html()).toBeNull() + } else if (process.env.__NEXT_EXPERIMENTAL_PPR) { + // in PPR we expect the shell to be rendered at build and the page to be rendered at runtime + expect($('#layout').text()).toBe('build') + expect($('#page').text()).toBe('build') + // we expect there to be a suspense boundary in fallback state + expect($('#boundary').html()).toBeNull() + } else { + // in static generation we expect the entire page to be rendered at runtime + expect($('#layout').text()).toBe('build') + expect($('#page').text()).toBe('build') + // we expect there to be no susupense boundary in fallback state + expect($('#boundary').html()).toBeNull() + } + + expect($('#headers .fooheader').html()).toBeNull() + expect($('#cookies .foocookie').html()).toBeNull() + expect($('#searchparams .foo').html()).toBeNull() + }) + + it('should track searchParams access as dynamic when the Page is a client component', async () => { + console.log('=========================') + const $ = await next.render$( + '/client-page?foo=foosearch', + {}, + { + headers: { + fooheader: 'foo header value', + cookie: 'foocookie=foo cookie value', + }, + } + ) + if (isNextDev) { + // in dev we expect the entire page to be rendered at runtime + expect($('#layout').text()).toBe('run') + expect($('#page').text()).toBe('run') + // we don't assert the state of the fallback because it can depend on the timing + // of when streaming starts and how fast the client references resolve + } else if (process.env.__NEXT_EXPERIMENTAL_PPR) { + // in PPR we expect the shell to be rendered at build and the page to be rendered at runtime + expect($('#layout').text()).toBe('build') + expect($('#page').text()).toBe('run') + // we expect there to be a suspense boundary in fallback state + expect($('#boundary').html()).not.toBeNull() + } else { + // in static generation we expect the entire page to be rendered at runtime + expect($('#layout').text()).toBe('run') + expect($('#page').text()).toBe('run') + // we don't assert the state of the fallback because it can depend on the timing + // of when streaming starts and how fast the client references resolve + } + + expect($('#searchparams .foo').text()).toBe('foosearch') + }) + } +) + +createNextDescribe( + 'dynamic-data with dynamic = "error"', + { + files: __dirname + '/fixtures/require-static', + skipStart: true, + }, + ({ next, isNextDev, isNextDeploy }) => { + if (isNextDeploy) { + it.skip('should not run in next deploy.', () => {}) + return + } + + if (isNextDev) { + beforeAll(async () => { + await next.start() + }) + + it('displays redbox when `dynamic = "error"` and dynamic data is read in dev', async () => { + let browser = await next.browser('/cookies?foo=foosearch') + try { + expect(await hasRedbox(browser)).toBe(true) + expect(await getRedboxHeader(browser)).toMatch( + 'Error: Page with `dynamic = "error"` couldn\'t be rendered statically because it used `cookies`' + ) + } finally { + await browser.close() + } + + browser = await next.browser('/headers?foo=foosearch') + try { + expect(await hasRedbox(browser)).toBe(true) + expect(await getRedboxHeader(browser)).toMatch( + 'Error: Page with `dynamic = "error"` couldn\'t be rendered statically because it used `headers`' + ) + } finally { + await browser.close() + } + + browser = await next.browser('/search?foo=foosearch') + try { + expect(await hasRedbox(browser)).toBe(true) + expect(await getRedboxHeader(browser)).toMatch( + 'Error: Page with `dynamic = "error"` couldn\'t be rendered statically because it used `searchParams`' + ) + } finally { + await browser.close() + } + }) + } else { + it('error when the build when `dynamic = "error"` and dynamic data is read', async () => { + try { + await next.start() + } catch (err) { + // We expect this to fail + } + // Error: Page with `dynamic = "error"` couldn't be rendered statically because it used `headers` + expect(next.cliOutput).toMatch( + 'Error: Page with `dynamic = "error"` couldn\'t be rendered statically because it used `cookies`' + ) + expect(next.cliOutput).toMatch( + 'Error: Page with `dynamic = "error"` couldn\'t be rendered statically because it used `headers`' + ) + expect(next.cliOutput).toMatch( + 'Error: Page with `dynamic = "error"` couldn\'t be rendered statically because it used `searchParams`.' + ) + }) + } + } +) + +createNextDescribe( + 'dynamic-data inside cache scope', + { + files: __dirname + '/fixtures/cache-scoped', + skipStart: true, + }, + ({ next, isNextDev, isNextDeploy }) => { + if (isNextDeploy) { + it.skip('should not run in next deploy..', () => {}) + return + } + + if (isNextDev) { + beforeAll(async () => { + await next.start() + }) + + it('displays redbox when accessing dynamic data inside a cache scope', async () => { + let browser = await next.browser('/cookies') + try { + expect(await hasRedbox(browser)).toBe(true) + expect(await getRedboxHeader(browser)).toMatch( + 'Error: used "cookies" inside a function cached with "unstable_cache(...)".' + ) + } finally { + await browser.close() + } + + browser = await next.browser('/headers') + try { + expect(await hasRedbox(browser)).toBe(true) + expect(await getRedboxHeader(browser)).toMatch( + 'Error: used "headers" inside a function cached with "unstable_cache(...)".' + ) + } finally { + await browser.close() + } + }) + } else { + it('error when the build when accessing dynamic data inside a cache scope', async () => { + try { + await next.start() + } catch (err) { + // We expect this to fail + } + expect(next.cliOutput).toMatch( + 'Error: used "cookies" inside a function cached with "unstable_cache(...)".' + ) + expect(next.cliOutput).toMatch( + 'Error: used "headers" inside a function cached with "unstable_cache(...)".' + ) + }) + } + } +) diff --git a/test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/cookies/page.js b/test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/cookies/page.js new file mode 100644 index 00000000000000..4a1ecddc606eb6 --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/cookies/page.js @@ -0,0 +1,35 @@ +import { cookies as nextCookies } from 'next/headers' +import { unstable_cache as cache } from 'next/cache' + +const cookies = cache(() => nextCookies()) + +export default async function Page({ searchParams }) { + console.log('cookies()', await cookies()) + return ( +
+
+ This example uses `cookies()` but is configured with `dynamic = 'error'` + which should cause the page to fail to build +
+
+

cookies

+ {cookies() + .getAll() + .map((cookie) => { + const key = cookie.name + let value = cookie.value + + if (key === 'userCache') { + value = value.slice(0, 10) + '...' + } + return ( +
+

{key}

+
{value}
+
+ ) + })} +
+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/headers/page.js b/test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/headers/page.js new file mode 100644 index 00000000000000..148312a50a2b9c --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/headers/page.js @@ -0,0 +1,29 @@ +import { headers as nextHeaders } from 'next/headers' +import { unstable_cache as cache } from 'next/cache' + +const headers = cache(() => nextHeaders()) + +export default async function Page() { + return ( +
+
+ This example uses `headers()` but is configured with `dynamic = 'error'` + which should cause the page to fail to build +
+
+

headers

+ {Array.from(await headers()) + .entries() + .map(([key, value]) => { + if (key === 'cookie') return null + return ( +
+

{key}

+
{value}
+
+ ) + })} +
+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/layout.js b/test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/layout.js new file mode 100644 index 00000000000000..6dfbbdf73384e9 --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/layout.js @@ -0,0 +1,23 @@ +import { Suspense } from 'react' + +export default async function Layout({ children }) { + return ( + + + app-dynamic-data + + +

+ This test fixture helps us assert that accessing dynamic data in + various scopes and with various `dynamic` configurations works as + intended +

+
+ loading...}> + {children} + +
+ + + ) +} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/client-page/page.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/client-page/page.js new file mode 100644 index 00000000000000..6f996293c640bd --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/client-page/page.js @@ -0,0 +1,34 @@ +'use client' + +export default async function Page({ searchParams }) { + const { __TEST_SENTINEL } = process.env + return ( +
+
{__TEST_SENTINEL}
+
+ This example uses headers/cookies/searchParams directly in a Page + configured with `dynamic = 'force-dynamic'`. This should cause the page + to always render dynamically regardless of dynamic APIs used +
+
+

headers

+

This is a client Page so `headers()` is not available

+
+
+

cookies

{' '} +

This is a client Page so `cookies()` is not available

+
+
+

searchParams

+ {Object.entries(searchParams).map(([key, value]) => { + return ( +
+

{key}

+
{value}
+
+ ) + })} +
+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js new file mode 100644 index 00000000000000..2600585338d87b --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js @@ -0,0 +1,59 @@ +import { headers, cookies } from 'next/headers' + +export const dynamic = 'force-dynamic' + +export default async function Page({ searchParams }) { + const { __TEST_SENTINEL } = process.env + return ( +
+
{__TEST_SENTINEL}
+
+ This example uses headers/cookies/searchParams directly in a Page + configured with `dynamic = 'force-dynamic'`. This should cause the page + to always render dynamically regardless of dynamic APIs used +
+
+

headers

+ {Array.from(headers().entries()).map(([key, value]) => { + if (key === 'cookie') return null + return ( +
+

{key}

+
{value}
+
+ ) + })} +
+
+

cookies

+ {cookies() + .getAll() + .map((cookie) => { + const key = cookie.name + let value = cookie.value + + if (key === 'userCache') { + value = value.slice(0, 10) + '...' + } + return ( +
+

{key}

+
{value}
+
+ ) + })} +
+
+

searchParams

+ {Object.entries(searchParams).map(([key, value]) => { + return ( +
+

{key}

+
{value}
+
+ ) + })} +
+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js new file mode 100644 index 00000000000000..386920e307fbcd --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js @@ -0,0 +1,59 @@ +import { headers, cookies } from 'next/headers' + +export const dynamic = 'force-static' + +export default async function Page({ searchParams }) { + const { __TEST_SENTINEL } = process.env + return ( +
+
{__TEST_SENTINEL}
+
+ This example uses headers/cookies/searchParams directly in a Page + configured with `dynamic = 'force-static'`. This should cause the page + to always statically render but without exposing dynamic data +
+
+

headers

+ {Array.from(headers().entries()).map(([key, value]) => { + if (key === 'cookie') return null + return ( +
+

{key}

+
{value}
+
+ ) + })} +
+
+

cookies

+ {cookies() + .getAll() + .map((cookie) => { + const key = cookie.name + let value = cookie.value + + if (key === 'userCache') { + value = value.slice(0, 10) + '...' + } + return ( +
+

{key}

+
{value}
+
+ ) + })} +
+
+

searchParams

+ {Object.entries(searchParams).map(([key, value]) => { + return ( +
+

{key}

+
{value}
+
+ ) + })} +
+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/layout.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/layout.js new file mode 100644 index 00000000000000..0146d8e26a0292 --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/layout.js @@ -0,0 +1,25 @@ +import { Suspense } from 'react' + +export default async function Layout({ children }) { + const { __TEST_SENTINEL } = process.env + return ( + + + app-dynamic-data + + +

+ This test fixture helps us assert that accessing dynamic data in + various scopes and with various `dynamic` configurations works as + intended +

+
+
{__TEST_SENTINEL}
+ loading...}> + {children} + +
+ + + ) +} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/setenv/route.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/setenv/route.js new file mode 100644 index 00000000000000..b5f2c27c1a19dd --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/setenv/route.js @@ -0,0 +1,4 @@ +export async function GET(request) { + process.env.__TEST_SENTINEL = request.nextUrl.searchParams.get('value') + return new Response('ok') +} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js new file mode 100644 index 00000000000000..5c84fa636af01d --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js @@ -0,0 +1,57 @@ +import { headers, cookies } from 'next/headers' + +export default async function Page({ searchParams }) { + const { __TEST_SENTINEL } = process.env + return ( +
+
{__TEST_SENTINEL}
+
+ This example uses headers/cookies/searchParams directly. In static + generation we'd expect this to bail out to dynamic. In PPR we expect + this to partially render the root layout only +
+
+

headers

+ {Array.from(headers().entries()).map(([key, value]) => { + if (key === 'cookie') return null + return ( +
+

{key}

+
{value}
+
+ ) + })} +
+
+

cookies

+ {cookies() + .getAll() + .map((cookie) => { + const key = cookie.name + let value = cookie.value + + if (key === 'userCache') { + value = value.slice(0, 10) + '...' + } + return ( +
+

{key}

+
{value}
+
+ ) + })} +
+
+

searchParams

+ {Object.entries(searchParams).map(([key, value]) => { + return ( +
+

{key}

+
{value}
+
+ ) + })} +
+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/cookies/page.js b/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/cookies/page.js new file mode 100644 index 00000000000000..e5c3cf5147e3b4 --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/cookies/page.js @@ -0,0 +1,33 @@ +import { cookies } from 'next/headers' + +export const dynamic = 'error' + +export default async function Page({ searchParams }) { + return ( +
+
+ This example uses `cookies()` but is configured with `dynamic = 'error'` + which should cause the page to fail to build +
+
+

cookies

+ {cookies() + .getAll() + .map((cookie) => { + const key = cookie.name + let value = cookie.value + + if (key === 'userCache') { + value = value.slice(0, 10) + '...' + } + return ( +
+

{key}

+
{value}
+
+ ) + })} +
+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/headers/page.js b/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/headers/page.js new file mode 100644 index 00000000000000..1b3486550f8162 --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/headers/page.js @@ -0,0 +1,26 @@ +import { headers } from 'next/headers' + +export const dynamic = 'error' + +export default async function Page() { + return ( +
+
+ This example uses `headers()` but is configured with `dynamic = 'error'` + which should cause the page to fail to build +
+
+

headers

+ {Array.from(headers().entries()).map(([key, value]) => { + if (key === 'cookie') return null + return ( +
+

{key}

+
{value}
+
+ ) + })} +
+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/layout.js b/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/layout.js new file mode 100644 index 00000000000000..6dfbbdf73384e9 --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/layout.js @@ -0,0 +1,23 @@ +import { Suspense } from 'react' + +export default async function Layout({ children }) { + return ( + + + app-dynamic-data + + +

+ This test fixture helps us assert that accessing dynamic data in + various scopes and with various `dynamic` configurations works as + intended +

+
+ loading...}> + {children} + +
+ + + ) +} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/search/page.js b/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/search/page.js new file mode 100644 index 00000000000000..c87718537996bc --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/search/page.js @@ -0,0 +1,23 @@ +export const dynamic = 'error' + +export default async function Page({ searchParams }) { + return ( +
+
+ This example uses `searchParams` but is configured with `dynamic = + 'error'` which should cause the page to fail to build +
+
+

searchParams

+ {Object.entries(searchParams).map(([key, value]) => { + return ( +
+

{key}

+
{value}
+
+ ) + })} +
+
+ ) +}