@blac/react 2.0.0-rc-7 → 2.0.0-rc-8

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/src/useBloc.tsx CHANGED
@@ -5,13 +5,9 @@ import {
5
5
  BlocState,
6
6
  InferPropsFromGeneric,
7
7
  } from '@blac/core';
8
- import {
9
- useEffect,
10
- useMemo,
11
- useRef,
12
- useSyncExternalStore
13
- } from 'react';
8
+ import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
14
9
  import useExternalBlocStore from './useExternalBlocStore';
10
+ import { DependencyTracker } from './DependencyTracker';
15
11
 
16
12
  /**
17
13
  * Type definition for the return type of the useBloc hook
@@ -63,59 +59,74 @@ export default function useBloc<B extends BlocConstructor<BlocBase<any>>>(
63
59
  bloc: B,
64
60
  options?: BlocHookOptions<InstanceType<B>>,
65
61
  ): HookTypes<B> {
66
- // Determine ID for isolated or shared blocs
67
- // const base = bloc as unknown as BlocBaseAbstract;
68
- // const isIsolated = base.isolated;
69
- // const effectiveBlocId = isIsolated ? rid : blocId;
70
62
  const {
71
63
  externalStore,
72
64
  usedKeys,
73
65
  usedClassPropKeys,
74
66
  instance,
75
67
  rid,
68
+ hasProxyTracking,
76
69
  } = useExternalBlocStore(bloc, options);
77
70
 
78
- // Subscribe to state changes using React's external store API
79
71
  const state = useSyncExternalStore<BlocState<InstanceType<B>>>(
80
72
  externalStore.subscribe,
81
73
  () => {
82
74
  const snapshot = externalStore.getSnapshot();
83
75
  if (snapshot === undefined) {
84
- throw new Error(`[useBloc] State snapshot is undefined for bloc ${bloc.name}. Bloc may not be properly initialized.`);
76
+ throw new Error(
77
+ `[useBloc] State snapshot is undefined for bloc ${bloc.name}. Bloc may not be properly initialized.`,
78
+ );
85
79
  }
86
80
  return snapshot;
87
81
  },
88
- externalStore.getServerSnapshot ? () => {
89
- const serverSnapshot = externalStore.getServerSnapshot!();
90
- if (serverSnapshot === undefined) {
91
- throw new Error(`[useBloc] Server state snapshot is undefined for bloc ${bloc.name}. Bloc may not be properly initialized.`);
92
- }
93
- return serverSnapshot;
94
- } : undefined,
82
+ externalStore.getServerSnapshot
83
+ ? () => {
84
+ const serverSnapshot = externalStore.getServerSnapshot!();
85
+ if (serverSnapshot === undefined) {
86
+ throw new Error(
87
+ `[useBloc] Server state snapshot is undefined for bloc ${bloc.name}. Bloc may not be properly initialized.`,
88
+ );
89
+ }
90
+ return serverSnapshot;
91
+ }
92
+ : undefined,
95
93
  );
96
94
 
97
- // Cache proxies to avoid recreation on every render
95
+ const dependencyTracker = useRef<DependencyTracker | null>(null);
96
+ if (!dependencyTracker.current) {
97
+ dependencyTracker.current = new DependencyTracker({
98
+ enableBatching: true,
99
+ enableMetrics: process.env.NODE_ENV === 'development',
100
+ enableDeepTracking: false,
101
+ });
102
+ }
103
+
98
104
  const stateProxyCache = useRef<WeakMap<object, object>>(new WeakMap());
99
105
  const classProxyCache = useRef<WeakMap<object, object>>(new WeakMap());
100
106
 
101
107
  const returnState = useMemo(() => {
108
+ // If a custom selector is provided, don't use proxy tracking
109
+ if (options?.selector) {
110
+ return state;
111
+ }
112
+
113
+ hasProxyTracking.current = true;
114
+
102
115
  if (typeof state !== 'object' || state === null) {
103
116
  return state;
104
117
  }
105
118
 
106
- // Check cache first
107
119
  let proxy = stateProxyCache.current.get(state);
108
120
  if (!proxy) {
109
121
  proxy = new Proxy(state, {
110
122
  get(target, prop) {
111
- // Use setTimeout to defer dependency tracking until after render
112
- setTimeout(() => {
113
- usedKeys.current.add(prop as string);
114
- }, 0);
123
+ if (typeof prop === 'string') {
124
+ usedKeys.current.add(prop);
125
+ dependencyTracker.current?.trackStateAccess(prop);
126
+ }
115
127
  const value = target[prop as keyof typeof target];
116
128
  return value;
117
129
  },
118
- // Handle symbols and non-enumerable properties
119
130
  has(target, prop) {
120
131
  return prop in target;
121
132
  },
@@ -124,7 +135,7 @@ export default function useBloc<B extends BlocConstructor<BlocBase<any>>>(
124
135
  },
125
136
  getOwnPropertyDescriptor(target, prop) {
126
137
  return Reflect.getOwnPropertyDescriptor(target, prop);
127
- }
138
+ },
128
139
  });
129
140
  stateProxyCache.current.set(state as object, proxy as object);
130
141
  }
@@ -133,20 +144,24 @@ export default function useBloc<B extends BlocConstructor<BlocBase<any>>>(
133
144
 
134
145
  const returnClass = useMemo(() => {
135
146
  if (!instance.current) {
136
- throw new Error(`[useBloc] Bloc instance is null for ${bloc.name}. This should never happen - bloc instance must be defined.`);
147
+ throw new Error(
148
+ `[useBloc] Bloc instance is null for ${bloc.name}. This should never happen - bloc instance must be defined.`,
149
+ );
137
150
  }
138
151
 
139
- // Check cache first
140
152
  let proxy = classProxyCache.current.get(instance.current);
141
153
  if (!proxy) {
142
154
  proxy = new Proxy(instance.current, {
143
155
  get(target, prop) {
144
156
  if (!target) {
145
- throw new Error(`[useBloc] Bloc target is null for ${bloc.name}. This should never happen - bloc target must be defined.`);
157
+ throw new Error(
158
+ `[useBloc] Bloc target is null for ${bloc.name}. This should never happen - bloc target must be defined.`,
159
+ );
146
160
  }
147
161
  const value = target[prop as keyof InstanceType<B>];
148
- if (typeof value !== 'function') {
149
- usedClassPropKeys.current.add(prop as string);
162
+ if (typeof value !== 'function' && typeof prop === 'string') {
163
+ usedClassPropKeys.current.add(prop);
164
+ dependencyTracker.current?.trackClassAccess(prop);
150
165
  }
151
166
  return value;
152
167
  },
@@ -156,38 +171,48 @@ export default function useBloc<B extends BlocConstructor<BlocBase<any>>>(
156
171
  return proxy;
157
172
  }, [instance.current?.uid]);
158
173
 
159
- // Create a stable reference object for this component
160
174
  const componentRef = useRef({});
161
175
 
162
- // Set up bloc lifecycle management
163
176
  useEffect(() => {
164
177
  const currentInstance = instance.current;
165
178
  if (!currentInstance) return;
166
-
167
- // Pass component reference for proper memory management
179
+
168
180
  currentInstance._addConsumer(rid, componentRef.current);
169
181
 
170
- // Call onMount callback if provided
171
182
  options?.onMount?.(currentInstance);
172
183
 
173
- // Cleanup: remove this component as a consumer using the captured instance
174
184
  return () => {
175
185
  if (!currentInstance) {
176
186
  return;
177
187
  }
178
188
  options?.onUnmount?.(currentInstance);
179
189
  currentInstance._removeConsumer(rid);
190
+
191
+ dependencyTracker.current?.reset();
180
192
  };
181
- }, [instance.current?.uid, rid]); // Use UID to ensure we re-run when instance changes
193
+ }, [instance.current?.uid, rid]);
194
+ useEffect(() => {
195
+ if (process.env.NODE_ENV === 'development' && dependencyTracker.current) {
196
+ const metrics = dependencyTracker.current.getMetrics();
197
+ if (metrics.stateAccessCount > 0 || metrics.classAccessCount > 0) {
198
+ console.debug(`[useBloc] ${bloc.name} Performance Metrics:`, metrics);
199
+ }
200
+ }
201
+ });
182
202
 
183
- // Ensure state and instance are never undefined/null
184
203
  if (returnState === undefined) {
185
- throw new Error(`[useBloc] State is undefined for ${bloc.name}. This should never happen - state must be defined.`);
204
+ throw new Error(
205
+ `[useBloc] State is undefined for ${bloc.name}. This should never happen - state must be defined.`,
206
+ );
186
207
  }
187
208
  if (!returnClass) {
188
- throw new Error(`[useBloc] Instance is null for ${bloc.name}. This should never happen - instance must be defined.`);
209
+ throw new Error(
210
+ `[useBloc] Instance is null for ${bloc.name}. This should never happen - instance must be defined.`,
211
+ );
189
212
  }
190
213
 
191
- // Safe return with proper typing
192
- return [returnState, returnClass] as [BlocState<InstanceType<B>>, InstanceType<B>];
214
+ return [returnState, returnClass] as [
215
+ BlocState<InstanceType<B>>,
216
+ InstanceType<B>,
217
+ ];
193
218
  }
@@ -34,6 +34,7 @@ export interface ExternalBlacStore<
34
34
  externalStore: ExternalStore<B>;
35
35
  instance: React.RefObject<InstanceType<B>>;
36
36
  rid: string;
37
+ hasProxyTracking: React.RefObject<boolean>;
37
38
  }
38
39
 
39
40
  /**
@@ -59,6 +60,10 @@ const useExternalBlocStore = <
59
60
 
60
61
  const usedKeys = useRef<Set<string>>(new Set());
61
62
  const usedClassPropKeys = useRef<Set<string>>(new Set());
63
+
64
+ // Track whether proxy-based dependency tracking has been initialized
65
+ // This helps distinguish between direct external store usage and useBloc proxy usage
66
+ const hasProxyTracking = useRef<boolean>(false);
62
67
 
63
68
  const getBloc = useCallback(() => {
64
69
  return Blac.getBloc(bloc, {
@@ -75,52 +80,67 @@ const useExternalBlocStore = <
75
80
  blocInstance.current = getBloc();
76
81
  }, [getBloc]);
77
82
 
78
- const dependencyArray: BlocHookDependencyArrayFn<BlocState<InstanceType<B>>> =
79
- useMemo(
80
- () =>
81
- (newState): ReturnType<BlocHookDependencyArrayFn<BlocState<InstanceType<B>>>> => {
82
- const instance = blocInstance.current;
83
+ // Track previous state and dependencies for selector
84
+ const previousStateRef = useRef<BlocState<InstanceType<B>> | undefined>(undefined);
85
+ const lastDependenciesRef = useRef<unknown[][] | undefined>(undefined);
86
+ const lastStableSnapshot = useRef<BlocState<InstanceType<B>> | undefined>(undefined);
87
+
88
+ // Track bloc instance uid to prevent unnecessary store recreation
89
+ const blocUidRef = useRef<string | undefined>(undefined);
90
+ if (blocUidRef.current !== blocInstance.current?.uid) {
91
+ blocUidRef.current = blocInstance.current?.uid;
92
+ }
83
93
 
84
- if (!instance) {
85
- return [];
86
- }
87
-
88
- // Use custom dependency selector if provided
89
- if (selector) {
90
- return selector(newState);
91
- }
94
+ const dependencyArray = useMemo(
95
+ () =>
96
+ (newState: BlocState<InstanceType<B>>, oldState?: BlocState<InstanceType<B>>): unknown[][] => {
97
+ const instance = blocInstance.current;
92
98
 
93
- // Fall back to bloc's default dependency selector if available
94
- if (instance.defaultDependencySelector) {
95
- return instance.defaultDependencySelector(newState);
96
- }
99
+ if (!instance) {
100
+ return [[], []]; // [stateArray, classArray]
101
+ }
97
102
 
98
- // For primitive states, use default selector
99
- if (typeof newState !== 'object') {
100
- // Default behavior for primitive states: re-render if the state itself changes.
101
- return [[newState]];
102
- }
103
+ // Use the provided oldState or fall back to our tracked previous state
104
+ const previousState = oldState ?? previousStateRef.current;
105
+
106
+ let currentDependencies: unknown[][];
103
107
 
108
+ // Use custom dependency selector if provided
109
+ if (selector) {
110
+ const flatDeps = selector(newState, previousState, instance);
111
+ // Wrap flat custom selector result in the two-array structure for consistency
112
+ currentDependencies = [flatDeps, []]; // [customSelectorDeps, classArray]
113
+ }
114
+ // Fall back to bloc's default dependency selector if available
115
+ else if (instance.defaultDependencySelector) {
116
+ const flatDeps = instance.defaultDependencySelector(newState, previousState, instance);
117
+ // Wrap flat default selector result in the two-array structure for consistency
118
+ currentDependencies = [flatDeps, []]; // [defaultSelectorDeps, classArray]
119
+ }
120
+ // For primitive states, use default selector
121
+ else if (typeof newState !== 'object') {
122
+ // Default behavior for primitive states: re-render if the state itself changes.
123
+ currentDependencies = [[newState], []]; // [primitiveStateArray, classArray]
124
+ }
125
+ else {
104
126
  // For object states, track which properties were actually used
105
- const usedStateValues: unknown[] = [];
127
+ const stateDependencies: unknown[] = [];
128
+ const classDependencies: unknown[] = [];
129
+
130
+ // Add state property values that were accessed
106
131
  for (const key of usedKeys.current) {
107
132
  if (key in newState) {
108
- usedStateValues.push(newState[key as keyof typeof newState]);
133
+ stateDependencies.push(newState[key as keyof typeof newState]);
109
134
  }
110
135
  }
111
136
 
112
- // Track used class properties for dependency tracking, this enables rerenders when class getters change
113
- const usedClassValues: unknown[] = [];
137
+ // Add class property values that were accessed
114
138
  for (const key of usedClassPropKeys.current) {
115
139
  if (key in instance) {
116
140
  try {
117
141
  const value = instance[key as keyof InstanceType<B>];
118
- switch (typeof value) {
119
- case 'function':
120
- continue;
121
- default:
122
- usedClassValues.push(value);
123
- continue;
142
+ if (typeof value !== 'function') {
143
+ classDependencies.push(value);
124
144
  }
125
145
  } catch (error) {
126
146
  Blac.instance.log('useBloc Error', error);
@@ -128,32 +148,57 @@ const useExternalBlocStore = <
128
148
  }
129
149
  }
130
150
 
131
- // If no state properties have been accessed, return empty dependencies to prevent re-renders
132
- // Class properties can change independently of state
133
- if (usedKeys.current.size === 0) {
134
- return usedClassPropKeys.current.size > 0 ? [usedClassValues] : [[]];
151
+ // If no properties have been accessed through proxy
152
+ if (usedKeys.current.size === 0 && usedClassPropKeys.current.size === 0) {
153
+ // If proxy tracking has never been initialized, this is direct external store usage
154
+ // In this case, always track the entire state to ensure notifications
155
+ if (!hasProxyTracking.current) {
156
+ stateDependencies.push(newState);
157
+ }
158
+ // If proxy tracking was initialized but no properties accessed,
159
+ // return empty dependencies to prevent unnecessary re-renders
135
160
  }
136
161
 
137
- return [usedStateValues, usedClassValues];
162
+ currentDependencies = [stateDependencies, classDependencies];
163
+ }
164
+
165
+ // Update tracked state
166
+ previousStateRef.current = newState;
167
+
168
+
169
+ // Return the dependencies for BlacObserver to compare
170
+ return currentDependencies;
138
171
  },
139
172
  [],
140
173
  );
141
174
 
175
+ // Store active subscriptions to reuse observers
176
+ const activeObservers = useRef<Map<Function, { observer: BlacObserver<BlocState<InstanceType<B>>>, unsubscribe: () => void }>>(new Map());
177
+
142
178
  const state: ExternalStore<B> = useMemo(() => {
143
179
  return {
144
180
  subscribe: (listener: (state: BlocState<InstanceType<B>>) => void) => {
181
+
145
182
  const currentInstance = blocInstance.current;
146
183
  if (!currentInstance) {
147
184
  return () => {}; // Return no-op if no instance
148
185
  }
149
186
 
187
+ // Check if we already have an observer for this listener
188
+ const existing = activeObservers.current.get(listener);
189
+ if (existing) {
190
+ return existing.unsubscribe;
191
+ }
150
192
  const observer: BlacObserver<BlocState<InstanceType<B>>> = {
151
193
  fn: () => {
152
194
  try {
153
- // Reset dependency tracking before listener is called
154
- // This ensures we only track properties accessed during the current render
155
- usedKeys.current = new Set();
156
- usedClassPropKeys.current = new Set();
195
+
196
+ // Only reset dependency tracking if we're not using a custom selector
197
+ // Custom selectors override proxy-based tracking entirely
198
+ if (!selector && !currentInstance.defaultDependencySelector) {
199
+ usedKeys.current = new Set();
200
+ usedClassPropKeys.current = new Set();
201
+ }
157
202
 
158
203
  // Only trigger listener if there are actual subscriptions
159
204
  listener(currentInstance.state);
@@ -179,10 +224,16 @@ const useExternalBlocStore = <
179
224
  // This will trigger the callback whenever the bloc's state changes
180
225
  const unSub = currentInstance._observer.subscribe(observer);
181
226
 
182
- // Return an unsubscribe function that can be called to clean up the subscription
183
- return () => {
227
+ const unsubscribe = () => {
228
+ activeObservers.current.delete(listener);
184
229
  unSub();
185
230
  };
231
+
232
+ // Store the observer and unsubscribe function
233
+ activeObservers.current.set(listener, { observer, unsubscribe });
234
+
235
+ // Return an unsubscribe function that can be called to clean up the subscription
236
+ return unsubscribe;
186
237
  },
187
238
  // Return an immutable snapshot of the current bloc state
188
239
  getSnapshot: (): BlocState<InstanceType<B>> | undefined => {
@@ -190,7 +241,55 @@ const useExternalBlocStore = <
190
241
  if (!instance) {
191
242
  return {} as BlocState<InstanceType<B>>;
192
243
  }
193
- return instance.state;
244
+
245
+ const currentState = instance.state;
246
+ const currentDependencies = dependencyArray(currentState, previousStateRef.current);
247
+
248
+ // Check if dependencies have changed using the two-array comparison logic
249
+ const lastDeps = lastDependenciesRef.current;
250
+ let dependenciesChanged = false;
251
+
252
+ if (!lastDeps) {
253
+ // First time - dependencies changed
254
+ dependenciesChanged = true;
255
+ } else if (lastDeps.length !== currentDependencies.length) {
256
+ // Array structure changed
257
+ dependenciesChanged = true;
258
+ } else {
259
+ // Compare each array (state and class dependencies)
260
+ for (let arrayIndex = 0; arrayIndex < currentDependencies.length; arrayIndex++) {
261
+ const lastArray = lastDeps[arrayIndex] || [];
262
+ const newArray = currentDependencies[arrayIndex] || [];
263
+
264
+ if (lastArray.length !== newArray.length) {
265
+ dependenciesChanged = true;
266
+ break;
267
+ }
268
+
269
+ // Compare each dependency value using Object.is
270
+ for (let i = 0; i < newArray.length; i++) {
271
+ if (!Object.is(lastArray[i], newArray[i])) {
272
+ dependenciesChanged = true;
273
+ break;
274
+ }
275
+ }
276
+
277
+ if (dependenciesChanged) break;
278
+ }
279
+ }
280
+
281
+ // Update dependency tracking
282
+ lastDependenciesRef.current = currentDependencies;
283
+
284
+ // If dependencies haven't changed, return the same snapshot reference
285
+ // This prevents React from re-rendering when dependencies are stable
286
+ if (!dependenciesChanged && lastStableSnapshot.current) {
287
+ return lastStableSnapshot.current;
288
+ }
289
+
290
+ // Dependencies changed - update and return new snapshot
291
+ lastStableSnapshot.current = currentState;
292
+ return currentState;
194
293
  },
195
294
  // Server snapshot mirrors the client snapshot in this implementation
196
295
  getServerSnapshot: (): BlocState<InstanceType<B>> | undefined => {
@@ -201,7 +300,7 @@ const useExternalBlocStore = <
201
300
  return instance.state;
202
301
  },
203
302
  }
204
- }, [blocInstance.current?.uid]); // Re-create store when instance changes
303
+ }, []); // Store is stable - individual methods handle instance changes
205
304
 
206
305
  return {
207
306
  usedKeys,
@@ -209,6 +308,7 @@ const useExternalBlocStore = <
209
308
  externalStore: state,
210
309
  instance: blocInstance,
211
310
  rid,
311
+ hasProxyTracking,
212
312
  };
213
313
  };
214
314