●COMPANION — The Rork Companion app lets you test your build on a real iPhone without paying for an Apple Developer account first●SEED — Rork raised a $15 million seed round in April 2026 led by Left Lane Capital, with Peak XV, True Ventures, Goodwater, and existing investor a16z Speedrun taking part●PAPER — Rork acquired the app builder Paperline to bring in engineering talent, and says it plans to stay acquisitive●ARR — Rork Max reportedly reached $1.5 million in ARR within three days of its February 2026 launch●NATIVE — Rork Max reaches AR and LiDAR scanning, Metal-backed 3D games, Dynamic Island, Live Activities, HealthKit, NFC, and on-device Core ML●PLATFORM — Targets include iPhone, iPad, Apple Watch, Apple TV, and Vision Pro, plus iMessage●COMPANION — The Rork Companion app lets you test your build on a real iPhone without paying for an Apple Developer account first●SEED — Rork raised a $15 million seed round in April 2026 led by Left Lane Capital, with Peak XV, True Ventures, Goodwater, and existing investor a16z Speedrun taking part●PAPER — Rork acquired the app builder Paperline to bring in engineering talent, and says it plans to stay acquisitive●ARR — Rork Max reportedly reached $1.5 million in ARR within three days of its February 2026 launch●NATIVE — Rork Max reaches AR and LiDAR scanning, Metal-backed 3D games, Dynamic Island, Live Activities, HealthKit, NFC, and on-device Core ML●PLATFORM — Targets include iPhone, iPad, Apple Watch, Apple TV, and Vision Pro, plus iMessage
What Renovate may bump in an Expo app, and what it must never touch
Turning on automated dependency updates in a Rork-generated app also hands Renovate the 123 packages Expo SDK 57 pins. Measured on 2026-07-30, six of them sit a full major version behind npm latest. Here is how to generate the ignore list from the SDK instead of maintaining it by hand.
Monday morning, a row of Renovate pull requests was waiting in the repository.
One of them caught my eye: react-native-gesture-handler, from 2.32.0 to 3.1.0. A full major version.
CI was green. Unit tests passed, types checked, the linter had nothing to say. When you maintain six apps on your own, a green dependency PR is normally a small gift. Anything that gets updated without your hands on the keyboard is welcome.
Except this app is managed by Expo. And running expo install --check reports that the exact version Renovate wanted to install does not match the SDK.
The machinery meant to keep dependencies fresh was quietly working as machinery for breaking them.
Before chasing the root cause, I wanted the blast radius. How many Expo-managed dependencies can Renovate actually reach?
Expo pins 123 packages
The Expo SDK package ships a file called bundledNativeModules.json. It is what expo install consults to decide the version range for a given package, which makes it the compatibility contract for that SDK release.
I pulled the real file and counted. Measured on 2026-07-30.
Doing the same for SDK 56.0.18 gives 122 entries. The difference between 56 and 57 is a single addition, expo-eas-client.
But 90 entries had their version range changed. Almost nothing was added or removed, yet nearly three quarters of the ranges moved. An SDK bump is not a change of which packages exist. It is a wholesale rewrite of what versions they may be.
That much I expected. The next count was where my assumptions broke.
The dangerous ones are the packages that don't look like Expo
Forty-one entries do not begin with expo. react-native-reanimated. @shopify/flash-list. @sentry/react-native. react-native-svg. They look like ordinary packages published independently on npm.
From Renovate's point of view they are ordinary packages. Nothing in the package name or the manifest records that Expo is holding their version.
So I measured how far those 41 have drifted from npm latest.
// audit.mjs — classify the gap between Expo-pinned and npm latest for non-expo-* packagesimport { execSync } from "node:child_process";import { readFileSync } from "node:fs";const bnm = JSON.parse(readFileSync("./package/bundledNativeModules.json", "utf8"));const names = Object.keys(bnm).filter((n) => !n.startsWith("expo-") && n !== "expo");const num = (v, i) => Number(String(v).replace(/^[~^><= ]+/, "").split(".")[i]);const rows = [];for (const n of names) { let latest = null; try { latest = execSync(`npm view ${n} version`, { encoding: "utf8", timeout: 40000 }).trim(); } catch { // Fall through to unknown. Throwing here would discard every result collected so far. } if (!latest) { rows.push({ n, pinned: bnm[n], latest: "n/a", gap: "unknown" }); continue; } const gap = num(latest, 0) > num(bnm[n], 0) ? "MAJOR" : num(latest, 1) > num(bnm[n], 1) ? "minor" : latest !== String(bnm[n]).replace(/^[~^]/, "") ? "patch" : "same"; rows.push({ n, pinned: bnm[n], latest, gap });}const count = (g) => rows.filter((r) => r.gap === g).length;console.log(`managed(non expo-*): ${names.length}`);console.log( `MAJOR=${count("MAJOR")} minor=${count("minor")} ` + `patch=${count("patch")} same=${count("same")}`,);for (const g of ["MAJOR", "minor"]) { console.log(`--- ${g} ---`); for (const r of rows.filter((r) => r.gap === g)) { console.log(`${r.n}\t${r.pinned}\t${r.latest}`); }}
Persistence, gestures, randomness, WebView, crash reporting, launch screen. Every one of them fails in a way that leaves CI green. A major bump to AsyncStorage touches saved user data. A gesture handler bump touches navigation. A Sentry bump touches the very system that is supposed to tell you something broke.
The nine minor gaps are worth listing too. They will not break anything immediately, but expo install --check flags them just the same.
Package
Pinned by Expo SDK 57
npm latest
@expo/vector-icons
^15.0.2
15.1.1
@stripe/stripe-react-native
0.64.0
0.72.0
react-native-keyboard-controller
1.21.9
1.22.2
react-native-maps
1.27.2
1.29.0
react-native-worklets
0.10.1
0.11.3
react-native-safe-area-context
~5.7.0
5.8.0
sentry-expo
~7.0.0
7.2.0
@shopify/react-native-skia
2.6.2
2.10.1
@shopify/flash-list
2.0.2
2.3.2
My working assumption had been that excluding anything starting with expo- would cover most of the risk. The measurement says the opposite: filtering by name misses all six of the dangerous ones, because not one of the six begins with expo.
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Expo SDK 57 pins 123 packages, and 41 of them do not start with expo-. Every one of those 41 is measured against npm latest here: 6 major-version gaps, 9 minor
✦A complete script that derives the ignore list from the installed SDK and exits 1 on drift, with execution logs showing it is idempotent and why bumping the SDK does not change the list
✦The trap that expo itself is absent from its own bundledNativeModules.json, what breaks when you miss those three lines, plus the renovate.json that governs whatever is left
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The fix itself is trivial. List the managed packages in Renovate's ignoreDeps and it leaves them alone.
The problem is maintaining that list. You would have to extract the subset of the 123 that your app actually installs, update it every time you add a dependency, and keep six apps in agreement. Three months later it will have drifted.
So I generate it from the expo package the app already resolves, which makes it structurally impossible for the SDK and the ignore list to disagree.
// scripts/gen-renovate-ignore.mjsimport { createRequire } from "node:module";import { writeFileSync, readFileSync, existsSync } from "node:fs";const require = createRequire(import.meta.url);// Read from the expo the app actually resolves.// Hardcoding a version here becomes a lie the moment you bump the SDK.const managed = JSON.parse( readFileSync(require.resolve("expo/bundledNativeModules.json"), "utf8"),);const pkg = JSON.parse(readFileSync("./package.json", "utf8"));const installed = new Set([ ...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {}),]);// expo itself is not listed in bundledNativeModules.json.// Without this, Renovate will happily bump the SDK's major version.const ignoreDeps = [ ...new Set([ ...Object.keys(managed).filter((n) => installed.has(n)), ...(installed.has("expo") ? ["expo"] : []), ]),].sort();const configPath = "./renovate.json";const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {};const before = config.ignoreDeps ?? [];config.ignoreDeps = ignoreDeps;writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");const added = ignoreDeps.filter((n) => !before.includes(n));const dropped = before.filter((n) => !ignoreDeps.includes(n));console.log(`expo managed: ${Object.keys(managed).length} / ignored: ${ignoreDeps.length}`);for (const n of added) console.log(`+ ${n}`);for (const n of dropped) console.log(`- ${n}`);// Exit non-zero on drift so this works directly as a CI gate.process.exit(added.length + dropped.length > 0 ? 1 : 0);
Those three lines that add expo were not in my first version. I added them afterwards.
The trap: expo is absent from its own manifest
Running the first version against a test manifest produced this:
expo managed: 123 / installed & ignored: 12
Twelve. Comparing against the manifest by eye, expo itself was missing.
The reason is obvious once you see it. bundledNativeModules.json lists the surrounding packages whose versions the SDK governs. The SDK package is the thing doing the governing, not a thing being governed.
But expo is precisely the package you least want bumped. If it slides from 57 to 58 unnoticed, its agreement with all 123 surrounding packages collapses at once. Deriving the list from the manifest felt safe, and that feeling of safety was about to open the largest hole in it.
After adding the three lines:
expo managed: 123 / ignored: 13
+ expo
exit=1
Run it a second time:
expo managed: 123 / ignored: 13
exit=0
Zero exit when there is no drift. It is idempotent, so it can sit in CI without turning red on unrelated changes.
Bumping the SDK does not change the list
One more result went against my expectations.
To test it, I swapped the contents of node_modules/expo for SDK 56.0.18 and reran the same script. The managed set drops from 123 entries to 122.
expo managed: 122 / ignored: 13
exit=0
Still 13. No drift at all.
Obvious in hindsight: ignoreDeps is a set of package names, with no version ranges in it. Bumping the SDK moves 90 ranges, but names barely move. The only naming difference between 56 and 57 is expo-eas-client, which the test manifest does not install.
This changes where the check belongs. I had started sketching a step that regenerates the ignore list during each SDK upgrade. That step would almost always do nothing.
What this script actually catches is not SDK upgrades. It is forgetting to register a newly added dependency. You run npx expo install react-native-maps one week, and the next week Renovate proposes moving it from 1.27.2 to 1.29.0. This is what stops that.
So the gate goes on every PR that touches package.json, not on the upgrade workflow.
# .github/workflows/deps.ymlname: depson: pull_request: paths: - "package.json" - "package-lock.json" - "renovate.json"jobs: renovate-ignore-sync: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 # We only need expo to resolve; no native build steps are involved. - run: npm ci --ignore-scripts - name: renovate.json ignoreDeps matches the installed SDK run: node scripts/gen-renovate-ignore.mjs - name: Show the drift when it fails if: failure() run: git --no-pager diff -- renovate.json
Once exclusion is automated, the Renovate config gets easier to write rather than harder. The dangerous set removes itself, so the remainder can take straightforward rules.
ignoreDeps is left as an empty array on purpose. It marks the slot the script overwrites, and signals that it is not a field humans edit.
The two groupName: null entries are also deliberate. Runtime minor and major updates each get their own PR rather than being batched. Batching produces fewer PRs, but when behaviour shifts on a physical device you have no way to isolate which bump caused it. Anything that needs device verification does not get bundled is where I landed after running six apps in parallel.
Dev dependency patches and minors go the other way: grouped and auto-merged. If those break, CI goes red, and no human needs to read them.
How much is actually left
I ran this against a manifest close to what I use for a wallpaper app: 14 entries in dependencies and 2 in devDependencies, 16 in total.
The result was 13 of 16 under Expo's control.
Renovate was left free to manage exactly three: zustand, date-fns, and typescript.
I will admit that seeing that ratio made me stop and ask whether Renovate was worth running at all.
Two reasons it stayed.
First, those three sit outside Expo's compatibility contract. Nothing outside that contract has anyone looking after it. As it turns out, the packages that go stale most quietly were on that side all along.
Second, the Dependency Dashboard. The 13 excluded packages still appear there, listed as ignored. expo install --check tells you whether you have drifted; the dashboard tells you what you have deliberately chosen not to manage. Reading through that list twice a year has become the audit itself.
The order to roll this out
Adding this to an existing app in the wrong order buries you in pull requests on day one. This is the sequence I would recommend.
Fix the drift first. Run npx expo install --check --fix so every Expo-managed dependency matches the SDK. Skip this and you generate an ignore list around a drifted state, freezing that drift as the new baseline.
Generate the ignore list and commit it. Run node scripts/gen-renovate-ignore.mjs and commit the renovate.json it writes. The first run always exits 1, which simply means drift was found. That is expected.
Enable Renovate and watch the first batch. With prConcurrentLimit at 3, you get at most three PRs. Read all three before deciding whether to raise the limit.
Doing step 2 before step 1 locks the drift in place. Migrating six apps one at a time, I got this backwards on the very first one and had to unwind it. It is only an ordering detail, but it is one I actually got wrong.
Wrapping up
When you add automated dependency updates to an Expo-managed app, the starting point is generating the ignore list from the SDK.
Write it by hand and you will eventually miss something among the 41 packages that don't carry the expo name. In this measurement, the six that hurt most were sitting right there in those 41.
There is one command you can run against your own app right now.
npx expo install --check
Silence means you have not drifted yet. Any output is most likely drift that automation introduced. Building the ignore list can wait until after you have reverted it.
Since putting this in place, I can finally read Monday's pull requests without bracing myself. If it saves anyone running a similar setup some of that effort, I would be glad. Thank you for reading.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.