diff --git a/packages/vinext/src/shims/app.ts b/packages/vinext/src/shims/app.ts deleted file mode 100644 index 53ec6849d..000000000 --- a/packages/vinext/src/shims/app.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * next/app shim - * - * Provides the AppProps type and default App component for _app.tsx. - */ -import type { ComponentType } from "react"; - -export type AppProps

> = { - Component: ComponentType

; - pageProps: P; -}; - -// Re-export for named import compatibility -export type { AppProps as default }; diff --git a/packages/vinext/src/shims/app.tsx b/packages/vinext/src/shims/app.tsx new file mode 100644 index 000000000..d357c2d45 --- /dev/null +++ b/packages/vinext/src/shims/app.tsx @@ -0,0 +1,107 @@ +/** + * next/app shim + * + * Provides the AppProps type and a runtime default `App` class component + * for Pages Router fixtures that follow the canonical `_app.js` pattern: + * + * import App from "next/app"; + * export default class MyApp extends App { ... } + * + * or call `App.getInitialProps(appContext)` from a custom getInitialProps. + * + * Ported from Next.js: + * https://github.com/vercel/next.js/blob/canary/packages/next/src/pages/_app.tsx + * + * Behavioural parity notes: + * - `App.getInitialProps(appContext)` returns `{ pageProps }`, where + * `pageProps` comes from the wrapped page's own `getInitialProps` (if + * any). This matches Next.js's behaviour via `loadGetInitialProps`. + * - `render()` returns `` — the default + * behaviour Next.js documents for the built-in App. + * - `origGetInitialProps` is preserved alongside `getInitialProps` for + * userland code that introspects the original implementation. + * + * Type signatures mirror Next.js's intentionally permissive `

