@equinor/fusion-framework-react-app 12.0.5 → 14.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/CHANGELOG.md +61 -0
- package/README.md +208 -0
- package/dist/esm/state/index.js +5 -0
- package/dist/esm/state/index.js.map +1 -0
- package/dist/esm/state/useAppState.js +247 -0
- package/dist/esm/state/useAppState.js.map +1 -0
- package/dist/esm/state/useStateSyncEvents.js +48 -0
- package/dist/esm/state/useStateSyncEvents.js.map +1 -0
- package/dist/esm/version.js +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/state/index.d.ts +4 -0
- package/dist/types/state/useAppState.d.ts +157 -0
- package/dist/types/state/useStateSyncEvents.d.ts +23 -0
- package/dist/types/version.d.ts +1 -1
- package/package.json +22 -9
- package/src/__tests__/useStateSyncEvents.test.ts +57 -0
- package/src/state/index.ts +12 -0
- package/src/state/useAppState.ts +299 -0
- package/src/state/useStateSyncEvents.ts +59 -0
- package/src/version.ts +1 -1
- package/tsconfig.json +5 -2
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { AllowedValue, IStateProvider, StateItem, StateSyncEvent, type StateSyncEventType, } from '@equinor/fusion-framework-module-state';
|
|
2
|
+
export { enableState as enableAppState } from '@equinor/fusion-framework-app/enable-state';
|
|
3
|
+
export { useAppState } from './useAppState';
|
|
4
|
+
export { useStateSyncEvents } from './useStateSyncEvents';
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { type SetStateAction } from 'react';
|
|
2
|
+
import type { AllowedValue } from '@equinor/fusion-framework-module-state';
|
|
3
|
+
/**
|
|
4
|
+
* Configuration options for the `useAppState` hook.
|
|
5
|
+
*/
|
|
6
|
+
interface UseAppStateOptions<T extends AllowedValue> {
|
|
7
|
+
/**
|
|
8
|
+
* Default value to return when the state item doesn't exist or is undefined.
|
|
9
|
+
* This value will be returned immediately on first render if no stored value exists.
|
|
10
|
+
*/
|
|
11
|
+
defaultValue?: T;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A React hook for managing persistent application state through the Fusion Framework.
|
|
15
|
+
*
|
|
16
|
+
* This hook provides a simple way to store and retrieve values that persist across
|
|
17
|
+
* app sessions and are shared between different parts of your application. It works
|
|
18
|
+
* similarly to `useState` but with automatic persistence and cross-component synchronization.
|
|
19
|
+
*
|
|
20
|
+
* **Key Features:**
|
|
21
|
+
* - Automatic persistence across app sessions
|
|
22
|
+
* - Real-time synchronization between components
|
|
23
|
+
* - Optimistic updates for responsive UX
|
|
24
|
+
* - Deep equality checking to prevent unnecessary re-renders
|
|
25
|
+
* - TypeScript support with type safety
|
|
26
|
+
* - Default value support
|
|
27
|
+
*
|
|
28
|
+
* **Important Notes:**
|
|
29
|
+
* - Setting a value to `undefined` will completely remove it from storage
|
|
30
|
+
* - Values must be serializable (JSON-compatible)
|
|
31
|
+
* - Use unique keys to avoid conflicts between different state items
|
|
32
|
+
* - **The key parameter must remain constant across re-renders** - changing the key after the hook
|
|
33
|
+
* is initialized will be ignored and a warning will be logged in development mode
|
|
34
|
+
*
|
|
35
|
+
* @template T The type of value to store. Must be serializable (string, number, boolean, object, array, etc.)
|
|
36
|
+
* @param key Unique identifier for this state item. Use descriptive names like 'user.preferences' or 'dashboard.filters'. **Must remain constant across re-renders.**
|
|
37
|
+
* @param options Configuration options including default value
|
|
38
|
+
* @returns A tuple containing [currentValue, setValue] similar to useState
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* **Basic Usage:**
|
|
42
|
+
* ```tsx
|
|
43
|
+
* function UserProfile() {
|
|
44
|
+
* // Simple string state with default value
|
|
45
|
+
* const [userName, setUserName] = useAppState('user.name', {
|
|
46
|
+
* defaultValue: 'Anonymous'
|
|
47
|
+
* });
|
|
48
|
+
*
|
|
49
|
+
* return (
|
|
50
|
+
* <input
|
|
51
|
+
* value={userName || ''}
|
|
52
|
+
* onChange={(e) => setUserName(e.target.value)}
|
|
53
|
+
* />
|
|
54
|
+
* );
|
|
55
|
+
* }
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* **Object State:**
|
|
60
|
+
* ```tsx
|
|
61
|
+
* interface UserSettings {
|
|
62
|
+
* theme: 'light' | 'dark';
|
|
63
|
+
* language: string;
|
|
64
|
+
* notifications: boolean;
|
|
65
|
+
* }
|
|
66
|
+
*
|
|
67
|
+
* function SettingsPanel() {
|
|
68
|
+
* const [settings, setSettings] = useAppState<UserSettings>('user.settings', {
|
|
69
|
+
* defaultValue: { theme: 'light', language: 'en', notifications: true }
|
|
70
|
+
* });
|
|
71
|
+
*
|
|
72
|
+
* const toggleTheme = () => {
|
|
73
|
+
* setSettings(prev => ({
|
|
74
|
+
* ...prev!,
|
|
75
|
+
* theme: prev!.theme === 'light' ? 'dark' : 'light'
|
|
76
|
+
* }));
|
|
77
|
+
* };
|
|
78
|
+
*
|
|
79
|
+
* return <button onClick={toggleTheme}>Theme: {settings?.theme}</button>;
|
|
80
|
+
* }
|
|
81
|
+
* ```
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* **Array State:**
|
|
85
|
+
* ```tsx
|
|
86
|
+
* function TaskList() {
|
|
87
|
+
* const [tasks, setTasks] = useAppState<string[]>('tasks', { defaultValue: [] });
|
|
88
|
+
*
|
|
89
|
+
* const addTask = (text: string) => {
|
|
90
|
+
* setTasks(prev => [...(prev || []), text]);
|
|
91
|
+
* };
|
|
92
|
+
*
|
|
93
|
+
* const removeTask = (index: number) => {
|
|
94
|
+
* setTasks(prev => prev?.filter((_, i) => i !== index));
|
|
95
|
+
* };
|
|
96
|
+
*
|
|
97
|
+
* return (
|
|
98
|
+
* <ul>
|
|
99
|
+
* {tasks?.map((task, index) => (
|
|
100
|
+
* <li key={index} onClick={() => removeTask(index)}>
|
|
101
|
+
* {task}
|
|
102
|
+
* </li>
|
|
103
|
+
* ))}
|
|
104
|
+
* </ul>
|
|
105
|
+
* );
|
|
106
|
+
* }
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* **Clearing State:**
|
|
111
|
+
* ```tsx
|
|
112
|
+
* function DataManager() {
|
|
113
|
+
* const [data, setData] = useAppState<unknown[]>('cache.data');
|
|
114
|
+
*
|
|
115
|
+
* const clearCache = () => {
|
|
116
|
+
* // Setting to undefined removes the item from storage completely
|
|
117
|
+
* setData(undefined);
|
|
118
|
+
* };
|
|
119
|
+
*
|
|
120
|
+
* return <button onClick={clearCache}>Clear Cache</button>;
|
|
121
|
+
* }
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* **Cross-Component Synchronization:**
|
|
126
|
+
* ```tsx
|
|
127
|
+
* // Component A
|
|
128
|
+
* function ComponentA() {
|
|
129
|
+
* const [counter, setCounter] = useAppState('shared.counter', { defaultValue: 0 });
|
|
130
|
+
* return <button onClick={() => setCounter(c => (c || 0) + 1)}>Count: {counter}</button>;
|
|
131
|
+
* }
|
|
132
|
+
*
|
|
133
|
+
* // Component B (automatically stays in sync)
|
|
134
|
+
* function ComponentB() {
|
|
135
|
+
* const [counter] = useAppState('shared.counter', { defaultValue: 0 });
|
|
136
|
+
* return <div>Current count: {counter}</div>;
|
|
137
|
+
* }
|
|
138
|
+
* ```
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* **Key Stability - DO and DON'T:**
|
|
142
|
+
* ```tsx
|
|
143
|
+
* function MyComponent({ userId }: { userId: string }) {
|
|
144
|
+
* // ❌ DON'T: Key changes with prop, will cause warnings and use initial key
|
|
145
|
+
* const [userPrefs] = useAppState(`user.${userId}.preferences`);
|
|
146
|
+
*
|
|
147
|
+
* // ✅ DO: Use a constant key
|
|
148
|
+
* const [globalSettings] = useAppState('app.global.settings');
|
|
149
|
+
*
|
|
150
|
+
* return <div>...</div>;
|
|
151
|
+
* }
|
|
152
|
+
* ```
|
|
153
|
+
*
|
|
154
|
+
* @since 6.3.0
|
|
155
|
+
*/
|
|
156
|
+
export declare const useAppState: <T extends AllowedValue = AllowedValue>(key: string, options?: UseAppStateOptions<T>) => [T | undefined, (action: SetStateAction<T | undefined>) => void];
|
|
157
|
+
export default useAppState;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type StateSyncEventType } from '@equinor/fusion-framework-module-state';
|
|
2
|
+
/**
|
|
3
|
+
* Subscribes to the app's `state` module sync events (`onStateSync.status`,
|
|
4
|
+
* `onStateSync.change`, `onStateSync.complete`, `onStateSync.error`) and returns the most
|
|
5
|
+
* recent `limit` events, oldest first.
|
|
6
|
+
*
|
|
7
|
+
* Events are only dispatched while the state module's storage is configured for replication
|
|
8
|
+
* (see `PouchDbSyncStorage`) - with the state module's default, local-only storage, this hook
|
|
9
|
+
* returns an empty array.
|
|
10
|
+
*
|
|
11
|
+
* @param limit - Maximum number of most-recent sync events to retain.
|
|
12
|
+
* @returns The most recent sync events, oldest first.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const events = useStateSyncEvents(20);
|
|
17
|
+
* const lastEvent = events.at(-1);
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* @since 12.1.0
|
|
21
|
+
*/
|
|
22
|
+
export declare const useStateSyncEvents: (limit: number) => StateSyncEventType[];
|
|
23
|
+
export default useStateSyncEvents;
|
package/dist/types/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const version = "
|
|
1
|
+
export declare const version = "14.0.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@equinor/fusion-framework-react-app",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "14.0.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "./dist/esm/index.js",
|
|
6
6
|
"types": "./dist/types/index.d.ts",
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
"types": "./dist/types/feature-flag/index.d.ts",
|
|
30
30
|
"import": "./dist/esm/feature-flag/index.js"
|
|
31
31
|
},
|
|
32
|
+
"./state": {
|
|
33
|
+
"types": "./dist/types/state/index.d.ts",
|
|
34
|
+
"import": "./dist/esm/state/index.js"
|
|
35
|
+
},
|
|
32
36
|
"./framework": {
|
|
33
37
|
"types": "./dist/types/framework/index.d.ts",
|
|
34
38
|
"import": "./dist/esm/framework/index.js"
|
|
@@ -86,6 +90,9 @@
|
|
|
86
90
|
"feature-flag": [
|
|
87
91
|
"dist/types/feature-flag/index.d.ts"
|
|
88
92
|
],
|
|
93
|
+
"state": [
|
|
94
|
+
"dist/types/state/index.d.ts"
|
|
95
|
+
],
|
|
89
96
|
"framework": [
|
|
90
97
|
"dist/types/framework/index.d.ts"
|
|
91
98
|
],
|
|
@@ -124,16 +131,17 @@
|
|
|
124
131
|
"directory": "packages/react"
|
|
125
132
|
},
|
|
126
133
|
"dependencies": {
|
|
127
|
-
"@equinor/fusion-framework-
|
|
134
|
+
"@equinor/fusion-framework-app": "13.0.0",
|
|
135
|
+
"@equinor/fusion-framework-module": "6.1.2",
|
|
128
136
|
"@equinor/fusion-framework-module-app": "8.0.4",
|
|
129
|
-
"@equinor/fusion-framework-app": "11.0.12",
|
|
130
137
|
"@equinor/fusion-framework-module-http": "8.0.5",
|
|
138
|
+
"@equinor/fusion-framework-module-navigation": "7.0.7",
|
|
131
139
|
"@equinor/fusion-framework-react": "8.0.1",
|
|
132
|
-
"@equinor/fusion-framework-module-
|
|
133
|
-
"@equinor/fusion-framework-react-module": "4.0.2"
|
|
134
|
-
"@equinor/fusion-framework-react-module-http": "11.0.1"
|
|
140
|
+
"@equinor/fusion-framework-react-module-http": "11.0.1",
|
|
141
|
+
"@equinor/fusion-framework-react-module": "4.0.2"
|
|
135
142
|
},
|
|
136
143
|
"devDependencies": {
|
|
144
|
+
"@testing-library/react": "^16.0.0",
|
|
137
145
|
"@types/react": "^19.2.7",
|
|
138
146
|
"@types/react-dom": "^19.2.3",
|
|
139
147
|
"happy-dom": "^20.8.4",
|
|
@@ -143,13 +151,14 @@
|
|
|
143
151
|
"typescript": "^7.0.2",
|
|
144
152
|
"vitest": "^4.1.0",
|
|
145
153
|
"@equinor/fusion-framework-module-ag-grid": "37.0.1",
|
|
154
|
+
"@equinor/fusion-framework-module-analytics": "3.0.5",
|
|
146
155
|
"@equinor/fusion-framework-module-event": "6.0.1",
|
|
147
|
-
"@equinor/fusion-framework-module-analytics": "3.0.4",
|
|
148
|
-
"@equinor/fusion-framework-module-feature-flag": "2.0.3",
|
|
149
156
|
"@equinor/fusion-framework-module-msal": "10.0.2",
|
|
157
|
+
"@equinor/fusion-framework-module-state": "^2.0.0",
|
|
150
158
|
"@equinor/fusion-framework-react-module-bookmark": "6.0.1",
|
|
151
|
-
"@equinor/fusion-framework-
|
|
159
|
+
"@equinor/fusion-framework-module-feature-flag": "2.0.3",
|
|
152
160
|
"@equinor/fusion-framework-react-router": "2.4.0",
|
|
161
|
+
"@equinor/fusion-framework-react-module-context": "7.0.2",
|
|
153
162
|
"@equinor/fusion-observable": "9.1.1"
|
|
154
163
|
},
|
|
155
164
|
"peerDependencies": {
|
|
@@ -158,6 +167,7 @@
|
|
|
158
167
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
159
168
|
"rxjs": "^7.0.0",
|
|
160
169
|
"@equinor/fusion-framework-module-msal": "^10.0.2",
|
|
170
|
+
"@equinor/fusion-framework-module-state": "^2.0.0",
|
|
161
171
|
"@equinor/fusion-framework-react-router": "^2.4.0"
|
|
162
172
|
},
|
|
163
173
|
"peerDependenciesMeta": {
|
|
@@ -176,6 +186,9 @@
|
|
|
176
186
|
"@equinor/fusion-framework-module-feature-flag": {
|
|
177
187
|
"optional": true
|
|
178
188
|
},
|
|
189
|
+
"@equinor/fusion-framework-module-state": {
|
|
190
|
+
"optional": true
|
|
191
|
+
},
|
|
179
192
|
"@equinor/fusion-observable": {
|
|
180
193
|
"optional": true
|
|
181
194
|
},
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { Subject } from 'rxjs';
|
|
3
|
+
|
|
4
|
+
import { act, renderHook } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
import { StateSyncEvent, type StateSyncEventType } from '@equinor/fusion-framework-module-state';
|
|
7
|
+
|
|
8
|
+
const event$ = new Subject<StateSyncEventType>();
|
|
9
|
+
|
|
10
|
+
vi.mock('../useAppModule', () => ({
|
|
11
|
+
default: () => ({ event$ }),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { useStateSyncEvents } from '../state/useStateSyncEvents';
|
|
15
|
+
|
|
16
|
+
describe('useStateSyncEvents', () => {
|
|
17
|
+
it('collects dispatched onStateSync.* events, oldest first', () => {
|
|
18
|
+
const { result } = renderHook(() => useStateSyncEvents(10));
|
|
19
|
+
|
|
20
|
+
expect(result.current).toEqual([]);
|
|
21
|
+
|
|
22
|
+
act(() => {
|
|
23
|
+
event$.next(new StateSyncEvent.Status({ detail: { status: 'active' } }));
|
|
24
|
+
event$.next(new StateSyncEvent.Status({ detail: { status: 'paused' } }));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(result.current).toHaveLength(2);
|
|
28
|
+
expect(result.current[0].detail.status).toBe('active');
|
|
29
|
+
expect(result.current[1].detail.status).toBe('paused');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('ignores events unrelated to state sync and trims the log to the given limit', () => {
|
|
33
|
+
const { result } = renderHook(() => useStateSyncEvents(1));
|
|
34
|
+
|
|
35
|
+
act(() => {
|
|
36
|
+
event$.next(new StateSyncEvent.Status({ detail: { status: 'active' } }));
|
|
37
|
+
event$.next(
|
|
38
|
+
new StateSyncEvent.Error({ detail: { error: new Error('boom'), type: 'error' } }),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(result.current).toHaveLength(1);
|
|
43
|
+
expect(result.current[0]).toBeInstanceOf(StateSyncEvent.Error);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('unsubscribes from the event stream on unmount', () => {
|
|
47
|
+
const { result, unmount } = renderHook(() => useStateSyncEvents(10));
|
|
48
|
+
|
|
49
|
+
unmount();
|
|
50
|
+
|
|
51
|
+
act(() => {
|
|
52
|
+
event$.next(new StateSyncEvent.Status({ detail: { status: 'active' } }));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(result.current).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
AllowedValue,
|
|
3
|
+
IStateProvider,
|
|
4
|
+
StateItem,
|
|
5
|
+
StateSyncEvent,
|
|
6
|
+
type StateSyncEventType,
|
|
7
|
+
} from '@equinor/fusion-framework-module-state';
|
|
8
|
+
|
|
9
|
+
export { enableState as enableAppState } from '@equinor/fusion-framework-app/enable-state';
|
|
10
|
+
|
|
11
|
+
export { useAppState } from './useAppState';
|
|
12
|
+
export { useStateSyncEvents } from './useStateSyncEvents';
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useLayoutEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
useSyncExternalStore,
|
|
7
|
+
type SetStateAction,
|
|
8
|
+
} from 'react';
|
|
9
|
+
|
|
10
|
+
import { BehaviorSubject, from } from 'rxjs';
|
|
11
|
+
import { map, skip } from 'rxjs/operators';
|
|
12
|
+
|
|
13
|
+
import type { AllowedValue, StateModule } from '@equinor/fusion-framework-module-state';
|
|
14
|
+
|
|
15
|
+
import useAppModule from '../useAppModule';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Configuration options for the `useAppState` hook.
|
|
19
|
+
*/
|
|
20
|
+
interface UseAppStateOptions<T extends AllowedValue> {
|
|
21
|
+
/**
|
|
22
|
+
* Default value to return when the state item doesn't exist or is undefined.
|
|
23
|
+
* This value will be returned immediately on first render if no stored value exists.
|
|
24
|
+
*/
|
|
25
|
+
defaultValue?: T;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A React hook for managing persistent application state through the Fusion Framework.
|
|
30
|
+
*
|
|
31
|
+
* This hook provides a simple way to store and retrieve values that persist across
|
|
32
|
+
* app sessions and are shared between different parts of your application. It works
|
|
33
|
+
* similarly to `useState` but with automatic persistence and cross-component synchronization.
|
|
34
|
+
*
|
|
35
|
+
* **Key Features:**
|
|
36
|
+
* - Automatic persistence across app sessions
|
|
37
|
+
* - Real-time synchronization between components
|
|
38
|
+
* - Optimistic updates for responsive UX
|
|
39
|
+
* - Deep equality checking to prevent unnecessary re-renders
|
|
40
|
+
* - TypeScript support with type safety
|
|
41
|
+
* - Default value support
|
|
42
|
+
*
|
|
43
|
+
* **Important Notes:**
|
|
44
|
+
* - Setting a value to `undefined` will completely remove it from storage
|
|
45
|
+
* - Values must be serializable (JSON-compatible)
|
|
46
|
+
* - Use unique keys to avoid conflicts between different state items
|
|
47
|
+
* - **The key parameter must remain constant across re-renders** - changing the key after the hook
|
|
48
|
+
* is initialized will be ignored and a warning will be logged in development mode
|
|
49
|
+
*
|
|
50
|
+
* @template T The type of value to store. Must be serializable (string, number, boolean, object, array, etc.)
|
|
51
|
+
* @param key Unique identifier for this state item. Use descriptive names like 'user.preferences' or 'dashboard.filters'. **Must remain constant across re-renders.**
|
|
52
|
+
* @param options Configuration options including default value
|
|
53
|
+
* @returns A tuple containing [currentValue, setValue] similar to useState
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* **Basic Usage:**
|
|
57
|
+
* ```tsx
|
|
58
|
+
* function UserProfile() {
|
|
59
|
+
* // Simple string state with default value
|
|
60
|
+
* const [userName, setUserName] = useAppState('user.name', {
|
|
61
|
+
* defaultValue: 'Anonymous'
|
|
62
|
+
* });
|
|
63
|
+
*
|
|
64
|
+
* return (
|
|
65
|
+
* <input
|
|
66
|
+
* value={userName || ''}
|
|
67
|
+
* onChange={(e) => setUserName(e.target.value)}
|
|
68
|
+
* />
|
|
69
|
+
* );
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* **Object State:**
|
|
75
|
+
* ```tsx
|
|
76
|
+
* interface UserSettings {
|
|
77
|
+
* theme: 'light' | 'dark';
|
|
78
|
+
* language: string;
|
|
79
|
+
* notifications: boolean;
|
|
80
|
+
* }
|
|
81
|
+
*
|
|
82
|
+
* function SettingsPanel() {
|
|
83
|
+
* const [settings, setSettings] = useAppState<UserSettings>('user.settings', {
|
|
84
|
+
* defaultValue: { theme: 'light', language: 'en', notifications: true }
|
|
85
|
+
* });
|
|
86
|
+
*
|
|
87
|
+
* const toggleTheme = () => {
|
|
88
|
+
* setSettings(prev => ({
|
|
89
|
+
* ...prev!,
|
|
90
|
+
* theme: prev!.theme === 'light' ? 'dark' : 'light'
|
|
91
|
+
* }));
|
|
92
|
+
* };
|
|
93
|
+
*
|
|
94
|
+
* return <button onClick={toggleTheme}>Theme: {settings?.theme}</button>;
|
|
95
|
+
* }
|
|
96
|
+
* ```
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* **Array State:**
|
|
100
|
+
* ```tsx
|
|
101
|
+
* function TaskList() {
|
|
102
|
+
* const [tasks, setTasks] = useAppState<string[]>('tasks', { defaultValue: [] });
|
|
103
|
+
*
|
|
104
|
+
* const addTask = (text: string) => {
|
|
105
|
+
* setTasks(prev => [...(prev || []), text]);
|
|
106
|
+
* };
|
|
107
|
+
*
|
|
108
|
+
* const removeTask = (index: number) => {
|
|
109
|
+
* setTasks(prev => prev?.filter((_, i) => i !== index));
|
|
110
|
+
* };
|
|
111
|
+
*
|
|
112
|
+
* return (
|
|
113
|
+
* <ul>
|
|
114
|
+
* {tasks?.map((task, index) => (
|
|
115
|
+
* <li key={index} onClick={() => removeTask(index)}>
|
|
116
|
+
* {task}
|
|
117
|
+
* </li>
|
|
118
|
+
* ))}
|
|
119
|
+
* </ul>
|
|
120
|
+
* );
|
|
121
|
+
* }
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* **Clearing State:**
|
|
126
|
+
* ```tsx
|
|
127
|
+
* function DataManager() {
|
|
128
|
+
* const [data, setData] = useAppState<unknown[]>('cache.data');
|
|
129
|
+
*
|
|
130
|
+
* const clearCache = () => {
|
|
131
|
+
* // Setting to undefined removes the item from storage completely
|
|
132
|
+
* setData(undefined);
|
|
133
|
+
* };
|
|
134
|
+
*
|
|
135
|
+
* return <button onClick={clearCache}>Clear Cache</button>;
|
|
136
|
+
* }
|
|
137
|
+
* ```
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* **Cross-Component Synchronization:**
|
|
141
|
+
* ```tsx
|
|
142
|
+
* // Component A
|
|
143
|
+
* function ComponentA() {
|
|
144
|
+
* const [counter, setCounter] = useAppState('shared.counter', { defaultValue: 0 });
|
|
145
|
+
* return <button onClick={() => setCounter(c => (c || 0) + 1)}>Count: {counter}</button>;
|
|
146
|
+
* }
|
|
147
|
+
*
|
|
148
|
+
* // Component B (automatically stays in sync)
|
|
149
|
+
* function ComponentB() {
|
|
150
|
+
* const [counter] = useAppState('shared.counter', { defaultValue: 0 });
|
|
151
|
+
* return <div>Current count: {counter}</div>;
|
|
152
|
+
* }
|
|
153
|
+
* ```
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* **Key Stability - DO and DON'T:**
|
|
157
|
+
* ```tsx
|
|
158
|
+
* function MyComponent({ userId }: { userId: string }) {
|
|
159
|
+
* // ❌ DON'T: Key changes with prop, will cause warnings and use initial key
|
|
160
|
+
* const [userPrefs] = useAppState(`user.${userId}.preferences`);
|
|
161
|
+
*
|
|
162
|
+
* // ✅ DO: Use a constant key
|
|
163
|
+
* const [globalSettings] = useAppState('app.global.settings');
|
|
164
|
+
*
|
|
165
|
+
* return <div>...</div>;
|
|
166
|
+
* }
|
|
167
|
+
* ```
|
|
168
|
+
*
|
|
169
|
+
* @since 6.3.0
|
|
170
|
+
*/
|
|
171
|
+
export const useAppState = <T extends AllowedValue = AllowedValue>(
|
|
172
|
+
key: string,
|
|
173
|
+
options?: UseAppStateOptions<T>,
|
|
174
|
+
): [T | undefined, (action: SetStateAction<T | undefined>) => void] => {
|
|
175
|
+
// Restrict development-only key validation to avoid production overhead.
|
|
176
|
+
if (process.env.NODE_ENV === 'development') {
|
|
177
|
+
// Warn early when callers provide a key that cannot identify persisted state.
|
|
178
|
+
if (!key || typeof key !== 'string') {
|
|
179
|
+
console.warn('useAppState: key must be a non-empty string');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Capture the initial key value and ensure it never changes
|
|
184
|
+
const initialKey = useRef(key);
|
|
185
|
+
|
|
186
|
+
// Warn about key changes only in development because the hook intentionally keeps its initial key.
|
|
187
|
+
if (process.env.NODE_ENV === 'development') {
|
|
188
|
+
// Surface an unstable key while preserving the original storage identity.
|
|
189
|
+
if (initialKey.current !== key) {
|
|
190
|
+
console.warn(
|
|
191
|
+
`useAppState: key changed from "${initialKey.current}" to "${key}". The key should remain constant across re-renders. Using initial key: "${initialKey.current}"`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Access the state module from the Fusion Framework's dependency injection system
|
|
197
|
+
const stateProvider = useAppModule<StateModule>('state');
|
|
198
|
+
|
|
199
|
+
// BehaviorSubject bridges async state provider with React's sync rendering.
|
|
200
|
+
// Provides immediate access via value$.value and replay semantics for new subscribers.
|
|
201
|
+
// Initialize with defaultValue for consistent SSR/client hydration.
|
|
202
|
+
const [value$] = useState(() => {
|
|
203
|
+
return new BehaviorSubject<T | undefined>(options?.defaultValue);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// useLayoutEffect runs synchronously after DOM mutations but before paint,
|
|
207
|
+
// preventing visual inconsistencies during hydration and ensuring state sync before updates.
|
|
208
|
+
useLayoutEffect(() => {
|
|
209
|
+
const subscription = from(
|
|
210
|
+
stateProvider.observeItem<T>(initialKey.current, { initialValue: value$.value }),
|
|
211
|
+
).subscribe({
|
|
212
|
+
next: (item) => {
|
|
213
|
+
// Convert state provider's null to undefined for React conventions
|
|
214
|
+
value$.next(item === null ? undefined : item.value);
|
|
215
|
+
},
|
|
216
|
+
error: (err) => {
|
|
217
|
+
// Log errors for debugging but don't crash the component
|
|
218
|
+
console.error(`State observation error for key "${initialKey.current}":`, err);
|
|
219
|
+
},
|
|
220
|
+
complete: () => {
|
|
221
|
+
// Complete the local stream when the source completes
|
|
222
|
+
value$.complete();
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Critical: Always cleanup subscriptions to prevent memory leaks
|
|
227
|
+
return () => {
|
|
228
|
+
subscription.unsubscribe();
|
|
229
|
+
};
|
|
230
|
+
}, [stateProvider, value$]);
|
|
231
|
+
|
|
232
|
+
// Helper function to get the current value, falling back to the default if necessary
|
|
233
|
+
const getValue = useCallback(
|
|
234
|
+
(rawValue: T | undefined) => (rawValue === undefined ? options?.defaultValue : rawValue),
|
|
235
|
+
[options?.defaultValue],
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// useSyncExternalStore integrates with React 18's concurrent features,
|
|
239
|
+
// ensuring consistent state during concurrent rendering and preventing tearing.
|
|
240
|
+
const value = useSyncExternalStore(
|
|
241
|
+
(callback) => {
|
|
242
|
+
const subscription = value$
|
|
243
|
+
// Transform state emissions into the snapshot updates expected by React.
|
|
244
|
+
.pipe(
|
|
245
|
+
// skip the initial value, since we don't want to emit anything before the app state provider has initialized
|
|
246
|
+
skip(1),
|
|
247
|
+
// Apply default value logic consistently with snapshot function
|
|
248
|
+
map((value) => (value === undefined ? options?.defaultValue : value)),
|
|
249
|
+
)
|
|
250
|
+
.subscribe(callback);
|
|
251
|
+
|
|
252
|
+
return () => {
|
|
253
|
+
subscription.unsubscribe();
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
// Snapshot function: returns current value synchronously for React rendering
|
|
258
|
+
() => getValue(value$.value),
|
|
259
|
+
|
|
260
|
+
// Server snapshot: ensures consistent hydration between server and client
|
|
261
|
+
() => options?.defaultValue,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// Implements optimistic updates: update local state immediately, then persist.
|
|
265
|
+
// If persistence fails, the state provider will emit the old value, reverting the update.
|
|
266
|
+
const setValue = useCallback(
|
|
267
|
+
(action: SetStateAction<T | undefined>) => {
|
|
268
|
+
// Handle both direct values and updater functions (like React's useState).
|
|
269
|
+
// Apply the same defaulting as the snapshot so updaters never see a bare `undefined`
|
|
270
|
+
// when a `defaultValue` was provided.
|
|
271
|
+
const value = typeof action === 'function' ? action(getValue(value$.value)) : action;
|
|
272
|
+
|
|
273
|
+
// Remove undefined values from storage while updating subscribers immediately.
|
|
274
|
+
if (value === undefined) {
|
|
275
|
+
// undefined means "delete from storage" - update local state first for immediate UI feedback
|
|
276
|
+
value$.next(undefined);
|
|
277
|
+
stateProvider.removeItem(initialKey.current).catch((error) => {
|
|
278
|
+
console.error(`Failed to remove item "${initialKey.current}":`, error);
|
|
279
|
+
});
|
|
280
|
+
} else {
|
|
281
|
+
// Optimistic update: local state first, then persist to storage
|
|
282
|
+
value$.next(value);
|
|
283
|
+
stateProvider
|
|
284
|
+
.storeItem({
|
|
285
|
+
key: initialKey.current,
|
|
286
|
+
value,
|
|
287
|
+
})
|
|
288
|
+
.catch((error) => {
|
|
289
|
+
console.error(`Failed to store item "${initialKey.current}":`, error);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
[stateProvider, value$, getValue],
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
return [value, setValue];
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
export default useAppState;
|