diff --git a/examples/cms-datocms/next.config.js b/examples/cms-datocms/next.config.js new file mode 100644 index 000000000000000..761cfcc4d39b227 --- /dev/null +++ b/examples/cms-datocms/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + images: { + domains: ['www.datocms-assets.com'], + }, +} diff --git a/examples/with-docker/Dockerfile b/examples/with-docker/Dockerfile index aa4b329d5e39965..57635340bb16f2a 100644 --- a/examples/with-docker/Dockerfile +++ b/examples/with-docker/Dockerfile @@ -6,6 +6,10 @@ WORKDIR /app COPY package.json yarn.lock ./ RUN yarn install --frozen-lockfile +# If using npm with a `package-lock.json` comment out above and use below instead +# COPY package.json package-lock.json / +# RUN npm install + # Rebuild the source code only when needed FROM node:16-alpine AS builder WORKDIR /app diff --git a/packages/next/lib/is-error.ts b/packages/next/lib/is-error.ts index 4560fa7e928e938..31ba3e4a47a5de7 100644 --- a/packages/next/lib/is-error.ts +++ b/packages/next/lib/is-error.ts @@ -1,4 +1,4 @@ -import { isPlainObject } from './is-plain-object' +import { isPlainObject } from '../shared/lib/is-plain-object' // We allow some additional attached properties for Errors export interface NextError extends Error { diff --git a/packages/next/lib/is-serializable-props.ts b/packages/next/lib/is-serializable-props.ts index 1202d4d98882d2d..e42407efc85f4fa 100644 --- a/packages/next/lib/is-serializable-props.ts +++ b/packages/next/lib/is-serializable-props.ts @@ -1,4 +1,7 @@ -import { isPlainObject, getObjectClassLabel } from './is-plain-object' +import { + isPlainObject, + getObjectClassLabel, +} from '../shared/lib/is-plain-object' const regexpPlainIdentifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/ diff --git a/packages/next/lib/is-plain-object.ts b/packages/next/shared/lib/is-plain-object.ts similarity index 100% rename from packages/next/lib/is-plain-object.ts rename to packages/next/shared/lib/is-plain-object.ts diff --git a/test/integration/react-streaming-and-server-components/test/index.test.js b/test/integration/react-streaming-and-server-components/test/index.test.js index a298254caaff563..18a5258fe7b5f22 100644 --- a/test/integration/react-streaming-and-server-components/test/index.test.js +++ b/test/integration/react-streaming-and-server-components/test/index.test.js @@ -1,6 +1,5 @@ /* eslint-env jest */ -import cheerio from 'cheerio' import { join } from 'path' import fs from 'fs-extra' import webdriver from 'next-webdriver' @@ -14,10 +13,11 @@ import { nextBuild as _nextBuild, nextStart as _nextStart, renderViaHTTP, - check, } from 'next-test-utils' import css from './css' +import rsc from './rsc' +import streaming from './streaming' const nodeArgs = ['-r', join(__dirname, '../../react-18/test/require-hook.js')] const appDir = join(__dirname, '../app') @@ -123,20 +123,6 @@ async function nextDev(dir, port) { }) } -async function resolveStreamResponse(response, onData) { - let result = '' - onData = onData || (() => {}) - await new Promise((resolve) => { - response.body.on('data', (chunk) => { - result += chunk.toString() - onData(chunk.toString(), result) - }) - - response.body.on('end', resolve) - }) - return result -} - describe('concurrentFeatures - basic', () => { it('should warn user for experimental risk with server components', async () => { const edgeRuntimeWarning = @@ -235,8 +221,8 @@ const customAppPageSuite = { expect(indexFlight).toContain('container-server') }) }, - before: () => appServerPage.write(rscAppPage), - after: () => appServerPage.delete(), + beforeAll: () => appServerPage.write(rscAppPage), + afterAll: () => appServerPage.delete(), } runSuite('Custom App', 'dev', customAppPageSuite) @@ -281,8 +267,8 @@ describe('concurrentFeatures - dev', () => { const cssSuite = { runTests: css, - before: () => appPage.write(appWithGlobalCss), - after: () => appPage.delete(), + beforeAll: () => appPage.write(appWithGlobalCss), + afterAll: () => appPage.delete(), } runSuite('CSS', 'dev', cssSuite) @@ -300,229 +286,76 @@ const documentSuite = { ) }) }, - before: () => documentPage.write(documentWithGip), - after: () => documentPage.delete(), + beforeAll: () => documentPage.write(documentWithGip), + afterAll: () => documentPage.delete(), } runSuite('document', 'dev', documentSuite) runSuite('document', 'prod', documentSuite) async function runBasicTests(context, env) { - const isDev = env === 'dev' - it('should render html correctly', async () => { - const homeHTML = await renderViaHTTP(context.appPort, '/', null, { - headers: { - 'x-next-test-client': 'test-util', - }, - }) - - // should have only 1 DOCTYPE - expect(homeHTML).toMatch(/^ { const path500HTML = await renderViaHTTP(context.appPort, '/err') - const pathNotFoundHTML = await renderViaHTTP( - context.appPort, - '/this-is-not-found' - ) - - const page404Content = 'custom-404-page' - - expect(homeHTML).toContain('component:index.server') - expect(homeHTML).toContain('env:env_var_test') - expect(homeHTML).toContain('header:test-util') - expect(homeHTML).toContain('path:/') - expect(homeHTML).toContain('foo.client') - - expect(dynamicRouteHTML1).toContain('query: dynamic1') - expect(dynamicRouteHTML2).toContain('query: dynamic2') - - const $404 = cheerio.load(path404HTML) - expect($404('#__next').text()).toBe(page404Content) - // In dev mode: it should show the error popup. + // In dev mode it should show the error popup. + const isDev = env === 'dev' expect(path500HTML).toContain(isDev ? 'Error: oops' : 'custom-500-page') - expect(pathNotFoundHTML).toContain(page404Content) - }) - - it('should disable cache for fizz pages', async () => { - const urls = ['/', '/next-api/image', '/next-api/link'] - await Promise.all( - urls.map(async (url) => { - const { headers } = await fetchViaHTTP(context.appPort, url) - expect(headers.get('cache-control')).toBe( - 'no-cache, no-store, max-age=0, must-revalidate' - ) - }) - ) - }) - - it('should support next/link', async () => { - const linkHTML = await renderViaHTTP(context.appPort, '/next-api/link') - const $ = cheerio.load(linkHTML) - const linkText = $('div[hidden] > a[href="/"]').text() - - expect(linkText).toContain('go home') - - const browser = await webdriver(context.appPort, '/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') - - expect(await browser.eval('window.beforeNav')).toBe(1) }) - it('should suspense next/image on server side', async () => { - const imageHTML = await renderViaHTTP(context.appPort, '/next-api/image') - const $ = cheerio.load(imageHTML) - const imageTag = $('div[hidden] > span > span > img') + it('should render 404 error correctly', async () => { + const path404HTML = await renderViaHTTP(context.appPort, '/404') + const pathNotFoundHTML = await renderViaHTTP(context.appPort, '/not-found') - expect(imageTag.attr('src')).toContain('data:image') + expect(path404HTML).toContain('custom-404-page') + expect(pathNotFoundHTML).toContain('custom-404-page') }) - it('should handle multiple named exports correctly', async () => { - const clientExportsHTML = await renderViaHTTP( + it('should render dynamic routes correctly', async () => { + const dynamicRoute1HTML = await renderViaHTTP( context.appPort, - '/client-exports' - ) - const $clientExports = cheerio.load(clientExportsHTML) - expect($clientExports('div[hidden] > div').text()).toBe('abcde') - - const browser = await webdriver(context.appPort, '/client-exports') - const text = await browser.waitForElementByCss('#__next').text() - expect(text).toBe('abcde') - }) - - it('should support multi-level server component imports', async () => { - const html = await renderViaHTTP(context.appPort, '/multi') - expect(html).toContain('bar.server.js:') - expect(html).toContain('foo.client') - }) - - it('should support streaming', async () => { - await fetchViaHTTP(context.appPort, '/streaming', null, {}).then( - async (response) => { - let gotFallback = false - let gotData = false - - await resolveStreamResponse(response, (_, result) => { - gotData = result.includes('next_streaming_data') - if (!gotFallback) { - gotFallback = result.includes('next_streaming_fallback') - if (gotFallback) { - expect(gotData).toBe(false) - } - } - }) - - expect(gotFallback).toBe(true) - expect(gotData).toBe(true) - } - ) - - // Should end up with "next_streaming_data". - const browser = await webdriver(context.appPort, '/streaming') - const content = await browser.eval(`window.document.body.innerText`) - expect(content).toMatchInlineSnapshot('"next_streaming_data"') - }) - - it('should support streaming flight request', async () => { - await fetchViaHTTP(context.appPort, '/?__flight__=1').then( - async (response) => { - const result = await resolveStreamResponse(response) - expect(result).toContain('component:index.server') - } + '/routes/dynamic1' ) - }) - - it('should support partial hydration with inlined server data', async () => { - await fetchViaHTTP(context.appPort, '/partial-hydration', null, {}).then( - async (response) => { - let gotFallback = false - let gotData = false - let gotInlinedData = false - - await resolveStreamResponse(response, (_, result) => { - gotInlinedData = result.includes('self.__next_s=') - gotData = result.includes('next_streaming_data') - if (!gotFallback) { - gotFallback = result.includes('next_streaming_fallback') - if (gotFallback) { - expect(gotData).toBe(false) - expect(gotInlinedData).toBe(false) - } - } - }) - - expect(gotFallback).toBe(true) - expect(gotData).toBe(true) - expect(gotInlinedData).toBe(true) - } + const dynamicRoute2HTML = await renderViaHTTP( + context.appPort, + '/routes/dynamic2' ) - // Should end up with "next_streaming_data". - const browser = await webdriver(context.appPort, '/partial-hydration') - 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' - ) + expect(dynamicRoute1HTML).toContain('query: dynamic1') + expect(dynamicRoute2HTML).toContain('query: dynamic2') }) it('should support api routes', async () => { const res = await renderViaHTTP(context.appPort, '/api/ping') expect(res).toContain('pong') }) + + rsc(context) + streaming(context) } -function runSuite(suiteName, env, { runTests, before, after }) { +function runSuite(suiteName, env, options) { const context = { appDir } describe(`${suiteName} ${env}`, () => { if (env === 'prod') { beforeAll(async () => { - before?.() + options.beforeAll?.() context.appPort = await findPort() - context.server = await nextDev(context.appDir, context.appPort) + await nextBuild(context.appDir) + context.server = await nextStart(context.appDir, context.appPort) }) } if (env === 'dev') { beforeAll(async () => { - before?.() + options.beforeAll?.() context.appPort = await findPort() context.server = await nextDev(context.appDir, context.appPort) }) } afterAll(async () => { - after?.() + options.afterAll?.() await killApp(context.server) }) - runTests(context, env) + options.runTests(context, env) }) } diff --git a/test/integration/react-streaming-and-server-components/test/rsc.js b/test/integration/react-streaming-and-server-components/test/rsc.js new file mode 100644 index 000000000000000..619c37c89b9e03e --- /dev/null +++ b/test/integration/react-streaming-and-server-components/test/rsc.js @@ -0,0 +1,75 @@ +/* eslint-env jest */ +import webdriver from 'next-webdriver' +import cheerio from 'cheerio' +import { renderViaHTTP, check } from 'next-test-utils' + +export default function (context) { + it('should render server components correctly', async () => { + const homeHTML = await renderViaHTTP(context.appPort, '/', null, { + headers: { + 'x-next-test-client': 'test-util', + }, + }) + + // should have only 1 DOCTYPE + expect(homeHTML).toMatch(/^ { + const html = await renderViaHTTP(context.appPort, '/multi') + expect(html).toContain('bar.server.js:') + expect(html).toContain('foo.client') + }) + + it('should support next/link in server components', async () => { + const linkHTML = await renderViaHTTP(context.appPort, '/next-api/link') + const $ = cheerio.load(linkHTML) + const linkText = $('div[hidden] > a[href="/"]').text() + + expect(linkText).toContain('go home') + + const browser = await webdriver(context.appPort, '/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') + + expect(await browser.eval('window.beforeNav')).toBe(1) + }) + + it('should suspense next/image in server components', async () => { + const imageHTML = await renderViaHTTP(context.appPort, '/next-api/image') + const $ = cheerio.load(imageHTML) + const imageTag = $('div[hidden] > span > span > img') + + expect(imageTag.attr('src')).toContain('data:image') + }) + + it('should handle multiple named exports correctly', async () => { + const clientExportsHTML = await renderViaHTTP( + context.appPort, + '/client-exports' + ) + const $clientExports = cheerio.load(clientExportsHTML) + expect($clientExports('div[hidden] > div').text()).toBe('abcde') + + const browser = await webdriver(context.appPort, '/client-exports') + const text = await browser.waitForElementByCss('#__next').text() + expect(text).toBe('abcde') + }) +} diff --git a/test/integration/react-streaming-and-server-components/test/streaming.js b/test/integration/react-streaming-and-server-components/test/streaming.js new file mode 100644 index 000000000000000..7748400b92b8289 --- /dev/null +++ b/test/integration/react-streaming-and-server-components/test/streaming.js @@ -0,0 +1,107 @@ +/* eslint-env jest */ +import webdriver from 'next-webdriver' +import { fetchViaHTTP } from 'next-test-utils' + +async function resolveStreamResponse(response, onData) { + let result = '' + onData = onData || (() => {}) + await new Promise((resolve) => { + response.body.on('data', (chunk) => { + result += chunk.toString() + onData(chunk.toString(), result) + }) + + response.body.on('end', resolve) + }) + return result +} + +export default function (context) { + it('should disable cache for fizz pages', async () => { + const urls = ['/', '/next-api/image', '/next-api/link'] + await Promise.all( + urls.map(async (url) => { + const { headers } = await fetchViaHTTP(context.appPort, url) + expect(headers.get('cache-control')).toBe( + 'no-cache, no-store, max-age=0, must-revalidate' + ) + }) + ) + }) + + it('should support streaming for fizz response', async () => { + await fetchViaHTTP(context.appPort, '/streaming', null, {}).then( + async (response) => { + let gotFallback = false + let gotData = false + + await resolveStreamResponse(response, (_, result) => { + gotData = result.includes('next_streaming_data') + if (!gotFallback) { + gotFallback = result.includes('next_streaming_fallback') + if (gotFallback) { + expect(gotData).toBe(false) + } + } + }) + + expect(gotFallback).toBe(true) + expect(gotData).toBe(true) + } + ) + + // Should end up with "next_streaming_data". + const browser = await webdriver(context.appPort, '/streaming') + const content = await browser.eval(`window.document.body.innerText`) + expect(content).toMatchInlineSnapshot('"next_streaming_data"') + }) + + it('should support streaming for flight response', async () => { + await fetchViaHTTP(context.appPort, '/?__flight__=1').then( + async (response) => { + const result = await resolveStreamResponse(response) + expect(result).toContain('component:index.server') + } + ) + }) + + it('should support partial hydration with inlined server data', async () => { + await fetchViaHTTP(context.appPort, '/partial-hydration', null, {}).then( + async (response) => { + let gotFallback = false + let gotData = false + let gotInlinedData = false + + await resolveStreamResponse(response, (_, result) => { + gotInlinedData = result.includes('self.__next_s=') + gotData = result.includes('next_streaming_data') + if (!gotFallback) { + gotFallback = result.includes('next_streaming_fallback') + if (gotFallback) { + expect(gotData).toBe(false) + expect(gotInlinedData).toBe(false) + } + } + }) + + expect(gotFallback).toBe(true) + expect(gotData).toBe(true) + expect(gotInlinedData).toBe(true) + } + ) + + // Should end up with "next_streaming_data". + const browser = await webdriver(context.appPort, '/partial-hydration') + 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' + ) + }) +}