import path from 'path' import { check, getClientReferenceManifest, getDistDir, retry, } from 'next-test-utils' import { nextTestSetup } from 'e2e-utils' import cheerio from 'cheerio' import { NEXT_RSC_UNION_QUERY, RSC_HEADER, } from 'next/dist/client/components/app-router-headers' // TODO: We should decide on an established pattern for gating test assertions // on experimental flags. For example, as a first step we could all the common // gates like this one into a single module. const isPPREnabledByDefault = process.env.__NEXT_CACHE_COMPONENTS === 'true' async function resolveStreamResponse(response: any, onData?: any) { let result = '' onData = onData || (() => {}) for await (const chunk of response.body) { result += chunk.toString() onData(chunk.toString(), result) } return result } describe('app dir - rsc basics', () => { const { next, isNextDev, isNextStart, isTurbopack } = nextTestSetup({ files: __dirname, resolutions: { '@babel/core': '7.22.18', '@babel/parser': '7.22.16', '@babel/types': '7.22.17', '@babel/traverse': '7.22.18', }, }) if (isNextDev && !isTurbopack) { it('should have correct client references keys in manifest', async () => { await next.render('/') await retry(() => { // Check that the client-side manifest is correct before any requests const clientReferenceManifest = getClientReferenceManifest( next, '/page' ) const clientModulesNames = Object.keys( clientReferenceManifest.clientModules ) expect(clientModulesNames).toSatisfyAll((name) => { const [, key] = name.split('#', 2) return key === undefined || key === '' || key === 'default' }) }) }) } describe('next internal shared context', () => { it('should not error if just load next/navigation module in pages/api', async () => { const res = await next.fetch('/api/navigation') expect(res.status).toBe(200) expect(await res.text()).toBe('just work') }) it('should not error if just load next/router module in app page', async () => { const res = await next.fetch('/shared-context/server') expect(res.status).toBe(200) expect(await res.text()).toContain('just work') }) }) it('should correctly render page returning null', async () => { const browser = await next.browser('/return-null/page') expect( await browser .elementByCss('#return-null-layout', { state: 'attached' }) .text() ).toBeEmpty() }) it('should correctly render component returning null', async () => { const browser = await next.browser('/return-null/component') expect( await browser .elementByCss('#return-null-layout', { state: 'attached' }) .text() ).toBeEmpty() }) it('should correctly render layout returning null', async () => { const browser = await next.browser('/return-null/layout') expect( await browser .elementByCss('#return-null-layout', { state: 'attached' }) .text() ).toBeEmpty() }) it('should correctly render page returning undefined', async () => { const browser = await next.browser('/return-undefined/page') expect( await browser .elementByCss('#return-undefined-layout', { state: 'attached' }) .text() ).toBeEmpty() }) it('should correctly render component returning undefined', async () => { const browser = await next.browser('/return-undefined/component') expect( await browser .elementByCss('#return-undefined-layout', { state: 'attached' }) .text() ).toBeEmpty() }) it('should correctly render layout returning undefined', async () => { const browser = await next.browser('/return-undefined/layout') expect( await browser .elementByCss('#return-undefined-layout', { state: 'attached' }) .text() ).toBeEmpty() }) it('should handle named client components imported as page', async () => { const $ = await next.render$('/reexport-named') expect($('#client-title').text()).toBe('Client Title') }) it('should handle client components imported as namespace', async () => { const $ = await next.render$('/reexport-namespace') expect($('#foo').text()).toBe('Foo') }) it('should render server components correctly', async () => { const homeHTML = await next.render('/', null, { headers: { 'x-next-test-client': 'test-util', }, }) // should have only 1 DOCTYPE expect(homeHTML).toMatch(/^') expect(homeHTML).toContain( '' ) expect(homeHTML).toContain('header:test-util') const inlineFlightContents = [] const $ = cheerio.load(homeHTML) expect($('h1').text()).toBe('component:index.server') $('script').each((_index, tag) => { const content = $(tag).text() if (content) inlineFlightContents.push(content) }) const internalQueries = [ '__nextFallback', '__nextLocale', '__nextDefaultLocale', '__nextIsNotFound', ] const hasNextInternalQuery = inlineFlightContents.some((content) => internalQueries.some((query) => content.includes(query)) ) expect(hasNextInternalQuery).toBe(false) expect(next.cliOutput).not.toContain( 'Each child in a list should have a unique "key" prop' ) }) it('should reuse the inline flight response without sending extra requests', async () => { const flightRequests: string[] = [] let requestsCount = 0 const browser = await next.browser('/root', { beforePageLoad(page) { page.on('request', (request) => { requestsCount++ const headers = request.headers() if ( headers['rsc'] === '1' && // Prefetches also include `rsc` headers['next-router-prefetch'] !== '1' ) { flightRequests.push(request.url()) } }) }, }) await browser.waitForIdleNetwork() expect(requestsCount).toBeGreaterThan(0) expect(flightRequests).toEqual([]) }) it('should support multi-level server component imports', async () => { const html = await next.render('/multi') expect(html).toContain('bar.server.js:') expect(html).toContain('foo.client') }) it('should create client reference successfully for all file conventions', async () => { const html = await next.render('/conventions') expect(html).toContain('it works') }) it('should be able to navigate between rsc routes', async () => { const browser = await next.browser('/root') await browser.waitForElementByCss('#goto-next-link').click() await new Promise((res) => setTimeout(res, 1000)) await check(() => browser.url(), `${next.url}/next-api/link`) await browser.waitForElementByCss('#goto-home').click() await new Promise((res) => setTimeout(res, 1000)) await check(() => browser.url(), `${next.url}/root`) const content = await browser.elementByCss('body').text() expect(content).toContain('component:root.server') await browser.waitForElementByCss('#goto-streaming-rsc').click() // Wait for navigation and streaming to finish. await check( () => browser.elementByCss('#content').text(), 'next_streaming_data' ) expect(await browser.url()).toBe(`${next.url}/streaming-rsc`) }) it('should handle streaming server components correctly', async () => { const browser = await next.browser('/streaming-rsc') const content = await browser.eval( `document.querySelector('#content').innerText` ) expect(content).toMatchInlineSnapshot('"next_streaming_data"') }) it('should track client components in dynamic imports', async () => { const html = await next.render('/dynamic') expect(html).toContain('dynamic data!') }) describe.each(['node', 'edge'])( 'client references with TLA (%s)', (runtime) => { let url = `/async-client${runtime === 'edge' ? '/edge' : ''}` it('should support TLA in sync client reference imports', async () => { const html = await next.render(url + '/sync') expect(html).toContain('client async') }) it('should support TLA in lazy client reference', async () => { const html = await next.render(url + '/lazy') expect(html).toContain('client async') }) } ) if (isPPREnabledByDefault) { // TODO: Figure out why this test is flaky when PPR is enabled } else { it('should support next/link in server components', async () => { const $ = await next.render$('/next-api/link') const linkText = $('body a[href="/root"]').text() expect(linkText).toContain('home') const browser = await next.browser('/next-api/link') // We need to make sure the app is fully hydrated before clicking, otherwise // it will be a full redirection instead of being taken over by the next // router. This timeout prevents it being flaky caused by fast refresh's // rebuilding event. await new Promise((res) => setTimeout(res, 1000)) await browser.eval('window.beforeNav = 1') await browser.waitForElementByCss('#next_id').click() await check(() => browser.elementByCss('#query').text(), 'query:1') await browser.waitForElementByCss('#next_id').click() await check(() => browser.elementByCss('#query').text(), 'query:2') if (isNextDev) { expect(await browser.eval('window.beforeNav')).toBe(1) } }) } it('should link correctly with next/link without mpa navigation to the page', async () => { // Select the button which is not hidden but rendered const selector = '#goto-next-link' const browser = await next.browser('/root', {}) await browser.eval('window.didNotReloadPage = true') await browser.elementByCss(selector).click().waitForElementByCss('#query') expect(await browser.eval('window.didNotReloadPage')).toBe(true) const text = await browser.elementByCss('#query').text() expect(text).toBe('query:0') }) it('should escape streaming data correctly', async () => { const browser = await next.browser('/escaping-rsc') const manipulated = await browser.eval(`window.__manipulated_by_injection`) expect(manipulated).toBe(undefined) }) it('should render built-in 404 page for missing route if pagesDir is not presented', async () => { const res = await next.fetch('/does-not-exist') expect(res.status).toBe(404) const html = await res.text() expect(html).toContain('This page could not be found') }) it('should suspense next/legacy/image in server components', async () => { const $ = await next.render$('/next-api/image-legacy') const imageTag = $('#myimg') expect(imageTag.attr('src')).toContain('data:image') }) it('should suspense next/image in server components', async () => { const $ = await next.render$('/next-api/image-new') const imageTag = $('#myimg') expect(imageTag.attr('src')).toMatch(/test.+jpg/) }) it('should handle various kinds of exports correctly', async () => { const $ = await next.render$('/various-exports') const content = $('body').text() expect(content).toContain('abcde') expect(content).toContain('default-export-arrow.client') expect(content).toContain('named.client') const browser = await next.browser('/various-exports') const hydratedContent = await browser.waitForElementByCss('body').text() expect(hydratedContent).toContain('abcde') expect(hydratedContent).toContain('default-export-arrow.client') expect(hydratedContent).toContain('named.client') expect(hydratedContent).toContain('cjs-shared') expect(hydratedContent).toContain('cjs-client') expect(hydratedContent).toContain('Export All: one, two, two') }) it('should support native modules in server component', async () => { const $ = await next.render$('/native-module') const content = $('body').text() expect(content).toContain('fs: function') expect(content).toContain('foo.client') }) it('should resolve different kinds of components correctly', async () => { const $ = await next.render$('/shared') const main = $('#main').html() const content = $('#bar').text() // Should have 5 occurrences of "client_component". expect(Array.from(main.matchAll(/client_component/g)).length).toBe(5) // Should have 2 occurrences of "shared:server", and 2 occurrences of // "shared:client". const sharedServerModule = Array.from(main.matchAll(/shared:server:(\d+)/g)) const sharedClientModule = Array.from(main.matchAll(/shared:client:(\d+)/g)) expect(sharedServerModule.length).toBe(2) expect(sharedClientModule.length).toBe(2) // Should have 2 modules created for the shared component. expect(sharedServerModule[0][1]).toBe(sharedServerModule[1][1]) expect(sharedClientModule[0][1]).toBe(sharedClientModule[1][1]) expect(sharedServerModule[0][1]).not.toBe(sharedClientModule[0][1]) expect(content).toContain('bar.server.js:') }) it('should stick to the url without trailing /page suffix', async () => { const browser = await next.browser('/edge/dynamic') const indexUrl = await browser.url() await browser.loadPage(`${next.url}/edge/dynamic/123`, { disableCache: false, }) const dynamicRouteUrl = await browser.url() expect(indexUrl).toBe(`${next.url}/edge/dynamic`) expect(dynamicRouteUrl).toBe(`${next.url}/edge/dynamic/123`) }) describe.each(['node', 'edge'])(`%s`, (runtime) => { it('should handle dynamic routes when URL segment matches the folder bracket syntax', async () => { const browser = await next.browser(`/${runtime}/dynamic/[id]`) expect(await browser.elementByCss('body').text()).toBe( 'dynamic route [id] page' ) }) }) it('should support streaming for flight response', async () => { await next .fetch(`/?${NEXT_RSC_UNION_QUERY}`, { headers: { [RSC_HEADER]: '1', }, }) .then(async (response) => { const result = await resolveStreamResponse(response) expect(result).toContain('component:index.server') if (isNextDev) { expect(result).toContain('"b":"development"') } }) }) it('should support partial hydration with inlined server data', async () => { await next.fetch('/partial-hydration').then(async (response) => { let gotFallback = false let gotData = false let gotInlinedData = false await resolveStreamResponse(response, (_, result) => { gotInlinedData = result.includes('self.__next_f=') gotData = result.includes('next_streaming_data') if (!gotFallback) { gotFallback = result.includes('next_streaming_fallback') if (gotFallback) { expect(gotData).toBe(false) // TODO-APP: investigate the failing test // expect(gotInlinedData).toBe(false) } } }) expect(gotFallback).toBe(true) expect(gotData).toBe(true) expect(gotInlinedData).toBe(true) }) }) it('should not apply rsc syntax checks in pages/api', async () => { const res = await next.fetch('/api/import-test') expect(await res.text()).toBe('Hello from import-test.js') }) // TODO: (PPR) remove once PPR is stable // TODO(new-dev-overlay): remove once new dev overlay is stable const bundledReactVersionPattern = process.env.__NEXT_CACHE_COMPONENTS === 'true' ? '-experimental-' : '-canary-' it('should not use bundled react for pages with app', async () => { const ssrPaths = ['/pages-react', '/edge-pages-react'] const promises = ssrPaths.map(async (pathname) => { const resPages$ = await next.render$(pathname) const ssrPagesReactVersions = [ await resPages$('#react').text(), await resPages$('#react-dom').text(), await resPages$('#react-dom-server').text(), ] ssrPagesReactVersions.forEach((version) => { expect(version).not.toMatch(bundledReactVersionPattern) }) }) await Promise.all(promises) const resApp$ = await next.render$('/app-react') const ssrAppReactVersions = [ await resApp$('#react').text(), await resApp$('#react-dom').text(), ] ssrAppReactVersions.forEach((version) => expect(version).toMatch(bundledReactVersionPattern) ) const browser = await next.browser('/pages-react') const browserPagesReactVersions = await browser.eval(` [ document.querySelector('#react').innerText, document.querySelector('#react-dom').innerText, document.querySelector('#react-dom-server').innerText, ] `) await browser.loadPage(next.url + '/edge-pages-react') const browserEdgePagesReactVersions = await browser.eval(` [ document.querySelector('#react').innerText, document.querySelector('#react-dom').innerText, document.querySelector('#react-dom-server').innerText, ] `) browserPagesReactVersions.forEach((version) => { expect(version).not.toMatch(bundledReactVersionPattern) }) browserEdgePagesReactVersions.forEach((version) => { expect(version).not.toMatch(bundledReactVersionPattern) }) }) it('should use canary react for app', async () => { const resPages$ = await next.render$('/app-react') const [ ssrReact, ssrReactDOM, ssrClientReact, ssrClientReactDOM, ssrClientReactDOMServer, ] = [ resPages$('#react').text(), resPages$('#react-dom').text(), resPages$('#client-react').text(), resPages$('#client-react-dom').text(), resPages$('#client-react-dom-server').text(), ] expect({ ssrReact, ssrReactDOM, ssrClientReact, ssrClientReactDOM, ssrClientReactDOMServer, }).toEqual({ ssrReact: expect.stringMatching(bundledReactVersionPattern), ssrReactDOM: expect.stringMatching(bundledReactVersionPattern), ssrClientReact: expect.stringMatching(bundledReactVersionPattern), ssrClientReactDOM: expect.stringMatching(bundledReactVersionPattern), ssrClientReactDOMServer: expect.stringMatching( bundledReactVersionPattern ), }) const browser = await next.browser('/app-react') const [ browserReact, browserReactDOM, browserClientReact, browserClientReactDOM, browserClientReactDOMServer, ] = await browser.eval(` [ document.querySelector('#react').innerText, document.querySelector('#react-dom').innerText, document.querySelector('#client-react').innerText, document.querySelector('#client-react-dom').innerText, document.querySelector('#client-react-dom-server').innerText, ] `) expect({ browserReact, browserReactDOM, browserClientReact, browserClientReactDOM, browserClientReactDOMServer, }).toEqual({ browserReact: expect.stringMatching(bundledReactVersionPattern), browserReactDOM: expect.stringMatching(bundledReactVersionPattern), browserClientReact: expect.stringMatching(bundledReactVersionPattern), browserClientReactDOM: expect.stringMatching(bundledReactVersionPattern), browserClientReactDOMServer: expect.stringMatching( bundledReactVersionPattern ), }) }) it('should be able to call legacy react-dom/server APIs in client components', async () => { const $ = await next.render$('/app-react') const content = $('#markup').text() expect(content).toBe( '
React Static Markup
' ) if (isNextDev) { const filePath = 'app/app-react/client-react.js' const fileContent = await next.readFile(filePath) await next.patchFile( filePath, fileContent.replace( `import { renderToStaticMarkup } from 'react-dom/server'`, `import { renderToStaticMarkup } from 'react-dom/server.browser'` ) ) const browser = await next.browser('/app-react') const markupContentInBrowser = await browser .elementByCss('#markup') .text() expect(markupContentInBrowser).toBe( '
React Static Markup
' ) await next.patchFile(filePath, fileContent) } }) // disable this flaky test it.skip('should support partial hydration with inlined server data in browser', async () => { // Should end up with "next_streaming_data". const browser = await next.browser('/partial-hydration', { waitHydration: false, }) const content = await browser.eval(`window.document.body.innerText`) expect(content).toContain('next_streaming_data') // Should support partial hydration: the boundary should still be pending // while another part is hydrated already. expect(await browser.eval(`window.partial_hydration_suspense_result`)).toBe( 'next_streaming_fallback' ) expect(await browser.eval(`window.partial_hydration_counter_result`)).toBe( 'count: 1' ) }) if (isNextStart) { it('should generate edge SSR manifests for Node.js', async () => { const requiredServerFiles = JSON.parse( await next.readFile(`${getDistDir()}/required-server-files.json`) ).files const files = ['middleware-build-manifest.js', 'middleware-manifest.json'] let promises = files.map(async (file) => { expect( await next.hasFile(path.join(`${getDistDir()}/server`, file)) ).toBe(true) }) await Promise.all(promises) promises = requiredServerFiles.map(async (file) => { expect(await next.hasFile(file)).toBe(true) }) await Promise.all(promises) }) } })