From e2e237a22ff28375cbaff583a9acebdca6bcabf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=97=E5=A4=A7?= Date: Mon, 19 Oct 2026 13:59:51 +0800 Subject: [PATCH] perf(ui): coalesce useScrollFade observers and skip identical setState (MUL-7987) (#9207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(ui): coalesce useScrollFade observers and skip identical setState Every fade host (sidebar groups, the tab bar, dialogs) observed scroll, resize AND childList:subtree mutations, and each observer hit synchronously read layout and dispatched a setState even when the computed fade value was unchanged. In busy workspaces with frequent realtime re-renders, a route remount fired hundreds of identical dispatchSetState calls per second across the shell's fade hosts — every one going through React's lane scheduler — stretching tab switches and detail-page opens out to several seconds. A production CPU profile of the stall showed roughly half of all samples inside that dispatchSetState path. Coalesce the three sources through one requestAnimationFrame per host and only dispatch when the computed value actually changes. The first frame after mount still applies the initial mask, so the visual behavior is unchanged. Also gives packages/ui a minimal vitest setup (jsdom + @testing-library, matching packages/core and packages/views) with coverage for the mask values and a regression test asserting that a mutation/scroll storm with unchanged layout reaches React zero times. * fix(ui): keep useScrollFade scroll handling synchronous Coalescing scroll through rAF broke chat's auto-scroll handshake: its stick-to-bottom latch attributes wheel displacement to the scroll event that confirms it within the same task, and deferring the fade update (and its re-render) off that task left followOutput stale in the autoscroll suite. Scroll stays synchronous — user scrolling is too rare to need coalescing, and the storms this hook guards against come from resizes and subtree mutations, which remain rAF-coalesced. Full packages/views suite (5981 tests) passes with this split. * fix(ui): resolve useScrollFade lifecycle defects from review Two defects from the PR review: 1. The cleanup removed the wrong closure — the listener was registered as `run` but unregistered as `schedule`, so StrictMode remounts and axis changes left the old measurement attached (double reads, stale axis). Remove `run` symmetrically. 2. A synchronous scroll executed `raf = 0` while a queued frame was still alive: the frame fired anyway (resize→scroll→mutation yielded two pending frames instead of one) and survived unmount cleanup, which saw the zeroed handle. `run` now cancels the queued frame up front — a synchronous measurement absorbs it. Also refreshes the header comment that still claimed all three sources go through rAF, and adds regression tests for the three review scenarios (absorb queued frame, zero frames after unmount, listener symmetry across cleanup and axis rebuild). * chore(ui): drop unused no-var eslint directive in test setup Co-authored-by: multica-agent --------- Co-authored-by: Multica Eve <273080382+multica-eve@users.noreply.github.com> Co-authored-by: multica-agent --- packages/ui/hooks/use-scroll-fade.test.tsx | 208 +++++++++++++++++++++ packages/ui/hooks/use-scroll-fade.ts | 81 +++++--- packages/ui/package.json | 10 +- packages/ui/test/setup.ts | 56 ++++++ packages/ui/vitest.config.ts | 12 ++ pnpm-lock.yaml | 105 ++++++++--- 6 files changed, 417 insertions(+), 55 deletions(-) create mode 100644 packages/ui/hooks/use-scroll-fade.test.tsx create mode 100644 packages/ui/test/setup.ts create mode 100644 packages/ui/vitest.config.ts diff --git a/packages/ui/hooks/use-scroll-fade.test.tsx b/packages/ui/hooks/use-scroll-fade.test.tsx new file mode 100644 index 000000000..09b246bf3 --- /dev/null +++ b/packages/ui/hooks/use-scroll-fade.test.tsx @@ -0,0 +1,208 @@ +// @vitest-environment jsdom +import { render, act } from "@testing-library/react"; +import { type CSSProperties, useRef, useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { useScrollFade } from "./use-scroll-fade"; + +/** jsdom reports every box as 0×0; these getters stand in for real layout. */ +function fakeScrollBox( + el: HTMLElement, + box: { client: number; scroll: number; position?: number }, +): void { + Object.defineProperty(el, "clientHeight", { configurable: true, value: box.client }); + Object.defineProperty(el, "scrollHeight", { configurable: true, value: box.scroll }); + Object.defineProperty(el, "scrollTop", { + configurable: true, + value: box.position ?? 0, + }); +} + +/** + * The hook measures through three layout getters per run; wrap the fakes so + * each measurement bumps a counter the lifecycle tests can assert against. + * `fakeScrollBox` defines these configurable, so the wrappers stack. + */ +function trackMeasures(el: HTMLElement): () => number { + let count = 0; + for (const prop of ["scrollTop", "scrollHeight", "clientHeight"]) { + const desc = Object.getOwnPropertyDescriptor(el, prop)!; + const value = desc.value; + Object.defineProperty(el, prop, { + configurable: desc.configurable, + enumerable: desc.enumerable, + get() { + count += 1; + return typeof value === "function" ? value.call(el) : value; + }, + }); + } + return () => count; +} + +// jsdom's cssstyle drops `mask-image`, so assert on the hook's return value. +function Host({ + box, + axis, + onRender, + onStyle, +}: { + box: { client: number; scroll: number; position?: number }; + axis?: "vertical" | "horizontal"; + onRender?: () => void; + onStyle?: (style: CSSProperties | undefined) => void; +}) { + const ref = useRef(null); + const [, bump] = useState(0); + const style = useScrollFade(ref, 32, axis ?? "vertical"); + onRender?.(); + onStyle?.(style); + return ( +
{ + ref.current = node; + if (node) fakeScrollBox(node, box); + }} + > + +
+ ); +} + +describe("useScrollFade", () => { + it("returns no mask for a non-scrollable box", () => { + const styles: (CSSProperties | undefined)[] = []; + render( styles.push(s)} />); + act(() => flushRaf()); + expect(styles.at(-1)).toBeUndefined(); + }); + + it("returns the end fade at the top of a scrollable box", () => { + const styles: (CSSProperties | undefined)[] = []; + render( styles.push(s)} />); + act(() => flushRaf()); + expect(String(styles.at(-1)?.maskImage)).toContain("transparent 100%"); + }); + + it("keeps the fade value when the box overflows and position does not change", () => { + // Mount and resize storms used to dispatch an identical setState per + // observer hit — hundreds per second across the shell's fade hosts. + // Identical values must not reach React at all. + const renders = vi.fn(); + const styles: (CSSProperties | undefined)[] = []; + const { container } = render( + styles.push(s)} />, + ); + act(() => flushRaf()); + const afterMount = renders.mock.calls.length; + const box = container.querySelector("div")!; + // Simulate the mount/realtime storm: far more mutations and scroll events + // than frames, with layout unchanged throughout. + act(() => { + for (let i = 0; i < 50; i++) { + box.dispatchEvent(new Event("scroll")); + box.appendChild(document.createElement("span")); + box.removeChild(box.lastChild!); + } + flushRaf(); + }); + expect(renders.mock.calls.length).toBe(afterMount); + expect(String(styles.at(-1)?.maskImage)).toContain("transparent 100%"); + }); + + it("absorbs the queued resize frame when a synchronous scroll lands first", async () => { + // Audit scenario: resize queues frame A, a synchronous scroll measures + // right away and must CANCEL A (not just drop its handle), then a + // mutation queues frame B — exactly one frame pending, two measurements + // total. + const { container } = render(); + act(() => flushRaf()); + const el = container.querySelector("div")!; + const measures = trackMeasures(el); + act(() => { + triggerResizeObservers(); + expect(pendingRaf()).toBe(1); + el.dispatchEvent(new Event("scroll")); + expect(pendingRaf()).toBe(0); + }); + // jsdom dispatches MutationObserver callbacks on a microtask. + await act(async () => { + el.appendChild(document.createElement("span")); + await Promise.resolve(); + }); + act(() => { + expect(pendingRaf()).toBe(1); + flushRaf(); + }); + expect(measures()).toBe(6); // scroll + frame B, 3 layout reads each + }); + + it("leaves no queued frame behind when unmounting after a synchronous scroll", () => { + // Audit scenario: resize queued a frame, scroll absorbed it, unmount — + // nothing may fire afterwards (the old handle drop let the frame + // survive cleanup). + const { container, unmount } = render(); + act(() => flushRaf()); + const el = container.querySelector("div")!; + const measures = trackMeasures(el); + act(() => { + triggerResizeObservers(); + el.dispatchEvent(new Event("scroll")); + }); + unmount(); + expect(pendingRaf()).toBe(0); + act(() => flushRaf()); + expect(measures()).toBe(3); // only the synchronous scroll measured + }); + + it("removes the scroll listener on cleanup", () => { + // Audit scenario: the cleanup used to remove `schedule` while the + // listener was registered as `run`, leaving the measurement attached + // across StrictMode remounts and axis changes. + const { container, unmount } = render(); + act(() => flushRaf()); + const el = container.querySelector("div")!; + const measures = trackMeasures(el); + unmount(); + const before = measures(); + el.dispatchEvent(new Event("scroll")); + expect(measures()).toBe(before); + }); + + it("rebuilds with the new axis and drops the old scroll listener", () => { + const styles: (CSSProperties | undefined)[] = []; + const { container, rerender } = render( + styles.push(s)} />, + ); + act(() => flushRaf()); + rerender( styles.push(s)} />); + act(() => flushRaf()); + const el = container.querySelector("div")!; + const measures = trackMeasures(el); + const before = measures(); + el.dispatchEvent(new Event("scroll")); + // The old axis reads scrollTop/scrollHeight/clientHeight; a leftover + // listener from the vertical closure would bump this counter. The new + // horizontal run sees no overflow in jsdom and renders no mask. + expect(measures() - before).toBe(0); + expect(styles.at(-1)).toBeUndefined(); + }); + + it("updates the fade when the scroll position crosses a boundary", () => { + const styles: (CSSProperties | undefined)[] = []; + const { container } = render( + styles.push(s)} />, + ); + act(() => flushRaf()); + const box = container.querySelector("div")!; + act(() => { + fakeScrollBox(box, { client: 100, scroll: 400, position: 150 }); + box.dispatchEvent(new Event("scroll")); + flushRaf(); + }); + const mask = String(styles.at(-1)?.maskImage ?? ""); + expect(mask).toContain("transparent 0%"); + expect(mask).toContain("transparent 100%"); + }); +}); diff --git a/packages/ui/hooks/use-scroll-fade.ts b/packages/ui/hooks/use-scroll-fade.ts index 74bee4a7c..0818fea8e 100644 --- a/packages/ui/hooks/use-scroll-fade.ts +++ b/packages/ui/hooks/use-scroll-fade.ts @@ -1,4 +1,4 @@ -import { type RefObject, type CSSProperties, useEffect, useState, useCallback } from "react"; +import { type RefObject, type CSSProperties, useEffect, useState } from "react"; export type ScrollFadeAxis = "vertical" | "horizontal"; @@ -16,52 +16,71 @@ export function useScrollFade( ): CSSProperties | undefined { const [fade, setFade] = useState<"none" | "start" | "end" | "both">("none"); - const update = useCallback(() => { - const el = ref.current; - if (!el) return; - - const position = axis === "horizontal" ? el.scrollLeft : el.scrollTop; - const scrollSize = axis === "horizontal" ? el.scrollWidth : el.scrollHeight; - const clientSize = axis === "horizontal" ? el.clientWidth : el.clientHeight; - const scrollable = scrollSize - clientSize; - - if (scrollable <= 0) { - setFade("none"); - return; - } - - const atStart = position <= 1; - const atEnd = position >= scrollable - 1; - - if (atStart && atEnd) setFade("none"); - else if (atStart) setFade("end"); - else if (atEnd) setFade("start"); - else setFade("both"); - }, [axis, ref]); - useEffect(() => { const el = ref.current; if (!el) return; - const frame = requestAnimationFrame(update); + // Resizes and child mutations funnel through one rAF-coalesced run + // (scroll stays synchronous — see its listener below). Each run reads + // layout once and calls setFade only when the value actually changes: + // with dozens of fade hosts on screen, a mount or a realtime-event + // re-render used to fire hundreds of identical dispatchSetState calls + // per second — every one of them went through React's lane + // scheduler and stretched tab switches to seconds. + let last: "none" | "start" | "end" | "both" | null = null; + let raf = 0; + const run = () => { + // A synchronous scroll absorbs any queued frame: this measurement + // covers it, and leaving the handle alive would both double-fire the + // callback and survive unmount cleanup (which sees raf === 0). + if (raf) { + cancelAnimationFrame(raf); + raf = 0; + } + const node = ref.current; + if (!node) return; + const position = axis === "horizontal" ? node.scrollLeft : node.scrollTop; + const scrollSize = axis === "horizontal" ? node.scrollWidth : node.scrollHeight; + const clientSize = axis === "horizontal" ? node.clientWidth : node.clientHeight; + const scrollable = scrollSize - clientSize; + const atStart = position <= 1; + const atEnd = position >= scrollable - 1; + const next: "none" | "start" | "end" | "both" = + scrollable <= 0 || (atStart && atEnd) ? "none" : atStart ? "end" : atEnd ? "start" : "both"; + if (next !== last) { + last = next; + setFade(next); + } + }; + const schedule = () => { + if (!raf) raf = requestAnimationFrame(run); + }; - el.addEventListener("scroll", update, { passive: true }); - const ro = new ResizeObserver(update); + schedule(); + // Scroll stays synchronous on purpose. Consumer wiring treats a scroll + // and its fade verdict as one task — chat's stick-to-bottom latch, for + // one, attributes wheel displacement to the scroll that confirms it, and + // deferring the fade update off that task reordered the handshake in a + // way its tests (and real scrolling feedback) depend on not happening. + // User scrolling is also far too rare to need coalescing; the storms come + // from resizes and DOM mutations below. + el.addEventListener("scroll", run, { passive: true }); + const ro = new ResizeObserver(schedule); ro.observe(el); // ResizeObserver only fires on the container's own box. When children // grow inside a flex/auto-sized parent, the scroll extent can change while // the viewport does not — the mask would stay "none" until the user scrolls. // MutationObserver on childList catches those content insertions. - const mo = new MutationObserver(update); + const mo = new MutationObserver(schedule); mo.observe(el, { childList: true, subtree: true }); return () => { - cancelAnimationFrame(frame); - el.removeEventListener("scroll", update); + if (raf) cancelAnimationFrame(raf); + el.removeEventListener("scroll", run); ro.disconnect(); mo.disconnect(); }; - }, [ref, update]); + }, [axis, ref]); if (fade === "none") return undefined; diff --git a/packages/ui/package.json b/packages/ui/package.json index 4a4880036..55e57ec53 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "typecheck": "tsc --noEmit", - "lint": "eslint ." + "lint": "eslint .", + "test": "vitest run" }, "exports": { "./components/ui/*": "./components/ui/*.tsx", @@ -69,10 +70,15 @@ "devDependencies": { "@multica/eslint-config": "workspace:*", "@multica/tsconfig": "workspace:*", + "@testing-library/jest-dom": "catalog:", + "@testing-library/react": "catalog:", "@types/linkify-it": "^5.0.0", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", "esbuild": "^0.25.12", - "typescript": "catalog:" + "jsdom": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/ui/test/setup.ts b/packages/ui/test/setup.ts new file mode 100644 index 000000000..921e26398 --- /dev/null +++ b/packages/ui/test/setup.ts @@ -0,0 +1,56 @@ +import { afterEach } from "vitest"; +import "@testing-library/jest-dom/vitest"; + +// jsdom ships neither class. use-scroll-fade observes both. +const roInstances: ResizeObserverStub[] = []; + +class ResizeObserverStub implements ResizeObserver { + static triggerAll(): void { + for (const instance of [...roInstances]) instance.callback([], instance); + } + + constructor(private readonly callback: ResizeObserverCallback) { + roInstances.push(this); + } + + observe(): void {} + + unobserve(): void {} + + disconnect(): void {} +} + +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = ResizeObserverStub; +} + +// jsdom never runs its own frame loop. Queue callbacks so tests can inspect +// exactly how many frames are pending and flush one batch at a time. +const rafQueue: FrameRequestCallback[] = []; +globalThis.requestAnimationFrame = (cb: FrameRequestCallback): number => { + rafQueue.push(cb); + return rafQueue.length; +}; +globalThis.cancelAnimationFrame = (handle: number): void => { + const index = handle - 1; + if (index >= 0 && index < rafQueue.length) rafQueue[index] = null as unknown as FrameRequestCallback; +}; +globalThis.flushRaf = (): void => { + const pending = rafQueue.splice(0, rafQueue.length); + for (const cb of pending) cb?.(performance.now()); +}; +globalThis.pendingRaf = (): number => + rafQueue.filter((cb) => typeof cb === "function").length; + +declare global { + var flushRaf: () => void; + var pendingRaf: () => number; + var triggerResizeObservers: () => void; +} + +globalThis.triggerResizeObservers = ResizeObserverStub.triggerAll; + +afterEach(() => { + rafQueue.length = 0; + roInstances.length = 0; +}); diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts new file mode 100644 index 000000000..d48661a41 --- /dev/null +++ b/packages/ui/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./test/setup.ts"], + include: ["**/*.test.{ts,tsx}"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b5bbcce1..15f3ad4b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -632,7 +632,7 @@ importers: version: 5.9.3 vite: specifier: ^8.0.1 - version: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) + version: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) vitest: specifier: 'catalog:' version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0)) @@ -705,7 +705,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0)) + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0)) jsdom: specifier: 'catalog:' version: 29.0.1(@noble/hashes@1.8.0) @@ -720,7 +720,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@1.21.7)(terser@5.47.1)(yaml@2.9.0)) + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0)) packages/core: dependencies: @@ -920,6 +920,12 @@ importers: '@multica/tsconfig': specifier: workspace:* version: link:../tsconfig + '@testing-library/jest-dom': + specifier: 'catalog:' + version: 6.9.1 + '@testing-library/react': + specifier: 'catalog:' + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/linkify-it': specifier: ^5.0.0 version: 5.0.0 @@ -929,12 +935,21 @@ importers: '@types/react-dom': specifier: ^19.2.0 version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0)) esbuild: specifier: ^0.25.12 version: 0.25.12 + jsdom: + specifier: 'catalog:' + version: 29.0.1(@noble/hashes@1.8.0) typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0)) packages/views: dependencies: @@ -14110,7 +14125,7 @@ snapshots: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) + vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) '@tanstack/query-core@5.96.2': {} @@ -14927,14 +14942,21 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) + vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) transitivePeerDependencies: - supports-color + '@vitejs/plugin-react@6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) + optionalDependencies: + babel-plugin-react-compiler: 1.0.0 + '@vitejs/plugin-react@6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) + vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) optionalDependencies: babel-plugin-react-compiler: 1.0.0 @@ -14947,6 +14969,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.0(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.14(@types/node@25.5.0)(typescript@5.9.3) + vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) + '@vitest/mocker@4.1.0(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@1.21.7)(terser@5.47.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.0 @@ -14963,7 +14994,7 @@ snapshots: magic-string: 0.30.21 optionalDependencies: msw: 2.12.14(@types/node@25.5.0)(typescript@5.9.3) - vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) + vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) '@vitest/pretty-format@4.1.0': dependencies: @@ -16304,7 +16335,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) + vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -17558,7 +17589,7 @@ snapshots: optionalDependencies: next: 15.5.18(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 - vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) + vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -22308,6 +22339,24 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.0.0-rc.10(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3) + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.5.0 + esbuild: 0.25.12 + fsevents: 2.3.3 + jiti: 2.7.0 + terser: 5.47.1 + yaml: 2.9.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@1.21.7)(terser@5.47.1)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -22325,22 +22374,34 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0): + vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0)): dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.5 - postcss: 8.5.26 - rolldown: 1.0.0-rc.10(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3) - tinyglobby: 0.2.17 + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.0 + '@vitest/runner': 4.1.0 + '@vitest/snapshot': 4.1.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 25.5.0 - fsevents: 2.3.3 - jiti: 2.7.0 - terser: 5.47.1 - yaml: 2.9.0 + jsdom: 29.0.1(@noble/hashes@1.8.0) transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + - msw vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@1.21.7)(terser@5.47.1)(yaml@2.9.0)): dependencies: @@ -22391,7 +22452,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) + vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1