@blac/react 2.0.0-rc-16 → 2.0.0-rc.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.
@@ -1,202 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { renderHook, act } from '@testing-library/react';
3
- import { Cubit, Blac } from '@blac/core';
4
- import useBloc from '../useBloc';
5
-
6
- interface TestState {
7
- count: number;
8
- name: string;
9
- unused: string;
10
- }
11
-
12
- class TestCubit extends Cubit<TestState> {
13
- static isolated = true; // Make it isolated to avoid test interference
14
-
15
- constructor() {
16
- super({ count: 0, name: 'Alice', unused: 'not-used' });
17
- }
18
-
19
- increment = () => {
20
- this.emit({ ...this.state, count: this.state.count + 1 });
21
- };
22
-
23
- changeName = (name: string) => {
24
- this.emit({ ...this.state, name });
25
- };
26
-
27
- changeUnused = (value: string) => {
28
- this.emit({ ...this.state, unused: value });
29
- };
30
- }
31
-
32
- describe('useBloc dependency tracking', () => {
33
- it('should only track properties accessed during current render', () => {
34
- let renderCount = 0;
35
- let accessedProperties: string[] = [];
36
-
37
- const { result, rerender } = renderHook(() => {
38
- const [state, cubit] = useBloc(TestCubit);
39
- renderCount++;
40
-
41
- // Reset tracked properties for this render
42
- accessedProperties = [];
43
-
44
- // First few renders: access both count and name
45
- if (renderCount <= 2) {
46
- // Access count and name
47
- void state.count;
48
- void state.name;
49
- accessedProperties = ['count', 'name'];
50
- } else {
51
- // Later renders: only access count
52
- void state.count;
53
- accessedProperties = ['count'];
54
- }
55
-
56
- return { state, cubit, accessedProperties };
57
- });
58
-
59
- // Initial render - accessing count and name
60
- expect(renderCount).toBe(1);
61
- expect(result.current.accessedProperties).toEqual(['count', 'name']);
62
-
63
- // Change name - should trigger re-render since we're tracking it
64
- act(() => {
65
- result.current.cubit.changeName('Bob');
66
- });
67
- expect(renderCount).toBe(2);
68
- expect(result.current.state.name).toBe('Bob');
69
-
70
- // Force a re-render (this will be render #3, where we stop accessing name)
71
- rerender();
72
- expect(renderCount).toBe(3);
73
- expect(result.current.accessedProperties).toEqual(['count']); // Only accessing count now
74
-
75
- // Change name again - should NOT trigger re-render since we're no longer tracking it
76
- const prevRenderCount = renderCount;
77
- act(() => {
78
- result.current.cubit.changeName('Charlie');
79
- });
80
-
81
- // Render count should not increase
82
- expect(renderCount).toBe(prevRenderCount);
83
- // But state should still be updated if we check it
84
- expect(result.current.cubit.state.name).toBe('Charlie');
85
-
86
- // Change count - should trigger re-render since we're still tracking it
87
- act(() => {
88
- result.current.cubit.increment();
89
- });
90
- expect(renderCount).toBe(prevRenderCount + 1);
91
- expect(result.current.state.count).toBe(1);
92
- });
93
-
94
- it('should not re-render when non-accessed properties change', () => {
95
- let renderCount = 0;
96
-
97
- const { result } = renderHook(() => {
98
- const [state, cubit] = useBloc(TestCubit);
99
- renderCount++;
100
-
101
- // Only access count, never access unused
102
- void state.count;
103
-
104
- return { state, cubit };
105
- });
106
-
107
- expect(renderCount).toBe(1);
108
-
109
- // Change unused property - should NOT trigger re-render
110
- act(() => {
111
- result.current.cubit.changeUnused('changed');
112
- });
113
-
114
- // Should not re-render
115
- expect(renderCount).toBe(1);
116
-
117
- // The component shouldn't re-render, so we can't check the state through result.current
118
- // This is the expected behavior - component doesn't see changes to untracked properties
119
- });
120
-
121
- it('should track nested property access correctly', () => {
122
- interface NestedState {
123
- user: {
124
- profile: {
125
- name: string;
126
- age: number;
127
- };
128
- };
129
- settings: {
130
- theme: string;
131
- };
132
- }
133
-
134
- class NestedCubit extends Cubit<NestedState> {
135
- static isolated = true;
136
-
137
- constructor() {
138
- super({
139
- user: {
140
- profile: {
141
- name: 'Alice',
142
- age: 25,
143
- },
144
- },
145
- settings: {
146
- theme: 'light',
147
- },
148
- });
149
- }
150
-
151
- updateName = (name: string) => {
152
- this.emit({
153
- ...this.state,
154
- user: {
155
- ...this.state.user,
156
- profile: {
157
- ...this.state.user.profile,
158
- name,
159
- },
160
- },
161
- });
162
- };
163
-
164
- updateTheme = (theme: string) => {
165
- this.emit({
166
- ...this.state,
167
- settings: {
168
- ...this.state.settings,
169
- theme,
170
- },
171
- });
172
- };
173
- }
174
-
175
- let renderCount = 0;
176
-
177
- const { result } = renderHook(() => {
178
- const [state, cubit] = useBloc(NestedCubit);
179
- renderCount++;
180
-
181
- // Only access user.profile.name
182
- void state.user.profile.name;
183
-
184
- return { state, cubit };
185
- });
186
-
187
- expect(renderCount).toBe(1);
188
-
189
- // Change theme - should NOT trigger re-render
190
- act(() => {
191
- result.current.cubit.updateTheme('dark');
192
- });
193
- expect(renderCount).toBe(1);
194
-
195
- // Change name - should trigger re-render
196
- act(() => {
197
- result.current.cubit.updateName('Bob');
198
- });
199
- expect(renderCount).toBe(2);
200
- expect(result.current.state.user.profile.name).toBe('Bob');
201
- });
202
- });
package/src/useBloc.ts DELETED
@@ -1,227 +0,0 @@
1
- import {
2
- BlacAdapter,
3
- BlocBase,
4
- BlocConstructor,
5
- BlocState,
6
- generateInstanceIdFromProps,
7
- Blac,
8
- } from '@blac/core';
9
- import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
10
-
11
- /**
12
- * Type definition for the return type of the useBloc hook
13
- */
14
- type HookTypes<B extends BlocConstructor<BlocBase<any>>> = [
15
- BlocState<InstanceType<B>>,
16
- InstanceType<B>,
17
- ];
18
-
19
- /**
20
- * React hook for integrating with Blac state management
21
- */
22
- function useBloc<B extends BlocConstructor<BlocBase<any>>>(
23
- blocConstructor: B,
24
- options?: {
25
- staticProps?: ConstructorParameters<B>[0];
26
- instanceId?: string;
27
- dependencies?: (bloc: InstanceType<B>) => unknown[];
28
- onMount?: (bloc: InstanceType<B>) => void;
29
- onUnmount?: (bloc: InstanceType<B>) => void;
30
- },
31
- ): HookTypes<B> {
32
- // Create a unique identifier for this hook instance
33
- const renderCount = useRef(0);
34
- renderCount.current++;
35
-
36
- const componentRef = useRef<object>({});
37
-
38
- // Get component name for debugging
39
- const componentName = useRef<string>('');
40
- if (!componentName.current) {
41
- // Try to get component name from stack trace
42
- try {
43
- const error = new Error();
44
- const stack = error.stack || '';
45
- const lines = stack.split('\n');
46
-
47
- // Look for React component in stack - try multiple patterns
48
- for (let i = 2; i < lines.length && i < 15; i++) {
49
- const line = lines[i];
50
-
51
- // Pattern 1: "at ComponentName" or "at Object.ComponentName"
52
- let match = line.match(/at\s+(?:Object\.)?([A-Z][a-zA-Z0-9_$]*)/);
53
-
54
- // Pattern 2: Look for component files like "ComponentName.tsx"
55
- if (!match) {
56
- match = line.match(/([A-Z][a-zA-Z0-9_$]*)\.tsx/);
57
- }
58
-
59
- // Pattern 3: Look for render functions
60
- if (!match) {
61
- match = line.match(/render([A-Z][a-zA-Z0-9_$]*)/);
62
- if (match && match[1]) {
63
- match[1] = match[1]; // Use the part after "render"
64
- }
65
- }
66
-
67
- if (
68
- match &&
69
- match[1] !== 'Object' &&
70
- !match[1].startsWith('use') &&
71
- !match[1].startsWith('Use')
72
- ) {
73
- componentName.current = match[1];
74
- break;
75
- }
76
- }
77
-
78
- // If still no name, try to get it from the bloc constructor name
79
- if (!componentName.current) {
80
- const blocName = blocConstructor.name;
81
- if (blocName && blocName !== 'Object') {
82
- // Remove 'Cubit' or 'Bloc' suffix to guess component name
83
- componentName.current =
84
- blocName.replace(/(Cubit|Bloc)$/, '') || 'Component';
85
- } else {
86
- componentName.current = 'Component';
87
- }
88
- }
89
- } catch (e) {
90
- componentName.current = 'Component';
91
- }
92
- }
93
-
94
- // Pass through options
95
- const normalizedOptions = options;
96
-
97
- // Generate instance id from static props if needed
98
- const instanceKey = useMemo(() => {
99
- if (normalizedOptions?.instanceId) {
100
- return normalizedOptions.instanceId;
101
- }
102
- if (normalizedOptions?.staticProps) {
103
- return generateInstanceIdFromProps(normalizedOptions.staticProps) || null;
104
- }
105
- return null;
106
- }, [normalizedOptions?.instanceId, normalizedOptions?.staticProps]);
107
-
108
- // Track adapter creation - recreate when instanceId/staticProps change
109
- const adapter = useMemo(() => {
110
- const newAdapter = new BlacAdapter<B>(
111
- {
112
- componentRef: componentRef,
113
- blocConstructor,
114
- },
115
- {
116
- instanceId: normalizedOptions?.instanceId,
117
- dependencies: normalizedOptions?.dependencies,
118
- staticProps: normalizedOptions?.staticProps,
119
- onMount: normalizedOptions?.onMount,
120
- onUnmount: normalizedOptions?.onUnmount,
121
- },
122
- );
123
- // Set component name for rerender logging
124
- if (componentName.current) {
125
- newAdapter.setComponentName(componentName.current);
126
- }
127
- return newAdapter;
128
- }, [blocConstructor, instanceKey]); // Recreate adapter when instance key changes
129
-
130
- // Reset tracking at the start of each render to ensure we only track
131
- // properties accessed during the current render
132
- adapter.resetTracking();
133
-
134
- // Notify plugins about render
135
- adapter.notifyRender();
136
-
137
- // Update adapter options when they change (except instanceId/staticProps which recreate the adapter)
138
- const optionsChangeCount = useRef(0);
139
- useEffect(() => {
140
- optionsChangeCount.current++;
141
- adapter.options = {
142
- instanceId: normalizedOptions?.instanceId,
143
- dependencies: normalizedOptions?.dependencies,
144
- staticProps: normalizedOptions?.staticProps,
145
- onMount: normalizedOptions?.onMount,
146
- onUnmount: normalizedOptions?.onUnmount,
147
- };
148
- }, [
149
- adapter,
150
- normalizedOptions?.dependencies,
151
- normalizedOptions?.onMount,
152
- normalizedOptions?.onUnmount,
153
- ]);
154
-
155
- // Register as consumer and handle lifecycle
156
- const mountEffectCount = useRef(0);
157
- useEffect(() => {
158
- mountEffectCount.current++;
159
- adapter.mount();
160
-
161
- return () => {
162
- adapter.unmount();
163
- };
164
- }, [adapter]);
165
-
166
- // Subscribe to state changes using useSyncExternalStore
167
- const subscribeMemoCount = useRef(0);
168
- const subscribe = useMemo(() => {
169
- subscribeMemoCount.current++;
170
- let subscriptionCount = 0;
171
-
172
- return (onStoreChange: () => void) => {
173
- subscriptionCount++;
174
- const unsubscribe = adapter.createSubscription({
175
- onChange: () => {
176
- onStoreChange();
177
- },
178
- });
179
-
180
- return () => {
181
- unsubscribe();
182
- };
183
- };
184
- }, [adapter]);
185
-
186
- const snapshotCount = useRef(0);
187
- const serverSnapshotCount = useRef(0);
188
-
189
- const rawState: BlocState<InstanceType<B>> = useSyncExternalStore(
190
- subscribe,
191
- // Get snapshot
192
- () => {
193
- snapshotCount.current++;
194
- const bloc = adapter.blocInstance;
195
- const state = bloc.state;
196
- return state;
197
- },
198
- // Get server snapshot (same as client for now)
199
- () => {
200
- const bloc = adapter.blocInstance;
201
- serverSnapshotCount.current++;
202
- const state = bloc.state;
203
- return state;
204
- },
205
- );
206
-
207
- // Create proxies for fine-grained tracking (if enabled)
208
- const stateMemoCount = useRef(0);
209
- const finalState = useMemo(() => {
210
- stateMemoCount.current++;
211
- // Always return the proxy - it will handle whether to actually proxy or not
212
- const proxyState = adapter.getStateProxy();
213
- return proxyState;
214
- }, [rawState, adapter]);
215
-
216
- const blocMemoCount = useRef(0);
217
- const finalBloc = useMemo(() => {
218
- blocMemoCount.current++;
219
- const proxyBloc = adapter.getBlocProxy();
220
- return proxyBloc;
221
- }, [adapter]);
222
-
223
- // Log final hook return
224
- return [finalState, finalBloc];
225
- }
226
-
227
- export default useBloc;
@@ -1,145 +0,0 @@
1
- import {
2
- Blac,
3
- BlocBase,
4
- BlocConstructor,
5
- BlocState,
6
- generateUUID,
7
- } from '@blac/core';
8
- import { useMemo, useRef } from 'react';
9
-
10
- interface ExternalStoreOptions<B extends BlocBase<any>> {
11
- id?: string;
12
- staticProps?: ConstructorParameters<BlocConstructor<B>>[0];
13
- selector?: (
14
- currentState: BlocState<B>,
15
- previousState: BlocState<B>,
16
- instance: B,
17
- ) => any[];
18
- }
19
-
20
- interface ExternalStore<T> {
21
- getSnapshot: () => T | undefined;
22
- subscribe: (listener: () => void) => () => void;
23
- getServerSnapshot?: () => T | undefined;
24
- }
25
-
26
- interface ExternalBlocStoreResult<B extends BlocBase<any>> {
27
- externalStore: ExternalStore<BlocState<B>>;
28
- instance: { current: B | null };
29
- usedKeys: { current: Set<string> };
30
- usedClassPropKeys: { current: Set<string> };
31
- rid: string;
32
- }
33
-
34
- /**
35
- * Hook for using an external bloc store
36
- * Provides low-level access to bloc instances for use with React's useSyncExternalStore
37
- */
38
- export default function useExternalBlocStore<
39
- B extends BlocConstructor<BlocBase<any>>,
40
- >(
41
- blocConstructor: B,
42
- options: ExternalStoreOptions<InstanceType<B>> = {},
43
- ): ExternalBlocStoreResult<InstanceType<B>> {
44
- const ridRef = useRef<string>(`external-${generateUUID()}`);
45
- const usedKeysRef = useRef<Set<string>>(new Set());
46
- const usedClassPropKeysRef = useRef<Set<string>>(new Set());
47
- const instanceRef = useRef<InstanceType<B> | null>(null);
48
-
49
- // Get or create bloc instance
50
- const bloc = useMemo(() => {
51
- const blac = Blac.getInstance();
52
- const base = blocConstructor as unknown as BlocBase<any>;
53
-
54
- // For isolated blocs, always create a new instance
55
- if (
56
- (base.constructor as any).isolated ||
57
- (blocConstructor as any).isolated
58
- ) {
59
- const newBloc = new blocConstructor(
60
- options?.staticProps,
61
- ) as InstanceType<B>;
62
- const uniqueId =
63
- options?.id || `${blocConstructor.name}_${generateUUID()}`;
64
- newBloc._id = uniqueId;
65
- blac.activateBloc(newBloc);
66
- return newBloc;
67
- }
68
-
69
- // For shared blocs, use the existing getBloc logic
70
- return blac.getBloc(blocConstructor, {
71
- id: options?.id,
72
- constructorParams: options?.staticProps,
73
- });
74
- }, [blocConstructor, options?.id]);
75
-
76
- // Create external store interface
77
- const externalStore = useMemo<ExternalStore<BlocState<InstanceType<B>>>>(
78
- () => ({
79
- getSnapshot: () => {
80
- const currentInstance = instanceRef.current;
81
- return currentInstance ? currentInstance.state : undefined;
82
- },
83
- subscribe: (listener: () => void) => {
84
- const currentInstance = instanceRef.current;
85
- if (!currentInstance) {
86
- // Return no-op unsubscribe if no instance
87
- return () => {};
88
- }
89
-
90
- // Wrap listener to handle errors gracefully
91
- const safeListener = (
92
- newState: BlocState<InstanceType<B>>,
93
- oldState: BlocState<InstanceType<B>>,
94
- ) => {
95
- try {
96
- // Reset tracking keys on each listener call
97
- usedKeysRef.current.clear();
98
- usedClassPropKeysRef.current.clear();
99
-
100
- // Call selector if provided
101
- if (options.selector && currentInstance) {
102
- options.selector(newState, oldState, currentInstance);
103
- }
104
-
105
- // Call listener with state if it expects it
106
- if (listener.length > 0) {
107
- (listener as any)(newState);
108
- } else {
109
- listener();
110
- }
111
- } catch (error) {
112
- console.error('Listener error in useExternalBlocStore:', error);
113
- // Don't rethrow to prevent breaking other listeners
114
- }
115
- };
116
-
117
- // Store the previous state for the listener
118
- let previousState = currentInstance.state;
119
-
120
- const unsubscribe = currentInstance.subscribe((state) => {
121
- const oldState = previousState;
122
- previousState = state;
123
- safeListener(state, oldState);
124
- });
125
- return unsubscribe;
126
- },
127
- getServerSnapshot: () => {
128
- const currentInstance = instanceRef.current;
129
- return currentInstance ? currentInstance.state : undefined;
130
- },
131
- }),
132
- [],
133
- );
134
-
135
- // Update instance ref
136
- instanceRef.current = bloc;
137
-
138
- return {
139
- externalStore,
140
- instance: instanceRef,
141
- usedKeys: usedKeysRef,
142
- usedClassPropKeys: usedClassPropKeysRef,
143
- rid: ridRef.current,
144
- };
145
- }