Skip to content

BE-705: Move the entities table view onto the table endpoint - #9095

Open
TimDiekmann wants to merge 7 commits into
t/be-705-graph-entities-table-endpointfrom
t/be-705-entities-page-table-view
Open

BE-705: Move the entities table view onto the table endpoint#9095
TimDiekmann wants to merge 7 commits into
t/be-705-graph-entities-table-endpointfrom
t/be-705-entities-page-table-view

Conversation

@TimDiekmann

@TimDiekmann TimDiekmann commented Jul 24, 2026

Copy link
Copy Markdown
Member

🌟 What is the purpose of this PR?

Moves the entities table view off the subgraph query and onto the dedicated queryEntitiesTable endpoint. The table renders from flat endpoint rows, and its data flow lives in a hook decoupled from the subgraph path — the Grid and Graph views stay on the old query, whose hook goes back to serving only them.

🔗 Related links

🔍 What does this change?

  • useEntitiesTableQuery (new): fetches pages through the endpoint, accumulates rows across pages, deep-merges the closed-type maps and definitions, and processes responses in an effect — Apollo's onCompleted swallows exceptions in production. A requestKey resets the accumulation when the filters change, consumed cursors dedupe Apollo's cache-then-network double emission, and a failed page keeps prior rows on screen with its cursor unconsumed so a retry reprocesses it.
  • Error surfacing: contract breaks reach Sentry and the error UI with their message instead of collapsing into a fixed string. A first page without a summary throws like the other contract checks (previously the filter chips loaded forever), pinned types resolving to no version surface as an error without a dead retry button, and the retry banner distinguishes a failed refresh (stale rows shown) from a failed load-more.
  • Type pills: the table passes its endpoint summary into useAvailableTypes through a discriminated SummarySource, so the pills come from the same transaction as the page instead of a second summarize call.
  • Filter builders: the subgraph builder applies the same kind×operator gates as the endpoint builder, so a pill can no longer read as active while the table drops its filter — pinned by a builder-agreement test.
  • Bulk actions: archive/un-archive works on rows via a structural ArchivableEntity slice, so table rows qualify without carrying full entity metadata. One-sided links (an endpoint hidden by permissions) render their visible side.

Pre-Merge Checklist 🚀

🚢 Has this modified a publishable library?

This PR:

  • does not modify any publishable blocks or libraries, or modifications do not need publishing

📜 Does this require a change to the docs?

The changes in this PR:

  • are internal and do not require a docs change

🕸️ Does this require a change to the Turbo Graph?

The changes in this PR:

  • do not affect the execution graph

⚠️ Known issues

  • The Grid and Graph views still run the subgraph query, so the old data hook and the subgraph filter builder stay alive until they move too.
  • The accumulation fold inside useEntitiesTableQuery is not unit-tested yet — extracting it into a pure function with vitest coverage is a planned follow-up.

🐾 Next steps

  • Move the Grid and Graph views onto dedicated queries, deleting the subgraph path and the type-universe gating
  • Extract the page-accumulation fold from useEntitiesTableQuery and unit-test it

🛡 What tests cover this?

  • build-property-filter-clause.test.ts: value coercion, incomplete and kind-mismatched filters staying inert, and the endpoint/subgraph builder agreement on null-ness
  • The endpoint behavior itself is covered by the integration tests in the base PR

❓ How to test this?

  1. Checkout the branch, run yarn dev, open /entities
  2. Page through a table (scroll to load more), toggle type pills, web filters, archived, and property filters — the counts and rows should stay consistent
  3. Sort by different columns and page again — continuation pages keep the sort
  4. Pin a type via an entity-type page and confirm the table scopes to it

📹 Demo

(screenshots to follow — UI is visually unchanged, the data path is new)

