29 Ağustos 2026'da, iOS için rüya günlüğü uygulamamız Dusora'ya bir OTA (over-the-air) güncelleme yayımladık. Güncelleme, projeye iki paket eklenmeden önce derlenmiş bütün binary'lerde uygulamayı çökertti. O gün bu, App Store'daki sürüm demekti. Üç gün sonra aynısı, uyku masalları uygulamamız Moonhush'ın TestFlight build'lerinde oldu. Üstelik orada yeni paket lazy yükleniyordu, .catch'i de vardı.
İki uygulamada da paketin giriş dosyası, yüklenirken requireNativeModule çağırıyor. Binary'de o modül yoksa bu çağrı hata fırlatıyor. Hatanın yakalanıp yakalanamayacağı, import'un nerede çalıştığına bağlı. Bir modülün en üst seviyesinde, require'ı saran try/catch hatayı yakalıyor. Bir effect'in ya da olay handler'ının içinde ise onu hiçbir şey yakalamıyor, dinamik import()'un .catch'i bile: modül runtime'ı hatayı fırlatmıyor, ölümcül hata (fatal) diye bildiriyor. Artık önce binary'ye soruyoruz, yalnızca modül oradaysa import ediyoruz.
O sırada Dusora, Expo SDK 54'teydi (expo-updates 29.0.16, React Native 0.81.5), Moonhush ise SDK 57'de (expo-updates 57.0.19, React Native 0.86.3).
Expo güncellemesinin değiştirebildikleri
İki uygulama da yalnızca JavaScript'in değiştiği işleri EAS Update ile gönderiyor. Expo'nun tanımıyla güncelleme, uygulamanın JavaScript, stil ve görseller gibi native olmayan parçalarını değiştirir. Native kod, native bağımlılık ve izin değişiklikleri yeni binary ister. Uygulama güncellemeyi açılırken arar ve yalnızca kendi platformu ile runtime sürümü için yayımlanmış olanı alır.
Yani her sürümün yaşları farklı iki yarısı var: kullanıcı mağazadan en son ne zaman kurduysa o günden kalma binary ve bugün yazılmış JavaScript. Bu yüzden native kodu olan bir paketi her import edişimizde, daha eski bir binary hakkında iddiada bulunuyoruz.
İlk çökme: Dusora, 29 Ağustos 2026
Sesle kayıt ve PDF dışa aktarma yeni özelliklerdi. Bunları expo-speech-recognition ve expo-print paketleriyle yazmıştık ve ikisi için de yeni build gerektiğini not düşmüştük. Ama App Store build'lerine giden JavaScript, bu iki paketi iki dosyanın en başında import ediyordu: rüya ekleme ekranının kullandığı ses hook'unda ve veri ayarları ekranının kullandığı dışa aktarma yardımcısında. App Store'daki 1.0.0 sürümü, iki paket de projede yokken derlenmişti, yani bu import'lar orada başarılı olamazdı.
Yeni JavaScript, eski binary'de olmayan native modülleri istedi.
Çökmeyi ilk nasıl fark ettiğimizi, o güncellemenin kendi kanalında ne kadar süre en yeni güncelleme olarak kaldığını ve düzeltilmiş olanın onun yerine ne zaman geçtiğini not almadık. İki uygulamada da çökme raporlayan bir SDK yok, bu yüzden kaç kurulumun etkilendiğini bilmiyoruz.
Düzeltmeyi aynı gün commit ettik. Dosya başındaki import'ları kaldırdık, konuşma modülünü de şöyle yükledik:
// Guarded require: the native module is missing in binaries built before it
// was added, and `requireNativeModule` throws at import time — an eager import
// would crash the whole app on launch via OTA. Resolve lazily and degrade to
// "unavailable".
function loadSpeechModule(): SpeechModule | null {
if (Platform.OS === 'web') return null;
try {
// biome-ignore lint/suspicious/noExplicitAny: guarded dynamic require
return (require('expo-speech-recognition') as any)
.ExpoSpeechRecognitionModule ?? null;
} catch {
return null;
}
}
const speech = loadSpeechModule();
const isAvailable = speech?.isRecognitionAvailable?.() ?? false;
Son iki satır, hook dosyasının en üst seviyesinde çalışıyor. Paketin olay hook'undan da vazgeçmemiz gerekti, bu yüzden dinleyicileri artık speech.addListener ile bağlıyoruz. PDF dışa aktarmaya da aynı try/catch'i, dışa aktarma fonksiyonunun içine koyduk.
Aynı gün daha sonra, dosya başındaki bir expo-clipboard import'u referral ekranını açılırken çökertti. Onu da try/catch içinde bir await import()'a çevirip dokunma handler'ının içine taşıdık ve şu kuralı yazdık: yeni bir native modül yalnızca onu kullanan handler'ın içinde, try/catch ile import edilir.
İkinci çökme: Moonhush, 1 Eylül 2026
Moonhush o gün henüz yayında değildi: App Review'a ilk kez 13 Eylül 2026'da gönderdik. Yani çöken build'ler TestFlight build'leriydi. (Moonhush'ın bir masalı altmış saniye içinde nasıl ürettiğini ayrı bir yazıda anlattık.)
Telefon eğildikçe kayan, yıldızlı bir gökyüzü ekledik. İvmeölçeri expo-sensors 57.0.2'den alıyorduk. Import kurala uygundu: useEffect içinde, .catch'li, dinamik bir import(). Güncelleme eski build'leri açılışta şu mesajla çökertti:
Cannot find native module 'ExponentPedometer'
Uygulama ivmeölçeri istemişti. Hata mesajında ise adımsayarın (pedometer) adı geçiyor, çünkü paketin index dosyası bütün sensörleri import ediyor, her sensörün dosyası yüklenirken requireNativeModule çağırıyor ve ilk sırada adımsayar var.
Çökmeyi dakikalar içinde gördük: düzeltmeyi, özellikten on üç dakika sonra commit ettik. Düzeltilmiş güncellemenin ne zaman çıktığını not almadık. Düzeltme, effect'in başındaki tek satır (tx ile ty, gökyüzünün animasyonlu kayma değerleri):
useEffect(() => {
// … listener handle and tilt baseline
// Builds without the expo-sensors native module just get a static sky. The
// probe must run BEFORE the import: the package index requires every sensor
// module at scope, and a missing one throws fatally on load — a .catch on
// the import does not save the old binaries.
if (!requireOptionalNativeModule('ExponentAccelerometer')) return;
import('expo-sensors')
.then(({ Accelerometer }) => {
Accelerometer.setUpdateInterval(120);
// … subscribe and move the sky with the tilt
})
.catch(() => {});
// … cleanup
}, [tx, ty]);
requireOptionalNativeModule, expo paketinden geliyor ve requireNativeModule'ün hata fırlattığı yerde null döndürüyor.
Hatayı try/catch'in yakalayıp .catch'in yakalayamamasının sebebi
Bir Metro bundle'ı, modüllerini dosyanın başındaki küçük bir runtime üzerinden yükler. Expo CLI, bu runtime'ın kendi kopyasını kullanıyor. Henüz yüklenmemiş bir modülün her require'ı aşağıdaki fonksiyondan geçer. Fonksiyonun metro-runtime 0.83.3'te yayımlanan hâli şöyle (GitHub'daki kaynağı):
let inGuard = false;
function guardedLoadModule(moduleId, module) {
if (!inGuard && global.ErrorUtils) {
inGuard = true;
let returnValue;
try {
returnValue = loadModuleImplementation(moduleId, module);
} catch (e) {
global.ErrorUtils.reportFatalError(e);
}
inGuard = false;
return returnValue;
} else {
return loadModuleImplementation(moduleId, module);
}
}
Bir modül yüklenirken inGuard değeri true olur. O sırada çalışan bir require ikinci dala girer ve require edilen modülün fırlattığı hata, sıradan bir hata gibi çağıran koda ulaşır. Dusora'daki konuşma koruması tam bu durumda: loadSpeechModule() hook dosyasının en üst seviyesinde çağrılıyor, bu yüzden hata onun catch'ine düşer.
Yükleme bittikten sonra inGuard değeri false olur. Sonradan, yani bir effect'ten, olay handler'ından ya da zamanlayıcıdan çalışan require birinci dala girer. Runtime hatayı kendisi yakalar, ErrorUtils.reportFatalError'a verir ve undefined döndürür. Dışarıya hata fırlatılmadığı için hiçbir catch çalışmaz. React Native, ölümcül hata bildirimini geliştirme build'inde hata ekranı olarak gösterir, release build'inde ise süreci sonlandırır.
Dinamik import() de farklı değil: Expo'nun import() implementasyonu aynı require'ı senkron çağırıyor. Çağrının hemen üstünde, bölünmüş bundle'lar için yazılmış bir açıklamada aynı davranış anlatılıyor: native tarafta eksik bir modülü require etmek hata fırlatmaz, global.ErrorUtils üzerinden ölümcül hata bildirir (“On native, requiring a missing module reports a fatal error via global.ErrorUtils instead of throwing”). Promise, ölümcül hata bildirildikten sonra boş bir modülle resolve olur. Reject olmadığı için .catch'in yakalayacağı bir şey de yok.
Bunu hem Expo CLI'ın kopyasında (iki uygulamanın bundle'ına giren runtime bu) hem de metro-runtime 0.83.3 ve 0.84.5'te okuduk. Her dosyayı, hata fırlatan bir modülle tek başına da çalıştırdık ve hepsi anlattığımız gibi davrandı. Eski bir binary'yi yeniden derlemedik. Cihazlardan gelen tek kanıt, bu iki çökme.
Yani ilk düzeltme, o gün anlamadığımız bir sebeple tuttu. O çökmeyi durduran, dosya başındaki import'ların kalkmasıydı. Konuşma koruması da modülün en üst seviyesinde çalıştığı için işe yarıyor.
PDF korumasına ise bir dokunuşla ulaşılıyor ve orada catch'ine hiçbir şey düşmez: 1.0.0 binary'sinde o dokunuş ölümcül hatayla sonuçlanırdı. Bunu eski bir binary'de hiç denemedik. O gün yazdığımız kural handler'ı gösteriyordu, oysa yükleme hatasının yakalanamadığı yer tam da handler.
Bu korumalar Dusora'da hâlâ yazdığımız gibi duruyor. 13 Eylül 2026'da gönderdiğimiz 1.1.1 sürümünden beri her binary bu modülleri içeriyor, yani o binary'lerde korumalara iş düşmüyor.
Çökerten güncellemeyi almış telefonda olanlar
Hatalı güncellemeyi almış bir telefonda devreye expo-updates'in hata kurtarma mekanizması girer. Bir güncelleme ilk açılışında, ekrana henüz hiçbir şey gelmeden ölümcül hata fırlatırsa kütüphane onu o cihazda başarısız sayar. Daha yeni bir güncelleme için beş saniyeye kadar bekler, gelmezse en son sorunsuz açılan güncellemeye döner.
Hata ilk render'dan sonraki on saniye içinde gelirse kütüphane daha yeni bir güncellemeyi indirmeyi dener ve uygulamanın çökmesine engel olmaz. İnen güncelleme bir sonraki açılışta çalışır. Daha geç gelen ölümcül hata ise kurtarma akışının tamamen dışında kalır: uygulama çöker, telefon da düzeltmeyi sonraki açılışlardan birinde, olağan güncelleme kontrolüyle alır. Etkilenen telefonların ne yaptığını not almadık. Yani bu anlattığımız, gözlemimiz değil, belgelerdeki akış.
Güncellemenin ulaştığı binary'ler
İki uygulama da runtimeVersion için appVersion politikasını kullanıyor. Yani runtime sürümü, app.json'daki uygulama sürümü.
Sürümü yükseltince yayındaki binary'lerle bağ kopuyor. Repo'daki sürüm numarası bir sonraki mağaza sürümü için yükseltildikten sonra, o hâliyle yayımlanan güncelleme yeni runtime'a gider. Kullanıcıların elindeki sürüme ulaşmak için sürüm numarasını yalnızca yayın sırasında, çalışma ağacında eskisine çekiyor, sonra geri alıyoruz. Mağaza build'inin alındığı commit'ten yayımlamak işe yaramaz, çünkü orada eski JavaScript var. Ekim 2026'da Dusora repo'sunda çalışan yapay zekâ agent'ı, önce eski sürüme hiç ulaşılamayacağını söyledi. Yeniden istedik ve kanalın geçmişinde, bir gün önce o sürüm için yayımlanmış bir güncelleme çıktı.
Güncelleme çıkmadan önce baktıklarımız
Yalnızca JavaScript'in değiştiği düzeltmeleri hâlâ OTA ile gönderiyoruz. Lazy import, bir paketin ne zaman yükleneceğini belirler; yüklemenin güvenli olup olmadığını belirlemez. Yayımlamadan önce baktıklarımız:
- JavaScript, yayındaki bir binary'de native modülü bulunmayan bir paketi yüklüyor mu? Yüklüyorsa önce yoklama (
requireOptionalNativeModule, Expo modülü olmayanlardaNativeModules.<Name>), sonra import. - Modülü içermeyen bir binary'ye yüklenip denendi mi? Eski pod'larla build alıyoruz (
expo run:ios --no-install), çünkü güncel bir geliştirme build'inde modül her zaman vardır. İkinci çökmenin ardından Moonhush'ın bir sonraki native bağımlılığını, aynı gün öğleden sonra, önüne yoklama koyarak ekledik. Eski pod'lu build'de onu kullanan düğme gizli kaldı. - Değişiklik, bu soruyu hiç gündeme getirmeden yapılabilir mi? Moonhush'ın sonraki bir tasarım belgesinde bu, bağlayıcı ilkelerden biri: “OTA'ya uygun. Yeni bağımlılık yok,
package.json'da olmayan hiçbir native modül eklenmez.” - Hangi runtime sürümü için yayımlanıyor, kanal oraya en son ne gönderdi?
- Fingerprint, yayındaki build'den sürüm dışında bir şeyde ayrılıyor mu? Sürüm de expo-updates 29.0.16'da hash'in parçası. Bu yüzden iki hash'i değil,
expo-updates fingerprint:generateçıktısındaki iki kaynak listesini karşılaştırın. - Geri dönüş yolu yazılı mı? Dusora'nın changelog'unda, son güncellemelerin kayıtlarında onları geri alan
eas update:republishkomutu da yazıyor.
Bu kontrollerin hiçbirini bir araç zorunlu kılmıyor. appVersion politikasında, yeni native bağımlılık getiren bir güncelleme eski binary'lere yine ulaşır. Expo'nun fingerprint politikası ise native runtime'ı etkileyebilecek herhangi bir şey değiştiğinde runtime sürümünü de değiştiriyor. Bu tanıma göre öyle bir güncellemeyi eski binary'lerden uzak tutar. İki uygulama da hâlâ appVersion kullanıyor.