` + * generics so that userland subclasses like `class MyApp extends App` + * type-check without forcing the caller to supply generic parameters. + */ +// oxlint-disable typescript/no-explicit-any -- match Next.js's permissive _app.tsx generics +import React, { type ComponentType } from "react"; + +export type AppProps

= { + Component: ComponentType

& { + getInitialProps?: (ctx: any) => any; + }; + pageProps: P; + router?: any; + __N_SSG?: boolean; + __N_SSP?: boolean; +}; + +/** + * The context passed to `App.getInitialProps`. Mirrors Next.js's + * `AppContextType` from `packages/next/src/shared/lib/utils.ts`. + */ +export type AppContext = { + Component: ComponentType & { + getInitialProps?: (ctx: any) => any; + }; + AppTree: ComponentType; + ctx: any; + router: any; +}; + +/** + * The initial props shape returned by `App.getInitialProps`. Mirrors + * Next.js's `AppInitialProps` from `packages/next/src/shared/lib/utils.ts`. + */ +export type AppInitialProps = { + pageProps: PageProps; +}; + +async function appGetInitialProps({ Component, ctx }: AppContext): Promise { + // Next.js delegates this to `loadGetInitialProps(Component, ctx)`. For the + // canonical _app pattern the relevant behaviour is: invoke the wrapped + // page's `getInitialProps` if defined, otherwise return `{}` for + // pageProps. We replicate that minimal shape without pulling in the + // full development-only validation logic from utils.ts. + let pageProps: any = {}; + if (typeof Component.getInitialProps === "function") { + pageProps = await Component.getInitialProps(ctx); + // Divergence from Next.js (intentional, current scope): + // + // Next.js's `loadGetInitialProps` throws when a page's getInitialProps + // resolves to null/undefined: + // + // ".getInitialProps() should resolve to an object. + // But found "null"/"undefined" instead." + // + // See: https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/utils.ts + // + // vinext currently coerces the missing value to `{}` so that fixtures + // and userland code that accidentally return nothing still render + // (just with empty pageProps) instead of crashing the page. If you're + // debugging a Pages Router page that renders with mysteriously empty + // props, suspect a `getInitialProps` that returns undefined — Next.js + // would have surfaced that as a thrown error at this point. + if (pageProps == null) { + pageProps = {}; + } + } + return { pageProps }; +} + +export default class App

extends React.Component

, S> { + static origGetInitialProps = appGetInitialProps; + static getInitialProps = appGetInitialProps; + + render(): React.ReactNode { + const { Component, pageProps } = this.props as AppProps; + // Cast to ComponentType so the JSX spread type-checks regardless + // of the user-supplied `CP` generic. Mirrors how Next.js's _app.tsx + // works in practice: callers extending `App` rarely supply explicit + // page-prop generics, so the spread has to be permissive here. + const PageComponent = Component as ComponentType; + return ; + } +} diff --git a/packages/vinext/src/shims/next-shims.d.ts b/packages/vinext/src/shims/next-shims.d.ts index d5c7c0f13..c254aab14 100644 --- a/packages/vinext/src/shims/next-shims.d.ts +++ b/packages/vinext/src/shims/next-shims.d.ts @@ -504,11 +504,46 @@ declare module "next/font/local" { } declare module "next/app" { - import { ComponentType } from "react"; - export type AppProps = { - Component: ComponentType; - pageProps: Record; + import * as React from "react"; + import type { ComponentType } from "react"; + + export type AppProps

= { + Component: ComponentType

& { + getInitialProps?: (ctx: any) => any; + }; + pageProps: P; + router?: any; + __N_SSG?: boolean; + __N_SSP?: boolean; + }; + + export type AppContext = { + Component: ComponentType & { + getInitialProps?: (ctx: any) => any; + }; + AppTree: ComponentType; + ctx: any; + router: any; }; + + export type AppInitialProps = { + pageProps: PageProps; + }; + + /** + * Default `App` class component used by Pages Router `_app.js`. Mirrors + * Next.js's `packages/next/src/pages/_app.tsx` so userland code can + * `import App from "next/app"` and either subclass or call + * `App.getInitialProps(appContext)` directly. + */ + export default class App

extends React.Component< + P & AppProps, + S + > { + static origGetInitialProps: (ctx: AppContext) => Promise; + static getInitialProps: (ctx: AppContext) => Promise; + render(): React.ReactNode; + } } declare module "next/cache" { diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 8867b0332..a35426e5e 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -12120,6 +12120,84 @@ describe("next/error shim", () => { }); }); +// next/app default export +// +// Ported from Next.js: packages/next/src/pages/_app.tsx +// https://github.com/vercel/next.js/blob/canary/packages/next/src/pages/_app.tsx +// +// Pages Router fixtures very commonly do: +// import App from "next/app"; +// export default class MyApp extends App { ... } +// or call App.getInitialProps(appContext) from a custom getInitialProps. +// Without a runtime default export, every such fixture fails to build with +// "[MISSING_EXPORT] 'default' is not exported by ...shims/app.js". +describe("next/app shim", () => { + it("exports a React class component as default", async () => { + const React = await import("react"); + const AppDefault = (await import("../packages/vinext/src/shims/app.js")).default; + expect(typeof AppDefault).toBe("function"); + // Class component: prototype must have a render method, instances must + // be instances of React.Component. + expect(typeof AppDefault.prototype.render).toBe("function"); + const instance = new AppDefault({ Component: () => null, pageProps: {} }); + expect(instance).toBeInstanceOf(React.Component); + }); + + it("default App.render() returns ", async () => { + const React = await import("react"); + const { renderToStaticMarkup } = await import("react-dom/server"); + const AppDefault = (await import("../packages/vinext/src/shims/app.js")).default; + + function Page(props: { greeting: string }) { + return React.createElement("p", null, props.greeting); + } + + // Use explicit generics so React.createElement's prop type inference + // lines up with `AppProps<{ greeting: string }>`. + const html = renderToStaticMarkup( + React.createElement(AppDefault, { + Component: Page, + pageProps: { greeting: "hello world" }, + }), + ); + expect(html).toBe("

hello world

"); + }); + + it("App.getInitialProps is a function and forwards Component.getInitialProps result as pageProps", async () => { + const AppDefault = (await import("../packages/vinext/src/shims/app.js")).default; + expect(typeof AppDefault.getInitialProps).toBe("function"); + // origGetInitialProps is preserved for userland code that introspects it. + expect(typeof AppDefault.origGetInitialProps).toBe("function"); + + const pageCtx = { req: { url: "/test" } }; + const Component = Object.assign(() => null, { + getInitialProps: async (ctx: unknown) => { + expect(ctx).toBe(pageCtx); + return { foo: "bar" }; + }, + }); + + const result = await AppDefault.getInitialProps({ + Component, + AppTree: () => null, + ctx: pageCtx, + router: {}, + }); + expect(result).toEqual({ pageProps: { foo: "bar" } }); + }); + + it("App.getInitialProps returns { pageProps: {} } when Component has no getInitialProps", async () => { + const AppDefault = (await import("../packages/vinext/src/shims/app.js")).default; + const result = await AppDefault.getInitialProps({ + Component: () => null, + AppTree: () => null, + ctx: {}, + router: {}, + }); + expect(result).toEqual({ pageProps: {} }); + }); +}); + describe("next/constants shim", () => { it("exports all phase constants", async () => { const constants = await import("../packages/vinext/src/shims/constants.js");