mondeja / leptos-fluent

Internationalization framework for Leptos using Fluent
https://mondeja.github.io/leptos-fluent/
MIT License
38 stars 10 forks source link

Islands example #246

Closed NCura closed 1 month ago

NCura commented 1 month ago

Hello,

This is my current implementation of an islands example. I've started with the Axum template, integrated the islands feature, and added the leptos_fluent dependencies. In app.rs, I introduced a macro to maintain consistent arguments across the application, which is called within the App() function.

To better reflect a real-world scenario, I created a new module instead of keeping everything in a single file. The module includes a HomePage() component, which utilizes the macro_tr!. Since it's a #[component], it automatically has access to the i18n context.

There are also two islands, each requiring its own i18n context. I modified the LanguageSelector to use the i18n context that was explicitly created, rather than relying on shortcuts that assume the context is already available.

The current setup is functional, but I see a few areas for improvement:

I'm happy to make further changes based on any suggestions or feedback!

mondeja commented 1 month ago
  • I'm currently reloading the page after changing the language because, without the reload, the changes don't propagate. It seems that the i18n context in LanguageSelector differs from the one in Counter.

Because LanguageSelector is not a children of Counter. Create a parent island to provide the context there and use it in their children.

                                  #[island] Parent     leptos_fluent!

#[island] Child1     move_tr!                                   #[island] Child2     expect_i18n().set_language()

Remember, one context per archipelago or island.

NCura commented 1 month ago

I'm not entirely sure I understand the intended workflow for managing multiple translation directories.

Currently, I’ve structured the locales folder at the root of the example, with subfolders for each language, each containing a main.ftl file. In app.rs, I reference this structure both when creating the static TRANSLATIONS array and when initializing the i18n context via the leptos_fluent! macro. And then the same in home.rs.

Right now, I’m using translations in a #[component] on the server as well as in an #[island] that is shipped to the client. To avoid sending unnecessary data to the client, you suggested separating the translation directories.

Would this structure work for that?

- locales
    - core
        - en
        - es
    - server
        - en
        - es
    - island-home
        - en
        - es
    - island-contact
        - en
        - es
    - languages.json

Create a parent island to provide the context there and use it in their children.

Does this align with the correct workflow?

NCura commented 1 month ago

The current implementation gives an error in the browser console when trying to switch languages, complaining about I18n context is missing. Is there a problem with the way I created the parent island and added the children islands?

mondeja commented 1 month ago

I've pushed a fix for the example. Does it resolve your questions?

  • The core directory would hold core_locales, containing translations used across multiple places (e.g., in a component and across several "archipelagos", like a common slogan).

This is a misunderstanding about what core_locales does. This fluent-templates' setting allows to share a common Fluent resource across multiple locales, not a locales directory between multiple translations.

NCura commented 1 month ago

Thank you for fixing the example! I've added more content to simulate a common setup for multilanguage sites, where the language selector is often placed in the header, footer, or both. I thus introduced some nested routes.

That’s the current layout of the example, but now I need to make it work as expected. At present, when the language is changed, only the text within the same island as the LanguageSelector updates. The rest of the site retains the previous language. Reloading the page resolves the issue and updates all translations correctly.

Do you have any suggestions on how to ensure that changing the language via the LanguageSelector propagates to the other i18n contexts? Or am I approaching this from the wrong angle?

mondeja commented 1 month ago

I think that you're not completely aware about how the reactive graph works. Is not the same as the component tree. Consider the next example:

#[component]
fn Foo() -> impl IntoView {
    provide_context::<usize>(0);

    view! {
        <h1>"Foo"</h1>
        {
            let value = expect_context::<usize>();
            view! {
                <p>"Context value before Bar: "{value}</p>
            }
        }
        <Bar/>
        {
            let value = expect_context::<usize>();
            view! {
                <p>"Context value after Bar -> Baz: "{value}</p>
            }
        }
    }
}

