From 08da98dfbc7df5b243eb0238f97df25ffc186183 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Mon, 15 Jul 2024 23:14:59 +0200 Subject: [PATCH] TypeScript plugin: Allow functions in action-like props (#67211) Co-authored-by: Delba de Oliveira <32464864+delbaoliveira@users.noreply.github.com> --- .../03-server-actions-and-mutations.mdx | 25 ++++++++++++++++--- .../typescript/rules/client-boundary.ts | 23 +++++++++++++++-- test/development/typescript-plugin/README.md | 17 +++++++++++++ .../typescript-plugin/app/client.tsx | 14 +++++++++++ .../typescript-plugin/app/layout.tsx | 7 ++++++ .../typescript-plugin/app/page.tsx | 11 ++++++++ .../typescript-plugin/package.json | 6 +++++ .../typescript-plugin/tsconfig.json | 24 ++++++++++++++++++ 8 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 test/development/typescript-plugin/README.md create mode 100644 test/development/typescript-plugin/app/client.tsx create mode 100644 test/development/typescript-plugin/app/layout.tsx create mode 100644 test/development/typescript-plugin/app/page.tsx create mode 100644 test/development/typescript-plugin/package.json create mode 100644 test/development/typescript-plugin/tsconfig.json diff --git a/docs/02-app/01-building-your-application/02-data-fetching/03-server-actions-and-mutations.mdx b/docs/02-app/01-building-your-application/02-data-fetching/03-server-actions-and-mutations.mdx index ff3df3be1061c..1d747ce1b6a26 100644 --- a/docs/02-app/01-building-your-application/02-data-fetching/03-server-actions-and-mutations.mdx +++ b/docs/02-app/01-building-your-application/02-data-fetching/03-server-actions-and-mutations.mdx @@ -85,17 +85,34 @@ export function Button() { You can also pass a Server Action to a Client Component as a prop: ```jsx - + ``` -```jsx filename="app/client-component.jsx" +```tsx filename="app/client-component.tsx" switcher 'use client' -export default function ClientComponent({ updateItem }) { - return
{/* ... */}
+export default function ClientComponent({ + updateItemAction, +}: { + updateItemAction: (formData: FormData) => void +}) { + return
{/* ... */}
} ``` +```jsx filename="app/client-component.jsx" switcher +'use client' + +export default function ClientComponent({ updateItemAction }) { + return
{/* ... */}
+} +``` + +Usually, the Next.js TypeScript plugin would flag `updateItemAction` in `client-component.tsx` since it is a function which generally can't be serialized across client-server boundaries. +However, props named `action` or ending with `Action` are assumed to receive Server Actions. +This is only a heuristic since the TypeScript plugin doesn't actually know if it receives a Server Action or an ordinary function. +Runtime type-checking will still ensure you don't accidentally pass a function to a Client Component. + ## Behavior - Server actions can be invoked using the `action` attribute in a [`
` element](#forms): diff --git a/packages/next/src/server/typescript/rules/client-boundary.ts b/packages/next/src/server/typescript/rules/client-boundary.ts index fc2dcb9dc91fb..9ed1616a88ac9 100644 --- a/packages/next/src/server/typescript/rules/client-boundary.ts +++ b/packages/next/src/server/typescript/rules/client-boundary.ts @@ -51,9 +51,28 @@ const clientBoundary = { const propName = (prop.propertyName || prop.name).getText() if (typeDeclarationNode) { - if ( + if (ts.isFunctionTypeNode(typeDeclarationNode)) { + // By convention, props named "action" can accept functions since we assume these are Server Actions. + // Structurally, there's no difference between a Server Action and a normal function until TypeScript exposes directives in the type of a function. + // This will miss accidentally passing normal functions but a false negative is better than a false positive given how frequent the false-positive would be. + const maybeServerAction = + propName === 'action' || /.+Action$/.test(propName) + if (!maybeServerAction) { + diagnostics.push({ + file: source, + category: ts.DiagnosticCategory.Warning, + code: NEXT_TS_ERRORS.INVALID_CLIENT_ENTRY_PROP, + messageText: + `Props must be serializable for components in the "use client" entry file. ` + + `"${propName}" is a function that's not a Server Action. ` + + `Rename "${propName}" either to "action" or have its name end with "Action" e.g. "${propName}Action" to indicate it is a Server Action.`, + start: prop.getStart(), + length: prop.getWidth(), + }) + } + } else if ( // Show warning for not serializable props. - ts.isFunctionOrConstructorTypeNode(typeDeclarationNode) || + ts.isConstructorTypeNode(typeDeclarationNode) || ts.isClassDeclaration(typeDeclarationNode) ) { // There's a special case for the error file that the `reset` prop is allowed diff --git a/test/development/typescript-plugin/README.md b/test/development/typescript-plugin/README.md new file mode 100644 index 0000000000000..022a0bf3709e8 --- /dev/null +++ b/test/development/typescript-plugin/README.md @@ -0,0 +1,17 @@ +# typescript-plugin fixture + +This fixture is used to test the TypeScript plugin. +The plugin only applies to VSCode so manual testing in VSCode is required. + +## Getting started + +1. Install the dependencies with `pnpm install` +2. Open any TypeScript file of this fixture in VSCode +3. Change TypeScript version to use Workspace version (see https://nextjs.org/docs/app/building-your-application/configuring/typescript#typescript-plugin) + +## Tests + +### Client component prop serialization + +`app/client.tsx#ClientComponent` has props that can and can't be serialized. +Ensure the current comments still describe the observed behavior. diff --git a/test/development/typescript-plugin/app/client.tsx b/test/development/typescript-plugin/app/client.tsx new file mode 100644 index 0000000000000..13b5f542ac9d6 --- /dev/null +++ b/test/development/typescript-plugin/app/client.tsx @@ -0,0 +1,14 @@ +'use client' + +export function ClientComponent({ + unknownAction, + //^^^^^^^^^^^ fine because it looks like an action + unknown, + //^^^^^ "Error(TS71007): Props must be serializable for components in the "use client" entry file. "unknown" is a function that's not a Server Action. Rename "unknown" either to "action" or have its name end with "Action" e.g. "unknownAction" to indicate it is a Server Action.ts(71007) +}: { + unknownAction: () => void + unknown: () => void +}) { + console.log({ unknown, unknownAction }) + return null +} diff --git a/test/development/typescript-plugin/app/layout.tsx b/test/development/typescript-plugin/app/layout.tsx new file mode 100644 index 0000000000000..c7295294439d6 --- /dev/null +++ b/test/development/typescript-plugin/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/development/typescript-plugin/app/page.tsx b/test/development/typescript-plugin/app/page.tsx new file mode 100644 index 0000000000000..deb29955b39ea --- /dev/null +++ b/test/development/typescript-plugin/app/page.tsx @@ -0,0 +1,11 @@ +import { ClientComponent } from './client' + +const noop = () => {} + +export default function Page() { + return ( + <> + + + ) +} diff --git a/test/development/typescript-plugin/package.json b/test/development/typescript-plugin/package.json new file mode 100644 index 0000000000000..2c1908185f655 --- /dev/null +++ b/test/development/typescript-plugin/package.json @@ -0,0 +1,6 @@ +{ + "name": "typescript-plugin-fixture", + "dependencies": { + "typescript": "^5.5.2" + } +} diff --git a/test/development/typescript-plugin/tsconfig.json b/test/development/typescript-plugin/tsconfig.json new file mode 100644 index 0000000000000..1d4f624eff7d9 --- /dev/null +++ b/test/development/typescript-plugin/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +}