@blac/react 2.0.15 → 2.0.17

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/README.md CHANGED
@@ -10,7 +10,7 @@ React bindings for BlaC — `useBloc` hook with proxy-based automatic re-render
10
10
  pnpm add @blac/react @blac/core
11
11
  ```
12
12
 
13
- Requires React 18+ (`useSyncExternalStore`).
13
+ Requires React 18+.
14
14
 
15
15
  ## Quick Start
16
16
 
@@ -54,11 +54,11 @@ const [state, bloc, ref] = useBloc(MyBloc, options?);
54
54
 
55
55
  BlaC blocs receive external data through three distinct channels:
56
56
 
57
- | Lane | Purpose | Keying | Lifetime | Example |
58
- |---|---|---|---|---|
59
- | **`args`** | Typed creation data; derives instance identity | **Yes** (structural hash or `static key`) | Once at `init()` | `userId`, `endpoint` |
60
- | **`deps`** | Non-serializable refs, callbacks, handles | **Never** | Live, per-consumer merged | `ref`, `onComplete` callback, `emblaApi` |
61
- | **events** | Values that change over time or are late-bound | N/A | Called from effects | `cubit.slidesChanged(v)` from `useEffect` |
57
+ | Lane | Purpose | Keying | Lifetime | Example |
58
+ | ---------- | ---------------------------------------------- | ----------------------------------------- | ------------------------- | ----------------------------------------- |
59
+ | **`args`** | Typed creation data; derives instance identity | **Yes** (structural hash or `static key`) | Once at `init()` | `userId`, `endpoint` |
60
+ | **`deps`** | Non-serializable refs, callbacks, handles | **Never** | Live, per-consumer merged | `ref`, `onComplete` callback, `emblaApi` |
61
+ | **events** | Values that change over time or are late-bound | N/A | Called from effects | `cubit.slidesChanged(v)` from `useEffect` |
62
62
 
63
63
  ### `args`: Typed Construction Data
64
64
 
@@ -84,40 +84,54 @@ function UserCard({ userId }: { userId: string }) {
84
84
  ```
85
85
 
86
86
  **Key properties:**
87
+
87
88
  - **Required when declared** — omitting `args` or passing the wrong shape is a type error.
88
89
  - **Drives identity** — different `args` ⇒ different instance. Same `args` ⇒ same instance (dev-warn if args mismatch on same-keyed second call).
89
90
  - **Serializable only** — non-serializable values (refs, callbacks) belong in the `deps` lane (below).
