Skip to content

Commit b7c97fd

Browse files
Copilothotlong
andcommitted
Add useDynamicApp hook for dynamic app configuration loading with tests
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent d13eded commit b7c97fd

3 files changed

Lines changed: 304 additions & 0 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* Tests for useDynamicApp hook
3+
*/
4+
5+
import { describe, it, expect, vi, beforeEach } from 'vitest';
6+
import { renderHook, waitFor, act } from '@testing-library/react';
7+
import { useDynamicApp } from '../useDynamicApp';
8+
9+
describe('useDynamicApp', () => {
10+
const staticConfig = {
11+
name: 'crm',
12+
label: 'CRM App',
13+
objects: ['contact', 'account'],
14+
};
15+
16+
it('returns static config when no adapter is provided', () => {
17+
const { result } = renderHook(() =>
18+
useDynamicApp({ appId: 'crm', staticConfig }),
19+
);
20+
21+
expect(result.current.config).toEqual(staticConfig);
22+
expect(result.current.isLoading).toBe(false);
23+
expect(result.current.isServerConfig).toBe(false);
24+
});
25+
26+
it('returns null config when neither adapter nor static config provided', () => {
27+
const { result } = renderHook(() =>
28+
useDynamicApp({ appId: 'crm' }),
29+
);
30+
31+
expect(result.current.config).toBeNull();
32+
expect(result.current.isLoading).toBe(false);
33+
});
34+
35+
it('loads config from server adapter', async () => {
36+
const serverConfig = { name: 'crm', label: 'CRM (Server)', objects: ['contact'] };
37+
const adapter = {
38+
getApp: vi.fn().mockResolvedValue(serverConfig),
39+
};
40+
41+
const { result } = renderHook(() =>
42+
useDynamicApp({ appId: 'crm', staticConfig, adapter }),
43+
);
44+
45+
// Initially loading
46+
expect(result.current.isLoading).toBe(true);
47+
48+
await waitFor(() => {
49+
expect(result.current.isLoading).toBe(false);
50+
});
51+
52+
expect(result.current.config).toEqual(serverConfig);
53+
expect(result.current.isServerConfig).toBe(true);
54+
expect(adapter.getApp).toHaveBeenCalledWith('crm');
55+
});
56+
57+
it('falls back to static config when server returns null', async () => {
58+
const adapter = {
59+
getApp: vi.fn().mockResolvedValue(null),
60+
};
61+
62+
const { result } = renderHook(() =>
63+
useDynamicApp({ appId: 'crm', staticConfig, adapter }),
64+
);
65+
66+
await waitFor(() => {
67+
expect(result.current.isLoading).toBe(false);
68+
});
69+
70+
expect(result.current.config).toEqual(staticConfig);
71+
expect(result.current.isServerConfig).toBe(false);
72+
});
73+
74+
it('falls back to static config on server error', async () => {
75+
const adapter = {
76+
getApp: vi.fn().mockRejectedValue(new Error('Network error')),
77+
};
78+
79+
const { result } = renderHook(() =>
80+
useDynamicApp({ appId: 'crm', staticConfig, adapter }),
81+
);
82+
83+
await waitFor(() => {
84+
expect(result.current.isLoading).toBe(false);
85+
});
86+
87+
expect(result.current.config).toEqual(staticConfig);
88+
expect(result.current.isServerConfig).toBe(false);
89+
expect(result.current.error?.message).toBe('Network error');
90+
});
91+
92+
it('skips loading when enabled is false', () => {
93+
const adapter = {
94+
getApp: vi.fn(),
95+
};
96+
97+
const { result } = renderHook(() =>
98+
useDynamicApp({ appId: 'crm', staticConfig, adapter, enabled: false }),
99+
);
100+
101+
expect(result.current.config).toEqual(staticConfig);
102+
expect(result.current.isLoading).toBe(false);
103+
expect(adapter.getApp).not.toHaveBeenCalled();
104+
});
105+
106+
it('refresh invalidates cache and re-fetches', async () => {
107+
let callCount = 0;
108+
const adapter = {
109+
getApp: vi.fn().mockImplementation(async () => {
110+
callCount++;
111+
return { name: 'crm', version: callCount };
112+
}),
113+
invalidateCache: vi.fn(),
114+
};
115+
116+
const { result } = renderHook(() =>
117+
useDynamicApp({ appId: 'crm', adapter }),
118+
);
119+
120+
await waitFor(() => {
121+
expect(result.current.isLoading).toBe(false);
122+
});
123+
124+
expect((result.current.config as any).version).toBe(1);
125+
126+
await act(async () => {
127+
await result.current.refresh();
128+
});
129+
130+
expect(adapter.invalidateCache).toHaveBeenCalledWith('app:crm');
131+
expect((result.current.config as any).version).toBe(2);
132+
});
133+
134+
it('reloads when appId changes', async () => {
135+
const adapter = {
136+
getApp: vi.fn().mockImplementation(async (appId: string) => {
137+
return { name: appId };
138+
}),
139+
};
140+
141+
const { result, rerender } = renderHook(
142+
({ appId }: { appId: string }) =>
143+
useDynamicApp({ appId, adapter }),
144+
{ initialProps: { appId: 'crm' } },
145+
);
146+
147+
await waitFor(() => {
148+
expect(result.current.isLoading).toBe(false);
149+
});
150+
151+
expect((result.current.config as any).name).toBe('crm');
152+
153+
rerender({ appId: 'erp' });
154+
155+
await waitFor(() => {
156+
expect((result.current.config as any).name).toBe('erp');
157+
});
158+
159+
expect(adapter.getApp).toHaveBeenCalledWith('erp');
160+
});
161+
});