#[component]
fn Bar() -> impl IntoView {
    provide_context::<usize>(1);
    view! {
        <h1>"Bar"</h1>
        {
            let value = expect_context::<usize>();
            view! {
                <p>"Context value before Baz: "{value}</p>
            }
        }
        <Baz/>
    }
}

#[component]
fn Baz() -> impl IntoView {
    provide_context::<usize>(2);
    view! {
        <h1>"Baz"</h1>
    }
}

What should it render? Well... it renders this:

<h1>Foo</h1>
<p>Context value before Bar: <!---->0</p>
<h1>Bar</h1>
<p>Context value before Baz: <!---->1</p>
<h1>Baz</h1>
<p>Context value after Bar -&gt; Baz: <!---->2</p>

Because Baz is a sibling of Foo children in the reactive graph. But you think that is just a children of Bar in the component tree and that is outside the scope of Foo children. That doesn't matter for Leptos.

This is not intuitive at a first glance. It is what is happening in your examples. Just pass an explicit i18n context to the tr! macros to avoid confusion.

NCura commented 1 month ago

Thank you for the clarifications, and for specifying the need to pass the i18n context to every move_tr! and tr! macro. This has solved the issue of ensuring all texts are translated correctly. Now, the remaining challenge is getting the translations to update dynamically when the LanguageSelector changes the language.

I'm currently focusing on the header translation:

#[component]
pub fn View() -> impl IntoView {
    let i18n = expect_i18n();
    view! {
        <header>
            <A href="/">{move_tr!(i18n, "home")}</A>
            <LanguageSelector />
            <A href="/page-2">{move_tr!(i18n, "page-2")}</A>
        </header>
    }
}

#[island]
fn LanguageSelector() -> impl IntoView {
    let i18n = i18n!([TRANSLATIONS], "./locales/header");
    view! {
        <div style="display: inline-flex; margin-left: 10px">
            {move_tr!(i18n, "select-language")} ": "
            {move || {
                i18n.languages
                    .iter()
                    .map(|lang| {
                        view! {
                            <div>
                                <input
                                    type="radio"
                                    id=lang
                                    name="language"
                                    value=lang
                                    checked=lang.is_active()
                                    on:click=move |_| i18n.language.set(lang)
                                />
                                <label for=lang>{lang.name}</label>
                            </div>
                        }
                    })
                    .collect::<Vec<_>>()
            }}

        </div>
    }
}

The goal is to update the text inside the <A> tags when the language changes. Since the LanguageSelector and the other components each have their own i18n context, the text does not update automatically.

I’ve attempted several approaches, including the four patterns outlined here. However, none have worked because the LanguageSelector is an island, and the #[island] macro requires props to be serializable to pass them from the server to the client.

I also tried saving the selected language to localStorage like this inside the LanguageSelector:

on:click=move |_| {
    i18n.language.set(lang);
    leptos_fluent::web_sys::window()
        .unwrap()
        .local_storage()
        .unwrap()
        .unwrap()
        .set_item("selected_language", lang.name)
        .unwrap();
}

But I encountered issues retrieving it inside the View component to update the language dynamically.

Do you have any suggestions on how we can achieve this?

mondeja commented 1 month ago

The goal is to update the text inside the <A> tags when the language changes. Since the LanguageSelector and the other components each have their own i18n context, the text does not update automatically.

Why are you using a #[component] for the view? Doesn't that go against islands design? AFAIK, islands are the interactive pieces of the page in an islands website. The translations that are in the rendered in the server can't be updated in the client, you don't even have the translations on the client.

Remember: #[component]s are rendered in the server and #[island]s are hydrated in the client.

NCura commented 1 month ago

This is the internal debate I’ve been having: On the one hand, islands are use for interactive pieces, and I would like the translations to react to the language change. On the other hand, they should be as small and specific as possible. However, if I have a fully translated website with little backend code, it seems I would need an archipelago containing almost all of my components just to share an i18n context. At that point, I feel like I'm losing the size reduction benefits of using islands. Am I missing something?

