Skip to content

feat: standardize list refresh after mutation (P0/P1/P2)#1127

Merged
hotlong merged 5 commits intomainfrom
copilot/standardize-list-refresh-mutations
Mar 25, 2026
Merged

feat: standardize list refresh after mutation (P0/P1/P2)#1127
hotlong merged 5 commits intomainfrom
copilot/standardize-list-refresh-mutations

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 25, 2026

ListView does not refresh after create/update/delete because ObjectView.handleFormSuccess increments its own refreshKey, but renderListView renders a ListView with an independent, unconnected refreshKey. This is an architectural gap — no signal path exists from mutation to list re-fetch.

P0 — refreshTrigger prop (fixes the reported bug)

  • Added refreshTrigger?: number to ListViewSchema in @object-ui/types
  • ListView data-fetch effect now includes schema.refreshTrigger in its dependency array
  • plugin-view/ObjectView.renderContent() passes its refreshKey into the renderListView callback as both a direct prop and schema.refreshTrigger
  • Console ObjectView combines local + plugin refresh signals: refreshKey + (pluginRefreshKey || 0)

P1 — imperative refresh() API + useDataRefresh hook

  • ListView converted from React.FC to React.forwardRef<ListViewHandle>
  • Exposes refresh() via useImperativeHandle
  • ListViewHandle exported from @object-ui/plugin-list
  • New reusable useDataRefresh(dataSource, objectName) hook in @object-ui/react — encapsulates refreshKey state + onMutation auto-subscription pattern for any view component
const listRef = useRef<ListViewHandle>(null);
<ListView ref={listRef} schema={schema} />
// after mutation:
listRef.current?.refresh();
// Or use the hook in any custom view:
const { refreshKey, refresh } = useDataRefresh(dataSource, schema.objectName);

P2 — DataSource mutation event bus (all views)

  • Added MutationEvent<T> interface and optional onMutation(cb): unsubscribe to DataSource
  • All data-bound views now auto-subscribe to mutation events when the DataSource implements onMutation:
    • ListView (@object-ui/plugin-list) — refreshes on matching resource mutations
    • ObjectView (@object-ui/plugin-view) — auto-refreshes non-grid view types on mutations
    • ObjectKanban (@object-ui/plugin-kanban) — auto-refreshes in standalone mode (skips when parent provides external data)
    • ObjectCalendar (@object-ui/plugin-calendar) — auto-refreshes in standalone mode (skips when parent provides external data)
  • ValueDataSource emits mutation events on create/update/delete with proper unsubscribe lifecycle
const unsub = dataSource.onMutation?.((event) => {
  if (event.resource === 'contacts') refreshList();
});

Tests

22 new tests: 8 in ListRefresh.test.tsx (refreshTrigger, imperative ref, onMutation auto-refresh, backward compat) + 7 in useDataRefresh.test.ts (hook subscribe/unsubscribe/refresh) + 7 in ValueDataSource.test.ts (emit/subscribe/unsubscribe/bulk). All 748 tests pass across 38 test files.

Note on Root Cause #5 (console boilerplate): The 4 system management pages (User/Org/Role/Permission) still use manual fetchData/setRefreshKey patterns. Refactoring these pages to use ListView+onMutation is a separate, larger scope change recommended for a follow-up PR.

Original prompt

This section details on the original issue you should resolve

<issue_title>Global: Standardize List Refresh After Mutation (addRecord/create/update/delete) Across All ObjectUI Scenarios</issue_title>
<issue_description>
After thorough code analysis of the entire ObjectUI codebase, I identified this as a platform-level architectural design deficiency, not just a single bug. Here's the complete assessment:

5 Root Causes Identified

