react-native-localize icon indicating copy to clipboard operation
react-native-localize copied to clipboard

Add SSR support

Open lesmo opened this issue 5 years ago • 12 comments

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.

lesmo avatar Dec 17 '19 09:12 lesmo

@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.

zoontek avatar Dec 17 '19 10:12 zoontek

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.

lesmo avatar Dec 17 '19 12:12 lesmo

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.

zoontek avatar Jan 05 '20 17:01 zoontek

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. 😀

lesmo avatar Jan 13 '20 02:01 lesmo

@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)!

zoontek avatar Jan 26 '20 13:01 zoontek

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? 😅

lesmo avatar Jan 28 '20 16:01 lesmo

@lesmo Totally in the v2 TODO list 🙂

zoontek avatar Jan 29 '20 09:01 zoontek

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!

sanderlooijenga avatar Jun 23 '20 10:06 sanderlooijenga

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.

lesmo avatar Jun 23 '20 23:06 lesmo

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

zoontek avatar Jun 24 '20 09:06 zoontek

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!

lesmo avatar Jun 24 '20 15:06 lesmo

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

longlho avatar Aug 17 '20 20:08 longlho