Skip to content

Commit b9cd980

Browse files
refactor(app-shell): move ObjectView's realtime refresh and record count into a sibling hook (slice 4) (#26336)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9VtKf3F9dzJPJM4RRtABaEc)_ Part of #25992 (slice 4 of the move-only split, the first slice inside `ObjectViewInner`; slices 1 to 3 landed as #26152, #26233 and #26310, and the card stays open for the slices listed below). Clause-②: no Metadata-protocol: none ## What moved Base `e6381930` (slice 3 merged). `ui/packages/app-shell/src/views/ObjectView.tsx` is 3171 lines there: imports, re-exports, `ConsoleObjectViewProps`, the `ObjectView` wrapper, and `ObjectViewInner` from line 222 to the end. This slice moves concern **K** of the seam table below, verbatim, into a custom hook `useObjectViewLiveData` in a new sibling module `ui/packages/app-shell/src/views/objectViewLiveData.ts`: - the realtime subscription on `object:<name>` that calls `refreshData()` when the server reports a change, - the reconnection conflict resolution (`useConflictResolution`, server-wins), - the footer's record-count fetch (`dataSource.find(name, { $top: 0 })` into `setRecordCount`), with its whole comment block. `ObjectViewInner` calls `useObjectViewLiveData({ dataSource, objectDef, refreshData, refreshKey, setRecordCount })` at the exact place the moved lines stood, so the order of React hooks in the component does not change. The `recordCount` state stays in the component (line 413), because the JSX return reads it; the hook gets its setter and every other input as arguments. No identifier is renamed, no new context is added, and the hook declares no state. The `@object-ui/collaboration` import moves with its only two readers. Nothing new is exported from `ObjectView.tsx`, and the package entry is untouched. `ObjectView.tsx` goes from 3171 to 3123 lines. Move-only proof: the multiset of non-blank lines of the base `ObjectView.tsx` equals new `ObjectView.tsx` plus `objectViewLiveData.ts`. Nothing is missing. The only added lines are the new module's header comment, its three imports, the hook's signature and parameter type (6 lines) and closing brace, and in `ObjectView.tsx` the new import plus the call with its two-line comment. ## Test edits None. No test reads the moved lines as source (checked every `readFileSync` reader of `ObjectView.tsx`), and the tests that `vi.mock('@object-ui/collaboration')` mock the module, so they cover the hook's import too. ## Seam table of `ObjectViewInner` (measured on `e6381930`) Lines are in the base file. "Reads" lists inputs from other regions (props `dataSource`, `objects`, `onEdit`, `externalRefreshKey` are left out). "Read by" lists the regions that use what a region declares. | # | Concern | Lines | Declares (main) | Reads | Read by | Slice | |---|---|---|---|---|---|---| | A | Route, i18n, metadata and preview inputs | 223–256 | `objectName`, `viewId`, `t`, `navigate`, `location`, `searchParams`, `servedViews`, `viewLabel`, `metadataClient`, `previewDrafts` | (hooks only) | all | stays (the inputs every slice receives) | | B | View config panel and create dialog state, `handleViewConfigSave`, `handleViewCreate` | 257–412 | `viewDraft`, panel/dialog open state, the two handlers | A, C (`objectDef`, `setRefreshKey`) | F, G, H, I, N, P, Q | 11 (census-pinned) | | C | Page identity: record count state, auth, permissions, `objectDef`, favourite, refresh key, action runtime | 413–509 | `recordCount`, `objectDef`, `user`, `perms`, `isAdmin`, `refreshKey`, `refreshData`, `actionRuntime` | A | all | stays (shared state) | | D | Environment entitlements, toolbar actions, create and import verdicts | 510–685 | `listActions`, `createVisible/Disabled`, `importVisible/Disabled`, `objectCanCreate/Import`, `isEnvironmentList`, `localizedToolbarActions` | A, C, J (`actions`) | G, O, P, Q | 7 | | E | Import wizard state, identity import, managed users | 686–753 | `showImport`, `identityImportEnabled`, `identityDataSource`, `identityPasswordPolicy`, `managedUsers`, `managedUserMarker` | A (`t`), C | O, P, Q | 5 | | F | Saved views, list preferences, overrides, views list, active view resolution | 754–1001 | `savedViews`, `listPrefs`, `viewOverrides`, `views`, `activeView`, `activeViewId`, `viewAccess` | A, B (`viewDraft`), C | most | stays (shared state) | | G | Header toolbar actions and the nav-run deep link | 1002–1085 | `headerToolbarActions`, `toolbarBar`, `toolbarActionsWithDeepLink`, `activeViewDeclaresColumns`, `activeViewIsSaved` | A, B, D, F, J | O, P, Q | 8 | | H | List URL state, preference seed, reset | 1086–1211 | `writeListUrlState`, `listSeedRef`, `listIdentityKey`, `handleViewUpdate`, `handleResetView` | A, B (`setViewDraft`), C, F | L, O, P, Q | 8 | | I | View switching, mobile switcher, view-tab CRUD callbacks | 1212–1420 | `handleViewChange`, `handleAddView`, `handleRenameView`, `handleDeleteView`, `handlePinView`, `handleSetDefaultView`, … | A, B, C, F | N, Q | 11 (census-pinned) | | J | Object actions and filter scope | 1421–1450 | `actions`, `filterScope` | A, C | D, G, O, P, Q | stays (shared) | | **K** | **Realtime refresh, conflict resolution, record count** | **1452–1502** | (nothing anyone reads) | C (`objectDef`, `refreshData`, `refreshKey`, `setRecordCount`) | none | **4, this PR** | | L | Navigation overlay, drawer, URL and user filters, row click | 1503–1740 | `navOverlay`, `handleRowClick`, `handleDrawerClose`, `urlFilters`, `listRecordActions` | A, C, F, H | O, Q | 6 | | M | Held list identity refs | 1741–1759 | `heldListFilter`, `heldListOptions` | (local) | O | 6 (with L) | | N | View tab bar | 1760–1825 | `viewTabBar`, `viewTabRow`, `viewTabItems` | A, B, C, F, I | O, Q | 9 | | O | `renderListView` | 1826–2593 | `renderListView` | A, C, D, E, F, G, H, J, L, M, N | Q | 11 (scan-pinned) | | P | Plugin `ObjectView` schema, create button and FAB, folded menu and header actions | 2594–2764 | `objectViewSchema`, `createButton`, `foldedHeaderActions`, `listFabClearance`, `refusedViewFace` | A, B, C, D, E, F, G, H, J | Q | 9 | | Q | JSX return (providers, `PageHeader`, FAB, import wizard, tabs, content, `NavigationOverlay`) | 2765–3170 | — | everything | — | 10 | ## Remaining slices (one PR each; the last closes the card) - **Slice 5 — E:** import wizard state, identity import and managed users into a sibling hook. - **Slice 6 — L + M:** navigation overlay, drawer, URL and user filters, row click and the held list refs into a sibling hook. - **Slice 7 — D:** environment entitlements, toolbar actions and the create and import verdicts into a sibling hook. - **Slice 8 — G + H:** header toolbar actions and the list URL state into sibling hooks. - **Slice 9 — N + P:** the view tab bar and the plugin schema plus header chrome into a sibling hook or sub-component. - **Slice 10 — Q:** the JSX return's `PageHeader`, import wizard and `NavigationOverlay` blocks into sub-components with explicit props. - **Slice 11 — B + I + O (closes the card):** the view-config and view-tab CRUD handlers and `renderListView`. They are pinned by file and symbol in `writeRefusalCensus-11583.test.ts` (`handleViewConfigSave`, `handleViewCreate`, `handleRenameView`, `handleDeleteView`, `handlePinView`, `handleSetDefaultView`) and by `ObjectView.relayRungCensus-7559.test.ts` (`renderListView`). Moving them needs those census rows re-keyed to the new file, which is more than the import-path edits the card allows; slice 2 had that allowance for one row. If the seat does not grant it, slice 10 closes the card and B, I and O stay in `ObjectView.tsx` as the component's core. A, C, F and J stay in the component in every plan: they are the shared state most other concerns read. Each later slice re-measures the line spans and the source-scan pins at its claim, because the file moves daily. ## Verification See the os-dev-report comment on the card for the full readings: app-shell `type-check`, every `ObjectView.*` test in `views/`, the `AppContent*` tests, every other test that reads `ObjectView.tsx` as source, eslint on the 2 touched files, the derived dispatch gates, and a Console build. ## Acceptance notes - eslint reports one new warning (0 errors): `react-hooks/exhaustive-deps` in the moved record-count effect now names `setRecordCount`, because the rule only treats a `useState` setter as stable inside the component that declares it. The dependency array is kept verbatim because the slice is move-only; the setter is still the component's stable `useState` setter. Changeset: `.changeset/25992-objectview-live-data-split.md` (patch, `@object-ui/app-shell`). --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QciHBvFJXnhZqXmdqpDtpz --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3ae7c6f commit b9cd980

