Skip to content

Example Expo setup

Terje Tjervaag edited this page Apr 16, 2024 · 6 revisions

Expo or React Native development is likely to be one of the main use cases for simctl.nvim, although the following examples could also apply to web development targeting the iOS Simulator.

The problem

Using NeoVim for developing Expo apps works great, but as with VS Code, I often find myself Command-Tabbing over to the iOS Simulator and using the mouse to clumsily force quit the running Expo Go app. Sometimes I also need to start from scratch and have to go through the somewhat cumbersome delete and reinstall app process. Too many mouse clicks for my liking.

A solution

In my personal setup I combine vimux with simctl.nvim to provide a dev setup for Expo that I can trigger with shortcuts. If you don't use tmux you could use a tool like toggleterm.nvim for instance, but I have found the tmux integration to be the most stable.

I've set up Vimux to reuse the same tmux pane by giving it a fixed VimuxRunnerName, and I want my code and my runner to be side by side:

vim.g.VimuxRunnerName = "vimuxout"
vim.g.VimuxOrientation = "h"

I've made a simple module in ~/.config/nvim/lua/user/expo.lua with some Expo-specific run tasks. You can place this anywhere and adjust your import accordingly. This uses yarn, so adjust the commands if you use npm or other tools.

local M = {}

--- Quit Expo Go on running iOS Simulators
-- @param callback function to call when command finishes
M.quit = function(callback)
  require("simctl.api").terminate({ deviceId = "booted", appId = "host.exp.Exponent" }, callback)
end

--- Uninstall Expo Go from running iOS Simulators
-- @param callback function to call when command finishes
M.uninstall = function(callback)
  require("simctl.api").uninstall({ deviceId = "booted", appId = "host.exp.Exponent" }, callback)
end

--- Send the reload command r to Expo Go running in a Vimux window
M.reload = function()
  vim.cmd("VimuxRunCommand 'r'")
end

--- Helper function to check if we have a booted device up and running
local hasBootedDevice = function(deviceList)
  for _, device in ipairs(deviceList) do
    if device.state == "Booted" then
      return true
    end
  end
  return false
end

M.run = function()
  -- Stop the Expo Go process if it is running
  if vim.g.VimuxRunnerIndex then
    vim.cmd "VimuxInterruptRunner"
  end

  local simctl = require "simctl.api"
  local aw = require("simctl.lib.async")
  aw.async(function()
    -- Check if we have a booted device to run on, boot one if not
    local _, devices = aw.await(simctl.list)
    if not hasBootedDevice(devices) then
      local success = aw.await(function(cb)
        simctl.boot({}, cb)
      end)
      if not success then
        -- No device was selected, cancel
        return
      end
    end

    -- Run the app
    vim.schedule(function()
      vim.cmd("VimuxRunCommand 'yarn start --ios'")
    end)
  end)
end

return M

I can now assign these actions to shortcuts using which-key.nvim:

local wk = require("which-key")

wk.register {
  ["<leader>me"] = { require("user.expo").run, "Expo Run" },
  ["<leader>mq"] = { require("user.expo").quit, "Quit Expo Go" },
  ["<leader>mr"] = { require("user.expo").reload, "Expo Reload" },
  ["<leader>mu"] = { require("user.expo").uninstall, "Expo Go Uninstall" },
}

Now when I press <leader>me, simctl.nvim prompts me to boot up a Simulator if one is not already running. After that, Expo starts in a tmux pane and runs my app on the booted Simulator. <leader>mr sends a reload r to the Expo runner without me having to leave NeoVim but I can also interact with the runner in the usual way in the separate tmux pane. If I need a hard refresh I can run <leader>mq to quit Expo Go before running again, or <leader>mu to uninstall Expo Go completely from the device.

The above example uses the async/await pattern that simctl.nvim uses internally. You can accomplish the same with only callbacks if you find that easier to read.

Monorepo

To make this work in a monorepo containing several Expo app we need to expand the runner slightly. First, add three helper functions:

local map = function(tbl, func)
  local newtbl = {}
  for i, v in ipairs(tbl) do
    newtbl[i] = func(v)
  end
  return newtbl
end

--- Select app name in a monorepo from a list
-- Looks for all apps under ./apps directory
-- @param callback function to call with selected app slug
local selectApp = function(callback)
  local scan = require "plenary.scandir"
  local apps = scan.scan_dir("./apps", { hidden = false, depth = 1, only_dirs = true })
  local slugs = map(apps, function(v)
    return v:match "([^/]-)/?$"
  end)

  vim.ui.select(slugs, { prompt = "Select app" }, function(selected)
    callback(selected)
  end)
end

local selectAppAndRun = function()
  local simctl = require("simctl.api")
  local aw = require("simctl.lib.async")

  aw.async(function()
    -- Quit expo go if it is running
    local success, apps = aw.await(function(cb)
      simctl.listapps({ deviceId = "booted" }, cb)
    end)
    if not success then
      vim.notify("Error listing apps", vim.log.levels.ERROR)
      return
    end
    if findAppByBundleIdentifier(apps, "host.exp.Exponent") then
      aw.await(function(cb)
        simctl.terminate({ deviceId = "booted", appId = "host.exp.Exponent" }, cb)
      end)
    end

    -- Select app from monorepo and run it using Vimux
    vim.schedule(function()
      selectApp(function(slug)
        vim.cmd("VimuxRunCommand 'yarn " .. slug .. ":start --ios'")
      end)
    end)
  end)
end

And then update the M.run function to use the new selectAppAndRun helper:

M.run = function()
  if vim.g.VimuxRunnerIndex then
    vim.cmd "VimuxInterruptRunner"
  end

  local simctl = require("simctl.api")
  local aw = require("simctl.lib.async")

  aw.async(function()
    -- Check if we have a booted device to run on, boot one if not
    local _, devices = aw.await(simctl.list)
    if not hasBootedDevice(devices) then
      local success = aw.await(function(cb)
        simctl.boot({}, cb)
      end)
      if not success then
        -- No device was selected, cancel
        return
      end
    end

    selectAppAndRun()
  end)
end

The same which-key shortcuts as above will still work the same, except now you get a selector for which app you want to run before Expo kicks off.

Clone this wiki locally