Skip to content

Commit 2518080

Browse files
committed
[ts-plugin] handle display the function type along with doc
1 parent 83ae64f commit 2518080

File tree

6 files changed

+92
-20
lines changed

6 files changed

+92
-20
lines changed

packages/next/src/server/typescript/rules/config.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isPositionInsideNode,
66
getTs,
77
removeStringQuotes,
8+
getTypeChecker,
89
} from '../utils'
910
import { NEXT_TS_ERRORS, ALLOWED_EXPORTS } from '../constant'
1011
import type tsModule from 'typescript/lib/tsserverlibrary'
@@ -308,9 +309,49 @@ const config = {
308309
API_DOCS[entryConfig].link,
309310
}
310311

311-
if (value && isPositionInsideNode(position, value)) {
312-
// Hovers the value of the config
313-
const isString = ts.isStringLiteral(value)
312+
// When the value is a flexible type (like a function), also compute its
313+
// inferred type so we can surface it alongside the docs. This is useful
314+
// even when the value is considered invalid by the config validation,
315+
// as long as it's not a direct literal export.
316+
let displayParts: tsModule.SymbolDisplayPart[] = []
317+
const typeChecker = getTypeChecker()
318+
const isString = !!value && ts.isStringLiteral(value)
319+
const isFunctionValue =
320+
!!value &&
321+
!isString &&
322+
(ts.isArrowFunction(value) ||
323+
ts.isFunctionExpression(value) ||
324+
ts.isFunctionDeclaration(value))
325+
326+
if (typeChecker && value && isFunctionValue) {
327+
try {
328+
// If we're hovering the config identifier, ask for the type at the
329+
// identifier; otherwise, ask at the value node. This makes sure
330+
// highlighting `generateMetadata` itself also shows the inferred type.
331+
const typeTarget = isPositionInsideNode(position, name) ? name : value
332+
const type = typeChecker.getTypeAtLocation(typeTarget)
333+
if (type) {
334+
const typeString = typeChecker.typeToString(type, typeTarget)
335+
if (typeString) {
336+
displayParts = [
337+
{
338+
text: typeString,
339+
kind: 'typeName',
340+
},
341+
]
342+
}
343+
}
344+
} catch {
345+
// If type checking fails, continue without type info.
346+
}
347+
}
348+
349+
// For non-function values (like literals), hovering the value should show
350+
// option-specific docs. For function-valued configs (e.g. `generateMetadata`),
351+
// we let TypeScript handle hover anywhere in the initializer except for the
352+
// export identifier itself.
353+
if (value && !isFunctionValue && isPositionInsideNode(position, value)) {
354+
// Hovering the value of the config
314355
const text = removeStringQuotes(value.getText())
315356
const key = isString ? `"${text}"` : text
316357

@@ -339,19 +380,32 @@ const config = {
339380
],
340381
}
341382
} else {
342-
// Wrong value, display the docs link
383+
// Wrong value: still show the docs link, and when available, the
384+
// inferred type for non-literal (i.e. non-direct) exports.
343385
overridden = {
344386
kind: ts.ScriptElementKind.enumElement,
345387
kindModifiers: ts.ScriptElementKindModifier.none,
346388
textSpan: {
347389
start: value.getStart(),
348390
length: value.getWidth(),
349391
},
350-
displayParts: [],
392+
displayParts,
351393
documentation: [docsLink],
352394
}
353395
}
354396
} else {
397+
// For function-valued configs, if we're hovering anywhere within the
398+
// initializer (including `async`, parameters, or the body) but not on
399+
// the export identifier itself, don't override TypeScript's default
400+
// hover. We only want to override when hovering the config identifier
401+
// (e.g. `generateMetadata`), not arbitrary tokens within the function.
402+
if (
403+
isFunctionValue &&
404+
isPositionInsideNode(position, value) && // hover is somewhere within the function initializer
405+
!isPositionInsideNode(position, name) // ...but not on the export identifier itself
406+
) {
407+
return
408+
}
355409
// Hovers the name of the config
356410
overridden = {
357411
kind: ts.ScriptElementKind.enumElement,
@@ -360,7 +414,7 @@ const config = {
360414
start: name.getStart(),
361415
length: name.getWidth(),
362416
},
363-
displayParts: [],
417+
displayParts,
364418
documentation: [
365419
{
366420
kind: 'text',
Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,3 @@
1-
import { ClientComponent } from './client'
2-
3-
const noop = () => {}
4-
51
export default function Page() {
6-
return (
7-
<>
8-
<ClientComponent unknown={noop} unknownAction={noop} />
9-
</>
10-
)
2+
return <>hello world</>
113
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Metadata } from 'next'
2+
3+
export default function Layout({ children }: { children: React.ReactNode }) {
4+
return <>{children}</>
5+
}
6+
7+
export const metadata: Metadata = {
8+
title: 'Project Layout',
9+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export default async function Page(props: PageProps<'/project/[slug]'>) {
2+
const { slug } = await props.params
3+
return <p>project {slug}</p>
4+
}
5+
6+
export const generateMetadata = async ({
7+
params,
8+
}: PageProps<'/project/[slug]'>) => {
9+
const { slug } = await params
10+
return {
11+
title: `Project ${slug}`,
12+
}
13+
}

test/e2e/app-dir/typed-routes/app/(logged-in)/project/[slug]/page.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,12 @@ export default async function Page(props: PageProps<'/project/[slug]'>) {
22
const { slug } = await props.params
33
return <p>project {slug}</p>
44
}
5+
6+
export const generateMetadata = async ({
7+
params,
8+
}: PageProps<'/project/[slug]'>) => {
9+
const { slug } = await params
10+
return {
11+
title: `Project ${slug}`,
12+
}
13+
}

test/e2e/app-dir/typed-routes/next.config.js

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,6 @@ const nextConfig = {
88
typedRoutes: true,
99
async redirects() {
1010
return [
11-
{
12-
source: '/project/:slug',
13-
destination: '/project/:slug',
14-
permanent: true,
15-
},
1611
{
1712
source: '/blog/:category/:slug*',
1813
destination: '/posts/:category/:slug*',

0 commit comments

Comments
 (0)