Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<QueryError | QuerySuccess>();

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" });
Expand All @@ -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>();

Expand All @@ -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" });
Expand Down Expand Up @@ -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);
});
});
});
7 changes: 4 additions & 3 deletions src/useEffectWhenMatch/useEffectWhenMatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -19,7 +20,7 @@ export function useEffectWhenMatch<
effect: UseEffectWhenEffect<MatchedDeps<K, Q, V>>,
deps: readonly [Q],
key: K,
value: V,
value: V | ReadonlyArray<V>,
options?: UseEffectWhenOptions<readonly [Q]>
): void {
useEffectWhen(effect, deps, isMatch<K, Q, V>(key, value), options);
Expand Down
4 changes: 2 additions & 2 deletions src/useEffectWhenMatch/useEffectWhenMatch.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ export type Discriminant<K extends PropertyKey> = Record<K, PropertyKey>;
export type MatchedDeps<
K extends PropertyKey,
Q extends Discriminant<K>,
V extends Q[K],
> = readonly [Extract<Q, Record<K, V>>];
V extends Q[K] | ReadonlyArray<Q[K]>,
> = readonly [Extract<Q, Record<K, V extends ReadonlyArray<infer E> ? E : V>>];
14 changes: 13 additions & 1 deletion src/useEffectWhenMatch/useEffectWhenMatch.utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import type { GuardPredicate } from "../useEffectWhen";
import type { Discriminant, MatchedDeps } from "./useEffectWhenMatch.types";

function isReadonlyArray<T>(value: T | ReadonlyArray<T>): value is ReadonlyArray<T> {
return Array.isArray(value);
}

function includesValue<T>(values: ReadonlyArray<T>, candidate: T): boolean {
return values.includes(candidate);
}

export function isMatch<K extends PropertyKey, Q extends Discriminant<K>, V extends Q[K]>(
key: K,
value: V
value: V | ReadonlyArray<V>
): GuardPredicate<readonly [Q], MatchedDeps<K, Q, V>> {
return function matchesDiscriminant(deps): deps is MatchedDeps<K, Q, V> {
if (isReadonlyArray(value)) {
return includesValue(value, deps[0][key]);
}

return deps[0][key] === value;
};
}
Loading