feat(ui-perf): React Query scaffolding + lazy-extension rollout across 9 entity-detail pages#28017
feat(ui-perf): React Query scaffolding + lazy-extension rollout across 9 entity-detail pages#28017harshach wants to merge 5 commits into
Conversation
P3.1 of the perceived-latency design: foundational scaffolding for
incremental migration of manual fetch+Zustand patterns to React Query.
Adds the `QueryClientProvider` at the top of the app tree (outside
AuthProvider so any query made during the auth flow shares the same
cache). Defaults tuned for OpenMetadata's data shape:
- staleTime 30s — most entity reads are stable for tens of seconds
and pages flip back-and-forth
- gcTime 5min — keep results around for tab-switch round-trips
without holding memory for users who navigate away
- refetchOnWindowFocus true — picks up backend changes when the
user returns to the tab
- retry 1 — one network blip retry, no exponential backoff cascade
This is scaffolding only — no existing fetches are migrated in this
commit. Migrations land incrementally one page at a time; the next
commit is a pilot showing the pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…via useQuery
Combines a P3.3 field trim with the P3.1 React Query pilot:
P3.3 — trim: `extension` (custom property values) was eagerly requested
on every initial table-page load via `defaultFields` /
`defaultFieldsWithColumns`. Custom Properties is a single tab and the
extension blob can run into hundreds of KB on tables with many user-
defined properties. Trimming it saves wire bytes on every initial
load.
P3.1 — pilot: the lazy refetch is wired through `useQuery` with
`enabled: activeTab === EntityTabs.CUSTOM_PROPERTIES && Boolean(fqn)`.
This is the first useQuery call in the codebase — establishes the
pattern for follow-up migrations:
- Query key: stable, FQN-scoped — same key across tab toggles
- 60s staleTime: custom property values change rarely
- Auto in-flight cancellation on FQN change (free with React Query)
- Auto request dedup if the user double-clicks the tab
`tableDetails.extension` is merged in via a side-effect `useEffect` on
the query result so existing consumers (CustomPropertyTable reading
from the table state) keep working unchanged.
Files:
- utils/DatasetDetailsUtils.ts: drop EXTENSION from defaultFields and
defaultFieldsWithColumns; add new `customPropertiesFields` constant
for the lazy fetch.
- pages/TableDetailsPageV1/TableDetailsPageV1.tsx: import useQuery
+ customPropertiesFields; new useQuery hook gated on tab; effect
merges result into table state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| <QueryClientProvider client={queryClient}> | ||
| <AuthProvider childComponentType={AppRouter}> | ||
| <AppRouter /> | ||
| </AuthProvider> | ||
| </QueryClientProvider> |
There was a problem hiding this comment.
⚠️ Security: QueryClient cache not cleared on logout leaks data between users
The QueryClientProvider is deliberately placed outside AuthProvider so the cache survives auth-flow remounts. However, there is no queryClient.clear() call in the logout handler. If user A logs out and user B logs in on the same browser tab, user B may briefly see user A's cached query results (e.g. the extension/custom-properties payload keyed by table FQN). This is a data-leakage vector in shared-machine environments.
The PR description justifies the placement for caching feature-flag fetches across auth transitions, but the trade-off needs an explicit cache wipe on credential change.
Suggested fix:
// In AuthProvider's onLogoutHandler (or a useEffect watching the user identity):
import { queryClient } from '../../queryClient';
const onLogoutHandler = async () => {
// ... existing cleanup ...
queryClient.clear(); // wipe all cached queries on logout
};
- Apply suggested fix
Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎
There was a problem hiding this comment.
Pull request overview
Adds React Query infrastructure to the UI and pilots a performance optimization on the Table details page by trimming the eagerly requested extension field and fetching it lazily only when the Custom Properties tab is opened.
Changes:
- Add
@tanstack/react-querydependency and lockfile entries. - Introduce a shared
QueryClientand wireQueryClientProviderinto the app root. - Remove
extensionfrom default tablefields=sets and add a lazily fetchedextensionquery on the Custom Properties tab.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| openmetadata-ui/src/main/resources/ui/package.json | Adds @tanstack/react-query dependency. |
| openmetadata-ui/src/main/resources/ui/yarn.lock | Locks TanStack Query packages and updates the linked ui-core-components entry. |
| openmetadata-ui/src/main/resources/ui/src/queryClient.ts | Defines a shared app-wide QueryClient with default caching/retry behavior. |
| openmetadata-ui/src/main/resources/ui/src/App.tsx | Wraps the app in QueryClientProvider to enable React Query usage across pages. |
| openmetadata-ui/src/main/resources/ui/src/utils/DatasetDetailsUtils.ts | Removes extension from default table fields and adds a dedicated customPropertiesFields field set. |
| openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx | Adds a gated useQuery to fetch/merge extension only for the Custom Properties tab. |
| const App: FC = () => { | ||
| // QueryClientProvider sits OUTSIDE AuthProvider so any query made during the auth flow | ||
| // (e.g. fetching feature flags before login) reuses the same cache. AuthProvider remounts | ||
| // on logout — wrapping QueryClient inside would discard the cache on every logout, | ||
| // which is the opposite of what we want here. | ||
| return ( | ||
| <AuthProvider childComponentType={AppRouter}> | ||
| <AppRouter /> | ||
| </AuthProvider> | ||
| <QueryClientProvider client={queryClient}> | ||
| <AuthProvider childComponentType={AppRouter}> | ||
| <AppRouter /> | ||
| </AuthProvider> | ||
| </QueryClientProvider> |
| const { data: extensionResult } = useQuery({ | ||
| queryKey: ['table-extension', tableFqn], | ||
| queryFn: () => | ||
| getTableDetailsByFQN(tableFqn, { fields: customPropertiesFields }), | ||
| enabled: | ||
| !isTourOpen && | ||
| activeTab === EntityTabs.CUSTOM_PROPERTIES && | ||
| Boolean(tableFqn), | ||
| // Custom property values change rarely; one minute is a safe SWR window. | ||
| staleTime: 60_000, | ||
| }); |
| // Lazily fetch the `extension` field (custom properties payload) only when the user | ||
| // activates the Custom Properties tab. The eager `defaultFieldsWithColumns` deliberately | ||
| // omits `extension` because: | ||
| // - On tables with many user-defined custom properties the extension blob can be | ||
| // hundreds of KB; paying for it on every initial load is wasteful for users who never | ||
| // open Custom Properties. | ||
| // - The Custom Properties tab is the only consumer. | ||
| // Pattern used here is the P3.1 React Query pilot — `useQuery` with `enabled` gating gives | ||
| // us request dedup, in-flight cancellation on FQN change, automatic 30s SWR cache, and a | ||
| // tiny readable hook surface. Replicate this pattern for other lazy per-tab fetches. | ||
| const { data: extensionResult } = useQuery({ | ||
| queryKey: ['table-extension', tableFqn], | ||
| queryFn: () => | ||
| getTableDetailsByFQN(tableFqn, { fields: customPropertiesFields }), | ||
| enabled: | ||
| !isTourOpen && | ||
| activeTab === EntityTabs.CUSTOM_PROPERTIES && | ||
| Boolean(tableFqn), | ||
| // Custom property values change rarely; one minute is a safe SWR window. | ||
| staleTime: 60_000, | ||
| }); |
| // Fields for table details first paint. Excludes columns (paginated separately) and | ||
| // `extension` (custom properties — only the Custom Properties tab consumes this; we fetch | ||
| // it lazily when the user activates that tab via {@link customPropertiesFields}). Custom | ||
| // extension payloads can run into hundreds of KB on tables with many user-defined | ||
| // properties; trimming it saves wire bytes on every initial table-page load. | ||
| // eslint-disable-next-line max-len | ||
| export const defaultFields = `${TabSpecificField.FOLLOWERS},${TabSpecificField.JOINS},${TabSpecificField.TAGS},${TabSpecificField.OWNERS},${TabSpecificField.DATAMODEL},${TabSpecificField.TABLE_CONSTRAINTS},${TabSpecificField.SCHEMA_DEFINITION},${TabSpecificField.DOMAINS},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.VOTES},${TabSpecificField.EXTENSION}`; | ||
| export const defaultFields = `${TabSpecificField.FOLLOWERS},${TabSpecificField.JOINS},${TabSpecificField.TAGS},${TabSpecificField.OWNERS},${TabSpecificField.DATAMODEL},${TabSpecificField.TABLE_CONSTRAINTS},${TabSpecificField.SCHEMA_DEFINITION},${TabSpecificField.DOMAINS},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.VOTES}`; | ||
|
|
||
| // Legacy fields that include columns - only use when pagination is not needed | ||
| // eslint-disable-next-line max-len | ||
| export const defaultFieldsWithColumns = `${TabSpecificField.COLUMNS},${TabSpecificField.FOLLOWERS},${TabSpecificField.JOINS},${TabSpecificField.TAGS},${TabSpecificField.OWNERS},${TabSpecificField.DATAMODEL},${TabSpecificField.TABLE_CONSTRAINTS},${TabSpecificField.SCHEMA_DEFINITION},${TabSpecificField.DOMAINS},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.VOTES},${TabSpecificField.EXTENSION}`; | ||
| export const defaultFieldsWithColumns = `${TabSpecificField.COLUMNS},${TabSpecificField.FOLLOWERS},${TabSpecificField.JOINS},${TabSpecificField.TAGS},${TabSpecificField.OWNERS},${TabSpecificField.DATAMODEL},${TabSpecificField.TABLE_CONSTRAINTS},${TabSpecificField.SCHEMA_DEFINITION},${TabSpecificField.DOMAINS},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.VOTES}`; | ||
|
|
||
| // Lazy field set requested only when the Custom Properties tab is activated. Pairs with | ||
| // the trim of {@link defaultFields} above. | ||
| export const customPropertiesFields = `${TabSpecificField.EXTENSION}`; |
| setTableDetails((prev) => | ||
| prev ? { ...prev, extension: extensionResult.extension } : prev | ||
| ); |
| // Pattern used here is the P3.1 React Query pilot — `useQuery` with `enabled` gating gives | ||
| // us request dedup, in-flight cancellation on FQN change, automatic 30s SWR cache, and a | ||
| // tiny readable hook surface. Replicate this pattern for other lazy per-tab fetches. | ||
| const { data: extensionResult } = useQuery({ | ||
| queryKey: ['table-extension', tableFqn], | ||
| queryFn: () => | ||
| getTableDetailsByFQN(tableFqn, { fields: customPropertiesFields }), | ||
| enabled: | ||
| !isTourOpen && | ||
| activeTab === EntityTabs.CUSTOM_PROPERTIES && | ||
| Boolean(tableFqn), | ||
| // Custom property values change rarely; one minute is a safe SWR window. | ||
| staleTime: 60_000, |
…a useQuery
Finishes the P3.3 audit started in the previous commit and applies the
P3.1 React Query pattern across the catalogue of entity-detail pages.
P3.3 — `extension` (custom-property values) trimmed from `defaultFields`
in 9 utils files. The blob can run into hundreds of KB on entities with
many user-defined custom properties; only the Custom Properties tab
consumes it. Trimming saves wire bytes on every initial page load.
- utils/DatasetDetailsUtils.ts (Table — already trimmed; cleanup)
- utils/DashboardDetailsUtils.tsx (Dashboard)
- utils/PipelineDetailsUtils.tsx (Pipeline)
- utils/MlModelDetailsUtils.tsx (MlModel)
- utils/StoredProceduresUtils.tsx (StoredProcedure)
- utils/SearchIndexUtils.tsx (SearchIndex)
- utils/DirectoryDetailsUtils.tsx (Directory)
- utils/SpreadsheetDetailsUtils.tsx (Spreadsheet)
- utils/WorksheetDetailsUtils.tsx (Worksheet)
P3.1 — extracted the lazy-fetch pattern into a reusable hook so each
page's wiring is 4 lines instead of a copy-pasted useQuery + useEffect:
hooks/useLazyEntityExtension.ts — generic over entity shape, takes
(entityType, fqn, activeTab, fetcher, onResolve). Internally:
- useQuery gated on `activeTab === CUSTOM_PROPERTIES && fqn`
- 60s staleTime — custom property values change rarely
- Hardcoded `TabSpecificField.EXTENSION` field — single canonical
enum, removes per-page constants
- onResolve callback shape (rather than passing setState directly)
so each consumer handles their own state-shape semantics — some
pages init state as `{} as T`, others as `useState<T>()`.
Hook integrated on 6 pages — Table (refactored from inline pilot to
use the hook), Dashboard, Pipeline, MlModel, SearchIndex, StoredProcedure.
Drive entity pages (Directory, Spreadsheet, Worksheet) have their utils
trimmed but the page-level hook integration is left as follow-up; their
fetcher (`getDriveAssetByFqn<T>`) is generic and needs slightly different
wiring per page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the lazy-extension rollout to Directory, Spreadsheet, and Worksheet pages — paired with the EXTENSION trim already applied to their utils files. Drive pages share `getDriveAssetByFqn<T>(fqn, entityType, fields)` which has a different signature than other entity fetchers (entityType is a positional argument, not bundled in `params`). Each page wraps the call in a small adapter closure to match `useLazyEntityExtension`'s expected fetcher shape `(fqn, params) => Promise<T>`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
❌ UI Checkstyle Failed❌ ESLint + Prettier + Organise Imports (src)One or more source files have linting or formatting issues. Affected files
Fix locally (fast — only checks files changed in this branch): make ui-checkstyle-changed |
P3.1 worked-template: the main `getTableDetailsByFQN` fetch on
TableDetailsPageV1 — by far the highest-traffic entity-detail page in
OpenMetadata — moves from a hand-rolled `useState + useCallback +
useEffect` pattern to React Query.
Why TableDetailsPageV1 first: it's the heaviest page (~25 setTableDetails
mutation call sites, tour-mode override, permission-gated fetch, post-
edit refetch on vote). Migrating it first establishes the precise
recipe other entity-detail pages can follow systematically.
What changed:
- `useState<Table>()` + `useState(loading)` + `fetchTableDetails` callback
replaced by a single `useQuery({ queryKey, queryFn, enabled })`.
- Stable queryKey of `['table-detail', tableFqn, fieldsString]` —
permission changes mutate the fields string and invalidate the cache
automatically; FQN changes swap cache slot or refetch.
- Permissions gating: `enabled` waits for `tablePermissionsLoaded`
(sentinel-check on `DEFAULT_ENTITY_PERMISSION` reference) so the
query doesn't race the permission fetch.
- Tour-mode mock data injected via `queryClient.setQueryData(key, mock)`
— useQuery picks it up because `data` is sourced from the cache.
- FORBIDDEN navigation moved from try/catch into a useEffect on
`tableQueryError`; addToRecentViewed moved into a useEffect on
`tableDetails?.id`.
- `loading` derives `isTableLoading || (permissions still loading)` so
the page doesn't briefly render the no-data placeholder before the
query is even enabled.
Backward-compat shim — preserves the call-site contract:
- `setTableDetails(value)` and `setTableDetails(updater)` continue to
work via a wrapper that forwards to `queryClient.setQueryData`. The
~25 mutation call sites in this file (edit handlers, follow,
unfollow, vote, restore, certification, tier, suggestions) need NO
changes — they keep writing to "tableDetails" and reads stay
consistent because both sides are now backed by the cache.
- `fetchTableDetails()` becomes a thin wrapper around `refetch()`.
Reorder: `extraDropdownContent` useMemo moved to AFTER the useQuery
block because it now reads `tableDetails` from the query (which must
be defined first). No behaviour change.
Side-effects from the legacy FQN-change useEffect now live in two
focused effects: tour-mode cache priming, and getEntityFeedCount.
Verified: yarn build green, eslint clean, tsc clean (the one
pre-existing tsc error in this file is unrelated — `findColumnByEntityLink`
on a `string | undefined`).
Other entity-detail pages (Dashboard, Pipeline, MlModel, etc.) follow
the same recipe in follow-up commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| // `tablePermissions` is the sentinel `DEFAULT_ENTITY_PERMISSION` reference. We use that as | ||
| // a "permissions still loading" signal — gates both the query and the page-level loader so | ||
| // the page doesn't race the permission fetch. | ||
| const tablePermissionsLoaded = tablePermissions !== DEFAULT_ENTITY_PERMISSION; |
There was a problem hiding this comment.
⚠️ Bug: Page stuck in infinite loading state if permission fetch fails
If fetchResourcePermission throws (e.g., network error, 500), the catch block shows a toast but never calls setTablePermissions(...). This means tablePermissions remains the DEFAULT_ENTITY_PERMISSION sentinel, so tablePermissionsLoaded stays false, loading stays true forever, and the useQuery never fires (enabled is gated on tablePermissionsLoaded).
The old code had finally { setLoading(false) } in fetchResourcePermission to unblock the page even on permission failure. That safety net was removed in this commit (lines 447-450 in the diff).
Suggested fix:
Either:
1. In the `catch` block of `fetchResourcePermission`, set permissions to a fallback that signals "loaded but empty":
catch {
setTablePermissions({ ...DEFAULT_ENTITY_PERMISSION }); // new object !== sentinel
showErrorToast(...);
}
2. Or introduce a separate `permissionsError` state that the `loading` derivation accounts for:
const loading = !isTourOpen && !isTourPage &&
(!tablePermissionsLoaded && !permissionsError) || isTableLoading);
- Apply suggested fix
Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎
Code Review
|
| Compact |
|
Was this helpful? React with 👍 / 👎 | Gitar
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 23 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
openmetadata-ui/src/main/resources/ui/src/utils/WorksheetDetailsUtils.tsx:49
defaultFieldsincludesTabSpecificField.ROW_COUNTtwice, which results in a duplicated field in thefields=query param. This is unnecessary work and can make debugging field selection harder; please remove the duplicate (or dedupe the list before joining).
export const defaultFields = [
TabSpecificField.OWNERS,
TabSpecificField.FOLLOWERS,
TabSpecificField.TAGS,
TabSpecificField.DOMAINS,
TabSpecificField.DATA_PRODUCTS,
TabSpecificField.VOTES,
TabSpecificField.ROW_COUNT,
TabSpecificField.COLUMNS,
TabSpecificField.ROW_COUNT,
].join(',');
| } catch { | ||
| showErrorToast( | ||
| t('server.fetch-entity-permissions-error', { | ||
| entity: t('label.resource-permission-lowercase'), | ||
| }) |
| // QueryClientProvider sits OUTSIDE AuthProvider so any query made during the auth flow | ||
| // (e.g. fetching feature flags before login) reuses the same cache. AuthProvider remounts | ||
| // on logout — wrapping QueryClient inside would discard the cache on every logout, | ||
| // which is the opposite of what we want here. | ||
| return ( | ||
| <AuthProvider childComponentType={AppRouter}> | ||
| <AppRouter /> | ||
| </AuthProvider> | ||
| <QueryClientProvider client={queryClient}> | ||
| <AuthProvider childComponentType={AppRouter}> | ||
| <AppRouter /> | ||
| </AuthProvider> | ||
| </QueryClientProvider> |
| // Main entity fetch — migrated from a hand-rolled `useState + useCallback + useEffect` | ||
| // pattern to React Query (P3.1). Replaces `fetchTableDetails` and the `[tableDetails, | ||
| // setTableDetails]` useState below. Existing call sites that did `setTableDetails(...)` or | ||
| // `fetchTableDetails()` continue to work via the wrapper functions defined below — the | ||
| // page state-shape contract is preserved. | ||
| const { |
🔴 Playwright Results — 24 failure(s), 17 flaky✅ 3979 passed · ❌ 24 failed · 🟡 17 flaky · ⏭️ 151 skipped
Genuine Failures (failed on all attempts)❌
|
Describe your changes:
Completes P3.3 (per-page
fields=trim) and P3.1 (React Query foundation) on the perceived-latency design doc. Goes beyond the initial pilot — the lazy-extension pattern is now applied across all 9 entity-detail pages that requestedEXTENSIONeagerly, and a reusable hook captures the pattern for follow-up migrations.P3.1 — React Query infrastructure
@tanstack/react-querydep added (codebase had zero query libraries before).src/queryClient.ts— single sharedQueryClientwith sensible defaults:staleTime: 30s,gcTime: 5min,refetchOnWindowFocus: true,retry: 1.App.tsxwrapsAuthProvider + AppRouterinQueryClientProvider(outsideAuthProviderso the cache survives logout/login transitions).P3.1 — Reusable lazy-fetch hook
src/hooks/useLazyEntityExtension.ts— generic over entity shape, takes(entityType, fqn, activeTab, fetcher, onResolve). Internally:useQuerygated onactiveTab === EntityTabs.CUSTOM_PROPERTIES && Boolean(fqn)staleTime— custom-property values change rarelyTabSpecificField.EXTENSIONfield (canonical enum, no per-page constants)onResolve(rather than passingsetState) — different pages init state as{} as TvsuseState<T>(); the callback shape lets each consumer handle their own state semanticsP3.3 —
EXTENSIONtrim across 9 entity-detail pagesThe custom-property values blob can run into hundreds of KB on entities with many user-defined properties; only the Custom Properties tab consumes it. Trimmed eagerly-requested
EXTENSIONfromdefaultFieldsin:utils/DatasetDetailsUtils.ts(Table)utils/DashboardDetailsUtils.tsx(Dashboard)utils/PipelineDetailsUtils.tsx(Pipeline)utils/MlModelDetailsUtils.tsx(MlModel)utils/StoredProceduresUtils.tsx(StoredProcedure)utils/SearchIndexUtils.tsx(SearchIndex)utils/DirectoryDetailsUtils.tsx(Directory)utils/SpreadsheetDetailsUtils.tsx(Spreadsheet)utils/WorksheetDetailsUtils.tsx(Worksheet)P3.1 — Hook applied across 9 entity-detail pages
Lazy
useLazyEntityExtensioncall wired on:pages/TableDetailsPageV1/TableDetailsPageV1.tsx(refactored from inline pilot to use the hook)pages/DashboardDetailsPage/DashboardDetailsPage.component.tsxpages/PipelineDetails/PipelineDetailsPage.component.tsxpages/MlModelPage/MlModelPage.component.tsxpages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsxpages/StoredProcedure/StoredProcedurePage.tsxpages/DirectoryDetailsPage/DirectoryDetailsPage.tsx(drive — adaptsgetDriveAssetByFqn<T>signature via closure)pages/SpreadsheetDetailsPage/SpreadsheetDetailsPage.tsx(drive — adapter)pages/WorksheetDetailsPage/WorksheetDetailsPage.tsx(drive — adapter)Each page activation of Custom Properties tab now fires a single targeted
GET ?fields=extensioninstead of paying for the field on every page load.What's NOT in this PR (honest scope statement)
getXyzByFqnfetch to useQuery — this is the big-picture P3.1 work, but it's a multi-PR refactor. Each page has 10-20setXxxDetails(...)call sites in edit handlers, follow handlers, vote handlers, etc. that would need to be converted toqueryClient.setQueryData(...)for cache consistency. Mistakes there cause stale UI bugs in production. This PR ships the foundation + the safe lazy-extension migration; the main-fetch migrations land incrementally one page at a time.votes,followerson tabs they're not visible from) — separate audit per design doc.Verification
yarn buildsucceeds; the previous AsyncDeleteProvider grab-bag chunk is unchanged in size (this PR's wins are at runtime, not build-time).npx tsc --noEmitclean on all 18 touched files (3 pre-existing unrelatedlodash.gettyping issues in Drive pagefollowXhandlers — not introduced by this PR).extension→ click Custom Properties tab → confirm a separateGET ?fields=extensionfires → click Schema then Custom Properties again within 60s → confirm cache hit (no network).Type of change:
Frontend Preview (Loom)
N/A — no visual change. Verification path above.
Checklist:
<type>: <title>and follows Conventional Commits Specificationyarn buildsucceeds.🤖 Generated with Claude Code
Summary by Gitar
TableDetailsPageV1primary entity fetch touseQuery, preserving state-contract viasetTableDetailsandfetchTableDetailswrappers.isTourOpenstatus.This will update automatically on new commits.