From e972098dce3508544bfedf11d099e8bd1c6c26f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 11:18:20 +0000 Subject: [PATCH] chore(app-shell): the dead slot refreshKey parameter is removed (#4549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Studio Data pillar's grid slot declared a `refreshKey` parameter it never read. #4528 removed the dead forward to `ListView` (which declares no such prop) and left the parameter behind for triage. Measured before changing anything: the pillar was never missing its refresh. plugin-view's ObjectView holds ONE `refreshKey` state and emits it to the slot twice — as `schema.refreshTrigger` (:1057) and as a bare `refreshKey` (:1063). `schema.refreshTrigger` is the only one `ListView` reads (its fetch effect's dependency array), and `renderStudioGridList` spreads the slot schema into `ListView`'s schema, so that signal already arrives. Bumping it refetches; severing it in reverse verification turns the new test red. So the parameter is dropped as dead, the comment asserting the opposite is corrected, and the live channel gains pin tests — including one that the refetch keys off the signal VALUE, not renders, and one that the producer really does emit both keys in lockstep. Empty-frontmatter changeset: no behaviour change, and the package's public `index.d.ts` is byte-identical before and after. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .../studio-grid-slot-refresh-measured.md | 6 + .../StudioDesignSurface.gridRefresh.test.tsx | 172 ++++++++++++++++++ .../studio-design/StudioDesignSurface.tsx | 35 ++-- 3 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 .changeset/studio-grid-slot-refresh-measured.md create mode 100644 packages/app-shell/src/views/studio-design/StudioDesignSurface.gridRefresh.test.tsx diff --git a/.changeset/studio-grid-slot-refresh-measured.md b/.changeset/studio-grid-slot-refresh-measured.md new file mode 100644 index 000000000..505406f3b --- /dev/null +++ b/.changeset/studio-grid-slot-refresh-measured.md @@ -0,0 +1,6 @@ +--- +--- + +The Studio Data pillar's grid slot (`renderStudioGridList`) drops the leftover `refreshKey` parameter and pins the refresh channel it actually rides (#4549). + +No behaviour change, so nothing is released by this. The parameter was already unread — #4528 removed the dead forward to `ListView`, which declares no such prop. What was measured under #4549 is that the pillar was never missing its post-mutation refresh at all: the plugin ObjectView emits the same counter twice, and the live one, `schema.refreshTrigger`, rides this slot's schema spread into `ListView`'s fetch effect. New tests pin that channel (severing it turns them red) and pin that it keys off the signal VALUE rather than renders. `@object-ui/app-shell`'s public `index.d.ts` is byte-identical before and after. diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.gridRefresh.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.gridRefresh.test.tsx new file mode 100644 index 000000000..470637353 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.gridRefresh.test.tsx @@ -0,0 +1,172 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Data pillar's grid refetches when the plugin ObjectView's + * `renderListView` slot signals a mutation — objectui#4549. + * + * The slot emits its refresh signal on TWO keys carrying ONE number: plugin-view's + * ObjectView holds a single `refreshKey` useState and hands it to the slot both as + * `schema.refreshTrigger` (plugin-view/src/ObjectView.tsx:1057) and as a bare + * `refreshKey` argument (:1063). Only the first is a real input — `ListView` lists + * `schema.refreshTrigger` in its fetch effect's dependency array + * (plugin-list/src/ListView.tsx:1609), whereas `refreshKey` is a prop of neither + * `ListView` nor anything it renders, which is the dead forward objectui#4528 + * measured and removed. + * + * So the Data pillar was never missing the refresh: `renderStudioGridList` spreads + * the slot schema into ListView's schema, and `refreshTrigger` rides that spread. + * These tests pin that live channel, because severing it would be invisible by + * inspection — the Studio grid re-renders constantly anyway (its schema is a fresh + * object literal every render), so "the list looks like it updates" either way. + * + * The harness mimics the producer exactly — ONE counter feeding both keys — since + * that is the only combination plugin-view can emit; bumping one while holding the + * other fixed would measure an input that does not exist. The last test pins that + * lockstep against the real plugin-view ObjectView, so the harness cannot drift + * away from the contract it stands in for. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, waitFor, fireEvent } from '@testing-library/react'; +import * as React from 'react'; +import { ObjectView as PluginObjectView } from '@object-ui/plugin-view'; +import { renderStudioGridList } from './StudioDesignSurface'; + +const objectSchema = { + name: 'showcase_task', + label: 'Task', + fields: [ + { name: 'name', label: 'Name', type: 'text' }, + { name: 'status', label: 'Status', type: 'text' }, + ], +}; + +/** + * Hoisted so its identity is stable across renders. ListView derives its expand + * fields from `schema.columns`, and that derivation is in the fetch effect's + * dependency chain by IDENTITY — so a fresh array literal per render refetches on + * every render, regardless of the refresh signal. Studio really does hand it a + * fresh array (StudioDesignSurface's `table.fields` is rebuilt inline), so that is + * a real defect, but a separate one: it is upstream of this slot and would mask + * the value-vs-render distinction these tests exist to measure. Filed separately. + */ +const STABLE_COLUMNS = ['name', 'status']; + +function createDataSource() { + const find = vi.fn(async () => []); + return { + find, + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + getObjectSchema: vi.fn(async () => objectSchema), + }; +} + +/** + * The slot payload plugin-view's ObjectView builds, reduced to the keys that + * matter here. `counter` stands in for its single `refreshKey` state, which feeds + * both keys. The schema object is rebuilt per call on purpose: the real producer + * hands the slot a fresh literal every render, so a mechanism keyed on object + * identity rather than on the VALUE would refetch on every render. + */ +function slotPayload(counter: number, dataSource: unknown) { + return { + schema: { + type: 'list-view', + objectName: 'showcase_task', + columns: STABLE_COLUMNS, + refreshTrigger: counter, + } as Record, + dataSource, + }; +} + +/** Drives the slot the way plugin-view's ObjectView drives it. */ +function Harness({ dataSource }: { dataSource: unknown }): React.ReactElement { + const [counter, setCounter] = React.useState(0); + const [, forceRender] = React.useState(0); + return ( +
+ + + {renderStudioGridList(slotPayload(counter, dataSource))} +
+ ); +} + +/** Long enough for the mount's own fetch to settle before a measurement. */ +const SETTLE_MS = 250; +const settle = () => new Promise((r) => setTimeout(r, SETTLE_MS)); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('Studio Data pillar grid — the renderListView slot refresh signal', () => { + it('refetches when the slot bumps its refresh signal', async () => { + const ds = createDataSource(); + const { getByTestId } = render(); + + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + await settle(); + const before = ds.find.mock.calls.length; + + fireEvent.click(getByTestId('bump')); + + await waitFor(() => expect(ds.find.mock.calls.length).toBeGreaterThan(before)); + }); + + it('keys off the signal VALUE, not renders — a re-render carrying the same value does not refetch', async () => { + const ds = createDataSource(); + const { getByTestId } = render(); + + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + await settle(); + const before = ds.find.mock.calls.length; + + // Three renders, each handing the slot a fresh schema literal with an + // unchanged counter — the Studio's steady state. + fireEvent.click(getByTestId('rerender')); + fireEvent.click(getByTestId('rerender')); + fireEvent.click(getByTestId('rerender')); + await settle(); + + expect(ds.find.mock.calls.length).toBe(before); + }); + + it('the plugin ObjectView emits the refresh signal on schema.refreshTrigger, in lockstep with the slot refreshKey', async () => { + const seen: { refreshTrigger: unknown; refreshKey: unknown }[] = []; + const ds = createDataSource(); + + render( + ) => { + const s = props.schema as Record; + seen.push({ refreshTrigger: s?.refreshTrigger, refreshKey: props.refreshKey }); + return
; + }} + />, + ); + + await waitFor(() => expect(seen.length).toBeGreaterThan(0)); + + // The dead `refreshKey` argument is still emitted by the producer (this card + // only stops the Studio consumer from declaring a parameter it cannot use). + // Both keys must carry the one counter, which is what lets the harness above + // drive `refreshTrigger` alone and still be faithful to the real slot. + for (const s of seen) { + expect(typeof s.refreshTrigger).toBe('number'); + expect(s.refreshTrigger).toBe(s.refreshKey); + } + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index ee312f91d..0bc43cc4b 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -1905,27 +1905,20 @@ const STUDIO_SYSTEM_FIELD_NAMES = new Set([ * plugin ObjectView's `renderListView` slot, so the object-view still owns data * fetching while ListView owns the toolbar + grid. Defined at module scope (not * inline) so it stays a static component reference. + * + * Exported for tests (StudioDesignSurface.gridRefresh.test.tsx) — the refresh + * channel this slot rides is a property of THIS function's schema forward, so + * the test drives the real slot rather than a reconstruction of it. It is a + * render-prop callback rather than a component, so Fast Refresh cannot treat it + * as one; the export is scoped to that test and never reaches the package entry + * (`index.ts` re-exports a named list, and `package.json` exports only `.`). */ -function renderStudioGridList(props: { +// eslint-disable-next-line react-refresh/only-export-components -- see above +export function renderStudioGridList(props: { schema: Record; dataSource: unknown; onEdit?: (record: Record) => void; className?: string; - /** - * Supplied by the plugin ObjectView's `renderListView` slot, and deliberately - * NOT read here. It used to be forwarded as `refreshKey={refreshKey}` to - * `ListView`, which reaches nothing: neither `ListView` nor any view - * component it renders declares or reads a `refreshKey` prop, so it rode the - * `{...props}` forward and was dropped. That was invisible until - * objectui#4528 stopped `ListViewProps` erasing itself. - * - * Removing the dead prop is behaviour-preserving; WIRING it is not, so it is - * deliberately left for triage rather than fixed under a type-only card. The - * working precedent is `app-shell/src/views/ObjectView.tsx`, which folds the - * slot's `refreshKey` into React's `key` to force a remount. Filed as a - * separate finding. - */ - refreshKey?: number; onAddRecord?: () => void; }): React.ReactElement { const { schema: listSchema, dataSource: ds, onEdit, className, onAddRecord } = props; @@ -1933,6 +1926,16 @@ function renderStudioGridList(props: {