diff --git a/packages/next/client/root-index.tsx b/packages/next/client/root-index.tsx new file mode 100644 index 000000000000000..e37c069470e6b8b --- /dev/null +++ b/packages/next/client/root-index.tsx @@ -0,0 +1,205 @@ +/* global location */ +import '../build/polyfills/polyfill-module' +// @ts-ignore react-dom/client exists when using React 18 +import ReactDOMClient from 'react-dom/client' +// @ts-ignore startTransition exists when using React 18 +import React, { useState, startTransition } from 'react' +import { RefreshContext } from './streaming/refresh' +import { createFromFetch } from 'next/dist/compiled/react-server-dom-webpack' + +/// + +export const version = process.env.__NEXT_VERSION + +const appElement: HTMLElement | Document | null = document + +let reactRoot: any = null + +function renderReactElement( + domEl: HTMLElement | Document, + fn: () => JSX.Element +): void { + const reactEl = fn() + if (!reactRoot) { + // Unlike with createRoot, you don't need a separate root.render() call here + reactRoot = (ReactDOMClient as any).hydrateRoot(domEl, reactEl) + } else { + reactRoot.render(reactEl) + } +} + +const getCacheKey = () => { + const { pathname, search } = location + return pathname + search +} + +const encoder = new TextEncoder() + +let initialServerDataBuffer: string[] | undefined = undefined +let initialServerDataWriter: WritableStreamDefaultWriter | undefined = undefined +let initialServerDataLoaded = false +let initialServerDataFlushed = false + +function nextServerDataCallback(seg: [number, string, string]) { + if (seg[0] === 0) { + initialServerDataBuffer = [] + } else { + if (!initialServerDataBuffer) + throw new Error('Unexpected server data: missing bootstrap script.') + + if (initialServerDataWriter) { + initialServerDataWriter.write(encoder.encode(seg[2])) + } else { + initialServerDataBuffer.push(seg[2]) + } + } +} + +// There might be race conditions between `nextServerDataRegisterWriter` and +// `DOMContentLoaded`. The former will be called when React starts to hydrate +// the root, the latter will be called when the DOM is fully loaded. +// For streaming, the former is called first due to partial hydration. +// For non-streaming, the latter can be called first. +// Hence, we use two variables `initialServerDataLoaded` and +// `initialServerDataFlushed` to make sure the writer will be closed and +// `initialServerDataBuffer` will be cleared in the right time. +function nextServerDataRegisterWriter(writer: WritableStreamDefaultWriter) { + if (initialServerDataBuffer) { + initialServerDataBuffer.forEach((val) => { + writer.write(encoder.encode(val)) + }) + if (initialServerDataLoaded && !initialServerDataFlushed) { + writer.close() + initialServerDataFlushed = true + initialServerDataBuffer = undefined + } + } + + initialServerDataWriter = writer +} + +// When `DOMContentLoaded`, we can close all pending writers to finish hydration. +const DOMContentLoaded = function () { + if (initialServerDataWriter && !initialServerDataFlushed) { + initialServerDataWriter.close() + initialServerDataFlushed = true + initialServerDataBuffer = undefined + } + initialServerDataLoaded = true +} +// It's possible that the DOM is already loaded. +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', DOMContentLoaded, false) +} else { + DOMContentLoaded() +} + +const nextServerDataLoadingGlobal = ((self as any).__next_s = + (self as any).__next_s || []) +nextServerDataLoadingGlobal.forEach(nextServerDataCallback) +nextServerDataLoadingGlobal.push = nextServerDataCallback + +function createResponseCache() { + return new Map() +} +const rscCache = createResponseCache() + +function fetchFlight(href: string, props?: any) { + const url = new URL(href, location.origin) + const searchParams = url.searchParams + searchParams.append('__flight__', '1') + if (props) { + searchParams.append('__props__', JSON.stringify(props)) + } + return fetch(url.toString()) +} + +function useServerResponse(cacheKey: string, serialized?: string) { + let response = rscCache.get(cacheKey) + if (response) return response + + if (initialServerDataBuffer) { + const t = new TransformStream() + const writer = t.writable.getWriter() + response = createFromFetch(Promise.resolve({ body: t.readable })) + nextServerDataRegisterWriter(writer) + } else { + const fetchPromise = serialized + ? (() => { + const t = new TransformStream() + const writer = t.writable.getWriter() + writer.ready.then(() => { + writer.write(new TextEncoder().encode(serialized)) + }) + return Promise.resolve({ body: t.readable }) + })() + : fetchFlight(getCacheKey()) + response = createFromFetch(fetchPromise) + } + + rscCache.set(cacheKey, response) + return response +} + +const ServerRoot = ({ + cacheKey, + serialized, +}: { + cacheKey: string + serialized?: string +}) => { + React.useEffect(() => { + rscCache.delete(cacheKey) + }) + const response = useServerResponse(cacheKey, serialized) + const root = response.readRoot() + return root +} + +function Root({ children }: React.PropsWithChildren<{}>): React.ReactElement { + if (process.env.__NEXT_TEST_MODE) { + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useEffect(() => { + window.__NEXT_HYDRATED = true + + if (window.__NEXT_HYDRATED_CB) { + window.__NEXT_HYDRATED_CB() + } + }, []) + } + + return children as React.ReactElement +} + +const RSCComponent = (props: any) => { + const cacheKey = getCacheKey() + const { __flight_serialized__ } = props + const [, dispatch] = useState({}) + const rerender = () => dispatch({}) + // If there is no cache, or there is serialized data already + function refreshCache(nextProps: any) { + startTransition(() => { + const currentCacheKey = getCacheKey() + const response = createFromFetch(fetchFlight(currentCacheKey, nextProps)) + + rscCache.set(currentCacheKey, response) + rerender() + }) + } + + return ( + + + + ) +} + +export function hydrate() { + renderReactElement(appElement!, () => ( + + + + + + )) +} diff --git a/packages/next/client/root-next.js b/packages/next/client/root-next.js new file mode 100644 index 000000000000000..696a23f8390e129 --- /dev/null +++ b/packages/next/client/root-next.js @@ -0,0 +1,8 @@ +import { hydrate, version } from './root-index' + +window.next = { + version, + root: true, +} + +hydrate() diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index e6a783bfee39309..c90d19cebba8940 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -95,6 +95,7 @@ export interface ExperimentalConfig { scrollRestoration?: boolean externalDir?: boolean conformance?: boolean + rootDir?: boolean amp?: { optimizer?: any validator?: string @@ -490,6 +491,7 @@ export const defaultConfig: NextConfig = { swcFileReading: true, craCompat: false, esmExternals: true, + rootDir: false, // default to 50MB limit isrMemoryCacheSize: 50 * 1024 * 1024, serverComponents: false, diff --git a/test/e2e/root-dir/app/next.config.js b/test/e2e/root-dir/app/next.config.js new file mode 100644 index 000000000000000..9abd7b65b73ae16 --- /dev/null +++ b/test/e2e/root-dir/app/next.config.js @@ -0,0 +1,8 @@ +module.exports = { + experimental: { + rootDir: true, + runtime: 'nodejs', + reactRoot: true, + serverComponents: true, + }, +} diff --git a/test/e2e/root-dir/app/pages/blog/[slug].js b/test/e2e/root-dir/app/pages/blog/[slug].js new file mode 100644 index 000000000000000..c504e8e49fe1e5a --- /dev/null +++ b/test/e2e/root-dir/app/pages/blog/[slug].js @@ -0,0 +1,7 @@ +export default function Page(props) { + return ( + <> +

