Skip to content

Commit cab16d2

Browse files
refactor(console): lazy MetadataProvider — only fetch app on mount
Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/89d7bf38-7ea0-4ee9-b0e9-1ecb766df6df Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com>
1 parent 532e4cd commit cab16d2

4 files changed

Lines changed: 814 additions & 76 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- **`MetadataProvider` now lazy-loads metadata.** Previously the console
13+
fetched the full `app`, `object`, `dashboard`, `report`, and `page`
14+
lists in parallel at app startup, so first-paint cost scaled linearly
15+
with tenant size. The provider now only fetches the `app` list
16+
eagerly (required by the router and the App switcher); the other
17+
buckets are loaded on demand the first time a consumer reads them.
18+
Concurrent reads share a single in-flight request, results are cached
19+
with a 5 minute TTL, and the eager `app` list is hydrated from
20+
`sessionStorage` on reload for an instant first paint.
21+
- New context API: `ensureType(type)`, `getItem(type, name)`,
22+
`invalidate(type, name?)`, `refresh(type?)` (per-type form).
23+
- New hooks: `useMetadataType(type)`, `useMetadataItem(type, name)`.
24+
- The legacy `useMetadata()` shape (`apps`, `objects`, `dashboards`,
25+
`reports`, `pages`, `loading`, `error`, `refresh`,
26+
`getItemsByType`) is preserved — reading any of the lazy array
27+
properties transparently triggers `ensureType` so existing
28+
components keep working without changes. The `loading` flag now
29+
reflects only the initial `app` load, not lazy buckets.
30+
1031
### Fixed
1132

1233
- **Record detail header** no longer renders two separate "More" (⋯) overflow

ROADMAP.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1410,6 +1410,42 @@ Plugin architecture refactoring to support true modular development, plugin isol
14101410

14111411
## 🐛 Bug Fixes
14121412

1413+
### Console Metadata Lazy Loading (Phase 1) (April 2026)
1414+
1415+
**Root Cause:** The console's `MetadataProvider` fetched the full `app`,
1416+
`object`, `dashboard`, `report`, and `page` lists in parallel at startup
1417+
and held them in five top-level arrays via `useMetadata()`. As tenant
1418+
metadata grew, first-paint TTI, browser memory, and API pressure all
1419+
scaled linearly with total catalogue size — even when entering a single
1420+
app that only needed a fraction of it.
1421+
1422+
**Fix (Phase 1):** Refactored `MetadataProvider` to a per-type cache map
1423+
(`status / items / byName / fetchedAt / promise`) with these
1424+
characteristics:
1425+
1426+
1. Only the `app` list is fetched on mount (required by router and App
1427+
switcher); other types are fully lazy.
1428+
2. New imperative API on the context value: `ensureType(type)` (in-flight
1429+
de-duplicated), `getItem(type, name)` (single-record fetch + cache),
1430+
`invalidate(type, name?)`, and per-type `refresh(type?)`.
1431+
3. New hooks: `useMetadataType(type)` and `useMetadataItem(type, name)`.
1432+
4. 5-minute TTL freshness on list buckets; `app` list is hydrated from
1433+
`sessionStorage` on reload for instant first paint.
1434+
5. Fully backward compatible: `apps / objects / dashboards / reports /
1435+
pages` are now lazy getters that auto-trigger `ensureType` on first
1436+
read, so all 20+ existing call sites keep working unchanged.
1437+
6. Dev-only timing/cache-hit logs.
1438+
1439+
**Tests:** 7 new `MetadataProviderLazy.test.tsx` cases (only `app`
1440+
fetched on mount, lazy fetch on getter access, in-flight de-duplication,
1441+
single-item fetch & cache, per-record invalidation, scoped `refresh`,
1442+
`useMetadataItem` hook). Full console suite: 891 passed / 1 skipped.
1443+
1444+
**Follow-ups (deferred to later phases of the original plan):** route-
1445+
scoped `AppScopeProvider`, per-component migration to
1446+
`useMetadataItem`, route-level `React.lazy` chunking, and server-side
1447+
`?app=` filter pushdown / ETag support.
1448+
14131449
### Record Detail "Record Not Found" in External Metadata Environments (March 2026)
14141450

