diff --git a/packages/bippy/package.json b/packages/bippy/package.json index af9aafe..68fc99a 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 0000000..bc9b504 --- /dev/null +++ b/packages/bippy/src/conditional-hooks.ts @@ -0,0 +1,940 @@ +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; + 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; +} + +interface ConditionalRef { + current: Value; +} + +interface ConditionalStateSetter { + (action: State | ((previousState: State) => State)): void; +} + +interface ConditionalReducerDispatcher { + (action: Action): void; +} + +export interface ConditionalHooksInstallation extends Unsubscribe { + readonly supportedRenderers: number; +} + +export interface ConditionalHooksOptions { + getHookKey?: ConditionalHookKeyResolver; +} + +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) => + readStateCell(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 readReducerCell( + getAutomaticHookKey(runtime, "useReducer"), + (state: unknown, action: unknown) => reducer(state, action), + initialState, + initializer, + ); + }; + } + if (property === "useRef") { + return (initialValue: unknown) => + readRefCell(getAutomaticHookKey(runtime, "useRef"), initialValue); + } + if (property === "useMemo") { + return (create: unknown, dependencies: unknown) => { + if (typeof create !== "function") throw new TypeError("useMemo requires a function."); + return readMemoCell( + 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 readMemoCell( + 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 (!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, + 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(); +}; + +const readStateCell = ( + 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]; +}; + +const readReducerCell = ( + 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]; +}; + +const readRefCell = (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; +}; + +const readMemoCell = ( + 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; +}; + +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, + }); +}; diff --git a/packages/bippy/src/index.ts b/packages/bippy/src/index.ts index 9459317..75332f8 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 a77bead..f241ec9 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 0000000..8baa9f7 --- /dev/null +++ b/packages/bippy/tests/conditional-hooks-adversarial.test.tsx @@ -0,0 +1,374 @@ +import { installConditionalHooks, type ConditionalHooksOptions } 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 => { + React.useState(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 => { + React.useEffect(() => { + 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] = React.useState(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] = React.useState(0); + const [previousSignal, setPreviousSignal] = React.useState(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 = React.useMemo(() => { + 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 => { + React.useLayoutEffect(() => { + 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] = React.useState(0); + return ; + }); + + render(); + fireEvent.click(screen.getByText("0")); + await waitFor(() => expect(screen.getByText("1")).toBeDefined()); + }); + + it("tracks provider updates through intercepted useContext", async () => { + install(); + 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 => { + React.useEffect(() => { + 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 automatic callsites across separate roots", async () => { + install(); + + const Counter = ({ name }: EffectChildProperties): React.ReactNode => { + const [count, setCount] = React.useState(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] = React.useState(0); + React.useEffect(() => 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(); + 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 0000000..b6c4790 --- /dev/null +++ b/packages/bippy/tests/conditional-hooks-edge-cases.test.tsx @@ -0,0 +1,277 @@ +import { installConditionalHooks, type ConditionalHooksOptions } 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(); + 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 => { + React.useEffect(() => { + 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); + React.useEffect(() => { + 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 => { + React.useLayoutEffect(() => { + 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 => { + React.useEffect(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 => { + React.useEffect(() => { + 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 conditional state", async () => { + install(); + + const Counter = (): React.ReactNode => { + const [count, setCount] = React.useState(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(); + + 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] = React.useState(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] = React.useState(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: React.Dispatch> | undefined; + + const Component = (): React.ReactNode => { + const [count, setCount] = React.useState(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(); + }); +}); 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 0000000..78df0f7 --- /dev/null +++ b/packages/bippy/tests/conditional-hooks-react-upstream.test.tsx @@ -0,0 +1,894 @@ +import { installConditionalHooks, type ConditionalHooksOptions } 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("updates multiple independent states", async () => { + install(); + + 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:20")).toBeDefined(); + }); + }); + + it("applies value and functional state updates in dispatch order", async () => { + install(); + + const Component = (): React.ReactNode => { + const [count, setCount] = React.useState(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] = React.useState(0); + renders.push(count); + React.useEffect(() => { + 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: ((action: number) => void) | undefined; + + const Component = ({ factor }: ReducerProperties): React.ReactNode => { + const [count, currentDispatch] = React.useReducer( + (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: ((action: number) => void) | undefined; + + const Component = ({ factor }: ReducerProperties): React.ReactNode => { + const [count, currentDispatch] = React.useReducer( + (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] = React.useReducer(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 => { + React.useEffect(() => { + 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 => { + React.useEffect(() => { + events.push(`mount:first:${label}`); + return () => events.push(`cleanup:first:${label}`); + }, [label]); + React.useEffect(() => { + 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 => { + React.useLayoutEffect(() => { + 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 => { + React.useLayoutEffect(() => { + 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") { + React.useEffect(() => { + 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(React.useCallback(() => 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 = React.useMemo(() => 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] = React.useState(0); + React.useEffect(() => { + 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 => { + React.useEffect(() => { + 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 => { + React.useEffect(() => { + events.push(`passive:${label}`); + }, [label]); + React.useLayoutEffect(() => { + 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] = React.useState(() => ++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 = React.useMemo(() => ++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] = React.useState(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 => { + React.useEffect(() => { + events.push("mount:first"); + return () => events.push("cleanup:first"); + }, []); + React.useEffect(() => { + 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 => { + React.useLayoutEffect(() => { + events.push("mount:first"); + return () => events.push("cleanup:first"); + }, []); + React.useLayoutEffect(() => { + 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 => { + React.useEffect(() => { + 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 => { + React.useEffect(() => { + 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 => { + React.useLayoutEffect(() => { + events.push("mount:layout"); + return () => events.push("cleanup:layout"); + }, []); + React.useEffect(() => { + 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 => { + React.useEffect(() => { + 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 => { + React.useState(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 => { + React.useLayoutEffect(() => { + 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 => { + React.useLayoutEffect(() => 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 => { + React.useLayoutEffect(() => { + 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 => { + React.useLayoutEffect(() => 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 => { + React.useLayoutEffect(() => { + 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 => { + React.useEffect(() => { + 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] = React.useState(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 0000000..2e13c52 --- /dev/null +++ b/packages/bippy/tests/conditional-hooks-stress.test.tsx @@ -0,0 +1,533 @@ +import { installConditionalHooks, type ConditionalHooksOptions } 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 ? React.useState(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] = React.useReducer((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<(action: number) => void> = []; + const references: Array<{ current: number }> = []; + + const Component = (): React.ReactNode => { + const [renderVersion, setRenderVersion] = React.useState(0); + const [, setCount] = React.useState(0); + const [, dispatch] = React.useReducer((state: number, amount: number) => state + amount, 0); + const reference = React.useRef(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] = React.useReducer( + (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] = React.useReducer((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: ((action: number) => void) | undefined; + + const Component = (): React.ReactNode => { + const [, dispatch] = React.useReducer(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] = React.useState(() => () => "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 = React.useMemo(() => 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 = React.useMemo(() => 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 = React.useMemo(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 = React.useCallback(() => 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); + React.useEffect(() => { + 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]; + React.useEffect(() => { + 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) React.useEffect(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 => { + React.useEffect(() => { + 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] = React.useState(0); + React.useEffect(() => () => setCount(update), []); + return null; + }; + + const rendered = render(); + await Promise.resolve(); + rendered.unmount(); + expect(update).not.toHaveBeenCalled(); + }); + + it("keeps adjacent automatic hook callsites distinct", async () => { + install(); + + const Component = (): React.ReactNode => { + const [numericValue, setNumericValue] = React.useState(0); + const [stringValue, setStringValue] = React.useState(10); + const [symbolValue, setSymbolValue] = React.useState(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] = React.useState(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" }); + + 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"); + }, + }); + + const BrokenComponent = (): React.ReactNode => { + React.useState(0); + return null; + }; + + expect(() => render()).toThrowError("resolver failed"); + }); + + it("forwards conditional useContext reads through the native dispatcher", () => { + install(); + 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(); + 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: React.Dispatch> | undefined; + let didResolve = false; + + const Component = (): React.ReactNode => { + const [version, updateVersion] = React.useState(0); + setVersion = updateVersion; + React.useEffect(() => { + 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 0000000..82f1179 --- /dev/null +++ b/packages/bippy/tests/conditional-hooks.test.tsx @@ -0,0 +1,184 @@ +import { installConditionalHooks, type ConditionalHooksOptions } 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(); + 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(); + 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 conditional 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] = React.useState(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 callsites between component instances", async () => { + install(); + + const Counter = ({ name }: CounterProperties): React.ReactNode => { + const [count, setCount] = React.useState(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] = React.useReducer( + (state: number, amount: number) => state + amount, + 1, + ); + const renderCount = React.useRef(0); + renderCount.current++; + const doubled = React.useMemo(() => 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) { + React.useEffect(() => { + 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 49c7271..b8be036 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 0000000..f06235c --- /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 0000000..0563fc4 --- /dev/null +++ b/packages/conditional-hooks-playground/index.html @@ -0,0 +1,13 @@ + + + + + + + Conditional Hooks — bippy + + +
+ + + diff --git a/packages/conditional-hooks-playground/package.json b/packages/conditional-hooks-playground/package.json new file mode 100644 index 0000000..873c7cb --- /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 0000000..2daa8e4 --- /dev/null +++ b/packages/conditional-hooks-playground/src/app.tsx @@ -0,0 +1,303 @@ +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 ConditionalDemoProperties { + onEvent: (message: string) => void; +} + +interface EventEntry { + id: number; + message: string; + 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 => + new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(new Date()); + +const ConditionalDemo = ({ onEvent }: ConditionalDemoProperties): React.ReactNode => { + const [isEnabled, setIsEnabled] = React.useState(false); + const [activationCount, setActivationCount] = React.useState(0); + + 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 demoContent: React.ReactNode = ( +
+ The conditional block is not running. + Lines 6–29 are currently skipped. +
+ ); + + if (isEnabled) { + 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("layout effect mounted"); + return () => onEvent("layout effect cleaned up"); + }, []); + + React.useEffect(() => { + onEvent("effect timer started"); + const intervalIdentifier = window.setInterval(() => { + setHeartbeat((currentHeartbeat) => currentHeartbeat + 1); + }, 1000); + return () => { + window.clearInterval(intervalIdentifier); + onEvent("effect timer stopped"); + }; + }, []); + + demoContent = ( +
+
+ {count} + × + {step} + = + {computedValue} +
+ +
+ + + + +
+ +
+ + + 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. +
+ +
+
+ Run lines 6–29 + {isEnabled ? "The conditional hooks are mounted." : "The hooks are skipped."} +
+ +
+ +
{demoContent}
+
+ ); +}; + +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, 5), + ]); + }, []); + + return ( +
+
+
+ b + bippy + + + {installation.supportedRenderers} renderer connected + +
+

Conditional hooks, explained

+

+ 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 +
+
+ +
+
+
+ 01 +

Read the full component

+
+ conditional-counter.tsx +
+

+ The highlighted block violates the Rules of Hooks on purpose. It contains state, a + reducer, a ref, a memo, and an effect. +

+
+          
+            {DEMO_SOURCE_LINES.map((sourceLine, sourceLineIndex) => {
+              const lineNumber = sourceLineIndex + 1;
+              const isConditionalLine = lineNumber >= 6 && lineNumber <= 29;
+              return (
+                
+                  {lineNumber}
+                  {sourceLine || " "}
+                
+              );
+            })}
+          
+        
+
+ + This entire block appears and disappears between renders. +
+
+ + + +
+
+
+ 03 +

Watch React commit it

+
+ live effect log +
+

+ Entering starts the effects. Leaving cleans them up. Re-entering restores the previous + count and step instead of creating new state. +

+
+ {events.length === 0 ? ( +

Toggle the branch to see commit activity.

+ ) : ( + events.map((event) => ( +
+ {event.message} + +
+ )) + )} +
+
+ +
+ runtime experiment + development build + GitHub +
+
+ ); +}; + +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 0000000..008af98 --- /dev/null +++ b/packages/conditional-hooks-playground/src/main.ts @@ -0,0 +1,7 @@ +import { installConditionalHooks } from "bippy/conditional-hooks"; + +const conditionalHooksInstallation = installConditionalHooks(); + +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 0000000..5dc8a96 --- /dev/null +++ b/packages/conditional-hooks-playground/src/styles.css @@ -0,0 +1,521 @@ +:root { + color: #1a1a1a; + background: #fbfaf8; + font-family: + Inter, + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: #fbfaf8; +} + +button { + color: inherit; + font: inherit; +} + +.page-shell { + width: min(760px, calc(100% - 32px)); + margin: 0 auto; + padding: 40px 0 48px; +} + +.intro { + width: min(100%, 560px); + margin-bottom: 52px; +} + +.brand-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 18px; +} + +.brand-mark { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 7px; + background: #1a1a1a; + color: white; + font-weight: 700; + line-height: 1; +} + +.connection-status { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + color: #858585; + font-size: 11px; + font-weight: 500; +} + +.status-dot { + display: inline-block; + width: 6px; + height: 6px; + flex: none; + border-radius: 50%; + background: #4dab67; +} + +.intro h1 { + margin: 28px 0 7px; + font-size: 28px; + line-height: 1.25; + letter-spacing: -0.035em; +} + +.intro p { + margin: 0; + color: #707070; + font-size: 16px; + font-weight: 500; + line-height: 1.55; +} + +.intro code { + padding: 1px 5px; + border-radius: 4px; + background: #efeeeb; + color: #353535; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 13px; +} + +.comparison-row { + display: grid; + grid-template-columns: auto 1fr; + gap: 0; + margin-top: 24px; + border-top: 1px solid #dddddd; +} + +.comparison-row span, +.comparison-row strong { + padding: 9px 0; + border-bottom: 1px solid #eeeeee; + font-size: 12px; +} + +.comparison-row span { + padding-right: 20px; + color: #8b8b8b; +} + +.comparison-row strong { + color: #444444; + font-weight: 600; +} + +.source-section, +.demo-section, +.log-section { + margin-top: 50px; +} + +.section-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; + padding-bottom: 11px; + border-bottom: 1px solid #dddddd; +} + +.section-heading > div { + display: flex; + align-items: baseline; + gap: 10px; +} + +.section-heading h2 { + margin: 0; + color: #3f3f3f; + font-size: 18px; + letter-spacing: -0.015em; +} + +.section-number, +.section-heading > span { + color: #929292; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 10px; +} + +.section-description { + width: min(100%, 590px); + margin: 14px 0 18px; + color: #7b7b7b; + font-size: 13px; + line-height: 1.55; +} + +.source-code { + overflow: auto; + max-height: 560px; + margin: 0; + padding: 12px 0; + border-radius: 9px; + background: #202020; + box-shadow: + 0 0 0 1px rgb(0 0 0 / 8%), + 0 2px 8px rgb(0 0 0 / 8%); + color: #d8d8d8; + font: + 12px/1.65 "SFMono-Regular", + Consolas, + monospace; + tab-size: 2; +} + +.source-line { + display: grid; + min-width: 650px; + grid-template-columns: 42px 1fr; + padding-right: 18px; + white-space: pre; +} + +.source-line.highlighted { + background: rgb(102 152 214 / 9%); +} + +.source-line.highlighted .line-number { + color: #77aeea; +} + +.line-number { + padding-right: 12px; + color: #666666; + text-align: right; + user-select: none; +} + +.legend { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + color: #858585; + font-size: 11px; +} + +.legend-swatch { + width: 14px; + height: 8px; + border-radius: 2px; + background: rgb(79 134 199 / 18%); +} + +.instructions { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1px; + margin: 18px 0; + padding: 0; + overflow: hidden; + border: 1px solid #e5e4e1; + border-radius: 9px; + background: #e5e4e1; + counter-reset: instructions; + list-style: none; +} + +.instructions li { + min-height: 82px; + padding: 13px; + background: rgb(255 255 255 / 78%); + color: #686868; + counter-increment: instructions; + font-size: 12px; + line-height: 1.45; +} + +.instructions li::before { + display: block; + margin-bottom: 8px; + color: #a0a0a0; + content: counter(instructions, decimal-leading-zero); + font: + 10px/1 "SFMono-Regular", + Consolas, + monospace; +} + +.toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 14px 0; +} + +.toggle-row > div { + display: flex; + flex-direction: column; + gap: 3px; +} + +.toggle-row strong { + color: #353535; + font-size: 13px; +} + +.toggle-row div > span { + color: #858585; + font-size: 13px; +} + +.switch { + position: relative; + width: 38px; + height: 22px; + flex: none; + padding: 0; + border: 0; + border-radius: 999px; + background: #d4d3d0; + cursor: pointer; +} + +.switch > span { + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: white; + box-shadow: 0 1px 2px rgb(0 0 0 / 14%); + transition: transform 140ms ease; +} + +.switch.enabled { + background: #262626; +} + +.switch.enabled > span { + transform: translateX(16px); +} + +.switch:focus-visible, +.button-row button:focus-visible { + outline: 2px solid #8ab9ef; + outline-offset: 2px; +} + +.demo-surface { + display: grid; + min-height: 190px; + place-items: center; + border: 1px solid #eeeeee; + border-radius: 10px; + background: rgb(255 255 255 / 72%); + box-shadow: 0 1px 2px rgb(0 0 0 / 4%); +} + +.demo-surface.enabled { + display: block; + padding: 24px; +} + +.branch-off { + display: flex; + flex-direction: column; + gap: 5px; + text-align: center; +} + +.branch-off strong { + color: #626262; + font-size: 13px; +} + +.branch-off span, +.empty-log { + color: #969696; + font-size: 12px; +} + +.equation { + display: flex; + align-items: baseline; + gap: 13px; +} + +.equation strong { + min-width: 46px; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 32px; + font-weight: 500; + text-align: center; +} + +.equation span { + color: #a0a0a0; + font: + 15px/1 "SFMono-Regular", + Consolas, + monospace; +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 24px; +} + +.button-row button { + padding: 8px 11px; + border: 0; + border-radius: 7px; + background: white; + box-shadow: + 0 0 0 1px rgb(0 0 0 / 7%), + 0 1px 3px rgb(0 0 0 / 8%); + color: #515151; + cursor: pointer; + font-size: 12px; + font-weight: 600; +} + +.button-row .primary-button { + background: #242424; + box-shadow: none; + color: white; +} + +.button-row button:active { + transform: translateY(1px); +} + +.runtime-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-top: 25px; + padding-top: 13px; + border-top: 1px solid #eeeeee; + color: #858585; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 10px; +} + +.runtime-row > span { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.event-list { + min-height: 64px; + margin-top: 4px; +} + +.empty-log { + margin: 0; + padding: 17px 0; +} + +.event-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 10px 0; + border-bottom: 1px solid #eeeeee; + color: #5a5a5a; + font-size: 13px; +} + +.event-row time { + flex: none; + color: #a0a0a0; + font: + 10px/1 "SFMono-Regular", + Consolas, + monospace; +} + +footer { + display: flex; + align-items: center; + gap: 15px; + margin-top: 52px; + padding-top: 20px; + border-top: 1px solid #dddddd; + color: #969696; + font-size: 12px; +} + +footer a { + margin-left: auto; + color: #707070; + text-decoration-color: #c4c4c4; + text-underline-offset: 3px; +} + +@media (max-width: 640px) { + .page-shell { + padding-top: 24px; + } + + .connection-status { + max-width: 126px; + text-align: right; + } + + .instructions { + grid-template-columns: 1fr; + } + + .instructions li { + min-height: 0; + } + + .section-heading { + align-items: flex-start; + } + + .section-heading > div { + align-items: flex-start; + } + + .section-heading > span { + max-width: 120px; + text-align: right; + } + + .source-code { + margin-right: -16px; + margin-left: -16px; + border-radius: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .switch > span { + transition: none; + } +} diff --git a/packages/conditional-hooks-playground/tsconfig.json b/packages/conditional-hooks-playground/tsconfig.json new file mode 100644 index 0000000..8ca068c --- /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 0000000..02e835f --- /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 7b69709..a2c691c 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':