How to force reevaluation of EEx tags in live view (for live updating gettext)

Hi all!

In our current project, we want to be able to live update all translated strings on the page (using gettext) as soon as the user selects a new language.
The event is handled using handle_event which calls Gettext.put_locale/1 as well as assigns the locale to the socket (assign(socket, locale: locale)).

Unfortunately, the translated strings on the page do not update for regular <%= gettext "foo" %> tags in EEx live templates.
We currently know of two workarounds that we can apply in our application to get translated text to update immedately:

  1. Wrap all content in the template in Gettext.with_locale(fn -> ... end). This has the major drawback that it prevents us from using live_component anywhere within that function call, as Live View doesn’t manage the elements anymore.

  2. Access assigns within tags that have translated text, e.g. <%= assigns && gettext "foo" %> or <%= gettext "foo", locale: @locale %>. Unfortunately, this approach seems cumbersume, repetitive and (in the second variant) requires us to pass @locale down to all views/components.

Thinking about there workarounds, I seemed to me as if gettext itself is working perfectly fine, but liveview doesn’t give it a chance to update if used in the simple form without any access to assigns.

After digging in the liveview source code, I found that tags are analyzed to find out whether they need to be re-rendered for changed assigns.
Trying to replicate the “any access to assigns will cause a rerender” situation, I tried to create a macro like this:

defmacro trans(msgid) do
  quote do
    var!(assigns) && gettext(unquote(msgid))
  end
end

and use it as <%= trans("foo") %>, but that still doesn’t update when it should.
I suspect that the macro is not expanded when the EEx template is compiled for live view and the access to assigns is thus not recognized.

What I found to be working well, but which requires source code modification in phoenix_live_view, is adding defp classify_taint(:gettext, _), do: :always to https://github.com/phoenixframework/phoenix_live_view/blob/900b151809ec4064f58e763c45c3906a5ff57ec2/lib/phoenix_live_view/engine.ex#L899

I’d like to hear some opinions of what would be a good solution to the problem we’re currently facing. I do understand that not all users “need” live updating translations on their page and always re-evaluating tags containing gettext calls incurs a performance penalty when compared to evaluating them just once.
Would it make sense to allow configuring custom “signatures” in config.exs to cater to such problems where live view can not correctly infer which tags need recomputation?

3 Likes

This is likely the way to go if you like to have proper change tracking. Needing to pass @locale everywhere just makes the dependency on it explicit. It has been there been there before.

Thanks for your response. However, I kind of disagree.

Gettext has already made the decision that locale doesn’t need to be passed in to any of the public methods (the private lgettext/4 is the only exception), but that it’s read from Process instead.
I don’t think that the circumstance that gettext is now used in live view (which is a pretty common use case IMO) changes the way developers expect to use the gettext functions.

1 Like

I can see your point, but gettext was not developed to deal with updates/rerendering. That is a thing LiveView brought to the table and LiveView handles. There might be a time when LiveView has integration with gettext to do what you’d expect it to do, but until then explicitly handling it is in my opinion the way to go.

This is not even the only place LiveView brought limitations. E.g. the same is true with @key vs. assigns[:key]. Before LiveView both were equally valid code in templates, but with LiveView only the first option does work with change tracking.

1 Like

Hm, I just tried it out and both assigns.locale and assigns[:locale] where updated correctly. Maybe your statement was true for an earlier version of live view?

Anway. What I would also “accept” as an solution is the explicit and active invalidation of all previously rendered tags. This way, live view could still be optimized as-is, but on such occasions where a value outside of assigns is modified that still has an effect on the rendered views, one could call sth. along the lines of force_rerender(socket) to cause a complete reevaluation of all tags.

This thread is, in fact, meant as a discussion about improving live view (if that’s not too presumptuous), not only about solving our specific problem :slight_smile:

3 Likes

They update, but they update on any assigns change not just on a change for @key.

force_rerender(socket) sounds great.

It turns out that my first (and probably hacky) attempt to force a full rerender immediately worked:

socket = %{socket | fingerprints: Phoenix.LiveView.Diff.new_fingerprints()}

However, I’d love some input from the people involved with LiveView about whether this would cause any unexpected effects regarding mounted components etc.

2 Likes

Hello,

The following doesn’t work for me:

# In the view
<button phx-click="set_en">English</button>
# In the handler
def handle_event("set_en", _, socket) do
  Gettext.put_locale(MyApp.Gettext, "en")
  socket = %{socket | fingerprints: Phoenix.LiveView.Diff.new_fingerprints()}
  {:noreply, socket}
end

In fact I get {nil, %{}} for the result of new_fingerprints().

Also I wasn’t able to find any documentation about The Diff module and that new_fingerprints() function.

Do you have any documentation or even some code to share?
Thank you!

EDIT 1:
Anyway I tried the following which is working (very trivial though)

# The button to trigger the translation live update
<button phx-click="set_en">English</button>
# the gettext calls in the views
<%= @x && gettext "Hello" %>
# In the handler (x was set to 0 in mount())
def handle_event("set_en", _, socket) do
  Gettext.put_locale(MyApp.Gettext, "en")
  {:noreply, update(socket, x, &(&1 + 1))}
end

EDIT 2:
The following make it even a little cleaner:

# in the handler
def handle_event("set_en", _, socket) do
  Gettext.put_locale(MyApp.Gettext, "en")
  {:noreply, force_rerender(socket)}
end
defp force_rerender(socket, fingerprint \\ :x) do
    update(socket, fingerprint, &(&1 + 1))
  end

Default to :x (as a single character to clutter a little less the view)…

I found this thread after trying to search for a way to re-render a live view page after calling Gettext.put_locale. The workaround here still works but, since this thread is almost 2-years old, I’m wondering if this is still the only way around it or if there’s an official way of re-rendering the page.

Initially, I thought push_patch/2 would do the trick but it doesn’t seem like it.

For some context: I have a settings LiveView page where users can change their preferred language. After submitting the form, I need the page’s locale to be updated to the selected language.

Maybe this isn’t helpful but just in case you didn’t realise,push_navigate (with replace: true for ergonomics) to the current url will terminate the current view and remount it, which is sort of what you want – except you’ll lose any other state but perhaps if they’ve hit save on settings you’ve serialized out all state at that point.

The Socket type has a private field while fingerprints is listed in the open, so perhaps that implies that fingerprints are free for meddling within reason.

The fact that its primary interface is mostly LiveView.Diff which is undocumented, and the type is also not explicitly documented, makes me think it probably should be treated as private. The developer intent around that is always a bit unclear to me with Elixir structs but most things you’re supposed to mess with in Phoenix have explicit documentation :thinking:.

Nuking the fingerprints lets you retain state if needed but I would probably just push_navigate unless I really needed to hold onto some expensive some state.

Thanks, @soup. I had tried push_navigate but it didn’t work either (as in: the locales weren’t updated). You can see it on this branch (maybe I’m missing something there). So far, the only thing that worked is calling new_fingerprints() but, I agree, ideally that shouldn’t be proper solution.

Here’s a hint :slight_smile:

Try looking at your on_mount hook, :set_locale_from_session. The push_navigate remounts & re-renders the page as we want, but does it mount with the correct language?

Spoilers

The session is only setup before the websocket takes over, so when you fetch the language from the session, its essentially stale as the user has changed the value out from under your cache.

You can fetch directly from the user, as that is set via the on_mount :ensure_authenticated hook (which calls down to mount_current_user), so its always current.

diff --git a/lib/zoonk_web/user_auth.ex b/lib/zoonk_web/user_auth.ex
index ca0f579..0818698 100644
--- a/lib/zoonk_web/user_auth.ex
+++ b/lib/zoonk_web/user_auth.ex
@@ -180,7 +180,8 @@ defmodule ZoonkWeb.UserAuth do
   end
 
   def on_mount(:set_locale_from_session, _params, session, socket) do
-    Gettext.put_locale(ZoonkWeb.Gettext, Map.get(session, "language"))
+    Gettext.put_locale(ZoonkWeb.Gettext,
+      Atom.to_string(socket.assigns.current_user.language))
     {:cont, socket}
   end

Basically you save the users language, update the Gettext locale in the page (all good so far), then push_navigate, which hits :set_local_from_session, which then overwrites your setting with the stale cache value from session, then re-renders the page with the old locale, so it looks like nothing changed.

1 Like

Oh, I see. Nice catch, thanks! I need to get better at debugging those things in Elixir/Phoenix.