Skip to content

Commit

Permalink
fix: Add error reporting when no timeZone is specified and downgrad…
Browse files Browse the repository at this point in the history
…e error handling for missing `now` value from throwing to reporting an error (#519)
  • Loading branch information
amannn committed Sep 22, 2023
1 parent 5c68c1e commit dc55ab2
Show file tree
Hide file tree
Showing 7 changed files with 99 additions and 32 deletions.
36 changes: 24 additions & 12 deletions docs/pages/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,10 @@ export default getRequestConfig(async ({locale}) => ({

Colocating your messages with app code is beneficial because it allows developers to make changes to messages quickly. Additionally, you can [use the shape of your local messages for type checking](/docs/workflows/typescript).

Translators can also collaborate on messages by using CI tools, such as <PartnerContentLink name="localization-management-intro" href="https://store.crowdin.com/github">Crowdin's GitHub integration</PartnerContentLink>, which allows changes to be synchronized directly into your code repository.
Translators can collaborate on messages by using CI tools, such as <PartnerContentLink name="localization-management-intro" href="https://store.crowdin.com/github">Crowdin's GitHub integration</PartnerContentLink>, which allows changes to be synchronized directly into your code repository.

<details>
<summary>How can I load messages from remote sources?</summary>

While it's recommended to colocate at least the messages for the default locale, you can also load messages from remote sources, e.g. with <PartnerContentLink name="localization-management-intro" href="https://crowdin.github.io/ota-client-js/">the Crowdin OTA JS Client</PartnerContentLink>.

Expand All @@ -136,7 +139,12 @@ const messages =
: await client.getStringsByLocale(locale);
```

Since the messages can be freely defined and loaded, you can also split your messages into multiple files and merge them later at runtime if you prefer:
</details>

<details>
<summary>How can I split my messages into multiple files?</summary>

Since the messages can be freely defined and loaded, you can split your messages into multiple files and merge them later at runtime if you prefer:

```tsx
const messages = {
Expand All @@ -145,6 +153,11 @@ const messages = {
};
```

</details>

<details>
<summary>How can I use messages from another locale as fallbacks?</summary>

If you have incomplete messages for a given locale and would like to use messages from another locale as a fallback, you can merge the two accordingly.

```tsx
Expand All @@ -155,9 +168,11 @@ const defaultMessages = (await import(`../../messages/en.json`)).default;
const messages = deepmerge(defaultMessages, userMessages);
```

</details>

## Time zone

If possible, you should configure an explicit time zone, as this affects the rendering of dates and times. By default, the available time zone of the runtime will be used: In Node.js this is the time zone that is configured for the server and in the browser, this is the local time zone of the user. As the time zone of the server and the one from the user can be different, this might be problematic when your app is both rendered on the server as well as on the client side.
If possible, you should configure an explicit time zone, as this affects the rendering of dates and times. By default, the available time zone of the runtime will be used: In Node.js this is the time zone that is configured for the server and in the browser, this is the local time zone of the user. As the time zone of the server and the one from the user can differ, this can lead to markup mismatches when your app is both rendered on the server as well as on the client side.

To ensure consistency, you can globally define a time zone:

Expand Down Expand Up @@ -197,9 +212,9 @@ export default getRequestConfig(async ({locale}) => ({

The available time zone names can be looked up in [the tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).

## Global now value
## Now value [#now]

To avoid mismatches between the server and client environment, it is recommended to configure a static global `now` value on the provider:
To avoid mismatches between the server and client environment, it is recommended to configure a global value for `now`:

<VersionTabs defaultLabel="Provider" rscLabel="i18n.ts">
<Tab>
Expand Down Expand Up @@ -237,6 +252,8 @@ export default getRequestConfig(async ({locale}) => ({

This value will be used as the default for [the `relativeTime` function](/docs/usage/dates-times#formatting-relative-time) as well as returned during the initial render of [`useNow`](/docs/usage/dates-times#usenow).

**Tip:** For consistent results in end-to-end tests, it can be helpful to mock this value to a constant value, e.g. based on an environment parameter.

<Callout type="warning">
**Important:** When you cache the rendered markup (e.g. when using [static
rendering](https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic-rendering#static-rendering-default)),
Expand All @@ -245,12 +262,7 @@ This value will be used as the default for [the `relativeTime` function](/docs/u
[`useNow`](/docs/usage/dates-times#usenow) on the client side.
</Callout>

<Callout>
For consistent results in end-to-end tests, it can be helpful to mock this
value to a constant value, e.g. based on an environment parameter.
</Callout>

## Global formats
## Formats

To achieve consistent date, time, number and list formatting, you can define a set of global formats.

Expand Down Expand Up @@ -354,7 +366,7 @@ function Component() {

## Default translation values

To achieve consistent usage of translation values and reduce redundancy, you can define a set of global default values. This configuration also can be used to apply consistent styling of commonly used rich text elements.
To achieve consistent usage of translation values and reduce redundancy, you can define a set of global default values. This configuration can also be used to apply consistent styling of commonly used rich text elements.

<VersionTabs defaultLabel="Provider" rscLabel="i18n.ts">
<Tab>
Expand Down
1 change: 1 addition & 0 deletions packages/use-intl/src/core/IntlError.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export enum IntlErrorCode {
MISSING_MESSAGE = 'MISSING_MESSAGE',
MISSING_FORMAT = 'MISSING_FORMAT',
ENVIRONMENT_FALLBACK = 'ENVIRONMENT_FALLBACK',
INSUFFICIENT_PATH = 'INSUFFICIENT_PATH',
INVALID_MESSAGE = 'INVALID_MESSAGE',
INVALID_KEY = 'INVALID_KEY',
Expand Down
34 changes: 26 additions & 8 deletions packages/use-intl/src/core/createFormatter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export default function createFormatter({
locale,
now: globalNow,
onError = defaultOnError,
timeZone
timeZone: globalTimeZone
}: Props) {
function resolveFormatOrOptions<Options>(
typeFormats: Record<string, Options> | undefined,
Expand Down Expand Up @@ -121,8 +121,19 @@ export default function createFormatter({
formatOrOptions,
formats?.dateTime,
(options) => {
if (timeZone && !options?.timeZone) {
options = {...options, timeZone};
if (!options?.timeZone) {
if (globalTimeZone) {
options = {...options, timeZone: globalTimeZone};
} else {
onError(
new IntlError(
IntlErrorCode.ENVIRONMENT_FALLBACK,
process.env.NODE_ENV !== 'production'
? `The \`timeZone\` 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#time-zone`
: undefined
)
);
}
}

return new Intl.DateTimeFormat(locale, options).format(value);
Expand Down Expand Up @@ -153,16 +164,23 @@ export default function createFormatter({
if (globalNow) {
now = globalNow;
} else {
throw new Error(
process.env.NODE_ENV !== 'production'
? `The \`now\` parameter wasn't provided and there was no global fallback configured on the provider.`
: undefined
onError(
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`
: undefined
)
);
}
}

const dateDate = date instanceof Date ? date : new Date(date);
const nowDate = now instanceof Date ? now : new Date(now);
const nowDate =
now instanceof Date
? now
: // @ts-expect-error -- `undefined` is fine for the `Date` constructor
new Date(now);

const seconds = (dateDate.getTime() - nowDate.getTime()) / 1000;
const {unit, value} = getRelativeTimeFormatConfig(seconds);
Expand Down
2 changes: 1 addition & 1 deletion packages/use-intl/test/core/createFormatter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {parseISO} from 'date-fns';
import {it, expect} from 'vitest';
import {createFormatter} from '../../src';

const formatter = createFormatter({locale: 'en'});
const formatter = createFormatter({locale: 'en', timeZone: 'Europe/Berlin'});

it('formats a date and time', () => {
expect(
Expand Down
2 changes: 1 addition & 1 deletion packages/use-intl/test/core/createIntl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {parseISO} from 'date-fns';
import {it, expect} from 'vitest';
import {createIntl} from '../../src';

const intl = createIntl({locale: 'en'});
const intl = createIntl({locale: 'en', timeZone: 'Europe/Berlin'});

it('formats a date and time', () => {
expect(
Expand Down
41 changes: 35 additions & 6 deletions packages/use-intl/test/react/useFormatter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ import {
function MockProvider(
props: Partial<ComponentProps<typeof IntlProvider>> & {children: ReactNode}
) {
return <IntlProvider locale="en" messages={{}} {...props} />;
return (
<IntlProvider
locale="en"
messages={{}}
timeZone="Europe/Berlin"
{...props}
/>
);
}

describe('dateTime', () => {
Expand Down Expand Up @@ -203,7 +210,7 @@ describe('dateTime', () => {
}

const {container} = render(
<MockProvider onError={onError}>
<MockProvider onError={onError} timeZone="Asia/Shanghai">
<Component />
</MockProvider>
);
Expand All @@ -215,6 +222,28 @@ describe('dateTime', () => {
expect(error.code).toBe(IntlErrorCode.FORMATTING_ERROR);
expect(container.textContent).toMatch(/Nov 20 2020/);
});

it('reports an error when formatting a date time and no time zone is available', () => {
const onError = vi.fn();

function Component() {
const format = useFormatter();
return <>{format.dateTime(mockDate)}</>;
}

const {container} = render(
<MockProvider onError={onError} timeZone={undefined}>
<Component />
</MockProvider>
);

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.code).toBe(IntlErrorCode.ENVIRONMENT_FALLBACK);
expect(container.textContent).toBe('11/20/2020');
});
});
});

Expand Down Expand Up @@ -438,7 +467,7 @@ describe('relativeTime', () => {
expect(container.textContent).toBe('not a number');
});

it('throws when no `now` value is available', () => {
it('reports an error when no `now` value is available', () => {
const onError = vi.fn();

function Component() {
Expand All @@ -454,10 +483,10 @@ describe('relativeTime', () => {
);

const error: IntlError = onError.mock.calls[0][0];
expect(error.message).toBe(
"FORMATTING_ERROR: The `now` parameter wasn't provided and there was no global fallback configured on the provider."
expect(error.message).toMatch(
"ENVIRONMENT_FALLBACK: The `now` parameter wasn't provided and there is no global default configured."
);
expect(error.code).toBe(IntlErrorCode.FORMATTING_ERROR);
expect(error.code).toBe(IntlErrorCode.ENVIRONMENT_FALLBACK);
});
});
});
Expand Down
15 changes: 11 additions & 4 deletions packages/use-intl/test/react/useIntl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ import {
function MockProvider(
props: Partial<ComponentProps<typeof IntlProvider>> & {children: ReactNode}
) {
return <IntlProvider locale="en" messages={{}} {...props} />;
return (
<IntlProvider
locale="en"
messages={{}}
timeZone="Europe/Berlin"
{...props}
/>
);
}

describe('formatDateTime', () => {
Expand Down Expand Up @@ -422,10 +429,10 @@ describe('formatRelativeTime', () => {
);

const error: IntlError = onError.mock.calls[0][0];
expect(error.message).toBe(
"FORMATTING_ERROR: The `now` parameter wasn't provided and there was no global fallback configured on the provider."
expect(error.message).toMatch(
"ENVIRONMENT_FALLBACK: The `now` parameter wasn't provided and there is no global default configured."
);
expect(error.code).toBe(IntlErrorCode.FORMATTING_ERROR);
expect(error.code).toBe(IntlErrorCode.ENVIRONMENT_FALLBACK);
});
});
});

2 comments on commit dc55ab2

@vercel
Copy link

@vercel vercel bot commented on dc55ab2 Sep 22, 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-example-next-13 – ./examples/example-next-13

next-intl-example-next-13-next-intl.vercel.app
next-intl-example-next-13.vercel.app
next-intl-example-next-13-git-main-next-intl.vercel.app

@vercel
Copy link

@vercel vercel bot commented on dc55ab2 Sep 22, 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.