packages/react/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ export * from './useActionRunner';
1111
export * from './useNavigationOverlay';
1212
export * from './usePageVariables';
1313
export * from './useViewData';
14+
export * from './useDynamicApp';
1415

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { useState, useEffect, useCallback, useRef } from 'react';
10+
11+
/**
12+
* Options for dynamic app configuration loading.
13+
*/
14+
export interface DynamicAppOptions<TAppConfig = unknown> {
15+
/** Application identifier to load */
16+
appId: string;
17+
/**
18+
* Static (fallback) configuration used when the server does not
19+
* provide an app definition or is unreachable.
20+
*/
21+
staticConfig?: TAppConfig;
22+
/**
23+
* Data source adapter with `getApp` and `invalidateCache` methods.
24+
* Typically an ObjectStackAdapter instance.
25+
*/
26+
adapter?: {
27+
getApp: (appId: string) => Promise<unknown | null>;
28+
invalidateCache?: (key?: string) => void;
29+
getObjectSchema?: (objectName: string) => Promise<unknown>;
30+
};
31+
/** Whether to attempt loading from the server (default: true) */
32+
enabled?: boolean;
33+
}
34+
35+
/**
36+
* Result returned by useDynamicApp.
37+
*/
38+
export interface DynamicAppResult<TAppConfig = unknown> {
39+
/** The resolved app configuration (server or static fallback) */
40+
config: TAppConfig | null;
41+
/** Whether the configuration is currently loading */
42+
isLoading: boolean;
43+
/** Error from the most recent load attempt */
44+
error: Error | null;
45+
/** Whether the config was loaded from the server (vs static fallback) */
46+
isServerConfig: boolean;
47+
/** Re-fetch the app configuration from the server, invalidating cache */
48+
refresh: () => Promise<void>;
49+
}
50+
51+
/**
52+
* React hook for dynamic app configuration loading.
53+
*
54+
* Fetches app configuration from the server via `adapter.getApp(appId)`,
55+
* falling back to `staticConfig` when the server is unavailable.
56+
* Supports cache-based hot-reload via the `refresh()` callback.
57+
*
58+
* @example
59+
* ```tsx
60+
* import { useDynamicApp } from '@object-ui/react';
61+
* import staticAppConfig from '../config/app.json';
62+
*
63+
* function App() {
64+
* const { config, isLoading, refresh } = useDynamicApp({
65+
* appId: 'crm',
66+
* staticConfig: staticAppConfig,
67+
* adapter: dataSource,
68+
* });
69+
*
70+
* if (isLoading) return <LoadingScreen />;
71+
* return <Console config={config} onRefresh={refresh} />;
72+
* }
73+
* ```
74+
*/
75+
export function useDynamicApp<TAppConfig = unknown>(
76+
options: DynamicAppOptions<TAppConfig>,
77+
): DynamicAppResult<TAppConfig> {
78+
const { appId, staticConfig, adapter, enabled = true } = options;
79+
80+
const [config, setConfig] = useState<TAppConfig | null>(staticConfig ?? null);
81+
const [isLoading, setIsLoading] = useState(enabled && !!adapter);
82+
const [error, setError] = useState<Error | null>(null);
83+
const [isServerConfig, setIsServerConfig] = useState(false);
84+
85+
// Ref to track unmount / stale requests
86+
const mountedRef = useRef(true);
87+
useEffect(() => {
88+
mountedRef.current = true;
89+
return () => { mountedRef.current = false; };
90+
}, []);
91+
92+
const loadConfig = useCallback(async () => {
93+
if (!adapter || !enabled) {
94+
setConfig(staticConfig ?? null);
95+
setIsServerConfig(false);
96+
setIsLoading(false);
97+
return;
98+
}
99+
100+
setIsLoading(true);
101+
setError(null);
102+
103+
try {
104+
const serverConfig = await adapter.getApp(appId);
105+
106+
if (!mountedRef.current) return;
107+
108+
if (serverConfig) {
109+
setConfig(serverConfig as TAppConfig);
110+
setIsServerConfig(true);
111+
} else {
112+
// Server returned null — fall back to static config
113+
setConfig(staticConfig ?? null);
114+
setIsServerConfig(false);
115+
}
116+
} catch (err) {
117+
if (!mountedRef.current) return;
118+
119+
setError(err instanceof Error ? err : new Error(String(err)));
120+
// Fall back to static config on error
121+
setConfig(staticConfig ?? null);
122+
setIsServerConfig(false);
123+
} finally {
124+
if (mountedRef.current) {
125+
setIsLoading(false);
126+
}
127+
}
128+
}, [adapter, appId, staticConfig, enabled]);
129+
130+
// Load on mount and when appId changes
131+
useEffect(() => {
132+
loadConfig();
133+
}, [loadConfig]);
134+
135+
const refresh = useCallback(async () => {
136+
// Invalidate cache before re-fetching
137+
adapter?.invalidateCache?.(`app:${appId}`);
138+
await loadConfig();
139+
}, [adapter, appId, loadConfig]);
140+
141+
return { config, isLoading, error, isServerConfig, refresh };
142+
}

0 commit comments

Comments
 (0)