From 69a8c66eed44c5da8553705b9b56e0c7744df381 Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Thu, 17 Sep 2026 10:00:12 +0300 Subject: [PATCH] feat: support multi-value discriminant matching --- .../__tests__/useEffectWhenMatch.test.ts | 105 ++++++++++++++++++ src/useEffectWhenMatch/useEffectWhenMatch.ts | 7 +- .../useEffectWhenMatch.types.ts | 4 +- .../useEffectWhenMatch.utils.ts | 14 ++- 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts b/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts index 9295416..9a1a5b4 100644 --- a/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts +++ b/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts @@ -63,6 +63,35 @@ describe("useEffectWhenMatch", () => { expect(track).toHaveBeenCalledWith("item-1"); }); + it("should run and narrow deps when the field matches one of several values", () => { + const track = vi.fn<(status: "error" | "success") => void>(); + const query = queryResult({ status: "error", error: new Error("Request failed") }); + + renderHook(() => + useEffectWhenMatch( + ([result]) => { + expectTypeOf(result).toEqualTypeOf(); + + track(result.status); + }, + [query], + "status", + ["success", "error"] as const + ) + ); + + expect(track).toHaveBeenCalledWith("error"); + }); + + it("should use array membership semantics for numeric discriminants", () => { + const effect = vi.fn(); + const result = { code: Number.NaN }; + + renderHook(() => useEffectWhenMatch(effect, [result], "code", [Number.NaN])); + + expect(effect).toHaveBeenCalledWith([result]); + }); + it("should not run when the field does not match", () => { const track = vi.fn<(id: string) => void>(); const query = queryResult({ status: "pending" }); @@ -74,6 +103,35 @@ describe("useEffectWhenMatch", () => { expect(track).not.toHaveBeenCalled(); }); + it("should call onSkip with unnarrowed deps outside the matched values", () => { + const query = queryResult({ status: "pending" }); + const onSkip = vi.fn<(deps: readonly [QueryResult]) => void>(); + + renderHook(() => + useEffectWhenMatch(() => undefined, [query], "status", ["success", "error"], { onSkip }) + ); + + expect(onSkip).toHaveBeenCalledWith([query]); + }); + + it("should call onSkip with unnarrowed deps outside a single matched value", () => { + const query = queryResult({ status: "pending" }); + const onSkip = vi.fn<(deps: readonly [QueryResult]) => void>(); + + renderHook(() => useEffectWhenMatch(() => undefined, [query], "status", "success", { onSkip })); + + expect(onSkip).toHaveBeenCalledWith([query]); + }); + + it("should never match an empty value array", () => { + const effect = vi.fn<(deps: readonly [never]) => void>(); + const query = queryResult({ status: "success", data: { id: "item-1" } }); + + renderHook(() => useEffectWhenMatch(effect, [query], "status", [] as const)); + + expect(effect).not.toHaveBeenCalled(); + }); + it("should re-run when the field changes to a match with once: false", () => { const track = vi.fn<(id: string) => void>(); @@ -95,6 +153,28 @@ describe("useEffectWhenMatch", () => { expect(track).toHaveBeenCalledTimes(2); }); + it("should re-run between different matching values with once: false", () => { + const track = vi.fn<(status: "error" | "success") => void>(); + + const { rerender } = renderHook( + ({ query }: { query: QueryResult }) => + useEffectWhenMatch( + ([result]) => track(result.status), + [query], + "status", + ["success", "error"], + { once: false } + ), + { initialProps: { query: queryResult({ status: "pending" }) } } + ); + + rerender({ query: { status: "success", data: { id: "item-2" } } }); + rerender({ query: { status: "error", error: new Error("Request failed") } }); + + expect(track).toHaveBeenNthCalledWith(1, "success"); + expect(track).toHaveBeenNthCalledWith(2, "error"); + }); + 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" }); @@ -135,5 +215,30 @@ describe("useEffectWhenMatch", () => { rerender({ query: { status: "success", data: { id: "item-2" } } }); expect(track).toHaveBeenCalledTimes(1); }); + + it("should not double-run an array match", () => { + const track = vi.fn<(status: "error" | "success") => void>(); + const wrapper = ({ children }: PropsWithChildren) => + createElement(StrictMode, null, children); + + const { rerender } = renderHook( + ({ query }: { query: QueryResult }) => + useEffectWhenMatch(([result]) => track(result.status), [query], "status", [ + "success", + "error", + ]), + { + initialProps: { + query: queryResult({ status: "error", error: new Error("Request failed") }), + }, + wrapper, + } + ); + + expect(track).toHaveBeenCalledTimes(1); + + rerender({ query: { status: "success", data: { id: "item-2" } } }); + expect(track).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/src/useEffectWhenMatch/useEffectWhenMatch.ts b/src/useEffectWhenMatch/useEffectWhenMatch.ts index 939bef4..d47de03 100644 --- a/src/useEffectWhenMatch/useEffectWhenMatch.ts +++ b/src/useEffectWhenMatch/useEffectWhenMatch.ts @@ -5,8 +5,9 @@ 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). + * equals `value` or any value in a readonly array, narrowing the effect's + * dependency to the matched variant or union of variants. An empty value array + * never matches and narrows the effect's dependency to `never`. * * This specialized helper accepts one dependency. Use `useEffectWhen` with a * custom type guard for conditions involving multiple dependencies. @@ -19,7 +20,7 @@ export function useEffectWhenMatch< effect: UseEffectWhenEffect>, deps: readonly [Q], key: K, - value: V, + value: V | ReadonlyArray, options?: UseEffectWhenOptions ): void { useEffectWhen(effect, deps, isMatch(key, value), options); diff --git a/src/useEffectWhenMatch/useEffectWhenMatch.types.ts b/src/useEffectWhenMatch/useEffectWhenMatch.types.ts index 9e74527..5df3cbc 100644 --- a/src/useEffectWhenMatch/useEffectWhenMatch.types.ts +++ b/src/useEffectWhenMatch/useEffectWhenMatch.types.ts @@ -8,5 +8,5 @@ export type Discriminant = Record; export type MatchedDeps< K extends PropertyKey, Q extends Discriminant, - V extends Q[K], -> = readonly [Extract>]; + V extends Q[K] | ReadonlyArray, +> = readonly [Extract ? E : V>>]; diff --git a/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts b/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts index 173f204..d9985a7 100644 --- a/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts +++ b/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts @@ -1,11 +1,23 @@ import type { GuardPredicate } from "../useEffectWhen"; import type { Discriminant, MatchedDeps } from "./useEffectWhenMatch.types"; +function isReadonlyArray(value: T | ReadonlyArray): value is ReadonlyArray { + return Array.isArray(value); +} + +function includesValue(values: ReadonlyArray, candidate: T): boolean { + return values.includes(candidate); +} + export function isMatch, V extends Q[K]>( key: K, - value: V + value: V | ReadonlyArray ): GuardPredicate> { return function matchesDiscriminant(deps): deps is MatchedDeps { + if (isReadonlyArray(value)) { + return includesValue(value, deps[0][key]); + } + return deps[0][key] === value; }; }