@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hash Ready Ready Preview, Comment Jul 27, 2026 6:49pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
hashdotdesign-tokens Ignored Ignored Preview Jul 27, 2026 6:49pm
petrinaut Skipped Skipped Jul 27, 2026 6:49pm

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the full diff against the actual base branch t/be-705-graph-entities-table-endpoint (this PR is stacked on #9094; endpoint behavior reviewed there, this review covers the frontend migration), with a focus on correctness and performance per BE-705. Items under Important are the ones I'd treat as blocking; Suggestions are non-blocking.

Important

1. Bulk archive/un-archive never refreshes the table once the user has paginated

handleBulkActionCompleted calls tableQuery.refetch() (apps/hash-frontend/src/pages/shared/entities-visualizer.tsx:691), which is Apollo's refetch of the current variables. After the user loads a second page, tableCursor is set, and three independent mechanisms each prevent the refresh from doing anything:

  1. Client dedup discards the response. The refetched page's cursor token is already in consumedCursors, so the processing effect hits if (continues && current.consumedCursors.has(pageCursor)) { return current; } (apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-table-query.tsx:296) and drops the fresh data entirely. The guard exists to dedupe Apollo's cache-then-network double emission, but it can't distinguish that from a deliberate refresh.
  2. The server snapshot is pinned anyway. The cursor token seals transaction_time/decision_time (libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs:352-374), so even without the dedup the refetched page would reflect the pre-mutation snapshot and could not show the archive.
  3. Earlier pages and the summary are never refetched. Only the last page's variables are re-run, and includeSummary: !cursor is false for a cursored request (use-entities-table-query.tsx:232), so totalResultCount also stays stale.

Net effect: archive rows on any table with more than one loaded page and nothing on screen changes — no error, no update — until the user changes a filter/sort/view or reloads. The mutation succeeds server-side, so this reads as a silent failure. The single-page case works only because pageCursor === null makes continues false, which will hide this in casual testing.

The hook's public refetch needs "restart the accumulated sequence" semantics — reset the cursor, clear the accumulation, refetch cursor-less first-page variables — rather than "re-run the last page's variables". Note the in-place tableQuery.refetch() calls at entities-visualizer.tsx:806 and entities-visualizer.tsx:865 have the same problem once paginated.

2. Failed first table page shows a "Try again" button that retries the wrong (skipped) query

On the default /entities table view (unpinned, no selection — the most common page), a failed first queryEntitiesTable page produces a permanently dead retry button:

  • tableQuery.error is mirrored into tableSummaryError (entities-visualizer.tsx:361-363), which flows into the external-mode SummarySource.
  • In useAvailableTypes, external mode reports typeUniverseError = summarySource.error since no summary ever arrived (apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-available-types.ts:258-259), making typeUniverseBlocksResults true (entities-visualizer.tsx:701-704).
  • The retry onClick branches on typeUniverseBlocksResults before usesTableEndpoint (entities-visualizer.tsx:803-804), so it calls refetchTypeUniverse — which in external mode is still the raw Apollo refetch of the summarizeEntities query (use-available-types.ts:291). That query is skipped (skip: !shouldFetchAvailableTypes, use-available-types.ts:129) and its data is ignored entirely by the external branches; Apollo's refetch punches through skip (the repo documents this and guards it with guardedRefetch in use-entities-visualizer-data.tsx:176-185 — a guard useAvailableTypes lacks).

So clicking "Try again" fires a wasted summarizeEntities request, tableQuery is never refetched, its error never clears, and the error screen persists no matter how many clicks — recreating exactly the dead-retry state the PR description says it set out to eliminate. Either check usesTableEndpoint before the type-universe branch in the onClick, or have useAvailableTypes return a no-op/caller-supplied refetch in external mode so the invalid wiring is unrepresentable.

3. Selecting a type pill drops the noisy-system-type exclusion, inflating the pills and changing row results

When selectedTypeIds is non-null the request sends types: { type: "include", entityTypeIds } and noisySystemBaseUrls is silently dropped — it is only sent in the exclude arm (use-entities-table-query.tsx:207-212). On the endpoint, base-URL exclusions live in the scope filter only for the Exclude variant, and the summary's type maps are computed over the scope before the type filter (table.rs:854-876, scope_summary). Two user-visible consequences:

  • Pills flip on selection. The moment any pill is selected, the first page re-issues (cursor resets on filter change) and its summary — now the pill source via SummarySource — spans the web/archived scope with no noisy exclusion, so User/Machine/Organization/notification/page types (the exact types FE-1238 removed) appear in the pill list and counts, then vanish when the selection is cleared.
  • Rows diverge from the other views. buildEntitiesFilter applies ignoreNoisySystemTypesFilter whenever the type is not pinned, including with a selection active (shared/build-filter.ts:180-182 — the doc comment there explains the multi-type-entity case), so a multi-type entity carrying a selected type plus a noisy type was excluded before but appears now, and the Grid/Graph views (still on the subgraph builder) disagree with the Table view under the same filter state.

The endpoint request should carry the noisy base-URL exclusions alongside a selection (the Include variant would need to accept exclusions, or the client keeps them in scope), or at minimum the summary fed to the pills should be filtered client-side.

4. Every appended page regenerates table data for ALL accumulated rows (quadratic pagination cost)

The accumulation effect builds rows = [...current.rows, ...response.rows] and then calls generateTableDataFromEndpointRows over the entire accumulation, not just the new page (use-entities-table-query.tsx:313-337), synchronously inside the setAccumulated updater. Per row the generator does real work — getClosedMultiEntityTypeFromMap, a loop over every property in the closed type schema, allOf ancestor walks, two format() date calls, generateEntityLabel fallback — plus re-running the getTextWidth canvas measurement for every property column each fold. With limit: 500, page k reprocesses k×500 rows: at 20 pages each further load-more stalls the main thread re-deriving 10k+ already-rendered rows whose output cannot change. The deleted use-entities-table-data.tsx was deliberately incremental (generate for the new page only; append rows, union columns/sets).

Since the PR already plans to extract the fold for unit testing, extracting it as an incremental merge fixes both at once. The only genuinely whole-set outputs — the shared-type-title intersection and the all-rows-miss-source/target column hiding — can be carried as running aggregates/counters as the old code did.

5. A cursor surviving a pinned-type prop change builds a summary-less accumulation with no error — filter chips spin forever

resetCursors is wired to every in-component mutation (filters, sort, view, conversions — entities-visualizer.tsx:218-259) but nothing clears tableCursor when the entityTypeId/entityTypeBaseUrl props change, and EntitiesVisualizer is rendered without a key at both call sites, so Next.js query-param-only navigation (e.g. /entities?entityTypeIdOrBaseUrl=X/entities, or between two type pages' entities tabs) keeps the component — and the stale cursor — mounted. The hook then sends the foreign cursor with includeSummary: !cursor = false. In the processing effect, continues is false (new requestKey) but pageCursor !== null, so the missing-summary guard if (pageCursor === null && !response.summary) (use-entities-table-query.tsx:307) is bypassed and a fresh accumulation is stored with summary: null (use-entities-table-query.tsx:338) — plus the rows are the foreign cursor's continuation page, silently skipping the first ~500 rows.

No error is raised: totalResultCount stays null and in external mode useAvailableTypes reports loading forever (summarySource.summary === null && !summarySource.error, use-available-types.ts:284) — exactly the endless-loading state the summary guard was added to prevent, since every follow-up request also carries a cursor. The hook should refuse (or clear) a cursor that wasn't issued for the current requestKey — e.g. only include cursor in the variables when it was produced by an accumulation with a matching key.

Suggestions

  • hadCachedContent is computed but never consumed. It runs a synchronous apolloClient.readQuery of the full 500-row response on every variables change (use-entities-table-query.tsx:368-376), is declared on the result type (use-entities-table-query.tsx:54) and churns the returned memo's identity, yet entities-visualizer.tsx never reads tableQuery.hadCachedContent and no other consumer exists. It appears copied from the subgraph hook's shape. Since this PR defines the new hook's contract from scratch, dropping the field and the cache read is free.
  • cache-and-network guarantees a wasted 500-row server query for any replayed continuation page. On a cache hit for a cursor-bearing page, the cache pass appends the page and marks the cursor consumed; the network pass then arrives and is unconditionally discarded by the guard at use-entities-table-query.tsx:296 (the file's own doc comment on consumedCursors anticipates this double emission). Since a continuation page is pinned to the first page's snapshot by design, freshness adds nothing — use cache-first for cursor-bearing pages, or process the re-emission by replacing the page in place.
  • Row data is retained in multiple copies with no eviction. AccumulatedPages.rows (use-entities-table-query.tsx:76) keeps every raw page solely so the full-table regeneration can re-run (the incremental fix in Important #4 removes this need), tableData.rows holds derived wrappers, and each cursor page's complete scalar response is cached under distinct request variables in Apollo with no keyArgs/eviction policy — so per-cursor cache entries (including abandoned filter sequences') grow unboundedly for the session. A type policy that avoids caching cursor pages, or evicts consumed ones, would cap this.
  • The processing effect is keyed on hideColumns array identity (use-entities-table-query.tsx:366): a caller passing a literal array would loop regeneration on every render for first pages (the PR knows — entities.page.tsx adds a useMemo with a comment saying exactly that), while on continuation pages a changed hideColumns silently doesn't apply due to the consumed-cursor guard. Column hiding is cheap post-processing over columns (generate-table-data-from-endpoint-rows.ts:132-151); deriving visible columns in a separate memo outside the accumulation removes both the identity hazard and the asymmetry.
  • A failed same-variables refresh is mislabelled as a failed load-more. dataIsStale is only true when accumulated.requestKey !== requestKey (use-entities-table-query.tsx:405), so when the bulk-action tableQuery.refetch() rejects, the banner at entities-visualizer.tsx:856 says "Something went wrong loading more entities" even though the on-screen rows are stale pre-mutation results — the "showing the previous results" wording is exactly what applies but is unreachable here. An in-flight-refresh flag would let the banner label it correctly.
  • hasPinnedTypes + resolvedPinnedEntityTypeIds is a two-field invariant the type system doesn't enforce (use-entities-table-query.tsx:143): the request builder uses resolvedPinnedEntityTypeIds ?? ... without consulting hasPinnedTypes, so false + non-null array silently pins, while true + empty array flips unresolvablePins. A discriminated parameter (e.g. pinnedTypes: null | { resolvedIds: VersionedUrl[] | null }, mirroring the SummarySource pattern this PR introduces) makes the invalid states unrepresentable. No live bug — the sole caller maintains the invariant today.
  • The builder-agreement test hand-picks 9 cases and leaves most of the closed operator space unpinned (build-property-filter-clause.test.ts:86). Its own comment states the invariant absolutely ("the two must never disagree on null-ness" — isPropertyFilterActive answers for both paths via one builder), yet notEquals, greaterThanOrEqual, lessThan, lessThanOrEqual, isEmpty, isTrue, isFalse, the entire boolean kind, and the whitespace-only number branch (build-property-filter-clause.ts:28) are never exercised. Because the gates are duplicated per-operator in both builders, a divergence in any untested branch passes this suite while producing exactly the failure the test exists to prevent. Both unions are small and closed — enumerating operators × kinds × a fixed value set fully pins the invariant at negligible cost.
  • The rewritten row-to-table transform has zero tests (generate-table-data-from-endpoint-rows.ts:190), despite being a pure function (bar a one-line getTextWidth mock) and despite this same PR adding a vitest suite for a sibling pure helper. None of the PR-new behavior is exercised: the endpointRow.label ?? generateEntityLabel(...) fallback, linkEndpointCell falling back to endpoint.entityId for permission-hidden endpoints (the PR body's advertised one-sided-link behavior), the index > 0 shared-title guard, the page-vs-endpoint archived split, the empty-rows edge of source/target column hiding, and the three throw new Error contract checks feeding the new Sentry/error surfacing. A table-driven suite with a few synthetic EntityTableRow fixtures would pin all of these.
  • The already-pure merge helpers need not wait for the deferred fold extraction. The Known Issues section defers unit-testing the accumulation fold, which is reasonable for the React-coupled parts — but mergeClosedMultiEntityTypeMaps (use-entities-table-query.tsx:91) and mergeDefinitions (use-entities-table-query.tsx:114) are pure module-level functions testable with just an export. The recursive merge has real edge behavior worth pinning (source page's schema silently wins on collision at line 102); a regression corrupts the closed-type map for every multi-page table and nothing would catch it.

Strengths

  • The core BE-705 goal lands: the table view truly skips the heavy subgraph path (use-entities-visualizer-data.tsx skips both the summarize and subgraph queries when view === "Table"), the summary is requested only on the first page (includeSummary: !cursor), and the type pills reuse it through the discriminated SummarySource — eliminating a full extra round trip and sourcing pills from the same transaction as the page.
  • The page-accumulation state machine is carefully reasoned for its main hazards: consumed-cursor dedup neutralizes Apollo's cache-then-network double emission (safe precisely because the endpoint cursor pins the snapshot), a stale accumulation never offers its continuation (cursor gated on isCurrent), a failed page leaves its cursor unconsumed so retry genuinely reprocesses it, and the requestKey guard quarantines late responses from previous filter sets (use-entities-table-query.tsx:292-343).
  • Processing responses in an effect instead of Apollo's production-exception-swallowing onCompleted, with explicit contract-break guards (missing definitions/closed types/first-page summary) surfaced to both the error UI and Sentry, closes a real invisible-failure class the old updateTableData path had.
  • The builder-agreement test plus the kind×operator gates retrofitted onto buildPropertyFilterClause fix a genuine pre-existing bug (string parameters sent to greater/less) and pin the invariant that isPropertyFilterActive answers for both query paths.
  • The shared-type-title logic fixes the old code's index-0 self-wipe (the prune loop ran against an empty set on the first entity, so the header was always "Entity"); the index > 0 guard makes the feature work for the first time.
  • One-sided link handling is more correct than before: per-side noSource/noTarget counting replaces the old both-or-nothing linkData branch, and endpoint types are verifiably present in the response's closed-type maps, so linkEndpointCell's lookups are safe.
  • Per-row client work drops meaningfully: rows prefer the server-materialized endpointRow.label, and link endpoint cells come from denormalized endpoint fields instead of subgraph traversal (getEntityRevision/generateLinkEntityLabel removed).
  • ArchivableEntity is a well-judged structural slice — the doc comment explains precisely why it is not a Pick of Entity, and the isType discriminant remains sound for the new union in BulkActionsDropdown.
  • Hot identities are handled deliberately: the closedMultiEntityTypes memo dedupes by sorted type-id key instead of recomputing per row, and resetCursors/conversions/hideColumns are memoized at the call sites to keep the query variables stable.

Generated by Claude Code

@TimDiekmann
TimDiekmann force-pushed the t/be-705-entities-page-table-view branch from 2222708 to 10edad5 Compare July 25, 2026 11:55
@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 25, 2026 11:55 Inactive
@TimDiekmann
TimDiekmann force-pushed the t/be-705-entities-page-table-view branch from 10edad5 to 71e0bfa Compare July 25, 2026 12:33
@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 25, 2026 12:33 Inactive
@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 25, 2026 12:46 Inactive
@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 25, 2026 12:53 Inactive
@github-actions github-actions Bot added area/libs Relates to first-party libraries/crates/packages (area) type/eng > backend Owned by the @backend team labels Jul 25, 2026
@TimDiekmann
TimDiekmann force-pushed the t/be-705-entities-page-table-view branch from 02952e2 to fe51fe8 Compare July 25, 2026 12:54
@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 25, 2026 12:54 Inactive
@github-actions github-actions Bot removed area/libs Relates to first-party libraries/crates/packages (area) type/eng > backend Owned by the @backend team labels Jul 25, 2026
@TimDiekmann
TimDiekmann marked this pull request as ready for review July 25, 2026 13:02
@cursor

cursor Bot commented Jul 25, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Large refactor of the primary /entities table data path (pagination, filters, errors, bulk actions) with dual query paths until Grid/Graph migrate; mitigated by tests on filter builders, cursor logic, and endpoint row generation.

Overview
Moves the entities Table view off queryEntitySubgraph onto the dedicated queryEntitiesTable endpoint, while Grid and Graph keep the subgraph path via a slimmed useEntitiesVisualizerData.

A new useEntitiesTableQuery hook pages through GraphQL, folds rows into EntitiesTableData with generateTableDataFromEndpointRows, and owns cursor/summary state (request-key resets, consumed-cursor dedupe, stale rows on failed refresh). Responses are processed in an effect so processing errors surface in the UI and Sentry instead of being swallowed by Apollo onCompleted. Type filter pills reuse the first page’s endpoint summary through useAvailableTypes’s SummarySource (no extra summarize call when on Table). Property filters gain buildEndpointPropertyFilter with the same inert rules as the subgraph builder (plus tests). Bulk archive works from table selection via a structural ArchivableEntity slice. Client-side subgraph table assembly (useEntitiesTableData / generateTableDataFrom-rows) is removed; EntitiesTable no longer takes a subgraph prop.

Reviewed by Cursor Bugbot for commit b7d6d84. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread apps/hash-frontend/src/pages/shared/entities-visualizer.tsx
@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 25, 2026 15:01 Inactive
The table view reads its rows through the dedicated queryEntitiesTable
endpoint instead of the subgraph query, through a decoupled hook that
accumulates pages, deep-merges the closed-type maps, and processes
responses in an effect — Apollo's onCompleted swallows exceptions in
production. Contract breaks reach Sentry and the error UI with their
message, a failed follow-up page keeps the rows on screen next to a
retry banner, and pins that resolve to no version surface as an error
instead of an empty table.

The Grid and Graph views stay on the subgraph query, whose hook goes
back to serving only them.
Refreshing the table after a bulk action re-ran the page in flight, whose
cursor pins the snapshot the sequence started on — and whose response the
dedup guard then discarded as a replay. Archiving a row on any table past
its first page changed nothing on screen, with no error. The hook now owns
its cursor and exposes loadMore/restart: a restart drops the accumulation
and reads the sequence from its first page, where the mutation is visible.

Owning the cursor also settles which sequence it belongs to. A cursor is
only sent when the current accumulation handed it out, so a navigation that
swaps the pinned type without remounting can no longer continue a foreign
sequence — which skipped its first pages and, with the summary suppressed
for cursored requests, left the filter chips loading forever.

Appending a page no longer re-derives the rows before it: the generator
carries its aggregates forward, so a page costs a page instead of the whole
table. Retrying a failed first page now restarts the table query rather
than a summary query the table does not read.

Type exclusions ride alongside a selection through the endpoint's new
filter field, so selecting a pill keeps the noisy system types out of the
rows, the count, and the pills.

The row generator gets its first tests, and the builder-agreement test
enumerates the operator and kind space instead of sampling it.
jsdom only provides a 2d canvas context when the optional `canvas` package
happens to be installed, so the generator's width measurement crashed on a
null context in CI while passing locally.
…ed one

A restart branched on the requested cursor, but what decides whether the
query re-runs is the cursor actually in effect: a requested one the current
sequence never handed out is already dropped, so clearing it changed no
variables — the accumulation was wiped and no read followed, leaving the
table blank with a Try again button that did nothing.

The rule for which cursor is in effect moves into a function of its own and
gets tests. Two of this hook's bugs came down to that rule, and it is
testable without standing up React.
Clearing the accumulation to read the first page again also cleared the
type definitions the conversion column headers resolve their titles from,
and resolving them threw during render — so a retry or a bulk action with a
unit conversion active took the whole entities page down. The titles now
wait for the definitions to arrive instead; the conversions themselves ride
in the request either way.

A processing error also stops speaking for the table once the filters move
on: its rows stay on screen, but the failure to retry is no longer the one
that produced them.
The accumulation bundled two lifetimes: the pages of one request, and the
types those pages resolved. A restart has to drop the former — its snapshot
predates whatever it is restarting for — but dropping the latter took the
pool out from under everything rendering a resolved type, which is how a
retry could crash on a conversion's column title.

The types now live in their own state, merged as pages arrive and kept
across sequences, since a closed schema does not depend on which rows asked
for it. A page merges against the pool as it stands rather than against the
render's, so the state is mirrored in a ref the effect reads.
…s to

Three failures with one root: an output that cannot say which request it
belongs to gets attributed to the wrong one.

A processing error was stored on whichever accumulation existed, so a first
page failing for new filters tagged itself onto the previous sequence — and
gating the report on that sequence being current then swallowed it. The
error now carries the request it came from and is reported while that
request is the current one, whether or not its rows are the ones on screen.

Load more only set the requested cursor, so clicking it after a failed page
set the state it already held and fetched nothing, while the footer kept
offering it. Asking for a page that was asked for already now takes the
round trip it needs.

Pins that resolve to no version build no request, so no page can follow the
rows on screen: they are no longer served, and the error stands alone
instead of sitting under rows from a request that is gone.
@claude
claude Bot force-pushed the t/be-705-graph-entities-table-endpoint branch from 84f441b to 8cfdd9f Compare July 27, 2026 18:28
@claude
claude Bot force-pushed the t/be-705-entities-page-table-view branch from 1f0a4c1 to b7d6d84 Compare July 27, 2026 18:28
@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 27, 2026 18:29 Inactive

@cursor cursor Bot 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.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b7d6d84. Configure here.

size="xs"
>
Try again
</Button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Load-more retry clears loaded pages

Medium Severity

When load-more fails, the retry banner always calls restart, which nulls accumulated pages and reloads from page one. loadMore already refetches the failed continuation while keeping prior rows, and the hook leaves that cursor unconsumed for exactly this retry path.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b7d6d84. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verified against b7d6d84 — confirmed, with one correction to the implied fix.

The banner's "Try again" unconditionally calls restart(), which nulls accumulated and drops every loaded page, while the hook explicitly supports a row-preserving retry: after a failed continuation the cursor is left unconsumed, and loadMore() hits its requestedCursor === nextCursor refetch branch, re-requesting only the failed page. (Scrolling again already triggers exactly that via loadMoreRows, so the button is strictly more destructive than the table's own recovery.)

But a blanket swap to loadMore would be wrong: the same banner also serves the dataIsStale case (new-filter first page failed while old rows are shown). There the sequence is no longer current, nextCursor is null, and loadMore() no-ops — that branch must keep restart. The fix is dataIsStale ? restart : loadMore.


Generated by Claude Code

// A failed page query with nothing to show gets the error state. With data
// on screen the stale results stay visible alongside a retry banner instead.
const queryBlocksResults =
!!activeError && (usesTableEndpoint ? !tableQuery.tableData : !subgraph);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Retry shows error while loading

Medium Severity

queryBlocksResults treats any error with empty tableData as terminal, ignoring an in-flight load. After restart clears accumulated rows and refetches the same first-page variables, Apollo still exposes the previous queryError, so the blocking error UI stays up during the retry instead of a loading state.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b7d6d84. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verified against b7d6d84 — the user-visible claim is accurate, though the mechanism is slightly different and it constrains where the fix goes.

There's no in-flight load being "ignored": the hook's useQuery has no notifyOnNetworkStatusChange (defaults to false in Apollo 3.10), so during a same-variables refetch() Apollo emits nothing — error stays set and loading stays false for the whole round trip. That means adding && !loading to queryBlocksResults wouldn't help; the hook needs notifyOnNetworkStatusChange: true (or its own pending flag around restart) so the component can tell a retry is running.

Two paths hit it: a failed fresh first page (error screen persists with no feedback, button can stack refetches) and the dataIsStale case, where restart nulls the old rows and the UI escalates from the banner to the full blocking error screen for the duration of the retry. The load-more→restart path is unaffected (dropping the cursor changes the variables, which does reset Apollo's state). Minor in that recovery does work once the refetch settles — the defect is the missing feedback.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/apps > hash* Affects HASH (a `hash-*` app) area/apps type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

1 participant