3 files changed

Lines changed: 87 additions & 52 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
refactor(app-shell): the Console `ObjectView`'s realtime refresh and record-count fetch move into a sibling hook
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
What to do: nothing.

‎ui/packages/app-shell/src/views/ObjectView.tsx‎

Lines changed: 4 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ import { useObjectActions } from '../hooks/useObjectActions.js';
8989
import { useObjectTranslation, useObjectLabel, useDisplayLocale, pickLocalized } from '@object-ui/i18n';
9090
import { usePermissions } from '@object-ui/permissions';
9191
import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth';
92-
import { useRealtimeSubscription, useConflictResolution } from '@object-ui/collaboration';
9392
import { ActionProvider, useNavigationOverlay, SchemaRenderer, useActionTextLocalizer, useRowPredicate, RelatedRecordActionsProvider, notifyDataChanged, useCapabilityGate, usePredicateScope, useMetadata } from '@object-ui/react';
9493
import { toast } from 'sonner';
9594
import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js';
@@ -126,6 +125,7 @@ import {
126125
dispatchViewPatches,
127126
} from './objectViewOptions.js';
128127
import { ObjectNotFoundState, useViewTabsFit, keepInlineEditModeForTheSession } from './objectViewChrome.js';
128+
import { useObjectViewLiveData } from './objectViewLiveData.js';
129129

130130
// objectstack#25992 slice 1: these module-level helpers moved to the sibling
131131
// `objectView*.ts` modules above. Every name this file exported stays
@@ -1449,57 +1449,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
14491449
onToast: toastHandler as (message: string, options?: { type?: string }) => void,
14501450
});
14511451

1452-
// Real-time: auto-refresh when server reports data changes
1453-
const { lastMessage: realtimeMessage } = useRealtimeSubscription({
1454-
channel: `object:${objectDef.name}`,
1455-
});
1456-
1457-
// Conflict resolution: detect and queue conflicts on reconnection
1458-
const conflictUserId = objectDef.name ? `user-${objectDef.name}` : 'current-user';
1459-
const { hasConflicts, resolveAllConflicts } = useConflictResolution(conflictUserId);
1460-
1461-
useEffect(() => {
1462-
if (realtimeMessage) {
1463-
// On reconnection data change, auto-resolve with server-wins strategy
1464-
if (hasConflicts) {
1465-
resolveAllConflicts('remote');
1466-
}
1467-
refreshData();
1468-
}
1469-
}, [realtimeMessage, hasConflicts, resolveAllConflicts, refreshData]);
1470-
1471-
// Fetch record count for footer display.
1472-
//
1473-
// `$top: 0` IS the request: give me the total, send no rows. It was written
1474-
// `limit: 0`, which is not a `QueryParams` key — `convertQueryParams` copies
1475-
// exactly the `$`-prefixed keys the type declares — so the cap reached no
1476-
// branch and was dropped, and the platform's GET list route has no default
1477-
// page size. The effect therefore did the opposite of what it says: it
1478-
// downloaded EVERY row of the object, on every mount and every refresh of
1479-
// every list view, to read one integer off the envelope. The dead spelling
1480-
// type-checked because `QueryParams` then carried `[key: string]: any` for
1481-
// adapter-specific params; `object-ui/no-unprefixed-query-params` rejects it
1482-
// at write time now (objectui#5458), and the index signature itself is gone
1483-
// (objectui#7497), so `tsc` refuses it as well.
1484-
//
1485-
// `total` is the only field that can still answer the question, so the
1486-
// row-counting fallbacks are gone rather than repointed. Once we ask for
1487-
// zero rows an empty `data` means "you asked for none", not "the object is
1488-
// empty" — counting the response would report a confident `0` for any
1489-
// adapter that does not send a total. Leaving `recordCount` `undefined`
1490-
// omits the footer line instead of asserting a wrong number; the render is
1491-
// already guarded by `typeof recordCount === 'number'`.
1492-
useEffect(() => {
1493-
if (dataSource?.find && objectDef.name) {
1494-
dataSource.find(objectDef.name, { $top: 0 }).then((result: any) => {
1495-
if (typeof result?.total === 'number') {
1496-
setRecordCount(result.total);
1497-
}
1498-
}).catch(() => {
1499-
// Silently ignore — record count is non-critical
1500-
});
1501-
}
1502-
}, [dataSource, objectDef.name, refreshKey]);
1452+
// objectstack#25992 slice 4: the realtime refresh, conflict resolution and
1453+
// footer record count moved to `./objectViewLiveData.ts`; the count state stays here.
1454+
useObjectViewLiveData({ dataSource, objectDef, refreshData, refreshKey, setRecordCount });
15031455

