diff --git a/.changeset/server-table-revalidation.md b/.changeset/server-table-revalidation.md new file mode 100644 index 0000000..38b610f --- /dev/null +++ b/.changeset/server-table-revalidation.md @@ -0,0 +1,5 @@ +--- +"@gemologic/sheen-table": patch +--- + +Add the server DataTable `refreshKey` prop for background revalidation and post-mutation invalidation. Key changes request the latest query through the existing abortable request controller, retaining accepted content, pagination, column layout, drafts, selection, and scroll. Initial keys do not trigger duplicate hydration requests. diff --git a/apps/loupe/src/generated/component-docs.ts b/apps/loupe/src/generated/component-docs.ts index 8f7f33d..c87103b 100644 --- a/apps/loupe/src/generated/component-docs.ts +++ b/apps/loupe/src/generated/component-docs.ts @@ -4273,6 +4273,12 @@ export const componentDocs = Object.freeze([ "type": "(request: TableExportRequest, signal: AbortSignal) => Promise", "required": false }, + { + "name": "refreshKey", + "description": "Server-only invalidation key. Changes revalidate the latest requested or accepted query, retaining rows, layout, drafts, selection, and scroll. The initial value does not fetch; changing it supersedes pending transport.", + "type": "string | number", + "required": false + }, { "name": "columns", "description": "Opaque column definitions returned by defineColumns. Noneditable custom cells may declare cellDependencies for extra row fields; accessor values always invalidate, and omitted dependencies retain whole-row invalidation. Include fields captured by event handlers and replace row data immutably.", diff --git a/apps/loupe/src/routes/table-chrome.tsx b/apps/loupe/src/routes/table-chrome.tsx index c0f7e39..523e2d0 100644 --- a/apps/loupe/src/routes/table-chrome.tsx +++ b/apps/loupe/src/routes/table-chrome.tsx @@ -16,6 +16,12 @@ const columns = defineColumns([ { id: "amount", header: "Amount", accessor: row => row.amount, numeric: true, footer: values => values.reduce((sum, row) => sum + row.amount, 0) }, ]); +const refreshColumns = defineColumns([ + { id: "name", header: "Name", accessor: row => row.name, sort: "text" }, + { id: "amount", header: "Amount", accessor: row => row.amount, numeric: true }, + { id: "draft", header: "Local draft", cellDependencies: [], cell: (_, row) =>
Notes for {row.id}
}, +]); + function describeAction(action: string, context: DataTableActionContext): string { const selection = context.selection.kind === "ids" ? context.selection.ids.join(",") : `query excluding ${context.selection.excluded.join(",")}`; return `${action}|anchor:${context.anchor?.id ?? "none"}|selection:${selection}|loaded:${context.loadedRows.map(row => row.id).join(",")}`; @@ -42,6 +48,8 @@ export default function TableChromeFixture() { const [actionResult, setActionResult] = createSignal("None"); const [selectionResult, setSelectionResult] = createSignal("None"); const [failNext, setFailNext] = createSignal(false); + const [refreshKey, setRefreshKey] = createSignal(0); + const [refreshSelection, setRefreshSelection] = createSignal(""); const [ready, setReady] = createSignal(false); const actions: readonly DataTableAction[] = [ { id: "archive", label: "Archive", tone: "danger", shortcut: "A", onSelect: context => setActionResult(describeAction("archive", context)) }, @@ -77,8 +85,11 @@ export default function TableChromeFixture() {
- row.id} caption="Refresh accounts" pagination={false} initialViewportHeight={150} - initialResult={{ rows, total: rows.length }} onStateChange={refresh} /> + + {refreshSelection()} + row.id} caption="Refresh accounts" pagination={false} initialViewportHeight={150} + selection={{ mode: "multiple", onChange: selection => setRefreshSelection(JSON.stringify(selection)) }} + initialResult={{ rows, total: rows.length }} onStateChange={refresh} refreshKey={refreshKey()} />
{ready() ? "Ready" : "Server"} ; diff --git a/dist/skill/llms.txt b/dist/skill/llms.txt index 87088fc..1c52472 100644 --- a/dist/skill/llms.txt +++ b/dist/skill/llms.txt @@ -100,7 +100,7 @@ Aliases: s=string, b=boolean, n=number, v=void, ro=readonly, node=JSX.Element, P - WorkspaceSwitcher(workspace:AdminWorkspaceModel,target?:"topbar-start"|"topbar-end"|"sidebar-header"|"sidebar-footer",collapsed?:b): An app-owned workspace selector with consistent topbar, sidebar-header, and sidebar-footer presentations; See full example. ## @gemologic/sheen-table -- DataTable(columns:SheenColumns,getRowId:(row:Row)=>s,caption:s,+29 optional): Virtualized continuous or paginated data with phone cards, selection, layouts, groups, and trees; See full example. +- DataTable(columns:SheenColumns,getRowId:(row:Row)=>s,caption:s,+30 optional): Virtualized continuous or paginated data with phone cards, selection, layouts, groups, and trees; See full example. - FilterBar(columns:SheenColumns,value:FilterNode,onChange:(filter:FilterNode)=>v,facets?:Readonly>>>,disabled?:b,class?:s,dateEditor?:FilterDateEditor): Edits the accepted serializable filter AST through removable condition chips and typed draft popovers; See full example. - QueryBuilder(columns:ro QueryBuilderColumn[],value:FilterNode,onChange:(filter:FilterNode)=>v,label?:s,disabled?:b): Builds nested, typed, serializable table filters with complete keyboard alternatives to structural editing; See full example. diff --git a/docs/table-requests.md b/docs/table-requests.md index 3358d85..71700db 100644 --- a/docs/table-requests.md +++ b/docs/table-requests.md @@ -1,5 +1,21 @@ # Server table request ownership +## DataTable revalidation + +Server DataTable accepts an optional `refreshKey: string | number`. Change it after a mutation or on an application-owned polling interval to request the latest requested query (or the accepted query when idle). Keep the component mounted and retain stable row IDs. The initial key does not fetch, including when `initialResult` supplies SSR content. Client tables instead receive updated `data`. + +```tsx +const [refreshKey, setRefreshKey] = createSignal(0); + row.id} + caption="Accounts" pagination={{ pageIndex: 0, pageSize: 25 }} + initialResult={initialResult} onStateChange={loadAccounts} + refreshKey={refreshKey()} />; +// After a successful mutation, or from an app-owned timer: +setRefreshKey(value => value + 1); +``` + +Revalidation does not reset search drafts, filters, sorting, pagination, column layout, selection, expansion, or scroll. Existing request validation may clamp a page after deletions. Pending and failed revalidation retains the accepted result and uses the usual progress/error/retry UI. A new key supersedes pending transport, including a query change, using that latest requested query. Late responses cannot overwrite newer results. Choose polling intervals longer than the application's request timeout to avoid repeatedly aborting slow work, and stop polling on disposal and authorization changes. Key changes are invalidation, not an authorization boundary: remove unauthorized content immediately when permissions change. + createTableRequests accepts an app callback taking state and AbortSignal and returning rows/total plus any app-typed response metadata. State includes explicit pagination and must be structured-cloneable. This framework-neutral controller exposes request, retry, getSnapshot, clear, and dispose. It does not fetch automatically or supply a reactive UI subscription; the DataTable adapter must publish snapshots around awaited requests. An optional second argument, { state, result }, supplies initial accepted results for SSR/hydration. Construction clones the query, validates pagination/completeness, and adopts the result without calling transport. Empty totals normalize to page zero. A nonempty dataset whose initial requested page needs clamping throws rather than publishing an invalid page or performing hidden I/O. The app must supply equivalent initial state/data on server and client and retain responsibility for safe serialization and authorization. Result row identity is preserved. diff --git a/llms-full.txt b/llms-full.txt index 149725b..0918f0d 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2739,6 +2739,7 @@ Props: - `initialResult: DataTableResult` (optional). Optional authoritative server result used for SSR without a duplicate hydration request. Delegated grouping includes groups for the accepted page. - `onStateChange: (state:TableState,signal:AbortSignal)=>Promise>` (optional). Server request adapter receiving complete state and an AbortSignal. Available only in server mode. - `onExport: (request:TableExportRequest,signal:AbortSignal)=>Promise` (optional). Optional server adapter returning a Blob for the captured accepted state and immutable selection. Server export UI is hidden without it. +- `refreshKey: string|number` (optional). Server-only invalidation key. Changes revalidate the latest requested or accepted query, retaining rows, layout, drafts, selection, and scroll. The initial value does not fetch; changing it supersedes pending transport. - `columns: SheenColumns` (required). Opaque column definitions returned by defineColumns. Noneditable custom cells may declare cellDependencies for extra row fields; accessor values always invalidate, and omitted dependencies retain whole-row invalidation. Include fields captured by event handlers and replace row data immutably. - `getRowId: (row:Row)=>string` (required). Returns a stable, unique, nonempty row ID. - `caption: string` (required). Required accessible table caption, visually hidden by default. diff --git a/llms.txt b/llms.txt index 87088fc..1c52472 100644 --- a/llms.txt +++ b/llms.txt @@ -100,7 +100,7 @@ Aliases: s=string, b=boolean, n=number, v=void, ro=readonly, node=JSX.Element, P - WorkspaceSwitcher(workspace:AdminWorkspaceModel,target?:"topbar-start"|"topbar-end"|"sidebar-header"|"sidebar-footer",collapsed?:b): An app-owned workspace selector with consistent topbar, sidebar-header, and sidebar-footer presentations; See full example. ## @gemologic/sheen-table -- DataTable(columns:SheenColumns,getRowId:(row:Row)=>s,caption:s,+29 optional): Virtualized continuous or paginated data with phone cards, selection, layouts, groups, and trees; See full example. +- DataTable(columns:SheenColumns,getRowId:(row:Row)=>s,caption:s,+30 optional): Virtualized continuous or paginated data with phone cards, selection, layouts, groups, and trees; See full example. - FilterBar(columns:SheenColumns,value:FilterNode,onChange:(filter:FilterNode)=>v,facets?:Readonly>>>,disabled?:b,class?:s,dateEditor?:FilterDateEditor): Edits the accepted serializable filter AST through removable condition chips and typed draft popovers; See full example. - QueryBuilder(columns:ro QueryBuilderColumn[],value:FilterNode,onChange:(filter:FilterNode)=>v,label?:s,disabled?:b): Builds nested, typed, serializable table filters with complete keyboard alternatives to structural editing; See full example. diff --git a/packages/table/src/DataTable.meta.ts b/packages/table/src/DataTable.meta.ts index 520dde3..10d97fc 100644 --- a/packages/table/src/DataTable.meta.ts +++ b/packages/table/src/DataTable.meta.ts @@ -37,6 +37,7 @@ export default defineMeta>({ data: { description: "Complete bounded client dataset. Available only in client mode." }, initialResult: { description: "Optional authoritative server result used for SSR without a duplicate hydration request. Delegated grouping includes groups for the accepted page." }, onStateChange: { description: "Server request adapter receiving complete state and an AbortSignal. Available only in server mode." }, + refreshKey: { description: "Server-only invalidation key. Changes revalidate the latest requested or accepted query, retaining rows, layout, drafts, selection, and scroll. The initial value does not fetch; changing it supersedes pending transport." }, onExport: { description: "Optional server adapter returning a Blob for the captured accepted state and immutable selection. Server export UI is hidden without it." }, }, tokens: ["--sheen-table-row-h", "--sheen-color-border", "--sheen-color-bg", "--sheen-color-bg-raised"], diff --git a/packages/table/src/DataTable.test.tsx b/packages/table/src/DataTable.test.tsx index 94b426c..588a679 100644 --- a/packages/table/src/DataTable.test.tsx +++ b/packages/table/src/DataTable.test.tsx @@ -39,6 +39,15 @@ const chromeColumns = defineColumns([ const rows = Array.from({ length: 40 }, (_, index): Row => ({ id: `row-${index}`, name: `Row ${index}`, amount: index })); describe("DataTable SSR", () => { + it("adopts a refresh key without transport or incomplete server content", () => { + let calls = 0; + const initialResult = { rows: rows.slice(0, 10), total: rows.length }; + const html = renderToString(() => row.id} caption="Revalidated rows" pagination={{ pageIndex: 0, pageSize: 10 }} + initialResult={initialResult} refreshKey={7} onStateChange={async () => { calls++; return initialResult; }} />); + expect(calls).toBe(0); + expect(html.match(/data-row-id="row-/gu)).toHaveLength(10); + }); it("uses Studio density geometry in the server virtual range before measuring the DOM", () => { for (const [density, height] of [["compact", 36], ["comfortable", 40], ["spacious", 48]] satisfies readonly (readonly ["compact" | "comfortable" | "spacious", number])[]) { const html = renderToString(() => row.id} caption="Studio rows" density={density} pagination={false} />); diff --git a/packages/table/src/DataTable.tsx b/packages/table/src/DataTable.tsx index ce03090..2b978e8 100644 --- a/packages/table/src/DataTable.tsx +++ b/packages/table/src/DataTable.tsx @@ -194,6 +194,7 @@ export interface ClientDataTableProps extends DataTableBaseP readonly initialResult?: never; readonly onStateChange?: never; readonly onExport?: never; + readonly refreshKey?: never; } export interface ServerDataTableProps extends DataTableBaseProps { @@ -202,6 +203,8 @@ export interface ServerDataTableProps extends DataTableBaseP readonly data?: never; readonly initialResult?: DataTableResult; readonly onStateChange: (state: TableState, signal: AbortSignal) => Promise>; + /** Change to revalidate the latest requested/accepted query without resetting layout or scroll. Initial value does not fetch. */ + readonly refreshKey?: string | number; /** Creates a complete server-side export for the captured accepted query and selection. Without this adapter, export UI is hidden. */ readonly onExport?: (request: TableExportRequest, signal: AbortSignal) => Promise; } @@ -737,6 +740,12 @@ export function DataTable(props: DataTableProps): JSX.E }); }, { defer: true })); + createEffect(on(() => props.refreshKey, () => { + if (!requests) return; + const snapshot = requests.getSnapshot(); + void transition(snapshot.requested ?? snapshot.accepted?.state ?? initialState); + }, { defer: true })); + const searchLabel = () => (searchOptions?.label ?? messages().searchTable).replaceAll("{caption}", props.caption); const searchPlaceholder = () => (searchOptions?.placeholder ?? searchLabel()).replaceAll("{caption}", props.caption); const searchShortcut = createMemo(() => searchOptions?.shortcut ? Object.freeze({ diff --git a/sheen.manifest.json b/sheen.manifest.json index d2b5307..687602f 100644 --- a/sheen.manifest.json +++ b/sheen.manifest.json @@ -95478,6 +95478,12 @@ "required": false, "inherited": false }, + "refreshKey": { + "description": "Server-only invalidation key. Changes revalidate the latest requested or accepted query, retaining rows, layout, drafts, selection, and scroll. The initial value does not fetch; changing it supersedes pending transport.", + "type": "string | number", + "required": false, + "inherited": false + }, "columns": { "description": "Opaque column definitions returned by defineColumns. Noneditable custom cells may declare cellDependencies for extra row fields; accessor values always invalidate, and omitted dependencies retain whole-row invalidation. Include fields captured by event handlers and replace row data immutably.", "type": "SheenColumns", diff --git a/tests/browser/table-revalidation.spec.ts b/tests/browser/table-revalidation.spec.ts new file mode 100644 index 0000000..f2e0dee --- /dev/null +++ b/tests/browser/table-revalidation.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from "@playwright/test"; + +test("refresh keys preserve accepted rows, focus, drafts, expansion and selection through success and failure", async ({ page }) => { + const requests: string[] = []; + page.on("request", request => { + if (request.url().includes("/api/table-chrome?delay=650")) requests.push(request.url()); + }); + await page.goto("/table-chrome"); + await expect(page.getByRole("status", { name: "Hydration state" })).toHaveText("Ready"); + expect(requests).toHaveLength(0); + const section = page.getByRole("region", { name: "Refresh table" }); + const root = section.locator(".sheen-data-table"); + const alpha = section.locator('tr[data-row-id="alpha"]'); + await section.getByRole("checkbox", { name: "Select row alpha" }).press("Space"); + await section.getByText("Notes for alpha", { exact: true }).click(); + const draft = section.getByRole("textbox", { name: "Draft for alpha" }); + await draft.fill("keep my draft"); + await draft.evaluate(element => { + if (!(element instanceof HTMLInputElement)) throw new Error("Expected draft input"); + element.setSelectionRange(2, 6); + element.setAttribute("data-original", "yes"); + }); + await section.getByRole("button", { name: "Revalidate accounts" }).evaluate(element => { + if (!(element instanceof HTMLButtonElement)) throw new Error("Expected refresh button"); + element.click(); + }); + await expect(root).toHaveAttribute("data-pending", ""); + const retained = await draft.evaluate(async element => { + if (!(element instanceof HTMLInputElement)) throw new Error("Expected input"); + const table = element.closest("table"); + const samples: boolean[] = []; + for (let frame = 0; frame < 50; frame++) { + samples.push(element.isConnected && document.activeElement === element && element.value === "keep my draft" + && element.selectionStart === 2 && element.selectionEnd === 6 + && table?.querySelectorAll("tbody tr[data-row-id]").length === 3); + await new Promise(resolve => requestAnimationFrame(() => resolve())); + } + return samples; + }); + expect(retained.every(Boolean)).toBe(true); + await expect(root).not.toHaveAttribute("data-pending", ""); + await expect(alpha).toHaveAttribute("data-selected", ""); + await expect(draft).toHaveAttribute("data-original", "yes"); + await expect(draft).toBeFocused(); + expect(requests).toHaveLength(1); + + await section.getByRole("button", { name: "Fail next refresh" }).click(); + await section.getByRole("button", { name: "Revalidate accounts" }).press("Enter"); + await expect(section.getByRole("alert")).toContainText("Chrome request failed (503)"); + await expect(draft).toHaveValue("keep my draft"); + await expect(alpha).toHaveAttribute("data-selected", ""); + await section.getByRole("button", { name: "Revalidate accounts" }).press("Enter"); + await expect(root).not.toHaveAttribute("data-pending", ""); + await expect(section.getByRole("alert")).toHaveCount(0); + await expect(draft).toHaveAttribute("data-original", "yes"); +}); + +test("key invalidation preserves the newest in-flight sort query", async ({ page }) => { + await page.goto("/table-chrome"); + await expect(page.getByRole("status", { name: "Hydration state" })).toHaveText("Ready"); + const section = page.getByRole("region", { name: "Refresh table" }); + const table = section.getByRole("table", { name: "Refresh accounts" }); + const sort = table.getByRole("button", { name: "Name", exact: true }); + await sort.click(); + await expect(section.locator(".sheen-data-table")).not.toHaveAttribute("data-pending", ""); + const requested = page.waitForRequest(request => request.url().includes("direction=desc")); + await sort.click(); + await requested; + const revalidated = page.waitForResponse(response => response.url().includes("direction=desc") && response.ok()); + await section.getByRole("button", { name: "Revalidate accounts" }).click(); + await revalidated; + await expect(table.locator("tbody tr[data-row-id]").first()).toHaveAttribute("data-row-id", "gamma"); + await expect(table.locator('th[data-column="name"]')).toHaveAttribute("aria-sort", "descending"); +});