Skip to content

Commit

Permalink
TypeScript plugin: Allow functions in action-like props (vercel#67211)
Browse files Browse the repository at this point in the history
Co-authored-by: Delba de Oliveira <[email protected]>
  • Loading branch information
eps1lon and delbaoliveira authored Jul 15, 2024
1 parent bebc755 commit 08da98d
Show file tree
Hide file tree
Showing 8 changed files with 121 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,34 @@ export function Button() {
You can also pass a Server Action to a Client Component as a prop:

```jsx
<ClientComponent updateItem={updateItem} />
<ClientComponent updateItemAction={updateItem} />
```

```jsx filename="app/client-component.jsx"
```tsx filename="app/client-component.tsx" switcher
'use client'

export default function ClientComponent({ updateItem }) {
return <form action={updateItem}>{/* ... */}</form>
export default function ClientComponent({
updateItemAction,
}: {
updateItemAction: (formData: FormData) => void
}) {
return <form action={updateItemAction}>{/* ... */}</form>
}
```

```jsx filename="app/client-component.jsx" switcher
'use client'

export default function ClientComponent({ updateItemAction }) {
return <form action={updateItemAction}>{/* ... */}</form>
}
```

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 [`<form>` element](#forms):
Expand Down
23 changes: 21 additions & 2 deletions packages/next/src/server/typescript/rules/client-boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions test/development/typescript-plugin/README.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions test/development/typescript-plugin/app/client.tsx
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 7 additions & 0 deletions test/development/typescript-plugin/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
11 changes: 11 additions & 0 deletions test/development/typescript-plugin/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ClientComponent } from './client'

const noop = () => {}

export default function Page() {
return (
<>
<ClientComponent unknown={noop} unknownAction={noop} />
</>
)
}
6 changes: 6 additions & 0 deletions test/development/typescript-plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "typescript-plugin-fixture",
"dependencies": {
"typescript": "^5.5.2"
}
}
24 changes: 24 additions & 0 deletions test/development/typescript-plugin/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}

0 comments on commit 08da98d

Please sign in to comment.