DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our only patched dependency spells its version in four places by hand, and the install checks three of them

Munchable's label camera has tap to focus. You point the phone at an ingredients panel, the autofocus hunts, you tap the words and it locks on them for a few seconds before handing back to continuous focus.

expo-camera does not ship that. So the repo carries a patch, and the whole of it is two lines of config:

# pnpm-workspace.yaml
patchedDependencies:
  expo-camera@57.0.5: patches/expo-camera@57.0.5.patch
Enter fullscreen mode Exit fullscreen mode

This is a post about those two lines rather than about the camera, because today I fixed a bug in them that no tool in the repository could have found.

What is in a 221-line patch

Ten files:

android/src/main/java/expo/modules/camera/CameraViewModule.kt
android/src/main/java/expo/modules/camera/ExpoCameraView.kt
ios/CameraViewModule.swift
ios/Current/CameraSessionManager.swift
ios/Current/CameraView.swift
src/Camera.types.ts
src/CameraView.tsx
build/Camera.types.d.ts
build/CameraView.d.ts
build/CameraView.js
Enter fullscreen mode Exit fullscreen mode

121 added lines. The Kotlin side builds a metering action from the touch point and lets the preview's own point factory undo whatever the cover-fit crop did to the coordinates. The Swift side does the equivalent. Then the same method has to exist in JavaScript, and that is where patching a published package gets interesting, because an Expo module ships compiled output:

  • src/CameraView.tsx is what a person reads
  • build/CameraView.js is what Metro actually loads
  • build/CameraView.d.ts is what TypeScript believes

All three have to be in the patch. Edit only the source and the method does not exist at runtime. Edit only the build output and your own call site is a type error. This is the part people do not expect from "just patch the package": you are editing a build artefact, by hand, in a diff.

Why a patch and not a fork

A fork means publishing a package and owning its release cadence against an SDK that moves every few weeks. A config plugin cannot add a method to a view manager. A separate native module means re-implementing enough of the camera to own a preview.

The patch, by contrast, is reproducible from the lockfile, which records it by content:

# pnpm-lock.yaml
patchedDependencies:
  expo-camera@57.0.5: d3f519a89e5ff8270e755eb5497f063d085496b31906fa63a44704e6aa67275b
Enter fullscreen mode Exit fullscreen mode

And that hash is not just a note. It is part of the package's identity in the resolution graph:

expo-camera@57.0.5(patch_hash=d3f519a8...)(expo@57.0.24)(react-native@0.86.3)...
Enter fullscreen mode Exit fullscreen mode

Change one line of the patch and the dependency becomes a different dependency, so every machine either has the patched copy or re-fetches and re-patches. That property is the whole argument for this approach. A patch that can silently not apply would be worse than a fork.

The version number lives in four hand-written places

Count them:

  1. apps/mobile/package.json, as the dependency range: "expo-camera": "~57.0.5"
  2. pnpm-workspace.yaml, as the patchedDependencies key: expo-camera@57.0.5
  3. The patch filename itself: patches/expo-camera@57.0.5.patch
  4. A comment in apps/mobile/app/capture.tsx explaining where focusAtAsync comes from

Plus two more that pnpm generates in the lockfile, which do not count because nobody types them.

Three of those four are load-bearing, and the install is loud about all three. I tested the second one in a throwaway project rather than taking pnpm's documentation on faith: a key naming a version that is not installed, with everything else correct.

[ERR_PNPM_UNUSED_PATCH] The following patches were not used: is-odd@3.0.0

Either remove them from "patchedDependencies" or update them to match
packages in your dependencies.
Enter fullscreen mode Exit fullscreen mode

Install fails. Not a warning, not a quiet skip, a non-zero exit, which is exactly what you want from a patch that adds a native method the app calls unconditionally. The filename is checked the same way: name a file that is not there and the install stops.

The fourth place is the comment, and nothing on earth checks a comment. When the dependency moved from 57.0.4 to 57.0.5, items one, two and three had to change together or the install would not complete. Item four did not have to change, so it did not, and for a while the file explaining the patch pointed at patches/expo-camera@57.0.4.patch, a path that does not exist, in the one place a new reader would go looking.

That was today's fix. One character.

The fix I did not make

The obvious response is to stop writing the version in the key. pnpm accepts a semver range, or the bare package name, and I checked both work:

patchedDependencies:
  is-odd@^3.0.0: patches/is-odd@3.0.0.patch   # applies to 3.0.1
  is-odd: patches/is-odd@3.0.0.patch          # also applies
Enter fullscreen mode Exit fullscreen mode

Tempting, and wrong for this patch. An exact pin means the next expo-camera release stops the install until a human re-reads 221 lines of native diff against the new upstream code. A range means a patch written for 57.0.5 applies itself to 57.1.0 as long as the context lines still match, and "the context lines still match" is not the same statement as "the camera still focuses". For a patch made of Kotlin, Swift and a compiled JavaScript file, I want the interruption.

So the pin stays, and the conclusion goes the other way: the version number should not be in the comment at all. The comment should say that a patch exists and that pnpm-workspace.yaml names it, because that file cannot be stale. A fact with no checker, written in prose, is not documentation. It is a claim about the repository that only decays.

If you want to see what those 121 lines bought, you need the app rather than the website: munchable.app has the install link, and the tap to focus is on the label camera, the screen you reach by scanning a barcode we do not hold yet. If you want the words instead, the about page says what the camera is for.

Top comments (0)