# Root Cause Affected Packages Severity
1 No unified event-driven refresh modelDataSource.create/update/delete completes silently; no active views are notified @object-ui/types, all consumers 🔴 Critical
2 ListView lacks imperative refresh() APIonAddRecord is fire-and-forget, parent cannot signal re-fetch plugin-list 🔴 Critical
3 refreshKey not propagatedObjectView.handleFormSuccess increments its own refreshKey, but renderListView renders a ListView with an independent refreshKey plugin-viewplugin-list 🔴 Critical
4 Schema-level refreshAfter not universally honored — only works in RecordDetailView actions, not in addRecord, bulk ops, lookup quick-create, etc. plugin-view, plugin-detail, plugin-list 🟡 High
5 Duplicated manual fetchData/setRefreshKey boilerplate — User, Org, Role, Permission pages each hand-roll their own refresh logic apps/console 🟡 Medium

3-Phase Solution Proposed

Phase Scope Key Deliverables
P0 — Immediate Fix Pass refreshKey through renderListViewListView + add refreshTrigger prop Fixes the reported bug
P1 — Imperative API ListView exposes ref.refresh() via forwardRef + useImperativeHandle; abstract useListRefresh hook Any parent can trigger refresh
P2 — Event Bus DataSource.onMutation() subscription; all views auto-refresh on relevant object mutations Salesforce/Retool/Airtable parity

Industry References

The issue includes design comparisons with:

  • Salesforce LightningrefreshApex / force:refreshView event bus
  • Retoolquery.trigger() invalidation
  • AppsmithonSuccess → listQuery.run() action chaining
  • Airtable — Optimistic insert + server-confirm sync</issue_description>

Comments on the Issue (you are @copilot in this section)


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 25, 2026

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

Project Deployment Actions Updated (UTC)
objectui-demo Ready Ready Preview, Comment Mar 25, 2026 11:59am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Mar 25, 2026 11:59am
objectui-storybook Ignored Ignored Mar 25, 2026 11:59am

Request Review

P0: Add refreshTrigger prop to ListViewSchema, wire it through
    ObjectView renderListView → ListView data fetch effect.
P1: ListView now exposes imperative refresh() via forwardRef.
P2: Add MutationEvent type + onMutation() subscriber to DataSource,
    implement in ValueDataSource, auto-subscribe in ListView.

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/3c0631ac-e239-4237-bce5-9d8dad44cf99
…ource, update CHANGELOG

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/3c0631ac-e239-4237-bce5-9d8dad44cf99
Copilot AI changed the title [WIP] Fix global list refresh after mutations across ObjectUI feat: standardize list refresh after mutation (P0/P1/P2) Mar 25, 2026
Copilot AI requested a review from hotlong March 25, 2026 06:50
@hotlong
Copy link
Copy Markdown
Contributor

hotlong commented Mar 25, 2026

@copilot 仔细评估是否完成了任务中要求的所有开发

…View/Kanban/Calendar

- P1: Create useDataRefresh() hook in @object-ui/react with 7 tests
- P2: Add onMutation subscription to plugin-view ObjectView
- P2: Add onMutation subscription to plugin-kanban ObjectKanban
- P2: Add onMutation subscription to plugin-calendar ObjectCalendar
- Updated CHANGELOG with complete scope

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/d269c01c-4c5b-4b90-8d2e-1427e459078f
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 25, 2026

@copilot 仔细评估是否完成了任务中要求的所有开发

经过全面审计,发现了以下缺失项并已在 d510bbd 中补全:

P1 缺失 — useDataRefresh hook

  • 新增 useDataRefresh(dataSource, objectName) hook 于 @object-ui/react,封装 refreshKey 状态 + onMutation 订阅模式,含 7 个测试

P2 缺失 — 仅 ListView 订阅了 onMutation,其余视图未覆盖

  • ObjectView (plugin-view) — 新增 onMutation 自动订阅,非 grid 视图类型的数据刷新
  • ObjectKanban (plugin-kanban) — 新增独立模式下的 onMutation 订阅(由父组件管理数据时跳过)
  • ObjectCalendar (plugin-calendar) — 新增独立模式下的 onMutation 订阅(由父组件管理数据时跳过)

Root Cause #5(console 系统页面重复代码) — 4 个系统管理页面仍使用手动 fetchData/setRefreshKey 模式。此项属于页面级重构,建议独立 PR 处理,避免与架构改动混合。

