Overview

This tutorial will guide you through setting up a new React Native iOS project and configuring it so that you can use ClojureScript with Om for implementation.

Given the nascent state of the libraries and tooling, this tutorial hopefully helps you develop and understanding of the rational behind the approach, which is still evolving.

If you'd like to quickly set up a new project without manually going through the steps below, check out Natal by Dan Motzenbecker. With Natal installed, getting things up and running is as simple as natal AwesomeProject.

Create Project

Set up a top-level directory which will contain the React Native project as well as the ClojureScript project.

mkdir Awesome
cd Awesome

Set up React Native iOS Project

Set up AwesomeProject using

react-native init AwesomeProject
 
as described at React Native Getting Started, but don't yet start Xcode or edit any files.

Configure Ambly Dependencies

Next, we will set things up so that the Ambly iOS ClojureScript REPL can be used for development with this project. To do this, we will use CocoaPods.

Go into the AwesomeProject/iOS directory and create a Podfile file with the following contents:

source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '8.0'

pod "Ambly", "~> 0.6.0"

With this Podfile in place, run

pod install
 
in the AwesomeProject/iOS directory. When this completes, open AwesomeProject.xcworkspace. You can do this with
open AwesomeProject.xcworkspace
 
Now, within Xcode, navigate to the Build Settings for the AwesomeProject target and find Other Linker Flags. It should currently be set to
-ObjC
 
Revise this so that it is instead set to
${inherited}
 

Hook in Ambly

The React Native setup script creates an AppDelegate implementation that loads React Native. Replace the contents of AppDelegate.m with the code below. This is essentially the same as the file generated by react-native init, but with additional initialization in order to bring up ClojureScript and set up the Ambly REPL. This code will probably be factored out into reusable bits in the future, but since things are evolving rapidly, it is all just dumped into AppDelegate for now.

/**
 * Copyright (c) 2015-present, Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 */

#import "AppDelegate.h"

#import "RCTRootView.h"
#import "RCTEventDispatcher.h"
#import "ABYServer.h"
#import "ABYContextManager.h"
#import "RCTContextExecutor.h"

/**
 This class exists so that a client-created `JSGlobalContextRef`
 instance and optional JavaScript thread can be injected
 into an `RCTContextExecutor`.
 */
@interface ABYContextExecutor : RCTContextExecutor

/**
 Sets the JavaScript thread that will be used when `init`ing
 an instance of this class. If not set, `[NSThread mainThread]`
 will be used.
 
 @param thread the thread
 */
+(void) setJavaScriptThread:(NSThread*)thread;

/**
 Sets the context that will be used when `init`ing an instance
 of this class.
 @param context the context
 */
+(void) setContext:(JSGlobalContextRef)context;

@end

static NSThread* staticJavaScriptThread = nil;
static JSGlobalContextRef staticContext;

@implementation ABYContextExecutor

RCT_EXPORT_MODULE()

- (instancetype)init
{
  id me = [self initWithJavaScriptThread:(staticJavaScriptThread ? staticJavaScriptThread : [NSThread mainThread])
                        globalContextRef:staticContext];
  staticJavaScriptThread = nil;
  JSGlobalContextRelease(staticContext);
  return me;
}

+(void) setJavaScriptThread:(NSThread*)thread
{
  staticJavaScriptThread = thread;
}

+(void) setContext:(JSGlobalContextRef)context
{
  staticContext = JSGlobalContextRetain(context);
}

@end


@interface AppDelegate()

@property (strong, nonatomic) ABYServer* replServer;
@property (strong, nonatomic) ABYContextManager* contextManager;
@property (strong, nonatomic) NSURL* compilerOutputDirectory;

@end

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  NSURL *jsCodeLocation;

  /**
   * Loading JavaScript code - uncomment the one you want.
   *
   * OPTION 1
   * Load from development server. Start the server from the repository root:
   *
   * $ npm start
   *
   * To run on device, change `localhost` to the IP address of your computer
   * (you can get this by typing `ifconfig` into the terminal and selecting the
   * `inet` value under `en0:`) and make sure your computer and iOS device are
   * on the same Wi-Fi network.
   */

  jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/index.ios.bundle"];

  /**
   * OPTION 2
   * Load from pre-bundled file on disk. To re-generate the static bundle
   * from the root of your project directory, run
   *
   * $ react-native bundle --minify
   *
   * see http://facebook.github.io/react-native/docs/runningondevice.html
   */

