Skip to content

fix(ui): restart pagination when the feed or category page falls past the end - #4519

Merged
fguillot merged 1 commit into
miniflux:mainfrom
justadityaraj:fix/mark-page-read-last-page
Oct 3, 2026
Merged

fguillot merged 1 commit into
miniflux:mainfrom
justadityaraj:fix/mark-page-read-last-page

Conversation

@justadityaraj

@justadityaraj justadityaraj commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #4386

Context

On the last page of a feed, "Mark this page as read" leaves an empty page with no navigation. The unread list shrinks under the current offset, so the offset is now past the end; the template only renders the pagination block when there are entries, so the view falls back to the "no unread entries" alert with no Previous link, even though the header still reports the entries that remain. Reloading, or reopening the same URL later, lands on the same dead end.

The global unread view already handles this: showUnreadPage resets the offset to 0 and requeries when offset >= countUnread && countUnread > 0. The feed and category views are the other two with a "Mark this page as read" button and never got the same guard.

Changes

Apply that guard to showFeedEntriesPage and showCategoryEntriesPage. The query moves into a small closure so the fallback does not duplicate the builder chain — WithOffset ignores 0, so the same builder cannot be reused directly.

Scope

The three views that carry the "Mark this page as read" button are the feed, category and unread lists, and markPageAsReadAction in app.js only reloads in place when data-show-only-unread is set, which only the feed and category templates set. So those two were the only ones missing the guard, and no other list view can reach this dead end through that button.

Other list views can strand on a stale offset by different routes, /starred when an entry is unstarred, /history when one is marked unread, /search?unread=1 when a result is auto-marked read on view. They are left alone here since they have no bulk action and are not what #4386 reports.

I also considered fixing this in the template instead, since getPagination already returns ShowPrev and PrevOffset correctly at a stale offset and the view discards them under {{ if not .entries }}. Rendering pagination for an empty page would restore the Previous link, but it would still show the "no unread entries" alert while unread entries exist just below the offset, so the reader is told the wrong thing. Resetting the offset shows them instead, and matches showUnreadPage.

Resetting to the first page rather than clamping to the new last page is also for parity with showUnreadPage.

User impact

Marking the last page as read lands on the first page of what is left instead of an empty page. A page still within range is unaffected, and a feed with genuinely nothing unread keeps its alert.

Verification

Built and run against a local PostgreSQL instance with a feed of 25 unread entries and 20 entries per page.

Case before after
offset=20, 25 unread 5 entries, pagination, Previous link unchanged
offset=20 after marking that page read (20 unread) 0 entries, no pagination, no Previous link 20 entries, pagination
offset=100, 25 unread 0 entries, no pagination 20 entries, pagination
offset=0 / offset=20, 0 unread "no unread entries" alert unchanged

/category/1/entries?offset=20 behaves identically before and after the change.

go vet ./internal/ui/..., gofmt and golangci-lint run ./internal/ui/... are clean. go test ./... passes except TestParseAdminPasswordFileOptionWithEmptyFile, which fails on this Windows host before and after the change because it writes its temporary file to C:\WINDOWS.

@jamalkamaladdin jamalkamaladdin left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read this against showUnreadPage and the query builder. Two things I checked

  1. WithOffset in internal/storage/entry_query_builder.go only assigns when offset > 0, so reusing an already built query and calling .WithOffset(0) on it would silently keep the stale offset. The closure avoids that, and the note in your description is accurate.

  2. Both handlers pass the reassigned offset into getPagination, so the restarted page also gets correct Previous and Next links instead of links built from the old offset.

One decision worth surfacing for the maintainer: showUnreadPage still carries this guard inline with the full builder chain written out twice. After this PR the same rule lives in three files in two different shapes. Moving unread_entries.go to the closure form here would leave one pattern to read, at the cost of touching a file the bug report does not require.

… the end

Marking a page as read shrinks the unread list under the current offset. On
the last page that leaves the offset past the end, and the template hides
pagination whenever there is nothing to render, so the view shows "no unread
entries" with no way back even though the header still reports the remaining
count. Reloading or reopening the URL keeps landing on the same dead end.

Fall back to the first page when the offset is past the end, which is what
showUnreadPage already does for the global unread view. A page that is still
within range is untouched, and a genuinely empty feed keeps its alert.
@justadityaraj
justadityaraj force-pushed the fix/mark-page-read-last-page branch from 7a6c440 to 2a8e8be Compare September 7, 2026 14:31
@justadityaraj

Copy link
Copy Markdown
Contributor Author

Thanks for reading it that closely, both of your checks match what I found.

On the third point I left unread_entries.go alone deliberately. I did measure the conversion first: routing its fallback through the same closure is +14/-17, net three lines shorter, and behaviour-identical, since its second chain differs from the first only by omitting WithOffset and a fresh builder starts at offset 0 anyway. So it is a clean change, just not this one. Every PR here lands as a single squashed commit under one conventional prefix, and reshaping a third file for consistency is refactor(ui): work riding inside a fix(ui): commit. Happy to send it as its own PR if @fguillot wants it.

Worth being honest about what it buys, too: the three handlers still would not read alike afterwards. showUnreadPage names the count countUnread and carries WithGloballyVisible(); the other two carry WithFeedID/WithCategoryID. It unifies the fallback shape rather than collapsing three files to one pattern.

I did trim the comment in 2a8e8bec. It was three lines in both handlers where every inline comment in internal/ui is one, so it is now a single line and the paragraph moved into the PR description, which is where it belonged. I also wrote down the scope boundary there: the feed and category views were the only ones missing this, because markPageAsReadAction only reloads in place when data-show-only-unread is set and only those two templates set it.

One thing your comment made me go back and check, since the old comment blamed the template. getPagination does return ShowPrev/PrevOffset correctly at a stale offset; the view drops them under {{ if not .entries }}. So a template fix would restore the Previous link. I still think the handler reset is the right layer, because rendering pagination on the empty page would leave the reader looking at the "no unread entries" alert while unread entries exist just below the offset. That is in the description now as well.

@fguillot
fguillot merged commit c52bdef into miniflux:main Oct 3, 2026
7 checks passed
dsh2dsh added a commit to dsh2dsh/miniflux that referenced this pull request Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: After "Mark this page as read" the feed shows 0 entries

3 participants