BRIKEV / remix-paraglidejs

remix utils and examples to work with paraglidejs
https://www.npmjs.com/package/remix-paraglidejs
MIT License
17 stars 1 forks source link

[BUG]: Client / Server mismatch for concurrent requests when using with Remix defer in the example #1

Open thebaba44 opened 7 months ago

thebaba44 commented 7 months ago

Describe the bug The languageTag is defined in module scope in the auto generated runtime.js file. On the client this is fine because the client is the only one that's managing it. On the server there will be an issue if there's concurrent requests when using with Remix's defer because renderToPipeableStream is not synchronous.

function handleBrowserRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  return new Promise((resolve, reject) => {
    let shellRendered = false;
    const lang = getContextLang(remixContext, {
      defaultValue: availableLanguageTags[0],
      availableLanguages: availableLanguageTags,
      urlParam: 'lang',
    });
    setLanguageTag(lang); // <-------- This is the issue
    ...

Suppose an English user visits the app, Remix will render the HTML until it hits a suspense boundary and will render a fall back in its place. Remix will continue to render the Suspense boundary's children only when the promise is resolved. Now suppose a Japanese user vists the app at the same time. This will cause Remix to change the language for the English user to Japanese and cause client / server mismatch

To Reproduce

As an example, suppose we have ($lang).ssr-links.tsx with a deferred loader supporting two languages (en, ja). The promise for languages that's not Japanese are configured to be delayed for 5s

routes/($lang).ssr-links.tsx

import { Await, useLoaderData } from "@remix-run/react";
import * as m from "../paraglide/messages";
import { LoaderFunctionArgs, defer } from "@remix-run/node";
import { Suspense } from "react";

export let loader = async ({ params }: LoaderFunctionArgs) => {
  return defer({
    lang: new Promise<string>((resolve) => {
      // Resolve immediately for Japanese
      if (params.lang === "ja") { 
        resolve("ja");
      } else {
        // Resolve the promise in 5s for other languages
        setTimeout(() => { 
          if (typeof params.lang === "string") { // Make Typescript happy
            resolve(params.lang);
          }
        }, 5000);
      }
    }),
  });
};

export default function Index() {
  const data = useLoaderData<typeof loader>();

  return (
    <div
      style={{
        fontFamily: "system-ui, sans-serif",
        lineHeight: "1.8",
        backgroundColor: "orange",
      }}
    >
      <h1>{m.title()}</h1>
      <p>{m.description()}</p>
      <Suspense fallback={<h1>Fall back</h1>}>
        <Await resolve={data.lang}>
          {(lang) => (
            <h1>
              {lang} {m.encouragement()}
            </h1>
          )}
        </Await>
      </Suspense>
    </div>
  );
}

messages/en.json


{
  "$schema": "https://inlang.com/schema/inlang-message-format",
  "title": "Remix web example",
  "description": "This is a simple example of how to use Remix to create a web app.",
  "encouragement": "You're doing great! Keep it up!"
}

messages/ja.json


{
  "$schema": "https://inlang.com/schema/inlang-message-format",
  "title": "リミックスウェブの例",
  "description": "これは、Remix を使用して Web アプリを作成する方法の簡単な例です。",
  "encouragement": "素晴らしいですね!これからも頑張ってください!"
}
Setup

1) Set up paraglide js with en and ja 2) Copy over translations above to the messages directory 3) Create routes/($lang).ssr-links.tsx and copy the loader above into the file 4) Open http://localhost:5173/en/ssr-links in one tab 5) Open http://localhost:5173/ja/ssr-links in another tab

Producing the bug

1) Refresh http://localhost:5173/en/ssr-links 2) Immediately after, refresh http://localhost:5173/ja/ssr-links 3) Head back to the http://localhost:5173/en/ssr-links tab and check console

Expected behavior Expect the server render to always use the language from the request that triggered it

Screenshots Screen shots showing page source and console for the English tab

image image

Desktop (please complete the following information):

kevinccbsg commented 6 months ago

Hi @thebaba44, thanks a lot for reporting.

We try to reproduce and I was able to do it 👍 However, We couldn't fix that issue, I see the correct lang after loading but I cannot avoid that error so far.

We were wondering if you have found a workaround 🤔?

The only thing that works for me is adding the setLanguageTag after the Defer finishes. We are thinking on adding a helper for that, but we still thinking about that.

export const loader = async ({ params }: LoaderFunctionArgs) => {
  return defer({
    lang: new Promise<string>((resolve) => {
      // Resolve immediately for spanish
      if (params.lang === "es") { 
        resolve("es");
      } else {
        // Resolve the promise in 5s for other languages
        setTimeout(() => { 
          if (typeof params.lang === "string") { // Make Typescript happy
            resolve(params.lang);
          }
        }, 1500);
      }
    }).finally(() => setLanguageTag(params.lang as "en" | "es")),
  });
};
thebaba44 commented 6 months ago

I know this is going to sound crazy, but I always use the language URL parameter /{lang}/... as the source of truth for displaying languages (falling back to en-US). It's just less overhead for me and my users. I only redirect users if they land on the index page with a Accept-Language header that is different from the default language. I am not using cookies or any other mechanism to decide what language to display.

In my components, I grab the URL parameter and pass it down as an argument to the translation functions

const params = useParams();
const lang = getLang(params);
const msg = m.hello({ name: "Samuel" }, { languageTag: lang }) // Hallo Samuel!

In my loaders (inspired by Arisa Fukuzaki - https://remix.run/blog/remix-i18n)

export const loader = async ({ params }: LoaderFunctionArgs) => {
  const lang = getLang(params);
  const singleContact = await getContact(params.contactId);

  const { avatar, twitter, notes, name } = singleContact;
  // Get the internationalized name based on URL params
  const translatedName = `${name?.[lang]?.first} ${name?.[lang]?.last}`;
  return json({ avatar, twitter, notes, name: translatedName });
};

Basically I just eliminated the problem instead of coming up with a solution 😂

Its just simpler for me, and I also like the explicitness. I don't like magic. But it doesn't feel very library-worthy

I believe Sergio (creator of Remix-18n) creates a context provider in the server entry file that wraps the entire application and passes the i18n instance with the configured language as the prop value. I do not really like having the structure of my React app separated, some being in the server entry file, some in the root.tsx file.

I also didn't really want to create custom wrappers around Paraglide because I wanted to get the types / autocomplete from the functions that Paraglide CLI generated.