') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); diagnostic: disable expo-updates + bump to 1.0.7 to surface real launch error by CraigBuckmaster · Pull Request #1516 · CraigBuckmaster/ScriptureDeepDive · GitHub
Skip to content

diagnostic: disable expo-updates + bump to 1.0.7 to surface real launch error - #1516

Merged
CraigBuckmaster merged 1 commit into
masterfrom
diagnostic/disable-updates-surface-crash
Apr 17, 2026
Merged

diagnostic: disable expo-updates + bump to 1.0.7 to surface real launch error#1516
CraigBuckmaster merged 1 commit into
masterfrom
diagnostic/disable-updates-surface-crash

Conversation

@CraigBuckmaster

Copy link
Copy Markdown
Owner

Why

TestFlight build 1.0.6(15) — the first build with PR #1514's iOS 26 TurboModule fix merged — launches, then crashes ~0.8s later. Unlike the original crash (which was in ObjCTurboModule::performVoidMethodInvocation), this one fires inside expo-updates' deliberate recovery-pipeline abort:

StartupProcedure.throwException(_:) (ErrorRecovery.swift:277)
← ErrorRecovery.crash()
← ErrorRecovery.runNextTask() (ErrorRecovery.swift:190)
← closure in ErrorRecovery.notify(newRemoteLoadStatus:) (ErrorRecovery.swift:149)

Reading the source: expo-updates runs waitForRemoteUpdate → launchCached → crash when it catches an initial error. On a fresh install where the remote server has nothing matching and the cache is empty, the pipeline blows straight through all three steps. The crash is not the bug — it's the symptom. Something else throws the initial error into expo-updates, and then expo-updates abends the app deliberately because it can't recover.

Ruled out

Stale OTA bundle mismatch. The EAS Updates dashboard shows all recent published updates tagged runtime 1.0.0. Our binary is runtime 1.0.6. A runtime-version mismatch means the server returns "no update" to the device — it never attempts to load a bundle — so a bad bundle can't be the trigger.

The plan

Temporarily disable expo-updates. This removes the ErrorRecovery pipeline from the startup path entirely. Whatever throws the initial error is no longer caught and converted into a deliberate abort() — it either propagates into a more informative crash signature or the app launches successfully.

Three possible outcomes on the next TestFlight build:

  1. App reaches the home screen → expo-updates + its recovery machinery was the proximate cause. Re-enable updates in a follow-up PR after publishing a fresh production bundle for the new runtime version.
  2. App crashes with a completely different signature → the real bug is exposed in that new stack trace. Debug it directly.
  3. Identical ErrorRecovery.crash() signature → would be very surprising since that code path requires enabled: true to activate. Would force a close reading of config.

Changes

Exactly two edits in app/app.json:

FieldBeforeAfterWhy
version1.0.61.0.7TestFlight build distinctness + invalidates runtime namespace so a stray publish can't land a bundle on the diagnostic binary
updates.enabledtruefalseRemoves expo-updates from startup path entirely

Everything else untouched — updates.url preserved, project ID preserved, runtime policy unchanged, runtimeVersion: { policy: "appVersion" } stays.

Precedent

Commit 9f33448c did exactly this ("fix: disable expo-updates to surface real launch crash + bump to 1.0.3") during an earlier crash investigation. Same diagnostic move; clean pattern in the repo.

Post-merge

  1. eas build --platform ios --profile production — source compile of React-Core is still in play via the #1514 patch
  2. Submit to TestFlight
  3. Crash log or home screen — report back here

Rollback

git revert + rebuild. Harmless; only restores the old config.

Follow-up (out of scope for this PR)

Once the real bug is fixed and the app launches, a follow-up PR should:

  • Re-enable updates.enabled: true
  • Run eas update --branch production to publish a bundle against runtime 1.0.7
  • Verify on TestFlight

I'll open a cleanup reminder issue tracking that.

