UNPKG

@blac/react

Version:

React bindings for BlaC — useBloc hook with automatic re-render optimization

608 lines (607 loc) 22.4 kB
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