完整清单:22 个新测试,748 个测试全部通过,42 个构建任务全部成功。

@hotlong hotlong marked this pull request as ready for review March 25, 2026 11:00
Copilot AI review requested due to automatic review settings March 25, 2026 11:00
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR standardizes “refresh after mutation” behavior across ObjectUI’s data-bound views by introducing a schema-level refresh signal, an imperative refresh API, and a DataSource mutation event bus that views can subscribe to for automatic re-fetching.

Changes:

  • Added refreshTrigger?: number to ListViewSchema and wired it into ListView’s data-fetch effect dependencies.
  • Introduced DataSource.onMutation() + MutationEvent and implemented emission/subscription (ValueDataSource + ListView/ObjectView/Kanban/Calendar auto-refresh).
  • Added ListView imperative ref.refresh() plus a reusable useDataRefresh() hook and associated tests.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/types/src/objectql.ts Adds refreshTrigger to ListViewSchema for parent-driven refresh signaling.
packages/types/src/data.ts Adds MutationEvent + optional DataSource.onMutation() subscription API.
packages/types/src/index.ts Re-exports MutationEvent from the types barrel.
packages/react/src/hooks/useDataRefresh.ts Adds a reusable hook that manages a refresh key and auto-subscribes to onMutation.
packages/react/src/hooks/index.ts Exports the new useDataRefresh hook.
packages/react/src/hooks/tests/useDataRefresh.test.ts Adds unit tests for the new hook’s refresh + subscribe/unsubscribe behavior.
packages/plugin-list/src/ListView.tsx Converts ListView to forwardRef, adds imperative refresh(), adds onMutation subscription, and includes schema.refreshTrigger in fetch deps.
packages/plugin-list/src/index.tsx Exports ListViewHandle from the plugin barrel.
packages/plugin-list/src/tests/ListView.test.tsx Updates the export/type test to reflect forwardRef-wrapped ListView.
packages/plugin-list/src/tests/ListRefresh.test.tsx Adds coverage for refreshTrigger, imperative refresh(), and onMutation auto-refresh.
packages/plugin-view/src/ObjectView.tsx Propagates refreshKey into rendered ListView + adds ObjectView onMutation subscription.
packages/plugin-kanban/src/ObjectKanban.tsx Adds standalone-mode onMutation refresh support for Kanban.
packages/plugin-calendar/src/ObjectCalendar.tsx Adds standalone-mode onMutation refresh support for Calendar.
packages/core/src/adapters/ValueDataSource.ts Implements onMutation listener registration and emits events on create/update/delete.
packages/core/src/adapters/tests/ValueDataSource.test.ts Adds tests for mutation emission, multiple subscribers, unsubscribe, and bulk behavior.
apps/console/src/components/ObjectView.tsx Combines console refresh signals with plugin ObjectView refreshKey for keying/remount behavior.
CHANGELOG.md Documents the refresh architecture changes and test additions.

