diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000000..75f549ad6d9a4 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,24 @@ +{ + "recommendations": [ + // Linting / Formatting + "rust-lang.rust-analyzer", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "usernamehw.errorlens", + + // Testing + "orta.vscode-jest", + + // PR management / Reviewing + "github.vscode-pull-request-github", + + // Showing todo comments + "gruntfuggly.todo-tree", + + // Collaborating + "ms-vsliveshare.vsliveshare", + + // Debugging + "ms-vscode.vscode-js-profile-flame" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 306e2cb06dd86..43daf37569518 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,15 +1,54 @@ { + // Formatting using Prettier by default for all languages + "editor.defaultFormatter": "esbenp.prettier-vscode", + // Formatting using Prettier for JavaScript, overrides VSCode default. + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + // Formatting using Rust-Analyzer for Rust. + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer" + }, + // Linting using ESLint. "eslint.validate": [ "javascript", "javascriptreact", - { "language": "typescript", "autoFix": true }, - { "language": "typescriptreact", "autoFix": true } + "typescript", + "typescriptreact" ], - "debug.javascript.unmapMissingSources": true, + // Disable Jest autoRun as otherwise it will start running all tests the first time. "jest.autoRun": "off", + + // Debugging. + "debug.javascript.unmapMissingSources": true, + "files.exclude": { "**/node_modules": false, "node_modules": true, "*[!test]**/node_modules": true - } + }, + + // Ensure enough terminal history is preserved when running tests. + "terminal.integrated.scrollback": 10000, + + // Configure todo-tree to exclude node_modules, dist, and compiled. + "todo-tree.filtering.excludeGlobs": [ + "**/node_modules", + "**/dist", + "**/compiled" + ], + // Match TODO-APP in addition to other TODOs. + "todo-tree.general.tags": [ + "BUG", + "HACK", + "FIXME", + "TODO", + "XXX", + "[ ]", + "[x]", + "TODO-APP" + ], + + // Disable TypeScript surveys. + "typescript.surveys.enabled": false } diff --git a/packages/next/client/components/bailout-to-client-rendering.ts b/packages/next/client/components/bailout-to-client-rendering.ts new file mode 100644 index 0000000000000..2658280b863a7 --- /dev/null +++ b/packages/next/client/components/bailout-to-client-rendering.ts @@ -0,0 +1,19 @@ +import { suspense } from '../../shared/lib/dynamic-no-ssr' +import { staticGenerationAsyncStorage } from './static-generation-async-storage' + +export function bailoutToClientRendering(): boolean | never { + const staticGenerationStore = + staticGenerationAsyncStorage && 'getStore' in staticGenerationAsyncStorage + ? staticGenerationAsyncStorage?.getStore() + : staticGenerationAsyncStorage + + if (staticGenerationStore?.forceStatic) { + return true + } + + if (staticGenerationStore?.isStaticGeneration) { + suspense() + } + + return false +} diff --git a/packages/next/client/components/navigation.ts b/packages/next/client/components/navigation.ts index 2c8990a9e64a8..c2186730f891c 100644 --- a/packages/next/client/components/navigation.ts +++ b/packages/next/client/components/navigation.ts @@ -12,7 +12,7 @@ import { PathnameContext, // LayoutSegmentsContext, } from '../../shared/lib/hooks-client-context' -import { staticGenerationBailout } from './static-generation-bailout' +import { bailoutToClientRendering } from './bailout-to-client-rendering' const INTERNAL_URLSEARCHPARAMS_INSTANCE = Symbol( 'internal for urlsearchparams readonly' @@ -76,7 +76,8 @@ export function useSearchParams() { return new ReadonlyURLSearchParams(searchParams || new URLSearchParams()) }, [searchParams]) - if (staticGenerationBailout('useSearchParams')) { + if (bailoutToClientRendering()) { + // TODO-APP: handle dynamic = 'force-static' here and on the client return readonlySearchParams } diff --git a/packages/next/client/components/react-dev-overlay/hot-reloader-client.tsx b/packages/next/client/components/react-dev-overlay/hot-reloader-client.tsx index 84a1573c9ad9d..09d90f1fb2787 100644 --- a/packages/next/client/components/react-dev-overlay/hot-reloader-client.tsx +++ b/packages/next/client/components/react-dev-overlay/hot-reloader-client.tsx @@ -348,6 +348,13 @@ function processMessage( dispatcher.onRefresh() }) + if (process.env.__NEXT_TEST_MODE) { + if (self.__NEXT_HMR_CB) { + self.__NEXT_HMR_CB() + self.__NEXT_HMR_CB = null + } + } + return } case 'reloadPage': { diff --git a/packages/next/client/components/react-dev-overlay/internal/ReactDevOverlay.tsx b/packages/next/client/components/react-dev-overlay/internal/ReactDevOverlay.tsx index 931a1c93ca745..5d0c233240fb2 100644 --- a/packages/next/client/components/react-dev-overlay/internal/ReactDevOverlay.tsx +++ b/packages/next/client/components/react-dev-overlay/internal/ReactDevOverlay.tsx @@ -75,10 +75,10 @@ class ReactDevOverlay extends React.PureComponent< /> ) : hasBuildError ? ( - ) : hasRuntimeErrors ? ( - ) : reactError ? ( + ) : hasRuntimeErrors ? ( + ) : undefined} ) : undefined} diff --git a/packages/next/client/components/static-generation-bailout.ts b/packages/next/client/components/static-generation-bailout.ts index 4b665567d2024..0957622e91929 100644 --- a/packages/next/client/components/static-generation-bailout.ts +++ b/packages/next/client/components/static-generation-bailout.ts @@ -1,7 +1,7 @@ import { DynamicServerError } from './hooks-server-context' import { staticGenerationAsyncStorage } from './static-generation-async-storage' -export function staticGenerationBailout(reason: string) { +export function staticGenerationBailout(reason: string): boolean | never { const staticGenerationStore = staticGenerationAsyncStorage && 'getStore' in staticGenerationAsyncStorage ? staticGenerationAsyncStorage?.getStore() @@ -17,4 +17,6 @@ export function staticGenerationBailout(reason: string) { } throw new DynamicServerError(reason) } + + return false } diff --git a/test/development/acceptance-app/ReactRefreshLogBox.test.ts b/test/development/acceptance-app/ReactRefreshLogBox.test.ts index 7b7a416c68bc7..9fa41067ff307 100644 --- a/test/development/acceptance-app/ReactRefreshLogBox.test.ts +++ b/test/development/acceptance-app/ReactRefreshLogBox.test.ts @@ -1221,4 +1221,52 @@ describe('ReactRefreshLogBox app', () => { await cleanup() }) + + test('Server component errors should open up in fullscreen', async () => { + const { session, browser, cleanup } = await sandbox( + next, + new Map([ + // Start with error + [ + 'app/page.js', + ` + export default function Page() { + throw new Error('Server component error') + return

Hello world

+ } + `, + ], + ]) + ) + expect(await session.hasRedbox(true)).toBe(true) + + // Remove error + await session.patch( + 'app/page.js', + ` + export default function Page() { + return

Hello world

+ } + ` + ) + expect(await browser.waitForElementByCss('#text').text()).toBe( + 'Hello world' + ) + expect(await session.hasRedbox()).toBe(false) + + // Re-add error + await session.patch( + 'app/page.js', + ` + export default function Page() { + throw new Error('Server component error!') + return

Hello world

+ } + ` + ) + + expect(await session.hasRedbox(true)).toBe(true) + + await cleanup() + }) }) diff --git a/test/e2e/app-dir/app-static.test.ts b/test/e2e/app-dir/app-static.test.ts index 66a08b6c3a807..bca51dbeda6c9 100644 --- a/test/e2e/app-dir/app-static.test.ts +++ b/test/e2e/app-dir/app-static.test.ts @@ -65,7 +65,15 @@ describe('app-dir static/dynamic handling', () => { 'hooks/use-pathname/[slug]/page.js', 'hooks/use-pathname/slug.html', 'hooks/use-pathname/slug.rsc', - 'hooks/use-search-params/[slug]/page.js', + 'hooks/use-search-params.html', + 'hooks/use-search-params.rsc', + 'hooks/use-search-params/force-static.html', + 'hooks/use-search-params/force-static.rsc', + 'hooks/use-search-params/force-static/page.js', + 'hooks/use-search-params/page.js', + 'hooks/use-search-params/with-suspense.html', + 'hooks/use-search-params/with-suspense.rsc', + 'hooks/use-search-params/with-suspense/page.js', 'ssg-preview.html', 'ssg-preview.rsc', 'ssg-preview/[[...route]]/page.js', @@ -146,6 +154,21 @@ describe('app-dir static/dynamic handling', () => { initialRevalidateSeconds: false, srcRoute: '/hooks/use-pathname/[slug]', }, + '/hooks/use-search-params': { + dataRoute: '/hooks/use-search-params.rsc', + initialRevalidateSeconds: false, + srcRoute: '/hooks/use-search-params', + }, + '/hooks/use-search-params/force-static': { + dataRoute: '/hooks/use-search-params/force-static.rsc', + initialRevalidateSeconds: false, + srcRoute: '/hooks/use-search-params/force-static', + }, + '/hooks/use-search-params/with-suspense': { + dataRoute: '/hooks/use-search-params/with-suspense.rsc', + initialRevalidateSeconds: false, + srcRoute: '/hooks/use-search-params/with-suspense', + }, '/force-static/first': { dataRoute: '/force-static/first.rsc', initialRevalidateSeconds: false, @@ -526,23 +549,28 @@ describe('app-dir static/dynamic handling', () => { expect(secondDate).not.toBe(initialDate) }) - 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('') - }) - } + describe('useSearchParams', () => { + describe('client', () => { + it('should bailout to client rendering - without suspense boundary', async () => { + const browser = await webdriver( + next.url, + '/hooks/use-search-params?first=value&second=other&third' + ) - it('should have the correct values', async () => { + 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 bailout to client rendering - with suspense boundary', async () => { const browser = await webdriver( next.url, - '/hooks/use-search-params/slug?first=value&second=other&third' + '/hooks/use-search-params/with-suspense?first=value&second=other&third' ) expect(await browser.elementByCss('#params-first').text()).toBe('value') @@ -555,6 +583,31 @@ describe('app-dir static/dynamic handling', () => { ) }) + it.skip('should have empty search params on force-static', async () => { + const browser = await webdriver( + next.url, + '/hooks/use-search-params/force-static?first=value&second=other&third' + ) + + expect(await browser.elementByCss('#params-first').text()).toBe('N/A') + expect(await browser.elementByCss('#params-second').text()).toBe('N/A') + expect(await browser.elementByCss('#params-third').text()).toBe('N/A') + expect(await browser.elementByCss('#params-not-real').text()).toBe( + 'N/A' + ) + + await browser.elementById('to-use-search-params').click() + await browser.waitForElementByCss('#hooks-use-search-params') + + // Should not be empty after navigating to another page with useSearchParams + expect(await browser.elementByCss('#params-first').text()).toBe('1') + expect(await browser.elementByCss('#params-second').text()).toBe('2') + expect(await browser.elementByCss('#params-third').text()).toBe('3') + expect(await browser.elementByCss('#params-not-real').text()).toBe( + 'N/A' + ) + }) + // TODO-APP: re-enable after investigating rewrite params if (!(global as any).isNextDeploy) { it('should have values from canonical url on rewrite', async () => { @@ -572,52 +625,89 @@ describe('app-dir static/dynamic handling', () => { }) } }) - - // TODO: needs updating as usePathname should not bail - describe.skip('usePathname', () => { - if (isDev) { - it('should bail out to client rendering during SSG', async () => { - const res = await fetchViaHTTP(next.url, '/hooks/use-pathname/slug') + // Don't run these tests in dev mode since they won't be statically generated + if (!isDev) { + describe('server response', () => { + it('should bailout to client rendering - without suspense boundary', async () => { + const res = await fetchViaHTTP(next.url, '/hooks/use-search-params') 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') + it('should bailout to client rendering - with suspense boundary', async () => { + const res = await fetchViaHTTP( + next.url, + '/hooks/use-search-params/with-suspense' + ) + const html = await res.text() + expect(html).toInclude('

search params suspense

') + }) - expect(await browser.elementByCss('#pathname').text()).toBe( - '/hooks/use-pathname/slug' - ) - }) + it.skip('should have empty search params on force-static', async () => { + const res = await fetchViaHTTP( + next.url, + '/hooks/use-search-params/force-static?first=value&second=other&third' + ) + const html = await res.text() - it('should have values from canonical url on rewrite', async () => { - const browser = await webdriver(next.url, '/rewritten-use-pathname') + // Shouild not bail out to client rendering + expect(html).not.toInclude('

search params suspense

') - expect(await browser.elementByCss('#pathname').text()).toBe( - '/rewritten-use-pathname' - ) + // Use empty search params instead + const $ = cheerio.load(html) + expect($('#params-first').text()).toBe('N/A') + expect($('#params-second').text()).toBe('N/A') + expect($('#params-third').text()).toBe('N/A') + expect($('#params-not-real').text()).toBe('N/A') + }) }) - }) + } + }) - if (!(global as any).isNextDeploy) { - 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` - ) + // TODO: needs updating as usePathname should not bail + describe.skip('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 keep querystring on static page', async () => { - const browser = await webdriver(next.url, '/blog/tim?message=hello-world') - const checkUrl = async () => - expect(await browser.url()).toBe( - next.url + '/blog/tim?message=hello-world' - ) + 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') - checkUrl() - await waitFor(1000) - checkUrl() + expect(await browser.elementByCss('#pathname').text()).toBe( + '/rewritten-use-pathname' + ) + }) + }) + + if (!(global as any).isNextDeploy) { + 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` + ) }) + } + + it('should keep querystring on static page', async () => { + const browser = await webdriver(next.url, '/blog/tim?message=hello-world') + const checkUrl = async () => + expect(await browser.url()).toBe( + next.url + '/blog/tim?message=hello-world' + ) + + checkUrl() + await waitFor(1000) + checkUrl() }) }) 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 deleted file mode 100644 index 1cd13ef8bb49f..0000000000000 --- a/test/e2e/app-dir/app-static/app/hooks/use-search-params/[slug]/layout.js +++ /dev/null @@ -1,7 +0,0 @@ -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/force-static/page.js b/test/e2e/app-dir/app-static/app/hooks/use-search-params/force-static/page.js new file mode 100644 index 0000000000000..b7f4512e4a011 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/hooks/use-search-params/force-static/page.js @@ -0,0 +1,19 @@ +export const dynamic = 'force-static' + +import Link from 'next/link' +import { Suspense } from 'react' +import UseSearchParams from '../search-params' + +export default function Page() { + return ( + search params suspense

}> + + + To / + +
+ ) +} diff --git a/test/e2e/app-dir/app-static/app/hooks/use-search-params/page.js b/test/e2e/app-dir/app-static/app/hooks/use-search-params/page.js new file mode 100644 index 0000000000000..0a9904e2a08c1 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/hooks/use-search-params/page.js @@ -0,0 +1,10 @@ +import UseSearchParams from './search-params' + +export default function Page() { + return ( + <> +

+ + + ) +} 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/search-params.js similarity index 82% rename from test/e2e/app-dir/app-static/app/hooks/use-search-params/[slug]/page.js rename to test/e2e/app-dir/app-static/app/hooks/use-search-params/search-params.js index 76e03025e838e..53ff9fc5c94d1 100644 --- 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/search-params.js @@ -1,11 +1,7 @@ 'use client' import { useSearchParams } from 'next/navigation' -export const config = { - dynamicParams: false, -} - -export default function Page() { +export default function UseSearchParams() { const params = useSearchParams() return ( diff --git a/test/e2e/app-dir/app-static/app/hooks/use-search-params/with-suspense/page.js b/test/e2e/app-dir/app-static/app/hooks/use-search-params/with-suspense/page.js new file mode 100644 index 0000000000000..3cc4bde9ab1ea --- /dev/null +++ b/test/e2e/app-dir/app-static/app/hooks/use-search-params/with-suspense/page.js @@ -0,0 +1,10 @@ +import { Suspense } from 'react' +import UseSearchParams from '../search-params' + +export default function Page() { + return ( + search params suspense

}> + + + ) +} diff --git a/test/e2e/app-dir/app-static/next.config.js b/test/e2e/app-dir/app-static/next.config.js index e03d15af21fbd..63c84d019a4b2 100644 --- a/test/e2e/app-dir/app-static/next.config.js +++ b/test/e2e/app-dir/app-static/next.config.js @@ -14,8 +14,7 @@ module.exports = { }, { source: '/rewritten-use-search-params', - destination: - '/hooks/use-search-params/slug?first=value&second=other%20value&third', + destination: '/hooks/use-search-params', }, { source: '/rewritten-use-pathname',