diff --git a/packages/next/navigation-types/compat/navigation.d.ts b/packages/next/navigation-types/compat/navigation.d.ts new file mode 100644 index 0000000000000..28b46d331740e --- /dev/null +++ b/packages/next/navigation-types/compat/navigation.d.ts @@ -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' +} diff --git a/packages/next/navigation-types/navigation.d.ts b/packages/next/navigation-types/navigation.d.ts new file mode 100644 index 0000000000000..12ed2d55c8394 --- /dev/null +++ b/packages/next/navigation-types/navigation.d.ts @@ -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' +} diff --git a/packages/next/package.json b/packages/next/package.json index 4071ae932bd2b..05821e441e2ae 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -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" @@ -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" diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index c79d86bcce869..28a69f68530d4 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -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'), @@ -193,6 +194,7 @@ function verifyTypeScriptSetup( disableStaticImages, cacheDir, isAppDirEnabled, + hasPagesDir, }) .then((result) => { typeCheckWorker.end() @@ -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 diff --git a/packages/next/src/client/components/navigation.ts b/packages/next/src/client/components/navigation.ts index 575016c9577d5..0733f797165b6 100644 --- a/packages/next/src/client/components/navigation.ts +++ b/packages/next/src/client/components/navigation.ts @@ -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'] @@ -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') { @@ -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') diff --git a/packages/next/src/lib/typescript/writeAppTypeDeclarations.ts b/packages/next/src/lib/typescript/writeAppTypeDeclarations.ts index edd3e861b237e..8cf1510ca3467 100644 --- a/packages/next/src/lib/typescript/writeAppTypeDeclarations.ts +++ b/packages/next/src/lib/typescript/writeAppTypeDeclarations.ts @@ -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 { + hasPagesDir: boolean + isAppDirEnabled: boolean +}): Promise { // Reference `next` types const appTypeDeclarations = path.join(baseDir, 'next-env.d.ts') @@ -25,19 +32,43 @@ export async function writeAppTypeDeclarations( eol = '\n' } } - } catch (err) {} - - const content = - '/// ' + - eol + - (imageImportsEnabled - ? '/// ' + 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. + '/// ', + ] + + if (imageImportsEnabled) { + directives.push('/// ') + } + + if (isAppDirEnabled) { + if (hasPagesDir) { + directives.push( + '/// ' + ) + } else { + directives.push( + '/// ' + ) + } + } + + // 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) { diff --git a/packages/next/src/lib/typescript/writeConfigurationDefaults.ts b/packages/next/src/lib/typescript/writeConfigurationDefaults.ts index 92ff58591dbbf..8fb6efb73ffb6 100644 --- a/packages/next/src/lib/typescript/writeConfigurationDefaults.ts +++ b/packages/next/src/lib/typescript/writeConfigurationDefaults.ts @@ -109,7 +109,8 @@ export async function writeConfigurationDefaults( tsConfigPath: string, isFirstTimeSetup: boolean, isAppDirEnabled: boolean, - distDir: string + distDir: string, + hasPagesDir: boolean ): Promise { if (isFirstTimeSetup) { await fs.writeFile(tsConfigPath, '{}' + os.EOL) @@ -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`) + ) + } } } diff --git a/packages/next/src/lib/verifyTypeScriptSetup.ts b/packages/next/src/lib/verifyTypeScriptSetup.ts index aefc72cea7028..34ace97542b98 100644 --- a/packages/next/src/lib/verifyTypeScriptSetup.ts +++ b/packages/next/src/lib/verifyTypeScriptSetup.ts @@ -46,6 +46,7 @@ export async function verifyTypeScriptSetup({ typeCheckPreflight, disableStaticImages, isAppDirEnabled, + hasPagesDir, }: { dir: string distDir: string @@ -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) @@ -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) diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index e17ff274c82d4..3cdee0fe39186 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -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) { diff --git a/packages/next/tsconfig.json b/packages/next/tsconfig.json index 0cbb6fd2199e9..19a34efa7da69 100644 --- a/packages/next/tsconfig.json +++ b/packages/next/tsconfig.json @@ -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" ] } diff --git a/test/unit/write-app-declarations.test.ts b/test/unit/write-app-declarations.test.ts index 5d9df38937b4a..b9809c56504a0 100644 --- a/test/unit/write-app-declarations.test.ts +++ b/test/unit/write-app-declarations.test.ts @@ -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 () => { @@ -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) }) @@ -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) }) @@ -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' + ) + }) })