Skip to content

fix(drive): page lists on the server's has_next signal - #665

Open
ebrahimgamdiwala wants to merge 5 commits into
frappe:developfrom
ebrahimgamdiwala:fix/drive-list-pagination-has-next
Open

fix(drive): page lists on the server's has_next signal#665
ebrahimgamdiwala wants to merge 5 commits into
frappe:developfrom
ebrahimgamdiwala:fix/drive-list-pagination-has-next

Conversation

@ebrahimgamdiwala

@ebrahimgamdiwala ebrahimgamdiwala commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Problem

Drive's list endpoints dedupe and permission-filter after applying LIMIT/OFFSET, so a full SQL window can return fewer visible rows once filtered. The client inferred "end of list" from page.length >= PAGE_SIZE, so one filtered row permanently killed infinite scroll — and with no "load more" button, the rest of the folder became unreachable. Worst in Shared, where the dedupe fires on every multi-path share.

Fix

The five paginated endpoints take an opt-in paginated flag and return {rows, has_next, next_start}. The paginated path walks raw SQL windows until it has a full page of rows the caller can actually see, and derives has_next from that — never from the raw count, which would make an empty page advertise the existence of denied files. Filling a page can span several windows, so the response carries next_start for the client to resume from. Non-paging callers keep the bare list, untouched.

Three further fixes this turned up:

  • Cache corruption on revisit — frappe-ui's offline restore assigns the raw response into resource.data, where consumers spread it as an array. The new envelope threw a TypeError on the second visit to any list.
  • Infinite scroll never fired on a cold first visit (pre-existing) — useInfiniteScroll binds its target once at setup, but the shell registers the scroll container a tick later, so it caught null and never re-armed.
  • Stale pagination race — a refresh while loadMore was in flight appended rows from the old query and overwrote the reset cursor. Requests now carry a query epoch.

Also: loadMore skipped the dotfile filter page 1 applies, so dotfiles appeared from page 2 onward.

Testing

7 backend, 11 frontend — each regression case verified failing without its fix. Verified in a browser on a built site: a cold visit fills a full 50-row page past denied rows and reaches all 55 visible files. All Drive backend modules and the full frontend suite pass.

ebrahimgamdiwala and others added 2 commits August 17, 2026 09:54
`get_query_data` dedupes and permission-filters *after* applying
LIMIT/OFFSET, so a full SQL window can come back with fewer rows than
`limit` while rows still remain. The client inferred end-of-list from the
row count, so a single filtered row permanently ended infinite scroll and
left the rest of the folder unreachable — there is no "load more" button.
Worst in Shared, where the dedupe fires on every multi-path share.

The five paginated endpoints now take an opt-in `paginated` flag; with it
`get_query_data` returns `{rows, has_next}`, where `has_next` is measured
from the raw row count *before* the dedupe and the filter run. Callers
that don't page keep the bare list, so folderTree, MoveDialog, Writer's
download helper and `get_attachments` are untouched.

An explicit parameter rather than a response header or a
`frappe.local.response` sibling key because frappe-ui's `frappeRequest`
returns `data.message` and discards both, so no out-of-band channel
reaches the first page, which goes through the resource layer.

Two further fixes the change turned out to need:

- `setCache` replaces the resource's `setData` with one that assigns
  `data` raw, and frappe-ui's offline restore calls exactly that with the
  persisted response. Left alone it would put `{rows, has_next}` straight
  into `resource.data`, where consumers spread it as an array — a
  TypeError on the second visit to every paginated list. Both stores are
  idb-keyval under the same key, so normalise through `unwrapRows`.

- `useInfiniteScroll` resolves its scroll target once at setup, but
  `useScrollContainer` is a module-level registry the shell fills in a
  tick later, so on a cold mount it bound to null and its `arrivedState`
  never updated again: a first visit loaded page 1 and then never
  paginated, however far you scrolled. Binding on the ref's transitions
  survives both orders. `fillViewport` covers a page too short to
  overflow its container, which can never emit a scroll event — likelier
  now that the server can thin a page.

