diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index e2de4fe3d46997e..6b20468b233985f 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -1095,7 +1095,9 @@ export const collectGenerateParams = async ( ): Promise => { if (!Array.isArray(segment)) return generateParams const isLayout = !!segment[2]?.layout - const mod = await (isLayout ? segment[2]?.layout?.() : segment[2]?.page?.()) + const mod = await (isLayout + ? segment[2]?.layout?.[0]?.() + : segment[2]?.page?.[0]?.()) const config = collectAppConfig(mod) const result = { diff --git a/packages/next/build/webpack/loaders/next-app-loader.ts b/packages/next/build/webpack/loaders/next-app-loader.ts index d5191c03ac2efd6..7f4f6c58c617abe 100644 --- a/packages/next/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/build/webpack/loaders/next-app-loader.ts @@ -21,11 +21,11 @@ const PAGE_SEGMENT = 'page$' // TODO-APP: check if this can be narrowed. type ComponentModule = () => any +type ModuleReference = [componentModule: ComponentModule, filePath: string] export type ComponentsType = { - readonly [componentKey in ValueOf]?: ComponentModule + readonly [componentKey in ValueOf]?: ModuleReference } & { - readonly layoutOrPagePath?: string - readonly page?: ComponentModule + readonly page?: ModuleReference } async function createTreeCodeFromPath({ @@ -67,9 +67,10 @@ async function createTreeCodeFromPath({ if (resolvedPagePath) pages.push(resolvedPagePath) // Use '' for segment as it's the page. There can't be a segment called '' so this is the safest way to add it. - props[parallelKey] = `['', {}, {layoutOrPagePath: ${JSON.stringify( - resolvedPagePath - )}, page: () => require(${JSON.stringify(resolvedPagePath)})}]` + props[parallelKey] = `['', {}, { + page: [() => require(${JSON.stringify( + resolvedPagePath + )}), ${JSON.stringify(resolvedPagePath)}]}]` continue } @@ -105,11 +106,9 @@ async function createTreeCodeFromPath({ if (filePath === undefined) { return '' } - return `${ - file === FILE_TYPES.layout - ? `layoutOrPagePath: ${JSON.stringify(filePath)},` - : '' - }'${file}': () => require(${JSON.stringify(filePath)}),` + return `'${file}': [() => require(${JSON.stringify( + filePath + )}), ${JSON.stringify(filePath)}],` }) .join('\n')} } diff --git a/packages/next/build/webpack/plugins/flight-client-entry-plugin.ts b/packages/next/build/webpack/plugins/flight-client-entry-plugin.ts index c048c8b9081797b..2ddbcb163343473 100644 --- a/packages/next/build/webpack/plugins/flight-client-entry-plugin.ts +++ b/packages/next/build/webpack/plugins/flight-client-entry-plugin.ts @@ -179,17 +179,18 @@ export class FlightClientEntryPlugin { for (const connection of compilation.moduleGraph.getOutgoingConnections( entryModule )) { - const layoutOrPageDependency = connection.dependency - const layoutOrPageRequest = connection.dependency.request + // Entry can be any user defined entry files such as layout, page, error, loading, etc. + const entryDependency = connection.dependency + const entryRequest = connection.dependency.request const [clientComponentImports] = this.collectClientComponentsAndCSSForDependency({ - layoutOrPageRequest, + entryRequest, compilation, - dependency: layoutOrPageDependency, + dependency: entryDependency, }) - const isAbsoluteRequest = path.isAbsolute(layoutOrPageRequest) + const isAbsoluteRequest = path.isAbsolute(entryRequest) // Next.js internals are put into a separate entry. if (!isAbsoluteRequest) { @@ -200,8 +201,8 @@ export class FlightClientEntryPlugin { } const relativeRequest = isAbsoluteRequest - ? path.relative(compilation.options.context, layoutOrPageRequest) - : layoutOrPageRequest + ? path.relative(compilation.options.context, entryRequest) + : entryRequest // Replace file suffix as `.js` will be added. const bundlePath = relativeRequest.replace(/\.(js|ts)x?$/, '') @@ -308,14 +309,14 @@ export class FlightClientEntryPlugin { for (const connection of compilation.moduleGraph.getOutgoingConnections( entryModule )) { - const layoutOrPageDependency = connection.dependency - const layoutOrPageRequest = connection.dependency.request + const entryDependency = connection.dependency + const entryRequest = connection.dependency.request const [, cssImports] = this.collectClientComponentsAndCSSForDependency({ - layoutOrPageRequest, + entryRequest, compilation, - dependency: layoutOrPageDependency, + dependency: entryDependency, clientEntryDependencyMap, }) @@ -353,12 +354,12 @@ export class FlightClientEntryPlugin { } collectClientComponentsAndCSSForDependency({ - layoutOrPageRequest, + entryRequest, compilation, dependency, clientEntryDependencyMap, }: { - layoutOrPageRequest: string + entryRequest: string compilation: any dependency: any /* Dependency */ clientEntryDependencyMap?: Record @@ -397,15 +398,15 @@ export class FlightClientEntryPlugin { : mod.resourceResolveData?.path + mod.resourceResolveData?.query // Ensure module is not walked again if it's already been visited - if (!visitedBySegment[layoutOrPageRequest]) { - visitedBySegment[layoutOrPageRequest] = new Set() + if (!visitedBySegment[entryRequest]) { + visitedBySegment[entryRequest] = new Set() } const storeKey = (inClientComponentBoundary ? '0' : '1') + ':' + modRequest - if (!modRequest || visitedBySegment[layoutOrPageRequest].has(storeKey)) { + if (!modRequest || visitedBySegment[entryRequest].has(storeKey)) { return } - visitedBySegment[layoutOrPageRequest].add(storeKey) + visitedBySegment[entryRequest].add(storeKey) const isClientComponent = isClientComponentModule(mod) @@ -425,9 +426,8 @@ export class FlightClientEntryPlugin { } } - serverCSSImports[layoutOrPageRequest] = - serverCSSImports[layoutOrPageRequest] || [] - serverCSSImports[layoutOrPageRequest].push(modRequest) + serverCSSImports[entryRequest] = serverCSSImports[entryRequest] || [] + serverCSSImports[entryRequest].push(modRequest) } // Check if request is for css file. diff --git a/packages/next/client/components/error-boundary.tsx b/packages/next/client/components/error-boundary.tsx index 649a7bd73721f67..2cb34ef2af63a23 100644 --- a/packages/next/client/components/error-boundary.tsx +++ b/packages/next/client/components/error-boundary.tsx @@ -6,6 +6,7 @@ export type ErrorComponent = React.ComponentType<{ }> interface ErrorBoundaryProps { errorComponent: ErrorComponent + errorStyles?: React.ReactNode | undefined } /** @@ -32,10 +33,13 @@ class ErrorBoundaryHandler extends React.Component< render() { if (this.state.error) { return ( - + <> + {this.props.errorStyles} + + ) } @@ -49,11 +53,15 @@ class ErrorBoundaryHandler extends React.Component< */ export function ErrorBoundary({ errorComponent, + errorStyles, children, }: ErrorBoundaryProps & { children: React.ReactNode }): JSX.Element { if (errorComponent) { return ( - + {children} ) diff --git a/packages/next/client/components/layout-router.tsx b/packages/next/client/components/layout-router.tsx index dc8cb0c5139a0cf..02f57e7d88e411a 100644 --- a/packages/next/client/components/layout-router.tsx +++ b/packages/next/client/components/layout-router.tsx @@ -257,15 +257,27 @@ export function InnerLayoutRouter({ function LoadingBoundary({ children, loading, + loadingStyles, hasLoading, }: { children: React.ReactNode loading?: React.ReactNode + loadingStyles?: React.ReactNode hasLoading: boolean }): JSX.Element { if (hasLoading) { - // @ts-expect-error TODO-APP: React.Suspense fallback type is wrong - return {children} + return ( + + {loadingStyles} + {loading} + + } + > + {children} + + ) } return <>{children} @@ -322,6 +334,7 @@ function RedirectBoundary({ children }: { children: React.ReactNode }) { interface NotFoundBoundaryProps { notFound?: React.ReactNode + notFoundStyles?: React.ReactNode children: React.ReactNode } @@ -347,6 +360,7 @@ class NotFoundErrorBoundary extends React.Component< return ( <> + {this.props.notFoundStyles} {this.props.notFound} ) @@ -356,9 +370,13 @@ class NotFoundErrorBoundary extends React.Component< } } -function NotFoundBoundary({ notFound, children }: NotFoundBoundaryProps) { +function NotFoundBoundary({ + notFound, + notFoundStyles, + children, +}: NotFoundBoundaryProps) { return notFound ? ( - + {children} ) : ( @@ -375,20 +393,28 @@ export default function OuterLayoutRouter({ segmentPath, childProp, error, + errorStyles, + templateStyles, loading, + loadingStyles, hasLoading, template, notFound, + notFoundStyles, rootLayoutIncluded, }: { parallelRouterKey: string segmentPath: FlightSegmentPath childProp: ChildProp error: ErrorComponent + errorStyles: React.ReactNode | undefined + templateStyles: React.ReactNode | undefined template: React.ReactNode loading: React.ReactNode | undefined + loadingStyles: React.ReactNode | undefined hasLoading: boolean notFound: React.ReactNode | undefined + notFoundStyles: React.ReactNode | undefined rootLayoutIncluded: boolean }) { const context = useContext(LayoutRouterContext) @@ -442,9 +468,16 @@ export default function OuterLayoutRouter({ - - + + + } > - {template} + <> + {templateStyles} + {template} + ) })} diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index d5fd2404538d9b1..59a38fe167cd608 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -915,7 +915,7 @@ export async function renderToHTMLOrFlight( } if (head) { - const Head = await interopDefault(head()) + const Head = await interopDefault(head[0]()) return } @@ -965,6 +965,39 @@ export async function renderToHTMLOrFlight( const assetPrefix = renderOpts.assetPrefix || '' + const createComponentAndStyles = async ({ + filePath, + getComponent, + shouldPreload, + }: { + filePath: string + getComponent: () => any + shouldPreload?: boolean + }): Promise => { + const cssHrefs = getCssInlinedLinkTags( + serverComponentManifest, + serverCSSManifest, + filePath, + serverCSSForEntries + ) + const cacheBustingUrlSuffix = dev ? `?ts=${Date.now()}` : '' + + const styles = cssHrefs + ? cssHrefs.map((href, index) => ( + + )) + : null + const Comp = await interopDefault(getComponent()) + + return [Comp, styles] + } + /** * Use the provided loader tree to create the React Component tree. */ @@ -973,15 +1006,7 @@ export async function renderToHTMLOrFlight( loaderTree: [ segment, parallelRoutes, - { - layoutOrPagePath, - layout, - template, - error, - loading, - page, - 'not-found': notFound, - }, + { layout, template, error, loading, page, 'not-found': notFound }, ], parentParams, firstItem, @@ -993,7 +1018,7 @@ export async function renderToHTMLOrFlight( rootLayoutIncluded?: boolean firstItem?: boolean }): Promise<{ Component: React.ComponentType }> => { - // TODO-APP: enable stylesheet per layout/page + const layoutOrPagePath = layout?.[1] || page?.[1] const stylesheets: string[] = layoutOrPagePath ? getCssInlinedLinkTags( serverComponentManifest, @@ -1003,25 +1028,46 @@ export async function renderToHTMLOrFlight( ) : [] - const preloadedFontFiles = getPreloadedFontFilesInlineLinkTags( - serverComponentManifest, - serverCSSManifest!, - fontLoaderManifest, - serverCSSForEntries, - layoutOrPagePath - ) - const Template = template - ? await interopDefault(template()) - : React.Fragment - const ErrorComponent = error ? await interopDefault(error()) : undefined - const Loading = loading ? await interopDefault(loading()) : undefined + const preloadedFontFiles = layoutOrPagePath + ? getPreloadedFontFilesInlineLinkTags( + serverComponentManifest, + serverCSSManifest!, + fontLoaderManifest, + serverCSSForEntries, + layoutOrPagePath + ) + : [] + + const [Template, templateStyles] = template + ? await createComponentAndStyles({ + filePath: template[1], + getComponent: template[0], + shouldPreload: true, + }) + : [React.Fragment] + + const [ErrorComponent, errorStyles] = error + ? await createComponentAndStyles({ + filePath: error[1], + getComponent: error[0], + }) + : [] + + const [Loading, loadingStyles] = loading + ? await createComponentAndStyles({ + filePath: loading[1], + getComponent: loading[0], + }) + : [] + const isLayout = typeof layout !== 'undefined' const isPage = typeof page !== 'undefined' const layoutOrPageMod = isLayout - ? await layout() + ? await layout[0]() : isPage - ? await page() + ? await page[0]() : undefined + /** * Checks if the current segment is a root layout. */ @@ -1032,11 +1078,14 @@ export async function renderToHTMLOrFlight( const rootLayoutIncludedAtThisLevelOrAbove = rootLayoutIncluded || rootLayoutAtThisLevel - const NotFound = notFound - ? await interopDefault(notFound()) + const [NotFound, notFoundStyles] = notFound + ? await createComponentAndStyles({ + filePath: notFound[1], + getComponent: notFound[0], + }) : rootLayoutAtThisLevel - ? DefaultNotFound - : undefined + ? [DefaultNotFound] + : [] if (typeof layoutOrPageMod?.revalidate === 'number') { defaultRevalidate = layoutOrPageMod.revalidate @@ -1147,14 +1196,18 @@ export async function renderToHTMLOrFlight( parallelRouterKey={parallelRouteKey} segmentPath={createSegmentPath(currentSegmentPath)} loading={Loading ? : undefined} + loadingStyles={loadingStyles} hasLoading={Boolean(Loading)} error={ErrorComponent} + errorStyles={errorStyles} template={ } + templateStyles={templateStyles} notFound={NotFound ? : undefined} + notFoundStyles={notFoundStyles} childProp={childProp} rootLayoutIncluded={rootLayoutIncludedAtThisLevelOrAbove} />, @@ -1187,7 +1240,9 @@ export async function renderToHTMLOrFlight( parallelRouterKey={parallelRouteKey} segmentPath={segmentPath} error={ErrorComponent} + errorStyles={errorStyles} loading={Loading ? : undefined} + loadingStyles={loadingStyles} // TODO-APP: Add test for loading returning `undefined`. This currently can't be tested as the `webdriver()` tab will wait for the full page to load before returning. hasLoading={Boolean(Loading)} template={ @@ -1195,7 +1250,9 @@ export async function renderToHTMLOrFlight( } + templateStyles={templateStyles} notFound={NotFound ? : undefined} + notFoundStyles={notFoundStyles} childProp={childProp} rootLayoutIncluded={rootLayoutIncludedAtThisLevelOrAbove} />, diff --git a/test/e2e/app-dir/app/app/error/client-component/error.js b/test/e2e/app-dir/app/app/error/client-component/error.js index a572d9d228398fe..305cea13a0b8865 100644 --- a/test/e2e/app-dir/app/app/error/client-component/error.js +++ b/test/e2e/app-dir/app/app/error/client-component/error.js @@ -1,10 +1,12 @@ 'use client' +import styles from './style.module.css' + export default function ErrorBoundary({ error, reset }) { return ( <>

An error occurred: {error.message}

- diff --git a/test/e2e/app-dir/app/app/error/client-component/style.module.css b/test/e2e/app-dir/app/app/error/client-component/style.module.css new file mode 100644 index 000000000000000..4a83fef4cc487f4 --- /dev/null +++ b/test/e2e/app-dir/app/app/error/client-component/style.module.css @@ -0,0 +1,3 @@ +.button { + font-size: 50px; +} diff --git a/test/e2e/app-dir/app/app/loading-bug/[categorySlug]/loading.js b/test/e2e/app-dir/app/app/loading-bug/[categorySlug]/loading.js index f1ca6af341511b2..1ddb4d6b404e6a3 100644 --- a/test/e2e/app-dir/app/app/loading-bug/[categorySlug]/loading.js +++ b/test/e2e/app-dir/app/app/loading-bug/[categorySlug]/loading.js @@ -1,3 +1,5 @@ +import './style.css' + export default function Loading() { return

Loading...

} diff --git a/test/e2e/app-dir/app/app/loading-bug/[categorySlug]/style.css b/test/e2e/app-dir/app/app/loading-bug/[categorySlug]/style.css new file mode 100644 index 000000000000000..60f1eab97137f7d --- /dev/null +++ b/test/e2e/app-dir/app/app/loading-bug/[categorySlug]/style.css @@ -0,0 +1,3 @@ +body { + color: red; +} diff --git a/test/e2e/app-dir/app/app/not-found/not-found.js b/test/e2e/app-dir/app/app/not-found/not-found.js index a022a980b338452..eab732a10872be1 100644 --- a/test/e2e/app-dir/app/app/not-found/not-found.js +++ b/test/e2e/app-dir/app/app/not-found/not-found.js @@ -1,3 +1,9 @@ +import styles from './style.module.css' + export default function NotFound() { - return

Not Found!

+ return ( +

+ Not Found! +

+ ) } diff --git a/test/e2e/app-dir/app/app/not-found/style.module.css b/test/e2e/app-dir/app/app/not-found/style.module.css new file mode 100644 index 000000000000000..c6dd2c88fabe351 --- /dev/null +++ b/test/e2e/app-dir/app/app/not-found/style.module.css @@ -0,0 +1,3 @@ +.red { + color: red; +} diff --git a/test/e2e/app-dir/app/app/template/clientcomponent/style.module.css b/test/e2e/app-dir/app/app/template/clientcomponent/style.module.css new file mode 100644 index 000000000000000..d5aa1c97c1cb714 --- /dev/null +++ b/test/e2e/app-dir/app/app/template/clientcomponent/style.module.css @@ -0,0 +1,3 @@ +.button { + font-size: 100px; +} diff --git a/test/e2e/app-dir/app/app/template/clientcomponent/template.js b/test/e2e/app-dir/app/app/template/clientcomponent/template.js index 44239b196ac1025..4fa19993b518ce2 100644 --- a/test/e2e/app-dir/app/app/template/clientcomponent/template.js +++ b/test/e2e/app-dir/app/app/template/clientcomponent/template.js @@ -1,13 +1,16 @@ 'use client' import { useState } from 'react' +import styles from './style.module.css' export default function Template({ children }) { const [count, setCount] = useState(0) return ( <>

Template {count}

- + {children} ) diff --git a/test/e2e/app-dir/app/app/template/servercomponent/style.module.css b/test/e2e/app-dir/app/app/template/servercomponent/style.module.css new file mode 100644 index 000000000000000..c6dd2c88fabe351 --- /dev/null +++ b/test/e2e/app-dir/app/app/template/servercomponent/style.module.css @@ -0,0 +1,3 @@ +.red { + color: red; +} diff --git a/test/e2e/app-dir/app/app/template/servercomponent/template.js b/test/e2e/app-dir/app/app/template/servercomponent/template.js index c3dc5dadb9255fb..089130fcb18cd81 100644 --- a/test/e2e/app-dir/app/app/template/servercomponent/template.js +++ b/test/e2e/app-dir/app/app/template/servercomponent/template.js @@ -1,7 +1,9 @@ +import styles from './style.module.css' + export default function Template({ children }) { return ( <> -

+

Template {performance.now()}

{children} diff --git a/test/e2e/app-dir/index.test.ts b/test/e2e/app-dir/index.test.ts index d389c1e2d34cc9c..8e91c7a4b6a6b6c 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -1507,6 +1507,72 @@ describe('app dir', () => { ).toBe('100px') }) }) + + describe('special entries', () => { + it('should include css imported in loading.js', async () => { + const html = await renderViaHTTP(next.url, '/loading-bug/hi') + // The link tag should be included together with loading + expect(html).toMatch( + /

Loading...<\/h2>/ + ) + }) + + it('should include css imported in client template.js', async () => { + const browser = await webdriver(next.url, '/template/clientcomponent') + expect( + await browser.eval( + `window.getComputedStyle(document.querySelector('button')).fontSize` + ) + ).toBe('100px') + }) + + it('should include css imported in server template.js', async () => { + const browser = await webdriver(next.url, '/template/servercomponent') + expect( + await browser.eval( + `window.getComputedStyle(document.querySelector('h1')).color` + ) + ).toBe('rgb(255, 0, 0)') + }) + + it('should include css imported in client not-found.js', async () => { + const browser = await webdriver( + next.url, + '/not-found/clientcomponent' + ) + expect( + await browser.eval( + `window.getComputedStyle(document.querySelector('h1')).color` + ) + ).toBe('rgb(255, 0, 0)') + }) + + it('should include css imported in server not-found.js', async () => { + const browser = await webdriver( + next.url, + '/not-found/servercomponent' + ) + expect( + await browser.eval( + `window.getComputedStyle(document.querySelector('h1')).color` + ) + ).toBe('rgb(255, 0, 0)') + }) + + it('should include css imported in error.js', async () => { + const browser = await webdriver(next.url, '/error/client-component') + await browser.elementByCss('button').click() + + // Wait for error page to render and CSS to be loaded + await new Promise((resolve) => setTimeout(resolve, 2000)) + + expect( + await browser.eval( + `window.getComputedStyle(document.querySelector('button')).fontSize` + ) + ).toBe('50px') + }) + }) }) describe('searchParams prop', () => {