visar.log
Technical notes from building things
← all posts

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.

  1. setView('manga'): previousViewMode = 'list', viewMode = 'manga'
  2. 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 favsActive internally 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.