The best compromise I’ve come up with, when using leptos-fluent and islands, is to limit islands to non-translation-related interactivity and keep all translation handling on the server. When the language changes, the page would reload. While this approach loses the benefit of changing the language without a reload, it significantly reduces the size of the WebAssembly (wasm) sent to the client, especially for sites with minimal interactivity and lots of content.

I’ve used the original example to demonstrate the first approach, where we have a single archipelago that shares the i18n context across components. I made some necessary adjustments based on a bug mentioned by gbj in the Leptos repo issue I raised:

...

  1. Using an island inside the view that you return from an island Either is fine, although 2. is (IIRC) bugged in 0.6 and fixed in 0.7

So I had to refactor the code:

#[island]
pub fn HeaderView() -> impl IntoView {
    view! {
        <header>
            <a href="/">{move_tr!("home")}</a>
            <a href="/page-2">{move_tr!("page-2")}</a>
            <LanguageSelector/>
        </header>
    }
}

into:

#[component]
pub fn HeaderView() -> impl IntoView {
    view! {
        <header>
            <HeaderLinks/>
            <LanguageSelector/>
        </header>
    }
}

#[island]
fn HeaderLinks() -> impl IntoView {
    view! {
            <a href="/">{move_tr!("home")}</a>
            <a href="/page-2">{move_tr!("page-2")}</a>
    }
}

I did similar changes in the home and page_2 modules. After running cargo leptos build --release, the resulting wasm file size was 382k. For reference, I ran the same project without islands, and the wasm size increased to 561k.

I also added a second example, keeping the translations on the server. The downside is that the page must reload when changing the language, as mentioned before. The upside is that the wasm size is reduced to 282k, and this size won’t grow even as more content is added to the site. In contrast, the first example (382k) will continue to increase in size with additional content.

Would it make sense to include both examples (or perhaps one example but with explanations for both approaches)? This way, users of leptos-fluent and the islands feature can choose the path that best fits their project—whether they prefer client-side translations with a larger wasm or server-side translations with page reloads.

mondeja commented 1 month ago

Would it make sense to include both examples (or perhaps one example but with explanations for both approaches)? This way, users of leptos-fluent and the islands feature can choose the path that best fits their project—whether they prefer client-side translations with a larger wasm or server-side translations with page reloads.

I think that should be added just one example for islands using the second approach with page reloads which is the one that has more sense with islands because exploits better the benefit of file size, the main goal of islands design. IMHO, including both examples would add unnecessary noise for users who consult them.

To provide more context, I would suggest you include an explanation of this lack of interactivity issue in the README and/or in comments within the code, probably where you trigger the site reload.

NCura commented 1 month ago

That sounds great to me! I’ll implement this tomorrow. As an example, I’ve already updated one of my client’s websites from the first approach to the second in the development environment. The result was a significant reduction in the WebAssembly size, going from just over 1000KB down to less than 400KB. Given this improvement, the trade-off of a page reload is definitely worth it.

NCura commented 1 month ago

Additional Changes to the Final Example

Request for Clarification

In the i18n context setup within both App and LanguageSelector, I'm unsure about which arguments should be used on the server versus the client. You mentioned in a previous issue:

Well, note that some of the arguments only make sense for the server and others for the client.

Could you help clean up the arguments in the leptos_fluent! macro, ensuring that the one in App only contains server-side arguments, and the one in LanguageSelector has the client-side ones?

Follow-up Questions

mondeja commented 1 month ago

Could you help clean up the arguments in the leptos_fluent! macro, ensuring that the one in App only contains server-side arguments, and the one in LanguageSelector has the client-side ones?

Cleaned.

  • Since we're on the server, could we use tr instead of move_tr, or is there still a reason to use the reactive version?

Yes, we should use tr! if no interactivity is expected.

  • I encountered a small issue: I added the LanguageSelector twice—once in the normal menu and once in the mobile menu. On larger screen sizes, the input for the selected language in the normal menu has the checked attribute, but it doesn't display as checked, while the one in the mobile menu does.

Because all <input>s have the same "language" name. Fixed now.

  • In the i18n context created within LanguageSelector, shouldn't we use a translations array containing only the necessary translations, rather than pulling in all server-side translations? However, if we limit the translations, it seems that anything after the header won't be translated, as only the client-side translations will be available.

