diff --git a/.changeset/25992-objectview-live-data-split.md b/.changeset/25992-objectview-live-data-split.md new file mode 100644 index 00000000000..03c85461ea0 --- /dev/null +++ b/.changeset/25992-objectview-live-data-split.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +refactor(app-shell): the Console `ObjectView`'s realtime refresh and record-count fetch move into a sibling hook + +Clause-②: no +Metadata-protocol: none + +The realtime subscription that refreshes the list when the server reports a change, the reconnection conflict resolution, and the footer's `$top: 0` record-count fetch move verbatim out of `ObjectViewInner` in `views/ObjectView.tsx` into `useObjectViewLiveData` in `objectViewLiveData.ts` beside it. The component calls the hook at the same place, so the hook order is unchanged; the record-count state stays in the component and is passed in with the other inputs. No logic, name or rendered output changes, and the package entry is unchanged. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 455555f6f6d..6676b3568e0 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -89,7 +89,6 @@ import { useObjectActions } from '../hooks/useObjectActions.js'; import { useObjectTranslation, useObjectLabel, useDisplayLocale, pickLocalized } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; -import { useRealtimeSubscription, useConflictResolution } from '@object-ui/collaboration'; import { ActionProvider, useNavigationOverlay, SchemaRenderer, useActionTextLocalizer, useRowPredicate, RelatedRecordActionsProvider, notifyDataChanged, useCapabilityGate, usePredicateScope, useMetadata } from '@object-ui/react'; import { toast } from 'sonner'; import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js'; @@ -126,6 +125,7 @@ import { dispatchViewPatches, } from './objectViewOptions.js'; import { ObjectNotFoundState, useViewTabsFit, keepInlineEditModeForTheSession } from './objectViewChrome.js'; +import { useObjectViewLiveData } from './objectViewLiveData.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling // `objectView*.ts` modules above. Every name this file exported stays @@ -1449,57 +1449,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co onToast: toastHandler as (message: string, options?: { type?: string }) => void, }); - // Real-time: auto-refresh when server reports data changes - const { lastMessage: realtimeMessage } = useRealtimeSubscription({ - channel: `object:${objectDef.name}`, - }); - - // Conflict resolution: detect and queue conflicts on reconnection - const conflictUserId = objectDef.name ? `user-${objectDef.name}` : 'current-user'; - const { hasConflicts, resolveAllConflicts } = useConflictResolution(conflictUserId); - - useEffect(() => { - if (realtimeMessage) { - // On reconnection data change, auto-resolve with server-wins strategy - if (hasConflicts) { - resolveAllConflicts('remote'); - } - refreshData(); - } - }, [realtimeMessage, hasConflicts, resolveAllConflicts, refreshData]); - - // Fetch record count for footer display. - // - // `$top: 0` IS the request: give me the total, send no rows. It was written - // `limit: 0`, which is not a `QueryParams` key — `convertQueryParams` copies - // exactly the `$`-prefixed keys the type declares — so the cap reached no - // branch and was dropped, and the platform's GET list route has no default - // page size. The effect therefore did the opposite of what it says: it - // downloaded EVERY row of the object, on every mount and every refresh of - // every list view, to read one integer off the envelope. The dead spelling - // type-checked because `QueryParams` then carried `[key: string]: any` for - // adapter-specific params; `object-ui/no-unprefixed-query-params` rejects it - // at write time now (objectui#5458), and the index signature itself is gone - // (objectui#7497), so `tsc` refuses it as well. - // - // `total` is the only field that can still answer the question, so the - // row-counting fallbacks are gone rather than repointed. Once we ask for - // zero rows an empty `data` means "you asked for none", not "the object is - // empty" — counting the response would report a confident `0` for any - // adapter that does not send a total. Leaving `recordCount` `undefined` - // omits the footer line instead of asserting a wrong number; the render is - // already guarded by `typeof recordCount === 'number'`. - useEffect(() => { - if (dataSource?.find && objectDef.name) { - dataSource.find(objectDef.name, { $top: 0 }).then((result: any) => { - if (typeof result?.total === 'number') { - setRecordCount(result.total); - } - }).catch(() => { - // Silently ignore — record count is non-critical - }); - } - }, [dataSource, objectDef.name, refreshKey]); + // objectstack#25992 slice 4: the realtime refresh, conflict resolution and + // footer record count moved to `./objectViewLiveData.ts`; the count state stays here. + useObjectViewLiveData({ dataSource, objectDef, refreshData, refreshKey, setRecordCount }); // Navigation overlay for record detail (supports drawer/modal/split/popover via config) // Priority: activeView.navigation > objectDef.navigation > default drawer. diff --git a/ui/packages/app-shell/src/views/objectViewLiveData.ts b/ui/packages/app-shell/src/views/objectViewLiveData.ts new file mode 100644 index 00000000000..aea1875e63c --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewLiveData.ts @@ -0,0 +1,71 @@ +/** + * Live data of the Console `ObjectView` (`./ObjectView.tsx`): the realtime + * subscription that refreshes the list when the server reports a change, the + * reconnection conflict resolution, and the footer's record-count fetch, moved + * out of `ObjectViewInner` verbatim (objectstack#25992, slice 4). The record + * count state stays in the component; it and every other input are passed in. + */ + +import { useEffect } from 'react'; +import type { DataSource } from '@object-ui/types'; +import { useRealtimeSubscription, useConflictResolution } from '@object-ui/collaboration'; + +export function useObjectViewLiveData({ dataSource, objectDef, refreshData, refreshKey, setRecordCount }: { + dataSource: DataSource; + objectDef: { name: string }; + refreshData: () => void; + refreshKey: number; + setRecordCount: (count: number) => void; +}) { + // Real-time: auto-refresh when server reports data changes + const { lastMessage: realtimeMessage } = useRealtimeSubscription({ + channel: `object:${objectDef.name}`, + }); + + // Conflict resolution: detect and queue conflicts on reconnection + const conflictUserId = objectDef.name ? `user-${objectDef.name}` : 'current-user'; + const { hasConflicts, resolveAllConflicts } = useConflictResolution(conflictUserId); + + useEffect(() => { + if (realtimeMessage) { + // On reconnection data change, auto-resolve with server-wins strategy + if (hasConflicts) { + resolveAllConflicts('remote'); + } + refreshData(); + } + }, [realtimeMessage, hasConflicts, resolveAllConflicts, refreshData]); + + // Fetch record count for footer display. + // + // `$top: 0` IS the request: give me the total, send no rows. It was written + // `limit: 0`, which is not a `QueryParams` key — `convertQueryParams` copies + // exactly the `$`-prefixed keys the type declares — so the cap reached no + // branch and was dropped, and the platform's GET list route has no default + // page size. The effect therefore did the opposite of what it says: it + // downloaded EVERY row of the object, on every mount and every refresh of + // every list view, to read one integer off the envelope. The dead spelling + // type-checked because `QueryParams` then carried `[key: string]: any` for + // adapter-specific params; `object-ui/no-unprefixed-query-params` rejects it + // at write time now (objectui#5458), and the index signature itself is gone + // (objectui#7497), so `tsc` refuses it as well. + // + // `total` is the only field that can still answer the question, so the + // row-counting fallbacks are gone rather than repointed. Once we ask for + // zero rows an empty `data` means "you asked for none", not "the object is + // empty" — counting the response would report a confident `0` for any + // adapter that does not send a total. Leaving `recordCount` `undefined` + // omits the footer line instead of asserting a wrong number; the render is + // already guarded by `typeof recordCount === 'number'`. + useEffect(() => { + if (dataSource?.find && objectDef.name) { + dataSource.find(objectDef.name, { $top: 0 }).then((result: any) => { + if (typeof result?.total === 'number') { + setRecordCount(result.total); + } + }).catch(() => { + // Silently ignore — record count is non-critical + }); + } + }, [dataSource, objectDef.name, refreshKey]); +}