@object-ui/react 0.5.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +32 -0
- package/dist/LazyPluginLoader.d.ts +44 -0
- package/dist/LazyPluginLoader.js +92 -3
- package/dist/SchemaRenderer.d.ts +20 -1
- package/dist/SchemaRenderer.js +76 -13
- package/dist/components/form/FieldFactory.js +44 -40
- package/dist/components/form/FormRenderer.js +45 -3
- package/dist/context/ActionContext.d.ts +76 -0
- package/dist/context/ActionContext.js +139 -0
- package/dist/context/DndContext.d.ts +135 -0
- package/dist/context/DndContext.js +120 -0
- package/dist/context/NotificationContext.d.ts +113 -0
- package/dist/context/NotificationContext.js +130 -0
- package/dist/context/ThemeContext.d.ts +94 -0
- package/dist/context/ThemeContext.js +215 -0
- package/dist/context/index.d.ts +4 -0
- package/dist/context/index.js +4 -0
- package/dist/hooks/index.d.ts +22 -0
- package/dist/hooks/index.js +22 -0
- package/dist/hooks/useActionRunner.d.ts +21 -7
- package/dist/hooks/useActionRunner.js +27 -7
- package/dist/hooks/useAnimation.d.ts +46 -0
- package/dist/hooks/useAnimation.js +59 -0
- package/dist/hooks/useClientNotifications.d.ts +53 -0
- package/dist/hooks/useClientNotifications.js +190 -0
- package/dist/hooks/useColumnSummary.d.ts +38 -0
- package/dist/hooks/useColumnSummary.js +54 -0
- package/dist/hooks/useCrudShortcuts.d.ts +39 -0
- package/dist/hooks/useCrudShortcuts.js +96 -0
- package/dist/hooks/useDensityMode.d.ts +48 -0
- package/dist/hooks/useDensityMode.js +60 -0
- package/dist/hooks/useDiscovery.d.ts +78 -0
- package/dist/hooks/useDiscovery.js +96 -0
- package/dist/hooks/useDynamicApp.d.ts +70 -0
- package/dist/hooks/useDynamicApp.js +92 -0
- package/dist/hooks/useETagCache.d.ts +75 -0
- package/dist/hooks/useETagCache.js +306 -0
- package/dist/hooks/useFocusManagement.d.ts +89 -0
- package/dist/hooks/useFocusManagement.js +188 -0
- package/dist/hooks/useFocusTrap.d.ts +41 -0
- package/dist/hooks/useFocusTrap.js +103 -0
- package/dist/hooks/useKeyboardShortcuts.d.ts +49 -0
- package/dist/hooks/useKeyboardShortcuts.js +86 -0
- package/dist/hooks/useNavigationOverlay.d.ts +78 -0
- package/dist/hooks/useNavigationOverlay.js +112 -0
- package/dist/hooks/useOffline.d.ts +96 -0
- package/dist/hooks/useOffline.js +185 -0
- package/dist/hooks/usePageTransition.d.ts +60 -0
- package/dist/hooks/usePageTransition.js +111 -0
- package/dist/hooks/usePageVariables.d.ts +67 -0
- package/dist/hooks/usePageVariables.js +109 -0
- package/dist/hooks/usePerformance.d.ts +87 -0
- package/dist/hooks/usePerformance.js +165 -0
- package/dist/hooks/usePerformanceBudget.d.ts +78 -0
- package/dist/hooks/usePerformanceBudget.js +209 -0
- package/dist/hooks/useReducedMotion.d.ts +28 -0
- package/dist/hooks/useReducedMotion.js +48 -0
- package/dist/hooks/useSchemaPersistence.d.ts +87 -0
- package/dist/hooks/useSchemaPersistence.js +173 -0
- package/dist/hooks/useViewData.d.ts +73 -0
- package/dist/hooks/useViewData.js +135 -0
- package/dist/hooks/useViewSharing.d.ts +57 -0
- package/dist/hooks/useViewSharing.js +74 -0
- package/dist/hooks/useViewTransition.d.ts +54 -0
- package/dist/hooks/useViewTransition.js +83 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/utils/i18n.d.ts +10 -0
- package/dist/utils/i18n.js +12 -0
- package/package.json +7 -5
- package/src/LazyPluginLoader.tsx +129 -7
- package/src/SchemaRenderer.tsx +122 -13
- package/src/__tests__/LazyPluginLoader.test.tsx +97 -1
- package/src/__tests__/SchemaRenderer.aria.test.tsx +118 -0
- package/src/__tests__/SchemaRenderer.errorBoundary.test.tsx +114 -0
- package/src/__tests__/SchemaRendererProvider.smoke.test.tsx +132 -0
- package/src/__tests__/ThemeProvider.test.tsx +451 -0
- package/src/__tests__/useNavigationOverlay.test.ts +416 -0
- package/src/__tests__/useOffline.test.ts +198 -0
- package/src/__tests__/usePageTransition.test.ts +214 -0
- package/src/__tests__/usePageVariables.test.tsx +334 -0
- package/src/__tests__/usePerformance.test.ts +137 -0
- package/src/__tests__/useViewData.test.tsx +422 -0
- package/src/__tests__/view-switching.test.tsx +456 -0
- package/src/components/form/FieldFactory.tsx +51 -46
- package/src/components/form/FormRenderer.test.tsx +145 -0
- package/src/components/form/FormRenderer.tsx +52 -3
- package/src/context/ActionContext.tsx +213 -0
- package/src/context/DndContext.tsx +260 -0
- package/src/context/NotificationContext.tsx +280 -0
- package/src/context/ThemeContext.tsx +325 -0
- package/src/context/__tests__/ActionContext.test.tsx +174 -0
- package/src/context/__tests__/DndContext.test.tsx +150 -0
- package/src/context/__tests__/NotificationContext.test.tsx +176 -0
- package/src/context/index.ts +4 -0
- package/src/hooks/__tests__/useActionRunner.test.ts +153 -0
- package/src/hooks/__tests__/useAnimation.test.ts +82 -0
- package/src/hooks/__tests__/useColumnSummary.test.ts +93 -0
- package/src/hooks/__tests__/useDensityMode.test.ts +81 -0
- package/src/hooks/__tests__/useDiscovery.test.tsx +149 -0
- package/src/hooks/__tests__/useDynamicApp.test.ts +161 -0
- package/src/hooks/__tests__/useETagCache.test.ts +153 -0
- package/src/hooks/__tests__/useExpression.test.ts +86 -0
- package/src/hooks/__tests__/useFocusTrap.test.ts +47 -0
- package/src/hooks/__tests__/useKeyboardShortcuts.test.ts +144 -0
- package/src/hooks/__tests__/usePerformanceBudget.test.ts +157 -0
- package/src/hooks/__tests__/useSchemaPersistence.test.ts +220 -0
- package/src/hooks/__tests__/useViewSharing.test.ts +121 -0
- package/src/hooks/__tests__/useViewTransition.test.ts +61 -0
- package/src/hooks/index.ts +22 -1
- package/src/hooks/useActionRunner.ts +49 -11
- package/src/hooks/useAnimation.ts +117 -0
- package/src/hooks/useClientNotifications.ts +251 -0
- package/src/hooks/useColumnSummary.ts +87 -0
- package/src/hooks/useCrudShortcuts.ts +126 -0
- package/src/hooks/useDensityMode.ts +102 -0
- package/src/hooks/useDiscovery.ts +141 -0
- package/src/hooks/useDynamicApp.ts +142 -0
- package/src/hooks/useETagCache.ts +404 -0
- package/src/hooks/useFocusManagement.ts +248 -0
- package/src/hooks/useFocusTrap.ts +138 -0
- package/src/hooks/useKeyboardShortcuts.ts +119 -0
- package/src/hooks/useNavigationOverlay.ts +180 -0
- package/src/hooks/useOffline.ts +301 -0
- package/src/hooks/usePageTransition.ts +187 -0
- package/src/hooks/usePageVariables.tsx +157 -0
- package/src/hooks/usePerformance.ts +246 -0
- package/src/hooks/usePerformanceBudget.ts +313 -0
- package/src/hooks/useReducedMotion.ts +53 -0
- package/src/hooks/useSchemaPersistence.ts +222 -0
- package/src/hooks/useViewData.ts +201 -0
- package/src/hooks/useViewSharing.ts +140 -0
- package/src/hooks/useViewTransition.ts +144 -0
- package/src/index.ts +21 -0
- package/src/utils/i18n.ts +10 -0
- package/tsconfig.json +1 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/__tests__/LazyPluginLoader.test.d.ts +0 -8
- package/dist/__tests__/LazyPluginLoader.test.js +0 -49
- package/dist/components/form/FormRenderer.test.d.ts +0 -8
- package/dist/components/form/FormRenderer.test.js +0 -534
- package/dist/index.test.d.ts +0 -8
- package/dist/index.test.js +0 -13
|
@@ -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,0 +1,404 @@
|
|
|
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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Types aligned with @objectstack/client ETag caching
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** Configuration for ETag-based HTTP caching. */
|
|
16
|
+
export interface ETagCacheConfig {
|
|
17
|
+
/** Enable ETag caching (default: true) */
|
|
18
|
+
enabled?: boolean;
|
|
19
|
+
/** Storage backend for ETags ('memory' | 'localStorage') */
|
|
20
|
+
storage?: 'memory' | 'localStorage';
|
|
21
|
+
/** Storage key prefix (default: 'oui-etag') */
|
|
22
|
+
storagePrefix?: string;
|
|
23
|
+
/** Max cache entries (default: 1000) */
|
|
24
|
+
maxEntries?: number;
|
|
25
|
+
/** Cache TTL in ms (default: 3600000 = 1 hour) */
|
|
26
|
+
ttl?: number;
|
|
27
|
+
/** Whether to register a Service Worker for caching (default: false) */
|
|
28
|
+
useServiceWorker?: boolean;
|
|
29
|
+
/** Service Worker script URL */
|
|
30
|
+
serviceWorkerUrl?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A single cache entry with its associated ETag and metadata. */
|
|
34
|
+
export interface CacheEntry<T = unknown> {
|
|
35
|
+
data: T;
|
|
36
|
+
etag: string;
|
|
37
|
+
url: string;
|
|
38
|
+
timestamp: number;
|
|
39
|
+
lastModified?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Result returned by the useETagCache hook. */
|
|
43
|
+
export interface ETagCacheResult {
|
|
44
|
+
/** Fetch data with ETag support — sends If-None-Match header, returns cached data on 304. */
|
|
45
|
+
fetchWithETag: <T>(
|
|
46
|
+
url: string,
|
|
47
|
+
options?: RequestInit,
|
|
48
|
+
) => Promise<{ data: T; fromCache: boolean; etag?: string }>;
|
|
49
|
+
/** Invalidate cache for a specific URL. */
|
|
50
|
+
invalidate: (url: string) => void;
|
|
51
|
+
/** Invalidate all cache entries matching a pattern. */
|
|
52
|
+
invalidatePattern: (pattern: string | RegExp) => void;
|
|
53
|
+
/** Clear entire cache. */
|
|
54
|
+
clearCache: () => void;
|
|
55
|
+
/** Number of cached entries. */
|
|
56
|
+
cacheSize: number;
|
|
57
|
+
/** Whether Service Worker is registered and active. */
|
|
58
|
+
serviceWorkerActive: boolean;
|
|
59
|
+
/** Register/update the Service Worker. */
|
|
60
|
+
registerServiceWorker: () => Promise<boolean>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Defaults
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const DEFAULTS = {
|
|
68
|
+
enabled: true,
|
|
69
|
+
storage: 'memory' as const,
|
|
70
|
+
storagePrefix: 'oui-etag',
|
|
71
|
+
maxEntries: 1000,
|
|
72
|
+
ttl: 3_600_000, // 1 hour
|
|
73
|
+
useServiceWorker: false,
|
|
74
|
+
serviceWorkerUrl: '/service-worker.js',
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// LRU cache implementation (module-scoped, shared across instances)
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const memoryCache = new Map<string, CacheEntry>();
|
|
82
|
+
|
|
83
|
+
/** Access order tracking for LRU eviction — most recently used at the end. */
|
|
84
|
+
const accessOrder: string[] = [];
|
|
85
|
+
|
|
86
|
+
function touchAccessOrder(url: string): void {
|
|
87
|
+
const idx = accessOrder.indexOf(url);
|
|
88
|
+
if (idx !== -1) accessOrder.splice(idx, 1);
|
|
89
|
+
accessOrder.push(url);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function evictLRU(maxEntries: number): void {
|
|
93
|
+
while (memoryCache.size > maxEntries && accessOrder.length > 0) {
|
|
94
|
+
const oldest = accessOrder.shift();
|
|
95
|
+
if (oldest) memoryCache.delete(oldest);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// localStorage helpers (SSR-safe)
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function loadFromLocalStorage(prefix: string): Map<string, CacheEntry> {
|
|
104
|
+
if (typeof localStorage === 'undefined') return new Map();
|
|
105
|
+
const entries = new Map<string, CacheEntry>();
|
|
106
|
+
try {
|
|
107
|
+
const raw = localStorage.getItem(`${prefix}:index`);
|
|
108
|
+
if (!raw) return entries;
|
|
109
|
+
const keys = JSON.parse(raw) as string[];
|
|
110
|
+
for (const key of keys) {
|
|
111
|
+
const item = localStorage.getItem(`${prefix}:${key}`);
|
|
112
|
+
if (item) {
|
|
113
|
+
entries.set(key, JSON.parse(item) as CacheEntry);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// corrupted storage — ignore
|
|
118
|
+
}
|
|
119
|
+
return entries;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function persistToLocalStorage(prefix: string, cache: Map<string, CacheEntry>): void {
|
|
123
|
+
if (typeof localStorage === 'undefined') return;
|
|
124
|
+
try {
|
|
125
|
+
const keys = Array.from(cache.keys());
|
|
126
|
+
localStorage.setItem(`${prefix}:index`, JSON.stringify(keys));
|
|
127
|
+
for (const [key, entry] of cache) {
|
|
128
|
+
localStorage.setItem(`${prefix}:${key}`, JSON.stringify(entry));
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// localStorage full or unavailable — silently ignore
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function clearLocalStorage(prefix: string): void {
|
|
136
|
+
if (typeof localStorage === 'undefined') return;
|
|
137
|
+
try {
|
|
138
|
+
const raw = localStorage.getItem(`${prefix}:index`);
|
|
139
|
+
if (raw) {
|
|
140
|
+
const keys = JSON.parse(raw) as string[];
|
|
141
|
+
for (const key of keys) {
|
|
142
|
+
localStorage.removeItem(`${prefix}:${key}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
localStorage.removeItem(`${prefix}:index`);
|
|
146
|
+
} catch {
|
|
147
|
+
// ignore
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function removeFromLocalStorage(prefix: string, url: string): void {
|
|
152
|
+
if (typeof localStorage === 'undefined') return;
|
|
153
|
+
try {
|
|
154
|
+
localStorage.removeItem(`${prefix}:${url}`);
|
|
155
|
+
const raw = localStorage.getItem(`${prefix}:index`);
|
|
156
|
+
if (raw) {
|
|
157
|
+
const keys = (JSON.parse(raw) as string[]).filter((k) => k !== url);
|
|
158
|
+
localStorage.setItem(`${prefix}:index`, JSON.stringify(keys));
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// ignore
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// useETagCache hook
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Hook for ETag-based HTTP caching with optional Service Worker support.
|
|
171
|
+
* Integrates with @objectstack/client ETag caching for efficient data fetching.
|
|
172
|
+
* When the server responds with 304 Not Modified, cached data is reused.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```tsx
|
|
176
|
+
* function UserProfile({ userId }: { userId: string }) {
|
|
177
|
+
* const cache = useETagCache({ storage: 'localStorage', ttl: 60_000 });
|
|
178
|
+
*
|
|
179
|
+
* useEffect(() => {
|
|
180
|
+
* cache.fetchWithETag<User>(`/api/users/${userId}`).then(({ data, fromCache }) => {
|
|
181
|
+
* console.log(fromCache ? 'Served from cache' : 'Fresh data');
|
|
182
|
+
* setUser(data);
|
|
183
|
+
* });
|
|
184
|
+
* }, [userId]);
|
|
185
|
+
*
|
|
186
|
+
* return <div>Cache entries: {cache.cacheSize}</div>;
|
|
187
|
+
* }
|
|
188
|
+
* ```
|
|
189
|
+
*/
|
|
190
|
+
export function useETagCache(userConfig: ETagCacheConfig = {}): ETagCacheResult {
|
|
191
|
+
const enabled = userConfig.enabled ?? DEFAULTS.enabled;
|
|
192
|
+
const storage = userConfig.storage ?? DEFAULTS.storage;
|
|
193
|
+
const storagePrefix = userConfig.storagePrefix ?? DEFAULTS.storagePrefix;
|
|
194
|
+
const maxEntries = userConfig.maxEntries ?? DEFAULTS.maxEntries;
|
|
195
|
+
const ttl = userConfig.ttl ?? DEFAULTS.ttl;
|
|
196
|
+
const useSW = userConfig.useServiceWorker ?? DEFAULTS.useServiceWorker;
|
|
197
|
+
const swUrl = userConfig.serviceWorkerUrl ?? DEFAULTS.serviceWorkerUrl;
|
|
198
|
+
|
|
199
|
+
const [cacheSize, setCacheSize] = useState(0);
|
|
200
|
+
const [serviceWorkerActive, setServiceWorkerActive] = useState(false);
|
|
201
|
+
|
|
202
|
+
// Keep config in a ref so callbacks always see latest values
|
|
203
|
+
const configRef = useRef({ enabled, storage, storagePrefix, maxEntries, ttl });
|
|
204
|
+
configRef.current = { enabled, storage, storagePrefix, maxEntries, ttl };
|
|
205
|
+
|
|
206
|
+
// Hydrate memory cache from localStorage on mount
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (storage === 'localStorage') {
|
|
209
|
+
const persisted = loadFromLocalStorage(storagePrefix);
|
|
210
|
+
for (const [key, entry] of persisted) {
|
|
211
|
+
if (!memoryCache.has(key)) {
|
|
212
|
+
memoryCache.set(key, entry);
|
|
213
|
+
touchAccessOrder(key);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
evictLRU(maxEntries);
|
|
217
|
+
setCacheSize(memoryCache.size);
|
|
218
|
+
}
|
|
219
|
+
// Only run on mount
|
|
220
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
221
|
+
}, []);
|
|
222
|
+
|
|
223
|
+
// Auto-register Service Worker when enabled
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (!useSW) return;
|
|
226
|
+
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
|
227
|
+
|
|
228
|
+
navigator.serviceWorker
|
|
229
|
+
.register(swUrl, { scope: '/' })
|
|
230
|
+
.then((registration) => {
|
|
231
|
+
const worker = registration.active ?? registration.installing ?? registration.waiting;
|
|
232
|
+
setServiceWorkerActive(worker?.state === 'activated');
|
|
233
|
+
|
|
234
|
+
registration.onupdatefound = () => {
|
|
235
|
+
const installing = registration.installing;
|
|
236
|
+
if (!installing) return;
|
|
237
|
+
installing.onstatechange = () => {
|
|
238
|
+
if (installing.state === 'activated') {
|
|
239
|
+
setServiceWorkerActive(true);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
})
|
|
244
|
+
.catch(() => {
|
|
245
|
+
setServiceWorkerActive(false);
|
|
246
|
+
});
|
|
247
|
+
}, [useSW, swUrl]);
|
|
248
|
+
|
|
249
|
+
// ----- Helpers that read/write the shared cache -----
|
|
250
|
+
|
|
251
|
+
const isExpired = useCallback((entry: CacheEntry): boolean => {
|
|
252
|
+
return Date.now() - entry.timestamp > configRef.current.ttl;
|
|
253
|
+
}, []);
|
|
254
|
+
|
|
255
|
+
const setEntry = useCallback(
|
|
256
|
+
(url: string, entry: CacheEntry): void => {
|
|
257
|
+
memoryCache.set(url, entry);
|
|
258
|
+
touchAccessOrder(url);
|
|
259
|
+
evictLRU(configRef.current.maxEntries);
|
|
260
|
+
|
|
261
|
+
if (configRef.current.storage === 'localStorage') {
|
|
262
|
+
persistToLocalStorage(configRef.current.storagePrefix, memoryCache);
|
|
263
|
+
}
|
|
264
|
+
setCacheSize(memoryCache.size);
|
|
265
|
+
},
|
|
266
|
+
[],
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const removeEntry = useCallback(
|
|
270
|
+
(url: string): void => {
|
|
271
|
+
memoryCache.delete(url);
|
|
272
|
+
const idx = accessOrder.indexOf(url);
|
|
273
|
+
if (idx !== -1) accessOrder.splice(idx, 1);
|
|
274
|
+
|
|
275
|
+
if (configRef.current.storage === 'localStorage') {
|
|
276
|
+
removeFromLocalStorage(configRef.current.storagePrefix, url);
|
|
277
|
+
}
|
|
278
|
+
setCacheSize(memoryCache.size);
|
|
279
|
+
},
|
|
280
|
+
[],
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// ----- Public API -----
|
|
284
|
+
|
|
285
|
+
const fetchWithETag = useCallback(
|
|
286
|
+
async <T>(
|
|
287
|
+
url: string,
|
|
288
|
+
options?: RequestInit,
|
|
289
|
+
): Promise<{ data: T; fromCache: boolean; etag?: string }> => {
|
|
290
|
+
// Validate URL: only allow http(s) or relative URLs
|
|
291
|
+
if (url.includes('://')) {
|
|
292
|
+
try {
|
|
293
|
+
const parsed = new URL(url);
|
|
294
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
295
|
+
throw new Error(`Unsupported URL protocol: ${parsed.protocol}`);
|
|
296
|
+
}
|
|
297
|
+
} catch (e) {
|
|
298
|
+
if (e instanceof Error && e.message.startsWith('Unsupported')) throw e;
|
|
299
|
+
throw new Error('Invalid URL');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!configRef.current.enabled) {
|
|
304
|
+
const res = await fetch(url, options);
|
|
305
|
+
const data = (await res.json()) as T;
|
|
306
|
+
return { data, fromCache: false, etag: res.headers.get('etag') ?? undefined };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const cached = memoryCache.get(url) as CacheEntry<T> | undefined;
|
|
310
|
+
const headers = new Headers(options?.headers);
|
|
311
|
+
|
|
312
|
+
if (cached && !isExpired(cached)) {
|
|
313
|
+
if (cached.etag) {
|
|
314
|
+
headers.set('If-None-Match', cached.etag);
|
|
315
|
+
}
|
|
316
|
+
if (cached.lastModified) {
|
|
317
|
+
headers.set('If-Modified-Since', cached.lastModified);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const res = await fetch(url, { ...options, headers });
|
|
322
|
+
|
|
323
|
+
// 304 Not Modified — serve from cache
|
|
324
|
+
if (res.status === 304 && cached) {
|
|
325
|
+
touchAccessOrder(url);
|
|
326
|
+
return { data: cached.data, fromCache: true, etag: cached.etag };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const data = (await res.json()) as T;
|
|
330
|
+
const etag = res.headers.get('etag') ?? undefined;
|
|
331
|
+
const lastModified = res.headers.get('last-modified') ?? undefined;
|
|
332
|
+
|
|
333
|
+
if (etag || lastModified) {
|
|
334
|
+
setEntry(url, {
|
|
335
|
+
data,
|
|
336
|
+
etag: etag ?? '',
|
|
337
|
+
url,
|
|
338
|
+
timestamp: Date.now(),
|
|
339
|
+
lastModified,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { data, fromCache: false, etag };
|
|
344
|
+
},
|
|
345
|
+
[isExpired, setEntry],
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const invalidate = useCallback(
|
|
349
|
+
(url: string): void => {
|
|
350
|
+
removeEntry(url);
|
|
351
|
+
},
|
|
352
|
+
[removeEntry],
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const invalidatePattern = useCallback(
|
|
356
|
+
(pattern: string | RegExp): void => {
|
|
357
|
+
const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
|
|
358
|
+
const keysToRemove = Array.from(memoryCache.keys()).filter((key) => regex.test(key));
|
|
359
|
+
for (const key of keysToRemove) {
|
|
360
|
+
removeEntry(key);
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
[removeEntry],
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const clearCache = useCallback((): void => {
|
|
367
|
+
memoryCache.clear();
|
|
368
|
+
accessOrder.length = 0;
|
|
369
|
+
|
|
370
|
+
if (configRef.current.storage === 'localStorage') {
|
|
371
|
+
clearLocalStorage(configRef.current.storagePrefix);
|
|
372
|
+
}
|
|
373
|
+
setCacheSize(0);
|
|
374
|
+
}, []);
|
|
375
|
+
|
|
376
|
+
const registerSW = useCallback(async (): Promise<boolean> => {
|
|
377
|
+
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
const registration = await navigator.serviceWorker.register(swUrl, { scope: '/' });
|
|
382
|
+
const worker = registration.active ?? registration.installing ?? registration.waiting;
|
|
383
|
+
const active = worker?.state === 'activated';
|
|
384
|
+
setServiceWorkerActive(active);
|
|
385
|
+
return active;
|
|
386
|
+
} catch {
|
|
387
|
+
setServiceWorkerActive(false);
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
}, [swUrl]);
|
|
391
|
+
|
|
392
|
+
return useMemo<ETagCacheResult>(
|
|
393
|
+
() => ({
|
|
394
|
+
fetchWithETag,
|
|
395
|
+
invalidate,
|
|
396
|
+
invalidatePattern,
|
|
397
|
+
clearCache,
|
|
398
|
+
cacheSize,
|
|
399
|
+
serviceWorkerActive,
|
|
400
|
+
registerServiceWorker: registerSW,
|
|
401
|
+
}),
|
|
402
|
+
[fetchWithETag, invalidate, invalidatePattern, clearCache, cacheSize, serviceWorkerActive, registerSW],
|
|
403
|
+
);
|
|
404
|
+
}
|