hello from pages/blog/[slug]

+ + ) +} diff --git a/test/e2e/root-dir/app/pages/index.js b/test/e2e/root-dir/app/pages/index.js new file mode 100644 index 000000000000000..8846b6ec63e5d65 --- /dev/null +++ b/test/e2e/root-dir/app/pages/index.js @@ -0,0 +1,7 @@ +export default function Page(props) { + return ( + <> +

hello from pages/index

+ + ) +} diff --git a/test/e2e/root-dir/app/public/hello.txt b/test/e2e/root-dir/app/public/hello.txt new file mode 100644 index 000000000000000..95d09f2b1015934 --- /dev/null +++ b/test/e2e/root-dir/app/public/hello.txt @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/test/e2e/root-dir/app/root.server.js b/test/e2e/root-dir/app/root.server.js new file mode 100644 index 000000000000000..d270310372cf689 --- /dev/null +++ b/test/e2e/root-dir/app/root.server.js @@ -0,0 +1,11 @@ +export default function Root({ headChildren, bodyChildren }) { + return ( + + + {headChildren} + Test + + {bodyChildren} + + ) +} diff --git a/test/e2e/root-dir/app/root/client-component-route.client.js b/test/e2e/root-dir/app/root/client-component-route.client.js new file mode 100644 index 000000000000000..64b3f98c1f12ba6 --- /dev/null +++ b/test/e2e/root-dir/app/root/client-component-route.client.js @@ -0,0 +1,12 @@ +import { useState, useEffect } from 'react' +export default function ClientComponentRoute() { + const [count, setCount] = useState(0) + useEffect(() => { + setCount(1) + }, [count]) + return ( + <> +

hello from root/client-component-route. count: {count}

+ + ) +} diff --git a/test/e2e/root-dir/app/root/client-nested.client.js b/test/e2e/root-dir/app/root/client-nested.client.js new file mode 100644 index 000000000000000..6f835e03f4c60fd --- /dev/null +++ b/test/e2e/root-dir/app/root/client-nested.client.js @@ -0,0 +1,15 @@ +import { useState, useEffect } from 'react' + +export default function ClientNestedLayout({ children }) { + const [count, setCount] = useState(0) + useEffect(() => { + setCount(1) + }, []) + return ( + <> +

Client Nested. Count: {count}

+ + {children} + + ) +} diff --git a/test/e2e/root-dir/app/root/client-nested/index.server.js b/test/e2e/root-dir/app/root/client-nested/index.server.js new file mode 100644 index 000000000000000..c71ce306e7b94d1 --- /dev/null +++ b/test/e2e/root-dir/app/root/client-nested/index.server.js @@ -0,0 +1,7 @@ +export default function ClientPage() { + return ( + <> +

hello from root/client-nested

+ + ) +} diff --git a/test/e2e/root-dir/app/root/conditional/[slug].server.js b/test/e2e/root-dir/app/root/conditional/[slug].server.js new file mode 100644 index 000000000000000..14dae08e2713ac6 --- /dev/null +++ b/test/e2e/root-dir/app/root/conditional/[slug].server.js @@ -0,0 +1,27 @@ +export async function getServerSideProps({ params }) { + if (params.slug === 'nonexistent') { + return { + notFound: true, + } + } + return { + props: { + isUser: params.slug === 'tim', + isBoth: params.slug === 'both', + }, + } +} + +export default function UserOrTeam({ isUser, isBoth, user, team }) { + return ( + <> + {isUser && !isBoth ? user : team} + {isBoth ? ( + <> + {user} + {team} + + ) : null} + + ) +} diff --git a/test/e2e/root-dir/app/root/conditional/[slug]@team/index.js b/test/e2e/root-dir/app/root/conditional/[slug]@team/index.js new file mode 100644 index 000000000000000..02119380f3382a9 --- /dev/null +++ b/test/e2e/root-dir/app/root/conditional/[slug]@team/index.js @@ -0,0 +1,7 @@ +export default function TeamHomePage(props) { + return ( + <> +

hello from team homepage

+ + ) +} diff --git a/test/e2e/root-dir/app/root/conditional/[slug]@team/members.js b/test/e2e/root-dir/app/root/conditional/[slug]@team/members.js new file mode 100644 index 000000000000000..2c3ba112beade78 --- /dev/null +++ b/test/e2e/root-dir/app/root/conditional/[slug]@team/members.js @@ -0,0 +1,7 @@ +export default function TeamMembersPage(props) { + return ( + <> +

hello from team/members

+ + ) +} diff --git a/test/e2e/root-dir/app/root/conditional/[slug]@user/index.js b/test/e2e/root-dir/app/root/conditional/[slug]@user/index.js new file mode 100644 index 000000000000000..81100777dae29f5 --- /dev/null +++ b/test/e2e/root-dir/app/root/conditional/[slug]@user/index.js @@ -0,0 +1,7 @@ +export default function UserHomePage(props) { + return ( + <> +

hello from user homepage

+ + ) +} diff --git a/test/e2e/root-dir/app/root/conditional/[slug]@user/teams.js b/test/e2e/root-dir/app/root/conditional/[slug]@user/teams.js new file mode 100644 index 000000000000000..294c3bf316dad4b --- /dev/null +++ b/test/e2e/root-dir/app/root/conditional/[slug]@user/teams.js @@ -0,0 +1,7 @@ +export default function UserTeamsPage(props) { + return ( + <> +

hello from user/teams

+ + ) +} diff --git a/test/e2e/root-dir/app/root/dashboard+changelog.server.js b/test/e2e/root-dir/app/root/dashboard+changelog.server.js new file mode 100644 index 000000000000000..a9a3c0e759c4519 --- /dev/null +++ b/test/e2e/root-dir/app/root/dashboard+changelog.server.js @@ -0,0 +1,7 @@ +export default function ChangelogPage(props) { + return ( + <> +

hello from root/dashboard/changelog

+ + ) +} diff --git a/test/e2e/root-dir/app/root/dashboard+rootonly/hello.server.js b/test/e2e/root-dir/app/root/dashboard+rootonly/hello.server.js new file mode 100644 index 000000000000000..fd76d51734650e6 --- /dev/null +++ b/test/e2e/root-dir/app/root/dashboard+rootonly/hello.server.js @@ -0,0 +1,7 @@ +export default function HelloPage(props) { + return ( + <> +

hello from root/dashboard/rootonly/hello

+ + ) +} diff --git a/test/e2e/root-dir/app/root/dashboard.server.js b/test/e2e/root-dir/app/root/dashboard.server.js new file mode 100644 index 000000000000000..2c16fe844ef2ed0 --- /dev/null +++ b/test/e2e/root-dir/app/root/dashboard.server.js @@ -0,0 +1,8 @@ +export default function DashboardLayout({ children }) { + return ( + <> +

Dashboard

+ {children} + + ) +} diff --git a/test/e2e/root-dir/app/root/dashboard/deployments.server.js b/test/e2e/root-dir/app/root/dashboard/deployments.server.js new file mode 100644 index 000000000000000..2272e9083f95b80 --- /dev/null +++ b/test/e2e/root-dir/app/root/dashboard/deployments.server.js @@ -0,0 +1,16 @@ +export function getServerSideProps() { + return { + props: { + message: 'hello', + }, + } +} + +export default function DeploymentsLayout({ message, children }) { + return ( + <> +

Deployments {message}

+ {children} + + ) +} diff --git a/test/e2e/root-dir/app/root/dashboard/deployments/[id].server.js b/test/e2e/root-dir/app/root/dashboard/deployments/[id].server.js new file mode 100644 index 000000000000000..b370eb46347c9bc --- /dev/null +++ b/test/e2e/root-dir/app/root/dashboard/deployments/[id].server.js @@ -0,0 +1,15 @@ +export async function getServerSideProps({ params }) { + return { + props: { + id: params.id, + }, + } +} + +export default function DeploymentsPage(props) { + return ( + <> +

hello from root/dashboard/deployments/[id]. ID is: {props.id}

+ + ) +} diff --git a/test/e2e/root-dir/app/root/dashboard/deployments/info.server.js b/test/e2e/root-dir/app/root/dashboard/deployments/info.server.js new file mode 100644 index 000000000000000..e11cc4999108697 --- /dev/null +++ b/test/e2e/root-dir/app/root/dashboard/deployments/info.server.js @@ -0,0 +1,7 @@ +export default function DeploymentsInfoPage(props) { + return ( + <> +

hello from root/dashboard/deployments/info

+ + ) +} diff --git a/test/e2e/root-dir/app/root/dashboard/index.server.js b/test/e2e/root-dir/app/root/dashboard/index.server.js new file mode 100644 index 000000000000000..f80ed6fe9120629 --- /dev/null +++ b/test/e2e/root-dir/app/root/dashboard/index.server.js @@ -0,0 +1,7 @@ +export default function DashboardPage(props) { + return ( + <> +

hello from root/dashboard

+ + ) +} diff --git a/test/e2e/root-dir/app/root/dashboard/integrations/index.server.js b/test/e2e/root-dir/app/root/dashboard/integrations/index.server.js new file mode 100644 index 000000000000000..0300726704aade2 --- /dev/null +++ b/test/e2e/root-dir/app/root/dashboard/integrations/index.server.js @@ -0,0 +1,7 @@ +export default function IntegrationsPage(props) { + return ( + <> +

hello from root/dashboard/integrations

+ + ) +} diff --git a/test/e2e/root-dir/app/root/partial-match-[id].server.js b/test/e2e/root-dir/app/root/partial-match-[id].server.js new file mode 100644 index 000000000000000..c14c8105e909f72 --- /dev/null +++ b/test/e2e/root-dir/app/root/partial-match-[id].server.js @@ -0,0 +1,15 @@ +export async function getServerSideProps({ params }) { + return { + props: { + id: params.id, + }, + } +} + +export default function DeploymentsPage(props) { + return ( + <> +

hello from root/partial-match-[id]. ID is: {props.id}

+ + ) +} diff --git a/test/e2e/root-dir/app/root/shared-component-route.js b/test/e2e/root-dir/app/root/shared-component-route.js new file mode 100644 index 000000000000000..a1d406229691444 --- /dev/null +++ b/test/e2e/root-dir/app/root/shared-component-route.js @@ -0,0 +1,7 @@ +export default function SharedComponentRoute() { + return ( + <> +

hello from root/shared-component-route

+ + ) +} diff --git a/test/e2e/root-dir/app/root/should-not-serve-client.client.js b/test/e2e/root-dir/app/root/should-not-serve-client.client.js new file mode 100644 index 000000000000000..8e0300fed5d8c24 --- /dev/null +++ b/test/e2e/root-dir/app/root/should-not-serve-client.client.js @@ -0,0 +1,7 @@ +export default function ShouldNotServeClientDotJs(props) { + return ( + <> +

hello from root/should-not-serve-client

+ + ) +} diff --git a/test/e2e/root-dir/app/root/should-not-serve-server.server.js b/test/e2e/root-dir/app/root/should-not-serve-server.server.js new file mode 100644 index 000000000000000..eb0db64e1151e3b --- /dev/null +++ b/test/e2e/root-dir/app/root/should-not-serve-server.server.js @@ -0,0 +1,7 @@ +export default function ShouldNotServeServerDotJs(props) { + return ( + <> +

hello from root/should-not-serve-server

+ + ) +} diff --git a/test/e2e/root-dir/index.test.ts b/test/e2e/root-dir/index.test.ts new file mode 100644 index 000000000000000..2f9b1b9dc91dec8 --- /dev/null +++ b/test/e2e/root-dir/index.test.ts @@ -0,0 +1,263 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP, renderViaHTTP } from 'next-test-utils' +import path from 'path' +import cheerio from 'cheerio' +import webdriver from 'next-webdriver' + +// TODO: implementation +describe.skip('root dir', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + public: new FileRef(path.join(__dirname, 'app/public')), + pages: new FileRef(path.join(__dirname, 'app/pages')), + root: new FileRef(path.join(__dirname, 'app/root')), + 'root.server.js': new FileRef( + path.join(__dirname, 'app/root.server.js') + ), + 'next.config.js': new FileRef( + path.join(__dirname, 'app/next.config.js') + ), + }, + dependencies: { + react: '18.0.0-rc.2', + 'react-dom': '18.0.0-rc.2', + }, + }) + }) + afterAll(() => next.destroy()) + + it('should serve from pages', async () => { + const html = await renderViaHTTP(next.url, '/') + expect(html).toContain('hello from pages/index') + }) + + it('should serve dynamic route from pages', async () => { + const html = await renderViaHTTP(next.url, '/blog/first') + expect(html).toContain('hello from pages/blog/[slug]') + }) + + it('should serve from public', async () => { + const html = await renderViaHTTP(next.url, '/hello.txt') + expect(html).toContain('hello world') + }) + + it('should serve from root', async () => { + const html = await renderViaHTTP(next.url, '/dashboard') + expect(html).toContain('hello from root/dashboard') + }) + + it('should include layouts when no direct parent layout', async () => { + const html = await renderViaHTTP(next.url, '/dashboard/integrations') + const $ = cheerio.load(html) + // Should not be nested in dashboard + expect($('h1').text()).toBe('Dashboard') + // Should include the page text + expect($('p').text()).toBe('hello from root/dashboard/integrations') + }) + + // TODO: why is this routable but /should-not-serve-server.server.js + it('should not include parent when not in parent directory with route in directory', async () => { + const html = await renderViaHTTP(next.url, '/dashboard/rootonly/hello') + const $ = cheerio.load(html) + + // Should be nested in /root.js + expect($('html').hasClass('this-is-the-document-html')).toBeTruthy() + expect($('body').hasClass('this-is-the-document-body')).toBeTruthy() + + // Should not be nested in dashboard + expect($('h1').text()).toBeFalsy() + + // Should render the page text + expect($('p').text()).toBe('hello from root/dashboard/rootonly/hello') + }) + + it('should include parent document when no direct parent layout', async () => { + const html = await renderViaHTTP(next.url, '/dashboard/integrations') + const $ = cheerio.load(html) + + // Root has to provide it's own document + expect($('html').hasClass('this-is-the-document-html')).toBeTruthy() + expect($('body').hasClass('this-is-the-document-body')).toBeTruthy() + }) + + it('should not include parent when not in parent directory', async () => { + const html = await renderViaHTTP(next.url, '/dashboard/changelog') + const $ = cheerio.load(html) + // Should not be nested in dashboard + expect($('h1').text()).toBeFalsy() + // Should include the page text + expect($('p').text()).toBe('hello from root/dashboard/changelog') + }) + + it('should serve nested parent', async () => { + const html = await renderViaHTTP(next.url, '/dashboard/deployments/123') + const $ = cheerio.load(html) + // Should be nested in dashboard + expect($('h1').text()).toBe('Dashboard') + // Should be nested in deployments + expect($('h2').text()).toBe('Deployments hello') + }) + + it('should serve dynamic parameter', async () => { + const html = await renderViaHTTP(next.url, '/dashboard/deployments/123') + const $ = cheerio.load(html) + // Should include the page text with the parameter + expect($('p').text()).toBe( + 'hello from root/dashboard/deployments/[id]. ID is: 123' + ) + }) + + it('should include document html and body', async () => { + const html = await renderViaHTTP(next.url, '/dashboard') + const $ = cheerio.load(html) + + expect($('html').hasClass('this-is-the-document-html')).toBeTruthy() + expect($('body').hasClass('this-is-the-document-body')).toBeTruthy() + }) + + it('should not serve when layout is provided but no folder index', async () => { + const res = await fetchViaHTTP(next.url, '/dashboard/deployments') + expect(res.status).toBe(404) + expect(await res.text()).toContain('This page could not be found') + }) + + // TODO: do we want to make this only work for /root or is it allowed + // to work for /pages as well? + it('should match partial parameters', async () => { + const html = await renderViaHTTP(next.url, '/partial-match-123') + expect(html).toContain('hello from root/partial-match-[id]. ID is: 123') + }) + + describe('parallel routes', () => { + describe('conditional routes', () => { + it('should serve user page', async () => { + const html = await renderViaHTTP(next.url, '/conditional/tim') + expect(html).toContain('hello from user homepage') + }) + + it('should serve user teams page', async () => { + const html = await renderViaHTTP(next.url, '/conditional/tim/teams') + expect(html).toContain('hello from user/teams') + }) + + it('should not serve teams page to user', async () => { + const html = await renderViaHTTP(next.url, '/conditional/tim/members') + expect(html).not.toContain('hello from team/members') + }) + + it('should serve team page', async () => { + const html = await renderViaHTTP(next.url, '/conditional/vercel') + expect(html).toContain('hello from team homepage') + }) + + it('should serve team members page', async () => { + const html = await renderViaHTTP( + next.url, + '/conditional/vercel/members' + ) + expect(html).toContain('hello from team/members') + }) + + it('should provide both matches if both paths match', async () => { + const html = await renderViaHTTP(next.url, '/conditional/both') + expect(html).toContain('hello from team homepage') + expect(html).toContain('hello from user homepage') + }) + + it('should 404 based on getServerSideProps', async () => { + const res = await fetchViaHTTP(next.url, '/conditional/nonexistent') + expect(res.status).toBe(404) + expect(await res.text()).toContain('This page could not be found') + }) + }) + }) + + describe('server components', () => { + // TODO: why is this not servable but /dashboard+rootonly/hello.server.js + // should be? Seems like they both either should be servable or not + it('should not serve .server.js as a path', async () => { + // Without .server.js should serve + const html = await renderViaHTTP(next.url, '/should-not-serve-server') + expect(html).toContain('hello from root/should-not-serve-server') + + // Should not serve `.server` + const res = await fetchViaHTTP( + next.url, + '/should-not-serve-server.server' + ) + expect(res.status).toBe(404) + expect(await res.text()).toContain('This page could not be found') + + // Should not serve `.server.js` + const res2 = await fetchViaHTTP( + next.url, + '/should-not-serve-server.server.js' + ) + expect(res2.status).toBe(404) + expect(await res2.text()).toContain('This page could not be found') + }) + + it('should not serve .client.js as a path', async () => { + // Without .client.js should serve + const html = await renderViaHTTP(next.url, '/should-not-serve-client') + expect(html).toContain('hello from root/should-not-serve-client') + + // Should not serve `.client` + const res = await fetchViaHTTP( + next.url, + '/should-not-serve-client.client' + ) + expect(res.status).toBe(404) + expect(await res.text()).toContain('This page could not be found') + + // Should not serve `.client.js` + const res2 = await fetchViaHTTP( + next.url, + '/should-not-serve-client.client.js' + ) + expect(res2.status).toBe(404) + expect(await res2.text()).toContain('This page could not be found') + }) + + it('should serve shared component', async () => { + // Without .client.js should serve + const html = await renderViaHTTP(next.url, '/shared-component-route') + expect(html).toContain('hello from root/shared-component-route') + }) + + it('should serve client component', async () => { + const html = await renderViaHTTP(next.url, '/client-component-route') + expect(html).toContain('hello from root/client-component-route. count: 0') + + const browser = await webdriver(next.url, '/client-component-route') + // After hydration count should be 1 + expect(await browser.elementByCss('p').text()).toBe( + 'hello from root/client-component-route. count: 1' + ) + }) + + it('should include client component layout with server component route', async () => { + const html = await renderViaHTTP(next.url, '/client-nested') + const $ = cheerio.load(html) + // Should not be nested in dashboard + expect($('h1').text()).toBe('Client Nested. Count: 0') + // Should include the page text + expect($('p').text()).toBe('hello from root/client-nested') + + const browser = await webdriver(next.url, '/client-nested') + // After hydration count should be 1 + expect(await browser.elementByCss('h1').text()).toBe( + 'Client Nested. Count: 0' + ) + + // After hydration count should be 1 + expect(await browser.elementByCss('h1').text()).toBe( + 'hello from root/client-nested' + ) + }) + }) +})