iOS WebKit Silently Kills IntersectionObservers After visibility:hidden
The Symptom
Browse manga list, scroll to load more pages (works). Open a manga. Open the reader. Go back to the list. Scroll down. Nothing loads. The sentinel div is in the DOM, the observer is connected, hasMore is true, isLoading is false. Everything looks correct. But onIntersect never fires again.
Desktop Chrome: fine. iOS Safari PWA: broken every time after the round-trip.
The Architecture
The app uses CSS visibility: hidden to hide inactive views during transitions. When you open a manga, the list view gets visibility: hidden. When you enter the reader, the manga view also gets hidden. When you navigate back, visibility is restored.
The sentinel uses an IntersectionObserver with the list’s scroll container as its root:
observer = new IntersectionObserver(callback, {
root: scrollContainer,
rootMargin: '500%',
});
observer.observe(sentinelDiv);
The sentinel Svelte action only recreated the observer when disabled (tied to isLoading) toggled from true to false. If the observer already existed and wasn’t disabled, update() was a no-op.
The Root Cause
iOS WebKit has an undocumented behavior: when an IntersectionObserver’s root element has visibility: hidden applied to it (or an ancestor), the observer can be permanently broken. It stays connected — observer.disconnect() doesn’t throw, observer.takeRecords() returns empty — but it silently stops delivering callbacks. Forever.
The lifecycle that triggers it:
- List view visible, sentinel observer works fine
- Navigate to manga view — list gets
visibility: hidden - Navigate to reader — manga view also hidden
- Navigate back to list — visibility restored
- Observer is still connected but dead
The observer survived the visibility: hidden transition in a zombie state. No error, no disconnect, just permanent silence.
Why the Existing Code Didn’t Catch It
The sentinel action’s update() method had this logic:
if (next.disabled) {
observer.disconnect();
observer = null;
} else if (!observer) {
create();
}
// else: observer exists and not disabled — no-op
The zombie observer hit the no-op branch every time. It existed (not null), it wasn’t disabled, so the action left it alone. The only way to get a fresh observer was if disabled went true then false, which required isLoading to toggle — but isLoading was false and stayed false because no fetch was triggered because the observer never fired.
A deadlock: observer needs to fire to trigger loading, loading needs to happen for observer to be recreated.
The Fix: Generation Counter
The first instinct was to derive a “generation” counter in the ListView component and pass it to the sentinel:
let sentinelGen = $state(0);
$effect(() => {
if (appState.ui.viewMode === 'list') sentinelGen++;
});
This caused effect_update_depth_exceeded — an infinite loop. The ++ operator both reads and writes sentinelGen, making the effect depend on its own output. Every increment re-triggers the effect.
The untrack() escape hatch works but is a code smell:
sentinelGen = untrack(() => sentinelGen) + 1;
The proper Svelte 5 pattern: put the counter where the state change happens. UIState.setView() already runs when views transition, so the counter belongs there:
class UIState {
listViewGeneration = $state(0);
setView(mode: ViewMode) {
this.previousViewMode = this.viewMode;
this.viewMode = mode;
if (mode === 'list') this.listViewGeneration++;
}
}
No effects, no untrack, no derived hacks. Imperative state update at the source. The sentinel action checks if generation changed in update():
} else if (!observer || generationChanged) {
create();
scheduleHealthCheck();
}
The Health Check
Even a freshly created IntersectionObserver can miss its initial intersection on iOS if the element is already in the zone when the observer is created. The IO spec says an initial entry should be delivered, but WebKit doesn’t always honor this after visibility transitions.
After every create(), a one-frame health check verifies the observer is working:
function scheduleHealthCheck() {
requestAnimationFrame(() => {
if (!observer || params.disabled || firedSinceCreate) return;
const rootRect = root.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
// Parse rootMargin, compute extension in pixels
if (nodeRect.top <= rootRect.bottom + extensionPx) {
// Sentinel is in the zone but IO didn't fire — manual fallback
params.onIntersect();
}
});
}
If the sentinel is geometrically within the root+margin zone but the observer hasn’t fired since creation, call the callback directly. One rAF delay ensures layout is complete.
Bonus: each_key_duplicate
While debugging, the console showed each_key_duplicate errors — a pre-existing bug. The manga grid uses {#each manga as m (m.slug)} but paginated search results can return the same manga across page boundaries. When loadNextPage() concatenated results, duplicate slugs caused Svelte to reuse DOM nodes incorrectly.
Fix at the data layer:
const seen = new Set(this.results.map(m => m.slug));
const deduped = data.manga.filter(m => !seen.has(m.slug));
this.results = [...this.results, ...deduped];
Svelte 5 Reactivity Lessons
The $effect + sentinelGen++ infinite loop exposed a fundamental Svelte 5 rule:
Never write to $state inside an $effect that reads it. The ++ operator is deceptive — it looks like a write, but it’s a read-then-write. Svelte 5’s proxy-based tracking sees the read and adds the variable as a dependency.
The general principle: if you need state B to update when state A changes, and B doesn’t depend on A’s value for computation (it’s just a “something happened” signal), put the update in the imperative method that changes A, not in a reactive effect.
- Imperative at source:
setView()increments counter — no cycles possible, nountrackneeded - Reactive with escape hatch:
$effect+untrack()— works but fragile, next developer will removeuntrackand break everything - Reactive without escape hatch:
$effect+++— infinite loop, app crashes on load
Reader Navigation: Arithmetic vs. Index
The reader had a function getAdjacentChapterId that found the next/previous chapter using arithmetic: current.number + 1. This breaks immediately for:
- Decimal chapters (3.1, 3.2, 3.5) —
3.1 + 1 = 4.1, which doesn’t exist - Gaps (chapters 10, 12 — no 11) —
10 + 1 = 11, lookup fails, navigation stuck - Chapter 0 — worked by accident, but fragile
The fix was embarrassingly simple: the chapter list is already filtered, deduped, and sorted. Just walk it by index.
private getAdjacent(chapterId: number, direction: 'next' | 'prev'): ChapterMeta | null {
const idx = this.chapterList.findIndex(c => c.chapterId === chapterId);
if (idx === -1) return null;
const targetIdx = direction === 'next' ? idx + 1 : idx - 1;
return this.chapterList[targetIdx] ?? null;
}
The reader stores the filtered chapter list (sorted ascending) when opened. Navigation is just index + 1 or index - 1. Works for decimals, gaps, chapter 0, any numbering scheme.
The old approach was solving a problem that didn’t need solving — the “next chapter” is whatever comes next in the list, not whatever has number + 1.
Chapter Dedup: Context Matters
Manga chapters often have multiple uploads from different scanlation groups — same chapter number, different translators. The chapter list needed deduplication, but the scope matters:
- “All” filter (no group selected): show everything. The user might want to compare groups or pick a preferred translator.
- Specific group selected: dedup by chapter number, keep the latest upload. Within one group, duplicate chapter numbers are just re-uploads.
The initial implementation deduped unconditionally, which hid valid alternate translations. A one-line if fixed it:
if (selectedGroups.size === 0) {
return [...chapters].sort((a, b) => b.number - a.number);
}
// Only dedup when filtering to specific groups
Stripping Diagnostic Logging
After the iOS debugging campaign wrapped up, the codebase had ~230 lines of diagnostic logging: a DEBUG flag, a log helper object, diag() in the sentinel, _snap() in SearchState, debugDump() on the window object, and log.info/warn/error calls everywhere.
The whole system was designed for remote debugging on iOS (no devtools available) — deploy a debug build, inspect logs later. Once the bugs were fixed, all of it became noise that obscured the actual logic.
Stripped it all in one pass. Operational console.error in catch blocks stays — those fire on actual failures. The diagnostic layer was pure scaffolding.