@blac/react
Version:
React bindings for BlaC — useBloc hook with automatic re-render optimization
608 lines (607 loc) • 22.4 kB
JavaScript
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
let react = require("react");
let _blac_core = require("@blac/core");
let _dirtytalk_structural = require("@dirtytalk/structural");
let react_jsx_runtime = require("react/jsx-runtime");
//#region src/BlocProvider.tsx
const ProvidedArgsContext = (0, react.createContext)(/* @__PURE__ */ new Map());
/**
* Provides args to descendant `useBloc` calls for a specific bloc class via
* React context.
*
* Descendants calling `useBloc(Bloc)` without their own `args` resolve to the
* args supplied here. Own `args` on the `useBloc` call always win.
*
* Multiple `BlocProvider` wrappers for different bloc classes compose: each
* provider merges its entry into the inherited map, so nested providers for
* different blocs do not interfere.
*
* @example
* ```tsx
* <BlocProvider bloc={UserBloc} args={{ userId: 'alice' }}>
* <UserProfile />
* </BlocProvider>
* ```
*
* @example Per-mount private instance
* ```tsx
* const id = useId();
* <BlocProvider bloc={CartBloc} args={{ _id: id }}>
* <CartWidget />
* </BlocProvider>
* ```
*/
function BlocProvider({ bloc, args, children }) {
const parentMap = (0, react.useContext)(ProvidedArgsContext);
const mergedMap = (0, react.useMemo)(() => {
const next = new Map(parentMap);
next.set(bloc, args);
return next;
}, [
parentMap,
bloc,
args
]);
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ProvidedArgsContext.Provider, {
value: mergedMap,
children
});
}
/**
* Returns the args provided by the nearest {@link BlocProvider} for the given
* bloc class, or `undefined` when called outside a matching provider.
*
* Used by `useBloc` to inherit provider args when no own `args` are given.
*/
function useProvidedArgs(BlocClass) {
return (0, react.useContext)(ProvidedArgsContext).get(BlocClass);
}
//#endregion
//#region src/buildTrackedProxy.ts
/**
* Build a per-consumer proxy pair for a bloc instance.
*
* Returns two proxies:
* - `proxy` — the stable outer proxy returned to the consumer. Getter
* properties are invoked with `thisProxy` as `this` so that `this.state`
* reads inside getters are redirected to the current render's tracking
* proxy. Non-getter properties fall through to the live instance.
* - `thisProxy` — the inner `this`-proxy used as the receiver for getter
* calls. Intercepts `state` to return `trackedStateRef.current` when a
* tracking context is active; otherwise falls through to the live value.
* When `onDepHandle` is supplied, a read off `this` whose value carries the
* `DEP_BRAND` symbol (i.e. a `depend()` handle) is routed through
* `onDepHandle`, which returns a per-consumer wrapper whose `.track()` opts
* the consumer into cross-bloc reactivity. Core stays decoupled — detection
* is purely by the `DEP_BRAND` symbol.
*
* Both allocations happen exactly once per bloc acquisition (inside `useMemo`)
* so the proxies are stable across renders.
*
* @param onDepHandle - Optional callback invoked when a getter reads a branded
* dep handle off `this`. Receives the original handle and returns the value
* to expose in its place (the session-bound wrapper). The callback is
* responsible for caching wrappers per handle to avoid re-allocation.
*/
function buildTrackedProxy(instance, trackedStateRef, onDepHandle) {
const getterDescs = /* @__PURE__ */ new Map();
let proto = Object.getPrototypeOf(instance);
while (proto && proto !== Object.prototype) {
const keys = [...Object.getOwnPropertyNames(proto), ...Object.getOwnPropertySymbols(proto)];
for (const key of keys) {
const desc = Object.getOwnPropertyDescriptor(proto, key);
if (desc?.get && !getterDescs.has(key)) getterDescs.set(key, desc);
}
proto = Object.getPrototypeOf(proto);
}
const thisProxy = new Proxy(instance, { get(t, k, r) {
if (k === "state") return trackedStateRef.current ?? Reflect.get(t, k, r);
const value = Reflect.get(t, k, r);
if (onDepHandle !== void 0 && (typeof value === "function" || typeof value === "object") && value !== null && value[_blac_core.DEP_BRAND] !== void 0) return onDepHandle(value);
return value;
} });
return {
proxy: new Proxy(instance, { get(target, key, receiver) {
const desc = getterDescs.get(key);
if (desc?.get) return desc.get.call(thisProxy);
return Reflect.get(target, key, receiver);
} }),
thisProxy
};
}
//#endregion
//#region src/useBloc.ts
let nextConsumerId = 0;
const ARGS_UNSET = Symbol("blac.argsKeyUnset");
/**
* `useRef` with a lazily-built initial value. `useRef(new Map())` discards its
* argument after the first render but still EVALUATES it on every render, so
* each such ref costs one dead allocation per render. Passing a module-level
* factory instead makes the per-render cost a single identity read.
*
* The factory MUST be a stable module-level function (an inline arrow would
* just move the per-render allocation from the value to the closure). The
* returned `current` is non-nullable, so read sites keep their original types.
*/
function useLazyRef(create) {
const ref = (0, react.useRef)(null);
if (ref.current === null) ref.current = create();
return ref;
}
const newComponentRef = () => ({});
const newArgsKeyEntry = () => ({
ref: ARGS_UNSET,
key: void 0
});
const newSessionMap = () => /* @__PURE__ */ new Map();
const newDepSubsMap = () => /* @__PURE__ */ new Map();
const newDepWrapperCache = () => /* @__PURE__ */ new WeakMap();
const newProxyCache = () => new _dirtytalk_structural.ProxyCache();
const primaryRefId = (consumerId) => `useBloc@${consumerId}`;
const depRefId = (consumerId) => `useBloc@${consumerId}:dep`;
/**
* React hook that connects a component to a state container with automatic
* re-render on state changes.
*
* Two tracking modes:
* - **Auto-tracking** (default): the returned state value is a proxy that
* records read paths during render. The component re-renders when any
* recorded path changes. Backed by `@dirtytalk/structural`'s
* {@link trackRender} + the container's path-scoped `DirtyChannel`.
* - **Manual select**: pass `options.select` to opt out of auto-tracking.
* The hook re-renders only when the returned array's elements change
* (per-index `Object.is`).
*
* Lifecycle:
* - The bloc is acquired from the registry on mount and released on
* unmount. The instance key is derived from `options.args` (own args),
* then the surrounding {@link BlocProvider} context args for this bloc,
* then the default key (no args).
* - `options.onMount` fires after the bloc is acquired; `options.onUnmount`
* fires *before* the registry releases its ref, so the bloc is still
* alive when the callback runs.
*
* Per-mount private instance:
* ```ts
* const id = useId();
* const [state, bloc] = useBloc(MyBloc, { args: { _id: id } });
* ```
*
* @template T - The state container constructor type (inferred from BlocClass)
* @param BlocClass - The state container class to connect to
* @param options - Configuration options
* @returns Tuple of `[state, bloc, ref]`
*
* @example Basic usage
* ```ts
* const [state, bloc] = useBloc(MyBloc);
* ```
*
* @example Manual select
* ```ts
* const [state, bloc] = useBloc(MyBloc, {
* select: (state) => [state.count],
* });
* ```
*
* @example Args-based shared instance
* ```ts
* const [state, bloc] = useBloc(UserBloc, { args: { userId: 'alice' } });
* ```
*/
function useBloc(BlocClass, options) {
const componentRef = useLazyRef(newComponentRef);
const consumerIdRef = (0, react.useRef)(null);
if (consumerIdRef.current === null) consumerIdRef.current = `useBloc-${nextConsumerId++}`;
const consumerId = consumerIdRef.current;
const isolated = options?.isolated === true || (0, _blac_core.isIsolatedClass)(BlocClass);
const isolationKey = isolated ? consumerId : void 0;
const selectRef = (0, react.useRef)(options?.select);
selectRef.current = options?.select;
const isSelectMode = options?.select !== void 0;
const onMountRef = (0, react.useRef)(options?.onMount);
onMountRef.current = options?.onMount;
const onUnmountRef = (0, react.useRef)(options?.onUnmount);
onUnmountRef.current = options?.onUnmount;
const isolatedRef = (0, react.useRef)(isolated);
isolatedRef.current = isolated;
const ownArgs = options?.args;
const ownArgsRef = (0, react.useRef)(ownArgs);
ownArgsRef.current = ownArgs;
const ownArgsKeyRef = useLazyRef(newArgsKeyEntry);
if (!Object.is(ownArgsKeyRef.current.ref, ownArgs)) ownArgsKeyRef.current = {
ref: ownArgs,
key: ownArgs === void 0 ? void 0 : JSON.stringify(ownArgs)
};
const ownArgsKey = ownArgsKeyRef.current.key;
const providerArgs = useProvidedArgs(BlocClass);
const providerArgsRef = (0, react.useRef)(providerArgs);
providerArgsRef.current = providerArgs;
const providerArgsKeyRef = useLazyRef(newArgsKeyEntry);
if (!Object.is(providerArgsKeyRef.current.ref, providerArgs)) providerArgsKeyRef.current = {
ref: providerArgs,
key: providerArgs === void 0 ? void 0 : JSON.stringify(providerArgs)
};
const providerArgsKey = providerArgsKeyRef.current.key;
const trackedStateRef = (0, react.useRef)(null);
const sessionRef = useLazyRef(newSessionMap);
const depSubsRef = useLazyRef(newDepSubsMap);
const depWrapperCacheRef = useLazyRef(newDepWrapperCache);
const proxyCacheRef = useLazyRef(newProxyCache);
const lastReconcileRef = (0, react.useRef)(null);
const [rebindNonce, bumpRebind] = (0, react.useReducer)((x) => x + 1, 0);
const ownedBlocRef = (0, react.useRef)(null);
const { bloc, instanceKey, trackedBloc } = (0, react.useMemo)(() => {
const baseArgs = ownArgsRef.current !== void 0 ? ownArgsRef.current : providerArgsRef.current;
const effectiveArgs = isolatedRef.current ? withIsolation(baseArgs, consumerId) : baseArgs;
const resolvedKey = (0, _blac_core.resolveInstanceKey)(BlocClass, effectiveArgs);
const instance = (0, _blac_core.getRegistry)().acquire(BlocClass, resolvedKey, {
canCreate: true,
countRef: false,
args: effectiveArgs
});
const onDepHandle = (handle) => {
const cache = depWrapperCacheRef.current;
const cached = cache.get(handle);
if (cached !== void 0) return cached;
const wrapper = makeDepWrapper(handle, consumerId, trackedStateRef, sessionRef, onDepHandle);
cache.set(handle, wrapper);
return wrapper;
};
const { proxy } = buildTrackedProxy(instance, trackedStateRef, onDepHandle);
return {
bloc: instance,
instanceKey: resolvedKey,
trackedBloc: proxy
};
}, [
BlocClass,
ownArgsKey,
providerArgsKey,
isolationKey,
rebindNonce
]);
const [, force] = (0, react.useReducer)((x) => x + 1, 0);
const pathRef = useLazyRef(_dirtytalk_structural.emptyPathSet);
const expandedInterestRef = useLazyRef(_dirtytalk_structural.emptyPathSet);
const lastSelectionRef = (0, react.useRef)(null);
const renderStateRef = (0, react.useRef)(void 0);
const prevBlocRef = (0, react.useRef)(null);
(0, react.useEffect)(() => {
const channel = bloc.channel;
if (isSelectMode) {
const unsub = channel.subscribe(() => _dirtytalk_structural.ALL_PATHS, () => {
const select = selectRef.current;
if (!select) {
force();
return;
}
const next = select(bloc.state, bloc);
const prev = lastSelectionRef.current;
if (prev !== null && shallowArrayEqual(prev, next)) return;
lastSelectionRef.current = next;
force();
});
const select = selectRef.current;
if (select) {
const next = select(bloc.state, bloc);
const prev = lastSelectionRef.current;
if (prev === null || !shallowArrayEqual(prev, next)) {
lastSelectionRef.current = next;
force();
}
}
return unsub;
}
const unsub = channel.subscribe(() => expandedInterestRef.current, () => force());
bloc.registerConsumerPaths(consumerId, pathRef.current);
if (bloc.state !== renderStateRef.current) force();
return () => {
unsub();
bloc.unregisterConsumer(consumerId);
};
}, [
bloc,
consumerId,
isSelectMode
]);
(0, react.useLayoutEffect)(() => {
const registry = (0, _blac_core.getRegistry)();
const live = registry.acquire(BlocClass, instanceKey, {
canCreate: true,
countRef: true,
refId: primaryRefId(consumerId)
});
ownedBlocRef.current = live;
onMountRef.current?.(live);
if (live !== bloc) bumpRebind();
return () => {
onUnmountRef.current?.(ownedBlocRef.current ?? bloc);
registry.release(BlocClass, instanceKey, false, primaryRefId(consumerId));
};
}, [
BlocClass,
instanceKey,
consumerId
]);
const rawState = bloc.state;
renderStateRef.current = rawState;
if (prevBlocRef.current !== bloc) {
prevBlocRef.current = bloc;
lastSelectionRef.current = null;
}
let state;
if (selectRef.current !== void 0) {
state = rawState;
if (lastSelectionRef.current === null) lastSelectionRef.current = selectRef.current(rawState, bloc);
} else {
const tracked = (0, _dirtytalk_structural.trackRender)(rawState, bloc.interner, proxyCacheRef.current);
state = tracked.value;
trackedStateRef.current = tracked.value;
pathRef.current = tracked.paths;
const session = sessionRef.current;
session.clear();
session.set(bloc, {
kind: "primary",
paths: tracked.paths
});
queueMicrotask(tracked.disarm);
}
(0, react.useLayoutEffect)(() => {
trackedStateRef.current = null;
if (selectRef.current !== void 0) {
lastReconcileRef.current = null;
return;
}
const container = bloc;
const paths = pathRef.current;
const session = sessionRef.current;
const last = lastReconcileRef.current;
if (last !== null && last.primaryContainer === container && (0, _dirtytalk_structural.pathSetEquals)(last.primaryPaths, paths)) {
let unchanged = last.deps.size === session.size - 1;
if (unchanged) for (const [depContainer, entry] of session) {
if (entry.kind === "primary") continue;
const prevEntry = last.deps.get(depContainer);
if (prevEntry === void 0 || prevEntry.key !== entry.key || prevEntry.refId !== entry.refId || !Object.is(prevEntry.args, entry.args) || !(0, _dirtytalk_structural.pathSetEquals)(prevEntry.paths, entry.paths)) {
unchanged = false;
break;
}
}
if (unchanged) return;
}
container.registerConsumerPaths(consumerId, paths);
expandedInterestRef.current = expandWithAncestors(paths, container.interner);
const subs = depSubsRef.current;
for (const [depContainer, sub] of subs) if (!session.has(depContainer)) {
sub.unsubscribe();
depContainer.unregisterConsumer(consumerId);
(0, _blac_core.getRegistry)().release(sub.Type, sub.key, false, sub.refId);
subs.delete(depContainer);
}
for (const [depContainer, entry] of session) {
if (entry.kind === "primary") continue;
const interest = expandWithAncestors(entry.paths, depContainer.interner);
depContainer.registerConsumerPaths(consumerId, entry.paths);
const existing = subs.get(depContainer);
if (existing) {
existing.interestRef.current = interest;
continue;
}
(0, _blac_core.getRegistry)().acquire(entry.Type, entry.key, {
canCreate: true,
countRef: true,
refId: entry.refId,
args: entry.args
});
const interestRef = { current: interest };
const unsubscribe = depContainer.channel.subscribe(() => interestRef.current, () => force());
subs.set(depContainer, {
unsubscribe,
interestRef,
Type: entry.Type,
key: entry.key,
refId: entry.refId,
args: entry.args
});
}
const depsSignature = /* @__PURE__ */ new Map();
for (const [depContainer, entry] of session) {
if (entry.kind === "primary") continue;
depsSignature.set(depContainer, {
paths: entry.paths,
key: entry.key,
refId: entry.refId,
args: entry.args
});
}
lastReconcileRef.current = {
primaryContainer: container,
primaryPaths: paths,
deps: depsSignature
};
});
(0, react.useEffect)(() => {
const subs = depSubsRef.current;
return () => {
for (const [depContainer, sub] of subs) {
sub.unsubscribe();
depContainer.unregisterConsumer(consumerId);
(0, _blac_core.getRegistry)().release(sub.Type, sub.key, false, sub.refId);
}
subs.clear();
};
}, [consumerId, depSubsRef]);
return [
state,
trackedBloc,
componentRef
];
}
/**
* Build the per-consumer wrapper that replaces a branded dep handle inside a
* tracked getter's `this`. The wrapper exposes the same accessors as the core
* handle and overrides `.track()`:
*
* - **Inside a render** (`trackedStateRef.current != null`): resolve (ENSURE,
* no ref) the dep, `trackRender` its state, merge the recorded paths into the
* session entry, build/reuse a tracked proxy for the dep so its OWN getters
* track too, and return `[trackedValue, depProxy]`. The ownership ref is taken
* by the layout-effect reconcile pass, not here.
* - **Outside a render**: degrade to live `[dep.state, dep]` — matches the core
* base impl, safe in event handlers/effects/methods.
*
* `.untracked()` always returns the live instance with no subscription.
*
* Args resolve at call time (`options.args ?? defaultArgs`), so a single handle
* can resolve different dep instances across calls; tracked-proxy state is
* therefore cached per resolved instance, not per handle. Guards against a
* container re-entering tracking within the same render (mutual A↔B deps): if
* the dep already has a non-primary session entry this render, reuse its proxy
* + union its paths instead of re-acquiring.
*/
function makeDepWrapper(handle, consumerId, trackedStateRef, sessionRef, onDepHandle) {
const brand = handle[_blac_core.DEP_BRAND];
const refId = depRefId(consumerId);
const registry = (0, _blac_core.getRegistry)();
const perDep = /* @__PURE__ */ new WeakMap();
const proxyCache = new _dirtytalk_structural.ProxyCache();
let lastArgs = ARGS_UNSET;
let lastKey = "";
const resolve = (options) => {
const args = options?.args ?? brand.defaultArgs;
if (!Object.is(lastArgs, args)) {
lastArgs = args;
lastKey = registry.resolveKey(brand.Type, void 0, args);
}
const key = lastKey;
return {
dep: registry.ensure(brand.Type, key, args),
key,
args
};
};
const wrapper = {
untracked: (options) => resolve(options).dep,
track: (options) => {
const { dep, key, args } = resolve(options);
if (trackedStateRef.current == null) return [dep.state, dep];
const session = sessionRef.current;
const existing = session.get(dep);
const tracked = (0, _dirtytalk_structural.trackRender)(dep.state, dep.interner, proxyCache);
let cache = perDep.get(dep);
if (cache === void 0) {
const ref = { current: tracked.value };
cache = {
ref,
proxy: buildTrackedProxy(dep, ref, onDepHandle).proxy
};
perDep.set(dep, cache);
} else cache.ref.current = tracked.value;
if (existing !== void 0) existing.paths = unionPaths(existing.paths, tracked.paths);
else session.set(dep, {
kind: "dep",
paths: tracked.paths,
Type: brand.Type,
key,
refId,
args
});
return [tracked.value, cache.proxy];
}
};
Object.defineProperty(wrapper, _blac_core.DEP_BRAND, {
value: brand,
enumerable: false,
writable: false,
configurable: false
});
return wrapper;
}
/** Union two PathSets (ALL_PATHS dominates). */
function unionPaths(a, b) {
if (a === _dirtytalk_structural.ALL_PATHS || b === _dirtytalk_structural.ALL_PATHS) return _dirtytalk_structural.ALL_PATHS;
const out = new Set(a);
for (const id of b) out.add(id);
return out;
}
/**
* Fold a per-mount isolation id into `args`. MERGES rather than replaces
* (plan Q2=B): a class-level `static isolated` must not be silently cancelled
* by a call site that happens to pass args. Non-object args are nested rather
* than spread, so a primitive arg can't be destructured into index keys.
*/
const withIsolation = (base, id) => base === void 0 ? { _blacIsolated: id } : typeof base === "object" && base !== null ? {
...base,
_blacIsolated: id
} : {
_blacArgs: base,
_blacIsolated: id
};
const shallowArrayEqual = (a, b) => {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (!Object.is(a[i], b[i])) return false;
return true;
};
/**
* Expand a PathSet to include an *ancestor-watch* id for every ancestor of
* every tracked leaf.
*
* The auto-tracker records leaf paths (e.g. `'items.length'`), but
* `StructuralContainer.patch` can only mark the parent (`'items'`) when it
* replaces a value atomically (arrays, `null`, primitives — it can't see
* inside). Without expansion, a subscriber with interest `{'items.length'}`
* would miss a `patch`-triggered atomic-replacement of `items`.
*
* Ancestors are added under the interner's *ancestor-watch* lane
* (`internAncestor`), NOT as normal ids. The source emits a matching
* ancestor-watch mark only for paths it replaces atomically — never for a
* plain-object structural pulse-up. So `{'items.length'}` wakes when the array
* `items` is replaced, but `{'user.email'}` does NOT wake when a sibling
* `user.name` changes and pulses `user` up: pulse-up `user` is a normal id and
* the ancestor-watch `user` only intersects another ancestor-watch `user`.
*
* Example: leaf `'a.b.c'` adds ancestor-watch ids for `'a.b'` and `'a'` (but
* NOT the `''` root — a root change is covered by `ALL_PATHS` from the source,
* and `''` would wake this consumer on every field change).
*/
function expandWithAncestors(paths, interner) {
if (paths === _dirtytalk_structural.ALL_PATHS) return _dirtytalk_structural.ALL_PATHS;
const leafPaths = paths;
if (leafPaths.size === 0) return paths;
const expanded = new Set(leafPaths);
for (const id of leafPaths) {
const str = interner.lookup(id);
let idx = str.lastIndexOf(".");
while (idx > 0) {
const ancestor = str.slice(0, idx);
expanded.add(interner.internAncestor(ancestor));
idx = ancestor.lastIndexOf(".");
}
}
return expanded;
}
let globalConfig = {};
/**
* Configure global defaults for `@blac/react` hooks.
*
* @param config - Partial configuration to merge with current globals
*/
function configureBlacReact(config) {
globalConfig = {
...globalConfig,
...config
};
}
//#endregion
exports.BlocProvider = BlocProvider;
exports.configureBlacReact = configureBlacReact;
Object.defineProperty(exports, "untracked", {
enumerable: true,
get: function() {
return _dirtytalk_structural.untracked;
}
});
exports.useBloc = useBloc;
exports.useProvidedArgs = useProvidedArgs;
//# sourceMappingURL=index.cjs.map