import {
ReadonlySignal,
computed,
signal,
} from "@preact-signals/unified-signals";
import React, { PropsWithChildren } from "react";
import { assert, describe, expect, expectTypeOf, it, vi } from "vitest";
import { $, ReactiveRef } from "../lib/$";
import { ReactiveProps, reactifyLite, withSignalProps } from "../lib/hocs";
import { reactify } from "../lib/hocs/reactify";
import { itRenderer } from "./utils";
describe("withSignalProps()", () => {
for (const valueType of ["signal", "Uncached"] as const) {
itRenderer(
`should force rerender dependent component (${valueType})`,
async ({ act, reactRoot, expect, root }) => {
const B = withSignalProps(
vi.fn((props: { value: number }) =>
{props.value}
)
);
const sig = signal(10);
await reactRoot().render(
sig.value)} />
);
expect(B).toHaveBeenCalledTimes(1);
expect(B).toHaveBeenCalledWith({ value: 10 }, {});
expect(root.firstChild).is.instanceOf(HTMLDivElement);
expect(root.firstChild).has.property("textContent", "10");
await act(() => {
sig.value = 20;
});
expect(B).toHaveBeenCalledTimes(2);
expect(B).toHaveBeenCalledWith({ value: 20 }, {});
expect(root.firstChild).is.instanceOf(HTMLDivElement);
expect(root.firstChild).has.property("textContent", "20");
}
);
}
it("should add prefix to props", () => {
const B = withSignalProps(vi.fn((props: { value: number }) => null));
assert(
B.displayName?.startsWith("WithSignalProps."),
"displayName incorrect"
);
});
itRenderer(
"should not rerender when unread signal changed",
async ({ expect, act, reactRoot }) => {
const B = withSignalProps(vi.fn((props: { value: number }) => null));
const sig = signal(10);
await reactRoot().render( sig.value)} />);
expect(B).toHaveBeenCalledTimes(1);
expect(B).toHaveBeenCalledWith({ value: 10 }, {});
await act(() => {
sig.value = 20;
});
expect(B).toHaveBeenCalledOnce();
}
);
it("should handle types", () => {
const B = withSignalProps((props: PropsWithChildren<{ value: number }>) => (
{props.value}
));
expectTypeOf(B)
.parameter(0)
.toHaveProperty("value")
.toEqualTypeOf | ReadonlySignal>();
expectTypeOf(B)
.parameter(0)
.toHaveProperty("children")
.toEqualTypeOf();
});
});
describe("reactifyLite()", () => {
it("should handle explicitly defined reactive props", () => {
const A = reactifyLite((props: ReactiveProps<{ value: number }>) => (
{props.value}
));
expectTypeOf(A)
.parameter(0)
.toHaveProperty("value")
.toEqualTypeOf | ReadonlySignal>();
});
it("should throw on not explicitly defined reactive props", () => {
const B = reactifyLite((props: { value: number }) => (
{props.value}
));
expectTypeOf(B).parameter(0).toBeNever();
});
itRenderer(
"should rerender when read signal changed",
async ({ expect, act, root, reactRoot }) => {
const sig = signal(10);
/**
* @useSignals
*/
const aRender = vi.fn((props: ReactiveProps<{ value: number }>) => (
{props.value}
));
const A = reactifyLite(aRender);
await act(() => reactRoot().render());
expect(A).toHaveBeenCalledTimes(1);
expect(A).toHaveBeenCalledWith({ value: 10 }, {});
expect(root.firstChild).is.instanceOf(HTMLDivElement);
expect(root.firstChild).has.property("textContent", "10");
await act(() => {
sig.value = 20;
});
expect(A).toHaveBeenCalledTimes(2);
expect(A).toHaveBeenCalledWith({ value: 20 }, {});
expect(root.firstChild).is.instanceOf(HTMLDivElement);
expect(root.firstChild).has.property("textContent", "20");
}
);
itRenderer(
"should not rerender when unread signal changed",
async ({ act, reactRoot, expect }) => {
const sig = signal(10);
const aRender = vi.fn((props: ReactiveProps<{ value: number }>) => null);
const A = reactifyLite(aRender);
await act(() => reactRoot().render());
expect(A).toHaveBeenCalledTimes(1);
expect(A).toHaveBeenCalledWith({ value: 10 }, {});
await act(() => {
sig.value = 20;
});
expect(A).toHaveBeenCalledTimes(1);
}
);
itRenderer(
"should update props when regular props changed",
async ({ act, reactRoot, expect }) => {
const sig = signal(10);
/**
* @useSignals
*/
const aRender = vi.fn(
(props: ReactiveProps<{ value: number }>) => props.value
);
const A = reactifyLite(aRender);
const B = () => ;
await act(() => reactRoot().render());
expect(A).toHaveBeenCalledTimes(1);
expect(A).lastCalledWith({ value: 10 }, {});
await act(() => {
sig.value = 20;
});
expect(A).toHaveBeenCalledTimes(2);
expect(A).lastCalledWith({ value: 20 }, {});
}
);
itRenderer(
"should update regular props reactively",
async ({ act, reactRoot, expect }) => {
const sig = signal(10);
let cmp: null | ReadonlySignal = null;
const aRender = vi.fn((props: ReactiveProps<{ value: number }>) => {
if (!cmp) {
cmp = computed(() => props.value);
}
return null;
});
const A = reactifyLite(aRender);
const B = () => ;
await act(() => reactRoot().render());
expect(A).toHaveBeenCalledTimes(1);
expect(A).lastCalledWith({ value: 10 }, {});
await act(() => {
sig.value = 20;
});
expect(A).toHaveBeenCalledTimes(2);
expect(A).lastCalledWith({ value: 20 }, {});
expect(cmp!.value).toBe(20);
}
);
itRenderer(
"should not allow to access previous omitted prop",
async ({ act, reactRoot, expect }) => {
const sig = signal(10);
const aRender = vi.fn((props: ReactiveProps<{ value?: number }>) => {
return null;
});
const A = reactifyLite(aRender);
const B = () => ;
await act(() => reactRoot().render());
expect(A).toHaveBeenCalledTimes(1);
expect(A).toHaveBeenLastCalledWith({ value: 10 }, {});
await act(() => {
sig.value = 20;
});
expect(A).toHaveBeenCalledTimes(2);
expect(A).toHaveBeenLastCalledWith({}, {});
}
);
});
describe("reactify()", () => {
itRenderer(
"should support value$ postfix",
async ({ expect, root, reactRoot }) => {
const A = reactify(
vi.fn((props: ReactiveProps<{ value: number }>) => (
{props.value}
))
);
await reactRoot().render( 10} />);
expect(A).toHaveBeenCalledTimes(1);
expect(A).toHaveBeenCalledWith({ value: 10 }, {});
expect(root.firstChild).is.instanceOf(HTMLDivElement);
expect(root.firstChild).has.property("textContent", "10");
}
);
for (const valueType of ["signal", "uncached", "$postfix"] as const) {
itRenderer(
`should rerender when deps changed. deps type: ${valueType}}`,
async ({ expect, act, root, reactRoot }) => {
const A = reactify(
vi.fn((props: ReactiveProps<{ value: number }>) => (
{props.value}
))
);
const sig = signal(10);
if (valueType === "$postfix") {
await act(() => reactRoot().render( sig.value} />));
} else {
await reactRoot().render(
sig.value)} />
);
}
expect(A).toHaveBeenCalledTimes(1);
expect(A).toHaveBeenCalledWith({ value: 10 }, {});
expect(root.firstChild).is.instanceOf(HTMLDivElement);
expect(root.firstChild).has.property("textContent", "10");
await act(() => {
sig.value = 20;
});
expect(A).toHaveBeenCalledTimes(2);
expect(A).toHaveBeenCalledWith({ value: 20 }, {});
expect(root.firstChild).is.instanceOf(HTMLDivElement);
expect(root.firstChild).has.property("textContent", "20");
}
);
}
itRenderer(
"should not rerender when unread signal changed",
async ({ expect, act, reactRoot }) => {
const A = reactify(
vi.fn((props: ReactiveProps<{ value: number }>) => null)
);
const sig = signal(10);
await reactRoot().render();
expect(A).toHaveBeenCalledTimes(1);
expect(A).toHaveBeenCalledWith({ value: 10 }, {});
await act(() => {
sig.value = 20;
});
expect(A).toHaveBeenCalledTimes(1);
}
);
itRenderer(
"should correctly pass props",
async ({ expect, act, reactRoot }) => {
const A = reactify(
vi.fn(
(
props: ReactiveProps<{
a: number;
b: () => void;
c: ReadonlySignal;
}>
) => null
)
);
const sig = signal(10);
const noop = () => {};
await reactRoot().render( 10} b={noop} c$={() => sig} />);
expect(A).toHaveBeenCalledTimes(1);
expect(A).toHaveBeenCalledWith({ a: 10, b: noop, c: sig }, {});
}
);
});