14151451
**Root Cause:** Three compounding issues caused "Record not found" when navigating from a list to a record detail page in external metadata environments:
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/**
2+
* MetadataProvider lazy-loading tests
3+
*
4+
* Verifies the Phase 1 refactor: only the `app` type is fetched at
5+
* provider mount; other metadata types are deferred until a consumer
6+
* actually reads them. Also covers in-flight de-duplication and
7+
* per-type invalidation/refresh behaviour.
8+
*/
9+
10+
import { describe, it, expect, vi, beforeEach } from 'vitest';
11+
import { render, act, waitFor } from '@testing-library/react';
12+
import { MetadataProvider, useMetadata, useMetadataItem } from '../context/MetadataProvider';
13+
14+
// ---------------------------------------------------------------------------
15+
// Helpers
16+
// ---------------------------------------------------------------------------
17+
18+
function createMockAdapter() {
19+
const meta = {
20+
getItems: vi.fn().mockImplementation((type: string) => {
21+
// Each type returns a distinguishable single-item payload.
22+
return Promise.resolve({ items: [{ name: `${type}-1` }] });
23+
}),
24+
getItem: vi.fn().mockImplementation((type: string, name: string) => {
25+
return Promise.resolve({ item: { name, type } });
26+
}),
27+
};
28+
const adapter = {
29+
getClient: () => ({ meta }),
30+
} as any;
31+
return { adapter, meta };
32+
}
33+
34+
function flushAll() {
35+
return act(async () => {
36+
await Promise.resolve();
37+
await Promise.resolve();
38+
});
39+
}
40+
41+
// Component that captures the latest context value into a ref-like object
42+
// so assertions can run outside of render.
43+
function Capture({ sink, read }: { sink: { value: any }; read?: (ctx: any) => void }) {
44+
const ctx = useMetadata();
45+
sink.value = ctx;
46+
if (read) read(ctx);
47+
return null;
48+
}
49+
50+
beforeEach(() => {
51+
if (typeof sessionStorage !== 'undefined') sessionStorage.clear();
52+
});
53+
54+
// ---------------------------------------------------------------------------
55+
// Tests
56+
// ---------------------------------------------------------------------------
57+
58+
describe('MetadataProvider — lazy loading (Phase 1)', () => {
59+
it('fetches only the app list on initial mount', async () => {
60+
const { adapter, meta } = createMockAdapter();
61+
const sink = { value: null as any };
62+
63+
render(
64+
<MetadataProvider adapter={adapter}>
65+
<Capture sink={sink} />
66+
</MetadataProvider>,
67+
);
68+
69+
await flushAll();
70+
await waitFor(() => expect(sink.value.loading).toBe(false));
71+
72+
// Only `app` should have been fetched at this point.
73+
const calledTypes = meta.getItems.mock.calls.map((c: any[]) => c[0]);
74+
expect(calledTypes).toEqual(['app']);
75+
expect(sink.value.apps).toEqual([{ name: 'app-1' }]);
76+
77+
// Lazy buckets are still empty arrays — no fetch yet.
78+
expect(meta.getItems).toHaveBeenCalledTimes(1);
79+
});
80+
81+
it('lazily fetches other types only when accessed via the legacy getter', async () => {
82+
const { adapter, meta } = createMockAdapter();
83+
const sink = { value: null as any };
84+
85+
render(
86+
<MetadataProvider adapter={adapter}>
87+
<Capture sink={sink} />
88+
</MetadataProvider>,
89+
);
90+
await flushAll();
91+
await waitFor(() => expect(sink.value.loading).toBe(false));
92+
expect(meta.getItems).toHaveBeenCalledTimes(1);
93+
94+
// Read `objects` via the legacy property (auto-triggers ensureType).
95+
let firstRead: any[] = [];
96+
await act(async () => {
97+
firstRead = sink.value.objects;
98+
});
99+
// Fetch is in-flight; the synchronous read returns the empty snapshot.
100+
expect(firstRead).toEqual([]);
101+
102+
// Wait for the lazy fetch to settle.
103+
await waitFor(() =>
104+
expect(meta.getItems).toHaveBeenCalledWith('object'),
105+
);
106+
await waitFor(() => expect(sink.value.objects).toEqual([{ name: 'object-1' }]));
107+
108+
// Still only two list fetches in total — `dashboard / report / page` untouched.
109+
expect(meta.getItems).toHaveBeenCalledTimes(2);
110+
const types = meta.getItems.mock.calls.map((c: any[]) => c[0]).sort();
111+
expect(types).toEqual(['app', 'object']);
112+
});
113+
114+
it('deduplicates concurrent ensureType calls into a single network request', async () => {
115+
const { adapter, meta } = createMockAdapter();
116+
const sink = { value: null as any };
117+
118+
render(
119+
<MetadataProvider adapter={adapter}>
120+
<Capture sink={sink} />
121+
</MetadataProvider>,
122+
);
123+
await flushAll();
124+
await waitFor(() => expect(sink.value.loading).toBe(false));
125+
126+
await act(async () => {
127+
const a = sink.value.ensureType('dashboard');
128+
const b = sink.value.ensureType('dashboard');
129+
const c = sink.value.ensureType('dashboard');
130+
await Promise.all([a, b, c]);
131+
});
132+
133+
const dashCalls = meta.getItems.mock.calls.filter((c: any[]) => c[0] === 'dashboard');
134+
expect(dashCalls.length).toBe(1);
135+
});
136+
137+
it('getItem fetches a single record without triggering a list fetch', async () => {
138+
const { adapter, meta } = createMockAdapter();
139+
const sink = { value: null as any };
140+
141+
render(
142+
<MetadataProvider adapter={adapter}>
143+
<Capture sink={sink} />
144+
</MetadataProvider>,
145+
);
146+
await flushAll();
147+
await waitFor(() => expect(sink.value.loading).toBe(false));
148+
149+
let item: any;
150+
await act(async () => {
151+
item = await sink.value.getItem('dashboard', 'sales-overview');
152+
});
153+
expect(item).toEqual({ name: 'sales-overview', type: 'dashboard' });
154+
155+
// No list fetch for `dashboard`.
156+
const dashCalls = meta.getItems.mock.calls.filter((c: any[]) => c[0] === 'dashboard');
157+
expect(dashCalls.length).toBe(0);
158+
expect(meta.getItem).toHaveBeenCalledWith('dashboard', 'sales-overview');
159+
160+
// Second call serves from the in-memory cache.
161+
meta.getItem.mockClear();
162+
await act(async () => {
163+
item = await sink.value.getItem('dashboard', 'sales-overview');
164+
});
165+
expect(item).toEqual({ name: 'sales-overview', type: 'dashboard' });
166+
expect(meta.getItem).not.toHaveBeenCalled();
167+
});
168+
169+
it('invalidate(type, name) drops a single record without dropping the bucket', async () => {
170+
const { adapter, meta } = createMockAdapter();
171+
const sink = { value: null as any };
172+
173+
render(
174+
<MetadataProvider adapter={adapter}>
175+
<Capture sink={sink} />
176+
</MetadataProvider>,
177+
);
178+
await flushAll();
179+
await waitFor(() => expect(sink.value.loading).toBe(false));
180+
181+
// Prime the dashboard bucket.
182+
await act(async () => {
183+
await sink.value.ensureType('dashboard');
184+
});
185+
expect(sink.value.dashboards).toEqual([{ name: 'dashboard-1' }]);
186+
187+
// Invalidate the single item — the bucket is preserved but the entry vanishes.
188+
await act(async () => {
189+
sink.value.invalidate('dashboard', 'dashboard-1');
190+
});
191+
expect(sink.value.dashboards).toEqual([]);
192+
193+
// Re-reading via getItem triggers a single-item fetch.
194+
meta.getItem.mockClear();
195+
await act(async () => {
196+
await sink.value.getItem('dashboard', 'dashboard-1');
197+
});
198+
expect(meta.getItem).toHaveBeenCalledWith('dashboard', 'dashboard-1');
199+
});
200+
201+
it('refresh(type) re-fetches a single bucket; refresh() refreshes only loaded buckets', async () => {
202+
const { adapter, meta } = createMockAdapter();
203+
const sink = { value: null as any };
204+
205+
render(
206+
<MetadataProvider adapter={adapter}>
207+
<Capture sink={sink} />
208+
</MetadataProvider>,
209+
);
210+
await flushAll();
211+
await waitFor(() => expect(sink.value.loading).toBe(false));
212+
213+
// Load `object` only; leave dashboard/report/page idle.
214+
await act(async () => {
215+
await sink.value.ensureType('object');
216+
});
217+
218+
meta.getItems.mockClear();
219+
await act(async () => {
220+
await sink.value.refresh();
221+
});
222+
223+
// refresh() should re-fetch `app` and `object` (loaded), not the idle types.
224+
const refreshed = meta.getItems.mock.calls.map((c: any[]) => c[0]).sort();
225+
expect(refreshed).toEqual(['app', 'object']);
226+
});
227+
});
228+
229+
describe('useMetadataItem hook', () => {
230+
it('resolves to the fetched item and reports loading transitions', async () => {
231+
const { adapter, meta } = createMockAdapter();
232+
let captured: any = null;
233+
234+
function Probe() {
235+
const state = useMetadataItem('dashboard', 'sales');
236+
captured = state;
237+
return null;
238+
}
239+
240+
render(
241+
<MetadataProvider adapter={adapter}>
242+
<Probe />
243+
</MetadataProvider>,
244+
);
245+
246+
expect(captured.loading).toBe(true);
247+
await waitFor(() => expect(captured.loading).toBe(false));
248+
expect(captured.item).toEqual({ name: 'sales', type: 'dashboard' });
249+
expect(captured.error).toBeNull();
250+
expect(meta.getItem).toHaveBeenCalledWith('dashboard', 'sales');
251+
});
252+
});

0 commit comments

Comments
 (0)