* unsub?.();
* ```
*/
onMutation?(callback: (event: MutationEvent) => void): () => void;
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

DataSource<T> declares onMutation as (event: MutationEvent) => void, which drops the generic T and makes event.record effectively any even for typed data sources. Consider changing this to onMutation?(callback: (event: MutationEvent<T>) => void): () => void so typed adapters (like ValueDataSource<T>) preserve record typing end-to-end.

Suggested change
onMutation?(callback: (event: MutationEvent) => void): () => void;
onMutation?(callback: (event: MutationEvent<T>) => void): () => void;

Copilot uses AI. Check for mistakes.
Comment on lines +232 to +239
if (!dataSource?.onMutation || !schema.objectName) return;
const unsub = dataSource.onMutation((event: any) => {
if (event.resource === schema.objectName) {
setRefreshKey(prev => prev + 1);
}
});
return unsub;
}, [dataSource, schema.objectName]);
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

This new onMutation subscription increments refreshKey, but refreshKey is also already incremented in handleFormSuccess/delete handlers. When a DataSource implements onMutation and also those handlers run, this will typically cause double refreshes (and double find() calls) for the same mutation. Suggest gating either the subscription or the manual refreshKey increments behind !dataSource.onMutation (or otherwise dedupe), and include renderListView/currentViewType in the decision so ListView-driven views don’t refresh twice.

Suggested change
if (!dataSource?.onMutation || !schema.objectName) return;
const unsub = dataSource.onMutation((event: any) => {
if (event.resource === schema.objectName) {
setRefreshKey(prev => prev + 1);
}
});
return unsub;
}, [dataSource, schema.objectName]);
// If there is no mutation subscription or no target object, do nothing.
if (!dataSource?.onMutation || !schema.objectName) return;
// ListView-driven configurations already manage refreshKey via
// form success / delete handlers. To avoid double refreshes and
// duplicate find() calls, do not auto-subscribe in that case.
if (schema.renderListView) return;
const unsub = dataSource.onMutation((event: any) => {
if (event.resource === schema.objectName) {
setRefreshKey(prev => prev + 1);
}
});
return unsub;
}, [dataSource, schema.objectName, schema.renderListView]);

Copilot uses AI. Check for mistakes.
Comment on lines +420 to +427
if (!dataSource?.onMutation || !schema.objectName) return;
const unsub = dataSource.onMutation((event) => {
if (event.resource === schema.objectName) {
setRefreshKey(k => k + 1);
}
});
return unsub;
}, [dataSource, schema.objectName]);
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

ListView now supports both schema.refreshTrigger (external refresh) and dataSource.onMutation (internal auto-refresh). In scenarios like ObjectView embedding ListView, a single mutation can change schema.refreshTrigger and fire onMutation, causing two refreshes/two find() calls. Consider skipping the onMutation subscription when schema.refreshTrigger is provided (or otherwise deduping refresh signals) to avoid redundant fetches.

Suggested change
if (!dataSource?.onMutation || !schema.objectName) return;
const unsub = dataSource.onMutation((event) => {
if (event.resource === schema.objectName) {
setRefreshKey(k => k + 1);
}
});
return unsub;
}, [dataSource, schema.objectName]);
// When an external refreshTrigger is provided, rely on that instead of
// subscribing to dataSource mutations to avoid double refreshes.
if (!dataSource?.onMutation || !schema.objectName || schema.refreshTrigger) return;
const unsub = dataSource.onMutation((event) => {
if (event.resource === schema.objectName) {
setRefreshKey(k => k + 1);
}
});
return unsub;
}, [dataSource, schema.objectName, schema.refreshTrigger]);

Copilot uses AI. Check for mistakes.
@hotlong
Copy link
Copy Markdown
Contributor

hotlong commented Mar 25, 2026

@copilot fix all

…utationEvent<T>

1. types/data.ts: onMutation now uses MutationEvent<T> to preserve record typing
2. ObjectView: skip onMutation subscription when renderListView is provided (avoids double refresh)
3. ListView: skip onMutation subscription when refreshTrigger is provided (avoids double refresh)

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/b095f623-c45f-4bc7-8ffd-a5cac05aac07
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 25, 2026

@copilot fix all

All 3 reviewer comments addressed in cd2b176:

  1. MutationEvent<T> generic typingDataSource.onMutation now uses MutationEvent<T> to preserve record type end-to-end for typed adapters like ValueDataSource<T>
  2. ObjectView double-refresh deduponMutation subscription is skipped when renderListView is provided, since ListView already manages its own refresh via refreshTrigger / form handlers
  3. ListView double-refresh deduponMutation subscription is skipped when schema.refreshTrigger is provided, since the parent (ObjectView) already signals mutations through the trigger prop

All 7248 tests pass across 382 test files, 42 build tasks successful.

@hotlong hotlong merged commit 80c65bd into main Mar 25, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Global: Standardize List Refresh After Mutation (addRecord/create/update/delete) Across All ObjectUI Scenarios

3 participants