visar.log
Technical notes from building things
← all posts

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:

  1. List view visible, sentinel observer works fine
  2. Navigate to manga view — list gets visibility: hidden
  3. Navigate to reader — manga view also hidden
  4. Navigate back to list — visibility restored
  5. 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, no untrack needed
  • Reactive with escape hatch: $effect + untrack() — works but fragile, next developer will remove untrack and 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.