"Upgrading" the react-native support

Hey everyone,

over the next few weeks I’ll be working on a couple of updates for the react-native integration in shadow-cljs. While it already works quite well it has some issues that can be improved and I wanted to give a quick overview of my understanding of them. I still don’t do any real react-native development myself so I’m mostly going by what others have told me. So please if you have found some rough edges or things that don’t work please let me know.

Upgrade #1: Source Maps

To explain why they don’t work it is probably best to provide some basic background in how all the tools work together. First the CLJS compiler (with or without shadow-cljs) will translate all CLJS code to JS. In doing so it’ll create the first set of source maps that map the generated parts of JS back to the original CLJS code. So after compilation you typically end up with a cljs/core.js and cljs/core.js.map file in some fashion. The source map could also be inlined in which case it’ll be part of the cljs/core.js without an extra .map file.

In the second step all .js files are processed by the react-native bundler called metro. It’ll process all code and transpile unsupported features out or provide polyfills for some. It’ll also process all import directives and require calls and provide the actual JS code for those. In turn metro will also produce source maps for the JS files it compiled. facebook invented their own source map format for reasons I don’t know but the problem here is that it doesn’t read “input source maps” at all. So it won’t read the source maps created by the CLJS compiler at all and only map errors and so on back to the generated JS but not the actual CLJS source. Other compile to JS languages suffer the same fate and there is an open issue about that.

Luckily there is a hacky way around that since react-native has a built-in “Debug JS remotely” option which will load all the JS in a remote Chrome instance which means we gain access to the capabilities of Chrome by “cheating”. re-natal uses a figwheel-bridge “cheat” where it basically first loads a dummy placeholder app which is processed by metro normally. At runtime it’ll then load the actual generated JS code dynamically and when done loading replace the placeholder with the actual app. Since Chrome properly respects the // #sourceMappingUrl directives in the source the code loaded this way will be mapped properly in the Chrome devtools.

This means the generated JS is never actually seen by metro though which means some manual work needs to be done if you want to include other JS dependencies. re-natal solves this with its command line tools I believe or you just manually repeat all the require calls you plan on using in the CLJS code.

The approach also adds an asynchronous step to the loading process. Since initialization by default it is not async you are forced to use the placeholder app. This is fine bug somewhat different than production builds where this async loading step does not occur.

So far shadow-cljs only produces JS code that could be fully processed by metro. This worked fine and no extra manual work needed to be done to extract js/require calls since metro would take care of them. It is mostly a zero-conf setup and doesn’t require a “placeholder” app. I’m told however that it is near impossible to debug issues at runtime given the lack of source maps.

The Plan

As a first step I adjusted the way the code is generated to essentially employ the same tricks as the figwheel-bridge did. “Hiding” the actual sources from metro and instead loading it dynamically. Instead of loading the code via HTTP requests however I embedded the sources inline as strings and they will just be evaluated directly on load. This means no extra async layer is introduced on startup. I first created this loader-mode for the browser a while ago and it works very well there. Source maps are also directly embedded inline so no extra request is necessary there either.

This gets source maps into a usable state as long as “Debug JS remotely” is used. The Chrome devtools correctly map (js/console.log "foo") calls and “Pause on Exceptions” also correctly show CLJS sources and lets you step through them.

The react-native “RedBox” and “YellowBox” Components to show Errors/Warnings still don’t show mapped sources but I think this cannot be cheated in any way until react-native actually supports input source maps.

The price for getting the partial source maps however is that all the generated JS code is no longer processed by metro at all which means it also won’t fill in manual js/require calls at all. However this is also a small benefit since metro can be quite slow processing all the generated JS code.

The Question

We now need a way to “gather” all JS require calls so they can be put into the sources that metro actually processes and made available to the CLJS parts. For JS code this is done completely automatically if you require code from the ns form.

(ns my.app
  (:require
    ["react-native" :as rn]
    ["some-react-native-lib/foo/Bar" :as X]))

;; use via aliases normally
rn/View
rn/Text
X
;; etc.

No extra config is needed for those. They map directly to how you’d use them in JS and the compiler will take care of adding the appropriate code where necessary.

There however can be “rogue” (js/require "something") calls which the compiler will not “find” currently. That still need to be processed since require is not available at runtime.

So while there are a couple of options to get this done properly I don’t know which one people would prefer?

I’m not using react-native myself and so probably shouldn’t be the one to decide this. I’ll just list the options and listen to the feedback.

Option 1: Manual via Config

Similar to re-natal or figwheel-bridge you just list the “extra” requires you need in the config file and shadow-cljs will make sure they are available at runtime. Just need to repeat every js/require call done in the code (eg. for assets) in the config again.

Option 2: Utility Macros

I used this approach before and it works well. A macro is created which “records” all require uses and makes that info directly available to the build tool. So instead of (js/require "../some/asset.png") you’d call (some-alias/require-asset "some/asset.png"). The parts needed for this look very similar to the shadow.resource example. The problem with this is that shadow-cljs is currently the only tool to process this info and therefore if you couldn’t use code using this with other build tools. And you have that extra ns :require in each ns needing this.

