@blac/react 2.0.20 → 2.0.22

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
@@ -51,12 +51,12 @@ BlaC follows one rule:
51
51
  > **A component becomes reactive only to the paths it reads during its own render.**
52
52
 
53
53
  `useBloc` returns a proxy that records which paths you read; the component
54
- re-renders only when one of *those* paths changes. Reads outside render — effects,
54
+ re-renders only when one of _those_ paths changes. Reads outside render — effects,
55
55
  event handlers, `setTimeout`, `await` — record nothing.
56
56
 
57
57
  ### Props are snapshots — `untracked()`
58
58
 
59
- A tracking proxy stays bound to the component that *created* it. If a parent passes
59
+ A tracking proxy stays bound to the component that _created_ it. If a parent passes
60
60
  a proxied value to a child and the child reads from it, that read is attributed to
61
61
  the **parent** — so the parent over-subscribes and re-renders for paths only the
62
62
  child uses.
@@ -82,14 +82,13 @@ function List() {
82
82
 
83
83
  The trade-off is intentional and React-like: a component that receives an
84
84
  `untracked()` value is **not** reactive to it — a change triggers nothing, memo or
85
- not. If no component reads a path during its *own* render, a change to that path
85
+ not. If no component reads a path during its _own_ render, a change to that path
86
86
  re-renders nothing. To react to data, a component must be a consumer itself (call
87
87
  its own `useBloc`). Nobody up the tree re-renders for a path unless they read it
88
88
  themselves.
89
89
 
90
90
  > Making this automatic — per-component read scoping so `untracked()` isn't needed
91
- > by hand — is a planned compiler/Babel plugin. See
92
- > [`plans/blac-ambient-tracking/design.md`](../../plans/blac-ambient-tracking/design.md).
91
+ > by hand — is a planned compiler/Babel plugin.
93
92
 
94
93
  ## useBloc
95
94
 
@@ -123,9 +122,14 @@ import { useBloc } from '@blac/react';
123
122
 
124
123
  class UserCardCubit extends Cubit<UserCardState, { userId: string }> {
125
124
  // Constructor is zero-arg; framework calls init(args) before first snapshot
126
- init(args: { userId: string }) {
125
+ protected init(args: { userId: string }) {
127
126
  this.userId = args.userId;
128
- void this.loadUser(args.userId);
127
+ }
128
+
129
+ // Side effects (fetch/subscribe/timer) belong in onActivate, not init —
130
+ // see "Activation: onActivate / onDeactivate" below.
131
+ protected onActivate(signal: AbortSignal) {
132
+ void this.loadUser(this.userId);
129
133
  }
130
134
  }
131
135
 
@@ -157,21 +161,16 @@ Use `deps` to inject refs, stable callbacks, and long-lived controller handles.
157
161
  - **Read lazily** — accessed via `this.deps.x` when needed, may be undefined
158
162
  - **Live** — can change over time
159
163
 
160
- `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):
164
+ `deps` is **not** a `useBloc` option. A component contributes its slice by calling `useBlocDeps`:
161
165
 
162
166
  ```tsx
163
- import { useEffect, useId, useRef } from 'react';
164
- import { APPLY_DEPS, REMOVE_DEPS_OWNER } from '@blac/core';
165
- import { useBloc } from '@blac/react';
167
+ import { useRef } from 'react';
168
+ import { useBloc, useBlocDeps } from '@blac/react';
166
169
 
167
170
  const inputRef = useRef<HTMLInputElement>(null);
168
- const ownerId = useId();
169
171
  const [state, cubit] = useBloc(FileUploadCubit, { args: { endpoint } });
170
172
 
171
- useEffect(() => {
172
- cubit[APPLY_DEPS](ownerId, { inputRef });
173
- return () => cubit[REMOVE_DEPS_OWNER](ownerId);
174
- }, [cubit, inputRef, ownerId]);
173
+ useBlocDeps(cubit, { inputRef });
175
174
  ```
176
175
 
177
176
  The bloc reads them lazily and guards for absence:
@@ -197,14 +196,16 @@ class FileUploadCubit extends Cubit<
197
196
 
198
197
  ```tsx
199
198
  // Component A owns inputRef
200
- cubitA[APPLY_DEPS](ownerIdA, { inputRef });
199
+ useBlocDeps(cubit, { inputRef });
201
200
 
202
201
  // Component B owns onSubmit
203
- cubitB[APPLY_DEPS](ownerIdB, { onSubmit });
202
+ useBlocDeps(cubit, { onSubmit });
204
203
 
205
204
  // cubit.deps === { inputRef, onSubmit } (merged from both consumers)
206
205
  ```
207
206
 
207
+ Each calling component is its own owner; unmounting one withdraws only its own keys — dropped keys reconcile to `undefined` rather than being deleted from the object. No dependency array is needed: `useBlocDeps` applies on every commit and the core shallow-compares, so an unchanged slice is a no-op. Still pass identity-stable values (`useMemo` / `useCallback`), since a new function identity counts as a change.
208
+
208
209
  **Avoid raw callbacks** — the callback staleness gotcha. Prefer:
209
210
 
210
211
  1. **Don't inject callbacks — invert** (best): expose state and let React call the fresh callback in its own effect.
@@ -264,7 +265,7 @@ const [state] = useBloc(CounterCubit, {
264
265
  | `onMount` | `(bloc) => void` | Called when component mounts |
265
266
  | `onUnmount` | `(bloc) => void` | Called when component unmounts |
266
267
 
267
- > Auto-tracking is always on when `select` is omitted — it is **not** a configurable option. `deps`, `autoInstance`, and `instanceId` are not `useBloc` options: wire deps from a mount effect (`APPLY_DEPS` / `REMOVE_DEPS_OWNER`); for per-mount private instances, embed a stable unique ID in `args` (e.g. `{ args: { _id: useId() } }`).
268
+ > Auto-tracking is always on when `select` is omitted — it is **not** a configurable option. `deps`, `autoInstance`, and `instanceId` are not `useBloc` options: wire deps with `useBlocDeps(bloc, slice)`; for per-mount private instances, embed a stable unique ID in `args` (e.g. `{ args: { _id: useId() } }`).
268
269
 
269
270
  ### Identity and Keying
270
271
 
@@ -309,22 +310,33 @@ useBloc(FormCubit, { args: { ...options, _id: id } });
309
310
  useBloc(EditorCubit, { args: { _id: 'editor-1' } });
310
311
  ```
311
312
 
313
+ ### Activation: `onActivate` / `onDeactivate`
314
+
315
+ `init(args)` stays the place to seed synchronous state — it is not deprecated and keeps working exactly as above. For side effects (fetch, subscribe, start a timer), use `onActivate(signal: AbortSignal)` instead: it fires on the instance's 0→1 ownership transition — i.e. only once something actually owns the instance — and gives you an `AbortSignal` that's aborted when the matching `onDeactivate()` fires on the 1→0 transition.
316
+
317
+ ```ts
318
+ class UserCardCubit extends Cubit<UserCardState, { userId: string }> {
319
+ protected init(args: { userId: string }) {
320
+ this.userId = args.userId;
321
+ }
322
+
323
+ protected onActivate(signal: AbortSignal) {
324
+ fetch(`/api/users/${this.userId}`, { signal })
325
+ .then((r) => r.json())
326
+ .then((user) => this.patch({ user }));
327
+ }
328
+ }
329
+ ```
330
+
331
+ > **Ordering with `useBlocDeps`:** `onActivate` fires in a layout effect, before the first `useBlocDeps` slice is applied. A bloc reading `this.deps.x` inside `onActivate` sees `undefined` on first activation — this matches today's `init()` timing, so it isn't a regression, but it means `onActivate` is the wrong hook for deps-driven work. Use `onDepsChanged` for that instead.
332
+
312
333
  ### Breaking Changes (v2)
313
334
 
314
335
  - **`dependencies` option renamed to `select`** — avoids confusion with the `deps` (non-serializable handles) lane.
315
- - **`autoTrack`, `autoInstance`, `instanceId`, and `deps` are no longer `useBloc` options** — auto-tracking is always on (opt out per-consumer with `select`); per-mount private instances embed a unique ID in `args`; deps are wired from a mount effect via `APPLY_DEPS` / `REMOVE_DEPS_OWNER`.
336
+ - **`autoTrack`, `autoInstance`, `instanceId`, and `deps` are no longer `useBloc` options** — auto-tracking is always on (opt out per-consumer with `select`); per-mount private instances embed a unique ID in `args`; deps are wired with `useBlocDeps(bloc, slice)`.
316
337
  - **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.
317
338
  - **`args` is required when declared, forbidden when void** — enforced by the type system; no runtime guard needed.
318
339
 
319
- ## Configuration
320
-
321
- ```tsx
322
- import { configureBlacReact } from '@blac/react';
323
-
324
- // Configuration is currently empty; the tracking model is fixed and not configurable.
325
- configureBlacReact({});
326
- ```
327
-
328
340
  ## Testing
329
341
 
330
342
  ```tsx
@@ -0,0 +1,54 @@
1
+ import { type ReactElement, type ReactNode } from 'react';
2
+ import type { ExtractArgs, StateContainerConstructor } from '@blac/core';
3
+ /**
4
+ * Props for {@link BlocProvider}.
5
+ * @public
6
+ */
7
+ export interface BlocProviderProps<T extends StateContainerConstructor> {
8
+ /**
9
+ * The bloc class whose args are being provided to descendants.
10
+ */
11
+ bloc: T;
12
+ /**
13
+ * Args that descendant `useBloc(bloc)` calls will resolve to when no
14
+ * own `args` are given.
15
+ */
16
+ args: ExtractArgs<T>;
17
+ children: ReactNode;
18
+ }
19
+ /**
20
+ * Provides args to descendant `useBloc` calls for a specific bloc class via
21
+ * React context.
22
+ *
23
+ * Descendants calling `useBloc(Bloc)` without their own `args` resolve to the
24
+ * args supplied here. Own `args` on the `useBloc` call always win.
25
+ *
26
+ * Multiple `BlocProvider` wrappers for different bloc classes compose: each
27
+ * provider merges its entry into the inherited map, so nested providers for
28
+ * different blocs do not interfere.
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * <BlocProvider bloc={UserBloc} args={{ userId: 'alice' }}>
33
+ * <UserProfile />
34
+ * </BlocProvider>
35
+ * ```
36
+ *
37
+ * @example Per-mount private instance
38
+ * ```tsx
39
+ * const id = useId();
40
+ * <BlocProvider bloc={CartBloc} args={{ _id: id }}>
41
+ * <CartWidget />
42
+ * </BlocProvider>
43
+ * ```
44
+ * @public
45
+ */
46
+ export declare function BlocProvider<T extends StateContainerConstructor>({ bloc, args, children, }: BlocProviderProps<T>): ReactElement;
47
+ /**
48
+ * Returns the args provided by the nearest {@link BlocProvider} for the given
49
+ * bloc class, or `undefined` when called outside a matching provider.
50
+ *
51
+ * Used by `useBloc` to inherit provider args when no own `args` are given.
52
+ * @public
53
+ */
54
+ export declare function useProvidedArgs<T extends StateContainerConstructor>(BlocClass: T): ExtractArgs<T> | undefined;
@@ -2,6 +2,7 @@ import { type ReactElement, type ReactNode } from 'react';
2
2
  import type { ExtractArgs, StateContainerConstructor } from '@blac/core';
3
3
  /**
4
4
  * Props for {@link BlocProvider}.
5
+ * @public
5
6
  */
6
7
  export interface BlocProviderProps<T extends StateContainerConstructor> {
7
8
  /**
@@ -40,6 +41,7 @@ export interface BlocProviderProps<T extends StateContainerConstructor> {
40
41
  * <CartWidget />
41
42
  * </BlocProvider>
42
43
  * ```
44
+ * @public
43
45
  */
44
46
  export declare function BlocProvider<T extends StateContainerConstructor>({ bloc, args, children, }: BlocProviderProps<T>): ReactElement;
45
47
  /**
@@ -47,5 +49,6 @@ export declare function BlocProvider<T extends StateContainerConstructor>({ bloc
47
49
  * bloc class, or `undefined` when called outside a matching provider.
48
50
  *
49
51
  * Used by `useBloc` to inherit provider args when no own `args` are given.
52
+ * @public
50
53
  */
51
54
  export declare function useProvidedArgs<T extends StateContainerConstructor>(BlocClass: T): ExtractArgs<T> | undefined;
@@ -0,0 +1,31 @@
1
+ import { type ReactElement, type ReactNode } from 'react';
2
+ import type { StateContainerRegistry } from '@blac/core';
3
+ /**
4
+ * Internal context value: a registry to use in place of the module-global
5
+ * default (`getRegistry()`), or `null` when no provider is present.
6
+ */
7
+ export declare const RegistryContext: import("react").Context<StateContainerRegistry | null>;
8
+ /**
9
+ * Props for {@link RegistryProvider}.
10
+ * @public
11
+ */
12
+ export interface RegistryProviderProps {
13
+ registry: StateContainerRegistry;
14
+ children: ReactNode;
15
+ }
16
+ /**
17
+ * Scopes descendant `useBloc` calls to a specific registry instead of the
18
+ * module-global default.
19
+ *
20
+ * Useful for test isolation, SSR isolation, and micro-frontends that must
21
+ * not share bloc instances with the host page.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * <RegistryProvider registry={new StateContainerRegistry()}>
26
+ * <App />
27
+ * </RegistryProvider>
28
+ * ```
29
+ * @public
30
+ */
31
+ export declare function RegistryProvider({ registry, children, }: RegistryProviderProps): ReactElement;
@@ -0,0 +1,31 @@
1
+ import { type ReactElement, type ReactNode } from 'react';
2
+ import type { StateContainerRegistry } from '@blac/core';
3
+ /**
4
+ * Internal context value: a registry to use in place of the module-global
5
+ * default (`getRegistry()`), or `null` when no provider is present.
6
+ */
7
+ export declare const RegistryContext: import("react").Context<StateContainerRegistry | null>;
8
+ /**
9
+ * Props for {@link RegistryProvider}.
10
+ * @public
11
+ */
12
+ export interface RegistryProviderProps {
13
+ registry: StateContainerRegistry;
14
+ children: ReactNode;
15
+ }
16
+ /**
17
+ * Scopes descendant `useBloc` calls to a specific registry instead of the
18
+ * module-global default.
19
+ *
20
+ * Useful for test isolation, SSR isolation, and micro-frontends that must
21
+ * not share bloc instances with the host page.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * <RegistryProvider registry={new StateContainerRegistry()}>
26
+ * <App />
27
+ * </RegistryProvider>
28
+ * ```
29
+ * @public
30
+ */
31
+ export declare function RegistryProvider({ registry, children, }: RegistryProviderProps): ReactElement;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Build a per-consumer proxy for a bloc instance.
3
+ *
4
+ * Getters run with `this` bound to the **real instance**, not a proxy, so ES
5
+ * `#private` fields and methods work in user blocs. Path recording is done by
6
+ * `[WITH_TRACKED_STATE]`, which makes the instance's own `state` getter report
7
+ * the render's tracking proxy for the duration of the call; nested getter
8
+ * chains stay tracked because the override is still set.
9
+ *
10
+ * One allocation per bloc acquisition (inside `useMemo`), so the proxy is
11
+ * stable across renders.
12
+ *
13
+ * @param onDepHandle - Optional callback invoked when a read returns a branded
14
+ * dep handle. Receives the original handle and returns the value to expose in
15
+ * its place (the session-bound wrapper). The callback is responsible for
16
+ * caching wrappers per handle to avoid re-allocation.
17
+ */
18
+ export declare function buildTrackedProxy<T extends object>(instance: T, trackedStateRef: {
19
+ current: unknown;
20
+ }, onDepHandle?: (handle: object) => unknown): {
21
+ proxy: T;
22
+ };
@@ -1,31 +1,22 @@
1
1
  /**
2
- * Build a per-consumer proxy pair for a bloc instance.
2
+ * Build a per-consumer proxy for a bloc instance.
3
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.
4
+ * Getters run with `this` bound to the **real instance**, not a proxy, so ES
5
+ * `#private` fields and methods work in user blocs. Path recording is done by
6
+ * `[WITH_TRACKED_STATE]`, which makes the instance's own `state` getter report
7
+ * the render's tracking proxy for the duration of the call; nested getter
8
+ * chains stay tracked because the override is still set.
17
9
  *
18
- * Both allocations happen exactly once per bloc acquisition (inside `useMemo`)
19
- * so the proxies are stable across renders.
10
+ * One allocation per bloc acquisition (inside `useMemo`), so the proxy is
11
+ * stable across renders.
20
12
  *
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.
13
+ * @param onDepHandle - Optional callback invoked when a read returns a branded
14
+ * dep handle. Receives the original handle and returns the value to expose in
15
+ * its place (the session-bound wrapper). The callback is responsible for
16
+ * caching wrappers per handle to avoid re-allocation.
25
17
  */
26
18
  export declare function buildTrackedProxy<T extends object>(instance: T, trackedStateRef: {
27
19
  current: unknown;
28
20
  }, onDepHandle?: (handle: object) => unknown): {
29
21
  proxy: T;
30
- thisProxy: T;
31
22
  };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Global configuration for `@blac/react`.
3
+ *
4
+ * The hook's tracking model is fixed: when `useBloc` is called without a
5
+ * `select`, render-time auto-tracking is used (via
6
+ * `@dirtytalk/structural`'s `trackRender`). When `select` is provided,
7
+ * re-renders are driven by per-index `Object.is` over the returned array.
8
+ *
9
+ * Reserved for forwards-compatible knobs; currently empty.
10
+ *
11
+ * @public
12
+ */
13
+ export interface BlacReactConfig {
14
+ }
15
+ /**
16
+ * Configure global defaults for `@blac/react` hooks.
17
+ *
18
+ * @param config - Partial configuration to merge with current globals
19
+ * @public
20
+ */
21
+ export declare function configureBlacReact(config: Partial<BlacReactConfig>): void;
22
+ /**
23
+ * Get the current global configuration.
24
+ * @internal
25
+ */
26
+ export declare function getBlacReactConfig(): BlacReactConfig;
27
+ /**
28
+ * Reset configuration to defaults (useful for testing).
29
+ * @internal
30
+ */
31
+ export declare function resetBlacReactConfig(): void;
package/dist/config.d.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  * re-renders are driven by per-index `Object.is` over the returned array.
8
8
  *
9
9
  * Reserved for forwards-compatible knobs; currently empty.
10
+ *
11
+ * @public
10
12
  */
11
13
  export interface BlacReactConfig {
12
14
  }
@@ -14,6 +16,7 @@ export interface BlacReactConfig {
14
16
  * Configure global defaults for `@blac/react` hooks.
15
17
  *
16
18
  * @param config - Partial configuration to merge with current globals
19
+ * @public
17
20
  */
18
21
  export declare function configureBlacReact(config: Partial<BlacReactConfig>): void;
19
22
  /**