That's the question with islands and I think that leptos-fluent is not enough featured to allow this for now. However, there is a really dirty workaround that we can apply now to the example.

The most simple solution when you don't need interactivity would be to pass translations as island arguments. Something like:

#[component]
fn MyServerComponent() -> impl IntoView {
    view! {
        <MyIsland translated_message=tr!("foo") />
    }
}

#[island]
fn MyIsland(translated_message: String) -> impl IntoView {
    view! {
        <p>{translated_message}</p>
    }
}

In the case of this example is not enough. Even if we pass a HashMap with a subset of the translations to the top-level island and provide it as a context, you still need the languages to build the language selector.

Currently leptos_fluent! does not allow to set translations: [] as an argument to leptos_fluent!. Opened #250 to allow this. Currently the limitation can be bypassed by including a locales folder with empty translations.

That would solve the problem for this example by using something like:

#[component]
fn MyServerComponent() -> impl IntoView {
    view! {
        <MyArchipelago translations=HashMap::from([
            ("foo", tr!("foo")),
            ("bar", tr!("bar")),
        ]) />
    }
}

#[island]
fn MyArchipelago(translations: HashMap<String, String>) -> impl IntoView {
    provide_context(translations)

    view! {
        <MyIsland />
    }
}

#[island]
fn MyIsland() -> impl IntoView {
    let tr = expect_context::<HashMap<String, String>>();
    view! {
        <p>{tr.get("foo").unwrap()}</p>
        <p>{tr.get("bar").unwrap()}</p>
    }
}

And though it works, the syntax looks weird.

Other current workaround is to duplicate the repeated keys in a separated locales folder, but that is not convenient.

Another possible solution would be to implement some kind of option to leptos_fluent! to just include a subset of the translations by their identifiers. Maybe a land: ["foo", "bar"]. I'm open to ideas. Let me know what you prefer as you seem to be tackling a real project with islands.

NCura commented 1 month ago

I saw the PR allowing the creation of an i18n context with an empty array — awesome!

In this case, given that some translations are required on the client for the header, I added a CLIENT_TRANSLATIONS array and the corresponding locale folder. After that, I checked the Network tab in the browser: the wasm file is 4.4MB (since it's not in release mode). I then added a large test translation to the server/en.md file, and the wasm size remained 4.4MB. When I added that translation to client/en.md to test, the wasm size increased to 4.6MB, so it looks like the server translations are correctly excluded from the client-side wasm.

However, the issue now is that the texts in the home and page_2 modules aren't being translated, because the i18n context is using CLIENT_TRANSLATIONS, while their translations are in SERVER_TRANSLATIONS.

To fix this, I called the i18n context function from App in header::View, and now everything is being translated properly. The wasm size is still 4.4MB.

#[component]
pub fn View() -> impl IntoView {
    view! {
        <header>
            <Archipelago>
                <LargeMenu />
                <MobileMenu />
            </Archipelago>
            {super::provide_i18n_context();}
        </header>
    }
}

If you think that there is a better place to call the context again, feel free to tell me. Using it in header::View makes it explicit that since we’re using an Archipelago that loads its own i18n context, we need to reset the context by providing the server one again.

What do you think of this solution? It seems to be working as expected and prevents the server-side translations from being sent to the client, but I'd appreciate your thoughts in case I’m overlooking something.

P.D.: I've kept the test translation text inside the server/en.md in case you want to use it inside the client/en.md to check the file size, but we can remove it whenever you want.

mondeja commented 1 month ago

To fix this, I called the i18n context function from App in header::View, and now everything is being translated properly.

If you call leptos_fluent! a second time, all the code that generates the macro will be created again, probably generating bugs because side effects could become desynchronized. The proper solution is to reprovide the context. I've fixed it.

NCura commented 1 month ago

Okay, got it! Do you want to keep the test translation inside server/en? Everything else seems fine to me.

mondeja commented 1 month ago

Awesome. Thank you for the great work!