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

Enable passing messages to 'useTranslations' without context #507

Closed
alexharri opened this issue Sep 14, 2023 · 9 comments
Closed

Enable passing messages to 'useTranslations' without context #507

alexharri opened this issue Sep 14, 2023 · 9 comments
Labels
enhancement New feature or request unconfirmed Needs triage.

Comments

@alexharri
Copy link

alexharri commented Sep 14, 2023

Is your feature request related to a problem? Please describe.

Context

I'm currently developing a library that makes integrating i18n libraries into Next.js apps easier. It's not intended to replace libraries like i18next and next-intl. Instead it supplements them by, for example:

  • Providing functionality to easily load the required messages for a page. Including only the required messages serves to reduce the amount of data sent to the client.
  • Make it easy to use major i18n libraries across the Pages Router and App Router.
  • Provide an in-browser translation editor during local development.

These features are out-of-scope for next-intl itself, but I believe they could be useful for users of next-intl.

Problem

useTranslations currently reads the messages from useIntlContext, which is using React contexts.

function useTranslations(namespace) {
  const context = useIntlContext();
  const messages = context.messages;

  return useTranslationsImpl(
    { '!': messages },
    namespace ? `!.${namespace}` : '!',
    '!'
  );
}

This works great in Client Components, but useTranslations cannot be used in Server Components because of its usage of React contexts.

(useTranslationsImpl also uses React context to read various configuration information such as locale, timezone, etc)

To avoid React contexts, I took a look at using createTranslator:

const t = createTranslator({
  namespace,
  messages,
  // ...
});

This works great when just using t(), but t.rich() does not allow the use of React nodes:

t.rich("message", {
  bold: (chunks) => <strong>{chunks}</strong>,
})
//=> IntlError: FORMATTING_ERROR: `createTranslator` only accepts
//              functions for rich text formatting that receive
//              and return strings.

The next-intl documentation does document these limitations on this page, including the workaround (which is using Client Components). It mentions that these limitations will apply until createServerContext is.

Describe the solution you'd like

I would like to be able to circumvent React contexts by passing the required data to useTranslations manually.

const t = useTranslations({
  namespace,
  messages,
  // ...
})

This would enable the use of useTranslations in Server Components.

As an example, react-i18next supports this by exposing react-i18next/TransWithoutContext:

import { Trans } from "react-i18next/TransWithoutContext";

next-intl could do a similar thing:

import { useTranslations } from "next-intl/useTranslationsWithoutContext";

// OR

import { useTranslationsWithoutContext } from "next-intl";

Alternatively, createTranslator could be updated to not require the return value to be a string (an option could be added to enable that, or a different function e.g. createReactTranslator could be added).

I would be more than willing to implement one of these and create a PR.

Describe alternatives you've considered

It's possible to work around this issue by using Client Components, as documented.

@alexharri alexharri added enhancement New feature or request unconfirmed Needs triage. labels Sep 14, 2023
@amannn
Copy link
Owner

amannn commented Sep 15, 2023

Hey, thank you so much for this interesting question! I'm really curious, is there any public information available about the project you're working on?

Optionally passing messages to useTranslations is likely not a major issue, but I'd be curious to understand the situation better before introducing this change.

  • Providing functionality to easily load the required messages for a page. Including only the required messages serves to reduce the amount of data sent to the client.

That's really interesting! I'm currently collecting thoughts in regard to this in #1, but haven't started working on it yet. Can you provide details how this would work with your project?

  • Make it easy to use major i18n libraries across the Pages Router and App Router.

Is there anything in next-intl that makes this harder than necessary? We already support both environments, also in a mixed mode.

  • Provide an in-browser translation editor during local development.

I'd also be curious to see where this is going!

In the localization management docs we currently mention Crowdin In-Context, which seems to be similar to what you're aiming for.


Would be really cool to hear a bit more about your project. If there are no downsides, I'm more than happy to support it!

@alexharri
Copy link
Author

Hey @amannn, here is information about the project:

The project is still in early development, but this should give you a rough idea about what the project will look like.

In regards to #1, my current intention is for translations to be split up into namespaces. Using the Pages Router, each page specifies the namespaces it requires:

Page.getInitialProps = async (ctx) => {
  // Only include the messages for the 'foo' and 'bar' namespaces
  const strings = await getStrings(ctx, [ "foo", "bar" ]);
  return { strings };
}