…ch error
Build 15 TestFlight crashed ~0.8s after launch inside
expo-updates' ErrorRecovery.crash() (ErrorRecovery.swift:277).
The iOS 26 TurboModule fix from #1514 worked — the old crash
signature is gone — but ErrorRecovery is now firing on a
completely different path.
The Updates dashboard shows zero published OTA bundles at
runtime 1.0.6 (all recent updates are runtime 1.0.0), so the
binary isn't being served a stale mismatched bundle. That
means the original error feeding into the recovery pipeline
is somewhere else entirely — most likely the embedded JS
bundle or early app init (DB download screen, etc.).
Two changes:
1. updates.enabled: false — disables expo-updates entirely
on this build, which removes the ErrorRecovery pipeline
from the startup path. If the app reaches the home screen,
expo-updates was the proximate cause. If it still crashes,
the underlying error is now visible in the new crash log
without the recovery machinery masking it.
2. version 1.0.6 → 1.0.7 — keeps TestFlight builds distinct
and invalidates the runtime namespace so no accidentally-
published future OTA can land on this binary during
diagnostic testing. Harmless either way.
This mirrors the pattern from commit 9f33448 (fix: disable
expo-updates to surface real launch crash + bump to 1.0.3),
which was used for the same reason during a previous crash
investigation.
Once the underlying cause is fixed, expo-updates should be
re-enabled and a production bundle published for the new
runtime version.
@github-actions

Copy link
Copy Markdown

Test Results

✅ All tests passed

PassedFailedTotal
Tests✅ 3428❌ 03428
Suites✅ 465❌ 0465

Coverage

StatementsBranchesFunctionsLines

⏱️ Duration: 81.1s

@CraigBuckmaster
CraigBuckmaster merged commit 78741cf into masterApr 17, 2026
6 checks passed
@CraigBuckmaster
CraigBuckmaster deleted the diagnostic/disable-updates-surface-crash branch April 17, 2026 23:44
CraigBuckmaster pushed a commit that referenced this pull request Apr 18, 2026
…eption)
## Root cause
Build 1.0.6(15) and 1.0.6(16) crashed on TestFlight ~13s after launch with a
signature that landed inside our own patched RCTTurboModule.mm:446 — i.e.
the iOS 26 patch from #1514 was doing its job re-throwing the NSException
cleanly, but something was still throwing an NSException from a void sync
TurboModule call that nothing up the GCD queue catches.
Stack traced to ContentUpdater.downloadWithProgress:
destFile.create({ overwrite: true });
destFile.write(bytes); // ~90 MB Uint8Array, sync Expo Modules Function
expo-file-system's File#write is exposed as a synchronous `Function`
(not AsyncFunction) on iOS. When invoked with a ~90 MB buffer, any
internal Swift error Data(bytes:count:).write(to:) raises — sandbox
scoped-access, disk pressure, TypedArray marshalling, etc. — surfaces as
an NSException via ObjCTurboModule::performVoidMethodInvocation. With
the #1514 patch in place the exception propagates cleanly through the
dispatch queue, but there is no @catch up the stack so the process
aborts instead of giving us a user-visible error.
The ContentUpdater docstring notes the 1.0.5 build hit exactly the same
crash signature against the legacy expo-file-system shim. Moving to the
new File API removed the shim but did not remove the single-shot sync
write pattern — same risk surface, same outcome.
## Fix
Replace the single destFile.write(bytes) call with a chunked write
through a FileHandle (destFile.open() → handle.writeBytes(chunk) loop →
handle.close() in finally), 1 MiB per chunk. This keeps each TurboModule
invocation in a size range that native file-system ops routinely handle.
Wrap the full write path in a try/catch so any failure becomes a promise
rejection surfaced by DbDownloadScreen's error UI as 'File write failed
after N bytes received: <message>' — no more silent process aborts, and
if this theory turns out to be wrong the next build will at least give
us the real error string.
## Tests
Extended the MockFile jest stub with open()/writeBytes/handleClose
tracking. Added three regression tests:
- New path uses FileHandle, not File#write
- 2.5 MiB payload splits into 3 chunks (1 MiB, 1 MiB, 0.5 MiB)
- Handle closes even when writeBytes throws; error surfaces cleanly
All 35 ContentUpdater tests pass. Full suite: 465 suites / 3431 tests
pass, zero TS errors, zero new lint errors.
## Scope
ContentUpdater.ts + its test file only. No app.json / eas.json / native
changes. updates.enabled stays false from #1516. Version stays 1.0.7 per
Craig's call — EAS autoIncrement handles the build number.
## References
- PR #1514 (iOS 26 TurboModule patch — successfully shipped)
- PR #1516 (diagnostic disable updates — shipped, same crash signature)
- react/react-native#54859 (TurboModule void-method NSException)
- SDK 54 expo-file-system File vs FileHandle API
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@CraigBuckmaster