Stack-Based View Navigation: Fixing Chained Swipe-Back and Favorites
The Two Bugs
1. Black screen on chained swipe-back. Open a manga from the list. Open the reader from the manga view. Swipe back — works, you see the manga view. Swipe back again — black screen.
2. No swipe-back from favorites. Favorites was a toggle on the list view, not a navigable view. Once you were in favorites mode, you couldn’t swipe back to search results.
Both bugs had the same root cause.
The Single-Slot Problem
Navigation state was a single previousViewMode:
class UIState {
viewMode = $state<ViewMode>('list');
previousViewMode = $state<ViewMode>('list');
setView(mode: ViewMode) {
this.previousViewMode = this.viewMode;
this.viewMode = mode;
}
}
Walk through the chain: list → manga → reader.
setView('manga'):previousViewMode = 'list',viewMode = 'manga'setView('reader'):previousViewMode = 'manga',viewMode = 'reader'
Swipe back from reader calls setView(previousViewMode), which is 'manga'. Now previousViewMode = 'reader'. But we need it to be 'list' for the next swipe-back. The first setView('reader') overwrote 'list' — it’s gone.
The swipe-back system in +page.svelte used the previous mode to decide which view to reveal behind the current one during the swipe gesture:
const mangaIsBack = inReader && prevMode === 'manga' && isSwiping;
const listIsBack = inManga && prevMode === 'list' && isSwiping;
After the first swipe-back, prevMode was 'reader', not 'list'. Neither condition matched. No view was revealed. Black screen.
The Stack
The fix is a view stack. Each navigation pushes the current view, each back-gesture pops it:
class UIState {
viewMode = $state<ViewMode>('list');
viewStack = $state<ViewMode[]>([]);
pushView(mode: ViewMode) {
this.viewStack = [...this.viewStack, this.viewMode];
this.viewMode = mode;
}
popView() {
const stack = this.viewStack;
if (stack.length === 0) return;
this.viewMode = stack[stack.length - 1];
this.viewStack = stack.slice(0, -1);
}
peekBack(): ViewMode {
return this.viewStack[this.viewStack.length - 1] ?? 'list';
}
resetTo(mode: ViewMode) {
this.viewStack = [];
this.viewMode = mode;
}
}
The chain list → manga → reader now builds viewStack = ['list', 'manga']. First pop restores 'manga' with ['list'] remaining. Second pop restores 'list' with an empty stack. Each swipe reveals the correct view behind it.
resetTo clears the stack entirely — used when submitting a new search, which should always land on a clean list view regardless of where you came from.
Generalizing Swipe-Back
The old +page.svelte had hardcoded back-target logic for each pair:
const mangaIsBack = inReader && prevMode === 'manga' && isSwiping;
const listIsBack = inManga && prevMode === 'list' && isSwiping;
const listIsBackFromReader = inReader && prevMode === 'list' && isSwiping;
With the stack, this collapses to a single derived value:
const backView = $derived(isSwiping ? appState.ui.peekBack() : null);
Each view layer uses the same pattern — hidden when not active and not the back-target, revealed when it’s the back-target:
<div
class="view-layer"
class:view-hidden={viewMode !== 'favorites' && backView !== 'favorites'}
class:swipe-back={backView === 'favorites'}
class:swipe-active={inFavorites && isSwiping}
style="{inFavorites && isSwiping
? `transform:translateX(${swipeProgress * 100}%)`
: ''}"
>
No special cases per view pair. Adding a new view just means adding another layer with the same three classes.
Favorites as a View
Favorites was previously a boolean toggle on ListView — setting isActive switched the list content between search results and favorites. This meant:
- No swipe-back gesture (list view was the same DOM layer regardless of mode)
- No visual transition between search and favorites
- The search bar had to track
favsActiveinternally and show/hide itself
Making favorites a proper ViewMode with its own view layer fixed all three. FavoritesView is a standalone component with its own SearchBar (in favorites mode) and MangaList. It sits in the view stack between list and manga, gets use:swipeBack, and participates in the same push/pop lifecycle as everything else.
The navigation chain list → favorites → manga → reader now builds viewStack = ['list', 'favorites', 'manga']. Each swipe-back peels off the top correctly: reader → manga → favorites → list.
Callers
Every place that called setView needed to switch to the right stack operation:
openManga():setView('manga')→pushView('manga')closeManga():setView('list')→popView()openReader():setView('reader')→pushView('reader')closeReader():setView(previousViewMode)→popView()- Search submit:
setView('list')→resetTo('list') - Favorites activate:
setView('list')+ toggle →pushView('favorites') - Favorites deactivate: toggle only →
popView()
The closeReader change is the most interesting. It used to explicitly read previousViewMode to decide where to go. With the stack, it just pops — the right destination is already there, regardless of whether the reader was opened from manga view or from somewhere else.
Takeaway
A single “previous” slot works for one level of back-navigation. The moment you chain navigations — A → B → C → back → back — the first transition overwrites the information the second one needs. A stack is the natural data structure for this: each forward navigation pushes, each back pops. The view layer becomes a generic loop over the stack rather than a matrix of hardcoded pairs.