sveltekit-i18n/lib

Use setContext instead of global store

Opened this issue · 10 comments

Unfortunately I see a fundamental design flaw in the way sveltekit-i18n implements the localisation store: If I see it correctly it uses a global store to manage localisation state. On most SSR environments this means localisation state is unintentionally shared between users.

For most smaller applications this doesn't have any visible effect because localisation state is set at the beginning of the request and the request is completed before another user's request is received.

But in high traffic environments and / or slow rendering pages the following could happen:

  • User A has a locale en. State is set, the page starts rendering with locale en.
  • While user A's request is still being rendered on the server, User B makes a request with a different locale, say cn. This modifies the global locale state.
  • User A's request will now finish rendering with locale cn!

The Sveltekit docs explicitly state to avoid global stores for exactly this reason:

https://kit.svelte.dev/docs/state-management

The better way to solve this would be to utilise setContext on the root layout to get a contextualised store. I've tried to do this in my app like this:

// /src/routes/+layout.js
export function load({ url }) {
  return {
    locale: cookies.get('locale') || 'en',
    path: url.pathname,
  };
}
<!-- /src/routes/+layout.svelte -->
<script>
  import { setContext } from 'svelte';
  import I18n from 'sveltekit-i18n';
  import { i18nConfig } from '$lib/i18n';

  export let data;

  const i18n = new I18n(i18nConfig);

  $: i18n.loadTranslations(data.locale, data.path);

  setContext('i18n', i18n.t);
</script>

<slot/>
<!-- /src/routes/path/to/any/+page.svelte -->
<script>
  import { getContext } from 'svelte';

  const t = getContext('i18n');
</script>

<h1>
    {$t('home.title')}
</h1>

This is working fine client-side. But server side no translation happens at all, due to the fact that the line $: i18n.loadTranslations(data.locale, data.path); returns a promise that isn't resolved before Sveltekit returns the rendered page. awaiting that promise before mounting <slot/> just makes SSR return no DOM at all for that slot.

The idiomatic way to await something would be inside the load function instead. But inside the load function I can't access context. I can't load the translations inside the load function and pass the I18n object as part of data either, because only plain objects are allowed.

Any Idea on how to get out of this pickle? The easiest way would be to make loadTranslations blocking.

I can't load the translations inside the load function and pass the I18n object as part of data either, because only plain objects are allowed.

No. load can return any kind of value. The only load functions that are limited on the type of values it can return are the ones defined in a +layout.server.js file, not the case here.

This worked very well for me:

src/routes/+layout.server.ts:

export function load({ request }) {
  return {
    languages:
      request.headers
        .get("accept-language")
        ?.split(",")
        ?.map(lang => lang.split(";")[0].trim()) ?? [],
  };
}

src/routes/+layout.ts:

import { browser } from "$app/environment";
import { loadTranslations } from "$lib/utils/i18n";

export async function load({ url, data }) {
  const languages = browser ? navigator.languages : data.languages;

  return {
    t: await loadTranslations(languages, url.pathname),
  };
}

src/routes/+layout.svelte:

<script lang="ts">
  import { setContext } from "svelte";

  export let data;

  setContext("t", data.t);
</script>

<slot />

src/app.d.ts:

declare module "svelte" {
  export function getContext(key: "t"): Awaited<ReturnType<typeof import("$lib/utils/i18n")["loadTranslations"]>>;
}

src/lib/utils/i18n.ts:

const config: Config = {
  loaders: [
    // ...
  ]
};

const availableLanguages = new Set(config.loaders?.map(loader => loader.locale) ?? []);
const defaultLocale = "en";
const defaultLocaleForLanguage: Record<string, string> = {
  en: "en-US",
  pt: "pt-BR",
};

export async function loadTranslations(languages: readonly string[], route: string) {
  const locale =
    languages.find(lang =>
      availableLanguages.has(
        !lang.includes("-") && lang in defaultLocaleForLanguage ? defaultLocaleForLanguage[lang] : lang,
      ),
    ) ?? defaultLocale;

  const instance = new i18n(config);

  await instance.loadTranslations(locale, route);

  return instance.t;
}

Any page that uses translations:

<script lang="ts">
  import { getContext } from "svelte";

  const t = getContext("t");
</script>

<p>{$t("some-key")}</p>
Lobidu commented

Hey @lbguilherme - Thank you so much for your detailed answer. I stand corrected, this does indeed work.

Just one thing - It doesn't load any translations for the new route when switching routes on frontend only. I can use data.t, but not getContext('t'). Any idea why that is?

The solution above (#106 (comment)) seems like a good way to avoid side-effects in load.

Shouldn't it be included in the examples?

Albeit, my use case is a pretty simple one, and I don't know whether this solution works 100% for more complex setups (e.g. what @Lobidu states here #106 (comment)).

P.S. probably all of this would need reworked again after the introduction of runes.

Hey @lbguilherme - Thank you so much for your detailed answer. I stand corrected, this does indeed work.

Just one thing - It doesn't load any translations for the new route when switching routes on frontend only. I can use data.t, but not getContext('t'). Any idea why that is?

I currently have the same issue, did you manage to fix it?

EDIT: I made it work by passing the setContext in a reactive statement like this:

export let data;

$: if ($page) {
    setContext("t", data.t);
}

Hi @CustomEntity, I've come to the same problem with translation loading, and also tried to employ the same solution of sharing the $t store via the context API — this doesn't fully work, as you're passing a new object in each context. For example, it is not possible to change the language (client side) on one page, and react to that change on another page, because you would have two different stores from two different contexts.

Please text back if you're successful using your reactive setContext solution with dynamic client side language changing (e.g. $locale = 'en' or setLocale('en')), I'd be interested in trying it.

Hi @CustomEntity, I've come to the same problem with translation loading, and also tried to employ the same solution of sharing the $t store via the context API — this doesn't fully work, as you're passing a new object in each context. For example, it is not possible to change the language (client side) on one page, and react to that change on another page, because you would have two different stores from two different contexts.

Please text back if you're successful using your reactive setContext solution with dynamic client side language changing (e.g. $locale = 'en' or setLocale('en')), I'd be interested in trying it.

Hello @mtpython, I finally encountered the same problem as yours, the solution worked on a single page but not on multi-page like you. So I've stopped using SvelteKit-i18n until they fix the problem on their side, the other libraries also have the same problem so I've decided to use a temporary DIY solution that works as well as SvelteKit-i18n for my use.

Thanks for mentioning the "poly-i18n", but for my project the currently missing lazy load feature is more important than the downsides of race conditions, so until a more complete/mature solution is released, I'd resort to just accepting the current state of this library as is...

tl;dr: this issue should remain open

lbguilherme commented on May 4
This worked very well for me:

Your solutions seem to work for me as well, but there is type error:
Cannot use 't' as a store. 't' needs to be an object with a subscribe method on it.

<script lang="ts">
  import {getContext} from 'svelte'
  const t = getContext('t')
</script>

<div>
  <h2>{$t('home.title')}</h2>
  <p>{$t('home.content')}</p>
</div>

Wow, glad I found this issue. I was going crazy about why translations kept doing crazy things on production but always worked locally and on staging. The solution by lbguilherme worked for me. But I had to disable lazy loading for different routes or they wouldn't load in SPA mode. I guess this should be mentioned in the docs.