Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/server-table-revalidation.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions apps/loupe/src/generated/component-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4273,6 +4273,12 @@ export const componentDocs = Object.freeze([
"type": "(request: TableExportRequest, signal: AbortSignal) => Promise<Blob>",
"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.",
Expand Down
15 changes: 13 additions & 2 deletions apps/loupe/src/routes/table-chrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ const columns = defineColumns<ChromeRow>([
{ id: "amount", header: "Amount", accessor: row => row.amount, numeric: true, footer: values => values.reduce((sum, row) => sum + row.amount, 0) },
]);

const refreshColumns = defineColumns<ChromeRow>([
{ 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) => <details><summary>Notes for {row.id}</summary><input aria-label={`Draft for ${row.id}`} /></details> },
]);

function describeAction(action: string, context: DataTableActionContext<ChromeRow>): 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(",")}`;
Expand All @@ -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<ChromeRow>[] = [
{ id: "archive", label: "Archive", tone: "danger", shortcut: "A", onSelect: context => setActionResult(describeAction("archive", context)) },
Expand Down Expand Up @@ -77,8 +85,11 @@ export default function TableChromeFixture() {
</section>
<section aria-label="Refresh table">
<Button onClick={() => setFailNext(true)}>Fail next refresh</Button>
<DataTable mode="server" columns={columns} getRowId={row => row.id} caption="Refresh accounts" pagination={false} initialViewportHeight={150}
initialResult={{ rows, total: rows.length }} onStateChange={refresh} />
<Button onClick={() => setRefreshKey(value => value + 1)}>Revalidate accounts</Button>
<output aria-label="Refresh selection">{refreshSelection()}</output>
<DataTable mode="server" columns={refreshColumns} getRowId={row => 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()} />
</section>
<output aria-label="Hydration state">{ready() ? "Ready" : "Server"}</output>
</main>;
Expand Down
2 changes: 1 addition & 1 deletion dist/skill/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Row>,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<Row>,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<Row>,value:FilterNode,onChange:(filter:FilterNode)=>v,facets?:Readonly<Record<s,Readonly<Record<s,n>>>>,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.

Expand Down
16 changes: 16 additions & 0 deletions docs/table-requests.md
Original file line number Diff line number Diff line change
@@ -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);
<DataTable mode="server" columns={columns} getRowId={row => 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.
Expand Down
1 change: 1 addition & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2739,6 +2739,7 @@ Props:
- `initialResult: DataTableResult<Row>` (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<DataTableResult<Row>>` (optional). Server request adapter receiving complete state and an AbortSignal. Available only in server mode.
- `onExport: (request:TableExportRequest,signal:AbortSignal)=>Promise<Blob>` (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<Row>` (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.
Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Row>,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<Row>,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<Row>,value:FilterNode,onChange:(filter:FilterNode)=>v,facets?:Readonly<Record<s,Readonly<Record<s,n>>>>,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.

Expand Down
1 change: 1 addition & 0 deletions packages/table/src/DataTable.meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default defineMeta<DataTableProps<ExampleRow>>({
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"],
Expand Down
9 changes: 9 additions & 0 deletions packages/table/src/DataTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ const chromeColumns = defineColumns<Row>([
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(() => <ThemeProvider><DataTable mode="server" columns={columns}
getRowId={row => row.id} caption="Revalidated rows" pagination={{ pageIndex: 0, pageSize: 10 }}
initialResult={initialResult} refreshKey={7} onStateChange={async () => { calls++; return initialResult; }} /></ThemeProvider>);
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(() => <ThemeProvider><ThemeScope theme="studio"><DataTable data={rows} columns={columns} getRowId={row => row.id} caption="Studio rows" density={density} pagination={false} /></ThemeScope></ThemeProvider>);
Expand Down
9 changes: 9 additions & 0 deletions packages/table/src/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export interface ClientDataTableProps<Row extends object> extends DataTableBaseP
readonly initialResult?: never;
readonly onStateChange?: never;
readonly onExport?: never;
readonly refreshKey?: never;
}

export interface ServerDataTableProps<Row extends object> extends DataTableBaseProps<Row> {
Expand All @@ -202,6 +203,8 @@ export interface ServerDataTableProps<Row extends object> extends DataTableBaseP
readonly data?: never;
readonly initialResult?: DataTableResult<Row>;
readonly onStateChange: (state: TableState, signal: AbortSignal) => Promise<DataTableResult<Row>>;
/** 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<Blob>;
}
Expand Down Expand Up @@ -737,6 +740,12 @@ export function DataTable<Row extends object>(props: DataTableProps<Row>): 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<ShortcutAction | undefined>(() => searchOptions?.shortcut ? Object.freeze({
Expand Down
6 changes: 6 additions & 0 deletions sheen.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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<Row>",
Expand Down
74 changes: 74 additions & 0 deletions tests/browser/table-revalidation.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void>(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");
});