//   jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];

  // Set up the ClojureScript compiler output directory
  self.compilerOutputDirectory = [[self privateDocumentsDirectory] URLByAppendingPathComponent:@"cljs-out"];
  
  // Set up our context manager
  self.contextManager = [[ABYContextManager alloc] initWithContext:JSGlobalContextCreate(NULL)
                                           compilerOutputDirectory:self.compilerOutputDirectory];
  
  // Inject our context using ABYContextExecutor
  [ABYContextExecutor setContext:self.contextManager.context];
  
  // Set React Native to intstantiate our ABYContextExecutor, doing this by slipping the executorClass
  // assignement between alloc and initWithBundleURL:moduleProvider:launchOptions:
  RCTBridge *bridge = [RCTBridge alloc];
  bridge.executorClass = [ABYContextExecutor class];
  bridge = [bridge initWithBundleURL:jsCodeLocation
                      moduleProvider:nil
                       launchOptions:launchOptions];
  
  // Set up a root view using the bridge defined above
  RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge
                                                   moduleName:@"AwesomeProject"
                                            initialProperties:nil];
  
  // Set up to be notified when the React Native UI is up
  [[NSNotificationCenter defaultCenter] addObserver:self
                                           selector:@selector(contentDidAppear)
                                               name:RCTContentDidAppearNotification
                                             object:rootView];

  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  UIViewController *rootViewController = [[UIViewController alloc] init];
  rootViewController.view = rootView;
  self.window.rootViewController = rootViewController;
  [self.window makeKeyAndVisible];
  return YES;
}

- (NSURL *)privateDocumentsDirectory
{
  NSURL *libraryDirectory = [[[NSFileManager defaultManager] URLsForDirectory:NSLibraryDirectory inDomains:NSUserDomainMask] lastObject];
  
  return [libraryDirectory URLByAppendingPathComponent:@"Private Documents"];
}

- (void)createDirectoriesUpTo:(NSURL*)directory
{
  if (![[NSFileManager defaultManager] fileExistsAtPath:[directory path]]) {
    NSError *error = nil;
    
    if (![[NSFileManager defaultManager] createDirectoryAtPath:[directory path]
                                   withIntermediateDirectories:YES
                                                    attributes:nil
                                                         error:&error]) {
      NSLog(@"Can't create directory %@ [%@]", [directory path], error);
      abort();
    }
  }
}

-(void)requireAppNamespaces:(JSContext*)context
{
  [context evaluateScript:[NSString stringWithFormat:@"goog.require('%@');", [self munge:@"awesome-project.core"]]];
}

- (JSValue*)getValue:(NSString*)name inNamespace:(NSString*)namespace fromContext:(JSContext*)context
{
  JSValue* namespaceValue = nil;
  for (NSString* namespaceElement in [namespace componentsSeparatedByString: @"."]) {
    if (namespaceValue) {
      namespaceValue = namespaceValue[[self munge:namespaceElement]];
    } else {
      namespaceValue = context[[self munge:namespaceElement]];
    }
  }
  
  return namespaceValue[[self munge:name]];
}

- (NSString*)munge:(NSString*)s
{
  return [[[s stringByReplacingOccurrencesOfString:@"-" withString:@"_"]
           stringByReplacingOccurrencesOfString:@"!" withString:@"_BANG_"]
          stringByReplacingOccurrencesOfString:@"?" withString:@"_QMARK_"];
}