Option 3: Compiler Magic

It would be sort of simple to automatically process js/require calls at the compiler level and basically make it a “special form”. So basically automated Option 1. I’m always hesitant to adding shadow-cljs only stuff to the compiler though.

Option 4: Use the ns

Technically the ns form already does everything we need and this is the way the JS world does everything after all. In JS require is used to load other JS code as well as static assets (eg. images).

In JS(X) you’ll see examples like

<Image source={require('/react-native/img/favicon.png')} />

which mapped to something like reagent looks like

[rn/Image {:source (js/require "/react-native/img/favicon.png")}]

and could be also

(ns my.fancy.component
  (:require
    ["react-native" :as rn]
    ;; some kind of prefix to "mark" as not-code
    ["asset:/react-native/img/favicon.png" :as favicon]))

[rn/Image {:source favicon}]

I think this is getting into dangerous territory since it would be shadow-cljs specific again but it moves the configuration to the place it is actually used. No need to configure anything else again.

I also kind of like this approach since it makes things declarative again. The ns form is basically just static information that can be processed separately. All other options require actual compilation and puts more burden on the user.

But this is moving things into a dangerous messy direction the JS world is in right now and I kinda want to keep this out of CLJS. Pure developer convenience isn’t always the best idea.

Anyways. I’m curious what actual react-native developer think and which approach you’d prefer.

  • Manual Config
  • Utility Macros
  • Compiler Magic
  • Use the ns

0 voters

3 Likes

Not using shadow-cljs in our current RN setup. Very happy to see work done on this though and it makes me want to migrate. Still will have some opinions though, in case we migrate in the future.

One thing that would make us migrate instantly is getting source-maps to work in production. We use Bugsnag and errors like these are not very fun:

TypeError: undefined is not an object (evaluating ‘l.slice’)
But I guess that would be hard to achieve before the Metro-bug is fixed.

A problem with macros is that requires can be dynamic strings generated at runtime. Agree otherwise that nothing RN specific should be added to the compiler.

Less magic is better in general I think. But, if you require something and it’s not found because you didn’t add it to the source then it would be VERY nice with a semi-magic error message that helps you:

Maybe you forgot to add “react-native/img/favicon.png” to the require config

Don’t know how easy this would be to implement though.

Can they? I thought they were strictly static? Do you have a JS example for this?

Improved error messages for manual js/require would be possible since we’d provide a custom require function anyways.

Improving bugsnag errors is unlikely to work since that would require metro support. It might however be possible to do a manual lookup via something like I described here. So maybe something that takes the bugsnag error report, parses it and translates the source locations to CLJS locations where possible.

I vote for the Compiler Magic, Option 3.
I think the result of Option 3 is to use image with js/require, right?

Original Post:

Are these 4 Options all related to how to use image?

I think this is great.

(js/require "/react-native/img/favicon.png")

It’s better to NOT manually maintain a list of packages, that’s really complex. The React-Native is already complex enough.

1 Like

I’m also in favour of minimal magic. I migrated away from the original expo-cljs-template which had magic that scanned through the code for js/require calls, in favour of maintaining a manual list. The magic approach frequently failed, and didn’t handle comments properly. The frequency of adding a new asset/dependency is relatively low compared to normal coding and magic adds a complexity burden that doesn’t quite feel worth it.

I think the sweet spot is something like what @vikeri said - a semi magic “Maybe you forgot to add x to the require config”.

That’s the sort of thing we have now with a modified figwheel bridge.

Using macros is an interesting idea. Perhaps this could be a separate tiny library, which would mean that it could be used from other build tools.

I don’t think you can have dynamic js/require (I think the bundler does something which is essentially more like a static scan of the source, replacing requires with numerical ids). This is actually one of the areas where clojure can improve on the js experience, because you can create a macro which ends up in a static js/require call that the bundler can use. I’ve experimented with that by making a macro that scans a directory and returns a map of id -> static require calls for images that are available at compile time.

Either way, really glad that this is being looked at - as @vikeri said, it’s pretty much only source maps that are holding us back to switching from figwheel - everything else works much better, and feels much more solid. Thanks for all your hard work on this!

2 Likes

Can we do a “agnostic” cljs-resource.rn even that it need to some work on the “compiler” side (shadow-cljs, re-natal, figwheel…)
I think that it’s not good write code that just work in one compiler.

Indeed. In development builds all calls to require are rewritten to something like _$$_REQUIRE(_dependencyMap[2], "whatever"); and the _dependencyMap object is populated by metro. Production builds look a bit different but require is still replaced in a similar way.

require is basically a special form that is replaced by the compiler. It is not a regular function call although it may look like one.

Agreed.

