Skip to content

Commit

Permalink
fix: Allow to import next-intl/server into Client Components (howev…
Browse files Browse the repository at this point in the history
…er not call any functions). This allows for easier testing of page components with multiple exports. (#683)

Fixes #681
  • Loading branch information
amannn committed Dec 1, 2023
1 parent b49ca70 commit 5ca4075
Show file tree
Hide file tree
Showing 42 changed files with 349 additions and 109 deletions.
13 changes: 13 additions & 0 deletions examples/example-app-router-playground/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/* eslint-env node */
const nextJest = require('next/jest');

const createJestConfig = nextJest({dir: './'});

module.exports = createJestConfig({
moduleNameMapper: {
'^next$': require.resolve('next'),
'^next/navigation$': require.resolve('next/navigation')
},
testEnvironment: 'jsdom',
rootDir: 'src'
});
11 changes: 9 additions & 2 deletions examples/example-app-router-playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
"scripts": {
"dev": "next dev",
"lint": "eslint src && tsc",
"test": "playwright test",
"test:watch": "chokidar 'tests/main.spec.ts' -c 'npm run test'",
"test": "pnpm run test:playwright && pnpm run test:jest",
"test:playwright": "playwright test",
"test:playwright:watch": "chokidar 'tests/main.spec.ts' -c 'npm run test:playwright'",
"test:jest": "jest",
"build": "next build",
"start": "next start"
},
Expand All @@ -19,14 +21,19 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@jest/globals": "^29.5.0",
"@playwright/test": "^1.33.0",
"@testing-library/react": "^13.0.0",
"@types/jest": "^29.5.0",
"@types/lodash": "^4.14.176",
"@types/node": "^17.0.23",
"@types/react": "^18.2.29",
"chokidar-cli": "3.0.0",
"eslint": "^8.54.0",
"eslint-config-molindo": "^7.0.0",
"eslint-config-next": "^14.0.3",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"sharp": "^0.32.6",
"typescript": "^5.2.2"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {render, screen} from '@testing-library/react';
import pick from 'lodash/pick';
import {NextIntlClientProvider} from 'next-intl';
import messages from '../../../../messages/en.json';
import Nested, {generateMetadata} from './page';

it('renders', () => {
render(
<NextIntlClientProvider locale="en" messages={pick(messages, ['Nested'])}>
<Nested />
</NextIntlClientProvider>
);
screen.getByText('Nested');
});

it("can't generate metadata in a test", async () => {
await expect(generateMetadata()).rejects.toThrow(
'`getTranslations` is not supported in Client Components.'
);
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import {useTranslations} from 'next-intl';
import {getTranslations} from 'next-intl/server';
import PageLayout from '../../../components/PageLayout';
import UnlocalizedPathname from './UnlocalizedPathname';

export async function generateMetadata() {
const t = await getTranslations('Nested');
return {
title: t('title')
};
}

export default function Nested() {
const t = useTranslations('Nested');

Expand Down
13 changes: 9 additions & 4 deletions packages/next-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
},
"./server": {
"types": "./server.d.ts",
"default": "./dist/esm/server.js"
"react-server": "./dist/esm/server.react-server.js",
"default": "./dist/server.react-client.js"
},
"./config": {
"types": "./config.d.ts",
Expand Down Expand Up @@ -107,7 +108,7 @@
},
{
"path": "dist/production/index.react-server.js",
"limit": "13.57 KB"
"limit": "13.58 KB"
},
{
"path": "dist/production/navigation.react-client.js",
Expand All @@ -118,8 +119,12 @@
"limit": "2.8 KB"
},
{
"path": "dist/production/server.js",
"limit": "13.2 kB"
"path": "dist/production/server.react-client.js",
"limit": "1 KB"
},
{
"path": "dist/production/server.react-server.js",
"limit": "12.8 KB"
},
{
"path": "dist/production/middleware.js",
Expand Down
3 changes: 2 additions & 1 deletion packages/next-intl/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ const config = {
'navigation.react-client': 'src/navigation.react-client.tsx',
'navigation.react-server': 'src/navigation.react-server.tsx',

server: 'src/server.tsx',
'server.react-client': 'src/server.react-client.tsx',
'server.react-server': 'src/server.react-server.tsx',

middleware: 'src/middleware.tsx',
plugin: 'src/plugin.tsx',
Expand Down
2 changes: 1 addition & 1 deletion packages/next-intl/server.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export * from './dist/types/src/server';
export * from './dist/types/src/server/react-server';
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,10 @@ export default function createLocalizedPathnamesNavigation<
function usePathname(): keyof PathnamesConfig {
const pathname = useBasePathname();
const locale = useTypedLocale();
return getRoute({pathname, locale, pathnames: opts.pathnames});
// @ts-expect-error -- Mirror the behavior from Next.js, where `null` is returned when `usePathname` is used outside of Next, but the types indicate that a string is always returned.
return pathname
? getRoute({pathname, locale, pathnames: opts.pathnames})
: pathname;
}

function getPathname({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,15 @@ export default function createSharedPathnamesNavigation<
return clientRedirect({...opts, pathname}, ...args);
}

function usePathname(): string {
// @ts-expect-error -- Mirror the behavior from Next.js, where `null` is returned when `usePathname` is used outside of Next, but the types indicate that a string is always returned.
return useBasePathname();
}

return {
Link: LinkWithRef,
redirect,
usePathname: useBasePathname,
usePathname,
useRouter: useBaseRouter
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {hasPathnamePrefixed, unlocalizePathname} from '../../shared/utils';
* const pathname = usePathname();
* ```
*/
export default function useBasePathname(): string {
export default function useBasePathname(): string | null {
// The types aren't entirely correct here. Outside of Next.js
// `useParams` can be called, but the return type is `null`.
const pathname = useNextPathname() as ReturnType<
Expand All @@ -28,8 +28,7 @@ export default function useBasePathname(): string {
const locale = useLocale();

return useMemo(() => {
if (!pathname) return pathname as ReturnType<typeof useNextPathname>;

if (!pathname) return pathname;
const isPathnamePrefixed = hasPathnamePrefixed(locale, pathname);
const unlocalizedPathname = isPathnamePrefixed
? unlocalizePathname(pathname, locale)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {ComponentProps} from 'react';
import {getLocale} from '../../server';
import {getLocale} from '../../server.react-server';
import {AllLocales} from '../../shared/types';
import BaseLink from '../shared/BaseLink';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {ComponentProps} from 'react';
import {getRequestLocale} from '../../server/RequestLocale';
import {getRequestLocale} from '../../server/react-server/RequestLocale';
import {
AllLocales,
LocalePrefix,
Expand Down Expand Up @@ -82,10 +82,10 @@ export default function createLocalizedPathnamesNavigation<
});
}

function notSupported(message: string) {
function notSupported(hookName: string) {
return () => {
throw new Error(
`\`${message}\` is not supported in Server Components. You can use this hook if you convert the component to a Client Component.`
`\`${hookName}\` is not supported in Server Components. You can use this hook if you convert the component to a Client Component.`
);
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import serverRedirect from './serverRedirect';
export default function createSharedPathnamesNavigation<
Locales extends AllLocales
>(opts: {locales: Locales; localePrefix?: LocalePrefix}) {
function notSupported(message: string) {
function notSupported(hookName: string) {
return () => {
throw new Error(
`\`${message}\` is not supported in Server Components. You can use this hook if you convert the component to a Client Component.`
`\`${hookName}\` is not supported in Server Components. You can use this hook if you convert the component to a Client Component.`
);
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {getRequestLocale} from '../../server/RequestLocale';
import {getRequestLocale} from '../../server/react-server/RequestLocale';
import {LocalePrefix, ParametersExceptFirstTwo} from '../../shared/types';
import baseRedirect from '../shared/baseRedirect';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {ComponentProps} from 'react';
import {getLocale, getNow, getTimeZone} from '../server';
import {getLocale, getNow, getTimeZone} from '../server.react-server';
import BaseNextIntlClientProvider from '../shared/NextIntlClientProvider';

type Props = ComponentProps<typeof BaseNextIntlClientProvider>;
Expand Down
2 changes: 1 addition & 1 deletion packages/next-intl/src/react-server/getBaseTranslator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
createTranslator,
MarkupTranslationValues
} from 'use-intl/core';
import getConfig from '../server/getConfig';
import getConfig from '../server/react-server/getConfig';

const getMessageFormatCache = cache(() => new Map());

Expand Down
2 changes: 1 addition & 1 deletion packages/next-intl/src/react-server/useFormatter.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {useFormatter as useFormatterType} from 'use-intl';
import getFormatter from '../server/getFormatter';
import getFormatter from '../server/react-server/getFormatter';
import useHook from './useHook';
import useLocale from './useLocale';

Expand Down
2 changes: 1 addition & 1 deletion packages/next-intl/src/react-server/useLocale.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {useLocale as useLocaleType} from 'use-intl';
import {getRequestLocale} from '../server/RequestLocale';
import {getRequestLocale} from '../server/react-server/RequestLocale';

export default function useLocale(
// eslint-disable-next-line no-empty-pattern
Expand Down
2 changes: 1 addition & 1 deletion packages/next-intl/src/react-server/useMessages.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {useMessages as useMessagesType} from 'use-intl';
import getMessages from '../server/getMessages';
import getMessages from '../server/react-server/getMessages';
import useHook from './useHook';
import useLocale from './useLocale';

Expand Down
2 changes: 1 addition & 1 deletion packages/next-intl/src/react-server/useNow.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {useNow as useNowType} from 'use-intl';
import getNow from '../server/getNow';
import getNow from '../server/react-server/getNow';
import useHook from './useHook';
import useLocale from './useLocale';

Expand Down
2 changes: 1 addition & 1 deletion packages/next-intl/src/react-server/useTimeZone.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {useTimeZone as useTimeZoneType} from 'use-intl';
import getTimeZone from '../server/getTimeZone';
import getTimeZone from '../server/react-server/getTimeZone';
import useHook from './useHook';
import useLocale from './useLocale';

Expand Down
1 change: 1 addition & 0 deletions packages/next-intl/src/server.react-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './server/react-client/index';
1 change: 1 addition & 0 deletions packages/next-intl/src/server.react-server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './server/react-server/index';
1 change: 0 additions & 1 deletion packages/next-intl/src/server.tsx

This file was deleted.

25 changes: 25 additions & 0 deletions packages/next-intl/src/server/react-client/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Allows to import `next-intl/server` in non-RSC environments.
*
* This is mostly relevant for testing, since e.g. a `generateMetadata`
* export from a page might use `next-intl/server`, but the test
* only uses the default export for a page.
*/

function notSupported(message: string) {
return () => {
throw new Error(`\`${message}\` is not supported in Client Components.`);
};
}

export const getRequestConfig = notSupported('getRequestConfig');
export const getFormatter = notSupported('getFormatter');
export const getNow = notSupported('getNow');
export const getTimeZone = notSupported('getTimeZone');
export const getTranslations = notSupported('getTranslations');
export const getMessages = notSupported('getMessages');
export const getLocale = notSupported('getLocale');

export const unstable_setRequestLocale = notSupported(
'unstable_setRequestLocale'
);
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {headers} from 'next/headers';
import {cache} from 'react';
import {HEADER_LOCALE_NAME} from '../shared/constants';
import {HEADER_LOCALE_NAME} from '../../shared/constants';

const getLocaleFromHeader = cache(() => {
let locale;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {cache} from 'react';
import {initializeConfig} from 'use-intl/core';
import createRequestConfig from '../server/createRequestConfig';
import createRequestConfig from './createRequestConfig';

// Make sure `now` is consistent across the request in case none was configured
const getDefaultNow = cache(() => new Date());
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import createLocalizedPathnamesNavigationClient from '../../src/navigation/react
import createLocalizedPathnamesNavigationServer from '../../src/navigation/react-server/createLocalizedPathnamesNavigation';
import BaseLink from '../../src/navigation/shared/BaseLink';
import {Pathnames} from '../../src/navigation.react-client';
import {getRequestLocale} from '../../src/server/RequestLocale';
import {getRequestLocale} from '../../src/server/react-server/RequestLocale';

vi.mock('next/navigation');
vi.mock('next-intl/config', () => ({
Expand All @@ -32,7 +32,7 @@ vi.mock('../../src/navigation/react-server/ServerLink', () => ({
return <BaseLink locale={locale || 'en'} {...rest} />;
}
}));
vi.mock('../../src/server/RequestLocale', () => ({
vi.mock('../../src/server/react-server/RequestLocale', () => ({
getRequestLocale: vi.fn(() => 'en')
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {it, describe, vi, expect, beforeEach} from 'vitest';
import createSharedPathnamesNavigationClient from '../../src/navigation/react-client/createSharedPathnamesNavigation';
import createSharedPathnamesNavigationServer from '../../src/navigation/react-server/createSharedPathnamesNavigation';
import BaseLink from '../../src/navigation/shared/BaseLink';
import {getRequestLocale} from '../../src/server/RequestLocale';
import {getRequestLocale} from '../../src/server/react-server/RequestLocale';

vi.mock('next/navigation', () => ({
useParams: vi.fn(() => ({locale: 'en'})),
Expand All @@ -35,7 +35,7 @@ vi.mock('../../src/navigation/react-server/ServerLink', () => ({
return <BaseLink locale={locale || 'en'} {...rest} />;
}
}));
vi.mock('../../src/server/RequestLocale', () => ({
vi.mock('../../src/server/react-server/RequestLocale', () => ({
getRequestLocale: vi.fn(() => 'en')
}));

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {expect, it, vi} from 'vitest';
import NextIntlClientProvider from '../../src/react-server/NextIntlClientProvider';
import {getLocale, getNow, getTimeZone} from '../../src/server';
import {getLocale, getNow, getTimeZone} from '../../src/server.react-server';
import BaseNextIntlClientProvider from '../../src/shared/NextIntlClientProvider';

vi.mock('../../src/server', async () => ({
vi.mock('../../src/server/react-server', async () => ({
getLocale: vi.fn(async () => 'en-US'),
getNow: vi.fn(async () => new Date('2020-01-01T00:00:00.000Z')),
getTimeZone: vi.fn(async () => 'America/New_York')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import {
getFormatter,
getNow,
getTimeZone
} from '../../src/server';
import {HEADER_LOCALE_NAME} from '../../src/shared/constants';
} from '../../../src/server/react-server';
import {HEADER_LOCALE_NAME} from '../../../src/shared/constants';

vi.mock('next-intl/config', () => ({
default: async () =>
((await vi.importActual('../../src/server')) as any).getRequestConfig({
(
(await vi.importActual('../../../src/server/react-server')) as any
).getRequestConfig({
locale: 'en',
now: new Date('2020-01-01T00:00:00.000Z'),
timeZone: 'Europe/London',
Expand Down

2 comments on commit 5ca4075

@vercel
Copy link

@vercel vercel bot commented on 5ca4075 Dec 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on 5ca4075 Dec 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

next-intl-docs – ./docs

next-intl-docs.vercel.app
next-intl-docs-git-main-next-intl.vercel.app
next-intl-docs-next-intl.vercel.app

Please sign in to comment.