svelte-i18n icon indicating copy to clipboard operation
svelte-i18n copied to clipboard

Allow non-singelton locale initialization, for supporting multi-language&SSR&async server

Open Tal500 opened this issue 2 years ago • 5 comments

The Problem The initialization of svelte-i18n sets the preferred language in a global variable. While this is acceptable for client, it is not acceptable for the server (with SSR).

The server might be async, in which it can proccess at the same time both request for one language and a request for other language. However, as far as I can see, setting a new locale will override the existing one, and a race condition might occur.

Solution? Allow to configure the current locale per request and not only globally.

Alternatives We may instead just run multiply instances of the nodejs server, or just decide that the server render in SSR stage only a fixed language.

Is there already any mechanism to fix it? I'm using Sapper BTW.

Tal500 avatar Oct 11 '21 09:10 Tal500

Hey @Tal500 👋

This one's a long coming one. Currently, this is indeed not supported and will require some refactoring. Mainly, we will need to change how we get the stores, moving them inside some factory method.

What I imagine is something like:

Initializing:

// src/i18n.ts
+import { createI18nClient } from 'svelte-i18n'

+const i18n = createI18nClient(options)

+const { t, locale, formatDate, ... } = i18n

+export { t, locale, formatDate, ... }

Using:

-import { t } from 'svelte-i18n'
+import { t } from 'src/i18n.ts'

We could still support importing directly from the module for the majority of use cases, if folks think it's important, by having something like

// src/i18n.ts
import { createi18nClient, setClient } from 'svelte-i18n'

const i18n = createi18nClient(options)

const { t, locale, formatDate, ... } = i18n

+setClient(i18n)

export { t, locale, formatDate, ... }

Note: this is not implemented

kaisermann avatar Oct 11 '21 11:10 kaisermann

Thanks @kaisermann !

I should notice that after a little bit of checking I'm not sure Svelte is even capable to handle async requests.

In sapper, the middleware return a non-async function, so ExpressJS/Polka will only process one request at a time. (tested in Polka by me)

But as I said, I'm not sure that Svelte can handle async at SSR.

Tal500 avatar Oct 11 '21 17:10 Tal500

Thanks @kaisermann !

I should notice that after a little bit of checking I'm not sure Svelte is even capable to handle async requests.

In sapper, the middleware return a non-async function, so ExpressJS/Polka will only process one request at a time. (tested in Polka by me)

But as I said, I'm not sure that Svelte can handle async at SSR.

Well, turned up I was completely wrong (and my previous testing was wrong as well) It seems that my feature request is critical, and I'll explain. I will talk only about Sapper because this is what I know, but I believe that it is the same for SvelteKit.

Sapper is a middleware for Express/Polka. In short, the architecture of Express/Polka middleware is a list of middleware, that call the function next(req, res, next) when they wish to continue HTTP request handaling, and call res.end(...) if this middleware decides the user should get its response, and end the request handling chain. The reason I'm telling this is for showing that all the process of sapper prefetch/session/whatever is non-blocking(due to the behavior of calling next()), and when it call for a blocking resource it will (assuming the developer using Svelte&Sapper used async process calls) not block, so Express/Polka could process another HTTP request(s) meanwhile, while async-waiting for resources.

The fact that Express/Polka could (and in reality for a production website, will!) process many request at a time should worry you about the singleton global initialization of svelte-i18n! The problem is, as seen in the example of Sapper with i18 (specifically in this code), that in every request it will override the initialized locale, but since it maybe concurate, the SSR might gives translation of a different language to the end-user.

I believe it will be fixed after Svelte will be loaded at the client code, but still it is very disturbing to know it might (and will) happen!

Do you agree that this issue is very serious @kaisermann? I believe that anyone that use it for production shell execute different instance of Sapper/SvelteKit servers by each locale, and redirect request to the correct server by reading the locale cookie in HTTP request.

Tal500 avatar Dec 31 '21 10:12 Tal500

Hi @kaisermann, here is my suggestion according to your preferred syntax. First, it should keep support for the currently support syntax, and add a new recommended syntax for future code.

As you said, we need to have some "i18n Client" object, probably represented directed by the funcs { _, locale, formatDate, ... }. We also need that Svelte users could easily translate import { _, locale, formatDate, ... } from 'svelte-i18n'; to { _, locale, formatDate, ... } = i18n;.

Additionally, I think we need to take advantage of Svelte context management functions functions (available only during component initialization).

Proposed User Usage

The user can write the initialization code in src/i18n.js:

// src/i18n.js
import { register, createI18nClient } from 'svelte-i18n';

register('en', () => import('./en.json'));
register('en-US', () => import('./en-US.json'));
register('pt', () => import('./pt.json'));

function setupI18nClient(locale?) {
    const client = createI18nClient({
        fallbackLocale: 'en',
        initialLocale: locale,
    });

    return client;
}