`loadMore` also formats pages through the same `formatRows` the resource
transform uses; it previously called `prettyData` directly and skipped
the dotfile filter, so dotfiles were hidden on page 1 and visible from
page 2 on.

Tests: 6 backend covering the envelope, the raw-count signal and a window
thinned by a deny row; 10 frontend covering the has_next signal, the
late-registering container, the short-page top-up and the cache restore.
The three carrying the regressions were verified failing without their
fix, and the pagination path was verified end-to-end in a browser against
a built site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 5/5

The PR appears safe to merge.

The stale-pagination race is guarded by query epochs, and deterministic fixture names resolve the previous test concern; no blocking failure remains.

Reviews (3): Last reviewed commit: "docs(drive): drop internal tracker IDs f..." | Re-trigger Greptile

Comment thread frontend/src/apps/drive/components/GenericPage.vue
Comment thread suite/drive/api/tests/test_list.py
@ebrahimgamdiwala
ebrahimgamdiwala marked this pull request as draft August 17, 2026 10:03
The first cut of this fix computed `has_next` from `len(raw_sql_rows) ==
limit`, measured before the permission filter. The raw query carries no
permission predicate — access is resolved per row afterwards — so the
signal was derived from rows the caller may not be allowed to see.

That turned an empty page into an existence oracle. A user with read on a
folder but denied a file inside it previously got an empty list, which is
indistinguishable from "nothing here". With the raw count it got:

    files(entity_name=folder, limit=1, paginated=True)
    => {'rows': [], 'has_next': True}

`rows` is correctly empty, but `has_next` proves something is there. It
leaks existence only — no name, no content — and needs folder-level read
first, but it is still information about explicitly denied files, and it
was introduced by this branch rather than pre-existing.

The paginated path now walks raw windows until it has collected a full
page of rows the caller can actually see, or the query is exhausted, and
reports `has_next` from that. An empty page can now only mean the query
ran out. Each window asks for just the shortfall, so a page never
overshoots `limit`. Scanning is capped at MAX_SCAN_WINDOWS: without a
bound, a folder of near-entirely denied rows would scan the whole table
running the per-row access check on each. On hitting the cap we report
has_next=True, the safe direction — one extra request rather than hidden
rows.

Because filling a page can consume several windows, `start + limit` is no
longer where the next page begins, so the envelope carries `next_start`
and the client resumes from it. Dedupe state is threaded across windows
too, or a file reachable by two share paths would survive duplicates that
straddle a boundary.

Also addresses review feedback:

- Stale pagination race (P1). Search, sort or refresh resets pagination
  while a `loadMore` may still be in flight; the late response appended
  rows from the previous query and overwrote the reset cursor, mixing two
  result sets and skipping a page of the current one. Requests now carry
  a query epoch and are discarded when it moves on. `fillViewport` made
  this materially more likely, since it issues a page request off the
  back of every refresh. `refreshData` owns clearing `loadingMore`, as a
  stale response deliberately leaves it alone and would otherwise wedge
  pagination shut.

- Deterministic pagination test fixtures (P2), so a failing run names the
  same rows every time.

Tests: 7 backend, including the oracle case — folder readable, every file
in it denied, page must come back empty *and* has_next False. 11
frontend, including a page landing after the query moved on. Both
verified failing without their fix. Re-checked in a browser: a cold visit
fills a full 50-row first page past the denied rows and reaches all 55
visible files, resuming from the server's cursor (start=55, not 50).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ebrahimgamdiwala
ebrahimgamdiwala marked this pull request as ready for review August 17, 2026 10:21
ebrahimgamdiwala and others added 2 commits August 17, 2026 10:23
The repo allows a longer line than the assertion had been wrapped to, so
`ruff format --check` (the gating lint step) rejected it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
F5/N1 referenced a personal review-tracker doc, meaningless outside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant