From e78099d3d8e7473170c6c32701f0536b50a1cab6 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 12 Nov 2024 15:28:20 +0100 Subject: [PATCH 01/14] Lazy-init `now` when `useNow` or `format.relativeTime` is used in components --- .../src/react-server/useTranslations.tsx | 4 +-- .../src/server/react-server/getConfig.tsx | 16 +++++----- .../react-server/getTranslations.test.tsx | 29 +++++++++++++++++++ .../server/react-server/getTranslations.tsx | 8 ++--- .../react-server/getTranslator.tsx | 11 ++++++- 5 files changed, 52 insertions(+), 16 deletions(-) create mode 100644 packages/next-intl/src/server/react-server/getTranslations.test.tsx rename packages/next-intl/src/{ => server}/react-server/getTranslator.tsx (52%) diff --git a/packages/next-intl/src/react-server/useTranslations.tsx b/packages/next-intl/src/react-server/useTranslations.tsx index 3968c5902..a8442631d 100644 --- a/packages/next-intl/src/react-server/useTranslations.tsx +++ b/packages/next-intl/src/react-server/useTranslations.tsx @@ -1,10 +1,10 @@ import type {useTranslations as useTranslationsType} from 'use-intl'; -import getBaseTranslator from './getTranslator.tsx'; +import getTranslator from '../server/react-server/getTranslator.tsx'; import useConfig from './useConfig.tsx'; export default function useTranslations( ...[namespace]: Parameters ): ReturnType { const config = useConfig('useTranslations'); - return getBaseTranslator(config, namespace); + return getTranslator(config, namespace); } diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index a5c2a85bb..26ddb56d3 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -65,12 +65,7 @@ See also: https://next-intl-docs.vercel.app/docs/usage/configuration#i18n-reques ); } - return { - ...result, - locale: result.locale, - now: result.now || getDefaultNow(), - timeZone: result.timeZone || getDefaultTimeZone() - }; + return result; } const receiveRuntimeConfig = cache(receiveRuntimeConfigImpl); @@ -92,7 +87,14 @@ async function getConfigImpl(localeOverride?: Locale): Promise< ); return { ...initializeConfig(runtimeConfig), - _formatters: getFormatters(getCache()) + _formatters: getFormatters(getCache()), + timeZone: runtimeConfig.timeZone || getDefaultTimeZone(), + + // Only init when necessary to avoid triggering a `dynamicIO` error + // (i.e. when using `format.relativeTime` or `useNow`) + get now() { + return runtimeConfig.now ?? getDefaultNow(); + } }; } const getConfig = cache(getConfigImpl); diff --git a/packages/next-intl/src/server/react-server/getTranslations.test.tsx b/packages/next-intl/src/server/react-server/getTranslations.test.tsx new file mode 100644 index 000000000..5a19977c5 --- /dev/null +++ b/packages/next-intl/src/server/react-server/getTranslations.test.tsx @@ -0,0 +1,29 @@ +import {createTranslator} from 'use-intl/core'; +import {expect, it, vi} from 'vitest'; +import getTranslations from './getTranslations.tsx'; + +vi.mock('react'); +vi.mock('use-intl/core'); + +vi.mock('next-intl/config', () => ({ + default: async () => + ( + (await vi.importActual('../../../src/server/react-server')) as any + ).getRequestConfig({ + locale: 'en', + timeZone: 'Europe/London', + messages: { + title: 'Hello' + } + }) +})); + +it('should not include `now` in the translator config', async () => { + await getTranslations(); + + expect(createTranslator).toHaveBeenCalledWith( + expect.not.objectContaining({ + now: expect.anything() + }) + ); +}); diff --git a/packages/next-intl/src/server/react-server/getTranslations.tsx b/packages/next-intl/src/server/react-server/getTranslations.tsx index e3d5d8c68..9b4047bf2 100644 --- a/packages/next-intl/src/server/react-server/getTranslations.tsx +++ b/packages/next-intl/src/server/react-server/getTranslations.tsx @@ -7,6 +7,7 @@ import { createTranslator } from 'use-intl/core'; import getConfig from './getConfig.tsx'; +import getTranslator from './getTranslator.tsx'; // Maintainer note: `getTranslations` has two different call signatures. // We need to define these with function overloads, otherwise TypeScript @@ -40,12 +41,7 @@ async function getTranslations< } const config = await getConfig(locale); - - return createTranslator({ - ...config, - namespace, - messages: config.messages - }); + return getTranslator(config, namespace); } export default cache(getTranslations); diff --git a/packages/next-intl/src/react-server/getTranslator.tsx b/packages/next-intl/src/server/react-server/getTranslator.tsx similarity index 52% rename from packages/next-intl/src/react-server/getTranslator.tsx rename to packages/next-intl/src/server/react-server/getTranslator.tsx index 3b4d7f50c..9ef684cd1 100644 --- a/packages/next-intl/src/react-server/getTranslator.tsx +++ b/packages/next-intl/src/server/react-server/getTranslator.tsx @@ -13,8 +13,17 @@ function getTranslatorImpl< namespace?: NestedKey ): ReturnType> { return createTranslator({ - ...config, + locale: config.locale, + _cache: config._cache, + _formatters: config._formatters, + formats: config.formats, + getMessageFallback: config.getMessageFallback, + messages: config.messages, + onError: config.onError, + timeZone: config.timeZone, namespace + // We don't pass `now` here because a) it's not needed and b) it might + // require reading the current time, which causes an error with `dynamicIO` }); } From 7c528cf1bdbe1e2c64f96bfc84d3ca8faa4159be Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 12 Nov 2024 17:38:24 +0100 Subject: [PATCH 02/14] Don't create default value for `now` when inheriting in `NextIntlClientProvider`, adjust docs --- docs/src/pages/docs/usage/configuration.mdx | 23 +++++++++++-------- docs/src/pages/docs/usage/dates-times.mdx | 6 ++--- .../NextIntlClientProviderServer.tsx | 6 +++-- .../src/react-server/useFormatter.tsx | 11 ++++++++- .../next-intl/src/react-server/useNow.tsx | 3 ++- .../src/server/react-server/getConfig.tsx | 15 +----------- .../src/server/react-server/getConfigNow.tsx | 11 +++++++++ .../src/server/react-server/getDefaultNow.tsx | 8 +++++++ .../src/server/react-server/getFormats.tsx | 4 ++-- .../src/server/react-server/getNow.tsx | 12 +++------- .../src/server/react-server/getTranslator.tsx | 11 +-------- .../use-intl/src/core/createFormatter.tsx | 2 +- 12 files changed, 59 insertions(+), 53 deletions(-) create mode 100644 packages/next-intl/src/server/react-server/getConfigNow.tsx create mode 100644 packages/next-intl/src/server/react-server/getDefaultNow.tsx diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx index 993ad6af7..de9badc9e 100644 --- a/docs/src/pages/docs/usage/configuration.mdx +++ b/docs/src/pages/docs/usage/configuration.mdx @@ -218,7 +218,7 @@ import {getLocale} from 'next-intl/server'; const locale = await getLocale(); ``` -### `Locale` [#locale-type] +### `Locale` type [#locale-type] When passing a `locale` to another function, you can use the `Locale` type for the receiving parameter: @@ -453,7 +453,7 @@ const timeZone = await getTimeZone(); When formatting [relative dates and times](/docs/usage/dates-times#relative-times), `next-intl` will format times in relation to a reference point in time that is referred to as "now". By default, this is the time a component renders. -If you prefer to override the default, you can provide an explicit value for `now`: +If you prefer to provide a global default, you can configure an explicit value for `now`: @@ -461,24 +461,29 @@ If you prefer to override the default, you can provide an explicit value for `no ```tsx filename="i18n/request.ts" import {getRequestConfig} from 'next-intl/server'; +function now() { + 'use cache'; + + // Use this value consistently + return new Date(); +} + export default getRequestConfig(async () => { return { - // This is the default, a single date instance will be - // used by all Server Components to ensure consistency. - // Tip: This value can be mocked to a constant value - // for consistent results in end-to-end-tests. - now: new Date() + now: now() // ... }; }); ``` +If a `now` value is provided in `i18n/request.ts`, this will automatically be inherited by Client Components if you wrap them in a `NextIntlClientProvider` that is rendered by a Server Component. + ```tsx -const now = new Date('2020-11-20T10:36:01.516Z'); +const now = new Date(); ...; ``` @@ -486,8 +491,6 @@ const now = new Date('2020-11-20T10:36:01.516Z'); -Similarly to the `timeZone`, the `now` value in Client Components is automatically inherited from the server side if you wrap the relevant components in a `NextIntlClientProvider` that is rendered by a Server Component. - ### `useNow` & `getNow` [#use-now] The configured `now` value can be read in components via `useNow` or `getNow`: diff --git a/docs/src/pages/docs/usage/dates-times.mdx b/docs/src/pages/docs/usage/dates-times.mdx index a8627c853..b20409940 100644 --- a/docs/src/pages/docs/usage/dates-times.mdx +++ b/docs/src/pages/docs/usage/dates-times.mdx @@ -77,7 +77,7 @@ Note that values are rounded, so e.g. if 126 minutes have passed, "2 hours ago" ### Supplying `now` -By default, `relativeTime` will use [the global value for `now`](/docs/usage/configuration#now). If you want to use a different value, you can explicitly pass this as the second parameter. +By default, `relativeTime` will use the [global value for `now`](/docs/usage/configuration#now). If you want to use a different value, you can explicitly pass this as the second parameter. ```js import {useFormatter} from 'next-intl'; @@ -92,7 +92,7 @@ function Component() { } ``` -If you want the relative time value to update over time, you can do so with [the `useNow` hook](/docs/usage/configuration#now): +In case you want the relative time value to update over time, you can do so with [the `useNow` hook](/docs/usage/configuration#now): ```js import {useNow, useFormatter} from 'next-intl'; @@ -114,7 +114,7 @@ function Component() { ### Customizing the unit -By default, `relativeTime` will pick a unit based on the difference between the passed date and `now` (e.g. 3 seconds, 40 minutes, 4 days, etc.). +By default, `relativeTime` will pick a unit based on the difference between the passed date and `now` like "3 seconds" or "5 days". If you want to use a specific unit, you can provide options via the second argument: diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx index a17ac2e62..541375fce 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx @@ -1,6 +1,7 @@ import {ComponentProps} from 'react'; +import getConfigNow from '../server/react-server/getConfigNow.tsx'; import getFormats from '../server/react-server/getFormats.tsx'; -import {getLocale, getNow, getTimeZone} from '../server.react-server.tsx'; +import {getLocale, getTimeZone} from '../server.react-server.tsx'; import BaseNextIntlClientProvider from '../shared/NextIntlClientProvider.tsx'; type Props = ComponentProps; @@ -18,7 +19,8 @@ export default async function NextIntlClientProviderServer({ // See https://github.com/amannn/next-intl/issues/631 formats={formats === undefined ? await getFormats() : formats} locale={locale ?? (await getLocale())} - now={now ?? (await getNow())} + // Note that don't assign a default for `now` here + now={now ?? (await getConfigNow())} timeZone={timeZone ?? (await getTimeZone())} {...rest} /> diff --git a/packages/next-intl/src/react-server/useFormatter.tsx b/packages/next-intl/src/react-server/useFormatter.tsx index 48be0f27e..567d6d12d 100644 --- a/packages/next-intl/src/react-server/useFormatter.tsx +++ b/packages/next-intl/src/react-server/useFormatter.tsx @@ -1,11 +1,20 @@ import {cache} from 'react'; import type {useFormatter as useFormatterType} from 'use-intl'; import {createFormatter} from 'use-intl/core'; +import getDefaultNow from '../server/react-server/getDefaultNow.tsx'; import useConfig from './useConfig.tsx'; const createFormatterCached = cache(createFormatter); export default function useFormatter(): ReturnType { const config = useConfig('useFormatter'); - return createFormatterCached(config); + + return createFormatterCached({ + ...config, + // Only init when necessary to avoid triggering a `dynamicIO` error + // unnecessarily (`now` is only needed for `format.relativeTime`) + get now() { + return config.now ?? getDefaultNow(); + } + }); } diff --git a/packages/next-intl/src/react-server/useNow.tsx b/packages/next-intl/src/react-server/useNow.tsx index e5f210dc8..3f103db46 100644 --- a/packages/next-intl/src/react-server/useNow.tsx +++ b/packages/next-intl/src/react-server/useNow.tsx @@ -1,4 +1,5 @@ import type {useNow as useNowType} from 'use-intl'; +import getDefaultNow from '../server/react-server/getDefaultNow.tsx'; import useConfig from './useConfig.tsx'; export default function useNow( @@ -11,5 +12,5 @@ export default function useNow( } const config = useConfig('useNow'); - return config.now; + return config.now ?? getDefaultNow(); } diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index 26ddb56d3..495e5423d 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -10,12 +10,6 @@ import {getRequestLocale} from './RequestLocale.tsx'; import createRequestConfig from './createRequestConfig.tsx'; import {GetRequestConfigParams} from './getRequestConfig.tsx'; -// Make sure `now` is consistent across the request in case none was configured -function getDefaultNowImpl() { - return new Date(); -} -const getDefaultNow = cache(getDefaultNowImpl); - // This is automatically inherited by `NextIntlClientProvider` if // the component is rendered from a Server Component function getDefaultTimeZoneImpl() { @@ -75,7 +69,6 @@ const getCache = cache(_createCache); async function getConfigImpl(localeOverride?: Locale): Promise< IntlConfig & { getMessageFallback: NonNullable; - now: NonNullable; onError: NonNullable; timeZone: NonNullable; _formatters: ReturnType; @@ -88,13 +81,7 @@ async function getConfigImpl(localeOverride?: Locale): Promise< return { ...initializeConfig(runtimeConfig), _formatters: getFormatters(getCache()), - timeZone: runtimeConfig.timeZone || getDefaultTimeZone(), - - // Only init when necessary to avoid triggering a `dynamicIO` error - // (i.e. when using `format.relativeTime` or `useNow`) - get now() { - return runtimeConfig.now ?? getDefaultNow(); - } + timeZone: runtimeConfig.timeZone || getDefaultTimeZone() }; } const getConfig = cache(getConfigImpl); diff --git a/packages/next-intl/src/server/react-server/getConfigNow.tsx b/packages/next-intl/src/server/react-server/getConfigNow.tsx new file mode 100644 index 000000000..1a0750dc9 --- /dev/null +++ b/packages/next-intl/src/server/react-server/getConfigNow.tsx @@ -0,0 +1,11 @@ +import {cache} from 'react'; +import type {Locale} from 'use-intl'; +import getConfig from './getConfig.tsx'; + +async function getConfigNowImpl(locale?: Locale) { + const config = await getConfig(locale); + return config.now; +} +const getConfigNow = cache(getConfigNowImpl); + +export default getConfigNow; diff --git a/packages/next-intl/src/server/react-server/getDefaultNow.tsx b/packages/next-intl/src/server/react-server/getDefaultNow.tsx new file mode 100644 index 000000000..6d8ee5e14 --- /dev/null +++ b/packages/next-intl/src/server/react-server/getDefaultNow.tsx @@ -0,0 +1,8 @@ +import {cache} from 'react'; + +function getDefaultNowImpl() { + return new Date(); +} +const getDefaultNow = cache(getDefaultNowImpl); + +export default getDefaultNow; diff --git a/packages/next-intl/src/server/react-server/getFormats.tsx b/packages/next-intl/src/server/react-server/getFormats.tsx index d3900eeb9..4532b90ec 100644 --- a/packages/next-intl/src/server/react-server/getFormats.tsx +++ b/packages/next-intl/src/server/react-server/getFormats.tsx @@ -5,6 +5,6 @@ async function getFormatsCachedImpl() { const config = await getConfig(); return config.formats; } -const getFormatsCached = cache(getFormatsCachedImpl); +const getFormats = cache(getFormatsCachedImpl); -export default getFormatsCached; +export default getFormats; diff --git a/packages/next-intl/src/server/react-server/getNow.tsx b/packages/next-intl/src/server/react-server/getNow.tsx index ed39c17f9..8daf796e8 100644 --- a/packages/next-intl/src/server/react-server/getNow.tsx +++ b/packages/next-intl/src/server/react-server/getNow.tsx @@ -1,13 +1,7 @@ -import {cache} from 'react'; import type {Locale} from 'use-intl'; -import getConfig from './getConfig.tsx'; - -async function getNowCachedImpl(locale?: Locale) { - const config = await getConfig(locale); - return config.now; -} -const getNowCached = cache(getNowCachedImpl); +import getConfigNow from './getConfigNow.tsx'; +import getDefaultNow from './getDefaultNow.tsx'; export default async function getNow(opts?: {locale?: Locale}): Promise { - return getNowCached(opts?.locale); + return (await getConfigNow(opts?.locale)) ?? getDefaultNow(); } diff --git a/packages/next-intl/src/server/react-server/getTranslator.tsx b/packages/next-intl/src/server/react-server/getTranslator.tsx index 9ef684cd1..3b4d7f50c 100644 --- a/packages/next-intl/src/server/react-server/getTranslator.tsx +++ b/packages/next-intl/src/server/react-server/getTranslator.tsx @@ -13,17 +13,8 @@ function getTranslatorImpl< namespace?: NestedKey ): ReturnType> { return createTranslator({ - locale: config.locale, - _cache: config._cache, - _formatters: config._formatters, - formats: config.formats, - getMessageFallback: config.getMessageFallback, - messages: config.messages, - onError: config.onError, - timeZone: config.timeZone, + ...config, namespace - // We don't pass `now` here because a) it's not needed and b) it might - // require reading the current time, which causes an error with `dynamicIO` }); } diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index 02c38cf5a..794473bf6 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -219,7 +219,7 @@ export default function createFormatter({ new IntlError( IntlErrorCode.ENVIRONMENT_FALLBACK, process.env.NODE_ENV !== 'production' - ? `The \`now\` parameter wasn't provided and there is no global default configured. Consider adding a global default to avoid markup mismatches caused by environment differences. Learn more: https://next-intl-docs.vercel.app/docs/configuration#now` + ? `The \`now\` parameter wasn't provided and there is no global default configured, therefore the current time will be used as a fallback. To avoid markup mismatches caused by environment differences, either provide the \`now\` parameter or configure a global default. Learn more: https://next-intl-docs.vercel.app/docs/configuration#now` : undefined ) ); From 246e8282067eb9db821f98eea6d77a739d697ea1 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 13 Nov 2024 09:39:58 +0100 Subject: [PATCH 03/14] update test --- packages/use-intl/src/react/useFormatter.test.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/use-intl/src/react/useFormatter.test.tsx b/packages/use-intl/src/react/useFormatter.test.tsx index 88707ba4d..26b7b8a76 100644 --- a/packages/use-intl/src/react/useFormatter.test.tsx +++ b/packages/use-intl/src/react/useFormatter.test.tsx @@ -279,9 +279,7 @@ describe('dateTime', () => { ); const error: IntlError = onError.mock.calls[0][0]; - expect(error.message).toMatch( - "ENVIRONMENT_FALLBACK: The `timeZone` parameter wasn't provided and there is no global default configured." - ); + expect(error.message).toMatch(/^ENVIRONMENT_FALLBACK/); expect(error.code).toBe(IntlErrorCode.ENVIRONMENT_FALLBACK); expect(container.textContent).toBe('11/20/2020'); }); @@ -622,9 +620,7 @@ describe('relativeTime', () => { ); const error: IntlError = onError.mock.calls[0][0]; - expect(error.message).toMatch( - "ENVIRONMENT_FALLBACK: The `now` parameter wasn't provided and there is no global default configured." - ); + expect(error.message).toMatch(/^ENVIRONMENT_FALLBACK/); expect(error.code).toBe(IntlErrorCode.ENVIRONMENT_FALLBACK); }); }); From 76adb07da9e1b0bef561f650f1ea7a3249c3c475 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 13 Nov 2024 09:58:33 +0100 Subject: [PATCH 04/14] fix test --- .../NextIntlClientProviderServer.test.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx index fb40d7b1f..013d5e0fd 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx @@ -1,12 +1,12 @@ import {expect, it, vi} from 'vitest'; +import getConfigNow from '../server/react-server/getConfigNow.tsx'; import getFormats from '../server/react-server/getFormats.tsx'; -import {getLocale, getNow, getTimeZone} from '../server.react-server.tsx'; +import {getLocale, getTimeZone} from '../server.react-server.tsx'; import NextIntlClientProvider from '../shared/NextIntlClientProvider.tsx'; import NextIntlClientProviderServer from './NextIntlClientProviderServer.tsx'; 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') })); @@ -20,6 +20,10 @@ vi.mock('../../src/server/react-server/getFormats', () => ({ })) })); +vi.mock('../../src/server/react-server/getConfigNow', () => ({ + default: vi.fn(async () => new Date('2020-01-01T00:00:00.000Z')) +})); + vi.mock('../../src/shared/NextIntlClientProvider', async () => ({ default: vi.fn(() => 'NextIntlClientProvider') })); @@ -43,7 +47,7 @@ it("doesn't read from headers if all relevant configuration is passed", async () }); expect(getLocale).not.toHaveBeenCalled(); - expect(getNow).not.toHaveBeenCalled(); + expect(getConfigNow).not.toHaveBeenCalled(); expect(getTimeZone).not.toHaveBeenCalled(); expect(getFormats).not.toHaveBeenCalled(); }); @@ -69,7 +73,7 @@ it('reads missing configuration from getter functions', async () => { }); expect(getLocale).toHaveBeenCalled(); - expect(getNow).toHaveBeenCalled(); + expect(getConfigNow).toHaveBeenCalled(); expect(getTimeZone).toHaveBeenCalled(); expect(getFormats).toHaveBeenCalled(); }); From 99a31353d7a700117e7c0dcfde4213511f03fb50 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 13 Nov 2024 09:59:59 +0100 Subject: [PATCH 05/14] fix docs --- docs/src/pages/docs/usage/configuration.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx index de9badc9e..4ded13d4b 100644 --- a/docs/src/pages/docs/usage/configuration.mdx +++ b/docs/src/pages/docs/usage/configuration.mdx @@ -461,7 +461,7 @@ If you prefer to provide a global default, you can configure an explicit value f ```tsx filename="i18n/request.ts" import {getRequestConfig} from 'next-intl/server'; -function now() { +async function now() { 'use cache'; // Use this value consistently @@ -470,7 +470,7 @@ function now() { export default getRequestConfig(async () => { return { - now: now() + now: await now() // ... }; From 7e11418aad8c8c149e444834b0b3212a9f33aeaa Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 13 Nov 2024 10:03:26 +0100 Subject: [PATCH 06/14] fix comment --- .../src/react-server/NextIntlClientProviderServer.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx index 541375fce..d3da7c102 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx @@ -19,7 +19,9 @@ export default async function NextIntlClientProviderServer({ // See https://github.com/amannn/next-intl/issues/631 formats={formats === undefined ? await getFormats() : formats} locale={locale ?? (await getLocale())} - // Note that don't assign a default for `now` here + // Note that we don't assign a default for `now` here, + // we only read one from the request config - if any. + // Otherwise this would cause a `dynamicIO` error. now={now ?? (await getConfigNow())} timeZone={timeZone ?? (await getTimeZone())} {...rest} From a892a44327f899fe30dcb8f92cc6543fedffb314 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 13 Nov 2024 10:22:01 +0100 Subject: [PATCH 07/14] fix test --- examples/example-app-router-playground/src/i18n/request.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/example-app-router-playground/src/i18n/request.tsx b/examples/example-app-router-playground/src/i18n/request.tsx index db7a1b01d..faac058c3 100644 --- a/examples/example-app-router-playground/src/i18n/request.tsx +++ b/examples/example-app-router-playground/src/i18n/request.tsx @@ -45,7 +45,7 @@ export default getRequestConfig(async ({requestLocale}) => { return { locale, - now: now ? new Date(now) : undefined, + now: now ? new Date(now) : new Date(), timeZone, messages, formats, From 1e66d0717d823d184f41756399518c281156a1df Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 14 Nov 2024 08:43:25 +0100 Subject: [PATCH 08/14] Lazy read `now` only when necessary in `createFormatter` --- docs/src/pages/docs/usage/configuration.mdx | 26 +++++++++-- docs/src/pages/docs/usage/dates-times.mdx | 30 ++++++------- .../src/react-server/useFormatter.test.tsx | 43 +++++++++++++++++++ .../src/react-server/useFormatter.tsx | 16 +------ .../src/react-server/useTranslations.test.tsx | 14 ++++++ .../src/react-server/useTranslations.tsx | 4 +- .../src/server/react-server/getDefaultNow.tsx | 6 ++- .../server/react-server/getFormatter.test.tsx | 35 +++++++++++++++ .../src/server/react-server/getFormatter.tsx | 3 +- .../react-server/getServerFormatter.tsx | 21 +++++++++ ...Translator.tsx => getServerTranslator.tsx} | 4 +- .../server/react-server/getTranslations.tsx | 4 +- .../use-intl/src/core/createFormatter.tsx | 25 ++++++----- 13 files changed, 177 insertions(+), 54 deletions(-) create mode 100644 packages/next-intl/src/react-server/useFormatter.test.tsx create mode 100644 packages/next-intl/src/server/react-server/getFormatter.test.tsx create mode 100644 packages/next-intl/src/server/react-server/getServerFormatter.tsx rename packages/next-intl/src/server/react-server/{getTranslator.tsx => getServerTranslator.tsx} (83%) diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx index 4ded13d4b..313d1fa37 100644 --- a/docs/src/pages/docs/usage/configuration.mdx +++ b/docs/src/pages/docs/usage/configuration.mdx @@ -451,9 +451,7 @@ const timeZone = await getTimeZone(); ## Now value [#now] -When formatting [relative dates and times](/docs/usage/dates-times#relative-times), `next-intl` will format times in relation to a reference point in time that is referred to as "now". By default, this is the time a component renders. - -If you prefer to provide a global default, you can configure an explicit value for `now`: +When formatting [relative dates and times](/docs/usage/dates-times#relative-times), `next-intl` will format times in relation to a reference point in time that is referred to as "now". If you want to ensure that this value is consistent across components, you can configure a global `now` value: @@ -462,9 +460,11 @@ If you prefer to provide a global default, you can configure an explicit value f import {getRequestConfig} from 'next-intl/server'; async function now() { - 'use cache'; + // (if you're using `dynamicIO`) + // 'use cache'; // Use this value consistently + // when formatting relative times return new Date(); } @@ -479,6 +479,24 @@ export default getRequestConfig(async () => { If a `now` value is provided in `i18n/request.ts`, this will automatically be inherited by Client Components if you wrap them in a `NextIntlClientProvider` that is rendered by a Server Component. +
+How does the usage of `'use cache'` with `now` relate to cache expiration? + +If you're using [`dynamicIO`](https://nextjs.org/docs/canary/app/api-reference/config/next-config-js/dynamicIO), you can cache the value of `now` with the [`'use cache'`](https://nextjs.org/docs/canary/app/api-reference/directives/use-cache) directive: + +```tsx +async function now() { + 'use cache'; + const now = new Date(); +} +``` + +Since the request config from `i18n/request.ts` is shared among all Server Components that use features from `next-intl`, the cache expiration for `now` will apply to all of these, regardless of if they're using relative time formatting or not. + +If you want more granular cache control, you can consider passing the `now` value to [`format.relativeTime`](/docs/usage/dates-times#relative-times) explicitly as a second argument where relevant. + +
+
diff --git a/docs/src/pages/docs/usage/dates-times.mdx b/docs/src/pages/docs/usage/dates-times.mdx index b20409940..309a4f100 100644 --- a/docs/src/pages/docs/usage/dates-times.mdx +++ b/docs/src/pages/docs/usage/dates-times.mdx @@ -67,32 +67,30 @@ function Component() { const format = useFormatter(); const dateTime = new Date('2020-11-20T08:30:00.000Z'); - // At 2020-11-20T10:36:00.000Z, - // this will render "2 hours ago" - format.relativeTime(dateTime); + // A reference point in time + const now = new Date('2020-11-20T10:36:00.000Z'); + + // This will render "2 hours ago" + format.relativeTime(dateTime, now); } ``` Note that values are rounded, so e.g. if 126 minutes have passed, "2 hours ago" will be returned. -### Supplying `now` +### Providing `now` [#relative-times-now] -By default, `relativeTime` will use the [global value for `now`](/docs/usage/configuration#now). If you want to use a different value, you can explicitly pass this as the second parameter. +The `now` value can either be provided on a case-by-case basis or configured [globally](/docs/usage/configuration#now). -```js -import {useFormatter} from 'next-intl'; - -function Component() { - const format = useFormatter(); - const dateTime = new Date('2020-11-20T08:30:00.000Z'); - const now = new Date('2020-11-20T10:36:00.000Z'); +If you've configured a global `now` value, you can omit the corresponding parameter: - // Renders "2 hours ago" - format.relativeTime(dateTime, now); -} +```js +// Uses the global now value +format.relativeTime(dateTime); ``` -In case you want the relative time value to update over time, you can do so with [the `useNow` hook](/docs/usage/configuration#now): +### Continuously updating relative times [#relative-times-update] + +In case you want a relative time value to update over time, you can do so with [the `useNow` hook](/docs/usage/configuration#now): ```js import {useNow, useFormatter} from 'next-intl'; diff --git a/packages/next-intl/src/react-server/useFormatter.test.tsx b/packages/next-intl/src/react-server/useFormatter.test.tsx new file mode 100644 index 000000000..e6b5a3bee --- /dev/null +++ b/packages/next-intl/src/react-server/useFormatter.test.tsx @@ -0,0 +1,43 @@ +import {describe, expect, it, vi} from 'vitest'; +import getDefaultNow from '../server/react-server/getDefaultNow.tsx'; +import {renderToStream} from './testUtils.tsx'; +import useFormatter from './useFormatter.tsx'; + +vi.mock('react'); +vi.mock('../server/react-server/getDefaultNow.tsx', () => ({ + default: vi.fn(() => new Date()) +})); + +vi.mock('../../src/server/react-server/createRequestConfig', () => ({ + default: async () => ({ + locale: 'en' + }) +})); + +describe('dynamicIO', () => { + it('should not include `now` in the translator config', async () => { + function TestComponent() { + const format = useFormatter(); + format.dateTime(new Date()); + format.number(1); + format.dateTimeRange(new Date(), new Date()); + format.list(['a', 'b']); + format.relativeTime(new Date(), new Date()); + return null; + } + + await renderToStream(); + expect(getDefaultNow).not.toHaveBeenCalled(); + }); + + it('should read `now` for `relativeTime` if relying on a global `now`', async () => { + function TestComponent() { + const format = useFormatter(); + format.relativeTime(new Date()); + return null; + } + + await renderToStream(); + expect(getDefaultNow).toHaveBeenCalled(); + }); +}); diff --git a/packages/next-intl/src/react-server/useFormatter.tsx b/packages/next-intl/src/react-server/useFormatter.tsx index 567d6d12d..7e41b86b6 100644 --- a/packages/next-intl/src/react-server/useFormatter.tsx +++ b/packages/next-intl/src/react-server/useFormatter.tsx @@ -1,20 +1,8 @@ -import {cache} from 'react'; import type {useFormatter as useFormatterType} from 'use-intl'; -import {createFormatter} from 'use-intl/core'; -import getDefaultNow from '../server/react-server/getDefaultNow.tsx'; +import getServerFormatter from '../server/react-server/getServerFormatter.tsx'; import useConfig from './useConfig.tsx'; -const createFormatterCached = cache(createFormatter); - export default function useFormatter(): ReturnType { const config = useConfig('useFormatter'); - - return createFormatterCached({ - ...config, - // Only init when necessary to avoid triggering a `dynamicIO` error - // unnecessarily (`now` is only needed for `format.relativeTime`) - get now() { - return config.now ?? getDefaultNow(); - } - }); + return getServerFormatter(config); } diff --git a/packages/next-intl/src/react-server/useTranslations.test.tsx b/packages/next-intl/src/react-server/useTranslations.test.tsx index 1953d0ad5..6631d3943 100644 --- a/packages/next-intl/src/react-server/useTranslations.test.tsx +++ b/packages/next-intl/src/react-server/useTranslations.test.tsx @@ -31,6 +31,20 @@ vi.mock('use-intl/core', async (importActual) => { }; }); +describe('dynamicIO', () => { + it('should not include `now` in the translator config', async () => { + function TestComponent() { + useTranslations('A'); + return null; + } + + await renderToStream(); + expect(createTranslator).toHaveBeenCalledWith( + expect.not.objectContaining({now: expect.anything()}) + ); + }); +}); + describe('performance', () => { let attemptedRenders: Record; let finishedRenders: Record; diff --git a/packages/next-intl/src/react-server/useTranslations.tsx b/packages/next-intl/src/react-server/useTranslations.tsx index a8442631d..836b415bf 100644 --- a/packages/next-intl/src/react-server/useTranslations.tsx +++ b/packages/next-intl/src/react-server/useTranslations.tsx @@ -1,10 +1,10 @@ import type {useTranslations as useTranslationsType} from 'use-intl'; -import getTranslator from '../server/react-server/getTranslator.tsx'; +import getServerTranslator from '../server/react-server/getServerTranslator.tsx'; import useConfig from './useConfig.tsx'; export default function useTranslations( ...[namespace]: Parameters ): ReturnType { const config = useConfig('useTranslations'); - return getTranslator(config, namespace); + return getServerTranslator(config, namespace); } diff --git a/packages/next-intl/src/server/react-server/getDefaultNow.tsx b/packages/next-intl/src/server/react-server/getDefaultNow.tsx index 6d8ee5e14..fe296e494 100644 --- a/packages/next-intl/src/server/react-server/getDefaultNow.tsx +++ b/packages/next-intl/src/server/react-server/getDefaultNow.tsx @@ -1,8 +1,10 @@ import {cache} from 'react'; -function getDefaultNowImpl() { +function defaultNow() { + // See https://next-intl-docs.vercel.app/docs/usage/dates-times#relative-times return new Date(); } -const getDefaultNow = cache(getDefaultNowImpl); + +const getDefaultNow = cache(defaultNow); export default getDefaultNow; diff --git a/packages/next-intl/src/server/react-server/getFormatter.test.tsx b/packages/next-intl/src/server/react-server/getFormatter.test.tsx new file mode 100644 index 000000000..2499706da --- /dev/null +++ b/packages/next-intl/src/server/react-server/getFormatter.test.tsx @@ -0,0 +1,35 @@ +import {describe, expect, it, vi} from 'vitest'; +import getDefaultNow from './getDefaultNow.tsx'; +import getFormatter from './getFormatter.tsx'; + +vi.mock('react'); +vi.mock('./getDefaultNow.tsx', () => ({ + default: vi.fn(() => new Date()) +})); + +vi.mock('next-intl/config', () => ({ + default: async () => + ( + (await vi.importActual('../../../src/server/react-server')) as any + ).getRequestConfig({ + locale: 'en' + }) +})); + +describe('dynamicIO', () => { + it('should not read `now` unnecessarily', async () => { + const format = await getFormatter(); + format.dateTime(new Date()); + format.number(1); + format.dateTimeRange(new Date(), new Date()); + format.list(['a', 'b']); + format.relativeTime(new Date(), new Date()); + expect(getDefaultNow).not.toHaveBeenCalled(); + }); + + it('should read `now` for `relativeTime` if relying on a global `now`', async () => { + const format = await getFormatter(); + format.relativeTime(new Date()); + expect(getDefaultNow).toHaveBeenCalled(); + }); +}); diff --git a/packages/next-intl/src/server/react-server/getFormatter.tsx b/packages/next-intl/src/server/react-server/getFormatter.tsx index 9e1909076..1ba57e878 100644 --- a/packages/next-intl/src/server/react-server/getFormatter.tsx +++ b/packages/next-intl/src/server/react-server/getFormatter.tsx @@ -1,10 +1,11 @@ import {cache} from 'react'; import {type Locale, createFormatter} from 'use-intl/core'; import getConfig from './getConfig.tsx'; +import getServerFormatter from './getServerFormatter.tsx'; async function getFormatterCachedImpl(locale?: Locale) { const config = await getConfig(locale); - return createFormatter(config); + return getServerFormatter(config); } const getFormatterCached = cache(getFormatterCachedImpl); diff --git a/packages/next-intl/src/server/react-server/getServerFormatter.tsx b/packages/next-intl/src/server/react-server/getServerFormatter.tsx new file mode 100644 index 000000000..5beeb0820 --- /dev/null +++ b/packages/next-intl/src/server/react-server/getServerFormatter.tsx @@ -0,0 +1,21 @@ +import {cache} from 'react'; +import {createFormatter} from 'use-intl/core'; +import getDefaultNow from './getDefaultNow.tsx'; + +function getFormatterCachedImpl(config: Parameters[0]) { + // same here? + // also add a test + // also for getTranslations/useTranslations + // add a test with a getter maybe, don't mock + return createFormatter({ + ...config, + // Only init when necessary to avoid triggering a `dynamicIO` error + // unnecessarily (`now` is only needed for `format.relativeTime`) + get now() { + return config.now ?? getDefaultNow(); + } + }); +} +const getFormatterCached = cache(getFormatterCachedImpl); + +export default getFormatterCached; diff --git a/packages/next-intl/src/server/react-server/getTranslator.tsx b/packages/next-intl/src/server/react-server/getServerTranslator.tsx similarity index 83% rename from packages/next-intl/src/server/react-server/getTranslator.tsx rename to packages/next-intl/src/server/react-server/getServerTranslator.tsx index 3b4d7f50c..aec9d4a38 100644 --- a/packages/next-intl/src/server/react-server/getTranslator.tsx +++ b/packages/next-intl/src/server/react-server/getServerTranslator.tsx @@ -6,7 +6,7 @@ import { createTranslator } from 'use-intl/core'; -function getTranslatorImpl< +function getServerTranslatorImpl< NestedKey extends NamespaceKeys> = never >( config: Parameters[0], @@ -18,4 +18,4 @@ function getTranslatorImpl< }); } -export default cache(getTranslatorImpl); +export default cache(getServerTranslatorImpl); diff --git a/packages/next-intl/src/server/react-server/getTranslations.tsx b/packages/next-intl/src/server/react-server/getTranslations.tsx index 9b4047bf2..5615c2e46 100644 --- a/packages/next-intl/src/server/react-server/getTranslations.tsx +++ b/packages/next-intl/src/server/react-server/getTranslations.tsx @@ -7,7 +7,7 @@ import { createTranslator } from 'use-intl/core'; import getConfig from './getConfig.tsx'; -import getTranslator from './getTranslator.tsx'; +import getServerTranslator from './getServerTranslator.tsx'; // Maintainer note: `getTranslations` has two different call signatures. // We need to define these with function overloads, otherwise TypeScript @@ -41,7 +41,7 @@ async function getTranslations< } const config = await getConfig(locale); - return getTranslator(config, namespace); + return getServerTranslator(config, namespace); } export default cache(getTranslations); diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index 794473bf6..0ba242ce8 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -82,15 +82,16 @@ type Props = { _cache?: IntlCache; }; -export default function createFormatter({ - _cache: cache = createCache(), - _formatters: formatters = createIntlFormatters(cache), - formats, - locale, - now: globalNow, - onError = defaultOnError, - timeZone: globalTimeZone -}: Props) { +export default function createFormatter(props: Props) { + const { + _cache: cache = createCache(), + _formatters: formatters = createIntlFormatters(cache), + formats, + locale, + onError = defaultOnError, + timeZone: globalTimeZone + } = props; + function applyTimeZone(options?: DateTimeFormatOptions) { if (!options?.timeZone) { if (globalTimeZone) { @@ -212,8 +213,10 @@ export default function createFormatter({ } function getGlobalNow() { - if (globalNow) { - return globalNow; + // Only read when necessary to avoid triggering a `dynamicIO` error + // unnecessarily (`now` is only needed for `format.relativeTime`) + if (props.now) { + return props.now; } else { onError( new IntlError( From b4701848552c9bbe2379090424aad97c8d3309ff Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 14 Nov 2024 09:29:01 +0100 Subject: [PATCH 09/14] bump size --- packages/use-intl/.size-limit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts index c6dae5c70..4267cf64d 100644 --- a/packages/use-intl/.size-limit.ts +++ b/packages/use-intl/.size-limit.ts @@ -5,7 +5,7 @@ const config: SizeLimitConfig = [ name: "import * from 'use-intl' (production)", import: '*', path: 'dist/esm/production/index.js', - limit: '12.945 kB' + limit: '12.965 kB' }, { name: "import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from 'use-intl' (production)", From 6063dd2178e023e34c432862462fb75c677eb016 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 14 Nov 2024 10:11:23 +0100 Subject: [PATCH 10/14] fix race condition for linting example-app-router-playground --- examples/example-app-router-playground/next.config.mjs | 5 ++++- turbo.json | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/examples/example-app-router-playground/next.config.mjs b/examples/example-app-router-playground/next.config.mjs index eb3878299..0db82fe39 100644 --- a/examples/example-app-router-playground/next.config.mjs +++ b/examples/example-app-router-playground/next.config.mjs @@ -12,7 +12,10 @@ const withNextIntl = createNextIntlPlugin({ const withMdx = mdxPlugin(); export default withMdx( - withNextIntl({ + withNextIntl({, + eslint: { + ignoreDuringBuilds: true + }, trailingSlash: process.env.NEXT_PUBLIC_USE_CASE === 'trailing-slash', basePath: process.env.NEXT_PUBLIC_USE_CASE === 'base-path' diff --git a/turbo.json b/turbo.json index 49eabfe80..c1c687086 100644 --- a/turbo.json +++ b/turbo.json @@ -8,6 +8,9 @@ "lint": { "dependsOn": ["^build"] }, + "example-app-router-playground#lint": { + "dependsOn": ["example-app-router-playground#build"] + }, "test": { "dependsOn": ["build"] }, From 846fae8a4cda7c3e868bad5a4d81fd221893dfe5 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 14 Nov 2024 10:13:50 +0100 Subject: [PATCH 11/14] fix syntax error --- examples/example-app-router-playground/next.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/example-app-router-playground/next.config.mjs b/examples/example-app-router-playground/next.config.mjs index 0db82fe39..8c0332c69 100644 --- a/examples/example-app-router-playground/next.config.mjs +++ b/examples/example-app-router-playground/next.config.mjs @@ -12,7 +12,7 @@ const withNextIntl = createNextIntlPlugin({ const withMdx = mdxPlugin(); export default withMdx( - withNextIntl({, + withNextIntl({ eslint: { ignoreDuringBuilds: true }, From fdfe5f3e8d6fbf3c95f0b5f641d81c0b055cc4ba Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 14 Nov 2024 13:26:01 +0100 Subject: [PATCH 12/14] fix docs, now we've got it right --- docs/src/pages/docs/usage/configuration.mdx | 39 ++--------- docs/src/pages/docs/usage/dates-times.mdx | 70 ++++++++++++++++--- .../src/server/react-server/getDefaultNow.tsx | 2 +- .../use-intl/src/core/createFormatter.tsx | 2 +- packages/use-intl/src/react/useNow.tsx | 17 +---- 5 files changed, 71 insertions(+), 59 deletions(-) diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx index 313d1fa37..72cad6d65 100644 --- a/docs/src/pages/docs/usage/configuration.mdx +++ b/docs/src/pages/docs/usage/configuration.mdx @@ -451,7 +451,7 @@ const timeZone = await getTimeZone(); ## Now value [#now] -When formatting [relative dates and times](/docs/usage/dates-times#relative-times), `next-intl` will format times in relation to a reference point in time that is referred to as "now". If you want to ensure that this value is consistent across components, you can configure a global `now` value: +When formatting [relative dates and times](/docs/usage/dates-times#relative-times), `next-intl` will format times in relation to a reference point in time that is referred to as "now". While it can be beneficial in terms of caching to [provide this value](/docs/usage/dates-times#relative-times-usenow) where necessary, you can provide a global value for `now`, e.g. to ensure consistency when running tests. @@ -459,49 +459,20 @@ When formatting [relative dates and times](/docs/usage/dates-times#relative-time ```tsx filename="i18n/request.ts" import {getRequestConfig} from 'next-intl/server'; -async function now() { - // (if you're using `dynamicIO`) - // 'use cache'; - - // Use this value consistently - // when formatting relative times - return new Date(); -} - export default getRequestConfig(async () => { return { - now: await now() + now: new Date('2024-11-14T10:36:01.516Z') // ... }; }); ``` -If a `now` value is provided in `i18n/request.ts`, this will automatically be inherited by Client Components if you wrap them in a `NextIntlClientProvider` that is rendered by a Server Component. - -
-How does the usage of `'use cache'` with `now` relate to cache expiration? - -If you're using [`dynamicIO`](https://nextjs.org/docs/canary/app/api-reference/config/next-config-js/dynamicIO), you can cache the value of `now` with the [`'use cache'`](https://nextjs.org/docs/canary/app/api-reference/directives/use-cache) directive: - -```tsx -async function now() { - 'use cache'; - const now = new Date(); -} -``` - -Since the request config from `i18n/request.ts` is shared among all Server Components that use features from `next-intl`, the cache expiration for `now` will apply to all of these, regardless of if they're using relative time formatting or not. - -If you want more granular cache control, you can consider passing the `now` value to [`format.relativeTime`](/docs/usage/dates-times#relative-times) explicitly as a second argument where relevant. - -
-
```tsx -const now = new Date(); +const now = new Date('2024-11-14T10:36:01.516Z'); ...; ``` @@ -509,6 +480,8 @@ const now = new Date();
+If a `now` value is provided in `i18n/request.ts`, this will automatically be inherited by Client Components if you wrap them in a `NextIntlClientProvider` that is rendered by a Server Component. + ### `useNow` & `getNow` [#use-now] The configured `now` value can be read in components via `useNow` or `getNow`: @@ -523,6 +496,8 @@ import {getNow} from 'next-intl/server'; const now = await getNow(); ``` +Note that the returned value defaults to the current date and time, therefore making this hook useful when [providing `now`](/docs/usage/dates-times#relative-times-usenow) for `format.relativeTime` even when you haven't configured a global `now` value. + ## Formats To achieve consistent date, time, number and list formatting, you can define a set of global formats. diff --git a/docs/src/pages/docs/usage/dates-times.mdx b/docs/src/pages/docs/usage/dates-times.mdx index 309a4f100..8ad9ceccc 100644 --- a/docs/src/pages/docs/usage/dates-times.mdx +++ b/docs/src/pages/docs/usage/dates-times.mdx @@ -77,20 +77,72 @@ function Component() { Note that values are rounded, so e.g. if 126 minutes have passed, "2 hours ago" will be returned. -### Providing `now` [#relative-times-now] +### `useNow` [#relative-times-usenow] -The `now` value can either be provided on a case-by-case basis or configured [globally](/docs/usage/configuration#now). +Since providing `now` is a common pattern, `next-intl` provides a convenience hook that can be used to retrieve the current date and time: -If you've configured a global `now` value, you can omit the corresponding parameter: +```tsx {4} +import {useNow, useFormatter} from 'next-intl'; -```js -// Uses the global now value -format.relativeTime(dateTime); +function FormattedDate({date}) { + const now = useNow(); + const format = useFormatter(); + + format.relativeTime(date, now); +} +``` + +In contrast to simply calling `new Date()` in your component, `useNow` has some benefits: + +1. The returned value is consistent across re-renders. +2. The value can optionally be [updated continuously](#relative-times-update) based on an interval. +3. The value can optionally be initialized from a [global value](/docs/usage/configuration#now), e.g. allowing you to use a static `now` value to ensure consistency when running tests. + +
+How can I avoid hydration errors with `useNow`? + +If you're using `useNow` in a component that renders both on the server as well as the client, you can consider using [`suppressHydrationWarning`](https://react.dev/reference/react-dom/client/hydrateRoot#suppressing-unavoidable-hydration-mismatch-errors) to tell React that this particular text is expected to potentially be updated on the client side: + +```tsx {7} +import {useNow, useFormatter} from 'next-intl'; + +function FormattedDate({date}) { + const now = useNow(); + const format = useFormatter(); + + return {format.relativeTime(date, now)}; +} ``` -### Continuously updating relative times [#relative-times-update] +While this prop has a somewhat intimidating name, it's an escape hatch that was purposefully designed for cases like this. + +
+ +
+How can I use `now` in Server Components with `dynamicIO`? + +If you're using [`dynamicIO`](https://nextjs.org/docs/canary/app/api-reference/config/next-config-js/dynamicIO), Next.js may prompt you to specify a cache expiration in case you're using `useNow` in a Server Component. + +You can do so by annotating your component with the `'use cache'` directive, while converting it to an async function: + +```tsx +import {getNow, getFormatter} from 'next-intl/server'; + +async function FormattedDate({date}) { + 'use cache'; + + const now = await getNow(); + const format = await getFormatter(); + + return format.relativeTime(date, now); +} +``` + +
+ +### `updateInterval` [#relative-times-update] -In case you want a relative time value to update over time, you can do so with [the `useNow` hook](/docs/usage/configuration#now): +In case you want a relative time value to update over time, you can do so with [the `useNow` hook](/docs/usage/configuration#use-now): ```js import {useNow, useFormatter} from 'next-intl'; @@ -110,7 +162,7 @@ function Component() { } ``` -### Customizing the unit +### Customizing the unit [#relative-times-unit] By default, `relativeTime` will pick a unit based on the difference between the passed date and `now` like "3 seconds" or "5 days". diff --git a/packages/next-intl/src/server/react-server/getDefaultNow.tsx b/packages/next-intl/src/server/react-server/getDefaultNow.tsx index fe296e494..8f77baa47 100644 --- a/packages/next-intl/src/server/react-server/getDefaultNow.tsx +++ b/packages/next-intl/src/server/react-server/getDefaultNow.tsx @@ -1,7 +1,7 @@ import {cache} from 'react'; function defaultNow() { - // See https://next-intl-docs.vercel.app/docs/usage/dates-times#relative-times + // See https://next-intl-docs.vercel.app/docs/usage/dates-times#relative-times-server return new Date(); } diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index 0ba242ce8..0990a7df6 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -233,7 +233,7 @@ export default function createFormatter(props: Props) { function relativeTime( /** The date time that needs to be formatted. */ date: number | Date, - /** The reference point in time to which `date` will be formatted in relation to. */ + /** The reference point in time to which `date` will be formatted in relation to. If this value is absent, a globally configured `now` value or alternatively the current time will be used. */ nowOrOptions?: RelativeTimeFormatOptions['now'] | RelativeTimeFormatOptions ) { try { diff --git a/packages/use-intl/src/react/useNow.tsx b/packages/use-intl/src/react/useNow.tsx index 0fb30d472..21716b655 100644 --- a/packages/use-intl/src/react/useNow.tsx +++ b/packages/use-intl/src/react/useNow.tsx @@ -10,22 +10,7 @@ function getNow() { } /** - * Reading the current date via `new Date()` in components should be avoided, as - * it causes components to be impure and can lead to flaky tests. Instead, this - * hook can be used. - * - * By default, it returns the time when the component mounts. If `updateInterval` - * is specified, the value will be updated based on the interval. - * - * You can however also return a static value from this hook, if you - * configure the `now` parameter on the context provider. Note however, - * that if `updateInterval` is configured in this case, the component - * will initialize with the global value, but will afterwards update - * continuously based on the interval. - * - * For unit tests, this can be mocked to a constant value. For end-to-end - * testing, an environment parameter can be passed to the `now` parameter - * of the provider to mock this to a static value. + * @see https://next-intl-docs.vercel.app/docs/usage/dates-times#relative-times-usenow */ export default function useNow(options?: Options) { const updateInterval = options?.updateInterval; From 058036d96685c57da130ab36d71bb82d7976f88c Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 14 Nov 2024 14:40:47 +0100 Subject: [PATCH 13/14] cleanup --- examples/example-app-router-playground/src/i18n/request.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/example-app-router-playground/src/i18n/request.tsx b/examples/example-app-router-playground/src/i18n/request.tsx index faac058c3..db7a1b01d 100644 --- a/examples/example-app-router-playground/src/i18n/request.tsx +++ b/examples/example-app-router-playground/src/i18n/request.tsx @@ -45,7 +45,7 @@ export default getRequestConfig(async ({requestLocale}) => { return { locale, - now: now ? new Date(now) : new Date(), + now: now ? new Date(now) : undefined, timeZone, messages, formats, From d0c8f162848bfeb38b1f9909d18f8b49109e8a37 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 14 Nov 2024 14:44:19 +0100 Subject: [PATCH 14/14] revert --- examples/example-app-router-playground/src/i18n/request.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/example-app-router-playground/src/i18n/request.tsx b/examples/example-app-router-playground/src/i18n/request.tsx index db7a1b01d..2f329e1a9 100644 --- a/examples/example-app-router-playground/src/i18n/request.tsx +++ b/examples/example-app-router-playground/src/i18n/request.tsx @@ -45,7 +45,10 @@ export default getRequestConfig(async ({requestLocale}) => { return { locale, - now: now ? new Date(now) : undefined, + now: now + ? new Date(now) + : // Ensure a consistent value for a render + new Date(), timeZone, messages, formats,