diff --git a/README.md b/README.md index f590524..4ffd571 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,18 @@ [![npm downloads](https://img.shields.io/npm/dm/@okyrychenko-dev/react-effect-when.svg)](https://www.npmjs.com/package/@okyrychenko-dev/react-effect-when) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -> Declarative conditional effects for React, with less boilerplate and less development noise +> Run effects only when your deps are ready — with the narrowed types to prove it -`react-effect-when` helps you run effects only when dependencies reach the state you actually care about. Its main value is expressing conditional effects declaratively, while also removing repeated `useRef` guards, `if`-based boilerplate, and some development noise around gated effects in React Strict Mode. +```tsx +useEffectWhenReady( + ([user, socket]) => { + socket.emit("identify", user.id); // both non-null, no `!` or `?.` needed + }, + [user, socket] +); +``` + +`react-effect-when` helps you run effects only when dependencies reach the state you actually care about — and, unlike a manual `if (!user || !socket) return` guard, it gives the callback a type-narrowed version of your deps, so `user` and `socket` are provably non-null inside `effect` instead of merely "probably fine." Along the way it also removes repeated `useRef` guards, `if`-based boilerplate, and some development noise around gated effects in React Strict Mode. ## What Problem It Solves @@ -27,20 +36,21 @@ Teams often respond by: ## Main Goals +- Provide strong TypeScript support for readiness and predicate-based narrowing, so `effect` receives already-narrowed deps - Replace repetitive `useRef` guards and early-return boilerplate with a declarative API - Run effects only when dependencies are actually ready, truthy, or match a custom predicate - Keep effect intent readable at the call site instead of hiding conditions inside the effect body - Preserve predictable cleanup behavior and a familiar React mental model -- Provide strong TypeScript support for readiness and predicate-based narrowing - Reduce some Strict Mode-related development noise in gated-effect scenarios without turning Strict Mode off ## Why Use It -- Run an effect only when `predicate(deps)` becomes true +- Get a type-narrowed deps tuple inside `effect` instead of `user!.id` or `user?.id` - Avoid repeating `if (!user || !socket) return` across components -- Reduce extra development noise around initialization, analytics, sockets, and one-time side effects +- Run an effect only when `predicate(deps)` becomes true - Re-run only on meaningful matches with `once: false` - Use `useEffectWhenReady` and `useEffectWhenTruthy` for common typed cases +- Reduce extra development noise around initialization, analytics, sockets, and one-time side effects - Keep public imports simple through the root package API ## Installation @@ -57,6 +67,17 @@ This package requires the following peer dependencies: - [React](https://react.dev/) ^18.0.0 || ^19.0.0 +## ESLint Integration + +`react-hooks/exhaustive-deps` does not know about `useEffectWhen` and friends by default, so it will not lint their `deps` argument. Add them via `additionalHooks`: + +```js +// eslint.config.js (or .eslintrc) +"react-hooks/exhaustive-deps": ["warn", { + additionalHooks: "(useEffectWhen|useEffectWhenReady|useEffectWhenTruthy|useEffectWhenChanged|useEffectWhenMatch)" +}] +``` + ## Quick Start ```tsx @@ -77,22 +98,6 @@ function Dashboard() { } ``` -## Strict Mode In Development - -This library can reduce some development noise when a side effect should run only after a meaningful condition is satisfied. - -Common examples: - -- analytics and tracking calls -- one-time fire-and-forget effects -- notifications and toasts -- WebSocket or channel initialization after auth is ready -- effects that should wait for fully ready data - -The goal is not to fight React or replace `useEffect`. The goal is to make effect timing explicit and convenient in the cases where plain `useEffect` becomes noisy or repetitive. - -This is not a global fix for Strict Mode re-mount behavior. With `once: true`, the effect runs once per mount lifecycle after the predicate first matches. That is useful for fire-and-forget effects, but it is usually the wrong setting for long-lived resources that return cleanup functions. - ## Core Concepts - `useEffectWhen` is the base hook. It receives the current dependency tuple and runs only when your predicate returns `true`. @@ -147,6 +152,7 @@ Use `useEffectWhen` when a side effect should run only after a meaningful condit | Conditional effect execution | Manual guards inside the effect | Usually supported | Built-in | | Wait for non-null async readiness | Manual guards | Varies | `useEffectWhenReady` | | Wait for truthy values | Manual guards | Varies | `useEffectWhenTruthy` | +| Narrow a discriminated union by any field | Manual guard + `!`/`?.` | Rare | `useEffectWhenMatch` | | Skip the initial mount | Manual `useRef` guard | Varies | `useEffectWhenChanged` | | Repeat only on meaningful matches | Manual branching | Varies | `once: false` | | Access current deps tuple in the callback | Manual closure usage | Varies | Built-in | @@ -155,11 +161,11 @@ Use `useEffectWhen` when a side effect should run only after a meaningful condit ## Key Benefits +- Type-narrowed deps: `effect` receives already-narrowed values, so ready/truthy checks don't need `!` or `?.` inside the callback - Clear intent: the condition for running the effect is visible at the call site - Less boilerplate: fewer manual refs, flags, and nested guards - Better dev ergonomics: less local effect boilerplate and less noise around gated effects - Familiar semantics: still built on top of normal React effect behavior -- Typed readiness helpers: better safety when dependencies become available ## When To Use It @@ -190,11 +196,11 @@ The problem appears when the same pattern repeats across a codebase: `react-effect-when` gives that pattern one explicit API instead of many custom versions. -## Important Boundary +## Strict Mode In Development -This library does not disable React Strict Mode, patch React behavior, or guarantee perfectly identical production behavior in every scenario. +As a side effect of gating effects on a real condition instead of "on mount," this library also reduces some development noise from React Strict Mode double-invoking analytics calls, one-time fire-and-forget effects, and notifications before their trigger condition is actually met. -What it does is make effect timing explicit and ergonomic in the cases where you want to avoid repeating local guard logic and reduce unnecessary development noise around conditional effects. +It is not a global fix for Strict Mode re-mount behavior, and it does not disable Strict Mode, patch React behavior, or guarantee identical production behavior in every scenario. With `once: true`, the effect runs once per mount lifecycle after the predicate first matches — useful for fire-and-forget effects, but usually the wrong setting for long-lived resources that return cleanup (use `once: false` there instead). ## API Reference @@ -208,6 +214,7 @@ import { predicates, useEffectWhen, useEffectWhenChanged, + useEffectWhenMatch, useEffectWhenReady, useEffectWhenTruthy, } from "@okyrychenko-dev/react-effect-when"; @@ -221,6 +228,7 @@ Start with these first: - `useEffectWhenChanged` - `useEffectWhenReady` - `useEffectWhenTruthy` +- `useEffectWhenMatch` Use `createEffectWhen` when the same predicate repeats across multiple components and deserves a named reusable hook. @@ -242,7 +250,11 @@ This hook does not debounce or throttle updates. If your input changes rapidly, ```tsx import { useEffectWhenChanged } from "@okyrychenko-dev/react-effect-when"; -function Search({ query }: { query: string }) { +type SearchProps = { + query: string; +}; + +function Search({ query }: SearchProps) { useEffectWhenChanged( ([nextQuery]) => { trackSearchChange(nextQuery); @@ -274,7 +286,11 @@ By design, `predicate`, `onSkip`, and `once` are kept fresh via refs, so they do ```tsx import { useEffectWhen } from "@okyrychenko-dev/react-effect-when"; -function Game({ score }: { score: number }) { +type GameProps = { + score: number; +}; + +function Game({ score }: GameProps) { useEffectWhen( ([currentScore]) => { showConfetti(currentScore); @@ -301,7 +317,12 @@ Runs the effect when all dependency values are non-null and non-undefined. ```tsx import { useEffectWhenReady } from "@okyrychenko-dev/react-effect-when"; -function Profile({ user, token }: { user: User | null; token: string | null }) { +type ProfileProps = { + user: User | null; + token: string | null; +}; + +function Profile({ user, token }: ProfileProps) { useEffectWhenReady( ([readyUser, readyToken]) => { trackProfileView(readyUser.id, readyToken); @@ -326,7 +347,12 @@ Runs the effect when all dependency values are truthy. ```tsx import { useEffectWhenTruthy } from "@okyrychenko-dev/react-effect-when"; -function SessionBanner({ token, isOnline }: { token: string | null; isOnline: boolean }) { +type SessionBannerProps = { + token: string | null; + isOnline: boolean; +}; + +function SessionBanner({ token, isOnline }: SessionBannerProps) { useEffectWhenTruthy( ([readyToken, online]) => { connectBannerChannel(readyToken, online); @@ -337,6 +363,51 @@ function SessionBanner({ token, isOnline }: { token: string | null; isOnline: bo } ``` +### `useEffectWhenMatch(effect, deps, key, value, options?)` + +Runs the effect when a single dependency's discriminant field equals a given value, narrowing `effect`'s dependency to that matched variant. Not limited to a `status` field — `key` can be any discriminant property, so this works for `{ status, data }` shapes (such as TanStack Query and RTK Query results), `{ kind }`/`{ type }` unions, or a reducer's own discriminant field. + +This specialized API intentionally accepts one dependency. Use `useEffectWhen` with a custom type-guard predicate when the condition spans multiple dependencies or requires more than discriminant equality. + +**Types:** + +- `Discriminant` - constrains `Q` to an object carrying a literal-valued field at key `K` +- `MatchedDeps` - the single-element tuple `effect` receives, `Q` narrowed to the variant where `Q[K]` is `V` + +**Parameters:** + +- `effect: (deps: MatchedDeps) => void | (() => void)` +- `deps: readonly [Q]` - A single-element tuple wrapping the discriminated union value +- `key: K` - The discriminant field to match on +- `value: V` - The value `deps[0][key]` must equal for the effect to run +- `options?: UseEffectWhenOptions` + +**Example:** + +```tsx +import { useEffectWhenMatch } from "@okyrychenko-dev/react-effect-when"; + +type ProductQueryPending = { status: "pending" }; +type ProductQueryError = { status: "error"; error: Error }; +type ProductQuerySuccess = { status: "success"; data: Product }; +type ProductQuery = ProductQueryPending | ProductQueryError | ProductQuerySuccess; + +type ProductAnalyticsProps = { + query: ProductQuery; +}; + +function ProductAnalytics({ query }: ProductAnalyticsProps) { + useEffectWhenMatch( + ([result]) => { + analytics.track("product_loaded", { productId: result.data.id }); // `data` is not `undefined` here + }, + [query], + "status", + "success" + ); +} +``` + ### `createEffectWhen(predicate)` Creates a reusable hook with a baked-in predicate. @@ -358,15 +429,20 @@ If `predicate` is a type guard, the returned hook preserves narrowed dependency ```tsx import { createEffectWhen, type ReadyDeps } from "@okyrychenko-dev/react-effect-when"; -const useEffectWhenAuthed = createEffectWhen< - [User | null, string | null], - ReadyDeps<[User | null, string | null]> ->( - (deps): deps is ReadyDeps<[User | null, string | null]> => - deps[0] !== null && deps[1] !== null -); +type AuthDeps = [User | null, string | null]; -function Dashboard({ user, token }: { user: User | null; token: string | null }) { +function isAuthed(deps: AuthDeps): deps is ReadyDeps { + return deps[0] !== null && deps[1] !== null; +} + +const useEffectWhenAuthed = createEffectWhen>(isAuthed); + +type DashboardProps = { + user: User | null; + token: string | null; +}; + +function Dashboard({ user, token }: DashboardProps) { useEffectWhenAuthed( ([readyUser, readyToken]) => { initializeDashboard(readyUser.id, readyToken); @@ -381,12 +457,22 @@ function Dashboard({ user, token }: { user: User | null; token: string | null }) ```tsx import { createEffectWhen, predicates, type ReadyDeps } from "@okyrychenko-dev/react-effect-when"; -const useEffectWhenReady = createEffectWhen< - [User | null, Socket | null], - ReadyDeps<[User | null, Socket | null]> ->((deps): deps is ReadyDeps<[User | null, Socket | null]> => predicates.ready(deps)); +type ConnectionDeps = [User | null, Socket | null]; + +function isConnectionReady(deps: ConnectionDeps): deps is ReadyDeps { + return predicates.ready(deps); +} -function Connection({ user, socket }: { user: User | null; socket: Socket | null }) { +const useEffectWhenReady = createEffectWhen>( + isConnectionReady +); + +type ConnectionProps = { + user: User | null; + socket: Socket | null; +}; + +function Connection({ user, socket }: ConnectionProps) { useEffectWhenReady( ([readyUser, readySocket]) => { readySocket.emit("identify", readyUser.id); @@ -402,20 +488,25 @@ function Connection({ user, socket }: { user: User | null; socket: Socket | null // hooks/useEffectWhenAuthed.ts import { createEffectWhen, type ReadyDeps } from "@okyrychenko-dev/react-effect-when"; -export const useEffectWhenAuthed = createEffectWhen< - [User | null, string | null], - ReadyDeps<[User | null, string | null]> ->( - (deps): deps is ReadyDeps<[User | null, string | null]> => - deps[0] !== null && deps[1] !== null -); +export type AuthDeps = [User | null, string | null]; + +function isAuthed(deps: AuthDeps): deps is ReadyDeps { + return deps[0] !== null && deps[1] !== null; +} + +export const useEffectWhenAuthed = createEffectWhen>(isAuthed); + +export type AuthedProps = { + user: User | null; + token: string | null; +}; ``` ```tsx // Dashboard.tsx -import { useEffectWhenAuthed } from "./hooks"; +import { useEffectWhenAuthed, type AuthedProps } from "./hooks"; -function Dashboard({ user, token }: { user: User | null; token: string | null }) { +function Dashboard({ user, token }: AuthedProps) { useEffectWhenAuthed( ([readyUser, readyToken]) => { initializeDashboard(readyUser.id, readyToken); @@ -427,9 +518,9 @@ function Dashboard({ user, token }: { user: User | null; token: string | null }) ```tsx // Notifications.tsx -import { useEffectWhenAuthed } from "./hooks"; +import { useEffectWhenAuthed, type AuthedProps } from "./hooks"; -function Notifications({ user, token }: { user: User | null; token: string | null }) { +function Notifications({ user, token }: AuthedProps) { useEffectWhenAuthed( ([readyUser, readyToken]) => { connectNotifications(readyUser.id, readyToken); @@ -470,12 +561,45 @@ useEffectWhen( ## Real-World Examples +### Sync a query's data only once it has actually loaded + +```tsx +import { useEffectWhenMatch } from "@okyrychenko-dev/react-effect-when"; + +type ProductQueryPending = { status: "pending" }; +type ProductQueryError = { status: "error"; error: Error }; +type ProductQuerySuccess = { status: "success"; data: Product }; +type ProductQuery = ProductQueryPending | ProductQueryError | ProductQuerySuccess; + +type ProductDetailsProps = { + query: ProductQuery; +}; + +function ProductDetails({ query }: ProductDetailsProps) { + useEffectWhenMatch( + ([result]) => { + document.title = result.data.name; // `data` is narrowed, not `Product | undefined` + }, + [query], + "status", + "success" + ); +} +``` + +TanStack Query already models its result as a discriminated union, so checking `query.status === "success"` narrows the complete result object. The value of `useEffectWhenMatch` is ergonomic: it moves that repeated runtime check out of the effect body and passes only the matched variant into the callback. The same hook also works for `{ kind }`, `{ type }`, or any other discriminant. + ### Prevent analytics from firing twice in development ```tsx import { useEffectWhen } from "@okyrychenko-dev/react-effect-when"; -function ProductPage({ productId, isReady }: { productId: string; isReady: boolean }) { +type ProductPageProps = { + productId: string; + isReady: boolean; +}; + +function ProductPage({ productId, isReady }: ProductPageProps) { useEffectWhen( ([id]) => { analytics.track("product_view", { productId: id }); @@ -493,13 +617,12 @@ This is a common React Strict Mode double-invoke pain point in development when ```tsx import { useEffectWhenReady } from "@okyrychenko-dev/react-effect-when"; -function RealtimeConnection({ - userId, - authToken, -}: { +type RealtimeConnectionProps = { userId: string | null; authToken: string | null; -}) { +}; + +function RealtimeConnection({ userId, authToken }: RealtimeConnectionProps) { useEffectWhenReady( ([readyUserId, readyToken]) => { const socket = connectSocket({ userId: readyUserId, token: readyToken }); @@ -521,7 +644,11 @@ This keeps WebSocket setup declarative and avoids scattering `if (!userId || !au ```tsx import { useEffectWhen } from "@okyrychenko-dev/react-effect-when"; -function Modal({ isOpen }: { isOpen: boolean }) { +type ModalProps = { + isOpen: boolean; +}; + +function Modal({ isOpen }: ModalProps) { useEffectWhen( ([open]) => { toast.info("Modal opened"); @@ -541,7 +668,11 @@ This is useful when development re-mounts would otherwise create extra toast or ```tsx import { useEffectWhen } from "@okyrychenko-dev/react-effect-when"; -function Modal({ isOpen }: { isOpen: boolean }) { +type ModalProps = { + isOpen: boolean; +}; + +function Modal({ isOpen }: ModalProps) { useEffectWhen( ([open]) => { fetchModalData(open); diff --git a/package-lock.json b/package-lock.json index a0eb26e..02d0ce2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "tsup": "^8.5.1", "typescript": "^5.3.3", "typescript-eslint": "^8.46.4", - "vitest": "^4.0.9" + "vitest": "^4.1.10" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" @@ -323,40 +323,6 @@ "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", - "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -1090,32 +1056,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", - "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" - } - }, "node_modules/@oxc-project/types": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", - "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", "dev": true, "license": "MIT", "funding": { @@ -1130,9 +1074,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", - "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", "cpu": [ "arm64" ], @@ -1147,9 +1091,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", - "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", "cpu": [ "arm64" ], @@ -1164,9 +1108,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", - "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", "cpu": [ "x64" ], @@ -1181,9 +1125,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", - "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", "cpu": [ "x64" ], @@ -1198,9 +1142,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", - "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", "cpu": [ "arm" ], @@ -1215,9 +1159,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", - "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", "cpu": [ "arm64" ], @@ -1235,9 +1179,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", - "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", "cpu": [ "arm64" ], @@ -1255,9 +1199,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", - "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", "cpu": [ "ppc64" ], @@ -1275,9 +1219,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", - "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", "cpu": [ "s390x" ], @@ -1295,9 +1239,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", - "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", "cpu": [ "x64" ], @@ -1315,9 +1259,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", - "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", "cpu": [ "x64" ], @@ -1335,9 +1279,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", - "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", "cpu": [ "arm64" ], @@ -1351,26 +1295,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", - "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "2.0.0-alpha.3", - "@emnapi/runtime": "2.0.0-alpha.3", - "@napi-rs/wasm-runtime": "^1.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", - "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", "cpu": [ "arm64" ], @@ -1385,9 +1313,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", - "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", "cpu": [ "x64" ], @@ -1860,17 +1788,6 @@ } } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2189,14 +2106,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", - "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2210,8 +2127,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.7", - "vitest": "4.1.7" + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2220,16 +2137,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2238,13 +2155,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.7", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2265,9 +2182,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2278,13 +2195,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -2292,14 +2209,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2308,9 +2225,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -2318,13 +2235,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.7.tgz", - "integrity": "sha512-TP6utB2yX6rsJNVRo2qAlsi48i1YwFTrLV2tnTtWqJaYX7m4lRCCLirZBjU6xC5m0RsPHr+L2+N+eIPhgEzFfw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.10.tgz", + "integrity": "sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.10", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -2336,17 +2253,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.7" + "vitest": "4.1.10" } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -6125,13 +6042,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", - "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.142.0", + "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -6141,21 +6058,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.1", - "@rolldown/binding-darwin-arm64": "1.2.1", - "@rolldown/binding-darwin-x64": "1.2.1", - "@rolldown/binding-freebsd-x64": "1.2.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", - "@rolldown/binding-linux-arm64-gnu": "1.2.1", - "@rolldown/binding-linux-arm64-musl": "1.2.1", - "@rolldown/binding-linux-ppc64-gnu": "1.2.1", - "@rolldown/binding-linux-s390x-gnu": "1.2.1", - "@rolldown/binding-linux-x64-gnu": "1.2.1", - "@rolldown/binding-linux-x64-musl": "1.2.1", - "@rolldown/binding-openharmony-arm64": "1.2.1", - "@rolldown/binding-wasm32-wasi": "1.2.1", - "@rolldown/binding-win32-arm64-msvc": "1.2.1", - "@rolldown/binding-win32-x64-msvc": "1.2.1" + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" } }, "node_modules/rollup": { @@ -6827,14 +6743,6 @@ "json5": "lib/cli.js" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/tsup": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", @@ -7102,16 +7010,16 @@ } }, "node_modules/vite": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", - "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.23", - "rolldown": "~1.2.0", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "bin": { @@ -7180,19 +7088,19 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -7220,12 +7128,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 6d3d8c8..a0a0ca1 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,22 @@ { "name": "@okyrychenko-dev/react-effect-when", "version": "1.2.0", - "description": "Declarative conditional effects for React with less Strict Mode noise in development", + "description": "Run effects only when your deps are ready — with the narrowed types to prove it", "keywords": [ "react", "hook", "useeffect", - "strict-mode", - "strictmode", - "double-invoke", - "double-render", + "type-safe", + "narrowing", + "type-narrowing", + "guard-predicate", "effect", "conditional-effect", - "useeffect-strict-mode", "useeffect-guard", "predicate", "typescript", "react-hooks", - "development" + "strict-mode" ], "homepage": "https://github.com/okyrychenko-dev/react-effect-when#readme", "bugs": { @@ -91,7 +90,7 @@ "tsup": "^8.5.1", "typescript": "^5.3.3", "typescript-eslint": "^8.46.4", - "vitest": "^4.0.9" + "vitest": "^4.1.10" }, "allowScripts": { "esbuild@0.27.4": true diff --git a/src/index.ts b/src/index.ts index 54740ed..5e0f311 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export { useEffectWhen, predicates } from "./useEffectWhen"; export { useEffectWhenReady } from "./useEffectWhenReady"; export { useEffectWhenTruthy } from "./useEffectWhenTruthy"; export { useEffectWhenChanged } from "./useEffectWhenChanged"; +export { useEffectWhenMatch } from "./useEffectWhenMatch"; export { createEffectWhen } from "./createEffectWhen"; export type { Falsy, @@ -13,3 +14,4 @@ export type { UseEffectWhenOptions, UseEffectWhenPredicates, } from "./useEffectWhen"; +export type { Discriminant, MatchedDeps } from "./useEffectWhenMatch"; diff --git a/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts b/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts new file mode 100644 index 0000000..9295416 --- /dev/null +++ b/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts @@ -0,0 +1,139 @@ +import { renderHook } from "@testing-library/react"; +import { type PropsWithChildren, StrictMode, createElement } from "react"; +import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import { useEffectWhenMatch } from "../useEffectWhenMatch"; + +interface QueryData { + id: string; +} + +interface QueryPending { + status: "pending"; +} + +interface QueryError { + status: "error"; + error: Error; +} + +interface QuerySuccess { + status: "success"; + data: QueryData; +} + +type QueryResult = QueryPending | QueryError | QuerySuccess; + +interface JobQueued { + kind: "queued"; +} + +interface JobDone { + kind: "done"; + output: string; +} + +type Job = JobQueued | JobDone; + +function queryResult(result: QueryResult): QueryResult { + return result; +} + +function jobResult(result: Job): Job { + return result; +} + +describe("useEffectWhenMatch", () => { + it("should run and narrow deps when the field matches", () => { + const track = vi.fn<(id: string) => void>(); + const query = queryResult({ status: "success", data: { id: "item-1" } }); + + renderHook(() => + useEffectWhenMatch( + ([result]) => { + expectTypeOf(result.data).toEqualTypeOf(); + + track(result.data.id); + }, + [query], + "status", + "success" + ) + ); + + expect(track).toHaveBeenCalledWith("item-1"); + }); + + it("should not run when the field does not match", () => { + const track = vi.fn<(id: string) => void>(); + const query = queryResult({ status: "pending" }); + + renderHook(() => + useEffectWhenMatch(([result]) => track(result.data.id), [query], "status", "success") + ); + + expect(track).not.toHaveBeenCalled(); + }); + + it("should re-run when the field changes to a match with once: false", () => { + const track = vi.fn<(id: string) => void>(); + + const { rerender } = renderHook( + ({ query }: { query: QueryResult }) => + useEffectWhenMatch(([result]) => track(result.data.id), [query], "status", "success", { + once: false, + }), + { initialProps: { query: { status: "pending" } } } + ); + + expect(track).not.toHaveBeenCalled(); + + rerender({ query: { status: "success", data: { id: "item-2" } } }); + expect(track).toHaveBeenCalledWith("item-2"); + + rerender({ query: { status: "success", data: { id: "item-3" } } }); + expect(track).toHaveBeenCalledWith("item-3"); + expect(track).toHaveBeenCalledTimes(2); + }); + + it("should work with any discriminant field name, not just `status`", () => { + const track = vi.fn<(output: string) => void>(); + const job = jobResult({ kind: "done", output: "result-1" }); + + renderHook(() => + useEffectWhenMatch( + ([result]) => { + expectTypeOf(result.output).toEqualTypeOf(); + + track(result.output); + }, + [job], + "kind", + "done" + ) + ); + + expect(track).toHaveBeenCalledWith("result-1"); + }); + + describe("Strict Mode behavior", () => { + it("should not re-run on rerender inside Strict Mode when once is true", () => { + const track = vi.fn<(id: string) => void>(); + const wrapper = ({ children }: PropsWithChildren) => + createElement(StrictMode, null, children); + + const { rerender } = renderHook( + ({ query }: { query: QueryResult }) => + useEffectWhenMatch(([result]) => track(result.data.id), [query], "status", "success"), + { + initialProps: { query: { status: "success", data: { id: "item-1" } } }, + wrapper, + } + ); + + expect(track).toHaveBeenCalledTimes(1); + + rerender({ query: { status: "success", data: { id: "item-2" } } }); + expect(track).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/useEffectWhenMatch/index.ts b/src/useEffectWhenMatch/index.ts new file mode 100644 index 0000000..6ca7e9c --- /dev/null +++ b/src/useEffectWhenMatch/index.ts @@ -0,0 +1,2 @@ +export { useEffectWhenMatch } from "./useEffectWhenMatch"; +export type { Discriminant, MatchedDeps } from "./useEffectWhenMatch.types"; diff --git a/src/useEffectWhenMatch/useEffectWhenMatch.ts b/src/useEffectWhenMatch/useEffectWhenMatch.ts new file mode 100644 index 0000000..939bef4 --- /dev/null +++ b/src/useEffectWhenMatch/useEffectWhenMatch.ts @@ -0,0 +1,26 @@ +import { useEffectWhen } from "../useEffectWhen"; +import { isMatch } from "./useEffectWhenMatch.utils"; +import type { UseEffectWhenEffect, UseEffectWhenOptions } from "../useEffectWhen"; +import type { Discriminant, MatchedDeps } from "./useEffectWhenMatch.types"; + +/** + * Gates an effect on a discriminated union at `deps[0]` whose field at `key` + * equals `value`, narrowing the effect's dependency to that matched variant + * (e.g. a query result narrowed to its "success" shape). + * + * This specialized helper accepts one dependency. Use `useEffectWhen` with a + * custom type guard for conditions involving multiple dependencies. + */ +export function useEffectWhenMatch< + K extends PropertyKey, + Q extends Discriminant, + V extends Q[K], +>( + effect: UseEffectWhenEffect>, + deps: readonly [Q], + key: K, + value: V, + options?: UseEffectWhenOptions +): void { + useEffectWhen(effect, deps, isMatch(key, value), options); +} diff --git a/src/useEffectWhenMatch/useEffectWhenMatch.types.ts b/src/useEffectWhenMatch/useEffectWhenMatch.types.ts new file mode 100644 index 0000000..9e74527 --- /dev/null +++ b/src/useEffectWhenMatch/useEffectWhenMatch.types.ts @@ -0,0 +1,12 @@ +/** An object discriminated by a literal-valued field at key `K`. */ +export type Discriminant = Record; + +/** + * Narrows a single dependency `Q` (discriminated by `K`) down to the variant + * whose `K` field equals `V`. + */ +export type MatchedDeps< + K extends PropertyKey, + Q extends Discriminant, + V extends Q[K], +> = readonly [Extract>]; diff --git a/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts b/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts new file mode 100644 index 0000000..173f204 --- /dev/null +++ b/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts @@ -0,0 +1,11 @@ +import type { GuardPredicate } from "../useEffectWhen"; +import type { Discriminant, MatchedDeps } from "./useEffectWhenMatch.types"; + +export function isMatch, V extends Q[K]>( + key: K, + value: V +): GuardPredicate> { + return function matchesDiscriminant(deps): deps is MatchedDeps { + return deps[0][key] === value; + }; +} diff --git a/tsup.config.ts b/tsup.config.ts index a53fbb0..669e116 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,5 +1,40 @@ +import { readFile, writeFile } from "node:fs/promises"; import { defineConfig } from "tsup"; +const USE_CLIENT_DIRECTIVE = '"use client";\n'; +const OUTPUT_FILES = ["dist/index.js", "dist/index.cjs"]; + +function prependSourceMapLine(sourceMap: unknown, file: string): unknown { + if ( + typeof sourceMap !== "object" || + sourceMap === null || + !("mappings" in sourceMap) || + typeof sourceMap.mappings !== "string" + ) { + throw new TypeError(`Invalid source map emitted for ${file}`); + } + + sourceMap.mappings = `;${sourceMap.mappings}`; + + return sourceMap; +} + +async function prependUseClientDirective(file: string): Promise { + const contents = await readFile(file, "utf8"); + + if (contents.startsWith(USE_CLIENT_DIRECTIVE)) { + return; + } + + await writeFile(file, USE_CLIENT_DIRECTIVE + contents); + + const sourceMapFile = `${file}.map`; + const sourceMap: unknown = JSON.parse(await readFile(sourceMapFile, "utf8")); + const shiftedSourceMap = prependSourceMapLine(sourceMap, sourceMapFile); + + await writeFile(sourceMapFile, `${JSON.stringify(shiftedSourceMap)}\n`); +} + export default defineConfig({ entry: ["src/index.ts"], format: ["cjs", "esm"], @@ -10,4 +45,7 @@ export default defineConfig({ external: ["react"], treeshake: true, minify: false, + async onSuccess() { + await Promise.all(OUTPUT_FILES.map(prependUseClientDirective)); + }, });