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