From 2c4e715f7ffa34b6bdcd19b0b83e5bb8d5f1011c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 21:19:43 +0000 Subject: [PATCH 1/4] fix(plugin-list): key the records fetch on its window, not on the current visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ListView`'s fetch effect named `currentView` in its dependency list, so every visualization switch re-ran it — while the query it builds never reads `currentView`. The visualization reaches the wire through exactly one number, the `$skip` of the window, and at page 1 that number is 0 for a flat grid and 0 for every other surface. The re-issued request was therefore byte-for-byte the one already on screen, and a board opened through a view switch cost two identical `GET /api/v1/data/?top=…&select=…` round trips before its first paint (objectui#7394). The effect is keyed on `fetchSkip` instead. The two surface-shaped readings the fetch used to latch — the server total behind the grid's pager and the row-cap banner's "…but the real total is known" half — are derived at render, so they answer for the render that reads them rather than for the one that wrote them, which is what let the dependency go. Every re-fetch that moves the window is kept: turning the page still refetches, and leaving a paged grid from page 3 for a surface that consumes the whole batch still refetches. `ganttOwnsData` and `groupingConfig` stay named — the first flips this effect between fetching and standing down, the second changes the projection it asks for. Not the kanban: `ObjectKanban`'s own fetch stands down under `ListView` (`hasExternalData`) and issued zero data requests across the reproduction. Not an in-flight dedupe: the effect now runs once rather than running twice into a suppressed second request. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- .../7394-listview-view-switch-refetch.md | 22 ++ packages/plugin-list/src/ListView.tsx | 81 +++++- .../ListView.viewSwitchRefetch-7394.test.tsx | 232 ++++++++++++++++++ 3 files changed, 322 insertions(+), 13 deletions(-) create mode 100644 .changeset/7394-listview-view-switch-refetch.md create mode 100644 packages/plugin-list/src/__tests__/ListView.viewSwitchRefetch-7394.test.tsx diff --git a/.changeset/7394-listview-view-switch-refetch.md b/.changeset/7394-listview-view-switch-refetch.md new file mode 100644 index 0000000000..b282a6cf07 --- /dev/null +++ b/.changeset/7394-listview-view-switch-refetch.md @@ -0,0 +1,22 @@ +--- +"@object-ui/plugin-list": patch +--- + +fix(plugin-list): stop re-issuing the identical records query on every visualization switch + +`ListView`'s fetch effect named `currentView` in its dependency list, so +switching between Grid, Kanban, Calendar and the other visualizations re-ran it +— but the query it builds never reads the current visualization. The only way a +surface reaches the wire is the `$skip` of its window, and on page 1 that number +is 0 for a flat grid and 0 for every other surface, so the re-issued request was +byte-for-byte the one already on screen. A board reached through a view switch +therefore cost two identical `GET /api/v1/data/?top=…&select=…` round +trips before its first paint. + +The effect is now keyed on the window itself (`fetchSkip`), and the two +surface-shaped readings the fetch used to latch — the server total behind the +grid's pager, and the row-cap banner's "…but the real total is known" half — are +derived at render instead. Every re-fetch that changes the window is kept: +turning the page still refetches, and leaving a paged grid from page 3 for a +board that consumes the whole batch still refetches. What is gone is the +round trip that changed nothing. diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 85b21312fc..12b1097f14 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -1326,7 +1326,14 @@ export const ListView = React.forwardRef(({ // its existing (single) DataTable pager becomes server-driven — records past // the first window are reachable, and we never stack a second pager on top. const [serverPage, setServerPage] = React.useState(1); - const [serverTotal, setServerTotal] = React.useState(null); + /** + * The match total the LAST fetch reported, exactly as it came back + * (objectui#7394). Read through the derived `serverTotal` below, never + * directly: this is the server's answer about the QUERY, while `serverTotal` + * is the answer about the SURFACE, and keeping the two apart is what lets the + * fetch effect stop depending on which visualization is on screen. + */ + const [fetchedTotal, setFetchedTotal] = React.useState(null); // The params of the last successful fetch — the query behind the window this // view is currently showing (objectui#4501). Handed DOWN with that window, in // the same block as `rowCount`/`page`: whoever renders the rows may need to @@ -1372,6 +1379,35 @@ export const ListView = React.forwardRef(({ } }, [initialGroupingConfig]); + /** + * Does THIS surface page server-side, and where does its window start? + * (objectui#7394) + * + * Window the request only for the flat grid view. Grouped grids and the + * visual views (kanban/calendar/gantt/gallery) consume the whole batch, so + * they keep their single-window fetch and in-memory handling. + * + * ⭐ Hoisted out of the fetch effect DELIBERATELY, and the position is the + * whole point rather than tidying. `currentView` used to be named in that + * effect's dependency list, so every visualization switch re-issued the + * query — and the query does not read `currentView`. It reads this `skip`, + * which is the ONLY way the current visualization reaches the wire. At page + * 1 that number is 0 on both sides of a grid/kanban switch, so the re-issued + * request was byte-for-byte the one already on screen: the duplicate + * `GET /api/v1/data/…` this card measured. Keying the effect on `fetchSkip` + * keeps every re-fetch that changes the window (turning the page, leaving a + * paged grid from page 3) and drops the ones that change nothing. + * + * `serverTotal` is derived here for the same reason. It used to be LATCHED + * at fetch time as `paginate ? knownTotal : null`, which is a statement about + * the render that wrote it — so it could only stay true by re-fetching on + * every switch. Derived, it answers for the render that READS it, and the + * values every consumer sees are the ones they saw before. + */ + const paginate = currentView === 'grid' && !(groupingConfig?.fields?.length); + const fetchSkip = paginate ? (serverPage - 1) * effectivePageSize : 0; + const serverTotal = paginate ? fetchedTotal : null; + // Row color state (initialized from schema, user can configure via popover) const [rowColorConfig, setRowColorConfig] = React.useState(schema.rowColor); const [showColorPopover, setShowColorPopover] = React.useState(false); @@ -2304,11 +2340,11 @@ export const ListView = React.forwardRef(({ // or `undefined`, so this is the whole test. const hasFilter = finalFilter !== undefined; - // Window the request only for the flat grid view. Grouped grids and the - // visual views (kanban/calendar/gantt/gallery) consume the whole batch, - // so they keep their single-window fetch and in-memory handling. - const paginate = currentView === 'grid' && !(groupingConfig?.fields?.length); - const skip = paginate ? (serverPage - 1) * effectivePageSize : 0; + // `fetchSkip` is resolved at render (see its definition) and named in + // this effect's dependency list, so what reaches the wire and what + // re-runs this effect are the same number — there is no second + // spelling free to drift from it. + const skip = fetchSkip; // Hoisted out of the `find` call so the exact params that produced this // window can be handed down with it (objectui#4501). One object, one @@ -2360,13 +2396,18 @@ export const ListView = React.forwardRef(({ ? (results as any).total : undefined; const knownTotal = typeof rawTotal === 'number' ? rawTotal : null; - setServerTotal(paginate ? knownTotal : null); + // RAW, not gated on the surface: `serverTotal` applies that gate at + // render (objectui#7394), so this effect no longer has to re-run just + // because a different visualization is now drawing the same rows. + setFetchedTotal(knownTotal); // Past the stale-request guard, so this is the query behind the rows // that were just set — never an in-flight one that lost the race. setLastFindParams(findParams); - setDataLimitReached( - !(paginate && knownTotal != null) && items.length >= effectivePageSize, - ); + // Saturation of the window THIS request carried, and nothing else. + // The "…but the real total is known, so nothing is hidden" half of the + // old expression moved to the banner's own render gate below, for the + // same reason `serverTotal` did (objectui#7394). + setDataLimitReached(items.length >= effectivePageSize); } catch (err) { // Only log + surface errors from the latest request. A failed fetch is // NOT an empty result — record it so the render shows an error panel @@ -2416,7 +2457,17 @@ export const ListView = React.forwardRef(({ // discard-immune thing — props/state where they are the memo's inputs, a // value key where they are not. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schema.objectName, schema.data, dataSource, schema.filter, effectivePageSize, currentSort, currentFilters, userFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, serverPage, currentView, groupingConfig, ganttOwnsData]); // Re-fetch on filter/sort/search/refreshTrigger/perms/page change + // + // objectui#7394 — `currentView` is NOT named here, and `fetchSkip` is. + // The query this effect builds never reads the visualization; it reads the + // WINDOW, and `fetchSkip` is where the visualization reaches that window. + // Naming `currentView` therefore re-issued an identical `find` on every + // switch — measured in a browser as two byte-identical + // `GET /api/v1/data/showcase_task?top=100&select=…` round trips for one + // board. `ganttOwnsData` and `groupingConfig` stay named: the first flips + // this effect between fetching and standing down, and the second changes + // the projection it asks for, so both move the request itself. + }, [schema.objectName, schema.data, dataSource, schema.filter, effectivePageSize, currentSort, currentFilters, userFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, ganttOwnsData]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change // Any change to the result-defining inputs (object, filters, sort, search, // grouping, page size) invalidates the current page number — snap back to @@ -4625,7 +4676,7 @@ export const ListView = React.forwardRef(({ : { data })} loading={loading} onRowSelect={setSelectedRows} - {...(currentView === 'grid' && !(groupingConfig?.fields?.length) && serverTotal != null + {...(paginate && serverTotal != null ? { // Drive the flat grid's single (DataTable) pager from the // server: it renders THIS window as the current page, the real @@ -4772,7 +4823,11 @@ export const ListView = React.forwardRef(({ : t('list.recordCount', { count: totalCount }); })()} - {dataLimitReached && ( + {/* The cap warning is about rows the user CANNOT REACH. A paged grid + with a known total can reach them all through its pager, so the + warning stays off there — the gate the fetch used to apply when it + wrote this flag (objectui#7394). */} + {dataLimitReached && !(paginate && serverTotal != null) && ( {t('list.dataLimitReached', { limit: effectivePageSize })} diff --git a/packages/plugin-list/src/__tests__/ListView.viewSwitchRefetch-7394.test.tsx b/packages/plugin-list/src/__tests__/ListView.viewSwitchRefetch-7394.test.tsx new file mode 100644 index 0000000000..edd09d5709 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.viewSwitchRefetch-7394.test.tsx @@ -0,0 +1,232 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7394 — switching visualization re-issued the IDENTICAL records query. + * + * The card measured two byte-identical + * `GET /api/v1/data/showcase_task?top=100&select=…` round trips for one board, + * back to back, before the first paint, on a production build (so ⛔ not a + * StrictMode double effect) and outside the drag path (a later PATCH refetched + * once). + * + * ## Where it came from + * + * `ListView`'s fetch effect named `currentView` in its dependency list. The + * query that effect builds never reads `currentView`: the visualization + * reaches the wire through ONE number, the `$skip` of the window, and at page 1 + * that number is 0 for a flat grid and 0 for every other surface. So the switch + * re-issued the request already on screen and threw the answer away. + * + * ⛔ NOT the kanban. `ObjectKanban`'s own fetch effect stands down under + * `ListView` (`hasExternalData` — the parent hands rows down as `data`), and it + * issued zero data requests across the reproduction; its one round trip on a + * switch is the object-definition read, which is not what the card counted. + * ⛔ NOT an in-flight dedupe either: the effect now RUNS once, rather than + * running twice into a suppressed second request. + * + * ## The discriminating pair is the point + * + * A "fix" that simply dropped the dependency would also pass the first two + * cases here, so each is matched by a control where the SAME switch must still + * refetch — from page 3, where leaving the paged grid really does move the + * window from `$skip: 200` to no `$skip` at all. §3 is that control, and §4/§5 + * keep the effect and the banner honest either side of the change. + * + * REVERSE VERIFICATION — direction predicted before running, then observed: + * put `currentView` back in the dependency list and §1 and §2 go RED (two + * `find` calls for one board), while §3, §4 and §5 stay GREEN in both worlds. + * + * The browser-level reading behind this pin — the real `ObjectKanban` mounted + * through the real `ViewSwitcher`, counted on four independent channels with a + * hand-issued same-subject self-test — is recorded on the card; this file is + * its component-level guard. + */ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { ListView } from '../ListView'; +import { SchemaRendererProvider } from '@object-ui/react'; +import type { ListViewSchema } from '@object-ui/types'; + +const TOTAL = 40; +const PAGE_SIZE = 2; + +let lastGridProps: any = null; +let kanbanMounts = 0; +let lastKanbanProps: any = null; + +function makeDataSource() { + const find = vi.fn(async (_object: string, params: any) => { + const top = params.$top ?? PAGE_SIZE; + const skip = params.$skip ?? 0; + const rows = Array.from( + { length: Math.max(0, Math.min(top, TOTAL - skip)) }, + (_, i) => ({ id: `id-${skip + i}`, title: `Row ${skip + i}`, status: 'todo' }), + ); + return { data: rows, total: TOTAL }; + }); + return { + find, + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: async (name: string) => ({ + name, + fields: { + id: { type: 'text' }, + title: { type: 'text' }, + status: { type: 'select', options: [{ label: 'Todo', value: 'todo' }] }, + }, + }), + } as any; +} + +const schema: ListViewSchema = { + type: 'list-view', + objectName: 'showcase_task', + viewType: 'grid', + columns: ['title', 'status'], + kanban: { groupByField: 'status' }, + pagination: { pageSize: PAGE_SIZE }, + appearance: { allowedVisualizations: ['grid', 'kanban'] }, +} as any; + +let prevGrid: any; +let prevKanban: any; +beforeAll(() => { + // plugin-grid and plugin-kanban are not dependencies of plugin-list (that + // would be a cycle), so both slots are stubbed — the same device + // `ListView.serverPagination.test.tsx` uses. What is under test here is + // ListView's QUERY COUNT, which is decided before either child renders; the + // real board was driven in a browser for the reading this pin guards. + prevGrid = ComponentRegistry.get('object-grid'); + prevKanban = ComponentRegistry.get('object-kanban'); + ComponentRegistry.register('object-grid', (props: any) => { + lastGridProps = props; + return
; + }); + ComponentRegistry.register('object-kanban', (props: any) => { + lastKanbanProps = props; + React.useEffect(() => { kanbanMounts += 1; }, []); + return
{(props.data ?? []).length} rows
; + }); +}); +afterAll(() => { + if (prevGrid) ComponentRegistry.register('object-grid', prevGrid); + else ComponentRegistry.unregister('object-grid'); + if (prevKanban) ComponentRegistry.register('object-kanban', prevKanban); + else ComponentRegistry.unregister('object-kanban'); +}); + +beforeEach(() => { lastGridProps = null; lastKanbanProps = null; kanbanMounts = 0; }); +afterEach(() => { cleanup(); lastGridProps = null; lastKanbanProps = null; }); + +function renderList(ds: any, overrides: Partial = {}) { + return render( + + + , + ); +} + +const clickView = async (name: string) => { + const tab = await screen.findByRole('tab', { name }); + await act(async () => { tab.click(); }); +}; + +describe('objectui#7394 — a visualization switch does not re-issue the same query', () => { + it('§1 grid -> kanban at page 1 issues NO second find', async () => { + const ds = makeDataSource(); + renderList(ds); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(1)); + const first = ds.find.mock.calls[0][1]; + + await clickView('Kanban'); + + // The switch REALLY happened and the board really got the rows — a count + // taken from a page that never left the grid would pass §1 for free. + await waitFor(() => expect(screen.queryByTestId('kanban-stub')).not.toBeNull()); + expect(kanbanMounts).toBe(1); + expect((lastKanbanProps.data ?? []).length).toBe(PAGE_SIZE); + + // THE DEFECT: this was 2, with the second call identical to the first. + expect(ds.find).toHaveBeenCalledTimes(1); + expect(first.$skip ?? 0).toBe(0); + }); + + it('§2 switching back kanban -> grid issues no further find either', async () => { + const ds = makeDataSource(); + renderList(ds); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(1)); + + await clickView('Kanban'); + await waitFor(() => expect(screen.queryByTestId('kanban-stub')).not.toBeNull()); + await clickView('Grid'); + await waitFor(() => expect(screen.queryByTestId('grid-stub')).not.toBeNull()); + + expect(ds.find).toHaveBeenCalledTimes(1); + }); + + it('§3 CONTROL: from PAGE 3 the same switch still refetches, because the window moves', async () => { + const ds = makeDataSource(); + renderList(ds); + await waitFor(() => expect(lastGridProps?.manualPagination).toBe(true)); + + await act(async () => { lastGridProps.onPageChange(3); }); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(2)); + expect(ds.find.mock.calls[1][1].$skip).toBe((3 - 1) * PAGE_SIZE); + + await clickView('Kanban'); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(3)); + // The kanban consumes the whole batch, so it asks from the top again. + expect(ds.find.mock.calls[2][1].$skip ?? 0).toBe(0); + }); + + it('§4 CONTROL: a real query change after the switch still refetches', async () => { + const ds = makeDataSource(); + const { rerender } = render( + + + , + ); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(1)); + await clickView('Kanban'); + await waitFor(() => expect(screen.queryByTestId('kanban-stub')).not.toBeNull()); + expect(ds.find).toHaveBeenCalledTimes(1); + + // A genuinely different question — the effect must still be alive. + await act(async () => { + rerender( + + + , + ); + }); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(2)); + }); + + it('§5 CONTROL: the row-cap banner keeps the gate the fetch used to apply', async () => { + // Hidden on a paged grid that knows its total — every row is reachable + // through the pager — and shown on a surface that consumes one window. + const ds = makeDataSource(); + renderList(ds); + await waitFor(() => expect(screen.queryByTestId('grid-stub')).not.toBeNull()); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(1)); + expect(screen.queryByTestId('data-limit-warning')).toBeNull(); + + await clickView('Kanban'); + await waitFor(() => expect(screen.queryByTestId('data-limit-warning')).not.toBeNull()); + }); +}); From 4e67cb6cf1334b80e7c4db1a6da3ab626dbd7f4f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 21:21:03 +0000 Subject: [PATCH 2/4] test(plugin-list): make the #7394 controls independent of the defect assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "a real query change still refetches" case asserted the post-switch count as its precondition, so it went red under the ablation alongside the two cases that are ABOUT the defect — which would let a switched-off effect look like a control failure rather than what it is. It now counts from whatever the switch left behind, so it discriminates a fix from a dead effect and stays green on both sides of the change. Registry stubs get their namespaces while here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- .../ListView.viewSwitchRefetch-7394.test.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/plugin-list/src/__tests__/ListView.viewSwitchRefetch-7394.test.tsx b/packages/plugin-list/src/__tests__/ListView.viewSwitchRefetch-7394.test.tsx index edd09d5709..9f361beb8b 100644 --- a/packages/plugin-list/src/__tests__/ListView.viewSwitchRefetch-7394.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.viewSwitchRefetch-7394.test.tsx @@ -112,12 +112,12 @@ beforeAll(() => { ComponentRegistry.register('object-grid', (props: any) => { lastGridProps = props; return
; - }); + }, { namespace: 'plugin-grid' } as never); ComponentRegistry.register('object-kanban', (props: any) => { lastKanbanProps = props; React.useEffect(() => { kanbanMounts += 1; }, []); return
{(props.data ?? []).length} rows
; - }); + }, { namespace: 'plugin-kanban' } as never); }); afterAll(() => { if (prevGrid) ComponentRegistry.register('object-grid', prevGrid); @@ -200,7 +200,10 @@ describe('objectui#7394 — a visualization switch does not re-issue the same qu await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(1)); await clickView('Kanban'); await waitFor(() => expect(screen.queryByTestId('kanban-stub')).not.toBeNull()); - expect(ds.find).toHaveBeenCalledTimes(1); + // Counted from WHATEVER the switch left behind, so this case says nothing + // about the defect and stays green on both sides of the change — which is + // the only way it can discriminate a fix from a switched-off effect. + const afterSwitch = ds.find.mock.calls.length; // A genuinely different question — the effect must still be alive. await act(async () => { @@ -214,7 +217,7 @@ describe('objectui#7394 — a visualization switch does not re-issue the same qu , ); }); - await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(ds.find.mock.calls.length).toBe(afterSwitch + 1)); }); it('§5 CONTROL: the row-cap banner keeps the gate the fetch used to apply', async () => { From 41b364a4f26b3dca6416465fef603e51753d8464 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 21:28:18 +0000 Subject: [PATCH 3/4] fix(plugin-list): keep the exhaustive-deps directive adjacent to the array it governs The #7394 note landed BETWEEN `// eslint-disable-next-line react-hooks/exhaustive-deps` and the dependency array, which detaches the directive: `eslint .` then reports it as an unused directive (an ERROR, not a warning) and the finding it was suppressing reappears elsewhere. The prose moves above the directive, and says so in place so the next edit does not repeat it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- packages/plugin-list/src/ListView.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 12b1097f14..4ef9a331df 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -2456,9 +2456,8 @@ export const ListView = React.forwardRef(({ // (`items`), which is not discard-immune. Key on the nearest // discard-immune thing — props/state where they are the memo's inputs, a // value key where they are not. - // eslint-disable-next-line react-hooks/exhaustive-deps // - // objectui#7394 — `currentView` is NOT named here, and `fetchSkip` is. + // objectui#7394 — `currentView` is NOT named below, and `fetchSkip` is. // The query this effect builds never reads the visualization; it reads the // WINDOW, and `fetchSkip` is where the visualization reaches that window. // Naming `currentView` therefore re-issued an identical `find` on every @@ -2467,6 +2466,13 @@ export const ListView = React.forwardRef(({ // board. `ganttOwnsData` and `groupingConfig` stay named: the first flips // this effect between fetching and standing down, and the second changes // the projection it asks for, so both move the request itself. + // + // ⚠️ The directive below governs the NEXT LINE. Anything written between it + // and the dependency array detaches it from the array and turns it into an + // unused directive — which `eslint .` reports as an ERROR, and which also + // silently un-suppresses nothing, because the finding it was suppressing + // simply moves elsewhere. Add prose ABOVE this point, never below it. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [schema.objectName, schema.data, dataSource, schema.filter, effectivePageSize, currentSort, currentFilters, userFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, ganttOwnsData]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change // Any change to the result-defining inputs (object, filters, sort, search, From c9ff691d28a50e13a0fa7a31195d9332b45c4828 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 21:39:18 +0000 Subject: [PATCH 4/4] docs(plugin-list): say at the site that `serverPage` left the dep list WITH `currentView` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reviewer diffing the dependency array sees two names vanish and has to re-derive why the second is safe. It is the same removal: `fetchSkip` is defined as the exact expression the effect used to compute inline, so the page, the page size and whether the surface pages at all are folded into the one number the query carries. `serverPage` is untouched elsewhere and the pager still reads it. The tighter consequence — a `serverPage` change under `paginate === false` moves nothing and no longer re-runs the effect — is stated as a claim about the definition and labelled as one, because nothing in the suite re-derives it (AGENTS.md #9). Comment only; no behaviour moves with this commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- packages/plugin-list/src/ListView.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 4ef9a331df..5ed43d47d9 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -2467,6 +2467,26 @@ export const ListView = React.forwardRef(({ // this effect between fetching and standing down, and the second changes // the projection it asks for, so both move the request itself. // + // ⭐ TWO names left this list, not one: `serverPage` went with + // `currentView`, and that is the SAME removal rather than a second, + // undescribed change. `fetchSkip` is defined above as the exact expression + // this effect used to compute inline — `paginate ? (serverPage - 1) * + // effectivePageSize : 0` — so the page, the page size and whether this + // surface pages at all are folded into the one number the query carries, + // and the effect names that number instead of its three operands. + // `serverPage` is untouched everywhere else; the pager still reads it, and + // `__tests__/ListView.serverPagination.test.tsx` is what re-derives that + // turning the page still refetches with the `$skip` it moved to. + // + // ⚠️ One consequence follows from the DEFINITION and is deliberate, and + // nothing in the suite re-derives it, which is why it is spelled out here + // rather than left to be inferred from a green run: on a surface where + // `paginate` is false, `fetchSkip` is pinned at 0, so a `serverPage` change + // under it moves nothing and no longer re-runs this effect. The reachable + // instance is the page-reset effect below snapping a grid back to page 1 as + // the user leaves it for a board — a second identical request under the old + // list. Read it as a claim about this definition, ⛔ not as a measured one. + // // ⚠️ The directive below governs the NEXT LINE. Anything written between it // and the dependency array detaches it from the array and turns it into an // unused directive — which `eslint .` reports as an ERROR, and which also