And in the main Svelte component (App.svelte in the examples, or src/routes/_layout.svelte in Sapper/SvelteKit) write this:

// Main Svelte component (e.g. App.svelte or src/routes/_layout.svelte)
...
<script>
...
    import { getLocaleFromNavigator, setI18nClientInContext } from 'svelte-i18n';
    import { setupI18nClient } from '../i18n';

    const i18nClient = setupI18nClient(getLocaleFromNavigator());
    setI18nClientInContext(i18nClient);// Set i18n Client in Svelte context
...
</script>
...

If the user use SSR, she may pass the locale data in session data, and wait for locale dictionary loading before returning. In Sapper (and very similarly, in SvelteKit), one should seed in session data the correct value of the locale by reading a cookie, and wait for locale dictionary loading in preload. Then src/routes/_layout.svelte could look like this:

// src/routes/_layout.svelte

<script context="module">
    import { waitLocale } from 'svelte-i18n';

    export async function preload(page, session) {
        await waitLocale(session.locale);
    }
</script>

<script>
...
    import { get } from 'svelte/store';
    import { stores } from '@sapper/app';
    import { getLocaleFromNavigator, setI18nClientInContext } from 'svelte-i18n';
    import { setupI18nClient } from '../i18n';

    const { session } = stores();
    const locale = (typeof window !== 'undefined') ? getLocaleFromNavigator() : get(session).locale;

    const i18nClient = setupI18nClient(locale);
    setI18nClientInContext(i18nClient);// Set i18n Client in Svelte context

    if (typeof window !== 'undefined') {
        // TODO: Set here a cookie in client side with the value of locale=getLocaleFromNavigator()
    }
...
</script>
...

Now all components can use formatting this way (including 3rd parties):

// Some Svelte component
...
<script>
...
    import { getI18nClientFromContextOrGlobal } from 'svelte-i18n';

    { _, locale, formatDate } = getI18nClientFromContextOrGlobal();
...
</script>
...

The function getI18nClientFromContextOrGlobal() returns the current i18n that is on the context, or if none on the context returns the global i18n (the "old" singleton i18n initialization), or throw an exception if none of them did happen. (See implementation details) The reason for returning the global singleton is that 3rd party code could easily support both "new" and the "old" approach of svelte-i18n.

Implementation details

I looked over the directory structure of svelte-18n code. The tough thing require a major refactoring is implementing the function createI18nClient() to return JS object { _, locale, formatDate, ... }. I think no one but the maintainer @kaisermann could perform or at least instruct how to modify the code&directory structure. Again, as I explained in my previous comment, this feature is not only "nice to have", it is critical if you wish to support i18n in a production environment with SSR, or otherwise you'd have race condition (meaning, at least, bad/mixed HTML language translation in server HTTP response).

I will speak about how to implement the rest of the functions, that's easy:

// Internal code of svelte-i18n
import { setContext, getContext } from 'svelte';

const key = {};

// All the functions below can be called only in Svelte component initialization

export function setI18nClientInContext(i18nClient) {
    setContext(key, i18nClient);
}

export function clearI18nClientInContext() {
    setContext(key, undefined);
}

export function getI18nClientFromContextOrGlobal() {
    var i18nClient = getContext(key);

    if (i18nClient) {
        return i18nClient;
    } else {
        // Return the global singleton client that was initialized by 'init()', or throw an exception if none.
    }
}

What do you think? I think I'm following @kaisermann suggestion, just use additionally the power of Svelte contexts for the user sanity to handle i18n client managing.

Tal500 avatar Jan 31 '22 14:01 Tal500

To address SSR (Server-Side Rendering) generation, you can follow this approach in your Svelte application:

Call waitLocale function from 'svelte-i18n' to ensure the desired locale is loaded in cache before rendering the page:

#In [[lang]]/+page.ts
import { waitLocale } from 'svelte-i18n';

export async function load({ params }): Promise<Record<string, string>> {
    const lang = params.lang || "en";
    await waitLocale(lang);
    return {
        lang
    };
}

Pass the $page.data.lang to the formatting methods, for eg:

# Any svelte component
<script lang="ts">
    import { _, json } from "svelte-i18n";
    import { page } from "$app/stores";
</script>

{$_("box.readmore", {locale: $page.data.lang})}
OR
{$json("box.readmore", $page.data.lang)}

Control that you never set the locale store, nor rely on it in your application.

This is enough for SSR. But since we are not using the local store anymore, you can add the code below in your main +layout.svelte component to restore the reactive update of the 'lang' attribute of the HTML document during client-side rendering (CSR):

#+layout.svelte
...
$: if (browser) document.documentElement.setAttribute("lang", $page.data.lang);
...

I think it would be advisable to revise the SSR example in the documentation.

mulder999 avatar Oct 29 '23 20:10 mulder999