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 ? (
Hello world
+ } + `, + ], + ]) + ) + expect(await session.hasRedbox(true)).toBe(true) + + // Remove error + await session.patch( + 'app/page.js', + ` + export default function Page() { + returnHello 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!') + returnHello 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 ( +