- (void)contentDidAppear
{
  // Ensure private documents directory exists
  [self createDirectoriesUpTo:[self privateDocumentsDirectory]];
  
  // Copy resources from bundle "out" to compilerOutputDirectory
  
  NSFileManager* fileManager = [NSFileManager defaultManager];
  fileManager.delegate = self;
  
  // First blow away old compiler output directory
  [fileManager removeItemAtPath:self.compilerOutputDirectory.path error:nil];
  
  // Copy files from bundle to compiler output driectory
  NSString *outPath = [[NSBundle mainBundle] pathForResource:@"out" ofType:nil];
  [fileManager copyItemAtPath:outPath toPath:self.compilerOutputDirectory.path error:nil];
  
  [self.contextManager setUpAmblyImportScript];
  
  NSString* mainJsFilePath = [[self.compilerOutputDirectory URLByAppendingPathComponent:@"main" isDirectory:NO] URLByAppendingPathExtension:@"js"].path;
  
  NSURL* googDirectory = [self.compilerOutputDirectory URLByAppendingPathComponent:@"goog"];
  
  [self.contextManager bootstrapWithDepsFilePath:mainJsFilePath
                                    googBasePath:[[googDirectory URLByAppendingPathComponent:@"base" isDirectory:NO] URLByAppendingPathExtension:@"js"].path];
  
  JSContext* context = [JSContext contextWithJSGlobalContextRef:self.contextManager.context];
  [self requireAppNamespaces:context];
  
  JSValue* initFn = [self getValue:@"init" inNamespace:@"awesome-project.core" fromContext:context];
  NSAssert(!initFn.isUndefined, @"Could not find the app init function");
  [initFn callWithArguments:@[]];
  
  // Send a nonsense UI event to cause React Native to load our Om UI
  RCTRootView* rootView = (RCTRootView*)self.window.rootViewController.view;
  [rootView.bridge.modules[@"RCTEventDispatcher"] sendInputEventWithName:@"dummy" body:@{@"target": @1}];
  
  // Now that React Native has been initialized, fire up our REPL server
  self.replServer = [[ABYServer alloc] initWithContext:self.contextManager.context
                               compilerOutputDirectory:self.compilerOutputDirectory];
  [self.replServer startListening];
}

@end

The code above expects AppDelegate to satisfy the NSFileManagerDelegate protocol, so modify AppDelegate.h to add that:

/**
 * Copyright (c) 2015-present, Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 */

#import <UIKit/UIKit.h>

@interface AppDelegate : UIResponder <UIApplicationDelegate, NSFileManagerDelegate>

@property (nonatomic, strong) UIWindow *window;

@end

Set up ClojureScript Project

Within the Awesome directory do:

lein new awesome-project
 

Go into the resulting awesome-project directory, and edit the project.clj file. Change the dependencies to be:

  :dependencies [[org.clojure/clojure "1.7.0"]
                 [org.clojure/clojurescript "0.0-3308"]
                 [org.omcljs/om "0.8.8"]
                 [org.omcljs/ambly "0.6.0"]]
 

Also add cljsbuild support:

  :plugins [[lein-cljsbuild "1.0.6"]]
  :cljsbuild {:builds {:dev {:source-paths ["src"]
                             :compiler {:output-to "target/out/main.js"
                                        :output-dir "target/out"
                                        :optimizations :none}}}}
 

Rename src/awesome_project/core.clj to be src/awesome_project/core.cljs and replace its contents with a simple "Hello World!"-style Om UI:

;; Need to set js/React first so that Om can load
(set! js/React (js/require "react-native/Libraries/react-native/react-native.js"))

(ns awesome-project.core
  (:require [om.core :as om]))

;; Reset js/React back as the form above loads in an different React
(set! js/React (js/require "react-native/Libraries/react-native/react-native.js"))


;; Setup some methods to help create React Native elements

(defn view [opts & children]
  (apply js/React.createElement js/React.View (clj->js opts) children))

(defn text [opts & children]
  (apply js/React.createElement js/React.Text (clj->js opts) children))


;; Set up our Om UI

(defonce app-state (atom {:text "Hello from ClojureScript!"}))

(defn widget [data owner]
  (reify
    om/IRender
    (render [this]
      (view {:style {:flexDirection "row" :margin 40 :backgroundColor "cyan"}}
        (text nil (:text data))))))

(om/root widget app-state {:target 1})

(defn ^:export init []  
  ((fn render []
     (.requestAnimationFrame js/window render))))
 

The ClojureScript compiler doesn't yet have the support needed to consume the CommonJS and JSX artifacts that are used by React Native, so we instead rely on the app using the React Native packager. Owing to subtle issues with the way Om then loads, we have the code above perform a trick where we set js/React, load Om, and then set it back to the one being used by React Native. This odd approach will likely go away as the ClosureScript compiler gains the ability to directly consume the artifacts, but this gets us by for now.

Add ClojureScript build output to Xcode project

Now build the project using:

lein cljsbuild once dev
 

This will cause the ClojureScript compiler to emit its JavaScript into target/out. To bundle this output with your app, using the OS X Finder, drag the target/out directory into the AwesomeProject tree in the Xcode project navigator. You can drop it anywhere; adjacent to the existing main.jsbundle is fine. Make sure Copy items if needed is not checked, and that Create folder references is chosen.

Try It Out

Run the project in Xcode on one of the simulators. You should see the "AwesomeProject" launch screen, briefly followed by a screen displaying "Welcome to React Native!", followed by a screen indicating "Hello from ClojureScript!"

The intermediary "Welcome to React Native!" screen exists because of the state of ClojureScript's support. We are using the React Native packager, replacing the root app with our own. In the future we will be able to eliminate this completely.

But for now, edit Awesome/AwesomeProject/index.ios.js file so that, while this interim view is created, it just looks like a temporary white screen:

var AwesomeProject = React.createClass({
  render: function() {
    return (
      <View/>
    );
  }
});

You can also delete the styles while in there; they won't be used.

With this, restart your app in Xcode, and from the user's perspective you should see it transition from the launch image to your ClojureScript app.

REPL Interaction

Start up the Clojure REPL by running the following from within the Awesome/awesome-project directory:

lein repl
 
Once the REPL starts, launch the Ambly REPL by evaluating these two forms:
(require
  '[cljs.repl :as repl]
  '[ambly.core :as ambly])
 

(repl/repl (ambly/repl-env) :analyze-path "src")

You may bee curious about the :analyze-path REPL option being passed above, it is documented here and some exposition on it is here.

The running AwesomeProject app should be discovered and you can then connect to it.

Note: See Connectivity for details, should any networking difficulty arise.

[1] AwesomeProject on iPhone Simulator (My-MacBook-Pro)

[R] Refresh

Choice: 1

Connecting to AwesomeProject on iPhone Simulator (My-MacBook-Pro) ...

To quit, type: :cljs/quit
cljs.user=> 

Then go into the awesome-project.core namespace:

(in-ns 'awesome-project.core)
 

You can then update the app-state atom to display a different string:

(swap! app-state assoc :text "Hello again!")

Update the rendering code in core.cljs (say, revise the :backgroundColor of the top-level View element), and then save the file and dynamically reload it:

(require 'awesome-project.core :reload)
 
The change should be immediately reflected in the simulator.

Ambly Start Script

To make it easier to start the Ambly REPL you could add a script somewhere in your project with the following contents. This script additionally checks for rlwrap (which adds keyboard input editing and history support) and uses it if installed.

This script must be executed from Awesome/awesome-project (where project.clj resides). So, it could be placed in Awesome/awesome-project/script/repl, for example, and started by executing script/repl.

#!/bin/bash

type lein >/dev/null 2>&1 || { echo >&2 "I require lein but it's not installed.  Aborting."; exit 1; }

if hash rlwrap 2>/dev/null; then
    COMMAND="rlwrap lein"
else
    COMMAND="lein"
fi

$COMMAND trampoline run -m clojure.main -e \
"(require '[cljs.repl :as repl])
(require '[ambly.core :as ambly])
(repl/repl (ambly/repl-env) :analyze-path \"src\")"

Fake Figwheel Hot Loading

You can use the :reload option to require to reload changes to your code. While Ambly doesn't support Figwheel, it is possible to simulate the aspect of automatic reloading whenever changes to your code are made. This is described in this blog post and can be achieved with a revised start script:

#!/bin/bash

type lein >/dev/null 2>&1 || { echo >&2 "I require lein but it's not installed.  Aborting."; exit 1; }

if hash rlwrap 2>/dev/null; then
    COMMAND="rlwrap lein"
else
    COMMAND="lein"
fi

$COMMAND trampoline run -m clojure.main -e \
"(require '[cljs.repl :as repl])
(require '[ambly.core :as ambly])
(let [repl-env (ambly.core/repl-env)] 
  (cljs.repl/repl repl-env 
    :watch \"src\" 
    :watch-fn 
      (fn [] 
        (cljs.repl/load-file repl-env 
          \"src/awesome_project/core.cljs\"))
    :analyze-path \"src\"))"

With this in place, changes to core.cljs will get reflected in the UI automatically upon save.