diff --git a/packages/next/client/components/headers.ts b/packages/next/client/components/headers.ts index 2674f83270f7..f1f8d366230b 100644 --- a/packages/next/client/components/headers.ts +++ b/packages/next/client/components/headers.ts @@ -1,21 +1,5 @@ -import { DynamicServerError } from './hooks-server-context' import { requestAsyncStorage } from './request-async-storage' -import { staticGenerationAsyncStorage } from './static-generation-async-storage' - -function staticGenerationBailout(reason: string) { - const staticGenerationStore = - staticGenerationAsyncStorage && 'getStore' in staticGenerationAsyncStorage - ? staticGenerationAsyncStorage?.getStore() - : staticGenerationAsyncStorage - - if (staticGenerationStore?.isStaticGeneration) { - // TODO: honor the dynamic: 'force-static' - if (staticGenerationStore) { - staticGenerationStore.revalidate = 0 - } - throw new DynamicServerError(reason) - } -} +import { staticGenerationBailout } from './static-generation-bailout' export function headers() { staticGenerationBailout('headers') diff --git a/packages/next/client/components/navigation.ts b/packages/next/client/components/navigation.ts index 13148ac10a7f..755922ac789a 100644 --- a/packages/next/client/components/navigation.ts +++ b/packages/next/client/components/navigation.ts @@ -12,6 +12,7 @@ import { PathnameContext, // LayoutSegmentsContext, } from './hooks-client-context' +import { staticGenerationBailout } from './static-generation-bailout' const INTERNAL_URLSEARCHPARAMS_INSTANCE = Symbol( 'internal for urlsearchparams readonly' @@ -69,6 +70,7 @@ 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) const readonlySearchParams = useMemo(() => { return new ReadonlyURLSearchParams(searchParams) @@ -80,6 +82,7 @@ export function useSearchParams() { * Get the current pathname. For example usePathname() on /dashboard?foo=bar would return "/dashboard" */ export function usePathname(): string { + staticGenerationBailout('usePathname') return useContext(PathnameContext) } diff --git a/packages/next/client/components/static-generation-bailout.ts b/packages/next/client/components/static-generation-bailout.ts new file mode 100644 index 000000000000..9d30421e87c1 --- /dev/null +++ b/packages/next/client/components/static-generation-bailout.ts @@ -0,0 +1,17 @@ +import { DynamicServerError } from './hooks-server-context' +import { staticGenerationAsyncStorage } from './static-generation-async-storage' + +export function staticGenerationBailout(reason: string) { + const staticGenerationStore = + staticGenerationAsyncStorage && 'getStore' in staticGenerationAsyncStorage + ? staticGenerationAsyncStorage?.getStore() + : staticGenerationAsyncStorage + + if (staticGenerationStore?.isStaticGeneration) { + // TODO: honor the dynamic: 'force-static' + if (staticGenerationStore) { + staticGenerationStore.revalidate = 0 + } + throw new DynamicServerError(reason) + } +} diff --git a/test/e2e/app-dir/app-static.test.ts b/test/e2e/app-dir/app-static.test.ts index 9fd8557f6d9b..6c3f2e7baaa9 100644 --- a/test/e2e/app-dir/app-static.test.ts +++ b/test/e2e/app-dir/app-static.test.ts @@ -10,6 +10,8 @@ import webdriver from 'next-webdriver' const glob = promisify(globOrig) describe('app-dir static/dynamic handling', () => { + const isDev = (global as any).isNextDev + if ((global as any).isNextDeploy) { it('should skip next deploy for now', () => {}) return @@ -56,6 +58,8 @@ 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', + 'hooks/use-pathname/[slug]/page.js', + 'hooks/use-search-params/[slug]/page.js', 'ssr-auto/cache-no-store/page.js', 'ssr-auto/fetch-revalidate-zero/page.js', 'ssr-forced/page.js', @@ -359,9 +363,79 @@ describe('app-dir static/dynamic handling', () => { expect(secondDate).not.toBe(initialDate) }) - it('should show a message to leave feedback for `appDir`', async () => { - expect(next.cliOutput).toContain( - `Thank you for testing \`appDir\` please leave your feedback at https://nextjs.link/app-feedback` - ) + describe('hooks', () => { + describe('useSearchParams', () => { + if (isDev) { + it('should bail out to client rendering during SSG', async () => { + const res = await fetchViaHTTP( + next.url, + '/hooks/use-search-params/slug' + ) + const html = await res.text() + expect(html).toInclude('') + }) + } + + it('should have the correct values', async () => { + const browser = await webdriver( + next.url, + '/hooks/use-search-params/slug?first=value&second=other&third' + ) + + expect(await browser.elementByCss('#params-first').text()).toBe('value') + expect(await browser.elementByCss('#params-second').text()).toBe( + 'other' + ) + expect(await browser.elementByCss('#params-third').text()).toBe('') + expect(await browser.elementByCss('#params-not-real').text()).toBe( + 'N/A' + ) + }) + + it('should have values from canonical url on rewrite', async () => { + const browser = await webdriver( + next.url, + '/rewritten-use-search-params?first=a&second=b&third=c' + ) + + expect(await browser.elementByCss('#params-first').text()).toBe('a') + expect(await browser.elementByCss('#params-second').text()).toBe('b') + expect(await browser.elementByCss('#params-third').text()).toBe('c') + expect(await browser.elementByCss('#params-not-real').text()).toBe( + 'N/A' + ) + }) + }) + + describe('usePathname', () => { + if (isDev) { + it('should bail out to client rendering during SSG', async () => { + const res = await fetchViaHTTP(next.url, '/hooks/use-pathname/slug') + const html = await res.text() + expect(html).toInclude('') + }) + } + + it('should have the correct values', async () => { + const browser = await webdriver(next.url, '/hooks/use-pathname/slug') + + expect(await browser.elementByCss('#pathname').text()).toBe( + '/hooks/use-pathname/slug' + ) + }) + + it('should have values from canonical url on rewrite', async () => { + const browser = await webdriver(next.url, '/rewritten-use-pathname') + + expect(await browser.elementByCss('#pathname').text()).toBe( + '/rewritten-use-pathname' + ) + }) + }) + it('should show a message to leave feedback for `appDir`', async () => { + expect(next.cliOutput).toContain( + `Thank you for testing \`appDir\` please leave your feedback at https://nextjs.link/app-feedback` + ) + }) }) }) diff --git a/test/e2e/app-dir/app-static/app/hooks/use-pathname/[slug]/layout.js b/test/e2e/app-dir/app-static/app/hooks/use-pathname/[slug]/layout.js new file mode 100644 index 000000000000..1cd13ef8bb49 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/hooks/use-pathname/[slug]/layout.js @@ -0,0 +1,7 @@ +export default function Layout({ children }) { + return children +} + +export function generateStaticParams() { + return [{ slug: 'slug' }] +} diff --git a/test/e2e/app-dir/app-static/app/hooks/use-pathname/[slug]/page.js b/test/e2e/app-dir/app-static/app/hooks/use-pathname/[slug]/page.js new file mode 100644 index 000000000000..9e7d52cb993f --- /dev/null +++ b/test/e2e/app-dir/app-static/app/hooks/use-pathname/[slug]/page.js @@ -0,0 +1,12 @@ +'use client' +import { usePathname } from 'next/navigation' + +export const config = { + dynamicParams: false, +} + +export default function Page() { + const pathname = usePathname() + + return

{pathname}

+} diff --git a/test/e2e/app-dir/app-static/app/hooks/use-search-params/[slug]/layout.js b/test/e2e/app-dir/app-static/app/hooks/use-search-params/[slug]/layout.js new file mode 100644 index 000000000000..1cd13ef8bb49 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/hooks/use-search-params/[slug]/layout.js @@ -0,0 +1,7 @@ +export default function Layout({ children }) { + return children +} + +export function generateStaticParams() { + return [{ slug: 'slug' }] +} diff --git a/test/e2e/app-dir/app-static/app/hooks/use-search-params/[slug]/page.js b/test/e2e/app-dir/app-static/app/hooks/use-search-params/[slug]/page.js new file mode 100644 index 000000000000..76e03025e838 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/hooks/use-search-params/[slug]/page.js @@ -0,0 +1,19 @@ +'use client' +import { useSearchParams } from 'next/navigation' + +export const config = { + dynamicParams: false, +} + +export default function Page() { + const params = useSearchParams() + + return ( + <> +

{params.get('first') ?? 'N/A'}

+

{params.get('second') ?? 'N/A'}

+

{params.get('third') ?? 'N/A'}

+

{params.get('notReal') ?? 'N/A'}

+ + ) +} diff --git a/test/e2e/app-dir/app-static/next.config.js b/test/e2e/app-dir/app-static/next.config.js index 2eb9fe766a8a..771ea0e5638f 100644 --- a/test/e2e/app-dir/app-static/next.config.js +++ b/test/e2e/app-dir/app-static/next.config.js @@ -11,6 +11,15 @@ module.exports = { source: '/rewritten-to-dashboard', destination: '/dashboard', }, + { + source: '/rewritten-use-search-params', + destination: + '/hooks/use-search-params/slug?first=value&second=other%20value&third', + }, + { + source: '/rewritten-use-pathname', + destination: '/hooks/use-pathname/slug', + }, ], } }, diff --git a/test/e2e/app-dir/app/app/hooks/use-pathname/page.js b/test/e2e/app-dir/app/app/hooks/use-pathname/page.js index 221764506fa3..df89081381ae 100644 --- a/test/e2e/app-dir/app/app/hooks/use-pathname/page.js +++ b/test/e2e/app-dir/app/app/hooks/use-pathname/page.js @@ -8,7 +8,7 @@ export default function Page() { return ( <>

- hello from /hooks/use-pathname + hello from {pathname}

) diff --git a/test/e2e/app-dir/app/app/hooks/use-search-params/page.js b/test/e2e/app-dir/app/app/hooks/use-search-params/page.js index 4fb6bb13a375..d84e0f522cd5 100644 --- a/test/e2e/app-dir/app/app/hooks/use-search-params/page.js +++ b/test/e2e/app-dir/app/app/hooks/use-search-params/page.js @@ -7,15 +7,10 @@ export default function Page() { return ( <> -

- hello from /hooks/use-search-params -

+

{params.get('first') ?? 'N/A'}

+

{params.get('second') ?? 'N/A'}

+

{params.get('third') ?? 'N/A'}

+

{params.get('notReal') ?? 'N/A'}

) } diff --git a/test/e2e/app-dir/app/app/search-params-prop/page.js b/test/e2e/app-dir/app/app/search-params-prop/page.js new file mode 100644 index 000000000000..f6535877d785 --- /dev/null +++ b/test/e2e/app-dir/app/app/search-params-prop/page.js @@ -0,0 +1,15 @@ +'use client' + +export default function Page({ searchParams }) { + return ( +

+ hello from searchParams prop client +

+ ) +} diff --git a/test/e2e/app-dir/app/app/search-params-prop/server/page.js b/test/e2e/app-dir/app/app/search-params-prop/server/page.js new file mode 100644 index 000000000000..1a809657563c --- /dev/null +++ b/test/e2e/app-dir/app/app/search-params-prop/server/page.js @@ -0,0 +1,13 @@ +export default function Page({ searchParams }) { + return ( +

+ hello from searchParams prop server +

+ ) +} diff --git a/test/e2e/app-dir/app/middleware.js b/test/e2e/app-dir/app/middleware.js index b9163ca06ade..7ef85d902a36 100644 --- a/test/e2e/app-dir/app/middleware.js +++ b/test/e2e/app-dir/app/middleware.js @@ -42,4 +42,24 @@ export function middleware(request) { return NextResponse[method](new URL('/internal/success', request.url)) } + + if (request.nextUrl.pathname === '/search-params-prop-middleware-rewrite') { + return NextResponse.rewrite( + new URL( + '/search-params-prop?first=value&second=other%20value&third', + request.url + ) + ) + } + + if ( + request.nextUrl.pathname === '/search-params-prop-server-middleware-rewrite' + ) { + return NextResponse.rewrite( + new URL( + '/search-params-prop/server?first=value&second=other%20value&third', + request.url + ) + ) + } } diff --git a/test/e2e/app-dir/app/next.config.js b/test/e2e/app-dir/app/next.config.js index 0dcacc5e9453..81e780526f0f 100644 --- a/test/e2e/app-dir/app/next.config.js +++ b/test/e2e/app-dir/app/next.config.js @@ -12,6 +12,25 @@ module.exports = { source: '/rewritten-to-dashboard', destination: '/dashboard', }, + { + source: '/search-params-prop-rewrite', + destination: + '/search-params-prop?first=value&second=other%20value&third', + }, + { + source: '/search-params-prop-server-rewrite', + destination: + '/search-params-prop/server?first=value&second=other%20value&third', + }, + { + source: '/rewritten-use-search-params', + destination: + '/hooks/use-search-params?first=value&second=other%20value&third', + }, + { + source: '/rewritten-use-pathname', + destination: '/hooks/use-pathname', + }, { source: '/hooks/use-selected-layout-segment/rewritten', destination: diff --git a/test/e2e/app-dir/index.test.ts b/test/e2e/app-dir/index.test.ts index a8740a0d0d10..64ea0cdbc58a 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -1135,6 +1135,17 @@ describe('app dir', () => { '/hooks/use-pathname' ) }) + + it('should have the canonical url pathname on rewrite', async () => { + const html = await renderViaHTTP( + next.url, + '/rewritten-use-pathname' + ) + const $ = cheerio.load(html) + expect($('#pathname').attr('data-pathname')).toBe( + '/rewritten-use-pathname' + ) + }) }) describe('useSearchParams', () => { @@ -1144,11 +1155,22 @@ describe('app dir', () => { '/hooks/use-search-params?first=value&second=other%20value&third' ) const $ = cheerio.load(html) - const el = $('#params') - expect(el.attr('data-param-first')).toBe('value') - expect(el.attr('data-param-second')).toBe('other value') - expect(el.attr('data-param-third')).toBe('') - expect(el.attr('data-param-not-real')).toBe('N/A') + expect($('#params-first').text()).toBe('value') + expect($('#params-second').text()).toBe('other value') + expect($('#params-third').text()).toBe('') + expect($('#params-not-real').text()).toBe('N/A') + }) + + it('should have the canonical url search params on rewrite', async () => { + const html = await renderViaHTTP( + next.url, + '/rewritten-use-search-params?first=a&second=b&third=c' + ) + const $ = cheerio.load(html) + expect($('#params-first').text()).toBe('a') + expect($('#params-second').text()).toBe('b') + expect($('#params-third').text()).toBe('c') + expect($('#params-not-real').text()).toBe('N/A') }) }) @@ -1384,6 +1406,91 @@ describe('app dir', () => { }) }) }) + + describe('searchParams prop', () => { + describe('client component', () => { + it('should have the correct search params', async () => { + const html = await renderViaHTTP( + next.url, + '/search-params-prop?first=value&second=other%20value&third' + ) + const $ = cheerio.load(html) + const el = $('#params') + expect(el.attr('data-param-first')).toBe('value') + expect(el.attr('data-param-second')).toBe('other value') + expect(el.attr('data-param-third')).toBe('') + expect(el.attr('data-param-not-real')).toBe('N/A') + }) + + it('should have the correct search params on rewrite', async () => { + const html = await renderViaHTTP( + next.url, + '/search-params-prop-rewrite' + ) + const $ = cheerio.load(html) + const el = $('#params') + expect(el.attr('data-param-first')).toBe('value') + expect(el.attr('data-param-second')).toBe('other value') + expect(el.attr('data-param-third')).toBe('') + expect(el.attr('data-param-not-real')).toBe('N/A') + }) + + it('should have the correct search params on middleware rewrite', async () => { + const html = await renderViaHTTP( + next.url, + '/search-params-prop-middleware-rewrite' + ) + const $ = cheerio.load(html) + const el = $('#params') + expect(el.attr('data-param-first')).toBe('value') + expect(el.attr('data-param-second')).toBe('other value') + expect(el.attr('data-param-third')).toBe('') + expect(el.attr('data-param-not-real')).toBe('N/A') + }) + }) + + describe('server component', () => { + it('should have the correct search params', async () => { + const html = await renderViaHTTP( + next.url, + '/search-params-prop/server?first=value&second=other%20value&third' + ) + const $ = cheerio.load(html) + const el = $('#params') + expect(el.attr('data-param-first')).toBe('value') + expect(el.attr('data-param-second')).toBe('other value') + expect(el.attr('data-param-third')).toBe('') + expect(el.attr('data-param-not-real')).toBe('N/A') + }) + + it('should have the correct search params on rewrite', async () => { + const html = await renderViaHTTP( + next.url, + '/search-params-prop-server-rewrite' + ) + const $ = cheerio.load(html) + const el = $('#params') + expect(el.attr('data-param-first')).toBe('value') + expect(el.attr('data-param-second')).toBe('other value') + expect(el.attr('data-param-third')).toBe('') + expect(el.attr('data-param-not-real')).toBe('N/A') + }) + + it('should have the correct search params on middleware rewrite', async () => { + const html = await renderViaHTTP( + next.url, + '/search-params-prop-server-middleware-rewrite' + ) + const $ = cheerio.load(html) + const el = $('#params') + expect(el.attr('data-param-first')).toBe('value') + expect(el.attr('data-param-second')).toBe('other value') + expect(el.attr('data-param-third')).toBe('') + expect(el.attr('data-param-not-real')).toBe('N/A') + }) + }) + }) + describe('sass support', () => { describe('server layouts', () => { it('should support global sass/scss inside server layouts', async () => {