Projects included: - maerchenzauber (NestJS backend + Expo mobile + SvelteKit web + Astro landing) - manacore (Expo mobile + SvelteKit web + Astro landing) - manadeck (NestJS backend + Expo mobile + SvelteKit web) - memoro (Expo mobile + SvelteKit web + Astro landing) This commit preserves the current state before monorepo restructuring. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
18 KiB
iOS Deeplink Crash Fix - Complete Debugging Journey
Date: 2025-11-02 Platform: iOS 26.0.1 (iPhone 13) App: Märchenzauber v1.1.0 Issue: App crashes when opening character sharing deeplinks
Executive Summary
The app was crashing when users opened deeplinks to shared characters (maerchenzauber://share/character/{id}). What appeared to be a single issue was actually 5 distinct crashes that occurred sequentially during navigation. This document details the complete debugging journey, dead ends, and solutions.
The Problem
Initial Symptoms
- App crashes ~1 second after opening a character sharing deeplink
- Only happens on physical iOS devices (not simulator)
- User sees "Opening character..." loading screen, then crash
- No obvious error message in logs before crash
Deeplink Flow
1. User clicks: maerchenzauber://share/character/{id}
2. App opens: app/share/character/[id].tsx
3. Should redirect to: app/character/preview/[characterId].tsx
4. Should display: Character preview screen
5. Actually: ❌ CRASH
The Debugging Journey
Crash #1: LinearGradient Rendering (FALSE LEAD)
Symptoms:
Thread 0 (Main):
RNSScreen setViewToSnapshot + 1416
UIKit snapshotViewAfterScreenUpdates + 148
Initial Diagnosis: ❌ WRONG Misdiagnosed as LinearGradient rendering issue based on Stack Overflow comment.
Action Taken:
- Updated
expo-linear-gradientfrom 14.0.1 → 15.0.7 - Added explicit layout bounds to all LinearGradient components
- Added
minHeight: 100to login screen gradient
Result: Crash still occurred (but we fixed compatibility issues)
Learning: Don't trust Stack Overflow comments blindly - analyze the actual crash stack trace.
Crash #2: CoreGraphics Image Deallocation
Symptoms:
Thread 0 (Main):
CoreGraphics vm_allocator_deallocate + 44
CoreGraphics image_finalize + 60
QuartzCore CA::Layer::~Layer() + 328
Diagnosis: ✅ CORRECT Image loading race condition during rapid navigation - images being deallocated while CoreGraphics still rendering them.
Root Cause:
<Image
source={{ uri: currentImage }}
transition={300} // ❌ Transition conflicts with navigation
/>
Solution:
<Image
source={{ uri: currentImage }}
transition={0} // ✅ Disable transitions
recyclingKey={characterId} // ✅ Help expo-image reuse instances
cachePolicy="memory-disk" // ✅ Proper caching
/>
Files Modified:
app/character/preview/[characterId].tsx:360
Result: Fixed image crashes, but navigation still crashed.
Crash #3: RNSScreen Snapshot on Share Route
Symptoms:
Thread 0 (Main):
RNSScreen setViewToSnapshot + 1416
react-native-screens snapshot creation
Diagnosis: ✅ CORRECT
The /share/ route wasn't inheriting the root Stack configuration that disables snapshots.
Root Cause:
app/
├── _layout.tsx // Has freezeOnBlur: false
└── share/
├── _layout.tsx // ❌ MISSING - needs its own config
└── character/
└── [id].tsx
Solution 1: Created dedicated Stack layout for share route
// app/share/_layout.tsx
export default function ShareLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
animation: Platform.OS === 'ios' ? 'none' : 'default',
freezeOnBlur: false, // ✅ Prevent snapshots
presentation: 'transparentModal', // ✅ Bypass native screens
}}
/>
);
}
Solution 2: Bypassed screen rendering entirely
// app/share/character/[id].tsx - BEFORE
export default function ShareCharacterDeeplink() {
const [loading, setLoading] = useState(true);
return loading ? <LoadingScreen /> : <Redirect />; // ❌ Creates snapshot
}
// AFTER
export default function ShareCharacterDeeplink() {
return <Redirect href={`/character/preview/${id}`} />; // ✅ No snapshot
}
Files Created/Modified:
- Created:
app/share/_layout.tsx - Modified:
app/share/character/[id].tsx
Result: Snapshot crashes resolved, but app still crashed!
Crash #4: THE ACTUAL LinearGradient Crash
Symptoms:
Thread 0 (Main):
5 CoreGraphics CGContextDrawLinearGradient + 236
6 Mrchenzauber LinearGradientLayer.draw(in:) + 932
7 Mrchenzauber LinearGradientLayer.display() + 420
Diagnosis: ✅ CORRECT (Finally!)
LinearGradient components using width: '100%' had undefined dimensions during navigation before parent layout was fully calculated.
Root Cause:
// During navigation, this sequence happens:
1. Component mounts
2. LinearGradient tries to render
3. Parent layout hasn't resolved yet
4. width: '100%' = undefined/0/NaN
5. CoreGraphics crashes trying to render gradient with invalid dimensions
Why This Happens During Deeplinks:
- Deeplink navigation bypasses normal React Native navigation lifecycle
- Components mount before layout calculations complete
- Normal navigation has timing that masks this issue
Solution Pattern:
// ❌ BEFORE - Crash during navigation
const styles = StyleSheet.create({
gradientStyle: {
width: '100%', // Can be undefined during layout
justifyContent: 'center',
}
});
// ✅ AFTER - Safe during navigation
const styles = StyleSheet.create({
gradientStyle: {
alignSelf: 'stretch', // More reliable than width: '100%'
minWidth: 200, // Ensures CoreGraphics has valid dimensions
justifyContent: 'center',
}
});
Files Fixed (3 components):
-
app/login.tsx:1141 -
manaInfoBannermanaInfoBanner: { alignSelf: 'stretch', // Changed from width: '100%' minWidth: 200, minHeight: 100, }Also removed
locations={[0, 0.7, 1]}prop for simpler gradient distribution. -
components/story/EndScreen.tsx:280 -
buttonGradientbuttonGradient: { alignSelf: 'stretch', // Changed from width: '100%' minWidth: 200, } -
components/molecules/PremiumCuddlyToyCard.tsx:119 -
gradientBackgroundgradientBackground: { alignSelf: 'stretch', // Changed from width: '100%' minWidth: 200, }
Result: CoreGraphics crashes resolved!
Crash #5: MagicalLoadingScreen Missing Prop
Symptoms:
ERROR [TypeError: Cannot convert undefined value to object]
Code: MagicalLoadingScreen.tsx:173
{phrases[currentPhrase]}
Diagnosis: ✅ CORRECT
Simple prop missing - MagicalLoadingScreen requires context prop to determine which phrases to show.
Root Cause:
// app/character/preview/[characterId].tsx:315
return <MagicalLoadingScreen />; // ❌ Missing context prop
// This caused:
const phrases = magicalPhrases[undefined]; // undefined
phrases[currentPhrase] // ❌ Cannot convert undefined to object
Solution:
return <MagicalLoadingScreen context="character" />; // ✅ Fixed
Files Modified:
app/character/preview/[characterId].tsx:315
Result: All crashes resolved! ✅
Crash #6: TimeOfDayBackground LinearGradient (Universal Links)
Date: 2025-11-04 Context: Universal links implementation for character sharing
Symptoms:
Thread 0 (Main):
9 Mrchenzauber LinearGradientLayer.display() + 64 ← CRASH
3 CoreGraphics ripc_DrawShading + 6864
Diagnosis: ✅ CORRECT
Another LinearGradient component with width: '100%' and height: '100%' causing the same crash pattern as Crash #4, but this time in a different component during app startup.
Root Cause:
// components/atoms/TimeOfDayBackground.tsx:66-74
gradient: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%', // ❌ Caused crash during navigation
height: '100%', // ❌ Caused crash during navigation
}
Why This Crashed:
When a component has position: 'absolute' with all four edges defined (top, left, right, bottom), the width and height properties are redundant. During rapid navigation or startup, these percentage-based dimensions can be undefined/0/NaN before parent layout is calculated, causing CoreGraphics to crash.
Solution:
// components/atoms/TimeOfDayBackground.tsx:66-74
gradient: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
// Removed width/height - absolute positioning with all edges defined
// is more reliable and prevents CoreGraphics crashes during navigation
}
Files Modified:
components/atoms/TimeOfDayBackground.tsx:66-74
Result: App startup crashes resolved! ✅
Learning: When absolutely positioning an element with all edges defined, NEVER add width or height properties. The layout system automatically calculates dimensions from the edge constraints.
Issue #7: Expo Router Duplicate Screen Names
Date: 2025-11-04 Context: Universal links route configuration
Symptoms:
ERROR: A navigator cannot contain multiple 'Screen' components
with the same name (found duplicate screen named 'character/[id]')
Diagnosis: ✅ CORRECT Expo-router was finding duplicate route definitions due to having both a file and a directory with the same dynamic segment name.
Root Cause:
app/
├── character/
│ ├── [id].tsx ← Screen: character/[id]
│ └── [id]/ ← CONFLICT! Also creates character/[id]
│ └── [shareCode].tsx
And similarly:
app/share/character/
├── [id].tsx ← Screen: share/character/[id]
└── [id]/ ← CONFLICT! Also creates share/character/[id]
└── [shareCode].tsx
Why This Happens: Expo-router generates screen names from file paths. When you have both:
- A file:
character/[id].tsx - A directory:
character/[id]/
Both register as the same screen name character/[id], causing a conflict.
Solution: Remove the unnecessary file routes and keep only the nested routes:
app/
├── character/
│ └── [id].tsx ← Character detail screen (kept)
└── share/character/
└── [id]/ ← Directory for deep link routes
├── _layout.tsx ← Crash prevention layout
└── [shareCode].tsx ← Deep link handler (kept)
Files Removed:
- ✅ Deleted:
app/character/[id]/[shareCode].tsx(duplicate) - ✅ Deleted:
app/share/character/[id].tsx(unnecessary)
Important: After removing route files, you MUST clear Metro cache:
rm -rf .expo node_modules/.cache
watchman watch-del-all
npx expo start --clear
Result: Route conflicts resolved! ✅
Learning:
- Never have both a file and directory with the same dynamic segment name
- Metro aggressively caches route configuration - always clear cache after route changes
- Use
watchman watch-del-allto clear file system watch caches
Summary of Solutions
| Issue | Root Cause | Solution | Files |
|---|---|---|---|
| Image crashes | Transition animation conflicts with navigation | Disable transitions, add recycling keys | character/preview/[characterId].tsx |
| Snapshot crashes | Share route missing Stack config | Created share/_layout.tsx with snapshot prevention |
share/_layout.tsx (new) |
| Navigation crashes | Loading screen triggers snapshot creation | Use immediate <Redirect> instead |
share/character/[id].tsx |
| LinearGradient crashes | width: '100%' undefined during layout |
Use alignSelf: 'stretch' + minWidth |
login.tsx, EndScreen.tsx, PremiumCuddlyToyCard.tsx |
| Loading screen crash | Missing required prop | Add context="character" prop |
character/preview/[characterId].tsx |
| TimeOfDayBackground crash | width/height: '100%' on absolute positioned gradient |
Remove width/height, use edge constraints only | TimeOfDayBackground.tsx |
| Route naming conflict | Duplicate screen names in expo-router | Remove conflicting file routes, clear Metro cache | Route structure cleanup |
Key Technical Learnings
1. React Native Navigation Lifecycle
- Deeplinks bypass normal navigation timing
- Components can mount before layout calculations complete
- Always ensure components handle undefined dimensions gracefully
2. expo-router File-Based Routing
- Each directory can have its own
_layout.tsx - Child layouts don't automatically inherit parent Stack settings
- Use
freezeOnBlur: falseto prevent snapshot crashes
3. react-native-screens Snapshots
- iOS creates snapshots during screen transitions for animations
- Snapshots can fail when:
- Views have complex gradients/blurs during rapid navigation
- Layout isn't fully resolved
- Images are loading/unloading
- Solutions:
freezeOnBlur: false- Disable freezinganimation: 'none'- Disable animationspresentation: 'transparentModal'- Bypass native screens- Use
<Redirect>to avoid rendering intermediate screens
4. expo-linear-gradient Rendering
width: '100%'can be undefined during navigationalignSelf: 'stretch'is more reliable- Always provide minimum dimensions for CoreGraphics:
minWidth: 200, // Ensures valid render bounds minHeight: 100 - Avoid
locationsprop unless necessary - simpler is safer
5. expo-image Best Practices for Navigation
<Image
source={{ uri }}
transition={0} // Disable for rapid navigation
recyclingKey={uniqueId} // Help expo-image reuse instances
cachePolicy="memory-disk" // Proper caching
/>
6. Debugging Complex Issues
- One symptom can have multiple root causes
- Fix each issue, test, document what changed
- Don't assume first diagnosis is correct
- Stack traces show where it crashes, not always why
Testing Checklist
After implementing fixes, test:
- Cold start deeplink (app not running)
- Warm start deeplink (app in background)
- Multiple deeplinks in succession
- Slow network conditions
- Physical iOS device (not just simulator)
- Different iOS versions if possible
Prevention for Future Development
Code Review Guidelines
-
Never use
width: '100%'with LinearGradient// ❌ AVOID <LinearGradient style={{ width: '100%' }} /> // ✅ USE <LinearGradient style={{ alignSelf: 'stretch', minWidth: 200 }} /> -
Never use width/height on absolute positioned elements with all edges defined
// ❌ AVOID style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, width: '100%', // Redundant and can cause crashes height: '100%', // Redundant and can cause crashes }} // ✅ USE style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, // Width/height automatically calculated from edges }} -
Always provide context prop to MagicalLoadingScreen
// ❌ AVOID <MagicalLoadingScreen /> // ✅ USE <MagicalLoadingScreen context="character" /> -
Disable image transitions for navigation screens
// ❌ RISKY <Image transition={300} /> // ✅ SAFE <Image transition={0} recyclingKey={id} /> -
Configure Stack settings in every _layout.tsx
export default function Layout() { return ( <Stack screenOptions={{ freezeOnBlur: false, // Prevent snapshots animation: Platform.OS === 'ios' ? 'default' : 'slide_from_right', }} /> ); } -
Avoid expo-router file/directory naming conflicts
// ❌ AVOID - Creates duplicate screens app/ ├── character/ │ ├── [id].tsx ← Screen: character/[id] │ └── [id]/ ← CONFLICT: Also character/[id] │ └── detail.tsx // ✅ USE - Clear separation app/ ├── character/ │ └── [id].tsx ← Screen: character/[id] └── character-detail/ └── [id]/ ← Screen: character-detail/[id] └── detail.tsx -
Always clear Metro cache after route changes
# After adding/removing route files: rm -rf .expo node_modules/.cache watchman watch-del-all npx expo start --clear
Automated Testing
Add E2E test for deeplink navigation:
describe('Deeplink Navigation', () => {
it('should open character preview without crashing', async () => {
await device.launchApp({
url: 'maerchenzauber://share/character/test-id'
});
await expect(element(by.id('character-preview'))).toBeVisible();
});
});
Related Files
Modified Files
app/_layout.tsx- Navigation timeout increasedapp/login.tsx- LinearGradient dimensions fixedapp/character/preview/[characterId].tsx- Image transitions disabled, MagicalLoadingScreen prop addedcomponents/story/EndScreen.tsx- LinearGradient dimensions fixedcomponents/molecules/PremiumCuddlyToyCard.tsx- LinearGradient dimensions fixedpackage.json- Updated expo-linear-gradient to 15.0.7
Created Files
app/share/_layout.tsx- Stack configuration for share routesdocs/DEEPLINK_CRASH_FIX.md- This document
References
- expo-router Documentation
- react-native-screens Stack Options
- expo-linear-gradient Documentation
- expo-image Transitions
Conclusion
What seemed like a single crash was actually 7 distinct issues across two debugging sessions:
Session 1 (2025-11-02):
- Image transition race conditions
- Missing Stack layout configuration
- Snapshot creation during intermediate screens
- LinearGradient rendering with undefined dimensions
- Missing required component prop
Session 2 (2025-11-04 - Universal Links): 6. TimeOfDayBackground LinearGradient crash (same pattern as #4) 7. Expo-router duplicate screen names
The key to solving this was:
- Methodical testing after each fix
- Documenting each crash with stack traces
- Not assuming the first diagnosis was correct
- Understanding the React Native/iOS rendering pipeline
- Aggressive cache clearing when changing routes
Total time: ~6 hours of debugging (across 2 sessions) Total issues fixed: 7 Final result: ✅ Stable deeplink navigation on iOS + Universal links working