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

Plural hydration error #528

Closed
karimshalapy opened this issue Sep 24, 2023 · 11 comments
Closed

Plural hydration error #528

karimshalapy opened this issue Sep 24, 2023 · 11 comments
Labels
bug Something isn't working unconfirmed Needs triage.

Comments

@karimshalapy
Copy link
Contributor

Description

When using the pluralization feature, all the numbers injected are formatted by default to the current locale.

The problem here is that for some reason this happens on the server only, so when it runs on the client it doesn't format the numbers which results in a hydration error for the mismatch between the Arabic digits rendered on the server, and the English digits rendered on the client.

image

Mandatory reproduction URL (CodeSandbox or GitHub repository)

https://codesandbox.io/p/sandbox/next-intl-bug-template-app-forked-hyncfy?file=%2Fsrc%2Fmiddleware.ts%3A4%2C25

Reproduction description

Steps to reproduce:

  1. Open reproduction
  2. Navigate to /ar in the URL bar
  3. See error:
    Error: Text content does not match server-rendered HTML.
    Warning: Text content did not match. Server: "١ مشكلة" Client: "1 مشكلة"
    

Expected behaviour

Server and Client match the same number format.

@karimshalapy karimshalapy added bug Something isn't working unconfirmed Needs triage. labels Sep 24, 2023
@karimshalapy
Copy link
Contributor Author

karimshalapy commented Sep 24, 2023