90
- - **Per-component private instances** — use with `autoInstance: true` to give each mount its own instance, disposed on unmount:
91
+ - **Per-component private instances** — pass a per-mount `instanceId` (keyed by React's `useId()`) to give each mount its own instance, disposed on unmount:
91
92
 
92
93
  ```tsx
93
- const [state, cubit] = useBloc(FormCubit, { args: options, autoInstance: true });
94
+ const instanceId = useId();
95
+ const [state, cubit] = useBloc(FormCubit, { args: options, instanceId });
94
96
  ```
95
97
 
96
98
  ### `deps`: Non-Serializable Refs and Callbacks
97
99
 
98
100
  Use `deps` to inject refs, stable callbacks, and long-lived controller handles. Unlike `args`, `deps` are:
101
+
99
102
  - **Never keying** — different refs don't fork the instance
100
103
  - **Per-consumer merged** — each component contributes its own slice
101
104
  - **Read lazily** — accessed via `this.deps.x` when needed, may be undefined
102
105
  - **Live** — can change over time
103
106
 
107
+ `deps` is **not** a `useBloc` option. A component contributes its slice from a **mount effect**, using `APPLY_DEPS` / `REMOVE_DEPS_OWNER` from `@blac/core` (marked `@internal` today; a friendlier wrapper may land later):
108
+
104
109
  ```tsx
110
+ import { useEffect, useId, useRef } from 'react';
111
+ import { APPLY_DEPS, REMOVE_DEPS_OWNER } from '@blac/core';
112
+ import { useBloc } from '@blac/react';
113
+
105
114
  const inputRef = useRef<HTMLInputElement>(null);
106
- const onComplete = useCallback(() => { /* ... */ }, []);
115
+ const ownerId = useId();
116
+ const [state, cubit] = useBloc(FileUploadCubit, { args: { endpoint } });
107
117
 
108
- const [state, cubit] = useBloc(FileUploadCubit, {
109
- args: { endpoint },
110
- deps: { inputRef, onComplete },
111
- });
118
+ useEffect(() => {
119
+ cubit[APPLY_DEPS](ownerId, { inputRef });
120
+ return () => cubit[REMOVE_DEPS_OWNER](ownerId);
121
+ }, [cubit, inputRef, ownerId]);
112
122
  ```
113
123
 
114
124
  The bloc reads them lazily and guards for absence:
115
125
 
116
126
  ```ts
117
- class FileUploadCubit extends Cubit<UploadState, { endpoint: string }, {
118
- inputRef?: RefObject<HTMLInputElement>;
119
- onComplete?: () => void;
120
- }> {
127
+ class FileUploadCubit extends Cubit<
128
+ UploadState,
129
+ { endpoint: string },
130
+ {
131
+ inputRef?: RefObject<HTMLInputElement>;
132
+ onComplete?: () => void;
133
+ }
134
+ > {
121
135
  async upload() {
122
136
  this.deps.inputRef?.current?.click?.();
123
137
  // ... perform upload ...
@@ -129,16 +143,17 @@ class FileUploadCubit extends Cubit<UploadState, { endpoint: string }, {
129
143
  **Multi-consumer merge:** when multiple components provide the same cubit with different `deps`, their keys are shallow-merged:
130
144
 
131
145
  ```tsx
132
- // Component A provides inputRef
133
- useBloc(FormCubit, { args: { formId }, deps: { inputRef } });
146
+ // Component A owns inputRef
147
+ cubitA[APPLY_DEPS](ownerIdA, { inputRef });
134
148
 
135
- // Component B provides onSubmit
136
- useBloc(FormCubit, { args: { formId }, deps: { onSubmit } });
149
+ // Component B owns onSubmit
150
+ cubitB[APPLY_DEPS](ownerIdB, { onSubmit });
137
151
 
138
- // cubit.deps === { inputRef, onSubmit } (merged from both)
152
+ // cubit.deps === { inputRef, onSubmit } (merged from both consumers)
139
153
  ```
140
154
 
141
155
  **Avoid raw callbacks** — the callback staleness gotcha. Prefer:
156
+
142
157
  1. **Don't inject callbacks — invert** (best): expose state and let React call the fresh callback in its own effect.
143
158
  2. **Stabilize at source** — wrap in `useCallback`.
144
159
  3. **As an event** — push the callback via a bloc method called from your effect.
@@ -168,7 +183,7 @@ function Carousel({ slides }: { slides: Slide[] }) {
168
183
  }
169
184
  ```
170
185
 
171
- **Convention:** *one component owns syncing any given live value.* Two components calling the same event from both their effects on the same shared instance is a design smell (and rare, because keyed `args` usually route multi-consumer cases to distinct instances).
186
+ **Convention:** _one component owns syncing any given live value._ Two components calling the same event from both their effects on the same shared instance is a design smell (and rare, because keyed `args` usually route multi-consumer cases to distinct instances).
172
187
 
173
188
  ### Tracking Modes
174
189
 
@@ -187,39 +202,34 @@ const [state] = useBloc(CounterCubit, {
187
202
  });
188
203
  ```
189
204
 
190
- **No tracking:** Re-renders on every state change.
191
-
192
- ```tsx
193
- const [state] = useBloc(MyBloc, { autoTrack: false });
194
- ```
195
-
196
205
  ### Options
197
206
 
198
- | Option | Type | Description |
199
- | -------------- | ---------------------------- | ---------------------------------------------------- |
200
- | `args` | `Args` type | Required when bloc declares `Args != void`; forbidden when `void` |
201
- | `deps` | `{ [key]: value }` | Per-consumer slice of non-serializable handles |
202
- | `autoTrack` | `boolean` | Enable proxy-based auto-tracking (default: `true`) |
203
- | `select` | `(state, bloc) => unknown[]` | Manual dependency selector (renamed from `dependencies`) |
204
- | `autoInstance` | `boolean` | Per-mount instance keyed by `useId()` instead of shared (default: `false`) |
205
- | `instanceId` | `string \| number` | Explicit identity key (overrides args-derived keying) |
206
- | `onMount` | `(bloc) => void` | Called when component mounts |
207
- | `onUnmount` | `(bloc) => void` | Called when component unmounts |
207
+ | Option | Type | Description |
208
+ | ------------ | ---------------------------- | -------------------------------------------------------------------------------- |
209
+ | `args` | `Args` type | Required when bloc declares `Args != void`; forbidden when `void` |
210
+ | `select` | `(state, bloc) => unknown[]` | Manual dependency selector (renamed from `dependencies`); disables auto-tracking |
211
+ | `instanceId` | `string \| number` | Explicit identity key; pass `useId()` for a per-mount instance |
212
+ | `onMount` | `(bloc) => void` | Called when component mounts |
213
+ | `onUnmount` | `(bloc) => void` | Called when component unmounts |
214
+
215
+ > Auto-tracking is always on when `select` is omitted — it is **not** a configurable option. `deps` and `autoInstance` are not `useBloc` options either: wire deps from a mount effect (`APPLY_DEPS` / `REMOVE_DEPS_OWNER`), and use `instanceId: useId()` for per-mount instances.
208
216
 
209
217
  ### Identity and Keying
210
218
 
211
219
  Instance identity is resolved in precedence order:
212
220
 
213
- 1. **Explicit `instanceId`** — hard override if provided
214
- 2. **`autoInstance: true`** — per-mount instance keyed by `useId()`
215
- 3. **`static key(args)` → structural hash of `args`** — default when bloc declares `Args` (if `static key` is defined on the class, it takes precedence; otherwise a stable hash of all `args`)
216
- 4. **`<BlocProvider>` context id** — inherited from parent provider
217
- 5. **`'default'`** — singleton fallback
221
+ 1. **Explicit `instanceId`** — hard override (pass `useId()` for a per-mount private instance)
222
+ 2. **`<BlocProvider>` context id** — inherited from an ancestor provider
223
+ 3. **`static key(args)` → structural hash of `args`** — default when the bloc declares `Args` (`static key` wins if defined; otherwise a stable hash of all `args`)
224
+ 4. **`'default'`** — singleton fallback
218
225
 
219
226
  Blocs declare explicit identity via a static class property:
220
227
 
221
228
  ```ts
222
- class DocumentCubit extends Cubit<DocState, { docId: string; readonly: boolean }> {
229
+ class DocumentCubit extends Cubit<
230
+ DocState,
231
+ { docId: string; readonly: boolean }
232
+ > {
223
233
  static key = (args) => args.docId;
224
234
  // Identity is docId; readonly config rides along but doesn't fork instances
225
235
  }
@@ -227,14 +237,15 @@ class DocumentCubit extends Cubit<DocState, { docId: string; readonly: boolean }
227
237
 
228
238
  ### Instance Sharing and Lifecycle
229
239
 
230
- By default, all components using `useBloc(MyBloc)` with the same identity share one instance. Use `autoInstance` for per-component private instances:
240
+ By default, all components using `useBloc(MyBloc)` with the same identity share one instance. Pass a per-mount `instanceId` (via `useId()`) for per-component private instances:
231
241
 
232
242
  ```tsx
233
243
  // All users with userId=123 share one instance
234
244
  useBloc(UserCardCubit, { args: { userId: 123 } });
235
245
 
236
246
  // Each component mount gets its own instance, disposed on unmount
237
- useBloc(FormCubit, { args: options, autoInstance: true });
247
+ const instanceId = useId();
248
+ useBloc(FormCubit, { args: options, instanceId });
238
249
 
239
250
  // Explicit id (escape hatch for non-derivable identity)
240
251
  useBloc(EditorCubit, { instanceId: 'editor-1' });
@@ -242,7 +253,8 @@ useBloc(EditorCubit, { instanceId: 'editor-1' });
242
253
 
243
254
  ### Breaking Changes (v2)
244
255
 
245
- - **`dependencies` option renamed to `select`** — avoids confusion with the new `deps` (non-serializable handles) lane.
256
+ - **`dependencies` option renamed to `select`** — avoids confusion with the `deps` (non-serializable handles) lane.
257
+ - **`autoTrack`, `autoInstance`, and `deps` are no longer `useBloc` options** — auto-tracking is always on (opt out per-consumer with `select`); per-mount instances use `instanceId: useId()`; deps are wired from a mount effect via `APPLY_DEPS` / `REMOVE_DEPS_OWNER`.
246
258
  - **Zero-arg constructor + `init(args)` lifecycle** — all blocs now use `new Type()` with no constructor args. Blocs that declare `Args` receive them via `init(args)` called by the framework before the first state snapshot.
247
259
  - **`args` is required when declared, forbidden when void** — enforced by the type system; no runtime guard needed.
248
260
 
@@ -251,7 +263,8 @@ useBloc(EditorCubit, { instanceId: 'editor-1' });
251
263
  ```tsx
252
264
  import { configureBlacReact } from '@blac/react';
253
265
 
254
- configureBlacReact({ autoTrack: true });
266
+ // Configuration is currently empty; the tracking model is fixed and not configurable.
267
+ configureBlacReact({});
255
268
  ```
256
269
 
257
270
  ## Testing
@@ -1,33 +1,51 @@
1
1
  import { type ReactElement, type ReactNode } from 'react';
2
+ import type { ExtractArgs, StateContainerConstructor } from '@blac/core';
2
3
  /**
3
4
  * Props for {@link BlocProvider}.
4
5
  */
5
- export interface BlocProviderProps {
6
+ export interface BlocProviderProps<T extends StateContainerConstructor> {
6
7
  /**
7
- * Instance id that descendants' `useBloc(C)` calls will resolve to when no
8
- * explicit `instanceId` is given.
8
+ * The bloc class whose args are being provided to descendants.
9
9
  */
10
- instanceId: string | number;
10
+ bloc: T;
11
+ /**
12
+ * Args that descendant `useBloc(bloc)` calls will resolve to when no
13
+ * own `args` are given.
14
+ */
15
+ args: ExtractArgs<T>;
11
16
  children: ReactNode;
12
17
  }
13
18
  /**
14
- * Provides an `instanceId` to descendant `useBloc` calls via React context.
19
+ * Provides args to descendant `useBloc` calls for a specific bloc class via
20
+ * React context.
15
21
  *
16
- * Descendants calling `useBloc(C)` without an `instanceId` resolve to the id
17
- * supplied here. An explicit `instanceId` on the `useBloc` call still wins.
18
- * `autoInstance` / `static isolated = true` also override the context, because
19
- * those force a per-mount auto-key.
22
+ * Descendants calling `useBloc(Bloc)` without their own `args` resolve to the
23
+ * args supplied here. Own `args` on the `useBloc` call always win.
24
+ *
25
+ * Multiple `BlocProvider` wrappers for different bloc classes compose: each
26
+ * provider merges its entry into the inherited map, so nested providers for
27
+ * different blocs do not interfere.
20
28
  *
21
29
  * @example
22
30
  * ```tsx
23
- * <BlocProvider instanceId="customer-42">
24
- * <CustomerView />
31
+ * <BlocProvider bloc={UserBloc} args={{ userId: 'alice' }}>
32
+ * <UserProfile />
33
+ * </BlocProvider>
34
+ * ```
35
+ *
36
+ * @example Per-mount private instance
37
+ * ```tsx
38
+ * const id = useId();
39
+ * <BlocProvider bloc={CartBloc} args={{ _id: id }}>
40
+ * <CartWidget />
25
41
  * </BlocProvider>
26
42
  * ```
27
43
  */
28
- export declare function BlocProvider({ instanceId, children, }: BlocProviderProps): ReactElement;
44
+ export declare function BlocProvider<T extends StateContainerConstructor>({ bloc, args, children, }: BlocProviderProps<T>): ReactElement;
29
45
  /**
30
- * Reads the nearest {@link BlocProvider} instance id, or `undefined` when
31
- * called outside a provider.
46
+ * Returns the args provided by the nearest {@link BlocProvider} for the given
47
+ * bloc class, or `undefined` when called outside a matching provider.
48
+ *
49
+ * Used by `useBloc` to inherit provider args when no own `args` are given.
32
50
  */
33
- export declare function useInstanceIdFromContext(): string | undefined;
51
+ export declare function useProvidedArgs<T extends StateContainerConstructor>(BlocClass: T): ExtractArgs<T> | undefined;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Build a per-consumer proxy pair for a bloc instance.
3
+ *
4
+ * Returns two proxies:
5
+ * - `proxy` — the stable outer proxy returned to the consumer. Getter
6
+ * properties are invoked with `thisProxy` as `this` so that `this.state`
7
+ * reads inside getters are redirected to the current render's tracking
8
+ * proxy. Non-getter properties fall through to the live instance.
9
+ * - `thisProxy` — the inner `this`-proxy used as the receiver for getter
10
+ * calls. Intercepts `state` to return `trackedStateRef.current` when a
11
+ * tracking context is active; otherwise falls through to the live value.
12
+ * When `onDepHandle` is supplied, a read off `this` whose value carries the
13
+ * `DEP_BRAND` symbol (i.e. a `depend()` handle) is routed through
14
+ * `onDepHandle`, which returns a per-consumer wrapper whose `.track()` opts
15
+ * the consumer into cross-bloc reactivity. Core stays decoupled — detection
16
+ * is purely by the `DEP_BRAND` symbol.
17
+ *
18
+ * Both allocations happen exactly once per bloc acquisition (inside `useMemo`)
19
+ * so the proxies are stable across renders.
20
+ *
21
+ * @param onDepHandle - Optional callback invoked when a getter reads a branded
22
+ * dep handle off `this`. Receives the original handle and returns the value
23
+ * to expose in its place (the session-bound wrapper). The callback is
24
+ * responsible for caching wrappers per handle to avoid re-allocation.
25
+ */
26
+ export declare function buildTrackedProxy<T extends object>(instance: T, trackedStateRef: {
27
+ current: unknown;
28
+ }, onDepHandle?: (handle: object) => unknown): {
29
+ proxy: T;
30
+ thisProxy: T;
31
+ };