From b423baa7e6fb23ea241a547f64e75f2a1d2c0ad5 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 11 Jul 2023 18:13:51 +0200 Subject: [PATCH] fix(RSC): Return `string` from `(await getTranslator()).rich` (#395) To be in sync with the `createTranslator` API --- packages/next-intl/dts.config.js | 2 +- packages/next-intl/package.json | 1 + .../src/react-server/getBaseTranslator.tsx | 128 ++++++++++++++++++ .../src/react-server/useTranslations.tsx | 11 +- .../next-intl/src/server/getTranslations.tsx | 18 +-- .../next-intl/src/server/getTranslator.tsx | 18 +-- .../test/server/getTranslator.test.tsx | 84 ++++++++++++ packages/use-intl/dts.config.js | 2 +- pnpm-lock.yaml | 22 ++- 9 files changed, 246 insertions(+), 40 deletions(-) create mode 100644 packages/next-intl/src/react-server/getBaseTranslator.tsx create mode 100644 packages/next-intl/test/server/getTranslator.test.tsx diff --git a/packages/next-intl/dts.config.js b/packages/next-intl/dts.config.js index 82f1fcb7b..0d711aa6f 100644 --- a/packages/next-intl/dts.config.js +++ b/packages/next-intl/dts.config.js @@ -1,7 +1,7 @@ /* global module */ /** - * @type {import('dts-cli').DtsConfig} + * @type {import('dts-cli').DtsOptions} */ module.exports = { rollup(config) { diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index baeb032da..d85b9a652 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -85,6 +85,7 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" }, "devDependencies": { + "@edge-runtime/vm": "^3.0.3", "@size-limit/preset-big-lib": "^8.2.6", "@testing-library/react": "^13.0.0", "@types/negotiator": "^0.6.1", diff --git a/packages/next-intl/src/react-server/getBaseTranslator.tsx b/packages/next-intl/src/react-server/getBaseTranslator.tsx new file mode 100644 index 000000000..04bdda244 --- /dev/null +++ b/packages/next-intl/src/react-server/getBaseTranslator.tsx @@ -0,0 +1,128 @@ +/* eslint-disable import/default -- False positives */ + +import {ReactElement, ReactNodeArray, cache} from 'react'; +import type Formats from 'use-intl/dist/src/core/Formats'; +import type TranslationValues from 'use-intl/dist/src/core/TranslationValues'; +import type {RichTranslationValues} from 'use-intl/dist/src/core/TranslationValues'; +import createBaseTranslator, { + getMessagesOrError +} from 'use-intl/dist/src/core/createBaseTranslator'; +import MessageKeys from 'use-intl/dist/src/core/utils/MessageKeys'; +import NamespaceKeys from 'use-intl/dist/src/core/utils/NamespaceKeys'; +import NestedKeyOf from 'use-intl/dist/src/core/utils/NestedKeyOf'; +import NestedValueOf from 'use-intl/dist/src/core/utils/NestedValueOf'; +import getConfig from '../server/getConfig'; + +let hasWarned = false; + +async function getTranslatorImpl< + NestedKey extends NamespaceKeys< + IntlMessages, + NestedKeyOf + > = never +>( + locale: + | string + | { + namespace?: NestedKey; + locale: string; + }, + namespace?: NestedKey +): // Explicitly defining the return type is necessary as TypeScript would get it wrong +Promise<{ + // Default invocation + < + TargetKey extends MessageKeys< + NestedValueOf< + {'!': IntlMessages}, + [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + >, + NestedKeyOf< + NestedValueOf< + {'!': IntlMessages}, + [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + > + > + > + >( + key: TargetKey, + values?: TranslationValues, + formats?: Partial + ): string; + + // `rich` + rich< + TargetKey extends MessageKeys< + NestedValueOf< + {'!': IntlMessages}, + [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + >, + NestedKeyOf< + NestedValueOf< + {'!': IntlMessages}, + [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + > + > + > + >( + key: TargetKey, + values?: RichTranslationValues, + formats?: Partial + ): string | ReactElement | ReactNodeArray; + + // `raw` + raw< + TargetKey extends MessageKeys< + NestedValueOf< + {'!': IntlMessages}, + [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + >, + NestedKeyOf< + NestedValueOf< + {'!': IntlMessages}, + [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + > + > + > + >( + key: TargetKey + ): any; +}> { + if (typeof locale === 'object') { + const opts = locale; + namespace = opts.namespace; + locale = opts.locale; + if (!hasWarned) { + console.warn( + ` +DEPRECATION WARNING: Calling \`getTranslator\` with an object argument is deprecated, please update your call site accordingly. + +// Previously +getTranslator({locale: 'en', namespace: 'About'}); + +// Now +getTranslator('en', 'About'); + +See also https://next-intl-docs.vercel.app/docs/environments/metadata-route-handlers +` + ); + hasWarned = true; + } + } + + const config = await getConfig(locale); + + const messagesOrError = getMessagesOrError({ + messages: config.messages as any, + namespace, + onError: config.onError + }); + + return createBaseTranslator({ + ...config, + namespace, + messagesOrError + }); +} + +export default cache(getTranslatorImpl); diff --git a/packages/next-intl/src/react-server/useTranslations.tsx b/packages/next-intl/src/react-server/useTranslations.tsx index b95893231..7349173f8 100644 --- a/packages/next-intl/src/react-server/useTranslations.tsx +++ b/packages/next-intl/src/react-server/useTranslations.tsx @@ -1,5 +1,5 @@ import type {useTranslations as useTranslationsType} from 'use-intl'; -import getTranslator from '../server/getTranslator'; +import getBaseTranslator from './getBaseTranslator'; import useHook from './useHook'; import useLocale from './useLocale'; @@ -7,9 +7,10 @@ export default function useTranslations( ...[namespace]: Parameters ): ReturnType { const locale = useLocale(); - const result = useHook('useTranslations', getTranslator(locale, namespace)); + const result = useHook( + 'useTranslations', + getBaseTranslator(locale, namespace) + ); - // The types are slightly off here and indicate that rich text formatting - // doesn't integrate with React - this is not the case. - return result as any; + return result; } diff --git a/packages/next-intl/src/server/getTranslations.tsx b/packages/next-intl/src/server/getTranslations.tsx index 776385c6a..a9403709e 100644 --- a/packages/next-intl/src/server/getTranslations.tsx +++ b/packages/next-intl/src/server/getTranslations.tsx @@ -1,11 +1,9 @@ /* eslint-disable import/default */ import {cache} from 'react'; +import {createTranslator} from 'use-intl/dist/src/core'; import type Formats from 'use-intl/dist/src/core/Formats'; import type TranslationValues from 'use-intl/dist/src/core/TranslationValues'; -import createBaseTranslator, { - getMessagesOrError -} from 'use-intl/dist/src/core/createBaseTranslator'; import {CoreRichTranslationValues} from 'use-intl/dist/src/core/createTranslatorImpl'; import MessageKeys from 'use-intl/dist/src/core/utils/MessageKeys'; import NamespaceKeys from 'use-intl/dist/src/core/utils/NamespaceKeys'; @@ -95,20 +93,10 @@ Learn more: https://next-intl-docs.vercel.app/docs/environments/metadata-route-h const locale = getLocaleFromHeader(); const config = await getConfig(locale); - const messagesOrError = getMessagesOrError({ - messages: config.messages as any, - namespace, - onError: config.onError - }); - - // We allow to resolve rich text formatting here, but the types forbid it when - // `getTranslations` is used directly. Supporting rich text is important when - // the react-server implementation calls into this function. - // @ts-ignore - return createBaseTranslator({ + return createTranslator({ ...config, namespace, - messagesOrError + messages: config.messages || {} }); } diff --git a/packages/next-intl/src/server/getTranslator.tsx b/packages/next-intl/src/server/getTranslator.tsx index c42e9a2a1..62967e3f4 100644 --- a/packages/next-intl/src/server/getTranslator.tsx +++ b/packages/next-intl/src/server/getTranslator.tsx @@ -1,11 +1,9 @@ /* eslint-disable import/default */ import {cache} from 'react'; +import {createTranslator} from 'use-intl/dist/src/core'; import type Formats from 'use-intl/dist/src/core/Formats'; import type TranslationValues from 'use-intl/dist/src/core/TranslationValues'; -import createBaseTranslator, { - getMessagesOrError -} from 'use-intl/dist/src/core/createBaseTranslator'; import {CoreRichTranslationValues} from 'use-intl/dist/src/core/createTranslatorImpl'; import MessageKeys from 'use-intl/dist/src/core/utils/MessageKeys'; import NamespaceKeys from 'use-intl/dist/src/core/utils/NamespaceKeys'; @@ -112,20 +110,10 @@ See also https://next-intl-docs.vercel.app/docs/environments/metadata-route-hand const config = await getConfig(locale); - const messagesOrError = getMessagesOrError({ - messages: config.messages as any, - namespace, - onError: config.onError - }); - - // We allow to resolve rich text formatting here, but the types forbid it when - // `getTranslations` is used directly. Supporting rich text is important when - // the react-server implementation calls into this function. - // @ts-ignore - return createBaseTranslator({ + return createTranslator({ ...config, namespace, - messagesOrError + messages: config.messages || {} }); } diff --git a/packages/next-intl/test/server/getTranslator.test.tsx b/packages/next-intl/test/server/getTranslator.test.tsx new file mode 100644 index 000000000..12b09c9f4 --- /dev/null +++ b/packages/next-intl/test/server/getTranslator.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment edge-runtime + +import {it, vi, expect, describe} from 'vitest'; +import { + getTranslator, + getMessages, + getFormatter, + getNow, + getTimeZone +} from '../../src/server'; + +vi.mock('next-intl/config', () => ({ + default: async () => + ((await vi.importActual('../../src/server')) as any).getRequestConfig({ + locale: 'en', + now: new Date('2020-01-01T00:00:00.000Z'), + timeZone: 'Europe/London', + messages: { + About: { + interpolation: 'Hello {name}', + rich: '{name}' + } + } + }) +})); + +vi.mock('react', async (importOriginal) => { + const React = (await importOriginal()) as typeof import('react'); + return { + ...React, + cache(fn: (...args: Array) => unknown) { + return (...args: Array) => fn(...args); + } + }; +}); + +describe('getTranslator', () => { + it('can interpolate variables', async () => { + const t = await getTranslator('en', 'About'); + expect(t('interpolation', {name: 'Jane'})).toBe('Hello Jane'); + }); + + it('renders rich text to a string', async () => { + const t = await getTranslator('en', 'About'); + expect( + t.rich('rich', { + name: 'Example', + link: (chunks) => `${chunks}` + }) + ).toBe('Example'); + }); + + it('renders raw text to a string', async () => { + const t = await getTranslator('en', 'About'); + expect(t.raw('rich')).toBe('{name}'); + }); +}); + +describe('getFormatter', () => { + it('can format a date', async () => { + const format = await getFormatter('en'); + expect(format.dateTime(new Date('2020-01-01T00:00:00.000Z'))).toBe( + '1/1/2020' + ); + }); +}); + +describe('getNow', () => { + it('returns the current time', async () => { + expect((await getNow('en')).toISOString()).toBe('2020-01-01T00:00:00.000Z'); + }); +}); + +describe('getMessages', () => { + it('returns the messages', async () => { + expect(await getMessages('en')).toHaveProperty('About'); + }); +}); + +describe('getTimeZone', () => { + it('returns the time zone', async () => { + expect(await getTimeZone('en')).toBe('Europe/London'); + }); +}); diff --git a/packages/use-intl/dts.config.js b/packages/use-intl/dts.config.js index 82f1fcb7b..0d711aa6f 100644 --- a/packages/use-intl/dts.config.js +++ b/packages/use-intl/dts.config.js @@ -1,7 +1,7 @@ /* global module */ /** - * @type {import('dts-cli').DtsConfig} + * @type {import('dts-cli').DtsOptions} */ module.exports = { rollup(config) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19c712ce6..a1411a48f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -511,6 +511,9 @@ importers: specifier: ^2.17.5 version: link:../use-intl devDependencies: + '@edge-runtime/vm': + specifier: ^3.0.3 + version: 3.0.3 '@size-limit/preset-big-lib': specifier: ^8.2.6 version: 8.2.6(size-limit@8.2.6) @@ -555,7 +558,7 @@ importers: version: 5.0.4 vitest: specifier: ^0.32.2 - version: 0.32.2 + version: 0.32.2(@edge-runtime/vm@3.0.3) packages/use-intl: dependencies: @@ -604,7 +607,7 @@ importers: version: 5.0.4 vitest: specifier: ^0.32.2 - version: 0.32.2 + version: 0.32.2(@edge-runtime/vm@3.0.3) packages: @@ -3594,6 +3597,18 @@ packages: '@jridgewell/trace-mapping': 0.3.9 dev: true + /@edge-runtime/primitives@3.0.3: + resolution: {integrity: sha512-YnfMWMRQABAH8IsnFMJWMW+SyB4ZeYBPnR7V0aqdnew7Pq60cbH5DyFjS/FhiLwvHQk9wBREmXD7PP0HooEQ1A==} + engines: {node: '>=14'} + dev: true + + /@edge-runtime/vm@3.0.3: + resolution: {integrity: sha512-SPfI1JeIRNs/4EEE2Oc0X6gG3RqjD1TnKu2lwmwFXq0435xgZGKhc3UiKkYAdoMn2dNFD73nlabMKHBRoMRpxg==} + engines: {node: '>=14'} + dependencies: + '@edge-runtime/primitives': 3.0.3 + dev: true + /@emotion/hash@0.9.0: resolution: {integrity: sha512-14FtKiHhy2QoPIzdTcvh//8OyBlknNs2nXRwIhG904opCby3l+9Xaf/wuPvICBF0rc1ZCNBd3nKe9cd2mecVkQ==} dev: true @@ -24493,7 +24508,7 @@ packages: fsevents: 2.3.2 dev: true - /vitest@0.32.2: + /vitest@0.32.2(@edge-runtime/vm@3.0.3): resolution: {integrity: sha512-hU8GNNuQfwuQmqTLfiKcqEhZY72Zxb7nnN07koCUNmntNxbKQnVbeIS6sqUgR3eXSlbOpit8+/gr1KpqoMgWCQ==} engines: {node: '>=v14.18.0'} hasBin: true @@ -24524,6 +24539,7 @@ packages: webdriverio: optional: true dependencies: + '@edge-runtime/vm': 3.0.3 '@types/chai': 4.3.5 '@types/chai-subset': 1.3.3 '@types/node': 17.0.23