Skip to content

Commit

Permalink
next/navigation Typescript support for pages/ (#45919)
Browse files Browse the repository at this point in the history
When you're trying to migrate an application from `pages/` to `app/`,
you'll need to access data like search parameters and the pathname in a
way that lets you migrate safely.

This adds support for dynamic typing of some of those exported functions
from `next/navigation`, namely `useSearchParams` and `usePathname`.
Currently, `searchParams` can’t be known when prerendering if the page
doesn’t use [Server-side
Rendering](https://nextjs.org/docs/basic-features/data-fetching/get-server-side-props)
in the `pages/` directory. `pathname` can’t be known during prerendering
if the page is a fallback page or has been automatically statically
optimized when accessed from `pages/`.

To make migraitons easier, this adds a new feature to `next dev` that
will automatically add the correct types for `next/navigation`. It does
this by checking if you have both a `app/` and `pages/` directory. If it
detects you have a `app/` directory, it will also enable the suggested
Typescript feature,
[`structNullChecks`](https://www.typescriptlang.org/tsconfig#strictNullChecks)
which will warn developers when trying to access a value that may be
`null`.

---------

Co-authored-by: JJ Kasper <[email protected]>
  • Loading branch information
wyattjoh and ijjk authored Feb 15, 2023
1 parent c8fe390 commit cdf1d52
Show file tree
Hide file tree
Showing 11 changed files with 187 additions and 37 deletions.
24 changes: 24 additions & 0 deletions packages/next/navigation-types/compat/navigation.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { ReadonlyURLSearchParams } from 'next/navigation'

declare module 'next/navigation' {
/**
* Get a read-only URLSearchParams object. For example searchParams.get('foo') would return 'bar' when ?foo=bar
* Learn more about URLSearchParams here: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
*
* If used from `pages/`, the hook may return `null` when the router is not
* ready.
*/
export function useSearchParams(): ReadonlyURLSearchParams | null

/**
* Get the current pathname. For example, if the URL is
* https://example.com/foo?bar=baz, the pathname would be /foo.
*
* If the hook is accessed from `pages/`, the pathname may be `null` when the
* router is not ready.
*/
export function usePathname(): string | null

// Re-export the types for next/navigation.
export * from 'next/dist/client/components/navigation'
}
18 changes: 18 additions & 0 deletions packages/next/navigation-types/navigation.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ReadonlyURLSearchParams } from 'next/navigation'

declare module 'next/navigation' {
/**
* Get a read-only URLSearchParams object. For example searchParams.get('foo') would return 'bar' when ?foo=bar
* Learn more about URLSearchParams here: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
*/
export function useSearchParams(): ReadonlyURLSearchParams

/**
* Get the current pathname. For example, if the URL is
* https://example.com/foo?bar=baz, the pathname would be /foo.
*/
export function usePathname(): string

// Re-export the types for next/navigation.
export * from 'next/dist/client/components/navigation'
}
7 changes: 5 additions & 2 deletions packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,14 @@
"types/global.d.ts",
"types/compiled.d.ts",
"image-types/global.d.ts",
"navigation-types/navigation.d.ts",
"navigation-types/compat/navigation.d.ts",
"font",
"navigation.js",
"navigation.d.ts",
"headers.js",
"headers.d.ts"
"headers.d.ts",
"navigation-types"
],
"bin": {
"next": "./dist/bin/next"
Expand All @@ -64,7 +67,7 @@
"release": "taskr release",
"build": "pnpm release && pnpm types",
"prepublishOnly": "cd ../../ && turbo run build",
"types": "tsc --declaration --emitDeclarationOnly --declarationDir dist",
"types": "tsc --declaration --emitDeclarationOnly --stripInternal --declarationDir dist",
"typescript": "tsec --noEmit",
"ncc-compiled": "ncc cache clean && taskr ncc",
"test-pack": "cd ../../ && pnpm test-pack next"
Expand Down
7 changes: 5 additions & 2 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ function verifyTypeScriptSetup(
disableStaticImages: boolean,
cacheDir: string | undefined,
enableWorkerThreads: boolean | undefined,
isAppDirEnabled: boolean
isAppDirEnabled: boolean,
hasPagesDir: boolean
) {
const typeCheckWorker = new JestWorker(
require.resolve('../lib/verifyTypeScriptSetup'),
Expand All @@ -193,6 +194,7 @@ function verifyTypeScriptSetup(
disableStaticImages,
cacheDir,
isAppDirEnabled,
hasPagesDir,
})
.then((result) => {
typeCheckWorker.end()
Expand Down Expand Up @@ -415,7 +417,8 @@ export default async function build(
config.images.disableStaticImages,
cacheDir,
config.experimental.workerThreads,
isAppDirEnabled
isAppDirEnabled,
!!pagesDir
).then((resolved) => {
const checkEnd = process.hrtime(typeCheckStart)
return [resolved, checkEnd] as const
Expand Down
20 changes: 13 additions & 7 deletions packages/next/src/client/components/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function readonlyURLSearchParamsError() {
return new Error('ReadonlyURLSearchParams cannot be modified')
}

class ReadonlyURLSearchParams {
export class ReadonlyURLSearchParams {
[INTERNAL_URLSEARCHPARAMS_INSTANCE]: URLSearchParams

entries: URLSearchParams['entries']
Expand Down Expand Up @@ -68,13 +68,21 @@ class ReadonlyURLSearchParams {
/**
* Get a read-only URLSearchParams object. For example searchParams.get('foo') would return 'bar' when ?foo=bar
* Learn more about URLSearchParams here: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
*
* @internal - re-exported in `next-env.d.ts`.
*/
export function useSearchParams() {
export function useSearchParams(): ReadonlyURLSearchParams | null {
clientHookInServerComponentError('useSearchParams')
const searchParams = useContext(SearchParamsContext)

const readonlySearchParams = useMemo(() => {
return new ReadonlyURLSearchParams(searchParams || new URLSearchParams())
if (!searchParams) {
// When the router is not ready in pages, we won't have the search params
// available.
return null
}

return new ReadonlyURLSearchParams(searchParams)
}, [searchParams])

if (typeof window === 'undefined') {
Expand All @@ -87,15 +95,13 @@ export function useSearchParams() {
}
}

if (!searchParams) {
throw new Error('invariant expected search params to be mounted')
}

return readonlySearchParams
}

/**
* Get the current pathname. For example usePathname() on /dashboard?foo=bar would return "/dashboard"
*
* @internal - re-exported in `next-env.d.ts`.
*/
export function usePathname(): string | null {
clientHookInServerComponentError('usePathname')
Expand Down
63 changes: 47 additions & 16 deletions packages/next/src/lib/typescript/writeAppTypeDeclarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@ import os from 'os'
import path from 'path'
import { promises as fs } from 'fs'

export async function writeAppTypeDeclarations(
baseDir: string,
export async function writeAppTypeDeclarations({
baseDir,
imageImportsEnabled,
hasPagesDir,
isAppDirEnabled,
}: {
baseDir: string
imageImportsEnabled: boolean
): Promise<void> {
hasPagesDir: boolean
isAppDirEnabled: boolean
}): Promise<void> {
// Reference `next` types
const appTypeDeclarations = path.join(baseDir, 'next-env.d.ts')

Expand All @@ -25,19 +32,43 @@ export async function writeAppTypeDeclarations(
eol = '\n'
}
}
} catch (err) {}

const content =
'/// <reference types="next" />' +
eol +
(imageImportsEnabled
? '/// <reference types="next/image-types/global" />' + eol
: '') +
eol +
'// NOTE: This file should not be edited' +
eol +
'// see https://nextjs.org/docs/basic-features/typescript for more information.' +
eol
} catch {}

/**
* "Triple-slash directives" used to create typings files for Next.js projects
* using Typescript .
*
* @see https://www.typescriptlang.org/docs/handbook/triple-slash-directives.html
*/
const directives: string[] = [
// Include the core Next.js typings.
'/// <reference types="next" />',
]

if (imageImportsEnabled) {
directives.push('/// <reference types="next/image-types/global" />')
}

if (isAppDirEnabled) {
if (hasPagesDir) {
directives.push(
'/// <reference types="next/navigation-types/compat/navigation" />'
)
} else {
directives.push(
'/// <reference types="next/navigation-types/navigation" />'
)
}
}

// Push the notice in.
directives.push(
'',
'// NOTE: This file should not be edited',
'// see https://nextjs.org/docs/basic-features/typescript for more information.'
)

const content = directives.join(eol) + eol

// Avoids an un-necessary write on read-only fs
if (currentContent === content) {
Expand Down
17 changes: 16 additions & 1 deletion packages/next/src/lib/typescript/writeConfigurationDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ export async function writeConfigurationDefaults(
tsConfigPath: string,
isFirstTimeSetup: boolean,
isAppDirEnabled: boolean,
distDir: string
distDir: string,
hasPagesDir: boolean
): Promise<void> {
if (isFirstTimeSetup) {
await fs.writeFile(tsConfigPath, '{}' + os.EOL)
Expand Down Expand Up @@ -226,6 +227,20 @@ export async function writeConfigurationDefaults(
)
}
}

// If `strict` is set to `false` or `strictNullChecks` is set to `false`,
// then set `strictNullChecks` to `true`.
if (
hasPagesDir &&
isAppDirEnabled &&
!userTsConfig.compilerOptions.strict &&
!('strictNullChecks' in userTsConfig.compilerOptions)
) {
userTsConfig.compilerOptions.strictNullChecks = true
suggestedActions.push(
chalk.cyan('strictNullChecks') + ' was set to ' + chalk.bold(`true`)
)
}
}
}

Expand Down
12 changes: 10 additions & 2 deletions packages/next/src/lib/verifyTypeScriptSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export async function verifyTypeScriptSetup({
typeCheckPreflight,
disableStaticImages,
isAppDirEnabled,
hasPagesDir,
}: {
dir: string
distDir: string
Expand All @@ -55,6 +56,7 @@ export async function verifyTypeScriptSetup({
typeCheckPreflight: boolean
disableStaticImages: boolean
isAppDirEnabled: boolean
hasPagesDir: boolean
}): Promise<{ result?: TypeCheckResult; version: string | null }> {
const resolvedTsConfigPath = path.join(dir, tsconfigPath)

Expand Down Expand Up @@ -122,11 +124,17 @@ export async function verifyTypeScriptSetup({
resolvedTsConfigPath,
intent.firstTimeSetup,
isAppDirEnabled,
distDir
distDir,
hasPagesDir
)
// Write out the necessary `next-env.d.ts` file to correctly register
// Next.js' types:
await writeAppTypeDeclarations(dir, !disableStaticImages)
await writeAppTypeDeclarations({
baseDir: dir,
imageImportsEnabled: !disableStaticImages,
hasPagesDir,
isAppDirEnabled,
})

if (isAppDirEnabled && !isCI) {
await writeVscodeConfigurations(dir, tsPath)
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/dev/next-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,7 @@ export default class DevServer extends Server {
tsconfigPath: this.nextConfig.typescript.tsconfigPath,
disableStaticImages: this.nextConfig.images.disableStaticImages,
isAppDirEnabled: !!this.appDir,
hasPagesDir: !!this.pagesDir,
})

if (verifyResult.version) {
Expand Down
4 changes: 3 additions & 1 deletion packages/next/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"image-types/global.d.ts",
"compat/*.d.ts",
"legacy/*.d.ts",
"types/compiled.d.ts"
"types/compiled.d.ts",
"navigation-types/*.d.ts",
"navigation-types/compat/*.d.ts"
]
}
51 changes: 45 additions & 6 deletions test/unit/write-app-declarations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ const declarationFile = join(fixtureDir, 'next-env.d.ts')
const imageImportsEnabled = false

describe('find config', () => {
beforeEach(async () => {
await fs.ensureDir(fixtureDir)
})
afterEach(() => fs.remove(declarationFile))

it('should preserve CRLF EOL', async () => {
Expand All @@ -25,10 +28,14 @@ describe('find config', () => {
'// see https://nextjs.org/docs/basic-features/typescript for more information.' +
eol

await fs.ensureDir(fixtureDir)
await fs.writeFile(declarationFile, content)

await writeAppTypeDeclarations(fixtureDir, imageImportsEnabled)
await writeAppTypeDeclarations({
baseDir: fixtureDir,
imageImportsEnabled,
hasPagesDir: false,
isAppDirEnabled: false,
})
expect(await fs.readFile(declarationFile, 'utf8')).toBe(content)
})

Expand All @@ -46,10 +53,14 @@ describe('find config', () => {
'// see https://nextjs.org/docs/basic-features/typescript for more information.' +
eol

await fs.ensureDir(fixtureDir)
await fs.writeFile(declarationFile, content)

await writeAppTypeDeclarations(fixtureDir, imageImportsEnabled)
await writeAppTypeDeclarations({
baseDir: fixtureDir,
imageImportsEnabled,
hasPagesDir: false,
isAppDirEnabled: false,
})
expect(await fs.readFile(declarationFile, 'utf8')).toBe(content)
})

Expand All @@ -67,8 +78,36 @@ describe('find config', () => {
'// see https://nextjs.org/docs/basic-features/typescript for more information.' +
eol

await fs.ensureDir(fixtureDir)
await writeAppTypeDeclarations(fixtureDir, imageImportsEnabled)
await writeAppTypeDeclarations({
baseDir: fixtureDir,
imageImportsEnabled,
hasPagesDir: false,
isAppDirEnabled: false,
})
expect(await fs.readFile(declarationFile, 'utf8')).toBe(content)
})

it('should include navigation types if app directory is enabled', async () => {
await writeAppTypeDeclarations({
baseDir: fixtureDir,
imageImportsEnabled,
hasPagesDir: false,
isAppDirEnabled: true,
})

await expect(fs.readFile(declarationFile, 'utf8')).resolves.toContain(
'next/navigation-types/navigation'
)

await writeAppTypeDeclarations({
baseDir: fixtureDir,
imageImportsEnabled,
hasPagesDir: true,
isAppDirEnabled: true,
})

await expect(fs.readFile(declarationFile, 'utf8')).resolves.toContain(
'next/navigation-types/compat/navigation'
)
})
})

0 comments on commit cdf1d52

Please sign in to comment.