diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf48715b..0d3fc2fe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,36 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 3.20.0 (2024-09-24) + +### Features + +* Type-safe global `formats` ([#1346](https://github.com/amannn/next-intl/issues/1346)) ([b7aa14e](https://github.com/amannn/next-intl/commit/b7aa14e741e016aaaf40f67e9d2cd9ea194a029e)) – by @dBianchii + +## 3.19.5 (2024-09-24) + +### Bug Fixes + +* Make all keys of `Formats` type optional for easier usage ([#1367](https://github.com/amannn/next-intl/issues/1367)) ([a7cbd9b](https://github.com/amannn/next-intl/commit/a7cbd9bb1d42aacb17e7a5500b7054c8bc76799b)) – by @amannn + +## 3.19.4 (2024-09-19) + +### Bug Fixes + +* Handle malformed pathnames in middleware ([#1353](https://github.com/amannn/next-intl/issues/1353)) ([dcda9d9](https://github.com/amannn/next-intl/commit/dcda9d9c851046bd3032f6841b10831b50937ebb)), closes [#1351](https://github.com/amannn/next-intl/issues/1351) – by @amannn + +## 3.19.3 (2024-09-17) + +### Bug Fixes + +* Handle overlapping locale prefixes correctly pt. 2 ([#1344](https://github.com/amannn/next-intl/issues/1344)) ([7958659](https://github.com/amannn/next-intl/commit/7958659f858bb5df19203ec3c1a8701e029ed2c4)) – by @amannn + +## 3.19.2 (2024-09-17) + +### Bug Fixes + +* Handle overlapping custom locale prefixes correctly ([#1343](https://github.com/amannn/next-intl/issues/1343)) ([72c1731](https://github.com/amannn/next-intl/commit/72c1731892db6e7d0470cefcea2b1f22a5f37ce2)), closes [#1329](https://github.com/amannn/next-intl/issues/1329) – by @amannn + ## 3.19.1 (2024-09-05) ### Bug Fixes diff --git a/docs/app/robots.tsx b/docs/app/robots.tsx new file mode 100644 index 000000000..b16dd99ea --- /dev/null +++ b/docs/app/robots.tsx @@ -0,0 +1,19 @@ +import {MetadataRoute} from 'next'; + +export default function robots(): MetadataRoute.Robots { + if (process.env.VERCEL_ENV !== 'production') { + return { + rules: { + userAgent: '*', + disallow: '/' + } + }; + } else { + return { + rules: { + userAgent: '*', + allow: '/' + } + }; + } +} diff --git a/docs/components/CodeSnippets.tsx b/docs/components/CodeSnippets.tsx index 0e3d2c384..199a5ee14 100644 --- a/docs/components/CodeSnippets.tsx +++ b/docs/components/CodeSnippets.tsx @@ -329,32 +329,29 @@ function buildOutput() { ┌ ● / - {' '}1.4 + {' '}2.1 kB - kB {' '} - 89.7 kB + 97.1 kB ├ ● /about {' '} - 205 - B - {' '} + 2.5 kB + {' '} - 89.3 kB + 97.6 kB └ λ /[username] {' '} - 3.24 - kB - {' '} + 3.2 kB + {' '} - 91.1 kB + 98.3 kB diff --git a/docs/components/CommunityLink.tsx b/docs/components/CommunityLink.tsx index c8abe88ea..83f8d3eea 100644 --- a/docs/components/CommunityLink.tsx +++ b/docs/components/CommunityLink.tsx @@ -18,7 +18,7 @@ export default function CommunityLink({ }: Props) { return (
{title}
{formatDistance(published, now, {addSuffix: true})}
; +} +``` + +A quick local test of this component renders the expected result: + +``` +1 hour ago +``` + +So, should we push this to production? + +Hmm, wait—something feels a bit off here. Let's take a closer look together. + +## Environment differences + +Since this component neither uses any interactive features of React like `useState`, nor does it read from server-side data sources, it can be considered a [shared component](https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md#sharing-code-between-server-and-client). This means that depending on where the component is being imported from, it may render as either a Server Component or a Client Component. + +Let's consider where the component renders in either case: + +| Type | Server | Client | +| ---------------- | ------ | ------ | +| Server Component | ✅ | | +| Client Component | ✅ | ✅ | + +Now the good news is that if we render this component as a Server Component, there's only one environment to consider: _the server_. The final markup is generated there, and this is what the user will see. + +When it comes to Client Components, the situation is a bit more complex. Since the server and the client likely have a different local time when the component is rendered, we can already anticipate that this component may render inconsistently depending on the environment. + +There's some interesting nuance here as well: Since our component qualifies as a shared component, it can run in either environment. Even if the developer originally intended the component to run as a Server Component, it may silently switch to a Client Component if it gets imported into a Client Component in the future—leading to the additional rendering environment to consider. + +## Hydration mismatches + +Let's take a closer look at what happens when `BlogPostPublishedDate` renders as a Client Component. + +In this case, the value for `now` will always differ between the server and client, due to the latency between these two environments. Depending on factors like caching, the difference may be even significant. + +```tsx +// Server: "1 hour ago" +formatDistance(published, now, {addSuffix: true})} + +// Client: "8 days ago" +formatDistance(published, now, {addSuffix: true})} +``` + +React is not particularly happy when it encounters such a situation: + +> Text content does not match server-rendered HTML + +We'll likely get better error reporting for this type of error in [Next.js 15](https://nextjs.org/blog/next-15-rc#hydration-error-improvements) (yay!), but the error still won't vanish by itself—or at least not yet. Interestingly, there's a discussion about React patching the `Date` object in the future, which could potentially help to mitigate this issue. + +{format(published, 'MMM d, yyyy')}
; +} +``` + +A quick local test shows that everything seems fine, so let's push this to production. + +> Text content does not match server-rendered HTML + +Back to square one. + +What's happening here? While our local test worked fine, we're suddenly getting an error in production. + +The reason for this is: **Time zones**. + +## Handling time zones + +While we, as performance-oriented developers, try to serve our app from a location that's close to the user, we can't expect that the server and the client share the same time zone. This means that the call to `format` can lead to different results on the server and the client. + +In our case, this can lead to different dates being displayed. Even more intricate: Only at certain times of the day, where the time zone difference is significant enough between the two environments. + +A bug like this can involve quite some detective work. I've learned this first hand, having written more than one lengthy pull request description, containing fixes for such issues in apps I've worked on. + +To fix our new bug, the solution is similar to the one we've used for the `now` variable: We can create a `timeZone` variable in a Server Component and use that as the source of truth. + +```tsx filename="page.tsx" +export default function BlogPostPage() { + // ... + + // Use the time zone of the server + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + return{format(published, timeZone, 'MMM d, yyyy')}
; +} +``` + +Sticking to a single time zone for your app is definitely the easiest solution here. However, in case you'd like to format dates in the user's time zone, a reasonable approach might require having the time zone available on the server side so that it can be used in server-only code. + +As browsers don't include the time zone of the user in an HTTP request, one way to get an approximation of the user's time zone is to use geographical information from the user's IP address. In case you're running your app on Vercel, the [`x-vercel-ip-timezone`](https://vercel.com/docs/edge-network/headers#x-vercel-ip-timezone) request header can be used as a convenient way to retrieve this value. However, it's important to note that this is only an approximation, so letting the user choose their time zone explicitly might still be sensible. + +## Localized date formatting + +So far, we've assumed that our app will be used by American English users, with dates being formatted like: + +``` +Sep 25, 2024 +``` + +Our situation gets interesting again, once we consider that the date format is not universal. In Great Britain, for instance, the same date might be formatted as "19 Sept 2024", with the day and month being swapped. + +In case we want to localize our app to another language, or even support multiple languages, we now need to consider the _locale_ of the user. Simply put, a locale represents the language of the user, optionally combined with additional information like the region (e.g. `en-GB` represents English as spoken in Great Britain). + +To address this new requirement, you might already have a hunch where this is going. + +Ensuring consistent date formatting across the server and client requires that we'll create a `locale` variable in a Server Component and pass it down to relevant components. This can in turn be used by a library like `date-fns-tz` to format the date accordingly. + +```tsx +import {format} from 'date-fns-tz'; + +type Props = { + published: Date; + timeZone: string; + locale: string; +}; + +export default function BlogPostPublishedDate({ + published, + timeZone, + locale +}: Props) { + return{format(published, timeZone, 'MMM d, yyyy', {locale})}
; +} +``` + +It's important to pass the `locale` to all formatting calls now, as this can differ by environment—just like the `timeZone` and our value for `now` from earlier. + +## Can `next-intl` help? + +The main problem we've addressed in this post revolves around hydration mismatches that occur when formatting dates across the server and client in Next.js applications. To avoid these errors, we need to ensure that three key environment properties are shared across the entire app: + +1. `now`: A single, shared timestamp representing the current time +2. `timeZone`: Geographical location of the user, affecting date offsets +3. `locale`: Language and regional settings for localization + +Since you're reading this post on the `next-intl` blog, you've probably already guessed that we have an opinion on this subject. Note that this is not at all a critizism of libraries like `date-fns` & friends. On the contrary, I can only recommend these packages. + +The challenge we've discussed in this post is rather about the centralization and distribution of environment configuration across a Next.js app, involving interleaved rendering across the server and client that is required for formatting dates consistently. Even when only supporting a single language within your app, this already requires careful consideration. + +`next-intl` uses a centralized [`i18n/request.ts`](/docs/getting-started/app-router/without-i18n-routing#i18n-request) module that allows to provide request-specific environment configuration like `now`, `timeZone` and the `locale` of the user. + +```tsx filename="src/i18n/request.ts" +import {getRequestConfig} from 'next-intl/server'; + +export default getRequestConfig(async () => ({ + // (defaults to the current time) + now: new Date(), + + // (defaults to the server's time zone) + timeZone: 'Europe/Berlin', + + // (requires an explicit preference) + locale: 'en' + + // ... +})); +``` + +Note that, as the name of `getRequestConfig` implies, the configuration object can be created per request, allowing for dynamic configuration based on a given user's preferences. + +This can now be used to format dates in components—at any point of the server-client spectrum: + +```tsx +import {useFormatter} from 'next-intl'; + +type Props = { + published: Date; +}; + +export default function BlogPostPublishedDate({published}: Props) { + // ✅ Works in any environment + const format = useFormatter(); + + // "Sep 25, 2024" + format.dateTime(published); + + // "8 days ago" + format.relativeTime(published); +} +``` + +Behind the scenes, `i18n/request.ts` is consulted by all server-only code, typically Server Components, but also Server Actions or Route Handlers. In turn, a component called [`NextIntlClientProvider`](/docs/getting-started/app-router/without-i18n-routing#layout), commonly placed in the root layout of your app, inherits this configuration and makes it available to all client-side code. + +As a result, formatting functions like `format.dateTime(…)` can seamlessly access the necessary configuration in any environment. This configuration is then passed to native JavaScript APIs like [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) to achieve correct and consistent formatting. + +--- + +**Related resources** + +While the main focus of this post was on date formatting, there are a few related resources that I can recommend if you're interested in digging deeper into the topic: + +1. [API and JavaScript Date Gotcha's](https://www.solberg.is/api-dates) by [Jökull Solberg](https://x.com/jokull) +2. [The Problem with Time & Timezones](https://www.youtube.com/watch?v=-5wpm-gesOY) by [Computerphile](https://www.youtube.com/@Computerphile) +3. [`date-fns`](https://date-fns.org/) by [Sasha Koss](https://x.com/kossnocorp) diff --git a/docs/pages/blog/index.mdx b/docs/pages/blog/index.mdx index 7ad939b4b..42c6d2d6d 100644 --- a/docs/pages/blog/index.mdx +++ b/docs/pages/blog/index.mdx @@ -3,6 +3,12 @@ import CommunityLink from 'components/CommunityLink'; # next-intl blog{t('title')}
; + t('title'); } ``` To enable this validation, add a global type definition file in your project root (e.g. `global.d.ts`): -```jsx filename="global.d.ts" +```ts filename="global.d.ts" import en from './messages/en.json'; type Messages = typeof en; @@ -40,10 +46,83 @@ declare global { You can freely define the interface, but if you have your messages available locally, it can be helpful to automatically create the interface based on the messages from your default locale by importing it. -**If you're encountering problems, please double check that:** +## Formats + +[Global formats](/docs/usage/configuration#formats) that are referenced in calls like `format.dateTime` can be strictly typed to ensure you're using valid format names across your app. + +```tsx +function Component() { + const format = useFormatter(); + + // ✅ Valid format + format.number(2, 'precise'); + + // ✅ Valid format + format.list(['HTML', 'CSS', 'JavaScript'], 'enumeration'); + + // ✖️ Unknown format string + format.dateTime(new Date(), 'unknown'); + + // ✅ Valid format + format.dateTime(new Date(), 'short'); +} +``` + +To enable this validation, export the formats that you're using in your request configuration: + +```ts filename="i18n/request.ts" +import {getRequestConfig} from 'next-intl/server'; +import {Formats} from 'next-intl'; + +export const formats = { + dateTime: { + short: { + day: 'numeric', + month: 'short', + year: 'numeric' + } + }, + number: { + precise: { + maximumFractionDigits: 5 + } + }, + list: { + enumeration: { + style: 'long', + type: 'conjunction' + } + } +} satisfies Formats; + +export default getRequestConfig(async ({locale}) => { + // ... + + return { + formats + } +}); +``` + +Now, a global type definition file in the root of your project can pick up the shape of your formats and use them for declaring the `IntlFormats` interface: + +```ts filename="global.d.ts" +import {formats} from './src/i18n/request'; + +type Formats = typeof formats; + +declare global { + // Use type safe formats with `next-intl` + interface IntlFormats extends Formats {} +} +``` + +## Troubleshooting + +If you're encountering problems, please double check that: -1. Your interface is called `IntlMessages`. +1. Your interface uses the correct name. 2. You're using TypeScript version 4 or later. -3. The path of your `import` is correct. +3. You're using correct paths for all modules you're importing into your global declaration file. 4. Your type declaration file is included in `tsconfig.json`. 5. Your editor has loaded the most recent type declarations. When in doubt, you can restart. diff --git a/examples/example-app-router-playground/global.d.ts b/examples/example-app-router-playground/global.d.ts index b749518b9..15004afe0 100644 --- a/examples/example-app-router-playground/global.d.ts +++ b/examples/example-app-router-playground/global.d.ts @@ -1,8 +1,13 @@ import en from './messages/en.json'; +import {formats} from './src/i18n/request'; type Messages = typeof en; +type Formats = typeof formats; declare global { // Use type safe message keys with `next-intl` interface IntlMessages extends Messages {} + + // Use type safe formats with `next-intl` + interface IntlFormats extends Formats {} } diff --git a/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx b/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx index 19ed49fe1..9d52d239d 100644 --- a/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx @@ -1,6 +1,6 @@ 'use client'; -import {useNow, useTimeZone, useLocale} from 'next-intl'; +import {useNow, useTimeZone, useLocale, useFormatter} from 'next-intl'; import {Link, usePathname} from '@/i18n/routing'; export default function ClientContent() { @@ -18,3 +18,23 @@ export default function ClientContent() { > ); } + +export function TypeTest() { + const format = useFormatter(); + + format.dateTime(new Date(), 'medium'); + // @ts-expect-error + format.dateTime(new Date(), 'unknown'); + + format.dateTimeRange(new Date(), new Date(), 'medium'); + // @ts-expect-error + format.dateTimeRange(new Date(), new Date(), 'unknown'); + + format.number(420, 'precise'); + // @ts-expect-error + format.number(420, 'unknown'); + + format.list(['this', 'is', 'a', 'list'], 'enumeration'); + // @ts-expect-error + format.list(['this', 'is', 'a', 'list'], 'unknown'); +} diff --git a/examples/example-app-router-playground/src/components/AsyncComponent.tsx b/examples/example-app-router-playground/src/components/AsyncComponent.tsx index 5d3626df7..ec219840a 100644 --- a/examples/example-app-router-playground/src/components/AsyncComponent.tsx +++ b/examples/example-app-router-playground/src/components/AsyncComponent.tsx @@ -1,4 +1,4 @@ -import {getTranslations} from 'next-intl/server'; +import {getTranslations, getFormatter} from 'next-intl/server'; export default async function AsyncComponent() { const t = await getTranslations('AsyncComponent'); @@ -15,9 +15,27 @@ export default async function AsyncComponent() { export async function TypeTest() { const t = await getTranslations('AsyncComponent'); + const format = await getFormatter(); + // @ts-expect-error await getTranslations('Unknown'); // @ts-expect-error t('unknown'); + + format.dateTime(new Date(), 'medium'); + // @ts-expect-error + format.dateTime(new Date(), 'unknown'); + + format.dateTimeRange(new Date(), new Date(), 'medium'); + // @ts-expect-error + format.dateTimeRange(new Date(), new Date(), 'unknown'); + + format.number(420, 'precise'); + // @ts-expect-error + format.number(420, 'unknown'); + + format.list(['this', 'is', 'a', 'list'], 'enumeration'); + // @ts-expect-error + format.list(['this', 'is', 'a', 'list'], 'unknown'); } diff --git a/examples/example-app-router-playground/src/i18n/request.tsx b/examples/example-app-router-playground/src/i18n/request.tsx index dbb30daed..f2ffe6e95 100644 --- a/examples/example-app-router-playground/src/i18n/request.tsx +++ b/examples/example-app-router-playground/src/i18n/request.tsx @@ -1,9 +1,31 @@ import {headers} from 'next/headers'; import {notFound} from 'next/navigation'; +import {Formats} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import defaultMessages from '../../messages/en.json'; import {routing} from './routing'; +export const formats = { + dateTime: { + medium: { + dateStyle: 'medium', + timeStyle: 'short', + hour12: false + } + }, + number: { + precise: { + maximumFractionDigits: 5 + } + }, + list: { + enumeration: { + style: 'long', + type: 'conjunction' + } + } +} satisfies Formats; + export default getRequestConfig(async ({locale}) => { // Validate that the incoming `locale` parameter is valid if (!routing.locales.includes(locale as any)) notFound(); @@ -22,15 +44,7 @@ export default getRequestConfig(async ({locale}) => { globalString: 'Global string', highlight: (chunks) => {chunks} }, - formats: { - dateTime: { - medium: { - dateStyle: 'medium', - timeStyle: 'short', - hour12: false - } - } - }, + formats, onError(error) { if ( error.message === diff --git a/lerna.json b/lerna.json index 8f2f52354..eaf622e2e 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/@lerna-lite/cli/schemas/lerna-schema.json", - "version": "3.19.1", + "version": "3.20.0", "packages": [ "packages/*" ], diff --git a/package.json b/package.json index 6f1c665bd..ff58da030 100644 --- a/package.json +++ b/package.json @@ -26,5 +26,5 @@ "rollup": "^4.18.0", "turbo": "^2.0.4" }, - "packageManager": "pnpm@8.15.0" + "packageManager": "pnpm@9.11.0" } diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 76974f868..64c393d9f 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -27,7 +27,7 @@ const config: SizeLimitConfig = [ }, { path: 'dist/production/middleware.js', - limit: '9.595 KB' + limit: '9.625 KB' }, { path: 'dist/production/routing.js', diff --git a/packages/next-intl/CHANGELOG.md b/packages/next-intl/CHANGELOG.md index 46abdcd34..2e80dd20a 100644 --- a/packages/next-intl/CHANGELOG.md +++ b/packages/next-intl/CHANGELOG.md @@ -3,6 +3,36 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 3.20.0 (2024-09-24) + +### Features + +* Type-safe global `formats` ([#1346](https://github.com/amannn/next-intl/issues/1346)) ([b7aa14e](https://github.com/amannn/next-intl/commit/b7aa14e741e016aaaf40f67e9d2cd9ea194a029e)) – by @dBianchii + +## 3.19.5 (2024-09-24) + +### Bug Fixes + +* Make all keys of `Formats` type optional for easier usage ([#1367](https://github.com/amannn/next-intl/issues/1367)) ([a7cbd9b](https://github.com/amannn/next-intl/commit/a7cbd9bb1d42aacb17e7a5500b7054c8bc76799b)) – by @amannn + +## 3.19.4 (2024-09-19) + +### Bug Fixes + +* Handle malformed pathnames in middleware ([#1353](https://github.com/amannn/next-intl/issues/1353)) ([dcda9d9](https://github.com/amannn/next-intl/commit/dcda9d9c851046bd3032f6841b10831b50937ebb)), closes [#1351](https://github.com/amannn/next-intl/issues/1351) – by @amannn + +## 3.19.3 (2024-09-17) + +### Bug Fixes + +* Handle overlapping locale prefixes correctly pt. 2 ([#1344](https://github.com/amannn/next-intl/issues/1344)) ([7958659](https://github.com/amannn/next-intl/commit/7958659f858bb5df19203ec3c1a8701e029ed2c4)) – by @amannn + +## 3.19.2 (2024-09-17) + +### Bug Fixes + +* Handle overlapping custom locale prefixes correctly ([#1343](https://github.com/amannn/next-intl/issues/1343)) ([72c1731](https://github.com/amannn/next-intl/commit/72c1731892db6e7d0470cefcea2b1f22a5f37ce2)), closes [#1329](https://github.com/amannn/next-intl/issues/1329) – by @amannn + ## 3.19.1 (2024-09-05) ### Bug Fixes diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 8a5b1f0d1..19de2c4e1 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -1,6 +1,6 @@ { "name": "next-intl", - "version": "3.19.1", + "version": "3.20.0", "sideEffects": false, "author": "Jan Amann