15041456
// Navigation overlay for record detail (supports drawer/modal/split/popover via config)
15051457
// Priority: activeView.navigation > objectDef.navigation > default drawer.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Live data of the Console `ObjectView` (`./ObjectView.tsx`): the realtime
3+
* subscription that refreshes the list when the server reports a change, the
4+
* reconnection conflict resolution, and the footer's record-count fetch, moved
5+
* out of `ObjectViewInner` verbatim (objectstack#25992, slice 4). The record
6+
* count state stays in the component; it and every other input are passed in.
7+
*/
8+
9+
import { useEffect } from 'react';
10+
import type { DataSource } from '@object-ui/types';
11+
import { useRealtimeSubscription, useConflictResolution } from '@object-ui/collaboration';
12+
13+
export function useObjectViewLiveData({ dataSource, objectDef, refreshData, refreshKey, setRecordCount }: {
14+
dataSource: DataSource;
15+
objectDef: { name: string };
16+
refreshData: () => void;
17+
refreshKey: number;
18+
setRecordCount: (count: number) => void;
19+
}) {
20+
// Real-time: auto-refresh when server reports data changes
21+
const { lastMessage: realtimeMessage } = useRealtimeSubscription({
22+
channel: `object:${objectDef.name}`,
23+
});
24+
25+
// Conflict resolution: detect and queue conflicts on reconnection
26+
const conflictUserId = objectDef.name ? `user-${objectDef.name}` : 'current-user';
27+
const { hasConflicts, resolveAllConflicts } = useConflictResolution(conflictUserId);
28+
29+
useEffect(() => {
30+
if (realtimeMessage) {
31+
// On reconnection data change, auto-resolve with server-wins strategy
32+
if (hasConflicts) {
33+
resolveAllConflicts('remote');
34+
}
35+
refreshData();
36+
}
37+
}, [realtimeMessage, hasConflicts, resolveAllConflicts, refreshData]);
38+
39+
// Fetch record count for footer display.
40+
//
41+
// `$top: 0` IS the request: give me the total, send no rows. It was written
42+
// `limit: 0`, which is not a `QueryParams` key — `convertQueryParams` copies
43+
// exactly the `$`-prefixed keys the type declares — so the cap reached no
44+
// branch and was dropped, and the platform's GET list route has no default
45+
// page size. The effect therefore did the opposite of what it says: it
46+
// downloaded EVERY row of the object, on every mount and every refresh of
47+
// every list view, to read one integer off the envelope. The dead spelling
48+
// type-checked because `QueryParams` then carried `[key: string]: any` for
49+
// adapter-specific params; `object-ui/no-unprefixed-query-params` rejects it
50+
// at write time now (objectui#5458), and the index signature itself is gone
51+
// (objectui#7497), so `tsc` refuses it as well.
52+
//
53+
// `total` is the only field that can still answer the question, so the
54+
// row-counting fallbacks are gone rather than repointed. Once we ask for
55+
// zero rows an empty `data` means "you asked for none", not "the object is
56+
// empty" — counting the response would report a confident `0` for any
57+
// adapter that does not send a total. Leaving `recordCount` `undefined`
58+
// omits the footer line instead of asserting a wrong number; the render is
59+
// already guarded by `typeof recordCount === 'number'`.
60+
useEffect(() => {
61+
if (dataSource?.find && objectDef.name) {
62+
dataSource.find(objectDef.name, { $top: 0 }).then((result: any) => {
63+
if (typeof result?.total === 'number') {
64+
setRecordCount(result.total);
65+
}
66+
}).catch(() => {
67+
// Silently ignore — record count is non-critical
68+
});
69+
}
70+
}, [dataSource, objectDef.name, refreshKey]);
71+
}

0 commit comments

Comments
 (0)