From 658c26420556d72764ff2546af7fd8895cd34d34 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 18 Jul 2026 22:51:03 -0700 Subject: [PATCH 1/4] add experimental conditional hooks runtime --- packages/bippy/package.json | 10 + packages/bippy/src/conditional-hooks.ts | 968 ++++++++++++++++ packages/bippy/src/index.ts | 1 + packages/bippy/src/types.ts | 2 +- .../conditional-hooks-adversarial.test.tsx | 397 +++++++ .../conditional-hooks-edge-cases.test.tsx | 315 +++++ .../conditional-hooks-react-upstream.test.tsx | 1009 +++++++++++++++++ .../tests/conditional-hooks-stress.test.tsx | 563 +++++++++ .../bippy/tests/conditional-hooks.test.tsx | 197 ++++ packages/bippy/vite.config.ts | 1 + .../conditional-hooks-playground/.gitignore | 2 + .../conditional-hooks-playground/index.html | 13 + .../conditional-hooks-playground/package.json | 22 + .../conditional-hooks-playground/src/app.tsx | 253 +++++ .../conditional-hooks-playground/src/main.ts | 9 + .../src/styles.css | 506 +++++++++ .../tsconfig.json | 13 + .../vite.config.ts | 9 + pnpm-lock.yaml | 28 + 19 files changed, 4317 insertions(+), 1 deletion(-) create mode 100644 packages/bippy/src/conditional-hooks.ts create mode 100644 packages/bippy/tests/conditional-hooks-adversarial.test.tsx create mode 100644 packages/bippy/tests/conditional-hooks-edge-cases.test.tsx create mode 100644 packages/bippy/tests/conditional-hooks-react-upstream.test.tsx create mode 100644 packages/bippy/tests/conditional-hooks-stress.test.tsx create mode 100644 packages/bippy/tests/conditional-hooks.test.tsx create mode 100644 packages/conditional-hooks-playground/.gitignore create mode 100644 packages/conditional-hooks-playground/index.html create mode 100644 packages/conditional-hooks-playground/package.json create mode 100644 packages/conditional-hooks-playground/src/app.tsx create mode 100644 packages/conditional-hooks-playground/src/main.ts create mode 100644 packages/conditional-hooks-playground/src/styles.css create mode 100644 packages/conditional-hooks-playground/tsconfig.json create mode 100644 packages/conditional-hooks-playground/vite.config.ts diff --git a/packages/bippy/package.json b/packages/bippy/package.json index af9aafea..68fc99aa 100644 --- a/packages/bippy/package.json +++ b/packages/bippy/package.json @@ -59,6 +59,16 @@ "default": "./dist/core.cjs" } }, + "./conditional-hooks": { + "import": { + "types": "./dist/conditional-hooks.d.ts", + "default": "./dist/conditional-hooks.js" + }, + "require": { + "types": "./dist/conditional-hooks.d.cts", + "default": "./dist/conditional-hooks.cjs" + } + }, "./install-hook-only": { "import": { "types": "./dist/install-hook-only.d.ts", diff --git a/packages/bippy/src/conditional-hooks.ts b/packages/bippy/src/conditional-hooks.ts new file mode 100644 index 00000000..07fcd137 --- /dev/null +++ b/packages/bippy/src/conditional-hooks.ts @@ -0,0 +1,968 @@ +import "./install-hook-only.js"; + +import { instrument, traverseFiber } from "./core.js"; +import { _renderers, getRDTHook, onRendererInject } from "./rdt-hook.js"; +import type { Fiber, FiberRoot, ReactRenderer } from "./types.js"; +import { toUnsubscribe, type Unsubscribe } from "./unsubscribe.js"; + +interface ConditionalHookDispatcher { + readContext?: (...arguments_: unknown[]) => unknown; + useCallback?: (...arguments_: unknown[]) => unknown; + useContext?: (...arguments_: unknown[]) => unknown; + useDebugValue?: (...arguments_: unknown[]) => unknown; + useEffect?: (...arguments_: unknown[]) => unknown; + useLayoutEffect?: (...arguments_: unknown[]) => unknown; + useMemo?: (...arguments_: unknown[]) => unknown; + useReducer?: (...arguments_: unknown[]) => unknown; + useRef?: (...arguments_: unknown[]) => unknown; + useState?: (...arguments_: unknown[]) => unknown; +} + +interface ConditionalHookDispatcherRef { + H?: ConditionalHookDispatcher | null; + current?: ConditionalHookDispatcher | null; +} + +interface ConditionalHookRuntime { + activeFiber: Fiber | null; + currentDispatcher: ConditionalHookDispatcher | null; + dispatcherKey: "H" | "current"; + dispatcherRef: ConditionalHookDispatcherRef; + getHookKey: ConditionalHookKeyResolver; + interceptReactHooks: boolean; + originalDescriptor: PropertyDescriptor | undefined; + proxyByDispatcher: WeakMap; + renderer: ReactRenderer; +} + +interface ConditionalHookScope { + cells: Map; + didCommit: boolean; + didUnmount: boolean; + effects: Map; + fiber: Fiber; + hookKinds: Map; + layoutEffectsDisconnected: boolean; + passiveEffectsDisconnected: boolean; + renderer: ReactRenderer; +} + +interface ConditionalRenderFrame { + callCounts: Map; + cells: Map; + effects: Map; + fiber: Fiber; + hookKinds: Map; + reducerActionCounts: Map; + reducerOverrides: Map unknown>; + replayedCells: Set; + renderPhaseUpdates: Map; + scope: ConditionalHookScope; +} + +interface ConditionalVisibilityState { + layoutEffectsHidden: boolean; + passiveEffectsHidden: boolean; +} + +interface ConditionalStateCell { + dispatch: (action: unknown) => void; + kind: "state"; + value: unknown; +} + +interface ConditionalReducerCell { + dispatch: (action: unknown) => void; + kind: "reducer"; + pendingActions: unknown[]; + reducer: (state: unknown, action: unknown) => unknown; + value: unknown; +} + +interface ConditionalRefCell { + kind: "ref"; + value: ConditionalRef; +} + +interface ConditionalMemoCell { + dependencies: readonly unknown[] | undefined; + kind: "memo"; + value: unknown; +} + +interface ConditionalEffectCell { + cleanup: (() => void) | undefined; + create: ConditionalEffectRegistration["create"]; + dependencies: readonly unknown[] | undefined; + kind: ConditionalEffectKind; + version: number; +} + +interface ConditionalEffectRegistration { + create: () => (() => void) | void; + dependencies: readonly unknown[] | undefined; + kind: ConditionalEffectKind; +} + +export interface ConditionalRef { + current: Value; +} + +export interface ConditionalStateSetter { + (action: State | ((previousState: State) => State)): void; +} + +export interface ConditionalReducerDispatcher { + (action: Action): void; +} + +export interface ConditionalHooksInstallation extends Unsubscribe { + readonly supportedRenderers: number; +} + +export interface ConditionalHooksOptions { + getHookKey?: ConditionalHookKeyResolver; + interceptReactHooks?: boolean; +} + +export interface ConditionalHookKeyResolver { + (hookName: string, stack: string): PropertyKey; +} + +type ConditionalHookCell = + | ConditionalMemoCell + | ConditionalReducerCell + | ConditionalRefCell + | ConditionalStateCell; + +type ConditionalEffectKind = "effect" | "layout-effect"; + +type ConditionalHookKind = ConditionalHookCell["kind"] | ConditionalEffectKind; + +const runtimes = new Set(); +const scopes = new Set(); +const runtimeByDispatcherRef = new WeakMap(); +const scopeByFiber = new WeakMap(); +const renderFrameByFiber = new WeakMap(); + +let installation: ConditionalHooksInstallation | null = null; +let scheduledUpdateVersion = 0; + +const getCurrentFiber = (renderer: ReactRenderer): Fiber | null => { + try { + return renderer.getCurrentFiber?.() ?? null; + } catch { + return null; + } +}; + +const isContextOnlyDispatcher = (dispatcher: ConditionalHookDispatcher | null): boolean => { + if (!dispatcher) return true; + return ( + typeof dispatcher.useState === "function" && + dispatcher.useState === dispatcher.useReducer && + dispatcher.useReducer === dispatcher.useRef && + dispatcher.useRef === dispatcher.useEffect + ); +}; + +const defaultHookKeyResolver: ConditionalHookKeyResolver = (hookName, stack) => { + const callsite = stack + .split("\n") + .map((line) => line.trim()) + .find( + (line) => + line.startsWith("at ") && + !line.includes("conditional-hooks") && + !line.includes("node_modules/react/") && + !line.includes("node_modules/.vite/deps/react") && + !line.includes("react.development.js") && + !line.includes("react.production.js"), + ); + if (!callsite) { + throw new Error(`Could not derive a callsite key for React.${hookName}().`); + } + return `${hookName}:${callsite}`; +}; + +const getAutomaticHookKey = (runtime: ConditionalHookRuntime, hookName: string): PropertyKey => { + const { frame } = getScope(); + const stack = new Error().stack ?? ""; + const callsiteKey = runtime.getHookKey(hookName, stack); + const occurrence = frame.callCounts.get(callsiteKey) ?? 0; + frame.callCounts.set(callsiteKey, occurrence + 1); + return `react:${String(callsiteKey)}:${occurrence}`; +}; + +const getDependencies = (value: unknown): readonly unknown[] | undefined => + Array.isArray(value) ? value : undefined; + +const createDispatcherProxy = ( + runtime: ConditionalHookRuntime, + dispatcher: ConditionalHookDispatcher, +): ConditionalHookDispatcher => + new Proxy(dispatcher, { + get: (target, property, receiver) => { + if (property === "useState") { + return (initialState: unknown) => + useConditionalState(getAutomaticHookKey(runtime, "useState"), initialState); + } + if (property === "useReducer") { + return (reducer: unknown, initialState: unknown, initialize: unknown) => { + if (typeof reducer !== "function") throw new TypeError("useReducer requires a reducer."); + const initializer = + typeof initialize === "function" ? (value: unknown) => initialize(value) : undefined; + return useConditionalReducer( + getAutomaticHookKey(runtime, "useReducer"), + (state: unknown, action: unknown) => reducer(state, action), + initialState, + initializer, + ); + }; + } + if (property === "useRef") { + return (initialValue: unknown) => + useConditionalRef(getAutomaticHookKey(runtime, "useRef"), initialValue); + } + if (property === "useMemo") { + return (create: unknown, dependencies: unknown) => { + if (typeof create !== "function") throw new TypeError("useMemo requires a function."); + return useConditionalMemo( + getAutomaticHookKey(runtime, "useMemo"), + () => create(), + getDependencies(dependencies), + ); + }; + } + if (property === "useCallback") { + return (callback: unknown, dependencies: unknown) => { + if (typeof callback !== "function") { + throw new TypeError("useCallback requires a function."); + } + return useConditionalMemo( + getAutomaticHookKey(runtime, "useCallback"), + () => callback, + getDependencies(dependencies), + ); + }; + } + if (property === "useEffect" || property === "useLayoutEffect") { + return (create: unknown, dependencies: unknown) => { + if (typeof create !== "function") { + throw new TypeError(`${property} requires a function.`); + } + registerEffect( + getAutomaticHookKey(runtime, property), + property === "useEffect" ? "effect" : "layout-effect", + () => create(), + getDependencies(dependencies), + ); + }; + } + if (property === "useContext" && typeof target.readContext === "function") { + return target.readContext; + } + if (property === "useDebugValue") return (): void => {}; + return Reflect.get(target, property, receiver); + }, + }); + +const getRuntimeDispatcher = ( + runtime: ConditionalHookRuntime, +): ConditionalHookDispatcher | null => { + const dispatcher = runtime.currentDispatcher; + if (!runtime.interceptReactHooks || !dispatcher || isContextOnlyDispatcher(dispatcher)) { + return dispatcher; + } + const existingProxy = runtime.proxyByDispatcher.get(dispatcher); + if (existingProxy) return existingProxy; + const proxy = createDispatcherProxy(runtime, dispatcher); + runtime.proxyByDispatcher.set(dispatcher, proxy); + return proxy; +}; + +const associateScopeWithFiber = (scope: ConditionalHookScope, fiber: Fiber): void => { + scope.fiber = fiber; + scopeByFiber.set(fiber, scope); + if (fiber.alternate) scopeByFiber.set(fiber.alternate, scope); +}; + +const beginRender = (runtime: ConditionalHookRuntime, fiber: Fiber): void => { + runtime.activeFiber = fiber; + const scope = + scopeByFiber.get(fiber) ?? (fiber.alternate ? scopeByFiber.get(fiber.alternate) : undefined); + if (!scope) return; + const previousFrame = renderFrameByFiber.get(fiber); + const shouldReplayStrictCells = + !scope.didCommit && (fiber.mode & 0b0001000) !== 0 && previousFrame !== undefined; + associateScopeWithFiber(scope, fiber); + renderFrameByFiber.set(fiber, { + callCounts: new Map(), + cells: shouldReplayStrictCells ? new Map(previousFrame.cells) : new Map(), + effects: new Map(), + fiber, + hookKinds: new Map(), + reducerActionCounts: new Map(), + reducerOverrides: new Map(), + replayedCells: shouldReplayStrictCells ? new Set(previousFrame.cells.keys()) : new Set(), + renderPhaseUpdates: new Map(), + scope, + }); +}; + +const handleDispatcherChange = ( + runtime: ConditionalHookRuntime, + dispatcher: ConditionalHookDispatcher | null, +): void => { + const previousDispatcher = runtime.currentDispatcher; + runtime.currentDispatcher = dispatcher; + if (isContextOnlyDispatcher(dispatcher)) { + runtime.activeFiber = null; + return; + } + const fiber = getCurrentFiber(runtime.renderer); + if (fiber && (fiber !== runtime.activeFiber || dispatcher !== previousDispatcher)) { + beginRender(runtime, fiber); + } +}; + +const restoreRuntime = (runtime: ConditionalHookRuntime): void => { + const descriptor = runtime.originalDescriptor; + if (descriptor) { + Object.defineProperty(runtime.dispatcherRef, runtime.dispatcherKey, descriptor); + runtime.dispatcherRef[runtime.dispatcherKey] = runtime.currentDispatcher; + } else { + delete runtime.dispatcherRef[runtime.dispatcherKey]; + runtime.dispatcherRef[runtime.dispatcherKey] = runtime.currentDispatcher; + } + runtimes.delete(runtime); + runtimeByDispatcherRef.delete(runtime.dispatcherRef); +}; + +const installRenderer = (renderer: ReactRenderer, options: ConditionalHooksOptions): boolean => { + if ( + typeof renderer.getCurrentFiber !== "function" || + typeof renderer.scheduleUpdate !== "function" + ) { + return false; + } + const dispatcherRef = renderer.currentDispatcherRef; + if (!dispatcherRef || typeof dispatcherRef !== "object") return false; + if (runtimeByDispatcherRef.has(dispatcherRef)) return true; + + const dispatcherKey = "H" in dispatcherRef ? "H" : "current"; + const originalDescriptor = Object.getOwnPropertyDescriptor(dispatcherRef, dispatcherKey); + if (originalDescriptor?.configurable === false) return false; + + const runtime: ConditionalHookRuntime = { + activeFiber: null, + currentDispatcher: dispatcherRef[dispatcherKey] ?? null, + dispatcherKey, + dispatcherRef, + getHookKey: options.getHookKey ?? defaultHookKeyResolver, + interceptReactHooks: options.interceptReactHooks ?? false, + originalDescriptor, + proxyByDispatcher: new WeakMap(), + renderer, + }; + + Object.defineProperty(dispatcherRef, dispatcherKey, { + configurable: true, + enumerable: originalDescriptor?.enumerable ?? true, + get: () => getRuntimeDispatcher(runtime), + set: (dispatcher: ConditionalHookDispatcher | null) => { + handleDispatcherChange(runtime, dispatcher); + }, + }); + + runtimes.add(runtime); + runtimeByDispatcherRef.set(dispatcherRef, runtime); + return true; +}; + +const getActiveRuntime = (): { fiber: Fiber; runtime: ConditionalHookRuntime } => { + for (const runtime of runtimes) { + const fiber = runtime.activeFiber ?? getCurrentFiber(runtime.renderer); + if (fiber) { + if (runtime.activeFiber !== fiber) beginRender(runtime, fiber); + return { fiber, runtime }; + } + } + throw new Error( + "Conditional hooks require a React development renderer and must be called while a component is rendering. Call installConditionalHooks() before rendering.", + ); +}; + +const getScope = (): { frame: ConditionalRenderFrame; scope: ConditionalHookScope } => { + const { fiber, runtime } = getActiveRuntime(); + let scope = + scopeByFiber.get(fiber) ?? (fiber.alternate ? scopeByFiber.get(fiber.alternate) : undefined); + if (!scope) { + scope = { + cells: new Map(), + didCommit: false, + didUnmount: false, + effects: new Map(), + fiber, + hookKinds: new Map(), + layoutEffectsDisconnected: false, + passiveEffectsDisconnected: false, + renderer: runtime.renderer, + }; + associateScopeWithFiber(scope, fiber); + } + let frame = renderFrameByFiber.get(fiber); + if (!frame || frame.scope !== scope) { + frame = { + callCounts: new Map(), + cells: new Map(), + effects: new Map(), + fiber, + hookKinds: new Map(), + reducerActionCounts: new Map(), + reducerOverrides: new Map(), + replayedCells: new Set(), + renderPhaseUpdates: new Map(), + scope, + }; + renderFrameByFiber.set(fiber, frame); + } + return { frame, scope }; +}; + +const registerHookKind = ( + frame: ConditionalRenderFrame, + scope: ConditionalHookScope, + key: PropertyKey, + kind: ConditionalHookKind, +): void => { + const previousKind = frame.hookKinds.get(key) ?? scope.hookKinds.get(key); + if (previousKind && previousKind !== kind) { + throw new Error( + `Conditional hook key ${String(key)} changed from ${previousKind} to ${kind}. Keys must identify one hook callsite.`, + ); + } + frame.hookKinds.set(key, kind); +}; + +const scheduleScopeUpdate = (scope: ConditionalHookScope): void => { + if (scope.didUnmount) return; + const scheduleUpdate = scope.renderer.scheduleUpdate; + if (!scheduleUpdate) { + throw new Error("The active React renderer does not expose scheduleUpdate()."); + } + // HACK: DevTools schedules a no-op lane, so cloning props bypasses React's bailout check. + const currentFiber = getCurrentFiberBranch(scope.fiber); + currentFiber.memoizedProps = { ...currentFiber.memoizedProps }; + if (currentFiber.tag === 14 || currentFiber.tag === 15) { + const pendingProps = { + ...currentFiber.pendingProps, + __bippyConditionalHookUpdate: ++scheduledUpdateVersion, + }; + currentFiber.pendingProps = pendingProps; + if (currentFiber.alternate) currentFiber.alternate.pendingProps = pendingProps; + } + scheduleUpdate(currentFiber); +}; + +const getCurrentFiberBranch = (fiber: Fiber): Fiber => { + let root = fiber; + while (root.return) root = root.return; + if (root.stateNode?.current === root) return fiber; + return fiber.alternate ?? fiber; +}; + +const getRenderFrameForScope = ( + scope: ConditionalHookScope, +): ConditionalRenderFrame | undefined => { + const fiber = getCurrentFiber(scope.renderer); + if (!fiber) return undefined; + const activeScope = + scopeByFiber.get(fiber) ?? (fiber.alternate ? scopeByFiber.get(fiber.alternate) : undefined); + if (activeScope !== scope) return undefined; + return renderFrameByFiber.get(fiber); +}; + +const enqueueRenderPhaseUpdate = ( + frame: ConditionalRenderFrame, + key: PropertyKey, + action: unknown, +): void => { + const updates = frame.renderPhaseUpdates.get(key); + if (updates) updates.push(action); + else frame.renderPhaseUpdates.set(key, [action]); +}; + +const areDependenciesEqual = ( + previousDependencies: readonly unknown[] | undefined, + nextDependencies: readonly unknown[] | undefined, +): boolean => { + if (!previousDependencies || !nextDependencies) return false; + if (previousDependencies.length !== nextDependencies.length) return false; + return previousDependencies.every((dependency, index) => + Object.is(dependency, nextDependencies[index]), + ); +}; + +const runEffectCleanup = (cell: ConditionalEffectCell): void => { + const cleanup = cell.cleanup; + cell.cleanup = undefined; + cleanup?.(); +}; + +const startEffect = ( + scope: ConditionalHookScope, + key: PropertyKey, + cell: ConditionalEffectCell, + create: ConditionalEffectRegistration["create"], +): void => { + const version = ++cell.version; + const invoke = (): void => { + if (scope.didUnmount || scope.effects.get(key) !== cell || cell.version !== version) return; + cell.cleanup = create() || undefined; + }; + if (cell.kind === "layout-effect") { + invoke(); + } else { + queueMicrotask(invoke); + } +}; + +const isStrictEffectsFiber = (fiber: Fiber): boolean => (fiber.mode & 0b0010000) !== 0; + +const getVisibilityState = (fiber: Fiber): ConditionalVisibilityState => { + let layoutEffectsHidden = false; + let passiveEffectsHidden = false; + let ancestor = fiber.return; + while (ancestor) { + if (ancestor.tag === 22 && ancestor.memoizedState !== null) { + layoutEffectsHidden = true; + } + if (ancestor.tag === 31 && ancestor.memoizedProps.mode === "hidden") { + layoutEffectsHidden = true; + passiveEffectsHidden = true; + } + ancestor = ancestor.return; + } + return { layoutEffectsHidden, passiveEffectsHidden }; +}; + +const reconnectEffects = (scope: ConditionalHookScope, kind: ConditionalEffectKind): void => { + for (const [key, cell] of scope.effects) { + if (cell.kind === kind) startEffect(scope, key, cell, cell.create); + } +}; + +const disconnectEffects = (scope: ConditionalHookScope, kind: ConditionalEffectKind): void => { + for (const cell of scope.effects.values()) { + if (cell.kind === kind) runEffectCleanup(cell); + } +}; + +const updateScopeVisibility = (scope: ConditionalHookScope): void => { + associateScopeWithFiber(scope, getCurrentFiberBranch(scope.fiber)); + const visibility = getVisibilityState(scope.fiber); + const shouldDisconnectLayoutEffects = visibility.layoutEffectsHidden; + const shouldDisconnectPassiveEffects = visibility.passiveEffectsHidden; + + if (shouldDisconnectLayoutEffects !== scope.layoutEffectsDisconnected) { + scope.layoutEffectsDisconnected = shouldDisconnectLayoutEffects; + if (shouldDisconnectLayoutEffects) disconnectEffects(scope, "layout-effect"); + else reconnectEffects(scope, "layout-effect"); + } + if (shouldDisconnectPassiveEffects !== scope.passiveEffectsDisconnected) { + scope.passiveEffectsDisconnected = shouldDisconnectPassiveEffects; + if (shouldDisconnectPassiveEffects) disconnectEffects(scope, "effect"); + else reconnectEffects(scope, "effect"); + } +}; + +const applyRenderPhaseUpdates = (frame: ConditionalRenderFrame): boolean => { + let didStateChange = false; + for (const [key, actions] of frame.renderPhaseUpdates) { + const cell = frame.cells.get(key) ?? frame.scope.cells.get(key); + if (!cell || (cell.kind !== "state" && cell.kind !== "reducer")) continue; + let nextValue = cell.value; + for (const action of actions) { + if (cell.kind === "state") { + nextValue = typeof action === "function" ? action(nextValue) : action; + } else { + const reducer = frame.reducerOverrides.get(key) ?? cell.reducer; + nextValue = reducer(nextValue, action); + } + } + if (Object.is(cell.value, nextValue)) continue; + cell.value = nextValue; + didStateChange = true; + } + return didStateChange; +}; + +const commitRenderFrame = ( + frame: ConditionalRenderFrame, + pendingLayoutEffects: Array<[ConditionalHookScope, PropertyKey, ConditionalEffectCell]>, + pendingStrictLayoutEffects: Array<[ConditionalHookScope, PropertyKey, ConditionalEffectCell]>, + pendingStrictPassiveEffects: Array<[ConditionalHookScope, PropertyKey, ConditionalEffectCell]>, +): void => { + const { effects, scope } = frame; + scopes.add(scope); + associateScopeWithFiber(scope, frame.fiber); + for (const [key, kind] of frame.hookKinds) scope.hookKinds.set(key, kind); + for (const [key, cell] of frame.cells) { + const previousCell = scope.cells.get(key); + if ( + previousCell?.kind === "reducer" && + cell.kind === "reducer" && + previousCell.dispatch === cell.dispatch + ) { + previousCell.value = cell.value; + } else { + scope.cells.set(key, cell); + } + } + for (const [key, reducer] of frame.reducerOverrides) { + const cell = scope.cells.get(key); + if (cell?.kind === "reducer") { + cell.reducer = reducer; + const actionCount = frame.reducerActionCounts.get(key) ?? 0; + if (actionCount > 0) cell.pendingActions.splice(0, actionCount); + } + } + const isInitialCommit = !scope.didCommit; + scope.didCommit = true; + + if (applyRenderPhaseUpdates(frame)) { + queueMicrotask(() => scheduleScopeUpdate(scope)); + return; + } + + for (const [key, cell] of scope.effects) { + if (effects.has(key)) continue; + runEffectCleanup(cell); + scope.effects.delete(key); + } + + const changedEffects: Array<[PropertyKey, ConditionalEffectCell]> = []; + for (const [key, registration] of effects) { + const previousCell = scope.effects.get(key); + if ( + previousCell && + previousCell.kind === registration.kind && + areDependenciesEqual(previousCell.dependencies, registration.dependencies) + ) { + previousCell.create = registration.create; + continue; + } + if (previousCell) runEffectCleanup(previousCell); + const cell: ConditionalEffectCell = { + cleanup: undefined, + create: registration.create, + dependencies: registration.dependencies, + kind: registration.kind, + version: previousCell?.version ?? 0, + }; + scope.effects.set(key, cell); + changedEffects.push([key, cell]); + } + + const visibility = getVisibilityState(scope.fiber); + const areLayoutEffectsHidden = visibility.layoutEffectsHidden; + const arePassiveEffectsHidden = visibility.passiveEffectsHidden; + for (const [key, cell] of changedEffects) { + if (cell.kind === "layout-effect" && areLayoutEffectsHidden) continue; + if (cell.kind === "effect" && arePassiveEffectsHidden) continue; + if (cell.kind === "layout-effect") pendingLayoutEffects.push([scope, key, cell]); + else startEffect(scope, key, cell, cell.create); + } + + if (isInitialCommit && isStrictEffectsFiber(frame.fiber)) { + const layoutEffects = areLayoutEffectsHidden + ? [] + : changedEffects.filter(([, cell]) => cell.kind === "layout-effect"); + for (const [key, cell] of layoutEffects) { + pendingStrictLayoutEffects.push([scope, key, cell]); + } + const passiveEffects = arePassiveEffectsHidden + ? [] + : changedEffects.filter(([, cell]) => cell.kind === "effect"); + for (const [key, cell] of passiveEffects) { + pendingStrictPassiveEffects.push([scope, key, cell]); + } + } +}; + +const commitRoot = (root: FiberRoot): void => { + const pendingLayoutEffects: Array<[ConditionalHookScope, PropertyKey, ConditionalEffectCell]> = + []; + const pendingStrictLayoutEffects: Array< + [ConditionalHookScope, PropertyKey, ConditionalEffectCell] + > = []; + const pendingStrictPassiveEffects: Array< + [ConditionalHookScope, PropertyKey, ConditionalEffectCell] + > = []; + traverseFiber(root.current, (fiber) => { + const frame = renderFrameByFiber.get(fiber); + if (!frame) return; + renderFrameByFiber.delete(fiber); + commitRenderFrame( + frame, + pendingLayoutEffects, + pendingStrictLayoutEffects, + pendingStrictPassiveEffects, + ); + }); + for (const [scope, key, cell] of pendingLayoutEffects) { + startEffect(scope, key, cell, cell.create); + } + if (pendingStrictLayoutEffects.length > 0 || pendingStrictPassiveEffects.length > 0) { + queueMicrotask(() => { + for (const [, , cell] of pendingStrictLayoutEffects) runEffectCleanup(cell); + for (const [, , cell] of pendingStrictPassiveEffects) runEffectCleanup(cell); + for (const [scope, key, cell] of pendingStrictLayoutEffects) { + startEffect(scope, key, cell, cell.create); + } + for (const [scope, key, cell] of pendingStrictPassiveEffects) { + startEffect(scope, key, cell, cell.create); + } + }); + } + for (const scope of scopes) updateScopeVisibility(scope); +}; + +const disposeScope = (scope: ConditionalHookScope): void => { + if (scope.didUnmount) return; + scope.didUnmount = true; + for (const cell of scope.effects.values()) runEffectCleanup(cell); + scope.effects.clear(); + scope.cells.clear(); + scope.hookKinds.clear(); + scopeByFiber.delete(scope.fiber); + renderFrameByFiber.delete(scope.fiber); + if (scope.fiber.alternate) { + scopeByFiber.delete(scope.fiber.alternate); + renderFrameByFiber.delete(scope.fiber.alternate); + } + scopes.delete(scope); +}; + +const unmountFiber = (fiber: Fiber): void => { + const scope = + scopeByFiber.get(fiber) ?? (fiber.alternate ? scopeByFiber.get(fiber.alternate) : undefined); + if (scope) disposeScope(scope); +}; + +export const installConditionalHooks = ( + options: ConditionalHooksOptions = {}, +): ConditionalHooksInstallation => { + if (installation) return installation; + + const rdtHook = getRDTHook(); + for (const renderer of [..._renderers, ...rdtHook.renderers.values()]) { + installRenderer(renderer, options); + } + const unsubscribeRendererInject = onRendererInject((renderer) => { + installRenderer(renderer, options); + }); + const unsubscribeInstrumentation = instrument({ + name: "bippy-conditional-hooks", + onCommitFiberRoot: (_rendererId, root) => commitRoot(root), + onCommitFiberUnmount: (_rendererId, fiber) => unmountFiber(fiber), + }); + + let didUnsubscribe = false; + const unsubscribe = toUnsubscribe(() => { + if (didUnsubscribe) return; + didUnsubscribe = true; + unsubscribeRendererInject(); + unsubscribeInstrumentation(); + for (const scope of scopes) disposeScope(scope); + for (const runtime of runtimes) restoreRuntime(runtime); + if (installation === unsubscribe) installation = null; + }); + + installation = Object.defineProperty(unsubscribe, "supportedRenderers", { + configurable: true, + get: () => runtimes.size, + }); + return installation; +}; + +const ensureInstalled = (): void => { + if (!installation) installConditionalHooks(); +}; + +export const useConditionalState = ( + key: PropertyKey, + initialState: State | (() => State), +): [State, ConditionalStateSetter] => { + ensureInstalled(); + const { frame, scope } = getScope(); + registerHookKind(frame, scope, key, "state"); + let cell = frame.cells.get(key) ?? scope.cells.get(key); + if (!cell) { + const stateCell: ConditionalStateCell = { + dispatch: (action) => { + if (scope.didUnmount) return; + const renderFrame = getRenderFrameForScope(scope); + if (renderFrame) { + enqueueRenderPhaseUpdate(renderFrame, key, action); + return; + } + if (scope.cells.get(key) !== stateCell) return; + const nextValue = typeof action === "function" ? action(stateCell.value) : action; + if (typeof action === "function" && (scope.fiber.mode & 0b0001000) !== 0) { + action(stateCell.value); + } + if (Object.is(stateCell.value, nextValue)) return; + stateCell.value = nextValue; + scheduleScopeUpdate(scope); + }, + kind: "state", + value: typeof initialState === "function" ? initialState() : initialState, + }; + cell = stateCell; + frame.cells.set(key, cell); + } else if (frame.replayedCells.delete(key) && typeof initialState === "function") { + initialState(); + } + if (cell.kind !== "state") throw new Error(`Conditional hook key ${String(key)} is not state.`); + return [cell.value, cell.dispatch] as [State, ConditionalStateSetter]; +}; + +export const useConditionalReducer = ( + key: PropertyKey, + reducer: (state: State, action: Action) => State, + initialState: InitialState, + initialize?: (initialState: InitialState) => State, +): [State, ConditionalReducerDispatcher] => { + ensureInstalled(); + const { frame, scope } = getScope(); + registerHookKind(frame, scope, key, "reducer"); + let cell = frame.cells.get(key) ?? scope.cells.get(key); + if (!cell) { + const reducerCell: ConditionalReducerCell = { + dispatch: (action) => { + if (scope.didUnmount) return; + const renderFrame = getRenderFrameForScope(scope); + if (renderFrame) { + enqueueRenderPhaseUpdate(renderFrame, key, action); + return; + } + if (scope.cells.get(key) !== reducerCell) return; + reducerCell.pendingActions.push(action); + scheduleScopeUpdate(scope); + }, + kind: "reducer", + pendingActions: [], + reducer: (state, action) => reducer(state as State, action as Action), + value: initialize ? initialize(initialState) : initialState, + }; + cell = reducerCell; + frame.cells.set(key, cell); + } else if (frame.replayedCells.delete(key) && initialize) { + initialize(initialState); + } + if (cell.kind !== "reducer") { + throw new Error(`Conditional hook key ${String(key)} is not a reducer.`); + } + const currentReducer = (state: unknown, action: unknown): unknown => + reducer(state as State, action as Action); + frame.reducerOverrides.set(key, currentReducer); + if (cell.pendingActions.length > 0) { + let nextValue = cell.value; + for (const action of cell.pendingActions) nextValue = currentReducer(nextValue, action); + const renderedCell: ConditionalReducerCell = { + ...cell, + value: nextValue, + }; + frame.cells.set(key, renderedCell); + frame.reducerActionCounts.set(key, cell.pendingActions.length); + cell = renderedCell; + } + return [cell.value, cell.dispatch] as [State, ConditionalReducerDispatcher]; +}; + +export const useConditionalRef = ( + key: PropertyKey, + initialValue: Value, +): ConditionalRef => { + ensureInstalled(); + const { frame, scope } = getScope(); + registerHookKind(frame, scope, key, "ref"); + let cell = frame.cells.get(key) ?? scope.cells.get(key); + if (!cell) { + cell = { + kind: "ref", + value: { current: initialValue }, + }; + frame.cells.set(key, cell); + } + if (cell.kind !== "ref") throw new Error(`Conditional hook key ${String(key)} is not a ref.`); + return cell.value as ConditionalRef; +}; + +export const useConditionalMemo = ( + key: PropertyKey, + create: () => Value, + dependencies?: readonly unknown[], +): Value => { + ensureInstalled(); + const { frame, scope } = getScope(); + registerHookKind(frame, scope, key, "memo"); + const cell = frame.cells.get(key) ?? scope.cells.get(key); + if (cell?.kind === "memo" && frame.replayedCells.delete(key)) { + create(); + return cell.value as Value; + } + if (cell?.kind === "memo" && areDependenciesEqual(cell.dependencies, dependencies)) { + return cell.value as Value; + } + if (cell && cell.kind !== "memo") { + throw new Error(`Conditional hook key ${String(key)} is not memoized.`); + } + const value = create(); + frame.cells.set(key, { + dependencies, + kind: "memo", + value, + }); + return value; +}; + +export const useConditionalCallback = unknown>( + key: PropertyKey, + callback: Callback, + dependencies?: readonly unknown[], +): Callback => useConditionalMemo(key, () => callback, dependencies); + +const registerEffect = ( + key: PropertyKey, + kind: ConditionalEffectKind, + create: () => (() => void) | void, + dependencies?: readonly unknown[], +): void => { + ensureInstalled(); + const { frame, scope } = getScope(); + registerHookKind(frame, scope, key, kind); + frame.effects.set(key, { + create, + dependencies, + kind, + }); +}; + +export const useConditionalEffect = ( + key: PropertyKey, + create: () => (() => void) | void, + dependencies?: readonly unknown[], +): void => { + registerEffect(key, "effect", create, dependencies); +}; + +export const useConditionalLayoutEffect = ( + key: PropertyKey, + create: () => (() => void) | void, + dependencies?: readonly unknown[], +): void => { + registerEffect(key, "layout-effect", create, dependencies); +}; diff --git a/packages/bippy/src/index.ts b/packages/bippy/src/index.ts index 94593175..75332f8a 100644 --- a/packages/bippy/src/index.ts +++ b/packages/bippy/src/index.ts @@ -1,3 +1,4 @@ import "./install-hook-only.js"; export * from "./core.js"; +export * from "./conditional-hooks.js"; diff --git a/packages/bippy/src/types.ts b/packages/bippy/src/types.ts index a77bead8..f241ec9f 100644 --- a/packages/bippy/src/types.ts +++ b/packages/bippy/src/types.ts @@ -400,7 +400,7 @@ export interface ReactRenderer { // dev only: https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberReconciler.js#L842 findFiberByHostInstance?: (hostInstance: unknown) => Fiber | null; // react devtools - getCurrentFiber?: (fiber: Fiber) => Fiber | null; + getCurrentFiber?: () => Fiber | null; overrideContext?: (fiber: Fiber, contextType: unknown, path: string[], value: unknown) => void; overrideHookState?: (fiber: Fiber, id: string, path: string[], value: unknown) => void; diff --git a/packages/bippy/tests/conditional-hooks-adversarial.test.tsx b/packages/bippy/tests/conditional-hooks-adversarial.test.tsx new file mode 100644 index 00000000..85b79229 --- /dev/null +++ b/packages/bippy/tests/conditional-hooks-adversarial.test.tsx @@ -0,0 +1,397 @@ +import { + installConditionalHooks, + type ConditionalHooksOptions, + useConditionalEffect, + useConditionalLayoutEffect, + useConditionalMemo, + useConditionalState, +} from "../src/index.js"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +interface Deferred { + promise: Promise; + resolve: (value: Value) => void; +} + +interface EffectChildProperties { + name: string; +} + +interface MemoRollbackProperties { + shouldSuspend: boolean; + value: number; +} + +interface SignalProperties { + signal: boolean; +} + +interface SuspenseContentProperties { + shouldSuspend: boolean; +} + +const installations: Array> = []; + +const install = (options?: ConditionalHooksOptions): ReturnType => { + const installation = installConditionalHooks(options); + installations.push(installation); + return installation; +}; + +const createDeferred = (): Deferred => { + let resolvePromise: ((value: Value) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + if (!resolvePromise) throw new Error("Deferred resolver was not initialized."); + return { promise, resolve: resolvePromise }; +}; + +afterEach(() => { + cleanup(); + for (const installation of installations.splice(0)) installation(); +}); + +describe("conditional hook adversarial cases", () => { + it("matches native Strict Mode state initializer replay", () => { + const nativeInitialize = vi.fn(() => 0); + const NativeComponent = (): React.ReactNode => { + React.useState(nativeInitialize); + return null; + }; + const nativeRendered = render( + + + , + ); + nativeRendered.unmount(); + + install(); + const conditionalInitialize = vi.fn(() => 0); + const ConditionalComponent = (): React.ReactNode => { + useConditionalState("state", conditionalInitialize); + return null; + }; + render( + + + , + ); + + expect(conditionalInitialize).toHaveBeenCalledTimes(nativeInitialize.mock.calls.length); + }); + + it("matches native Strict Mode passive effect replay", async () => { + const nativeEvents: string[] = []; + const NativeComponent = (): React.ReactNode => { + React.useEffect(() => { + nativeEvents.push("mount"); + return () => nativeEvents.push("cleanup"); + }, []); + return null; + }; + const nativeRendered = render( + + + , + ); + await waitFor(() => expect(nativeEvents).toEqual(["mount", "cleanup", "mount"])); + nativeRendered.unmount(); + + install(); + const conditionalEvents: string[] = []; + const ConditionalComponent = (): React.ReactNode => { + useConditionalEffect( + "effect", + () => { + conditionalEvents.push("mount"); + return () => conditionalEvents.push("cleanup"); + }, + [], + ); + return null; + }; + render( + + + , + ); + + await waitFor(() => expect(conditionalEvents).toEqual(nativeEvents.slice(0, 3))); + }); + + it("converges render-phase state updates", async () => { + install(); + const renders: number[] = []; + + const Component = (): React.ReactNode => { + const [count, setCount] = useConditionalState("count", 0); + renders.push(count); + if (count < 3) setCount(count + 1); + return {count}; + }; + + render(); + await waitFor(() => expect(screen.getByText("3")).toBeDefined()); + expect(renders).toEqual([0, 1, 2, 3]); + }); + + it("discards render-phase updates from a suspended attempt", async () => { + install(); + const deferred = createDeferred(); + const events: string[] = []; + + const Component = ({ signal }: SignalProperties): React.ReactNode => { + const [counter, setCounter] = useConditionalState("counter", 0); + const [previousSignal, setPreviousSignal] = useConditionalState("signal", true); + if (previousSignal !== signal) { + setCounter((value) => value + 1); + setPreviousSignal(signal); + if (counter === 0) { + events.push("suspend"); + throw deferred.promise; + } + } + return {counter}; + }; + + const rendered = render( + loading}> + + , + ); + expect(screen.getByText("0")).toBeDefined(); + + rendered.rerender( + loading}> + + , + ); + expect(screen.getByText("loading")).toBeDefined(); + + rendered.rerender( + loading}> + + , + ); + const attemptsAfterFirstSuspension = events.length; + expect(attemptsAfterFirstSuspension).toBeGreaterThan(0); + rendered.rerender( + loading}> + + , + ); + expect(events.length).toBeGreaterThan(attemptsAfterFirstSuspension); + expect(screen.getByText("loading")).toBeDefined(); + }); + + it("rolls memo cells back when a render suspends", () => { + install(); + const computedValues: number[] = []; + const deferred = createDeferred(); + + const Component = ({ shouldSuspend, value }: MemoRollbackProperties): React.ReactNode => { + const memoized = useConditionalMemo( + "memo", + () => { + computedValues.push(value); + return value; + }, + [value], + ); + if (shouldSuspend) throw deferred.promise; + return {memoized}; + }; + + const rendered = render( + loading}> + + , + ); + rendered.rerender( + loading}> + + , + ); + expect(screen.getByText("loading")).toBeDefined(); + rendered.rerender( + loading}> + + , + ); + + expect(computedValues.filter((value) => value === 0)).toEqual([0]); + expect(computedValues.every((value) => value === 0 || value === 1)).toBe(true); + }); + + it("disconnects and reconnects layout effects when Suspense hides content", async () => { + install(); + const deferred = createDeferred(); + const events: string[] = []; + let didResolve = false; + + const Child = (): React.ReactNode => { + useConditionalLayoutEffect( + "layout", + () => { + events.push("mount"); + return () => events.push("cleanup"); + }, + [], + ); + return child; + }; + const SuspenseContent = ({ shouldSuspend }: SuspenseContentProperties): React.ReactNode => { + return ( + <> + + {shouldSuspend && !didResolve + ? (() => { + throw deferred.promise; + })() + : null} + + ); + }; + + const rendered = render( + loading}> + + , + ); + expect(events).toEqual(["mount"]); + rendered.rerender( + loading}> + + , + ); + expect(events).toEqual(["mount", "cleanup"]); + + await act(async () => { + didResolve = true; + deferred.resolve(); + await deferred.promise; + }); + rendered.rerender( + loading}> + + , + ); + expect(events).toEqual(["mount", "cleanup", "mount"]); + }); + + it("updates a memoized component from its conditional setter", async () => { + install(); + + const Counter = React.memo((): React.ReactNode => { + const [count, setCount] = useConditionalState("count", 0); + return ; + }); + + render(); + fireEvent.click(screen.getByText("0")); + await waitFor(() => expect(screen.getByText("1")).toBeDefined()); + }); + + it("tracks provider updates through intercepted useContext", async () => { + install({ interceptReactHooks: true }); + const ValueContext = React.createContext("first"); + + const Child = React.memo((): React.ReactNode => { + const value = React.useContext(ValueContext); + return {value}; + }); + const Component = (): React.ReactNode => { + const [value, setValue] = React.useState("first"); + return ( + + + + + ); + }; + + render(); + fireEvent.click(screen.getByText("update")); + await waitFor(() => expect(screen.getByText("second")).toBeDefined()); + }); + + it("does not clean effects on retained siblings after deletion and reorder", async () => { + install(); + const events: string[] = []; + + const Child = ({ name }: EffectChildProperties): React.ReactNode => { + useConditionalEffect( + "effect", + () => { + events.push(`mount:${name}`); + return () => events.push(`cleanup:${name}`); + }, + [], + ); + return {name}; + }; + + const rendered = render( + <> + + + , + ); + await waitFor(() => expect(events).toEqual(["mount:first", "mount:second"])); + rendered.rerender(); + expect(events).toEqual(["mount:first", "mount:second", "cleanup:first"]); + rendered.unmount(); + expect(events).toEqual(["mount:first", "mount:second", "cleanup:first", "cleanup:second"]); + }); + + it("isolates identical explicit keys across separate roots", async () => { + install(); + + const Counter = ({ name }: EffectChildProperties): React.ReactNode => { + const [count, setCount] = useConditionalState("count", 0); + return ( + + ); + }; + + const firstRoot = render(); + const secondRoot = render(); + fireEvent.click(screen.getByText("first:0")); + await waitFor(() => expect(screen.getByText("first:1")).toBeDefined()); + expect(screen.getByText("second:0")).toBeDefined(); + firstRoot.unmount(); + secondRoot.unmount(); + }); + + it("supports a state update from passive effect setup", async () => { + install(); + + const Component = (): React.ReactNode => { + const [count, setCount] = useConditionalState("count", 0); + useConditionalEffect("effect", () => setCount((value) => value + 1), []); + return {count}; + }; + + render(); + await waitFor(() => expect(screen.getByText("1")).toBeDefined()); + }); + + it("restores the native dispatcher when installation is disposed", async () => { + const installation = install({ interceptReactHooks: true }); + installation(); + + const Component = (): React.ReactNode => { + const [count, setCount] = React.useState(0); + return ; + }; + + render(); + fireEvent.click(screen.getByText("0")); + await waitFor(() => expect(screen.getByText("1")).toBeDefined()); + }); +}); diff --git a/packages/bippy/tests/conditional-hooks-edge-cases.test.tsx b/packages/bippy/tests/conditional-hooks-edge-cases.test.tsx new file mode 100644 index 00000000..6a5c733c --- /dev/null +++ b/packages/bippy/tests/conditional-hooks-edge-cases.test.tsx @@ -0,0 +1,315 @@ +import { + installConditionalHooks, + type ConditionalHooksOptions, + type ConditionalStateSetter, + useConditionalEffect, + useConditionalLayoutEffect, + useConditionalRef, + useConditionalState, +} from "../src/index.js"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +interface Deferred { + promise: Promise; + resolve: (value: Value) => void; +} + +const installations: Array> = []; + +const install = (options?: ConditionalHooksOptions): ReturnType => { + const installation = installConditionalHooks(options); + installations.push(installation); + return installation; +}; + +const createDeferred = (): Deferred => { + let resolvePromise: ((value: Value) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + if (!resolvePromise) throw new Error("Deferred resolver was not initialized."); + return { + promise, + resolve: resolvePromise, + }; +}; + +afterEach(() => { + cleanup(); + for (const installation of installations.splice(0)) installation(); +}); + +describe("conditional hook edge cases", () => { + it("does not commit an effect from a suspended render", async () => { + install({ interceptReactHooks: true }); + const deferred = createDeferred(); + const events: string[] = []; + let didResolve = false; + + const SuspendedComponent = (): React.ReactNode => { + React.useState(0); + React.useEffect(() => { + events.push("mounted"); + return () => events.push("cleaned"); + }, []); + if (!didResolve) throw deferred.promise; + return resolved; + }; + + render( + loading}> + + , + ); + expect(screen.getByText("loading")).toBeDefined(); + await Promise.resolve(); + expect(events).toEqual([]); + + await act(async () => { + didResolve = true; + deferred.resolve(); + await deferred.promise; + }); + + expect(screen.getByText("resolved")).toBeDefined(); + await waitFor(() => expect(events).toEqual(["mounted"])); + }); + + it("does not commit an effect from a render that throws", async () => { + install(); + const events: string[] = []; + + const BrokenComponent = (): React.ReactNode => { + useConditionalEffect( + "aborted-effect", + () => { + events.push("mounted"); + }, + [], + ); + throw new Error("render aborted"); + }; + + expect(() => render()).toThrowError("render aborted"); + await Promise.resolve(); + expect(events).toEqual([]); + }); + + it("cleans up changed effect dependencies before starting the replacement", async () => { + install(); + const events: string[] = []; + + const Component = (): React.ReactNode => { + const [version, setVersion] = React.useState(0); + useConditionalEffect( + "dependency-effect", + () => { + events.push(`start:${version}`); + return () => events.push(`stop:${version}`); + }, + [version], + ); + return ; + }; + + render(); + await waitFor(() => expect(events).toEqual(["start:0"])); + fireEvent.click(screen.getByText("0")); + await waitFor(() => expect(events).toEqual(["start:0", "stop:0", "start:1"])); + }); + + it("runs layout effects synchronously and cleans them on unmount", () => { + install(); + const events: string[] = []; + + const Component = (): React.ReactNode => { + useConditionalLayoutEffect( + "layout-effect", + () => { + events.push("mounted"); + return () => events.push("cleaned"); + }, + [], + ); + return content; + }; + + const rendered = render(); + expect(events).toEqual(["mounted"]); + rendered.unmount(); + expect(events).toEqual(["mounted", "cleaned"]); + }); + + it("cancels a queued passive effect when the component unmounts first", async () => { + install(); + const effect = vi.fn(); + + const Component = (): React.ReactNode => { + useConditionalEffect("queued-effect", effect, []); + return null; + }; + + const rendered = render(); + rendered.unmount(); + await Promise.resolve(); + expect(effect).not.toHaveBeenCalled(); + }); + + it("cleans active effects when the conditional hook installation is disposed", async () => { + const installation = install(); + const events: string[] = []; + + const Component = (): React.ReactNode => { + useConditionalEffect( + "installed-effect", + () => { + events.push("mounted"); + return () => events.push("cleaned"); + }, + [], + ); + return null; + }; + + render(); + await waitFor(() => expect(events).toEqual(["mounted"])); + installation(); + expect(events).toEqual(["mounted", "cleaned"]); + }); + + it("keeps a newer installation active when an older disposer is called twice", () => { + const firstInstallation = install(); + firstInstallation(); + const secondInstallation = install(); + const supportedRenderers = secondInstallation.supportedRenderers; + expect(supportedRenderers).toBeGreaterThan(0); + firstInstallation(); + expect(secondInstallation.supportedRenderers).toBe(supportedRenderers); + }); + + it("gives a remounted Fiber fresh keyed state", async () => { + install(); + + const Counter = (): React.ReactNode => { + const [count, setCount] = useConditionalState("count", 0); + return ; + }; + + const Component = (): React.ReactNode => { + const [generation, setGeneration] = React.useState(0); + return ( +
+ + +
+ ); + }; + + render(); + fireEvent.click(screen.getByText("count:0")); + await waitFor(() => expect(screen.getByText("count:1")).toBeDefined()); + fireEvent.click(screen.getByText("remount")); + expect(screen.getByText("count:0")).toBeDefined(); + }); + + it("retains same-callsite loop cells when the loop shrinks and grows", async () => { + install({ interceptReactHooks: true }); + + const Component = (): React.ReactNode => { + const [itemCount, setItemCount] = React.useState(1); + const items: React.ReactNode[] = []; + for (let index = 0; index < itemCount; index++) { + const [value, setValue] = React.useState(index); + items.push( + , + ); + } + return ( +
+ + + {items} +
+ ); + }; + + render(); + fireEvent.click(screen.getByText("grow")); + fireEvent.click(screen.getByText("1:1")); + await waitFor(() => expect(screen.getByText("1:11")).toBeDefined()); + fireEvent.click(screen.getByText("shrink")); + expect(screen.queryByText("1:11")).toBeNull(); + fireEvent.click(screen.getByText("grow")); + expect(screen.getByText("1:11")).toBeDefined(); + }); + + it("applies multiple functional updates from one event", async () => { + install(); + + const Component = (): React.ReactNode => { + const [count, setCount] = useConditionalState("count", 0); + const incrementTwice = (): void => { + setCount((value) => value + 1); + setCount((value) => value + 1); + }; + return ; + }; + + render(); + fireEvent.click(screen.getByText("0")); + await waitFor(() => expect(screen.getByText("2")).toBeDefined()); + }); + + it("does not rerender for an Object.is-equal state update", async () => { + install(); + let renderCount = 0; + + const Component = (): React.ReactNode => { + renderCount++; + const [count, setCount] = useConditionalState("count", 0); + return ; + }; + + render(); + expect(renderCount).toBe(1); + fireEvent.click(screen.getByText("0")); + await Promise.resolve(); + expect(renderCount).toBe(1); + }); + + it("ignores a captured setter after its Fiber unmounts", () => { + install(); + let capturedSetter: ConditionalStateSetter | undefined; + + const Component = (): React.ReactNode => { + const [count, setCount] = useConditionalState("count", 0); + capturedSetter = setCount; + return {count}; + }; + + const rendered = render(); + rendered.unmount(); + expect(capturedSetter).toBeDefined(); + const update = vi.fn((value: number) => value + 1); + expect(() => capturedSetter?.(update)).not.toThrow(); + expect(update).not.toHaveBeenCalled(); + }); + + it("rejects one explicit key being reused for different hook kinds", () => { + install(); + + const BrokenComponent = (): React.ReactNode => { + useConditionalState("shared-key", 0); + useConditionalRef("shared-key", null); + return null; + }; + + expect(() => render()).toThrowError( + "Conditional hook key shared-key changed from state to ref", + ); + }); +}); diff --git a/packages/bippy/tests/conditional-hooks-react-upstream.test.tsx b/packages/bippy/tests/conditional-hooks-react-upstream.test.tsx new file mode 100644 index 00000000..e7cdde07 --- /dev/null +++ b/packages/bippy/tests/conditional-hooks-react-upstream.test.tsx @@ -0,0 +1,1009 @@ +import { + installConditionalHooks, + type ConditionalHooksOptions, + type ConditionalReducerDispatcher, + useConditionalCallback, + useConditionalEffect, + useConditionalLayoutEffect, + useConditionalMemo, + useConditionalReducer, + useConditionalState, +} from "../src/index.js"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +interface ActivityProperties { + children: React.ReactNode; + mode: "hidden" | "visible"; +} + +interface Deferred { + promise: Promise; + resolve: (value: Value) => void; +} + +interface EffectProperties { + label: string; +} + +interface ReducerProperties { + factor: number; +} + +interface SuspenseProperties { + shouldSuspend: boolean; +} + +const installations: Array> = []; + +const install = (options?: ConditionalHooksOptions): ReturnType => { + const installation = installConditionalHooks(options); + installations.push(installation); + return installation; +}; + +const createDeferred = (): Deferred => { + let resolvePromise: ((value: Value) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + if (!resolvePromise) throw new Error("Deferred resolver was not initialized."); + return { promise, resolve: resolvePromise }; +}; + +afterEach(() => { + cleanup(); + for (const installation of installations.splice(0)) installation(); +}); + +describe("ports from ReactHooksWithNoopRenderer-test.js", () => { + it("throws when called outside the render phase", () => { + install(); + expect(() => useConditionalState("state", 0)).toThrowError( + "must be called while a component is rendering", + ); + }); + + it("updates multiple independent states", async () => { + install(); + + const Component = (): React.ReactNode => { + const [first, setFirst] = useConditionalState("first", 0); + const [second, setSecond] = useConditionalState("second", 10); + return ( +
+ + +
+ ); + }; + + render(); + fireEvent.click(screen.getByText("first:0")); + fireEvent.click(screen.getByText("second:10")); + await waitFor(() => { + expect(screen.getByText("first:1")).toBeDefined(); + expect(screen.getByText("second:20")).toBeDefined(); + }); + }); + + it("applies value and functional state updates in dispatch order", async () => { + install(); + + const Component = (): React.ReactNode => { + const [count, setCount] = useConditionalState("count", 0); + const update = (): void => { + setCount(1); + setCount((value) => value + 2); + setCount(8); + }; + return ; + }; + + render(); + fireEvent.click(screen.getByText("0")); + await waitFor(() => expect(screen.getByText("8")).toBeDefined()); + }); + + it("applies multiple render-phase updates before committing effects", async () => { + install(); + const renders: number[] = []; + const effects: number[] = []; + + const Component = (): React.ReactNode => { + const [count, setCount] = useConditionalState("count", 0); + renders.push(count); + useConditionalEffect( + "effect", + () => { + effects.push(count); + }, + [count], + ); + if (count < 6) { + setCount((value) => value + 1); + setCount((value) => value + 1); + setCount((value) => value + 1); + } + return {count}; + }; + + render(); + await waitFor(() => expect(screen.getByText("6")).toBeDefined()); + await waitFor(() => expect(effects).toEqual([6])); + expect(renders).toEqual([0, 3, 6]); + }); + + it("uses a reducer supplied by the render that processes a queued action", async () => { + install(); + let dispatch: ConditionalReducerDispatcher | undefined; + + const Component = ({ factor }: ReducerProperties): React.ReactNode => { + const [count, currentDispatch] = useConditionalReducer( + "count", + (state: number, amount: number) => state + amount * factor, + 0, + ); + dispatch = currentDispatch; + return {count}; + }; + + const rendered = render(); + act(() => { + dispatch?.(1); + rendered.rerender(); + }); + await waitFor(() => expect(screen.getByText("10")).toBeDefined()); + }); + + it("does not replay a previous no-op reducer action on a prop update", async () => { + install(); + let dispatch: ConditionalReducerDispatcher | undefined; + + const Component = ({ factor }: ReducerProperties): React.ReactNode => { + const [count, currentDispatch] = useConditionalReducer( + "count", + (state: number, amount: number) => state + amount * factor, + 0, + ); + dispatch = currentDispatch; + return {count}; + }; + + const rendered = render(); + act(() => dispatch?.(1)); + expect(screen.getByText("0")).toBeDefined(); + rendered.rerender(); + await Promise.resolve(); + expect(screen.getByText("0")).toBeDefined(); + }); + + it("processes every reducer action in a batch exactly once", async () => { + install(); + const reducer = vi.fn((state: number, amount: number) => state + amount); + + const Component = (): React.ReactNode => { + const [count, dispatch] = useConditionalReducer("count", reducer, 0); + const update = (): void => { + dispatch(1); + dispatch(2); + dispatch(3); + }; + return ; + }; + + render(); + fireEvent.click(screen.getByText("0")); + await waitFor(() => expect(screen.getByText("6")).toBeDefined()); + expect(reducer.mock.calls.map(([, amount]) => amount)).toEqual([1, 2, 3]); + }); + + it("skips effects when dependencies have not changed", async () => { + install(); + const events: string[] = []; + + const Component = ({ label }: EffectProperties): React.ReactNode => { + useConditionalEffect( + "effect", + () => { + events.push(`mount:${label}`); + return () => events.push(`cleanup:${label}`); + }, + [label], + ); + return {label}; + }; + + const rendered = render(); + await waitFor(() => expect(events).toEqual(["mount:same"])); + rendered.rerender(); + await Promise.resolve(); + expect(events).toEqual(["mount:same"]); + }); + + it("unmounts all prior effects before creating replacements", async () => { + install(); + const events: string[] = []; + + const Component = ({ label }: EffectProperties): React.ReactNode => { + useConditionalEffect( + "first", + () => { + events.push(`mount:first:${label}`); + return () => events.push(`cleanup:first:${label}`); + }, + [label], + ); + useConditionalEffect( + "second", + () => { + events.push(`mount:second:${label}`); + return () => events.push(`cleanup:second:${label}`); + }, + [label], + ); + return null; + }; + + const rendered = render(); + await waitFor(() => expect(events).toEqual(["mount:first:A", "mount:second:A"])); + rendered.rerender(); + await waitFor(() => + expect(events).toEqual([ + "mount:first:A", + "mount:second:A", + "cleanup:first:A", + "cleanup:second:A", + "mount:first:B", + "mount:second:B", + ]), + ); + }); + + it("unmounts sibling layout effects before creating any replacements", () => { + install(); + const events: string[] = []; + + const Child = ({ label }: EffectProperties): React.ReactNode => { + useConditionalLayoutEffect( + "layout", + () => { + events.push(`mount:${label}`); + return () => events.push(`cleanup:${label}`); + }, + [label], + ); + return null; + }; + + const rendered = render( + <> + + + , + ); + events.length = 0; + rendered.rerender( + <> + + + , + ); + expect(events).toEqual(["cleanup:A0", "cleanup:B0", "mount:A1", "mount:B1"]); + }); + + it("runs layout effects after host mutations", () => { + install(); + const observedText: string[] = []; + + const Component = ({ label }: EffectProperties): React.ReactNode => { + useConditionalLayoutEffect( + "layout", + () => { + observedText.push(screen.getByTestId("host").textContent ?? ""); + }, + [label], + ); + return {label}; + }; + + const rendered = render(); + rendered.rerender(); + expect(observedText).toEqual(["A", "B"]); + }); + + it("deletes an effect after a render where it was skipped", async () => { + install(); + const events: string[] = []; + + const Component = ({ label }: EffectProperties): React.ReactNode => { + if (label !== "skip") { + useConditionalEffect( + "effect", + () => { + events.push(`mount:${label}`); + return () => events.push(`cleanup:${label}`); + }, + [], + ); + } + return {label}; + }; + + const rendered = render(); + await waitFor(() => expect(events).toEqual(["mount:mount"])); + rendered.rerender(); + expect(events).toEqual(["mount:mount", "cleanup:mount"]); + rendered.unmount(); + expect(events).toEqual(["mount:mount", "cleanup:mount"]); + }); + + it("memoizes callbacks by dependency equality", () => { + install(); + const callbacks: Array<() => string> = []; + + const Component = ({ label }: EffectProperties): React.ReactNode => { + callbacks.push(useConditionalCallback("callback", () => label, [label])); + return null; + }; + + const rendered = render(); + rendered.rerender(); + rendered.rerender(); + expect(callbacks[1]).toBe(callbacks[0]); + expect(callbacks[2]).not.toBe(callbacks[1]); + expect(callbacks[2]?.()).toBe("B"); + }); + + it("does not invoke memo factories on equal dependencies", () => { + install(); + const createMemo = vi.fn((label: string) => ({ label })); + + const Component = ({ label }: EffectProperties): React.ReactNode => { + const value = useConditionalMemo("memo", () => createMemo(label), [label]); + return {value.label}; + }; + + const rendered = render(); + rendered.rerender(); + rendered.rerender(); + expect(createMemo.mock.calls).toEqual([["A"], ["B"]]); + }); + + it("persists effect dependencies after render-phase updates", async () => { + install(); + const events: string[] = []; + + const Component = (): React.ReactNode => { + const [count, setCount] = useConditionalState("count", 0); + useConditionalEffect( + "effect", + () => { + events.push(`effect:${count}`); + }, + [count], + ); + if (count > 0) setCount(0); + return ; + }; + + render(); + await waitFor(() => expect(events).toEqual(["effect:0"])); + fireEvent.click(screen.getByText("0")); + await waitFor(() => expect(screen.getByText("0")).toBeDefined()); + expect(events).toEqual(["effect:0"]); + }); + + it.skip("routes passive effect setup errors through React error boundaries", async () => { + install(); + const error = new Error("effect failed"); + + const Component = (): React.ReactNode => { + useConditionalEffect( + "effect", + () => { + throw error; + }, + [], + ); + return null; + }; + + expect(() => render()).toThrow(error); + }); + + it.fails("flushes pending passive effects before a new layout effect", () => { + install(); + const events: string[] = []; + + const Component = ({ label }: EffectProperties): React.ReactNode => { + useConditionalEffect( + "passive", + () => { + events.push(`passive:${label}`); + }, + [label], + ); + useConditionalLayoutEffect( + "layout", + () => { + events.push(`layout:${label}`); + }, + [label], + ); + return null; + }; + + const rendered = render(); + rendered.rerender(); + expect(events).toEqual(["layout:A", "passive:A", "layout:B"]); + }); +}); + +describe("ports from StrictEffectsMode-test.js", () => { + it("uses the first Strict Mode state initializer result", () => { + install(); + let initializationCount = 0; + + const Component = (): React.ReactNode => { + const [value] = useConditionalState("state", () => ++initializationCount); + return {value}; + }; + + render( + + + , + ); + expect(initializationCount).toBe(2); + expect(screen.getByText("1")).toBeDefined(); + }); + + it("uses the first Strict Mode memo factory result", () => { + install(); + let factoryCallCount = 0; + + const Component = (): React.ReactNode => { + const value = useConditionalMemo("memo", () => ++factoryCallCount, []); + return {value}; + }; + + render( + + + , + ); + expect(factoryCallCount).toBe(2); + expect(screen.getByText("1")).toBeDefined(); + }); + + it("double invokes state updater functions while using the first result", async () => { + install(); + const updater = vi.fn((value: number) => value + 1); + + const Component = (): React.ReactNode => { + const [count, setCount] = useConditionalState("count", 0); + return ; + }; + + render( + + + , + ); + fireEvent.click(screen.getByText("0")); + await waitFor(() => expect(screen.getByText("1")).toBeDefined()); + expect(updater).toHaveBeenCalledTimes(2); + }); + + it("double invokes multiple passive effects in global phase order", async () => { + install(); + const events: string[] = []; + + const Component = (): React.ReactNode => { + useConditionalEffect( + "first", + () => { + events.push("mount:first"); + return () => events.push("cleanup:first"); + }, + [], + ); + useConditionalEffect( + "second", + () => { + events.push("mount:second"); + return () => events.push("cleanup:second"); + }, + [], + ); + return null; + }; + + render( + + + , + ); + await waitFor(() => + expect(events).toEqual([ + "mount:first", + "mount:second", + "cleanup:first", + "cleanup:second", + "mount:first", + "mount:second", + ]), + ); + }); + + it("double invokes multiple layout effects in global phase order", async () => { + install(); + const events: string[] = []; + + const Component = (): React.ReactNode => { + useConditionalLayoutEffect( + "first", + () => { + events.push("mount:first"); + return () => events.push("cleanup:first"); + }, + [], + ); + useConditionalLayoutEffect( + "second", + () => { + events.push("mount:second"); + return () => events.push("cleanup:second"); + }, + [], + ); + return null; + }; + + render( + + + , + ); + await waitFor(() => + expect(events).toEqual([ + "mount:first", + "mount:second", + "cleanup:first", + "cleanup:second", + "mount:first", + "mount:second", + ]), + ); + }); + + it("double invokes effects for children mounted after the initial commit", async () => { + install(); + const events: string[] = []; + + const Child = (): React.ReactNode => { + useConditionalEffect( + "effect", + () => { + events.push("mount"); + return () => events.push("cleanup"); + }, + [], + ); + return null; + }; + const Component = (): React.ReactNode => { + const [isVisible, setIsVisible] = React.useState(false); + return ; + }; + + render( + + + , + ); + fireEvent.click(screen.getByText("show")); + await waitFor(() => expect(events).toEqual(["mount", "cleanup", "mount"])); + }); + + it("double invokes sibling passive effects in global phase order", async () => { + install(); + const events: string[] = []; + + const Child = ({ label }: EffectProperties): React.ReactNode => { + useConditionalEffect( + "effect", + () => { + events.push(`mount:${label}`); + return () => events.push(`cleanup:${label}`); + }, + [], + ); + return null; + }; + + render( + + + + , + ); + await waitFor(() => + expect(events).toEqual([ + "mount:A", + "mount:B", + "cleanup:A", + "cleanup:B", + "mount:A", + "mount:B", + ]), + ); + }); + + it("orders mixed layout and passive Strict Mode replays like React", async () => { + install(); + const events: string[] = []; + + const Component = (): React.ReactNode => { + useConditionalLayoutEffect( + "layout", + () => { + events.push("mount:layout"); + return () => events.push("cleanup:layout"); + }, + [], + ); + useConditionalEffect( + "passive", + () => { + events.push("mount:passive"); + return () => events.push("cleanup:passive"); + }, + [], + ); + return null; + }; + + render( + + + , + ); + await waitFor(() => + expect(events).toEqual([ + "mount:layout", + "mount:passive", + "cleanup:layout", + "cleanup:passive", + "mount:layout", + "mount:passive", + ]), + ); + }); + + it("does not replay effects when keyed children only reorder", async () => { + install(); + const events: string[] = []; + + const Child = ({ label }: EffectProperties): React.ReactNode => { + useConditionalEffect( + "effect", + () => { + events.push(`mount:${label}`); + return () => events.push(`cleanup:${label}`); + }, + [], + ); + return {label}; + }; + const Component = (): React.ReactNode => { + const [labels, setLabels] = React.useState(["A", "B"]); + return ( + + ); + }; + + render( + + + , + ); + await waitFor(() => expect(events.length).toBe(6)); + events.length = 0; + fireEvent.click(screen.getByRole("button")); + await Promise.resolve(); + expect(events).toEqual([]); + }); +}); + +describe("ports from ReactSuspenseEffectsSemantics tests", () => { + it("restarts state initialization after an initial suspension", async () => { + install(); + const deferred = createDeferred(); + const initialize = vi.fn(() => 0); + let didResolve = false; + + const Component = (): React.ReactNode => { + useConditionalState("state", initialize); + if (!didResolve) throw deferred.promise; + return ready; + }; + + render( + loading}> + + , + ); + const attemptsBeforeResolution = initialize.mock.calls.length; + await act(async () => { + didResolve = true; + deferred.resolve(); + await deferred.promise; + }); + expect(screen.getByText("ready")).toBeDefined(); + expect(initialize.mock.calls.length).toBeGreaterThan(attemptsBeforeResolution); + }); + + it("disconnects a memoized descendant layout effect", async () => { + install(); + const deferred = createDeferred(); + const events: string[] = []; + let didResolve = false; + + const Child = React.memo((): React.ReactNode => { + useConditionalLayoutEffect( + "layout", + () => { + events.push("mount"); + return () => events.push("cleanup"); + }, + [], + ); + return child; + }); + const Content = ({ shouldSuspend }: SuspenseProperties): React.ReactNode => { + if (shouldSuspend && !didResolve) throw deferred.promise; + return ; + }; + + const rendered = render( + loading}> + + , + ); + expect(events).toEqual(["mount"]); + rendered.rerender( + loading}> + + , + ); + expect(events).toEqual(["mount", "cleanup"]); + await act(async () => { + didResolve = true; + deferred.resolve(); + await deferred.promise; + }); + expect(events).toEqual(["mount", "cleanup", "mount"]); + }); + + it("destroys a hidden layout effect only once when the boundary unmounts", () => { + install(); + const deferred = createDeferred(); + const cleanupEffect = vi.fn(); + + const Child = (): React.ReactNode => { + useConditionalLayoutEffect("layout", () => cleanupEffect, []); + return null; + }; + const Content = ({ shouldSuspend }: SuspenseProperties): React.ReactNode => { + return ( + <> + + {shouldSuspend + ? (() => { + throw deferred.promise; + })() + : null} + + ); + }; + + const rendered = render( + loading}> + + , + ); + rendered.rerender( + loading}> + + , + ); + rendered.unmount(); + expect(cleanupEffect).toHaveBeenCalledTimes(1); + }); + + it("disconnects only effects inside the Suspense boundary that hides", () => { + install(); + const deferred = createDeferred(); + const events: string[] = []; + + const Child = ({ label }: EffectProperties): React.ReactNode => { + useConditionalLayoutEffect( + "layout", + () => { + events.push(`mount:${label}`); + return () => events.push(`cleanup:${label}`); + }, + [], + ); + return {label}; + }; + const InnerContent = ({ shouldSuspend }: SuspenseProperties): React.ReactNode => { + if (shouldSuspend) throw deferred.promise; + return ; + }; + + const rendered = render( + outer-loading}> + + inner-loading}> + + + , + ); + expect(events).toEqual(["mount:outer", "mount:inner"]); + rendered.rerender( + outer-loading}> + + inner-loading}> + + + , + ); + expect(screen.getByText("outer")).toBeDefined(); + expect(screen.getByText("inner-loading")).toBeDefined(); + expect(events).toEqual(["mount:outer", "mount:inner", "cleanup:inner"]); + }); + + it("disconnects each layout effect once when siblings suspend together", () => { + install(); + const firstDeferred = createDeferred(); + const secondDeferred = createDeferred(); + const cleanupEffect = vi.fn(); + + const Child = (): React.ReactNode => { + useConditionalLayoutEffect("layout", () => cleanupEffect, []); + return null; + }; + const Suspender = ({ label }: EffectProperties): React.ReactNode => { + if (label === "first") throw firstDeferred.promise; + throw secondDeferred.promise; + }; + + const rendered = render( + loading}> + + , + ); + rendered.rerender( + loading}> + + + + , + ); + expect(cleanupEffect).toHaveBeenCalledTimes(1); + }); +}); + +describe("ports from Activity-test.js", () => { + it("mounts and unmounts layout effects as Activity visibility changes", () => { + install(); + const Activity: React.ComponentType = Reflect.get(React, "Activity"); + const events: string[] = []; + + const Child = (): React.ReactNode => { + useConditionalLayoutEffect( + "layout", + () => { + events.push("mount"); + return () => events.push("cleanup"); + }, + [], + ); + return child; + }; + + const rendered = render( + + + , + ); + expect(events).toEqual([]); + rendered.rerender( + + + , + ); + expect(events).toEqual(["mount"]); + rendered.rerender( + + + , + ); + expect(events).toEqual(["mount", "cleanup"]); + }); + + it("connects and disconnects passive effects as Activity visibility changes", async () => { + install(); + const Activity: React.ComponentType = Reflect.get(React, "Activity"); + const events: string[] = []; + + const Child = (): React.ReactNode => { + useConditionalEffect( + "effect", + () => { + events.push("mount"); + return () => events.push("cleanup"); + }, + [], + ); + return child; + }; + + const rendered = render( + + + , + ); + await Promise.resolve(); + expect(events).toEqual([]); + rendered.rerender( + + + , + ); + await waitFor(() => expect(events).toEqual(["mount"])); + rendered.rerender( + + + , + ); + expect(events).toEqual(["mount", "cleanup"]); + }); + + it("retains conditional state while Activity is hidden", async () => { + install(); + const Activity: React.ComponentType = Reflect.get(React, "Activity"); + + const Counter = (): React.ReactNode => { + const [count, setCount] = useConditionalState("count", 0); + return ; + }; + + const rendered = render( + + + , + ); + fireEvent.click(screen.getByText("0")); + await waitFor(() => expect(screen.getByText("1")).toBeDefined()); + rendered.rerender( + + + , + ); + rendered.rerender( + + + , + ); + expect(screen.getByText("1")).toBeDefined(); + }); +}); diff --git a/packages/bippy/tests/conditional-hooks-stress.test.tsx b/packages/bippy/tests/conditional-hooks-stress.test.tsx new file mode 100644 index 00000000..77d1667a --- /dev/null +++ b/packages/bippy/tests/conditional-hooks-stress.test.tsx @@ -0,0 +1,563 @@ +import { + installConditionalHooks, + type ConditionalHooksOptions, + type ConditionalReducerDispatcher, + type ConditionalStateSetter, + useConditionalCallback, + useConditionalEffect, + useConditionalMemo, + useConditionalReducer, + useConditionalRef, + useConditionalState, +} from "../src/index.js"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +interface Deferred { + promise: Promise; + resolve: (value: Value) => void; +} + +interface FactorCounterProperties { + factor: number; +} + +interface KeyedCounterProperties { + name: string; +} + +interface VersionedEffectProperties { + version: number; +} + +const installations: Array> = []; + +const install = (options?: ConditionalHooksOptions): ReturnType => { + const installation = installConditionalHooks(options); + installations.push(installation); + return installation; +}; + +const createDeferred = (): Deferred => { + let resolvePromise: ((value: Value) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + if (!resolvePromise) throw new Error("Deferred resolver was not initialized."); + return { promise, resolve: resolvePromise }; +}; + +afterEach(() => { + cleanup(); + for (const installation of installations.splice(0)) installation(); +}); + +describe("conditional hook stress cases", () => { + it("runs a lazy state initializer only once across branch toggles", () => { + install(); + const initialize = vi.fn(() => 7); + + const Component = (): React.ReactNode => { + const [isVisible, setIsVisible] = React.useState(true); + const content = isVisible ? useConditionalState("value", initialize)[0] : "hidden"; + return ; + }; + + render(); + expect(screen.getByText("7")).toBeDefined(); + fireEvent.click(screen.getByText("7")); + fireEvent.click(screen.getByText("hidden")); + expect(screen.getByText("7")).toBeDefined(); + expect(initialize).toHaveBeenCalledTimes(1); + }); + + it("runs a reducer initializer only once", async () => { + install(); + const initialize = vi.fn((value: number) => value * 2); + + const Component = (): React.ReactNode => { + const [renderVersion, setRenderVersion] = React.useState(0); + const [count] = useConditionalReducer("count", (state: number) => state, 3, initialize); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByText("6:0")); + await waitFor(() => expect(screen.getByText("6:1")).toBeDefined()); + expect(initialize).toHaveBeenCalledTimes(1); + }); + + it("keeps state setter, reducer dispatcher, and ref identities stable", async () => { + install(); + const stateSetters: Array> = []; + const reducerDispatchers: Array> = []; + const references: Array<{ current: number }> = []; + + const Component = (): React.ReactNode => { + const [renderVersion, setRenderVersion] = React.useState(0); + const [, setCount] = useConditionalState("state", 0); + const [, dispatch] = useConditionalReducer( + "reducer", + (state: number, amount: number) => state + amount, + 0, + ); + const reference = useConditionalRef("ref", 0); + stateSetters.push(setCount); + reducerDispatchers.push(dispatch); + references.push(reference); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByText("0")); + await waitFor(() => expect(screen.getByText("1")).toBeDefined()); + expect(stateSetters[1]).toBe(stateSetters[0]); + expect(reducerDispatchers[1]).toBe(reducerDispatchers[0]); + expect(references[1]).toBe(references[0]); + }); + + it("uses the latest reducer closure", async () => { + install(); + + const FactorCounter = ({ factor }: FactorCounterProperties): React.ReactNode => { + const [count, dispatch] = useConditionalReducer( + "count", + (state: number, amount: number) => state + amount * factor, + 0, + ); + return ; + }; + + const Component = (): React.ReactNode => { + const [factor, setFactor] = React.useState(1); + return ( +
+ + +
+ ); + }; + + render(); + fireEvent.click(screen.getByText("factor:1")); + fireEvent.click(screen.getByText("count:0")); + await waitFor(() => expect(screen.getByText("count:5")).toBeDefined()); + }); + + it("renders a queued reducer action even when it returns identical state", async () => { + install(); + let renderCount = 0; + + const Component = (): React.ReactNode => { + renderCount++; + const [value, dispatch] = useConditionalReducer("value", (state: number) => state, 1); + return ; + }; + + render(); + fireEvent.click(screen.getByText("1")); + await Promise.resolve(); + expect(renderCount).toBe(2); + }); + + it("ignores a captured reducer dispatcher after unmount", () => { + install(); + const reduce = vi.fn((state: number, amount: number) => state + amount); + let capturedDispatch: ConditionalReducerDispatcher | undefined; + + const Component = (): React.ReactNode => { + const [, dispatch] = useConditionalReducer("value", reduce, 0); + capturedDispatch = dispatch; + return null; + }; + + const rendered = render(); + rendered.unmount(); + capturedDispatch?.(1); + expect(reduce).not.toHaveBeenCalled(); + }); + + it("stores a function as state through initializer and updater functions", async () => { + install(); + + const Component = (): React.ReactNode => { + const [getLabel, setGetLabel] = useConditionalState("callback", () => () => "first"); + return ; + }; + + render(); + fireEvent.click(screen.getByText("first")); + await waitFor(() => expect(screen.getByText("second")).toBeDefined()); + }); + + it("uses Object.is semantics for memo dependencies", () => { + install(); + const createMemo = vi.fn((value: number) => String(value)); + + const Component = (): React.ReactNode => { + const [dependency, setDependency] = React.useState(Number.NaN); + const memoized = useConditionalMemo("memo", () => createMemo(dependency), [dependency]); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByText("NaN")); + expect(createMemo).toHaveBeenCalledTimes(1); + }); + + it("distinguishes positive and negative zero memo dependencies", async () => { + install(); + const createMemo = vi.fn((value: number) => (Object.is(value, -0) ? "negative" : "positive")); + + const Component = (): React.ReactNode => { + const [dependency, setDependency] = React.useState(0); + const memoized = useConditionalMemo("memo", () => createMemo(dependency), [dependency]); + return ; + }; + + render(); + fireEvent.click(screen.getByText("positive")); + await waitFor(() => expect(screen.getByText("negative")).toBeDefined()); + expect(createMemo).toHaveBeenCalledTimes(2); + }); + + it("recomputes a memo with omitted dependencies on every render", async () => { + install(); + const createMemo = vi.fn(() => "memoized"); + + const Component = (): React.ReactNode => { + const [renderVersion, setRenderVersion] = React.useState(0); + const memoized = useConditionalMemo("memo", createMemo); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByText("memoized:0")); + await waitFor(() => expect(screen.getByText("memoized:1")).toBeDefined()); + expect(createMemo).toHaveBeenCalledTimes(2); + }); + + it("preserves callbacks until their dependencies change", async () => { + install(); + const callbacks: Array<() => number> = []; + + const Component = (): React.ReactNode => { + const [dependency, setDependency] = React.useState(0); + const [renderVersion, setRenderVersion] = React.useState(0); + const callback = useConditionalCallback("callback", () => dependency, [dependency]); + callbacks.push(callback); + return ( +
+ + +
+ ); + }; + + render(); + fireEvent.click(screen.getByText("render:0")); + await waitFor(() => expect(screen.getByText("render:1")).toBeDefined()); + expect(callbacks[1]).toBe(callbacks[0]); + fireEvent.click(screen.getByText("dependency:0")); + await waitFor(() => expect(screen.getByText("dependency:1")).toBeDefined()); + expect(callbacks[2]).not.toBe(callbacks[1]); + expect(callbacks[2]?.()).toBe(1); + }); + + it("restarts an effect with omitted dependencies after every commit", async () => { + install(); + const events: string[] = []; + + const Component = (): React.ReactNode => { + const [version, setVersion] = React.useState(0); + useConditionalEffect("effect", () => { + events.push(`start:${version}`); + return () => events.push(`stop:${version}`); + }); + return ; + }; + + render(); + await waitFor(() => expect(events).toEqual(["start:0"])); + fireEvent.click(screen.getByText("0")); + await waitFor(() => expect(events).toEqual(["start:0", "stop:0", "start:1"])); + }); + + it("restarts an effect when the dependency array length changes", async () => { + install(); + const events: string[] = []; + + const Component = (): React.ReactNode => { + const [isExpanded, setIsExpanded] = React.useState(false); + const dependencies = isExpanded ? [1, 2] : [1]; + useConditionalEffect( + "effect", + () => { + events.push(`start:${dependencies.length}`); + return () => events.push(`stop:${dependencies.length}`); + }, + dependencies, + ); + return ; + }; + + render(); + await waitFor(() => expect(events).toEqual(["start:1"])); + fireEvent.click(screen.getByText("1")); + await waitFor(() => expect(events).toEqual(["start:1", "stop:1", "start:2"])); + }); + + it("cancels a queued effect when its branch disappears", async () => { + install(); + const effect = vi.fn(); + + const Component = (): React.ReactNode => { + const [isVisible, setIsVisible] = React.useState(true); + if (isVisible) useConditionalEffect("effect", effect, []); + return ; + }; + + render(); + fireEvent.click(screen.getByText("hide")); + await Promise.resolve(); + expect(effect).not.toHaveBeenCalled(); + }); + + it("starts only the latest queued effect after a rapid dependency change", async () => { + install(); + const events: string[] = []; + + const VersionedEffect = ({ version }: VersionedEffectProperties): React.ReactNode => { + useConditionalEffect( + "effect", + () => { + events.push(`start:${version}`); + }, + [version], + ); + return {version}; + }; + + const rendered = render(); + rendered.rerender(); + await waitFor(() => expect(events).toEqual(["start:1"])); + }); + + it("ignores state updates triggered by cleanup during unmount", async () => { + install(); + const update = vi.fn((value: number) => value + 1); + + const Component = (): React.ReactNode => { + const [, setCount] = useConditionalState("count", 0); + useConditionalEffect("effect", () => () => setCount(update), []); + return null; + }; + + const rendered = render(); + await Promise.resolve(); + rendered.unmount(); + expect(update).not.toHaveBeenCalled(); + }); + + it("keeps numeric, string, and symbol keys distinct", async () => { + install(); + const symbolKey = Symbol("value"); + + const Component = (): React.ReactNode => { + const [numericValue, setNumericValue] = useConditionalState(1, 0); + const [stringValue, setStringValue] = useConditionalState("1", 10); + const [symbolValue, setSymbolValue] = useConditionalState(symbolKey, 100); + const incrementAll = (): void => { + setNumericValue((value) => value + 1); + setStringValue((value) => value + 1); + setSymbolValue((value) => value + 1); + }; + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByText("0:10:100")); + await waitFor(() => expect(screen.getByText("1:11:101")).toBeDefined()); + }); + + it("keeps state attached to keyed Fibers when siblings reorder", async () => { + install(); + + const KeyedCounter = ({ name }: KeyedCounterProperties): React.ReactNode => { + const [count, setCount] = useConditionalState("count", 0); + return ( + + ); + }; + + const Component = (): React.ReactNode => { + const [names, setNames] = React.useState(["first", "second", "third"]); + return ( +
+ + {names.map((name) => ( + + ))} +
+ ); + }; + + render(); + fireEvent.click(screen.getByText("second:0")); + await waitFor(() => expect(screen.getByText("second:1")).toBeDefined()); + fireEvent.click(screen.getByText("reverse")); + expect(screen.getByText("second:1")).toBeDefined(); + expect(screen.getByText("first:0")).toBeDefined(); + expect(screen.getByText("third:0")).toBeDefined(); + }); + + it("separates repeated automatic keys by occurrence", async () => { + install({ getHookKey: () => "shared-callsite", interceptReactHooks: true }); + + const Component = (): React.ReactNode => { + const [first, setFirst] = React.useState(0); + const [second, setSecond] = React.useState(10); + return ( +
+ + +
+ ); + }; + + render(); + fireEvent.click(screen.getByText("first:0")); + fireEvent.click(screen.getByText("second:10")); + await waitFor(() => { + expect(screen.getByText("first:1")).toBeDefined(); + expect(screen.getByText("second:11")).toBeDefined(); + }); + }); + + it("propagates errors from a custom automatic key resolver", () => { + install({ + getHookKey: () => { + throw new Error("resolver failed"); + }, + interceptReactHooks: true, + }); + + const BrokenComponent = (): React.ReactNode => { + React.useState(0); + return null; + }; + + expect(() => render()).toThrowError("resolver failed"); + }); + + it("forwards conditional useContext reads through the native dispatcher", () => { + install({ interceptReactHooks: true }); + const LabelContext = React.createContext("default"); + + const Component = (): React.ReactNode => { + const [isVisible, setIsVisible] = React.useState(false); + const label = isVisible ? React.useContext(LabelContext) : "hidden"; + return ; + }; + + render( + + + , + ); + fireEvent.click(screen.getByText("hidden")); + expect(screen.getByText("provided")).toBeDefined(); + fireEvent.click(screen.getByText("provided")); + expect(screen.getByText("hidden")).toBeDefined(); + }); + + it("coexists with a native useId hook", async () => { + install({ interceptReactHooks: true }); + const identifiers: string[] = []; + + const Component = (): React.ReactNode => { + const identifier = React.useId(); + const [count, setCount] = React.useState(0); + identifiers.push(identifier); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole("button")); + await waitFor(() => expect(screen.getByRole("button").textContent).toContain(":1")); + expect(identifiers[1]).toBe(identifiers[0]); + }); + + it("keeps the committed effect active while an update is suspended", async () => { + install(); + const deferred = createDeferred(); + const events: string[] = []; + let setVersion: ConditionalStateSetter | undefined; + let didResolve = false; + + const Component = (): React.ReactNode => { + const [version, updateVersion] = useConditionalState("version", 0); + setVersion = updateVersion; + useConditionalEffect( + "effect", + () => { + events.push(`start:${version}`); + return () => events.push(`stop:${version}`); + }, + [version], + ); + if (version === 1 && !didResolve) throw deferred.promise; + return version:{version}; + }; + + render( + loading}> + + , + ); + await waitFor(() => expect(events).toEqual(["start:0"])); + + act(() => setVersion?.(1)); + expect(events).toEqual(["start:0"]); + + await act(async () => { + didResolve = true; + deferred.resolve(); + await deferred.promise; + }); + + expect(screen.getByText("version:1")).toBeDefined(); + await waitFor(() => expect(events).toEqual(["start:0", "stop:0", "start:1"])); + }); +}); diff --git a/packages/bippy/tests/conditional-hooks.test.tsx b/packages/bippy/tests/conditional-hooks.test.tsx new file mode 100644 index 00000000..51abf106 --- /dev/null +++ b/packages/bippy/tests/conditional-hooks.test.tsx @@ -0,0 +1,197 @@ +import { + installConditionalHooks, + type ConditionalHooksOptions, + useConditionalEffect, + useConditionalMemo, + useConditionalReducer, + useConditionalRef, + useConditionalState, +} from "../src/index.js"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const installations: Array> = []; + +interface CounterProperties { + name: string; +} + +const install = (options?: ConditionalHooksOptions): ReturnType => { + const installation = installConditionalHooks(options); + installations.push(installation); + return installation; +}; + +afterEach(() => { + cleanup(); + for (const installation of installations.splice(0)) installation(); +}); + +describe("conditional hooks", () => { + it("makes ordinary React hooks conditional through the dispatcher proxy", async () => { + install({ interceptReactHooks: true }); + const events: string[] = []; + + const Component = (): React.ReactNode => { + const [isEnabled, setIsEnabled] = React.useState(false); + let conditionalContent: React.ReactNode = "disabled"; + if (isEnabled) { + const [count, setCount] = React.useState(0); + React.useEffect(() => { + events.push("start"); + return () => events.push("stop"); + }, []); + conditionalContent = ( + + ); + } + return ( +
+ + {conditionalContent} +
+ ); + }; + + render(); + fireEvent.click(screen.getByText("toggle")); + await waitFor(() => expect(events).toEqual(["start"])); + fireEvent.click(screen.getByText("0")); + await waitFor(() => expect(screen.getByText("1")).toBeDefined()); + fireEvent.click(screen.getByText("toggle")); + expect(events).toEqual(["start", "stop"]); + fireEvent.click(screen.getByText("toggle")); + await waitFor(() => expect(screen.getByText("1")).toBeDefined()); + }); + + it("reuses intercepted callsite keys across Strict Mode render replays", async () => { + install({ interceptReactHooks: true }); + const initializeState = vi.fn(() => 0); + + const Component = (): React.ReactNode => { + const [count, setCount] = React.useState(initializeState); + return ; + }; + + render( + + + , + ); + expect(initializeState).toHaveBeenCalledTimes(2); + fireEvent.click(screen.getByText("0")); + await waitFor(() => expect(screen.getByText("1")).toBeDefined()); + }); + + it("retains keyed state while its branch is disabled", async () => { + install(); + + const Component = (): React.ReactNode => { + const [isEnabled, setIsEnabled] = React.useState(false); + let conditionalContent: React.ReactNode = disabled; + + if (isEnabled) { + const [count, setCount] = useConditionalState("count", 0); + conditionalContent = ( + + ); + } + + return ( +
+ + {conditionalContent} +
+ ); + }; + + render(); + expect(screen.getByTestId("value").textContent).toBe("disabled"); + fireEvent.click(screen.getByText("toggle")); + expect(screen.getByTestId("value").textContent).toBe("0"); + fireEvent.click(screen.getByTestId("value")); + await waitFor(() => expect(screen.getByTestId("value").textContent).toBe("1")); + fireEvent.click(screen.getByText("toggle")); + fireEvent.click(screen.getByText("toggle")); + expect(screen.getByTestId("value").textContent).toBe("1"); + }); + + it("isolates identical keys between component instances", async () => { + install(); + + const Counter = ({ name }: CounterProperties): React.ReactNode => { + const [count, setCount] = useConditionalState("count", 0); + return ( + + ); + }; + + render( + <> + + + , + ); + fireEvent.click(screen.getByText("first:0")); + await waitFor(() => expect(screen.getByText("first:1")).toBeDefined()); + expect(screen.getByText("second:0")).toBeDefined(); + }); + + it("supports reducer, ref, and memo cells in a conditional branch", async () => { + install(); + const createMemo = vi.fn((value: number) => value * 2); + + const Component = (): React.ReactNode => { + const [count, dispatch] = useConditionalReducer( + "reducer", + (state: number, amount: number) => state + amount, + 1, + ); + const renderCount = useConditionalRef("render-count", 0); + renderCount.current++; + const doubled = useConditionalMemo("memo", () => createMemo(count), [count]); + return ( + + ); + }; + + render(); + expect(screen.getByRole("button").textContent).toBe("1:2:1"); + fireEvent.click(screen.getByRole("button")); + await waitFor(() => expect(screen.getByRole("button").textContent).toBe("3:6:2")); + expect(createMemo).toHaveBeenCalledTimes(2); + }); + + it("runs and cleans up effects as branches appear and disappear", async () => { + install(); + const events: string[] = []; + + const Component = (): React.ReactNode => { + const [isEnabled, setIsEnabled] = React.useState(false); + if (isEnabled) { + useConditionalEffect( + "subscription", + () => { + events.push("start"); + return () => events.push("stop"); + }, + [], + ); + } + return ; + }; + + render(); + fireEvent.click(screen.getByText("toggle")); + await waitFor(() => expect(events).toEqual(["start"])); + fireEvent.click(screen.getByText("toggle")); + expect(events).toEqual(["start", "stop"]); + }); +}); diff --git a/packages/bippy/vite.config.ts b/packages/bippy/vite.config.ts index 49c72715..b8be0364 100644 --- a/packages/bippy/vite.config.ts +++ b/packages/bippy/vite.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ entry: { index: "./src/index.ts", core: "./src/core.ts", + "conditional-hooks": "./src/conditional-hooks.ts", source: "./src/source/index.ts", "react-refresh": "./src/react-refresh/index.ts", "install-hook-only": "./src/install-hook-only.ts", diff --git a/packages/conditional-hooks-playground/.gitignore b/packages/conditional-hooks-playground/.gitignore new file mode 100644 index 00000000..f06235c4 --- /dev/null +++ b/packages/conditional-hooks-playground/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/conditional-hooks-playground/index.html b/packages/conditional-hooks-playground/index.html new file mode 100644 index 00000000..3d7b0f52 --- /dev/null +++ b/packages/conditional-hooks-playground/index.html @@ -0,0 +1,13 @@ + + + + + + + Conditional Hooks Lab + + +
+ + + diff --git a/packages/conditional-hooks-playground/package.json b/packages/conditional-hooks-playground/package.json new file mode 100644 index 00000000..873c7cb9 --- /dev/null +++ b/packages/conditional-hooks-playground/package.json @@ -0,0 +1,22 @@ +{ + "name": "@bippy/conditional-hooks-playground", + "private": true, + "type": "module", + "scripts": { + "dev": "vp dev", + "build": "vp build", + "preview": "vp preview" + }, + "dependencies": { + "bippy": "workspace:*", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@types/react": "^19.0.4", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite-plus": "latest" + } +} diff --git a/packages/conditional-hooks-playground/src/app.tsx b/packages/conditional-hooks-playground/src/app.tsx new file mode 100644 index 00000000..3dbba361 --- /dev/null +++ b/packages/conditional-hooks-playground/src/app.tsx @@ -0,0 +1,253 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; + +import type { ConditionalHooksInstallation } from "bippy/conditional-hooks"; + +import "./styles.css"; + +interface ApplicationProperties { + installation: ConditionalHooksInstallation; +} + +interface ConditionalPanelProperties { + onEvent: (message: string) => void; +} + +interface EventEntry { + id: number; + message: string; + time: string; +} + +let eventIdentifier = 0; + +const formatTime = (): string => + new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(new Date()); + +const ConditionalPanel = ({ onEvent }: ConditionalPanelProperties): React.ReactNode => { + const [isBranchEnabled, setIsBranchEnabled] = React.useState(false); + const [activationCount, setActivationCount] = React.useState(0); + + const toggleBranch = (): void => { + setIsBranchEnabled((isEnabled) => { + const nextIsEnabled = !isEnabled; + if (nextIsEnabled) setActivationCount((count) => count + 1); + onEvent(nextIsEnabled ? "Branch entered" : "Branch exited"); + return nextIsEnabled; + }); + }; + + let branchContent: React.ReactNode = ( +
+
+ +
+ The hook branch is dormant +

Enable it to call six ordinary React hooks from inside an if statement.

+
+ ); + + if (isBranchEnabled) { + const [count, setCount] = React.useState(0); + const [step, changeStep] = React.useReducer( + (currentStep: number, direction: number) => Math.max(1, currentStep + direction), + 1, + ); + const renderCount = React.useRef(0); + const [heartbeat, setHeartbeat] = React.useState(0); + const computedValue = React.useMemo(() => count * step, [count, step]); + + renderCount.current++; + + React.useLayoutEffect(() => { + onEvent("Conditional layout effect mounted"); + return () => onEvent("Conditional layout effect cleaned up"); + }, []); + + React.useEffect(() => { + onEvent("Conditional interval started"); + const intervalIdentifier = window.setInterval(() => { + setHeartbeat((value) => value + 1); + }, 1000); + return () => { + window.clearInterval(intervalIdentifier); + onEvent("Conditional interval stopped"); + }; + }, []); + + branchContent = ( +
+
+
+ Counter + {count} +
+
+ Step + {step} +
+
+ Count × step + {computedValue} +
+
+ Render pass + {renderCount.current} +
+
+ +
+ + + + +
+ +
+ + Effect heartbeat {heartbeat} +
+
+ ); + } + + return ( +
+
+
+ Live Fiber experiment +

Conditional branch

+
+ +
+ +
{branchContent}
+ +
+ Branch activations + {activationCount} + State survives while disabled +
+
+ ); +}; + +const Application = ({ installation }: ApplicationProperties): React.ReactNode => { + const [events, setEvents] = React.useState([]); + + const addEvent = React.useCallback((message: string): void => { + setEvents((currentEvents) => [ + { + id: ++eventIdentifier, + message, + time: formatTime(), + }, + ...currentEvents.slice(0, 7), + ]); + }, []); + + return ( +
+
+
+ + {installation.supportedRenderers} development renderer connected +
+

+ Conditional hooks, +
+ actually running. +

+

+ Bippy proxies React’s active dispatcher, keys ordinary hooks by callsite, and stores their + state beside the current Fiber instead of consuming React’s positional hook list. +

+
+ +
+ + + +
+ +
+ Development builds only + Stack-keyed callsites + Absolutely unsupported +
+
+ ); +}; + +export const startApplication = (installation: ConditionalHooksInstallation): void => { + const rootElement = document.querySelector("#root"); + if (!rootElement) throw new Error("Missing root element."); + createRoot(rootElement).render( + + + , + ); +}; diff --git a/packages/conditional-hooks-playground/src/main.ts b/packages/conditional-hooks-playground/src/main.ts new file mode 100644 index 00000000..a220e1b1 --- /dev/null +++ b/packages/conditional-hooks-playground/src/main.ts @@ -0,0 +1,9 @@ +import { installConditionalHooks } from "bippy/conditional-hooks"; + +const conditionalHooksInstallation = installConditionalHooks({ + interceptReactHooks: true, +}); + +const { startApplication } = await import("./app.js"); + +startApplication(conditionalHooksInstallation); diff --git a/packages/conditional-hooks-playground/src/styles.css b/packages/conditional-hooks-playground/src/styles.css new file mode 100644 index 00000000..47e9ac2d --- /dev/null +++ b/packages/conditional-hooks-playground/src/styles.css @@ -0,0 +1,506 @@ +@import url("https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&display=swap"); + +:root { + color: #f3f0ea; + background: #09090b; + font-family: "Manrope", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: + radial-gradient(circle at 10% 0%, rgba(116, 81, 255, 0.14), transparent 28rem), + radial-gradient(circle at 88% 22%, rgba(52, 211, 153, 0.08), transparent 25rem), #09090b; +} + +button { + font: inherit; +} + +.page-shell { + width: min(1180px, calc(100% - 40px)); + margin: 0 auto; + padding: 72px 0 40px; +} + +.hero { + max-width: 820px; + margin-bottom: 48px; +} + +.status-pill, +.live-badge { + display: inline-flex; + align-items: center; + gap: 9px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; + background: rgba(255, 255, 255, 0.035); + color: #aaa7a1; + font: + 500 12px/1 "DM Mono", + monospace; + letter-spacing: 0.02em; + padding: 10px 13px; +} + +.status-pill span, +.live-badge::before { + width: 7px; + height: 7px; + border-radius: 50%; + background: #4ade80; + box-shadow: 0 0 0 4px rgba(74, 222, 128, 0.11); + content: ""; +} + +.hero h1 { + margin: 27px 0 20px; + font-size: clamp(48px, 8vw, 92px); + line-height: 0.96; + letter-spacing: -0.065em; +} + +.hero h1 em { + color: #9b87f5; + font-style: normal; + font-weight: 500; +} + +.hero p { + max-width: 720px; + margin: 0; + color: #96938e; + font-size: 17px; + line-height: 1.7; +} + +.workspace-grid { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.8fr); + gap: 18px; + align-items: stretch; +} + +.lab-card, +.code-card, +.events-card { + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 22px; + background: rgba(19, 19, 22, 0.88); + box-shadow: 0 22px 80px rgba(0, 0, 0, 0.32); + backdrop-filter: blur(18px); +} + +.lab-card { + display: flex; + min-height: 590px; + flex-direction: column; +} + +.card-heading, +.events-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 26px 28px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); +} + +.eyebrow { + display: block; + margin-bottom: 7px; + color: #77746f; + font: + 500 10px/1 "DM Mono", + monospace; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.card-heading h2, +.events-heading h3 { + margin: 0; + font-size: 20px; + letter-spacing: -0.03em; +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 999px; + background: #202024; + color: #aaa7a1; + cursor: pointer; + padding: 9px 13px 9px 9px; + transition: + border-color 160ms ease, + background 160ms ease, + color 160ms ease; +} + +.toggle span { + width: 18px; + height: 18px; + border: 5px solid #545159; + border-radius: 50%; + background: #18181b; +} + +.toggle.enabled { + border-color: rgba(74, 222, 128, 0.28); + background: rgba(74, 222, 128, 0.1); + color: #b9f8ce; +} + +.toggle.enabled span { + border-color: #4ade80; +} + +.branch-stage { + display: grid; + flex: 1; + place-items: center; + padding: 30px; +} + +.empty-state { + max-width: 350px; + color: #817e79; + text-align: center; +} + +.empty-state strong { + display: block; + margin: 24px 0 8px; + color: #c8c5bf; + font-size: 18px; +} + +.empty-state p { + margin: 0; + font-size: 14px; + line-height: 1.6; +} + +.empty-orbit { + display: grid; + width: 78px; + height: 78px; + margin: 0 auto; + place-items: center; + border: 1px dashed #3d3b40; + border-radius: 50%; +} + +.empty-orbit span { + width: 22px; + height: 22px; + border: 5px solid #55515d; + border-radius: 50%; +} + +.active-branch { + width: 100%; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.metrics-grid article { + min-height: 128px; + padding: 20px; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 16px; + background: #101012; +} + +.metrics-grid span { + color: #77746f; + font-size: 12px; +} + +.metrics-grid strong { + display: block; + margin-top: 24px; + color: #e9e5de; + font: + 500 38px/1 "DM Mono", + monospace; + letter-spacing: -0.05em; +} + +.controls-row { + display: flex; + flex-wrap: wrap; + gap: 9px; + margin-top: 18px; +} + +.controls-row button { + border-radius: 11px; + cursor: pointer; + padding: 11px 15px; +} + +.primary-button { + border: 1px solid #9b87f5; + background: #9b87f5; + color: #100e17; + font-weight: 700; +} + +.secondary-button { + border: 1px solid #343239; + background: #252429; + color: #dad6cf; +} + +.ghost-button { + border: 1px solid transparent; + background: transparent; + color: #85817c; +} + +.heartbeat-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 20px; + color: #807d77; + font: + 400 12px/1 "DM Mono", + monospace; +} + +.heartbeat-row strong { + color: #d3d0ca; +} + +.heartbeat-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #4ade80; + box-shadow: 0 0 14px rgba(74, 222, 128, 0.8); +} + +.card-footer { + display: flex; + align-items: center; + gap: 10px; + padding: 18px 28px; + border-top: 1px solid rgba(255, 255, 255, 0.07); + color: #77746f; + font-size: 12px; +} + +.card-footer strong { + color: #e0ddd7; + font-family: "DM Mono", monospace; +} + +.retention-note { + margin-left: auto; + color: #9b87f5; +} + +.side-stack { + display: grid; + grid-template-rows: auto 1fr; + gap: 18px; +} + +.window-bar { + display: flex; + align-items: center; + gap: 7px; + padding: 15px 17px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); +} + +.window-bar > span { + width: 8px; + height: 8px; + border-radius: 50%; + background: #4b4850; +} + +.window-bar > span:first-child { + background: #ff6b68; +} + +.window-bar > span:nth-child(2) { + background: #f2c94c; +} + +.window-bar > span:nth-child(3) { + background: #4ade80; +} + +.window-bar small { + margin-left: auto; + color: #65625e; + font: + 400 10px/1 "DM Mono", + monospace; +} + +.code-card pre { + overflow-x: auto; + margin: 0; + padding: 24px; + color: #c9c5bf; + font: + 400 12px/1.85 "DM Mono", + monospace; +} + +.syntax-purple { + color: #c4a7ff; +} + +.syntax-blue { + color: #75c8ff; +} + +.events-card { + min-height: 300px; +} + +.events-heading { + padding: 21px 23px; +} + +.live-badge { + padding: 7px 9px; + font-size: 9px; +} + +.event-list { + padding: 9px 22px 20px; +} + +.no-events { + padding: 22px 0; + color: #6f6c67; + font-size: 13px; +} + +.event-row { + display: grid; + grid-template-columns: 8px 1fr auto; + align-items: center; + gap: 10px; + padding: 12px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.055); + color: #aaa7a1; + font-size: 11px; +} + +.event-marker { + width: 5px; + height: 5px; + border-radius: 50%; + background: #9b87f5; +} + +.event-row time { + color: #5f5c58; + font: + 400 9px/1 "DM Mono", + monospace; +} + +.page-footer { + display: flex; + flex-wrap: wrap; + gap: 10px 30px; + padding: 27px 3px 0; + color: #56534f; + font: + 400 10px/1 "DM Mono", + monospace; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.page-footer span::before { + margin-right: 8px; + color: #77736f; + content: "✦"; +} + +@media (max-width: 860px) { + .page-shell { + padding-top: 45px; + } + + .workspace-grid { + grid-template-columns: 1fr; + } + + .lab-card { + min-height: 560px; + } +} + +@media (max-width: 560px) { + .page-shell { + width: min(100% - 24px, 1180px); + } + + .hero h1 { + font-size: 48px; + } + + .card-heading { + align-items: flex-start; + flex-direction: column; + } + + .metrics-grid { + grid-template-columns: 1fr 1fr; + } + + .metrics-grid article { + min-height: 110px; + padding: 16px; + } + + .metrics-grid strong { + font-size: 30px; + } + + .branch-stage { + padding: 20px; + } + + .retention-note { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} diff --git a/packages/conditional-hooks-playground/tsconfig.json b/packages/conditional-hooks-playground/tsconfig.json new file mode 100644 index 00000000..8ca068cc --- /dev/null +++ b/packages/conditional-hooks-playground/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext" + }, + "include": ["src", "vite.config.ts"] +} diff --git a/packages/conditional-hooks-playground/vite.config.ts b/packages/conditional-hooks-playground/vite.config.ts new file mode 100644 index 00000000..02e835f2 --- /dev/null +++ b/packages/conditional-hooks-playground/vite.config.ts @@ -0,0 +1,9 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 4175, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b697092..a2c691ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,34 @@ importers: specifier: latest version: 0.1.15(@types/node@20.19.34)(esbuild@0.27.4)(happy-dom@15.11.7)(jiti@2.7.0)(publint@0.3.18)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.6(@types/node@20.19.34)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + packages/conditional-hooks-playground: + dependencies: + bippy: + specifier: workspace:* + version: link:../bippy + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + devDependencies: + '@types/react': + specifier: ^19.0.4 + version: 19.1.17 + '@types/react-dom': + specifier: ^19.0.2 + version: 19.2.3(@types/react@19.1.17) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.3.6(@types/node@20.19.34)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vite-plus: + specifier: latest + version: 0.1.15(@types/node@20.19.34)(esbuild@0.27.4)(happy-dom@15.11.7)(jiti@2.7.0)(publint@0.3.18)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.6(@types/node@20.19.34)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + packages/e2e: devDependencies: '@jest/globals': From 05e477920129352780474aa7f3c347d405f3d3f3 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 19 Jul 2026 00:36:36 -0700 Subject: [PATCH 2/4] Clarify conditional hooks playground --- .../conditional-hooks-playground/index.html | 4 +- .../conditional-hooks-playground/src/app.tsx | 316 ++++---- .../src/styles.css | 679 +++++++++--------- 3 files changed, 532 insertions(+), 467 deletions(-) diff --git a/packages/conditional-hooks-playground/index.html b/packages/conditional-hooks-playground/index.html index 3d7b0f52..0563fc44 100644 --- a/packages/conditional-hooks-playground/index.html +++ b/packages/conditional-hooks-playground/index.html @@ -3,8 +3,8 @@ - - Conditional Hooks Lab + + Conditional Hooks — bippy
diff --git a/packages/conditional-hooks-playground/src/app.tsx b/packages/conditional-hooks-playground/src/app.tsx index 3dbba361..2daa8e43 100644 --- a/packages/conditional-hooks-playground/src/app.tsx +++ b/packages/conditional-hooks-playground/src/app.tsx @@ -9,7 +9,7 @@ interface ApplicationProperties { installation: ConditionalHooksInstallation; } -interface ConditionalPanelProperties { +interface ConditionalDemoProperties { onEvent: (message: string) => void; } @@ -19,6 +19,46 @@ interface EventEntry { time: string; } +const DEMO_SOURCE_LINES = [ + "const ConditionalCounter = () => {", + " const [enabled, setEnabled] = React.useState(false)", + "", + " let panel =

The branch is off.

", + "", + " if (enabled) {", + " const [count, setCount] = React.useState(0)", + " const [step, changeStep] = React.useReducer(", + " (current, direction) => Math.max(1, current + direction),", + " 1,", + " )", + " const renders = React.useRef(0)", + " const result = React.useMemo(() => count * step, [count, step])", + "", + " React.useEffect(() => {", + ' const timer = setInterval(() => console.log("tick"), 1000)', + " return () => clearInterval(timer)", + " }, [])", + "", + " renders.current++", + " panel = (", + "
", + "

{count} × {step} = {result}

", + " ", + " ", + " render {renders.current}", + "
", + " )", + " }", + "", + " return (", + " <>", + " ", + " {panel}", + " ", + " )", + "}", +]; + let eventIdentifier = 0; const formatTime = (): string => @@ -28,30 +68,27 @@ const formatTime = (): string => second: "2-digit", }).format(new Date()); -const ConditionalPanel = ({ onEvent }: ConditionalPanelProperties): React.ReactNode => { - const [isBranchEnabled, setIsBranchEnabled] = React.useState(false); +const ConditionalDemo = ({ onEvent }: ConditionalDemoProperties): React.ReactNode => { + const [isEnabled, setIsEnabled] = React.useState(false); const [activationCount, setActivationCount] = React.useState(0); - const toggleBranch = (): void => { - setIsBranchEnabled((isEnabled) => { - const nextIsEnabled = !isEnabled; - if (nextIsEnabled) setActivationCount((count) => count + 1); - onEvent(nextIsEnabled ? "Branch entered" : "Branch exited"); + const toggleDemo = (): void => { + setIsEnabled((currentIsEnabled) => { + const nextIsEnabled = !currentIsEnabled; + if (nextIsEnabled) setActivationCount((currentCount) => currentCount + 1); + onEvent(nextIsEnabled ? "entered the conditional branch" : "left the conditional branch"); return nextIsEnabled; }); }; - let branchContent: React.ReactNode = ( -
-
- -
- The hook branch is dormant -

Enable it to call six ordinary React hooks from inside an if statement.

+ let demoContent: React.ReactNode = ( +
+ The conditional block is not running. + Lines 6–29 are currently skipped.
); - if (isBranchEnabled) { + if (isEnabled) { const [count, setCount] = React.useState(0); const [step, changeStep] = React.useReducer( (currentStep: number, direction: number) => Math.max(1, currentStep + direction), @@ -64,89 +101,83 @@ const ConditionalPanel = ({ onEvent }: ConditionalPanelProperties): React.ReactN renderCount.current++; React.useLayoutEffect(() => { - onEvent("Conditional layout effect mounted"); - return () => onEvent("Conditional layout effect cleaned up"); + onEvent("layout effect mounted"); + return () => onEvent("layout effect cleaned up"); }, []); React.useEffect(() => { - onEvent("Conditional interval started"); + onEvent("effect timer started"); const intervalIdentifier = window.setInterval(() => { - setHeartbeat((value) => value + 1); + setHeartbeat((currentHeartbeat) => currentHeartbeat + 1); }, 1000); return () => { window.clearInterval(intervalIdentifier); - onEvent("Conditional interval stopped"); + onEvent("effect timer stopped"); }; }, []); - branchContent = ( -
-
-
- Counter - {count} -
-
- Step - {step} -
-
- Count × step - {computedValue} -
-
- Render pass - {renderCount.current} -
+ demoContent = ( +
+
+ {count} + × + {step} + = + {computedValue}
-
+
- - - + + +
-
- - Effect heartbeat {heartbeat} +
+ + + effect tick {heartbeat} + + render {renderCount.current}
); } return ( -
-
+
+
+
+ 02 +

Run the component

+
+ {activationCount} branch activations +
+ +
    +
  1. Turn the branch on.
  2. +
  3. Change the count and step.
  4. +
  5. Turn it off, then on again. The state comes back.
  6. +
+ +
- Live Fiber experiment -

Conditional branch

+ Run lines 6–29 + {isEnabled ? "The conditional hooks are mounted." : "The hooks are skipped."}
-
{branchContent}
- -
- Branch activations - {activationCount} - State survives while disabled -
+
{demoContent}
); }; @@ -161,82 +192,101 @@ const Application = ({ installation }: ApplicationProperties): React.ReactNode = message, time: formatTime(), }, - ...currentEvents.slice(0, 7), + ...currentEvents.slice(0, 5), ]); }, []); return (
-
-
- - {installation.supportedRenderers} development renderer connected +
+
+ b + bippy + + + {installation.supportedRenderers} renderer connected +
-

- Conditional hooks, -
- actually running. -

+

Conditional hooks, explained

- Bippy proxies React’s active dispatcher, keys ordinary hooks by callsite, and stores their - state beside the current Fiber instead of consuming React’s positional hook list. + React normally requires every hook to run in the same order. This experiment lets the + highlighted hooks exist only while the if branch is enabled.

+
+ Normal React + conditional hooks break + With this runtime + each callsite keeps its own state +
-
- - - -
+ )) + )} +
+
-