Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: Remove default of now={new Date()} from NextIntlClientProvider for usage with format.relativeTime (preparation for dynamicIO) #1536

Merged
merged 14 commits into from
Nov 14, 2024
18 changes: 7 additions & 11 deletions docs/src/pages/docs/usage/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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 override the default, you can provide 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". 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.

<Tabs items={['i18n/request.ts', 'Provider']}>
<Tabs.Tab>
Expand All @@ -463,11 +461,7 @@ import {getRequestConfig} from 'next-intl/server';

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: new Date('2024-11-14T10:36:01.516Z')

// ...
};
Expand All @@ -478,15 +472,15 @@ export default getRequestConfig(async () => {
<Tabs.Tab>

```tsx
const now = new Date('2020-11-20T10:36:01.516Z');
const now = new Date('2024-11-14T10:36:01.516Z');

<NextIntlClientProvider now={now}>...</NextIntlClientProvider>;
```

</Tabs.Tab>
</Tabs>

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.
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]

Expand All @@ -502,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.
Expand Down
80 changes: 65 additions & 15 deletions docs/src/pages/docs/usage/dates-times.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,32 +67,82 @@ 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`
### `useNow` [#relative-times-usenow]

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.
Since providing `now` is a common pattern, `next-intl` provides a convenience hook that can be used to retrieve the current date and time:

```js
import {useFormatter} from 'next-intl';
```tsx {4}
import {useNow, useFormatter} from 'next-intl';

function Component() {
function FormattedDate({date}) {
const now = useNow();
const format = useFormatter();
const dateTime = new Date('2020-11-20T08:30:00.000Z');
const now = new Date('2020-11-20T10:36:00.000Z');

// Renders "2 hours ago"
format.relativeTime(dateTime, now);
format.relativeTime(date, now);
}
```

If you want the relative time value to update over time, you can do so with [the `useNow` hook](/docs/usage/configuration#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.

<Details id="relative-times-hydration">
<summary>How can I avoid hydration errors with `useNow`?</summary>

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 <span suppressHydrationWarning>{format.relativeTime(date, now)}</span>;
}
```

While this prop has a somewhat intimidating name, it's an escape hatch that was purposefully designed for cases like this.

</Details>

<Details id="relative-times-server">
<summary>How can I use `now` in Server Components with `dynamicIO`?</summary>

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);
}
```

</Details>

### `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#use-now):

```js
import {useNow, useFormatter} from 'next-intl';
Expand All @@ -112,9 +162,9 @@ 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` (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:

Expand Down
3 changes: 3 additions & 0 deletions examples/example-app-router-playground/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const withMdx = mdxPlugin();

export default withMdx(
withNextIntl({
eslint: {
ignoreDuringBuilds: true
},
trailingSlash: process.env.NEXT_PUBLIC_USE_CASE === 'trailing-slash',
basePath:
process.env.NEXT_PUBLIC_USE_CASE === 'base-path'
Expand Down
5 changes: 4 additions & 1 deletion examples/example-app-router-playground/src/i18n/request.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
}));

Expand All @@ -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')
}));
Expand All @@ -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();
});
Expand All @@ -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();
});
Original file line number Diff line number Diff line change
@@ -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<typeof BaseNextIntlClientProvider>;
Expand All @@ -18,7 +19,10 @@ 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 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}
/>
Expand Down
43 changes: 43 additions & 0 deletions packages/next-intl/src/react-server/useFormatter.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<TestComponent />);
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(<TestComponent />);
expect(getDefaultNow).toHaveBeenCalled();
});
});
7 changes: 2 additions & 5 deletions packages/next-intl/src/react-server/useFormatter.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import {cache} from 'react';
import type {useFormatter as useFormatterType} from 'use-intl';
import {createFormatter} from 'use-intl/core';
import getServerFormatter from '../server/react-server/getServerFormatter.tsx';
import useConfig from './useConfig.tsx';

const createFormatterCached = cache(createFormatter);

export default function useFormatter(): ReturnType<typeof useFormatterType> {
const config = useConfig('useFormatter');
return createFormatterCached(config);
return getServerFormatter(config);
}
3 changes: 2 additions & 1 deletion packages/next-intl/src/react-server/useNow.tsx
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -11,5 +12,5 @@ export default function useNow(
}

const config = useConfig('useNow');
return config.now;
return config.now ?? getDefaultNow();
}
14 changes: 14 additions & 0 deletions packages/next-intl/src/react-server/useTranslations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TestComponent />);
expect(createTranslator).toHaveBeenCalledWith(
expect.not.objectContaining({now: expect.anything()})
);
});
});

describe('performance', () => {
let attemptedRenders: Record<string, number>;
let finishedRenders: Record<string, number>;
Expand Down
4 changes: 2 additions & 2 deletions packages/next-intl/src/react-server/useTranslations.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type {useTranslations as useTranslationsType} from 'use-intl';
import getBaseTranslator from './getTranslator.tsx';
import getServerTranslator from '../server/react-server/getServerTranslator.tsx';
import useConfig from './useConfig.tsx';

export default function useTranslations(
...[namespace]: Parameters<typeof useTranslationsType>
): ReturnType<typeof useTranslationsType> {
const config = useConfig('useTranslations');
return getBaseTranslator(config, namespace);
return getServerTranslator(config, namespace);
}
Loading
Loading