zoontek / react-native-localize

🌍 A toolbox for your React Native app localization
MIT License
2.27k stars 211 forks source link

Add SSR support #79

Open lesmo opened 4 years ago

lesmo commented 4 years ago

Feature Request

Support for SSR. Current index.web.js is directly calling browser-only objects, which is not possible while being rendered on the server.

Why it is needed

X-All-The-Y Because localize all the things everywhere!

Possible implementation

I noticed the calls are to navigatorand window objects. Those statements are called as soon as any code imports react-native-localize, so it would be necessary to "delay" that to a later stage, or call them lazily or even conditionally. I don't know how this module's internals work... yet 😏

Code sample

This is a conditional example that would render properly on SSR:

export let constants: LocalizationConstants = generateConstants(
  (navigator && navigator.languages) || [],
);

window && window.addEventListener("languagechange", () => {
  constants = generateConstants(navigator.languages);
  handlers.forEach(handler => handler());
});

The only complication is how to make constants somehow wait or be populated until the browser is ready without breaking anything. This could work:

/* Server Side would need to be populated some othery way... */
export let constants: LocalizationConstants; // Would be undefined until...
document && document.addEventListener("DOMContentLoaded", function(event) { 
  constants = navigator.languages; // We're in business
});

But I'm not sure if an undefined constants would break something, and there would of course need to be a way to know from the client which locale to use. I'll get back if I come up with something.

zoontek commented 4 years ago

@lesmo Hi 👋

I'm not a big user of SSR but it should be do-able. The main pain point is currently to parse and send the Accept-Language header to the module to have valid data at startup.

Imagine:

// on server
import { parseAcceptLanguageHeader }  from "react-native-localize/server"
// …
render(<App languages={parseAcceptLanguageHeader(header)} />)

In your app:

generateConstants(languages)

But could it be enough? getNumberFormatSettings, getTimeZone, uses24HourClock depends on browser Intl API.

Another solution could be to switch to lazy, synchronous getters.

lesmo commented 4 years ago

Oh I like that idea! Passing Accept-Language should be an easy task from most of the major SSR libraries (I'm trying to get this to work with [razzle]() btw).

The browser API could be polyfilled for SSR... 🥁 drums for dramatic effect... 🥁 with intl perhaps? Using it as an optional dependency would allow for this magic to happen.

zoontek commented 4 years ago

Intl.js seems unmaintained 😞 An easy start could be lazy evaluation: stop generating constants at start, but instead only when requested. It would make SSR related future work easier.

lesmo commented 4 years ago

I've looked into that and while it hasn't been updated, I wouldn't say it's unmaintained... just dusty. 😜

While researching for some bugs on my Android build, I found some mentions of that polyfill as a solution to the missing Intl object and some problems due to missing implementations. As far as I could tell, I think those missing implementations wouldn't make much of a difference... but still, after some thought even if that polyfill was maintained, I don't think it's a good idea to have it be a dependency to this lib. It's easier to say "wanna use it for SSR? make sure you have this or this", which brings me to my next point:

I think a reasonable solution would be to warn and maybe even have users of react-native-localize use a Node environment with support for internationalization when pretending to use it for SSR. Node has built-in support for the required stuff, although some builds might not have it built-in... but that's for the implementor to solve (maybe even with the polyfill).

I believe that's a nice solution. Just rewrite the web stuff to be lazy, and document that it'll need to be run on Node with proper support.

I'll see if I can put together a PR. 😀

zoontek commented 4 years ago

@lesmo I just published a new version with lazy getters on the web version: https://github.com/react-native-community/react-native-localize/releases/tag/1.3.3

Now we are free of code that calls navigator or window objects at module init. 😌 It should be easier to work on SSR support (we still need to find a way to parse the accept-language header and pass it down to the functions)!

lesmo commented 4 years ago

Thanks! I'll try this out!

I've been thinking about the Accept-Language thing, and it's quite difficult to solve with the current API. A solution I thought was making the RNLocalize an instantiable object that can be given an override param. Borrowing from react-navigation example for SSR (and actually quite similar to my solution), I imagined:

expressApp.get("/*", (req, res) => {
  const { path, query } = req;
  const runtimeLocales = parser.parse(req.get('Accept-Language');
  const localize = new RNLocalize({ runtimeLocales });
  const { navigation, title, options } = handleServerRequest(
    AppNavigator.router,
    path,
    query
  );
  // register the app
  AppRegistry.registerComponent('App', () => App);

  // prerender the app
  const { element, getStyleElement } = AppRegistry.getApplication('App', {
    initialProps: { navigation, localize }, // now <App> has localize prop
  });
  const markup = renderToString(<AppNavigator navigation={navigation} />);

  res.send(
    `<!doctype html>
  <html lang="">
  <head>
    <title>${title}</title>
    <script src="main.js"></script>
  </head>
  <body>
    <div id="root">${markup}</div>
  </body>
</html>`
  );
});

This way we can do:

// App.js
export default App = ({ localize }) => {
  const lang = localize.findBestAvailableLanguage(['en', 'en-GB', 'fr', 'pr']);
  return <AnAwesomeApp />
}

Or fancier, putting it inside a react context one could use hooks too:

// App.js
export default App = ({ runtimeLocales }) => {
   return (
    // Directly "override" the platform available languages with
    // the ones from express
    <LocalizeProvider platformLanguages={runtimeLocales}>
      <AnAwesomeApp />
    </LocalizeProvider>
  )
}

// Somewhere.js
export const Somewhere = () => {
  const localize = useLocalize();

  return (
    <Text>{localize.getCountry()}</Text>
  );
};

This way there's no direct dependency on global variables and it's SSR friendly... but it's a massive refactor, and I'm not sure it's a good idea (yet) or if could benefit other use cases at all. Something for like v2 maybe? 😅

zoontek commented 4 years ago

@lesmo Totally in the v2 TODO list 🙂

sanderlooijenga commented 4 years ago

Hi guys, I would be interested in this as well. Do you have some roadmap (for the v2) on when this will be implemented?

Cheers!

lesmo commented 4 years ago

Just a heads up, Intl.js will no longer be maintained so... I guess the best route forward would be to consider having implementers make sure they run Node with Intl compiled into the final binary.

zoontek commented 4 years ago

FormatJS offers a full set of polyfills: https://formatjs.io/docs/polyfills I see no mentions of NodeJS support, but it might be compatible.

lesmo commented 4 years ago

FormatJS offers a full set of polyfills: https://formatjs.io/docs/polyfills I see no mentions of NodeJS support, but it might be compatible.

Actually, the home page says it does so... that's the one!

longlho commented 4 years ago

We (formatjs) do support Node, although Node 14+ has almost everything you need (sans the bugs that we fixed)