@blac/react
Version:
React bindings for BlaC — useBloc hook with automatic re-render optimization
1 lines • 63.7 kB
Source Map (JSON)
{"version":3,"file":"index.cjs","names":["DEP_BRAND","ProxyCache","emptyPathSet","ALL_PATHS","DEP_BRAND"],"sources":["../src/BlocProvider.tsx","../src/buildTrackedProxy.ts","../src/useBloc.ts","../src/config.ts"],"sourcesContent":["import {\n createContext,\n useContext,\n useMemo,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport type { ExtractArgs, StateContainerConstructor } from '@blac/core';\n\n/**\n * Internal context value: a Map from bloc constructor to the args object\n * provided by the nearest BlocProvider for that bloc class.\n *\n * Stored as a WeakMap-keyed record so nested providers for different blocs\n * compose without clobbering each other.\n */\ntype ProvidedArgsMap = Map<StateContainerConstructor, unknown>;\n\nconst ProvidedArgsContext = createContext<ProvidedArgsMap>(new Map());\n\n/**\n * Props for {@link BlocProvider}.\n */\nexport interface BlocProviderProps<T extends StateContainerConstructor> {\n /**\n * The bloc class whose args are being provided to descendants.\n */\n bloc: T;\n /**\n * Args that descendant `useBloc(bloc)` calls will resolve to when no\n * own `args` are given.\n */\n args: ExtractArgs<T>;\n children: ReactNode;\n}\n\n/**\n * Provides args to descendant `useBloc` calls for a specific bloc class via\n * React context.\n *\n * Descendants calling `useBloc(Bloc)` without their own `args` resolve to the\n * args supplied here. Own `args` on the `useBloc` call always win.\n *\n * Multiple `BlocProvider` wrappers for different bloc classes compose: each\n * provider merges its entry into the inherited map, so nested providers for\n * different blocs do not interfere.\n *\n * @example\n * ```tsx\n * <BlocProvider bloc={UserBloc} args={{ userId: 'alice' }}>\n * <UserProfile />\n * </BlocProvider>\n * ```\n *\n * @example Per-mount private instance\n * ```tsx\n * const id = useId();\n * <BlocProvider bloc={CartBloc} args={{ _id: id }}>\n * <CartWidget />\n * </BlocProvider>\n * ```\n */\nexport function BlocProvider<T extends StateContainerConstructor>({\n bloc,\n args,\n children,\n}: BlocProviderProps<T>): ReactElement {\n const parentMap = useContext(ProvidedArgsContext);\n\n // Merge our entry into a new Map so sibling/parent providers for other blocs\n // are preserved. Memoised on (parentMap, bloc, args) identity.\n const mergedMap = useMemo(() => {\n const next = new Map(parentMap);\n next.set(bloc, args);\n return next;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [parentMap, bloc, args]);\n\n return (\n <ProvidedArgsContext.Provider value={mergedMap}>\n {children}\n </ProvidedArgsContext.Provider>\n );\n}\n\n/**\n * Returns the args provided by the nearest {@link BlocProvider} for the given\n * bloc class, or `undefined` when called outside a matching provider.\n *\n * Used by `useBloc` to inherit provider args when no own `args` are given.\n */\nexport function useProvidedArgs<T extends StateContainerConstructor>(\n BlocClass: T,\n): ExtractArgs<T> | undefined {\n const map = useContext(ProvidedArgsContext);\n return map.get(BlocClass) as ExtractArgs<T> | undefined;\n}\n","import { DEP_BRAND } from '@blac/core';\n\n/**\n * Build a per-consumer proxy pair for a bloc instance.\n *\n * Returns two proxies:\n * - `proxy` — the stable outer proxy returned to the consumer. Getter\n * properties are invoked with `thisProxy` as `this` so that `this.state`\n * reads inside getters are redirected to the current render's tracking\n * proxy. Non-getter properties fall through to the live instance.\n * - `thisProxy` — the inner `this`-proxy used as the receiver for getter\n * calls. Intercepts `state` to return `trackedStateRef.current` when a\n * tracking context is active; otherwise falls through to the live value.\n * When `onDepHandle` is supplied, a read off `this` whose value carries the\n * `DEP_BRAND` symbol (i.e. a `depend()` handle) is routed through\n * `onDepHandle`, which returns a per-consumer wrapper whose `.track()` opts\n * the consumer into cross-bloc reactivity. Core stays decoupled — detection\n * is purely by the `DEP_BRAND` symbol.\n *\n * Both allocations happen exactly once per bloc acquisition (inside `useMemo`)\n * so the proxies are stable across renders.\n *\n * @param onDepHandle - Optional callback invoked when a getter reads a branded\n * dep handle off `this`. Receives the original handle and returns the value\n * to expose in its place (the session-bound wrapper). The callback is\n * responsible for caching wrappers per handle to avoid re-allocation.\n */\nexport function buildTrackedProxy<T extends object>(\n instance: T,\n trackedStateRef: { current: unknown },\n onDepHandle?: (handle: object) => unknown,\n): { proxy: T; thisProxy: T } {\n // Build a map of getter descriptors from the prototype chain (excluding\n // Object.prototype). This is computed once per bloc acquisition so that\n // the proxy's get trap is O(1) per property access. Both string- and\n // symbol-keyed getters are collected. Arrow-function class properties\n // (own, bound in the constructor) are not getters and pass through\n // unmodified.\n const getterDescs = new Map<string | symbol, PropertyDescriptor>();\n let proto = Object.getPrototypeOf(instance);\n while (proto && proto !== Object.prototype) {\n const keys: (string | symbol)[] = [\n ...Object.getOwnPropertyNames(proto),\n ...Object.getOwnPropertySymbols(proto),\n ];\n for (const key of keys) {\n const desc = Object.getOwnPropertyDescriptor(proto, key);\n if (desc?.get && !getterDescs.has(key)) getterDescs.set(key, desc);\n }\n proto = Object.getPrototypeOf(proto);\n }\n\n // `this`-proxy for getter invocations, allocated ONCE per acquisition (the\n // trap closes over the stable `trackedStateRef`, so it never needs to be\n // rebuilt per access). Redirects `this.state` to the current render's\n // tracking proxy so getter reads during JSX record paths; outside render\n // `trackedStateRef.current` is null and it falls through to live state.\n // The receiver `r` (this proxy) is threaded through Reflect.get so chained\n // getter calls (getters reading other getters) stay in tracked context.\n const thisProxy = new Proxy(instance as object, {\n get(t, k, r) {\n if (k === 'state') return trackedStateRef.current ?? Reflect.get(t, k, r);\n const value = Reflect.get(t, k, r);\n // A branded dep handle read off `this` (e.g. `this.price`) is routed\n // through onDepHandle so the consumer's session can wrap `.track()`.\n if (\n onDepHandle !== undefined &&\n (typeof value === 'function' || typeof value === 'object') &&\n value !== null &&\n (value as Record<symbol, unknown>)[DEP_BRAND] !== undefined\n ) {\n return onDepHandle(value as object);\n }\n return value;\n },\n });\n\n // Stable proxy: one allocation per bloc acquisition. Non-getter access is\n // a single Map lookup + Reflect.get — no prototype walk on the hot path.\n const proxy = new Proxy(instance as object, {\n get(target, key, receiver) {\n const desc = getterDescs.get(key);\n if (desc?.get) return desc.get.call(thisProxy);\n return Reflect.get(target, key, receiver);\n },\n }) as T;\n\n return { proxy, thisProxy: thisProxy as T };\n}\n","import {\n useEffect,\n useLayoutEffect,\n useMemo,\n useReducer,\n useRef,\n type RefObject,\n} from 'react';\nimport {\n DEP_BRAND,\n getRegistry,\n isIsolatedClass,\n resolveInstanceKey,\n type ExtractArgs,\n type ExtractState,\n type InstanceState,\n type StateContainer,\n type StateContainerConstructor,\n} from '@blac/core';\nimport {\n ALL_PATHS,\n emptyPathSet,\n pathSetEquals,\n trackRender,\n PathInterner,\n ProxyCache,\n type PathSet,\n} from '@dirtytalk/structural';\nimport { useProvidedArgs } from './BlocProvider';\nimport { buildTrackedProxy } from './buildTrackedProxy';\nimport type { ComponentRef, UseBlocOptions, UseBlocReturn } from './types';\n\nlet nextConsumerId = 0;\n\n// Sentinel that can never `Object.is`-equal a real args value (including\n// `undefined`). Used to lazily seed args-key refs so the structural key is\n// computed only when the guard actually runs, never on every render.\nconst ARGS_UNSET: unique symbol = Symbol('blac.argsKeyUnset');\n\n/**\n * `useRef` with a lazily-built initial value. `useRef(new Map())` discards its\n * argument after the first render but still EVALUATES it on every render, so\n * each such ref costs one dead allocation per render. Passing a module-level\n * factory instead makes the per-render cost a single identity read.\n *\n * The factory MUST be a stable module-level function (an inline arrow would\n * just move the per-render allocation from the value to the closure). The\n * returned `current` is non-nullable, so read sites keep their original types.\n */\nfunction useLazyRef<T>(create: () => T): { current: T } {\n const ref = useRef<T | null>(null);\n if (ref.current === null) {\n ref.current = create();\n }\n return ref as { current: T };\n}\n\n// Stable factories for the lazy refs below — one per shape, module-level so the\n// reference passed to `useLazyRef` never changes. Each hook instance still gets\n// its OWN object: nothing here is shared between components (a shared session\n// map or ProxyCache would break per-consumer render isolation).\nconst newComponentRef = (): ComponentRef => ({});\nconst newArgsKeyEntry = (): { ref: unknown; key: string | undefined } => ({\n ref: ARGS_UNSET,\n key: undefined,\n});\nconst newSessionMap = (): Map<StateContainer, SessionEntry> => new Map();\nconst newDepSubsMap = (): Map<StateContainer, DepSub> => new Map();\nconst newDepWrapperCache = (): WeakMap<object, unknown> => new WeakMap();\nconst newProxyCache = (): ProxyCache => new ProxyCache();\n\n// Registry refId formats for a consumer's primary bloc and its tracked deps.\n// Centralised so the `acquire` and `release` sites can never drift apart — a\n// mismatch would leak the ref and keep the bloc alive past unmount.\nconst primaryRefId = (consumerId: string): string => `useBloc@${consumerId}`;\nconst depRefId = (consumerId: string): string => `useBloc@${consumerId}:dep`;\n\n/**\n * React hook that connects a component to a state container with automatic\n * re-render on state changes.\n *\n * Two tracking modes:\n * - **Auto-tracking** (default): the returned state value is a proxy that\n * records read paths during render. The component re-renders when any\n * recorded path changes. Backed by `@dirtytalk/structural`'s\n * {@link trackRender} + the container's path-scoped `DirtyChannel`.\n * - **Manual select**: pass `options.select` to opt out of auto-tracking.\n * The hook re-renders only when the returned array's elements change\n * (per-index `Object.is`).\n *\n * Lifecycle:\n * - The bloc is acquired from the registry on mount and released on\n * unmount. The instance key is derived from `options.args` (own args),\n * then the surrounding {@link BlocProvider} context args for this bloc,\n * then the default key (no args).\n * - `options.onMount` fires after the bloc is acquired; `options.onUnmount`\n * fires *before* the registry releases its ref, so the bloc is still\n * alive when the callback runs.\n *\n * Per-mount private instance:\n * ```ts\n * const id = useId();\n * const [state, bloc] = useBloc(MyBloc, { args: { _id: id } });\n * ```\n *\n * @template T - The state container constructor type (inferred from BlocClass)\n * @param BlocClass - The state container class to connect to\n * @param options - Configuration options\n * @returns Tuple of `[state, bloc, ref]`\n *\n * @example Basic usage\n * ```ts\n * const [state, bloc] = useBloc(MyBloc);\n * ```\n *\n * @example Manual select\n * ```ts\n * const [state, bloc] = useBloc(MyBloc, {\n * select: (state) => [state.count],\n * });\n * ```\n *\n * @example Args-based shared instance\n * ```ts\n * const [state, bloc] = useBloc(UserBloc, { args: { userId: 'alice' } });\n * ```\n */\nexport function useBloc<\n T extends StateContainerConstructor = StateContainerConstructor,\n>(\n BlocClass: T,\n options?: UseBlocOptions<T>,\n): UseBlocReturn<T, ExtractState<T>> {\n type TBloc = InstanceState<T>;\n\n const componentRef = useLazyRef(newComponentRef);\n\n // Stable per-consumer id (for the structural container's consumer registry).\n // Plain counter rather than `useId()` so we don't compete with internal hooks\n // for SSR id slots.\n const consumerIdRef = useRef<string | null>(null);\n if (consumerIdRef.current === null) {\n consumerIdRef.current = `useBloc-${nextConsumerId++}`;\n }\n const consumerId = consumerIdRef.current;\n\n // `isolated` is sugar over args identity: it injects a per-mount id into the\n // args used for keying. `consumerId` is already stable for this mount, so this\n // costs no extra hook slot (a `useId()` here would cost one in EVERY consumer).\n const isolated = options?.isolated === true || isIsolatedClass(BlocClass);\n // Memo dep: flips when isolation is toggled at runtime (`{ isolated: cond }`)\n // so the instance re-keys. Constant for the common case.\n const isolationKey = isolated ? consumerId : undefined;\n\n // Refs that always carry the latest option callbacks, so the commit effect\n // can read them without re-keying.\n const selectRef = useRef(options?.select);\n selectRef.current = options?.select;\n const isSelectMode = options?.select !== undefined;\n const onMountRef = useRef(options?.onMount);\n onMountRef.current = options?.onMount;\n const onUnmountRef = useRef(options?.onUnmount);\n onUnmountRef.current = options?.onUnmount;\n const isolatedRef = useRef(isolated);\n isolatedRef.current = isolated;\n\n // ---------------------------------------------------------------------------\n // Identity resolution\n //\n // Priority: own args > provider args (for this bloc class) > none.\n //\n // Args are user-supplied; callers commonly pass a fresh object literal each\n // render. Memoising on `args` directly would bust every render. We compute a\n // structural key (JSON.stringify) for the useMemo dep instead — undefined\n // args (void-args blocs) collapse to an undefined key.\n // ---------------------------------------------------------------------------\n const ownArgs = (options as { args?: ExtractArgs<T> } | undefined)?.args;\n const ownArgsRef = useRef(ownArgs);\n ownArgsRef.current = ownArgs;\n // Fast-path: only recompute the structural key when the args REFERENCE\n // changes. Callers commonly pass a stable args object (e.g. memoised or\n // module-level), so this skips a JSON.stringify call every render.\n const ownArgsKeyRef = useLazyRef(newArgsKeyEntry);\n if (!Object.is(ownArgsKeyRef.current.ref, ownArgs)) {\n ownArgsKeyRef.current = {\n ref: ownArgs,\n key: ownArgs === undefined ? undefined : JSON.stringify(ownArgs),\n };\n }\n const ownArgsKey = ownArgsKeyRef.current.key;\n\n // Read provided args from the nearest BlocProvider for this bloc class.\n const providerArgs = useProvidedArgs(BlocClass);\n const providerArgsRef = useRef(providerArgs);\n providerArgsRef.current = providerArgs;\n // Same reference fast-path as ownArgsKey above.\n const providerArgsKeyRef = useLazyRef(newArgsKeyEntry);\n if (!Object.is(providerArgsKeyRef.current.ref, providerArgs)) {\n providerArgsKeyRef.current = {\n ref: providerArgs,\n key:\n providerArgs === undefined ? undefined : JSON.stringify(providerArgs),\n };\n }\n const providerArgsKey = providerArgsKeyRef.current.key;\n\n // Current render's tracking proxy. Declared before the memo so the stable\n // ref object can be passed to buildTrackedProxy at acquisition time. The\n // proxy trap only reads `.current` at invocation time (not during creation),\n // so it is safe to pass on first mount even though the value is null.\n // Populated during each auto-track render snapshot (below); cleared to null\n // by useLayoutEffect after commit.\n const trackedStateRef = useRef<unknown>(null);\n\n // ---------------------------------------------------------------------------\n // Per-consumer cross-bloc session.\n //\n // Each render rebuilds a map of every container this consumer is currently\n // interested in. The PRIMARY bloc is the first uniform entry; every dep\n // reached through `this.<handle>.track()` inside a tracked getter adds an\n // entry. The layout-effect reconcile (below) diffs this map vs the previous\n // render to subscribe new deps and release dropped ones. The session lives in\n // this hook's refs only — there is no global ambient state, so sibling\n // renders never cross-contaminate.\n // ---------------------------------------------------------------------------\n const sessionRef = useLazyRef(newSessionMap);\n // Channel subscriptions for DEP containers (the primary keeps its own\n // dedicated effect). container -> { unsubscribe, interestRef, refId held }.\n const depSubsRef = useLazyRef(newDepSubsMap);\n // Per-handle wrapper cache, allocated once per bloc acquisition (in the memo)\n // so wrappers are stable across renders. handle -> session-bound wrapper.\n const depWrapperCacheRef = useLazyRef(newDepWrapperCache);\n const proxyCacheRef = useLazyRef(newProxyCache);\n // Snapshot of the last FULL reconcile's shape, used by the layout-effect\n // below to short-circuit when nothing actually changed. `null` means \"no\n // prior full run to compare against\" (first commit, or the previous commit\n // was in select-mode) — always forces a full reconcile in that case.\n const lastReconcileRef = useRef<ReconcileSignature | null>(null);\n\n // Rebind nonce: bumped by the ownership layout-effect when the instance the\n // render captured was disposed + recreated out from under us. This happens on\n // a same-commit ownership handoff of a shared (non-keepAlive) key — the sole\n // prior owner's effect cleanup releases refs→0 and SYNCHRONOUSLY disposes the\n // instance before this consumer's layout setup re-acquires (creating a fresh\n // one) — and equivalently under StrictMode's setup→cleanup→setup double-invoke\n // for a lone owner. Threaded into the memo deps so bumping it re-ensures `bloc`\n // against the LIVE registry entry instead of the disposed instance.\n const [rebindNonce, bumpRebind] = useReducer((x: number) => x + 1, 0);\n // The live instance actually owned (ref held) by the ownership layout-effect,\n // read by its cleanup so onUnmount always fires with the owned instance.\n const ownedBlocRef = useRef<TBloc | null>(null);\n\n const { bloc, instanceKey, trackedBloc } = useMemo<{\n bloc: TBloc;\n instanceKey: string;\n trackedBloc: TBloc;\n }>(() => {\n // Own args win over provider args; provider args win over no args.\n const baseArgs =\n ownArgsRef.current !== undefined\n ? ownArgsRef.current\n : providerArgsRef.current;\n // Isolation MERGES with resolved args rather than losing to them (plan Q2=B):\n // a class-level `static isolated` must not be silently cancelled by a call\n // site that happens to pass args.\n // The cast is the point of the documented behavior note: the injected\n // `_blacIsolated` key is NOT part of the bloc's declared `Args` type, but it\n // does reach `init(args)` — exactly as the `{ args: { _id: useId() } }`\n // idiom this replaces always did.\n const effectiveArgs = isolatedRef.current\n ? (withIsolation(baseArgs, consumerId) as ExtractArgs<T>)\n : baseArgs;\n\n const resolvedKey = resolveInstanceKey(BlocClass, effectiveArgs);\n const registry = getRegistry();\n // Render only ENSUREs the instance exists (no ref). Ownership is claimed in\n // the layout effect below, so an abandoned/uncommitted render can never\n // leak a ref and a memo re-run can never double-count one (R3/R4).\n const instance = registry.acquire(BlocClass, resolvedKey, {\n canCreate: true,\n countRef: false,\n args: effectiveArgs,\n }) as TBloc;\n\n // Build a session-bound wrapper for a dep handle the first time a getter\n // reads it off `this`; cache per handle so the wrapper identity is stable.\n // `onDepHandle` is threaded into each dep's tracked proxy too, so a nested\n // `this.<otherHandle>.track()` inside a dep's getter records into the SAME\n // consumer session — that is what makes deep chains (A→B→C) reactive.\n const onDepHandle = (handle: object): unknown => {\n const cache = depWrapperCacheRef.current;\n const cached = cache.get(handle);\n if (cached !== undefined) return cached;\n const wrapper = makeDepWrapper(\n handle as DepHandleLike,\n consumerId,\n trackedStateRef,\n sessionRef,\n onDepHandle,\n );\n cache.set(handle, wrapper);\n return wrapper;\n };\n\n const { proxy } = buildTrackedProxy(\n instance as object,\n trackedStateRef,\n onDepHandle,\n );\n\n return {\n bloc: instance,\n instanceKey: resolvedKey,\n trackedBloc: proxy as TBloc,\n };\n // oxlint-disable-next-line react-hooks/exhaustive-deps\n }, [BlocClass, ownArgsKey, providerArgsKey, isolationKey, rebindNonce]);\n\n // ---------------------------------------------------------------------------\n // Channel subscription\n //\n // We talk directly to `bloc.channel` — the StructuralContainer's path-scoped\n // DirtyChannel. Subscribing with a dynamic interest function lets us narrow\n // wakeups per consumer, so a component only re-renders when a path it\n // actually read changes (rather than on every state change).\n // ---------------------------------------------------------------------------\n const [, force] = useReducer((x: number) => x + 1, 0);\n const pathRef = useLazyRef(emptyPathSet);\n // Expanded interest: leaf paths PLUS *ancestor-watch* ids for their parents,\n // so that when `patch` atomically replaces a parent (e.g. the array 'items')\n // and the consumer tracked a child (e.g. 'items.length'), the intersection\n // still fires — while a structural pulse-up of a plain-object parent (e.g.\n // 'user' when a sibling 'user.name' changed) does NOT, because pulse-up marks\n // are normal ids and ancestor-watch ids only intersect their own lane.\n // Updated in useLayoutEffect after each render once pathRef.current is\n // populated.\n const expandedInterestRef = useLazyRef(emptyPathSet);\n // For select-mode: cache the last selected array so we can compare against\n // the next one before forcing a re-render.\n const lastSelectionRef = useRef<unknown[] | null>(null);\n // Render-time raw-state snapshot, seeded each render (below) and read by the\n // subscription effect to close the mount gap (R2): an emit landing between the\n // render read and the passive subscribe would otherwise be lost.\n const renderStateRef = useRef<unknown>(undefined);\n // Bloc identity from the previous render, so select-mode can reset its cached\n // selection when the underlying instance changes (re-key).\n const prevBlocRef = useRef<unknown>(null);\n\n useEffect(() => {\n // Subscribe via the channel directly. For auto-track we re-register the\n // current path interest on each commit (below); for select-mode we use\n // ALL_PATHS and compare selections in the callback.\n //\n // Self-healing note: this effect can run once against a pre-rebind (possibly\n // disposed) `bloc` before the ownership layout-effect's nonce bump triggers a\n // re-render that swaps `bloc` to the live instance (the memo dep array\n // includes `rebindNonce`). That's benign — `channel.subscribe` and\n // `unregisterConsumer` are plain Map operations that don't check disposal\n // state and never throw on a disposed container — so this effect's cleanup\n // runs cleanly and the re-render re-subscribes against the live instance.\n const channel = (bloc as unknown as StateContainer).channel;\n\n if (isSelectMode) {\n const unsub = channel.subscribe(\n () => ALL_PATHS,\n () => {\n const select = selectRef.current;\n if (!select) {\n force();\n return;\n }\n const next = select(\n (bloc as unknown as StateContainer).state as ExtractState<T>,\n bloc as InstanceState<T>,\n );\n const prev = lastSelectionRef.current;\n if (prev !== null && shallowArrayEqual(prev, next)) return;\n lastSelectionRef.current = next;\n force();\n },\n );\n // Close the mount gap (R2): an emit between the render's selector seed and\n // this subscribe would be lost. Recompute against LIVE state and force if\n // the selection advanced.\n const select = selectRef.current;\n if (select) {\n const next = select(\n (bloc as unknown as StateContainer).state as ExtractState<T>,\n bloc as InstanceState<T>,\n );\n const prev = lastSelectionRef.current;\n if (prev === null || !shallowArrayEqual(prev, next)) {\n lastSelectionRef.current = next;\n force();\n }\n }\n return unsub;\n }\n\n // Auto-track mode: subscribe with the expanded interest (leaf paths + their\n // ancestors). This ensures that when `patch` marks a parent path (e.g.\n // 'items') and the consumer tracked a child (e.g. 'items.length'), the\n // channel intersection still fires a re-render.\n //\n // `expandedInterestRef.current` is updated by the useLayoutEffect below\n // after each render, so the interest is always fresh at flush time.\n const unsub = channel.subscribe(\n () => expandedInterestRef.current,\n () => force(),\n );\n // Register the consumer's leaf paths with the container for skeleton\n // recomputation, so the source-side diff can skip us when our paths don't\n // intersect the change.\n (bloc as unknown as StateContainer).registerConsumerPaths(\n consumerId,\n pathRef.current,\n );\n // Close the mount gap (R2): if the container's state advanced between the\n // render snapshot (renderStateRef) and this subscribe, the emit was lost —\n // force one re-render so we don't stay stale.\n if ((bloc as unknown as StateContainer).state !== renderStateRef.current) {\n force();\n }\n return () => {\n unsub();\n (bloc as unknown as StateContainer).unregisterConsumer(consumerId);\n };\n // oxlint-disable-next-line react-hooks/exhaustive-deps\n }, [bloc, consumerId, isSelectMode]);\n\n // ---------------------------------------------------------------------------\n // Ownership + mount / unmount lifecycle.\n //\n // The ownership ref is claimed HERE (a layout effect), not in the render/memo,\n // so acquire and release are perfectly paired: a memo re-run can no longer\n // double-count (R3) and an uncommitted render can no longer leak (R4). Keyed\n // on [BlocClass, instanceKey, consumerId] — NOT `bloc` — so a genuine re-key\n // (args change, OR a BlocClass swap) releases the old ref and acquires the new\n // one, while a pure rebind (same class+key, instance replaced under us) does\n // NOT re-run this effect and therefore never releases the single ref we just\n // took (which, for a sole owner, would synchronously dispose the\n // freshly-created instance and churn indefinitely).\n //\n // `BlocClass` MUST stay in the dep array even though `instanceKey` alone often\n // determines identity: `resolveInstanceKey`/`resolveKey` collapse to the same\n // `DEFAULT_STRUCTURAL_KEY` sentinel across DIFFERENT classes when neither has\n // args nor a `static key` (e.g. `useBloc(cond ? AdminBloc : UserBloc)`). Without\n // `BlocClass` here, swapping classes at that shared key would never re-run this\n // effect: the old class's ref would leak until unmount, and the new class's\n // instance (only `ensure`d by the render memo, countRef:false) would be held\n // with zero ownership refs — exposed to disposal by unrelated traffic on that\n // class+key, with no rebind path to recover it.\n //\n // `acquire` returns the authoritative LIVE instance for the key. If it differs\n // from the instance the render captured (`bloc`), the render read a disposed /\n // replaced instance (same-commit shared-key handoff, or StrictMode remount);\n // we bump the rebind nonce so the memo re-ensures `bloc` against this live one.\n // onMount/onUnmount fire with the owned live instance and stay co-located with\n // acquire/release so onUnmount(bloc) runs BEFORE release(...) within one\n // cleanup, keeping the instance alive while the callback runs.\n // ---------------------------------------------------------------------------\n useLayoutEffect(() => {\n const registry = getRegistry();\n const live = registry.acquire(BlocClass, instanceKey, {\n canCreate: true,\n countRef: true,\n refId: primaryRefId(consumerId),\n }) as TBloc;\n ownedBlocRef.current = live;\n onMountRef.current?.(live as InstanceType<T>);\n // Rebind if the render captured a stale (disposed/replaced) instance so the\n // component renders + subscribes against the live registry entry, not a\n // disposed one. Only bumps on an actual mismatch, so it can fire at most once\n // per handoff and never loops (the re-ensured `bloc` equals `live`, and this\n // effect is not keyed on `bloc` so it won't re-run and re-release).\n if (live !== bloc) {\n bumpRebind();\n }\n return () => {\n onUnmountRef.current?.((ownedBlocRef.current ?? bloc) as InstanceType<T>);\n registry.release(BlocClass, instanceKey, false, primaryRefId(consumerId));\n };\n // oxlint-disable-next-line react-hooks/exhaustive-deps\n }, [BlocClass, instanceKey, consumerId]);\n\n // ---------------------------------------------------------------------------\n // Snapshot\n //\n // - Auto-track: wrap state in trackRender, record paths into pathRef, and\n // re-register with the container so the skeleton picks up new interest.\n // - Select-mode: return state directly; the subscription callback compares\n // selections to decide whether to re-render.\n // ---------------------------------------------------------------------------\n const rawState = (bloc as unknown as StateContainer).state as ExtractState<T>;\n // Seed the render-time snapshot so the subscription effect can detect an emit\n // that landed in the render→subscribe window (R2 mount gap).\n renderStateRef.current = rawState;\n // Reset the cached selection when the bloc identity changes (re-key) so the\n // select seed below re-seeds against the NEW instance instead of comparing\n // against a stale selection from the previous instance.\n if (prevBlocRef.current !== bloc) {\n prevBlocRef.current = bloc;\n lastSelectionRef.current = null;\n }\n let state: ExtractState<T>;\n if (selectRef.current !== undefined) {\n state = rawState;\n // Seed the last selection on the first render so we don't fire an\n // immediate \"different from null\" wakeup on the first emit.\n if (lastSelectionRef.current === null) {\n lastSelectionRef.current = selectRef.current(\n rawState,\n bloc as InstanceState<T>,\n );\n }\n } else {\n const tracked = trackRender(\n rawState,\n (bloc as unknown as StateContainer).interner,\n proxyCacheRef.current,\n );\n state = tracked.value as ExtractState<T>;\n trackedStateRef.current = tracked.value;\n pathRef.current = tracked.paths;\n // Rebuild the per-consumer session for this render. The primary bloc is the\n // first uniform entry; its `paths` are the SAME PathSet object the proxy\n // mutates during JSX (so it stays live as getters record leaves). Dep\n // entries are appended during JSX as `this.<handle>.track()` runs. Cleared\n // here (not in the layout effect) so a render that no longer tracks a dep\n // produces a session without it, and the reconcile drops it.\n const session = sessionRef.current;\n session.clear();\n session.set(bloc as unknown as StateContainer, {\n kind: 'primary',\n paths: tracked.paths,\n });\n // Freeze this render's tracking proxy after the synchronous render+commit\n // pass. The microtask fires only once the current task unwinds, so all\n // render-time JSX reads still record; reads afterwards — effects, event\n // handlers, async callbacks, devtools inspecting `state` — hit the\n // disarmed proxy and record nothing, so this render's path set can't be\n // polluted by work that outlives the render that owns it.\n queueMicrotask(tracked.disarm);\n // NOTE: registerConsumerPaths is intentionally NOT called here. The\n // proxy hasn't been accessed yet, so `tracked.paths` is an empty Set\n // that the proxy will mutate during JSX evaluation. Registering at\n // this point would store an empty interest with the container and\n // freeze the skeleton at that snapshot — subsequent emits would\n // diff against an empty skeleton and silently drop wakeups. The\n // useLayoutEffect below registers the populated set after render.\n }\n\n // After the render commits, pathRef.current is the consumer's actual leaf\n // interest (populated by the proxy during JSX evaluation). Re-register with\n // the container so the skeleton reflects the latest paths, and expand the\n // interest to include ancestor paths for the channel subscription.\n //\n // useLayoutEffect runs before the browser paints (and before any emit\n // triggered by another effect), so the skeleton is fresh by the time the\n // next emit fires.\n // oxlint-disable-next-line react-hooks/exhaustive-deps\n useLayoutEffect(() => {\n // Clear the render-time tracking proxy now that JSX has been evaluated and\n // committed. Getters invoked after this point (event handlers, effects,\n // method→getter chains) fall through to live state instead of reading this\n // render's frozen snapshot. The render body re-seeds it next render.\n trackedStateRef.current = null;\n if (selectRef.current !== undefined) {\n // Switching into (or staying in) select-mode: invalidate any prior full\n // reconcile so a later switch back to auto-track mode never mistakes a\n // stale signature for \"unchanged\" and skips a needed reconcile.\n lastReconcileRef.current = null;\n return;\n }\n const container = bloc as unknown as StateContainer;\n const paths = pathRef.current;\n const session = sessionRef.current;\n\n // ---------------------------------------------------------------------\n // Short-circuit: if the primary path set AND the full dep session are\n // set-equal (paths + key/refId/args) to the last FULL reconcile, none of\n // registerConsumerPaths/subscribe/unsubscribe/expandWithAncestors below\n // can have anything new to do — skip the whole block. Any mismatch, or\n // `lastReconcileRef.current === null` (first commit, or the immediately\n // preceding commit was select-mode / uncertain), falls through to the\n // full reconcile. Never skip on uncertainty — a missed re-subscribe would\n // leave a stale/dropped subscription.\n // ---------------------------------------------------------------------\n const last = lastReconcileRef.current;\n if (\n last !== null &&\n last.primaryContainer === container &&\n pathSetEquals(last.primaryPaths, paths)\n ) {\n let unchanged = last.deps.size === session.size - 1;\n if (unchanged) {\n for (const [depContainer, entry] of session) {\n if (entry.kind === 'primary') continue;\n const prevEntry = last.deps.get(depContainer);\n if (\n prevEntry === undefined ||\n prevEntry.key !== entry.key ||\n prevEntry.refId !== entry.refId ||\n !Object.is(prevEntry.args, entry.args) ||\n !pathSetEquals(prevEntry.paths, entry.paths)\n ) {\n unchanged = false;\n break;\n }\n }\n }\n if (unchanged) return;\n }\n\n container.registerConsumerPaths(consumerId, paths);\n // Register the *normal* leaf paths above for the source-side skeleton, then\n // build the channel interest as leaves + ancestor-watch ids so that an\n // atomic `patch` replacement of a parent (e.g. the array 'items') wakes a\n // consumer that tracked a child (e.g. 'items.length').\n expandedInterestRef.current = expandWithAncestors(\n paths,\n container.interner,\n );\n\n // -----------------------------------------------------------------------\n // Reconcile DEP containers (cross-bloc `.track()` interest).\n //\n // The primary bloc keeps its own dedicated subscription effect above; this\n // block manages only the *dep* containers recorded in the session this\n // render. We diff the new dep set vs the previously-subscribed set:\n // - new dep -> acquire was already done in `.track()`; subscribe its\n // channel + registerConsumerPaths + seed interest.\n // - surviving -> refresh its interest ref (subscribe closure reads it).\n // - dropped -> unsubscribe, unregisterConsumer, and release its ref.\n // -----------------------------------------------------------------------\n const subs = depSubsRef.current;\n\n // Pass 1: drop containers no longer in the session.\n for (const [depContainer, sub] of subs) {\n if (!session.has(depContainer)) {\n sub.unsubscribe();\n depContainer.unregisterConsumer(consumerId);\n getRegistry().release(sub.Type, sub.key, false, sub.refId);\n subs.delete(depContainer);\n }\n }\n\n // Pass 2: add/refresh containers in the session (skip the primary).\n for (const [depContainer, entry] of session) {\n if (entry.kind === 'primary') continue;\n const interest = expandWithAncestors(entry.paths, depContainer.interner);\n depContainer.registerConsumerPaths(consumerId, entry.paths);\n const existing = subs.get(depContainer);\n if (existing) {\n existing.interestRef.current = interest;\n continue;\n }\n // First commit that sees this dep: take the ownership ref HERE (not in\n // render/`.track()`), so an uncommitted render can never leak it (R4).\n getRegistry().acquire(entry.Type, entry.key, {\n canCreate: true,\n countRef: true,\n refId: entry.refId,\n args: entry.args,\n });\n const interestRef: { current: PathSet } = { current: interest };\n const unsubscribe = depContainer.channel.subscribe(\n () => interestRef.current,\n () => force(),\n );\n subs.set(depContainer, {\n unsubscribe,\n interestRef,\n Type: entry.Type,\n key: entry.key,\n refId: entry.refId,\n args: entry.args,\n });\n }\n\n // Capture this full reconcile's shape for the NEXT commit's short-circuit\n // check above. `paths`/`entry.paths` are fresh Sets seeded this render\n // (trackRender/unionPaths always allocate new Sets, never mutate one from\n // a prior render) — safe to keep direct references without cloning.\n const depsSignature = new Map<StateContainer, ReconcileDepSignature>();\n for (const [depContainer, entry] of session) {\n if (entry.kind === 'primary') continue;\n depsSignature.set(depContainer, {\n paths: entry.paths,\n key: entry.key,\n refId: entry.refId,\n args: entry.args,\n });\n }\n lastReconcileRef.current = {\n primaryContainer: container,\n primaryPaths: paths,\n deps: depsSignature,\n };\n });\n\n // Unmount: tear down every dep subscription + ref exactly once. Kept in its\n // own effect (consumerId is stable for the component's lifetime, so this only\n // runs on final unmount, not on every reconcile). depSubsRef is mutated in\n // place by the reconcile, so the captured Map reference still holds the live\n // set at unmount.\n useEffect(() => {\n // Capture the ref's Map (mutated in place across renders) so the cleanup\n // reads the captured reference rather than depSubsRef.current directly.\n const subs = depSubsRef.current;\n return () => {\n for (const [depContainer, sub] of subs) {\n sub.unsubscribe();\n depContainer.unregisterConsumer(consumerId);\n getRegistry().release(sub.Type, sub.key, false, sub.refId);\n }\n subs.clear();\n };\n // `depSubsRef` is a stable ref object (its identity never changes), so\n // listing it keeps this effect an unmount-only effect while satisfying\n // exhaustive-deps — the lint rule can't tell `useLazyRef` returns a ref.\n }, [consumerId, depSubsRef]);\n\n return [\n state,\n trackedBloc,\n componentRef as RefObject<ComponentRef>,\n ] as UseBlocReturn<T, ExtractState<T>>;\n}\n\n// ---------------------------------------------------------------------------\n// Cross-bloc session types + dep-handle wrapper.\n// ---------------------------------------------------------------------------\n\n/**\n * One entry in a consumer's per-render session map. Discriminated on `kind`:\n * the primary bloc is managed by its own dedicated effect, while dep entries\n * carry the registry coordinates the reconcile needs to release their ref.\n */\ntype SessionEntry =\n | {\n kind: 'primary';\n /** Tracked leaf paths recorded against the primary this render. */\n paths: PathSet;\n }\n | {\n kind: 'dep';\n /** Tracked leaf paths recorded against this dep this render. */\n paths: PathSet;\n /** Constructor for registry release. */\n Type: StateContainerConstructor;\n /** Resolved instance key for registry release. */\n key: string;\n /** refId held for this dep (released on drop/unmount). */\n refId: string;\n /** Construction args, used by the reconcile pass to acquire the ref. */\n args: unknown;\n };\n\n/** A live dep-channel subscription tracked between renders for reconciliation. */\ninterface DepSub {\n unsubscribe: () => void;\n interestRef: { current: PathSet };\n Type: StateContainerConstructor;\n key: string;\n refId: string;\n args: unknown;\n}\n\n/**\n * Snapshot of one dep's shape from the last FULL reconcile, compared against\n * the current session entry to decide whether the dep-reconcile layout effect\n * can short-circuit (see `lastReconcileRef` in `useBloc`).\n */\ninterface ReconcileDepSignature {\n paths: PathSet;\n key: string;\n refId: string;\n args: unknown;\n}\n\n/** Snapshot of the last FULL dep-reconcile layout effect run. */\ninterface ReconcileSignature {\n /** The primary container this signature was captured against (identity\n * check — a rebind/re-key swaps this even if the tracked paths happen to\n * be textually identical, and must never be mistaken for \"unchanged\"). */\n primaryContainer: StateContainer;\n primaryPaths: PathSet;\n deps: Map<StateContainer, ReconcileDepSignature>;\n}\n\n/** Per-access options shared by both dep accessors. */\ninterface DepAccessOptionsLike {\n args?: unknown;\n}\n\n/** Structural shape of a branded `depend()` handle as seen from React. */\ninterface DepHandleLike {\n track(options?: DepAccessOptionsLike): [unknown, StateContainer];\n untracked(options?: DepAccessOptionsLike): StateContainer;\n readonly [DEP_BRAND]: {\n Type: StateContainerConstructor;\n defaultArgs?: unknown;\n };\n}\n\n/**\n * Build the per-consumer wrapper that replaces a branded dep handle inside a\n * tracked getter's `this`. The wrapper exposes the same accessors as the core\n * handle and overrides `.track()`:\n *\n * - **Inside a render** (`trackedStateRef.current != null`): resolve (ENSURE,\n * no ref) the dep, `trackRender` its state, merge the recorded paths into the\n * session entry, build/reuse a tracked proxy for the dep so its OWN getters\n * track too, and return `[trackedValue, depProxy]`. The ownership ref is taken\n * by the layout-effect reconcile pass, not here.\n * - **Outside a render**: degrade to live `[dep.state, dep]` — matches the core\n * base impl, safe in event handlers/effects/methods.\n *\n * `.untracked()` always returns the live instance with no subscription.\n *\n * Args resolve at call time (`options.args ?? defaultArgs`), so a single handle\n * can resolve different dep instances across calls; tracked-proxy state is\n * therefore cached per resolved instance, not per handle. Guards against a\n * container re-entering tracking within the same render (mutual A↔B deps): if\n * the dep already has a non-primary session entry this render, reuse its proxy\n * + union its paths instead of re-acquiring.\n */\nfunction makeDepWrapper(\n handle: DepHandleLike,\n consumerId: string,\n trackedStateRef: { current: unknown },\n sessionRef: { current: Map<StateContainer, SessionEntry> },\n onDepHandle: (handle: object) => unknown,\n): DepHandleLike {\n const brand = handle[DEP_BRAND];\n const refId = depRefId(consumerId);\n const registry = getRegistry();\n // Per-resolved-instance tracked-state ref + proxy. Call-time args mean one\n // handle can resolve several instances, so cache is keyed by the instance.\n const perDep = new WeakMap<\n StateContainer,\n { ref: { current: unknown }; proxy: StateContainer }\n >();\n // One ProxyCache shared across every instance this handle resolves to\n // (call-time args can resolve different dep instances across calls) — safe\n // because ProxyCache's internal map is keyed by target object identity, so\n // unrelated instances' objects never collide in it.\n const proxyCache = new ProxyCache();\n // One-entry identity cache: skip re-hashing the structural key\n // (`resolveKey` -> `structuralKey` -> JSON.stringify) when `args` is the\n // same reference as last call — callers almost always pass a\n // reference-stable `args` (`options?.args ?? brand.defaultArgs`). Only the\n // derived key is cached; `ensure` still runs every call so a\n // disposed-and-recreated entry is always picked up.\n let lastArgs: unknown = ARGS_UNSET;\n let lastKey = '';\n\n const resolve = (options?: DepAccessOptionsLike) => {\n const args = options?.args ?? brand.defaultArgs;\n // `ARGS_UNSET` is module-private, so no caller's `args` can equal it — the\n // first call always misses and seeds `lastKey` before it is ever read.\n if (!Object.is(lastArgs, args)) {\n lastArgs = args;\n lastKey = registry.resolveKey(brand.Type, undefined, args);\n }\n const key = lastKey;\n const dep = registry.ensure(\n brand.Type,\n key,\n args,\n ) as unknown as StateContainer;\n return { dep, key, args };\n };\n\n const wrapper = {\n untracked: (options?: DepAccessOptionsLike) => resolve(options).dep,\n track: (options?: DepAccessOptionsLike) => {\n const { dep, key, args } = resolve(options);\n\n // Outside a render: live values, no subscription (core base behavior).\n if (trackedStateRef.current == null) {\n return [dep.state, dep];\n }\n\n const session = sessionRef.current;\n const existing = session.get(dep);\n\n // Render only ENSUREs the dep instance (via `resolve()` above); it does\n // NOT take a ref. Ownership is claimed by the layout-effect reconcile\n // pass-2 the first commit it sees this dep, and released on drop/unmount.\n // This keeps acquire/release paired so an uncommitted render can't leak a\n // dep ref (R4).\n\n const tracked = trackRender(dep.state, dep.interner, proxyCache);\n let cache = perDep.get(dep);\n if (cache === undefined) {\n const ref = { current: tracked.value as unknown };\n cache = { ref, proxy: buildTrackedProxy(dep, ref, onDepHandle).proxy };\n perDep.set(dep, cache);\n } else {\n cache.ref.current = tracked.value;\n }\n\n if (existing !== undefined) {\n // Re-entry this render (`.track()` twice, or a mutual cycle): union the\n // new paths into the existing entry rather than re-acquiring.\n existing.paths = unionPaths(existing.paths, tracked.paths);\n } else {\n session.set(dep, {\n kind: 'dep',\n paths: tracked.paths,\n Type: brand.Type,\n key,\n refId,\n args,\n });\n }\n\n return [tracked.value, cache.proxy];\n },\n } as DepHandleLike;\n\n Object.defineProperty(wrapper, DEP_BRAND, {\n value: brand,\n enumerable: false,\n writable: false,\n configurable: false,\n });\n\n return wrapper;\n}\n\n/** Union two PathSets (ALL_PATHS dominates). */\nfunction unionPaths(a: PathSet, b: PathSet): PathSet {\n if (a === ALL_PATHS || b === ALL_PATHS) return ALL_PATHS;\n const out = new Set<number>(a as Set<number>);\n for (const id of b as Set<number>) out.add(id);\n return out;\n}\n\n/**\n * Fold a per-mount isolation id into `args`. MERGES rather than replaces\n * (plan Q2=B): a class-level `static isolated` must not be silently cancelled\n * by a call site that happens to pass args. Non-object args are nested rather\n * than spread, so a primitive arg can't be destructured into index keys.\n */\nconst withIsolation = (base: unknown, id: string): object =>\n base === undefined\n ? { _blacIsolated: id }\n : typeof base === 'object' && base !== null\n ? { ...base, _blacIsolated: id }\n : { _blacArgs: base, _blacI