When I used the normal interpolation method {variable}, it did not format the numbers inputted automatically, but when using the interpolation method described in the pluralization feature {variable, plural, =1 {# Unit} other {# Units}}, the # is always a number and the library automatically formats it.

So, to solve this issue in this ticket, I found a workaround after digging into the source code that surprisingly worked.
I don't know if this will have any bad side effects or not, but if someone is facing this issue now you can try this temporary fix.

Instead of writing it like that:

{
  "key": "{variable, plural, =1 {# Unit} other {# Units}}"
}

Try this:

{
  "key": "{variable, plural, =1 {{variable} Unit} other {{variable} Units}}"
}

By doing that the library won't see any # in it and it will interpolate the {variable} just as normal, and will not format it automatically.

I don't know if this was intended or not, and if it was intended then maybe we can add that to the documentation somewhere because it was not obvious to me at first glance.
That being said, I don't think this should close the ticket as the main feature that's described in the documentation is throwing a hydration error and I think it's worth investigating why the translation API formats stuff differently on the client and the server.

I also would like to note that it would be perfect if there was a way to prevent the default formatting done by the library to the interpolated numbers by default. I know we have control on how the library formats the numbers, but we don't have control on whether it should be formatted or not.

@amannn
Copy link
Owner

amannn commented Sep 25, 2023

This looks like an environment inconsistency to me.

Chrome:
Screenshot 2023-09-25 at 09 26 06

Node.js 18.17:
Screenshot 2023-09-25 at 09 29 33

Firefox:
Screenshot 2023-09-25 at 09 26 23

It seems like there are more specific locales for ar that also affect the number system: formatjs/formatjs#1093 (comment)

I'm personally not that familiar with this topic, but generally the bug seems to occur due to different treatment of the ar locale in Chrome and Node.js.

Maybe @A7med3bdulBaset is familiar with this?

See also: formatjs/formatjs#4004

@karimshalapy
Copy link
Contributor Author

As it turns out, FormatJS actually does what I did in the previous comment in the docs if formatting was not wanted and the # is interpreted as {variable, number}, so adding {variable} removes the formatting of the number.

You have {itemCount, plural,
   =0 {no items}
   one {1 item}
   other {{itemCount} items}
}

In the output of the match, the # special token can be used as a placeholder for the numeric value and will be formatted as if it were {key, number}.

You have {itemCount, plural,
   =0 {no items}
   one {# item}
   other {# items}
}.

I had my doubts that it's being formatted differently on different machines because I faced this issue before in a different situation but it was mixed up!

Then this issue can be because of one of two reasons:

So, when a locale is presented to the Intl API it matches it to the locale list using different algorithms and the locale string itself that was passed only has the language and no script subtag or region (basically it has none of the optional parameters), so the localeMatcher algorithm results in different locales or the fallback defaults for the different languages are different, in the different hosting environments hence the current issue.

Chrome:
image

FireFox:
image

So, if I need proper formatting that matches on different machines I'll need to laser focus my locale argument passed, add something like the region for example like ar-EG to eliminate this discrepancy.
The question here is, Can I do that without changing the language and the messages JSON files to all be named ar-EG?
How can I handle different locales in next-intl, or is it possible somehow to pass a custom locale to FormatJS instead of the language that's passed by default via next-intl?

@amannn
Copy link
Owner

amannn commented Sep 25, 2023

Based on the screenshot, it seems like the numberingSystem is different. Maybe you could set up a global number format and use that for consistent formatting?

This works in Chrome too:

new Intl.NumberFormat('ar', {numberingSystem: 'arab'}).format(123) // '١٢٣'

@karimshalapy
Copy link
Contributor Author

Yeah, I guess we'll have to override the default Intl config then to do this.
This should work, but I still think it's a workaround.

Does the library have a localization solution??
So, if I have ar-EG and ar-AE locales, will I have to make ar-AE.json and ar-EG.json message files and handle both separately with all the duplication?

If there's a solution for that it might be a better approach because the library itself will be passing the correct locale to FormatJS instead of having to override the locale defaults because it's not only about the numberingSystem, it's also what's being used as a thousand separator . or ,, also, the time formatting has too many variables.

So, Does this feature exist? Should I close this ticket and open a Feature request regarding the localization?

@amannn
Copy link
Owner

amannn commented Sep 25, 2023

I understand!

So, if I have ar-EG and ar-AE locales, will I have to make ar-AE.json and ar-EG.json message files and handle both separately with all the duplication?

You can absolutely load the same messages for different locales! (see also the messages config section)

@karimshalapy
Copy link
Contributor Author

Yes, I think this should resolve it 👍🏼

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.

import deepmerge from 'deepmerge';

const userMessages = (await import(`../../messages/${locale}.json`)).default;
const defaultMessages = (await import(`../../messages/en.json`)).default;
const messages = deepmerge(defaultMessages, userMessages);

I can make here different locales and then next-intl will pass ar-EG to FormatJS and it should work just fine.
This will need to be added to the documentation as well, I'll maybe add it as a warning while writing the documentation later today.

@AhmedBaset
Copy link
Contributor

I encountered this as well. there are several conflicts between different browsers and node.js behavior in setting the fallback of the numbering system in Arabic.

This issue is that the server-rendered html follows the server runtime behavior of either nodejs or edge. e.g. Node.js will output "١ مشكلة" but when React hydrates the same code on the client, The Intl API will follow the browser's standard and results "1 مشكلة".

As a solution, You can set the global formatting config in both i18n.ts and <NextIntlClientProvider /> to numberingSystem: locale === "ar" ? "arab" : "latn", in dateTime, number etc...

import { type IntlConfig } from "next-intl";
import { getRequestConfig } from "next-intl/server";

export default getRequestConfig(async ({ locale }) => ({
  messages: (await import(`./translations/${locale}.json`)).default,
  timeZone: "Asia/Riyadh",
  formats: getGlobalFormatOptions(locale),
}));

export function getGlobalFormatOptions(
  locale: Locale
): IntlConfig["formats"] {
  return {
    dateTime: {
      date: {
        // ...
        numberingSystem: locale === "ar" ? "arab" : "latn",
      }
    },
    number: {
      currency: {
        // ...
        numberingSystem: locale === "ar" ? "arab" : "latn",
      },
    },
  };
}

@karimshalapy
Copy link
Contributor Author

karimshalapy commented Sep 25, 2023

@A7med3bdulBaset Won't passing ar-EG instead of ar fix the issue?
I'll need to handle different locales in my project anyway.

Because as mentioned above, it's not only about numberingSystem there's also localization for dates and times.

@karimshalapy
Copy link
Contributor Author

I can confirm passing the region along with the language in my locale string like "ar-EG" did actually work and everything is properly formatted in the different clients and the server.

@amannn
Copy link
Owner

amannn commented Jun 4, 2024

As a side note, custom prefixes are coming to next-intl: #1086.

This might be useful in the case discussed in this thread since you can append a numbering system to your locale without having to show it to the user in the URL.

Example:

import {LocalePrefix} from 'next-intl/routing';
 
// Explicitly use the arab numbering system
export const locales = ['ar-u-nu-arab', /* ... */] as const;
 
export const localePrefix = {
  mode: 'always',
  prefixes: {
    'ar-u-nu-arab': '/ar',
    // ...
  }
} satisfies LocalePrefix<typeof locales>;

See also:

new Intl.Locale('ar', {numberingSystem: 'arab'}).toString() // "ar-u-nu-arab"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working unconfirmed Needs triage.
Projects
None yet
Development

No branches or pull requests

3 participants