diff --git a/packages/next/src/client/components/client-hook-in-server-component-error.ts b/packages/next/src/client/components/client-hook-in-server-component-error.ts new file mode 100644 index 0000000000000..13f44a21fe7c3 --- /dev/null +++ b/packages/next/src/client/components/client-hook-in-server-component-error.ts @@ -0,0 +1,14 @@ +import React from 'react' + +export function clientHookInServerComponentError( + hookName: string +): void | never { + if (process.env.NODE_ENV !== 'production') { + // If useState is undefined we're in a server component + if (!React.useState) { + throw new Error( + `${hookName} only works in Client Components. Add the "use client" directive at the top of the file to use it. Read more: https://nextjs.org/docs/messages/react-client-hook-in-server-component` + ) + } + } +} diff --git a/packages/next/src/client/components/navigation.ts b/packages/next/src/client/components/navigation.ts index c2186730f891c..7250e36921334 100644 --- a/packages/next/src/client/components/navigation.ts +++ b/packages/next/src/client/components/navigation.ts @@ -13,6 +13,7 @@ import { // LayoutSegmentsContext, } from '../../shared/lib/hooks-client-context' import { bailoutToClientRendering } from './bailout-to-client-rendering' +import { clientHookInServerComponentError } from './client-hook-in-server-component-error' const INTERNAL_URLSEARCHPARAMS_INSTANCE = Symbol( 'internal for urlsearchparams readonly' @@ -70,6 +71,7 @@ class ReadonlyURLSearchParams { * Learn more about URLSearchParams here: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams */ export function useSearchParams() { + clientHookInServerComponentError('useSearchParams') const searchParams = useContext(SearchParamsContext) const readonlySearchParams = useMemo(() => { @@ -92,19 +94,16 @@ export function useSearchParams() { * Get the current pathname. For example usePathname() on /dashboard?foo=bar would return "/dashboard" */ export function usePathname(): string | null { + clientHookInServerComponentError('usePathname') return useContext(PathnameContext) } // TODO-APP: getting all params when client-side navigating is non-trivial as it does not have route matchers so this might have to be a server context instead. // export function useParams() { +// clientHookInServerComponentError('useParams') // return useContext(ParamsContext) // } -// TODO-APP: define what should be provided through context. -// export function useLayoutSegments() { -// return useContext(LayoutSegmentsContext) -// } - export { ServerInsertedHTMLContext, useServerInsertedHTML, @@ -114,6 +113,7 @@ export { * Get the router methods. For example router.push('/dashboard') */ export function useRouter(): import('../../shared/lib/app-router-context').AppRouterInstance { + clientHookInServerComponentError('useRouter') const router = useContext(AppRouterContext) if (router === null) { throw new Error('invariant expected app router to be mounted') @@ -161,6 +161,7 @@ function getSelectedLayoutSegmentPath( export function useSelectedLayoutSegments( parallelRouteKey: string = 'children' ): string[] { + clientHookInServerComponentError('useSelectedLayoutSegments') const { tree } = useContext(LayoutRouterContext) return getSelectedLayoutSegmentPath(tree, parallelRouteKey) } @@ -172,6 +173,7 @@ export function useSelectedLayoutSegments( export function useSelectedLayoutSegment( parallelRouteKey: string = 'children' ): string | null { + clientHookInServerComponentError('useSelectedLayoutSegment') const selectedLayoutSegments = useSelectedLayoutSegments(parallelRouteKey) if (selectedLayoutSegments.length === 0) { return null diff --git a/scripts/run-for-change.js b/scripts/run-for-change.js index 2bb77af19c3e9..c16e05452fcf6 100644 --- a/scripts/run-for-change.js +++ b/scripts/run-for-change.js @@ -65,7 +65,7 @@ async function main() { const typeIndex = process.argv.indexOf('--type') const type = typeIndex > -1 && process.argv[typeIndex + 1] const isNegated = process.argv.indexOf('--not') > -1 - const alwaysCanary = process.argv.includes('--always-canary') > -1 + const alwaysCanary = process.argv.indexOf('--always-canary') > -1 if (!type) { throw new Error( diff --git a/test/development/acceptance-app/server-components.test.ts b/test/development/acceptance-app/server-components.test.ts index 6d4ad12f4dbf9..335ae48331072 100644 --- a/test/development/acceptance-app/server-components.test.ts +++ b/test/development/acceptance-app/server-components.test.ts @@ -291,5 +291,47 @@ createNextDescribe( await cleanup() }) }) + + describe('Next.js component hooks called in Server Component', () => { + it.each([ + // TODO-APP: add test for useParams + // ["useParams"], + ['useRouter'], + ['useSearchParams'], + ['useSelectedLayoutSegment'], + ['useSelectedLayoutSegments'], + ['usePathname'], + ])('should show error when %s is called', async (hook: string) => { + const { session, cleanup } = await sandbox( + next, + new Map([ + [ + 'app/page.js', + ` + import { ${hook} } from 'next/navigation' + export default function Page() { + ${hook}() + return "Hello world" + }`, + ], + ]) + ) + + expect(await session.hasRedbox(true)).toBe(true) + + await check(async () => { + expect(await session.getRedboxSource(true)).toContain( + `Error: ${hook} only works in Client Components. Add the "use client" directive at the top of the file to use it. Read more: https://nextjs.org/docs/messages/react-client-hook-in-server-component` + ) + return 'success' + }, 'success') + + expect(next.cliOutput).toContain( + `${hook} only works in Client Components` + ) + + await cleanup() + }) + }) } ) diff --git a/test/unit/google-font-loader.test.ts b/test/unit/google-font-loader.test.ts index 45097bf105384..1c57550a4a708 100644 --- a/test/unit/google-font-loader.test.ts +++ b/test/unit/google-font-loader.test.ts @@ -166,7 +166,9 @@ describe('@next/font/google loader', () => { variableName: 'myFont', }) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Cannot read properties of undefined (reading 'subsets')"` + process.version.startsWith('v14') + ? `"Cannot read property 'subsets' of undefined"` + : `"Cannot read properties of undefined (reading 'subsets')"` ) })