Consuming these namespace-specific messages in next-intl looks like so:

import { useTranslations } from "next-intl";

const t = useTranslations("foo");
t("key");

// OR

import { useTranslations } from "next-intl";

const t = useTranslations();
t("foo.key");

While in the App Router:

  • Client Components are wrapped in a ClientComponentProvider, a server component which loads the translations.
  • Server Components use async hooks (specific to i18n library) which loads translations and provisions functions and components from the i18n library.
// Wrapping a Client Component
import { ClientComponentProvider } from "@streave/next/next-intl/server";

<ClientComponentProvider locale={locale} namespaces={["foo"]}>
  <ClientComponent />
</ClientComponentProvider>
// Using async hooks in Server Components
import { useTranslations } from "@streave/next/next-intl/server";

const t = await useTranslations("server-component", { locale });

Make it easy to use major i18n libraries across the Pages Router and App Router.

Is there anything in next-intl that makes this harder than necessary?

Aside from this specific issue (#507), no, I've found next-intl very easy to work with.

@amannn
Copy link
Owner

amannn commented Sep 15, 2023

Oh wow, cool project!

So in regard to #1, you'd still configure all the namespaces manually, right? Are you interested in potentially providing an automatic way for this or are you fine with the manual approach?

Generally it seems like there's quite some overlap between the RSC support for next-intl that is currently in beta and what you're building with Streave.

We've bikeshedded over APIs like await useTranslations("Component", { locale }); quite a lot in the RSC branch. I've currently landed on an environment-agnostic API useTranslations('Component') that is non-async also for the RSC implementation. The reason is that there's this concept of "shared components", which can either run in RSC or RCC. By having a unified API, you import these components either into an RSC or RCC dependency graph.

I have to say though that this is also a source of frustration, because due to the implementation, useTranslations doesn't work in async components (see #276). I hope though that this pays out in the long run since there's a workaround and potentially React could add support for use in async components. Time will tell! 🙂

What is currently only briefly mentioned in the Streave docs is the loading translations part. next-intl is quite agnostic here and we just expect to receive the messages either in i18n.ts for RSC support or through the messages prop of the provider for RCC support. I hope to get the RSC support out of beta in the near future though, so there will probably be a single getting started guide for the App Router based on RSC.

Do you have public plans for the loading translations part in Streave?

I'm digging a bit more into these topics because I'm trying to figure out where next-intl and Streave overlap and where they complement each other.

@alexharri
Copy link
Author

alexharri commented Sep 15, 2023

So in regard to #1, you'd still configure all the namespaces manually, right?

Do you have public plans for the loading translations part in Streave?

This is not publicly documented yet, but the getStrings function does a lot more work than the name suggests 😄. When using a cloud provider, the getStrings function resolves information about the namespaces (e.g. when it was last fetched) and takes care of keeping your content up to date.

The withStreave wrapper for next.config.js takes care of preloading the namespaces + translations at build-time. During production getStrings reads the pre-loaded translations, and revalidates them in the background (stale-while-revalidate strategy), though whether revalidation can be used depends on the deployment configuration.

When using the file system (i.e. tracking translations in Git), this syncing/mapping work is not applicable.

The async next-intl hook in @streave/next/next-intl/server is currently implemented like so:

import { createTranslator } from "next-intl";

export async function useTranslations(namespace: string, options: { locale: string }) {
  const { locale } = options;
  const strings = await getStrings({ locale }, [namespace]);

  const translator = createTranslator({
    locale,
    namespace,
    messages: stringsToNextIntlMessages([strings]),
  });

  return translator;
}

Ideally, I would like the implementation to become:

// could also be imported, e.g. from 'next-intl/withoutContext' to avoid
// adding options to the main 'useTranslations' hook
import { useTranslations as useNextIntlTranslations } from "next-intl";

export async function useTranslations(namespace: string, options: { locale: string }) {
  const { locale } = options;
  const strings = await getStrings({ locale }, [namespace]);

  const messages = stringsToNextIntlMessages([strings]);
  return useNextIntlTranslations(namespace, { locale, messages })
}

(The exact interface is not terribly important for this purpose. The ability to pass messages directly to useTranslations is the core concern)

This hook is being used in the /server-components page in the next-intl example: https://next-intl.demo.streave.co. (It's only being used for the title because t.rich cannot yet be used for the body, which is what this issue is about 😄).

@amannn
Copy link
Owner

amannn commented Sep 15, 2023

That prefetching and revalidation with getStrings sounds pretty cool!

What I currently don't understand is why useTranslations needs to be wrapped for RSC, but not for Client Components?

You should be able to use getStrings inside of getRequestConfig in i18n.ts (like in getServerSideProps). That's the place where next-intl can be configured for using other cloud translation providers too.

If you do that, users of next-intl could adopt Streave with a change in one file (i18n.ts). Otherwise you have to migrate individual components, making adoption harder.

The async next-intl hook in @streave/next/next-intl/server

My concern with this is that we're going for a non-async hook in next-intl for RSC support, therefore these APIs can't be used interchangeably but require a migration.

Does that make sense? Would be curious to hear your perspective!

@alexharri
Copy link
Author

alexharri commented Sep 15, 2023

You should be able to use getStrings inside of getRequestConfig in i18n.ts (like in getServerSideProps). That's the place where next-intl can be configured for using other cloud translation providers too.

I explored that, but it conflicts with how I want the revalidation behavior and DX of @streave/next to work. getStrings is designed to fetch and revalidates namespaces on-demand.

For example, if I load a page which calls getStrings(ctx, ["about"]), only the about namespace is fetched/revalidated. Given a large project with dozens or hundreds of namespaces, fetching every namespace could take a long time, and also makes it likelier for users to encounter API rate limits imposed by cloud providers.

The experience I want is something like:

  • When first pulling a project, no namespaces have been fetched
  • The user starts the dev server and loads /about
    • The about namespace has not been fetched before, so getStrings performs a blocking load.
  • Later, the user reloads the /about page
    • The about namespace has been fetched so getStrings resolves with the preloaded response, and revalidates about if >N seconds have passed (where N is configurable)
  • When building, every namespace is preloaded, which ensures that getStrings resolves immediately

Because getRequestConfig in i18n.ts requires that all messages be loaded (we don't know which namespaces will be used), it conflicts with the on-demand namespace loading in @streave/next.


This is the core reason behind why @streave/next currently wraps useTranslations in RSC. It's an abstraction for calling getStrings, and passing the result to createTranslator/useTranslation.

Ideally, I would like to avoid wrapping next-intl. To avoid the wrapping, I could create a utility function that invokes getStrings and maps the result to messages:

import { useTranslations } from "next-intl"
import { getMessages } from "@streave/next/next-intl/server"

const messages = await getMessages("namespace", { locale });
const t = useTranslations("namespace", { messages, locale });

The main problem here is the repetition. To avoid it, we could explore adding an useAsyncTranslations hook which accepts getMessages:

import { useAsyncTranslations } from "next-intl"
import { getMessages } from "@streave/next/next-intl/server"

const t = useAsyncTranslations("namespace", { locale, getMessages });

The useAsyncTranslations hook would then be implemented somewhat like so:

async function useAsyncTranslations(...) {
  const messages = await getMessages({ namespace, locale });
  return useTranslations(namespace, { locale, messages });
}

Which provides getMessages with information on the namespaces and locale being requested.

Doing this would avoid wrapping next-intl, and would also support the on-demand namespace loading which enables the DX I'm looking to create in @streave/next.

Does something along these lines make sense? I would love to hear your thoughts

@amannn
Copy link
Owner

amannn commented Sep 20, 2023

Hey, sorry for the late response!

I'm unfortunately quite busy this week and don't have so much time for digging deeper into this.

In regard to your initial question: The RSC beta versions that are being published for next-intl include a new createBaseTranslator API that is exported from use-intl and re-exported from next-intl.

It's intended as an internal API but I guess you could use it for your use case since it has the capability of returning a ReactNode from t.rich.

I hope this helps for the time being, would be happy to continue this conversation when I have more time!

@alexharri
Copy link
Author

Exposing createBaseTranslator resolves this issue so I'm closing it

@amannn
Copy link
Owner

amannn commented Oct 19, 2023

A quick heads up: In next-intl@3.0.0-rc.6 the createBaseTranslator API was removed. Instead, you can now just use createTranslator which has the same handling for t.rich as the t function that's received from useTranslations.

Some background: #406 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request unconfirmed Needs triage.
Projects
None yet
Development

No branches or pull requests

3 participants
@amannn @alexharri and others