/** * The remaining (deliberate or structural) divergences between React and * tap; see DIVERGENCES.md for the full rationale. Each test pins the CURRENT * behavior of BOTH sides so a change in either direction is caught: * * - tap's eager dispatch bailout is more aggressive than React's: no stale * lanes, so a same-value dispatch right after an update skips the render * React still does, and no-change renders commit no-deps effects. * - useLayoutEffect collapses onto useEffect (no layout phase). * - Dispatch after unmount applies, like an Activity hide: tap cannot * distinguish a hide from a deletion. Do NOT add an isMounted guard. */ /* oxlint-disable react/exhaustive-deps -- intentional missing-dep patterns are part of the scenarios */ import { describe, it, expect } from "vitest"; import { useEffect, useLayoutEffect, useReducer, useState } from "react"; import { isDevMode, runScenario, TAP_ENVS, type Scenario, } from "./describeParity"; const countOf = (events: string[], entry: string) => events.filter((e) => e === entry).length; /** Body invocations per committed render pass (dev double-invokes). */ const perRender = isDevMode ? 2 : 1; describe("divergence: eager dispatch bailout is more aggressive than React's", () => { it("same-value dispatch right after an update: React renders once more; tap roots bail", async () => { // React's eager bailout needs idle lanes on the fiber AND its alternate; // a committed update leaves the alternate's lanes stale, so the next // same-value dispatch still renders (a bailout render that clears them). // tap bails on Object.is equality whenever its batch is empty: strictly // fewer renders, identical state. Matching React exactly takes a lanes // emulation (dirty bit + bailout-render detection + effect suppression + // args guard); it was implemented and reverted as not worth the hot-path // complexity, since only impure updaters and render bodies can observe // the difference. const scenario: Scenario = { name: "", use: (log) => { const [count, setCount] = useState(0); log(`render ${count}`); return { set: (n: number) => setCount(n) }; }, drive: async ({ api, act }) => { await act(() => api().set(1)); await act(() => api().set(1)); }, }; const react = await runScenario("react", scenario); expect(countOf(react, "render 1")).toBe(2 * perRender); for (const env of ["tapRoot", "createTapRoot"] as const) { const tap = await runScenario(env, scenario); expect(countOf(tap, "render 1")).toBe(perRender); } const bridge = await runScenario("bridge", scenario); expect(bridge).toEqual(react); }); it("no-change render: React strips no-deps effects (bailoutHooks); tap commits them", async () => { // Consistent with tap's whole-tree re-renders, which refire no-deps // effects on every update anyway. const scenario: Scenario = { name: "", use: (log) => { const [state, dispatch] = useReducer((s: number) => s, 42); log(`render ${state}`); useEffect(() => { log("effect"); return () => log("cleanup"); }); return { dispatch }; }, drive: async ({ api, act }) => { await act(() => api().dispatch(0)); }, }; const react = await runScenario("react", scenario); for (const env of TAP_ENVS) { const tap = await runScenario(env, scenario); expect(countOf(tap, "render 42")).toBe(countOf(react, "render 42")); expect(countOf(tap, "effect")).toBe(countOf(react, "effect") + 1); expect(countOf(tap, "cleanup")).toBe(countOf(react, "cleanup") + 1); } }); }); describe("fully-bailable bridge dispatch matches React", () => { const scenario: Scenario = { name: "", use: (log) => { const [count, setCount] = useState(0); log(`render ${count}`); return { set: (n: number) => setCount(n) }; }, drive: async ({ api, act }) => { await act(() => api().set(0)); }, }; it("React, tap roots, and the bridge bail without rendering", async () => { const react = await runScenario("react", scenario); expect(countOf(react, "render 0")).toBe(perRender); for (const env of TAP_ENVS) { const tap = await runScenario(env, scenario); expect(tap).toEqual(react); } }); }); describe("divergence: dispatch from an unmount cleanup applies (Activity semantics)", () => { const scenario: Scenario = { name: "", use: (log) => { const [count, setCount] = useState(0); log(`render ${count}`); useEffect(() => { log(`mount ${count}`); return () => { log(`cleanup ${count}`); setCount(99); }; }, []); }, unmountAtEnd: true, }; it("React drops it (deletion); tap roots apply it like an Activity hide", async () => { const react = await runScenario("react", scenario); const bridge = await runScenario("bridge", scenario); expect(bridge).toEqual(react); if (isDevMode) { // In dev the strict remount cycle runs the cleanup while still mounted, // so even React renders 99 during mount. expect(react).toContain("render 99"); } else { expect(react).not.toContain("render 99"); } for (const env of ["tapRoot", "createTapRoot"] as const) { const tap = await runScenario(env, scenario); if (isDevMode) { // The strict remount cycle already set 99 while mounted; the unmount // dispatch then bails eagerly on equality, masking the divergence. expect(tap).toEqual(react); } else { // tap cannot distinguish this deletion from an Activity-style hide, // so the update applies and the (pure) render runs; effects stay off. expect(tap).toEqual([...react, "render 99"]); } } }); }); describe("divergence: useLayoutEffect is an alias for useEffect", () => { const scenario: Scenario = { name: "", use: (log) => { const [n, setN] = useState(0); useEffect(() => { log(`passive n=${n}`); return () => log(`passive-cleanup n=${n}`); }, [n]); useLayoutEffect(() => { log(`layout n=${n}`); return () => log(`layout-cleanup n=${n}`); }, [n]); return { bump: () => setN((c) => c + 1) }; }, drive: async ({ api, act }) => { await act(() => api().bump()); }, }; it("React runs the layout phase first; tap runs call order", async () => { const react = await runScenario("react", scenario); expect(react.slice(-4)).toEqual([ "layout-cleanup n=0", "layout n=1", "passive-cleanup n=0", "passive n=1", ]); for (const env of TAP_ENVS) { const tap = await runScenario(env, scenario); expect(tap.slice(-4)).toEqual([ "passive-cleanup n=0", "layout-cleanup n=0", "passive n=1", "layout n=1", ]); } }); });