This is really great news! My company already uses shadow-cljs for our web-facing code, and the tail end of 2019 is for us The Year of Linux on the Desktop fixing our long-suffering, still ExpoKit-encumbered React Native project into something we can stand to maintain long-term. I will be watching developments with interest, and actively seeking to beta-test, help debug, etc. as appropriate. Cheers!

1 Like

Hmm, now I’m getting unsure. I thought that the spec of require allowed for dynamic strings (as opposed to import). But I’m very possibly not correct.

LTTTA - Less things to think about - wins in my book. Compiler magic would be awesome.

FWIW I voted for option 2 (compiler macros) because there’s already, in my experience, a “okay now go do some manual work or it won’t build/won’t build in production mode” step in React Native work. Maybe that’s not true for everyone, but for my project which is still encumbered by an early decision to adopt ExpoKit, the need to produce several build variants (QA build lane for TestFlight, white-labeled app version, other reasons), etc., etc. I don’t think that it’s worth making too many structural compromises for developer convenience. Developer life in the React Native universe is going to have some unbuffed burrs and manual checklist-y things regardless of what shadow-cljs does or does not provide.
Why then not vote for Option 1? Simple; I expect that in the fullness of time this macro form might be the entry point for additional goodness that lets us get real source maps all the way out to YellowBox and RedBox, better reloading, and so forth. I don’t mind putting it in my code, I hope to be choosing a platform for the “long haul” (at least another 2-3 years) and so adopting some local dialect is not a huge cost, amortized over that long a period of hopefully-stable development.

I find it difficult to decide which one I like best. I know the one I like LEAST: (1). I think duplication should be avoided if at all possible, it is very confusing and error prone.

The ideal solution should “just work” the way people would expect. So how is that exactly? For me as somebody new to Clojure(Script) altogether, I was expecting something like option (4) until I learned that I had to use js/require, so for me that would be an intuitive solution. But people coming from non-shadow CLJSRN systems, this may be different.

I agree with the sentiment to be “hesitant to adding shadow-cljs only stuff”, but it seems that the better options all require this. I want to vote for (2), (3) and (4) as in: “anything that is not (1)”. Since that is not possible, I’m going to vote for option (3) because it does not involve duplication and it is the most popular non-(1) so has the best chances of gaining the lead :slight_smile:

By the way, are you aware of the mechanism used by Metro that decides on which files to treat as assets (https://facebook.github.io/metro/docs/en/configuration#assetexts)? I was thinking that in option (4) you could (should?) hook into that to automatically determine which of the ns-requires are assets and which are not…

By the way I’m really happy for you @thheller that you got the funding and it’s awesome that you’re involving us like this, thanks!!

1 Like

After some more investigation Option #4 is pretty much out since it it is important to maintain the “when” something is used. So the location where js/require is used may be relevant as much as what it actually required.

I have half implemented Option #3 and it seems like the most reliable option since it will automatically work if someone decides to build macros on top. The js/require is extracted during compilation from the actual AST so it should be pretty reliable. Repeating things in the config (ie. Option 1) seems entire unnecessary then.

3 Likes

Too bad there isn’t an instant runoff voting mechanism. I was pretty conflicted between 1 & 3.

1 Like

I just released shadow-cljs version 2.8.44 which contains a basic version of Option #3 so that all js/require calls should now be automatically extracted and just work without the need for additional configuration. They still have to be fully static though. Can always add manual configuration for additional requires later if needed.

Source maps should be working semi-reliable as long as Chrome is used but I expect there to be some quirks so please help me test this.

I added another feature which in theory should allow code-splitting react-native builds and when combining this with the “RAM-bundle” feature of metro should possibly improve startup performance. I’ll probably provide more details on this is a separate post once I had a bit of time to actually test it properly. If you’d be interested in helping me test this let me know. The bigger the app the better. :wink:

6 Likes

I haven’t actually done React native stuff, but what if we bypassed the whole source map issue altogether. If the problem is debugging, could a mode which instruments every single call be used? Like a try around everything that records the source location, and on error reports it. I guess it wouldn’t support full breakpoints and debugger tools in browser. But it would let us know where the issue occurred.

It’s almost some kind of ad-hoc inline source map as instrumented code. Don’t know if that’s easy or possible or useful.

Pretty sure that would make things unusably slow without providing any actual benefit.

Debugging shouldn’t be a problem when using Chrome. Pretty sure that is the only way to debug anything anyways so thats fine.

Wow, this is great news! I’ll try it out in a toy project later today and then start a conversation in the team about trying it out in our production app. I’m definitely interested in the code splitting / startup time idea - do you know if it require your app to be structured in a specific way?

Yep - debugging in chrome is suprisingly good when source maps are enabled - you get the line in the source mapped file highlighted where the error originates, then all the stack frames are clickable, so you can move around the call tree. It even in many cases will also have the relevant locals available so if you add variables to the watch list in the side panel you will see what value they had - pro tip call clj->js on clojure variables so you can drill down into maps etc.

1 Like