import { expect, describe, it, vi, beforeEach } from "vitest";
import { render } from "vitest-browser-react";
import {
createExternalState,
createStorageState,
type ExternalWithKernel,
} from "./createExternalState";
import { shallowEqual } from "./shallowEqual";
import React, { useState } from "react";
describe("createExternalState", () => {
it("测试初始状态值", () => {
const initialState = "initial";
const state = createExternalState(initialState);
expect(state.get()).toBe(initialState);
});
it("测试set方法更新状态", () => {
const initialState = "initial";
const newState = "updated";
const state = createExternalState(initialState);
state.set(newState);
expect(state.get()).toBe(newState);
});
it("测试useState钩子在组件中使用", async () => {
const initialState = "initial";
const state = createExternalState(initialState);
function TestComponent() {
const [value, setValue] = state.useState();
return (
{value}
);
}
const { getByTestId, getByText } = render();
const valueLocator = getByTestId("value");
const buttonLocator = getByText("Update");
expect(valueLocator.element().textContent).toBe(initialState);
await buttonLocator.click();
expect(valueLocator.element().textContent).toBe("updated");
expect(state.get()).toBe("updated");
});
it("测试多个组件共享状态", async () => {
const initialState = "initial";
const state = createExternalState(initialState);
function ComponentA() {
const [value, setValue] = state.useState();
return (
{value}
);
}
function ComponentB() {
const [value, setValue] = state.useState();
return (
{value}
);
}
const { getByTestId } = render(
<>
>
);
const valueALocator = getByTestId("valueA");
const valueBLocator = getByTestId("valueB");
const buttonALocator = getByTestId("buttonA");
const buttonBLocator = getByTestId("buttonB");
expect(valueALocator.element().textContent).toBe(initialState);
expect(valueBLocator.element().textContent).toBe(initialState);
await buttonALocator.click();
expect(valueALocator.element().textContent).toBe("updatedA");
expect(valueBLocator.element().textContent).toBe("updatedA");
expect(state.get()).toBe("updatedA");
await buttonBLocator.click();
expect(valueALocator.element().textContent).toBe("updatedB");
expect(valueBLocator.element().textContent).toBe("updatedB");
expect(state.get()).toBe("updatedB");
});
it("测试组件卸载时移除监听器", () => {
const initialState = "initial";
const state = createExternalState(
initialState
) as ExternalWithKernel;
function TestComponent() {
const [value, setValue] = state.useState();
return (
{value}
);
}
expect(state.__listeners.length).toBe(0);
const { rerender, getByTestId } = render();
expect(state.__listeners.length).toBe(1);
const valueLocator = getByTestId("value");
expect(valueLocator.element().textContent).toBe(initialState);
rerender(Rerender
);
expect(state.__listeners.length).toBe(0);
});
it("测试 onSet 每次 set 都会触发", () => {
const mockOnSet = vi.fn((...args) => void 0);
const initialState: string = "initial";
const state = createExternalState(initialState, {
onSet: mockOnSet,
});
state.set("updated");
expect(mockOnSet).toHaveBeenCalledTimes(1);
expect(mockOnSet).toHaveBeenCalledWith("updated", initialState);
state.set("updated");
expect(mockOnSet).toHaveBeenCalledTimes(2);
expect(mockOnSet).toHaveBeenCalledWith("updated", "updated");
state.set("updated2");
expect(mockOnSet).toHaveBeenCalledTimes(3);
expect(mockOnSet).toHaveBeenCalledWith("updated2", "updated");
});
it("测试 onChange 仅在值变化时触发", () => {
const mockOnChange = vi.fn((...args) => void 0);
const initialState: string = "initial";
const state = createExternalState(initialState, {
onChange: mockOnChange,
});
state.set("updated");
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange).toHaveBeenCalledWith("updated", initialState);
state.set("updated");
expect(mockOnChange).toHaveBeenCalledTimes(1);
state.set("updated2");
expect(mockOnChange).toHaveBeenCalledTimes(2);
expect(mockOnChange).toHaveBeenCalledWith("updated2", "updated");
});
it("测试异步 onSet 回调", async () => {
const mockAsyncOnSet = vi.fn().mockResolvedValue(undefined);
const initialState: string = "initial";
const state = createExternalState(initialState, {
onSet: mockAsyncOnSet,
});
state.set("updated");
expect(mockAsyncOnSet).toHaveBeenCalledTimes(1);
expect(mockAsyncOnSet).toHaveBeenCalledWith("updated", initialState);
});
it("测试复杂数据类型", async () => {
interface User {
name: string;
age: number;
}
const initialUser: User = { name: "张三", age: 25 };
const state = createExternalState(initialUser);
expect(state.get()).toEqual(initialUser);
const updatedUser: User = { name: "李四", age: 30 };
state.set(updatedUser);
expect(state.get()).toEqual(updatedUser);
function TestComponent() {
const [user, setUser] = state.useState();
return (
{user.name}
{user.age}
);
}
const { getByTestId, getByText } = render();
const nameLocator = getByTestId("name");
const ageLocator = getByTestId("age");
const buttonLocator = getByText("Update");
expect(nameLocator.element().textContent).toBe("李四");
expect(ageLocator.element().textContent).toBe("30");
await buttonLocator.click();
expect(nameLocator.element().textContent).toBe("王五");
expect(ageLocator.element().textContent).toBe("35");
expect(state.get()).toEqual({ name: "王五", age: 35 });
});
});
describe("useSelector", () => {
interface AppState {
name: string;
age: number;
theme: string;
}
const createAppState = () =>
createExternalState({ name: "wwog", age: 1, theme: "light" });
it("测试只订阅切片:无关字段变化不重渲染", async () => {
const state = createAppState();
let nameRenders = 0;
let ageRenders = 0;
let fullRenders = 0;
function NameView() {
const name = state.useSelector((s) => s.name);
nameRenders++;
return {name};
}
function AgeView() {
const age = state.useSelector((s) => s.age);
ageRenders++;
return {age};
}
// 整份 state 的消费者:任意字段变化都应该重渲染
function FullView() {
const [full] = state.useState();
fullRenders++;
return {full.theme};
}
const { getByTestId, getByText } = render(
<>
>
);
const nameBaseline = nameRenders;
const ageBaseline = ageRenders;
const fullBaseline = fullRenders;
expect(getByTestId("name").element().textContent).toBe("wwog");
// 改 theme:只有整份 state 的消费者重渲染,两个切片消费者都不动
await getByText("write theme").click();
expect(getByTestId("theme").element().textContent).toBe("dark");
expect(fullRenders).toBe(fullBaseline + 1);
expect(nameRenders).toBe(nameBaseline);
expect(ageRenders).toBe(ageBaseline);
// 改 age:只有 age 消费者 + 整份消费者重渲染
await getByText("write age").click();
expect(getByTestId("age").element().textContent).toBe("2");
expect(ageRenders).toBe(ageBaseline + 1);
expect(nameRenders).toBe(nameBaseline);
expect(fullRenders).toBe(fullBaseline + 2);
// 改 name:换一个消费者动,验证两个切片消费者互不牵连
await getByText("write name").click();
expect(getByTestId("name").element().textContent).toBe("wwog!");
expect(nameRenders).toBe(nameBaseline + 1);
expect(ageRenders).toBe(ageBaseline + 1);
expect(fullRenders).toBe(fullBaseline + 3);
});
it("测试合成对象切片:默认 Object.is 会重渲染,shallowEqual 不会", async () => {
const state = createAppState();
let defaultRenders = 0;
let shallowRenders = 0;
function DefaultPick() {
// 每次都是新对象,默认 Object.is 判定为「变了」
const head = state.useSelector((s) => ({ name: s.name, age: s.age }));
defaultRenders++;
return {`${head.name}:${head.age}`};
}
function ShallowPick() {
const head = state.useSelector((s) => ({ name: s.name, age: s.age }), shallowEqual);
shallowRenders++;
return {`${head.name}:${head.age}`};
}
const { getByTestId, getByText } = render(
<>
>
);
const defaultBaseline = defaultRenders;
const shallowBaseline = shallowRenders;
// 能渲染出内容就说明没有陷入「快照永远不等」的死循环
expect(getByTestId("shallow").element().textContent).toBe("wwog:1");
// 无关字段变化:合成对象内容相同,浅比较的消费者不重渲染
await getByText("write theme").click();
expect(defaultRenders).toBe(defaultBaseline + 1);
expect(shallowRenders).toBe(shallowBaseline);
// 相关字段变化:两者都必须重渲染并读到新值——证明浅比较不是「永不更新」
await getByText("write age").click();
expect(getByTestId("default").element().textContent).toBe("wwog:2");
expect(getByTestId("shallow").element().textContent).toBe("wwog:2");
expect(shallowRenders).toBe(shallowBaseline + 1);
expect(defaultRenders).toBe(defaultBaseline + 2);
});
it("测试自定义 isEqual 生效", async () => {
const state = createAppState();
let renders = 0;
function ModuloView() {
// 只关心 age 的个位:个位相同即视为未变化,返回值沿用上一次的切片
const age = state.useSelector((s) => s.age, (next, prev) => next % 10 === prev % 10);
renders++;
return {age};
}
const { getByTestId, getByText } = render(
<>
>
);
const baseline = renders;
expect(getByTestId("age").element().textContent).toBe("1");
// 1 → 11:个位没变,不重渲染
await getByText("to 11").click();
expect(renders).toBe(baseline);
expect(getByTestId("age").element().textContent).toBe("1");
// 11 → 12:个位变了,重渲染并拿到当前值
await getByText("to 12").click();
expect(renders).toBe(baseline + 1);
expect(getByTestId("age").element().textContent).toBe("12");
});
it("测试切换 selector 后读到最新切片", async () => {
const state = createAppState();
function SwitchingView() {
const [which, setWhich] = useState<"name" | "age">("name");
// 内联 selector:每次 render 都是新引用,切换后必须读到当前切片而不是缓存
const value = state.useSelector((s) => (which === "name" ? s.name : s.age));
return (
<>
{value}
>
);
}
const { getByTestId, getByText } = render();
expect(getByTestId("value").element().textContent).toBe("wwog");
await getByText("switch").click();
expect(getByTestId("value").element().textContent).toBe("1");
await getByText("write age").click();
expect(getByTestId("value").element().textContent).toBe("7");
});
it("测试组件外 set 触发切片更新,卸载后移除监听器", async () => {
const state = createAppState() as ExternalWithKernel;
function AgeView() {
const age = state.useSelector((s) => s.age);
return {age};
}
function NameView() {
const [full] = state.useState();
return {full.name};
}
expect(state.__listeners.length).toBe(0);
const { getByTestId, rerender } = render(
<>
>
);
// useSelector 与 useState 各自注册一个订阅
expect(state.__listeners.length).toBe(2);
// 组件外直接 set:没有事件处理器,React 会异步调度这次更新,所以等待其落地
state.set((prev) => ({ ...prev, age: 9 }));
await vi.waitFor(() => {
expect(getByTestId("age").element().textContent).toBe("9");
});
rerender(Rerender
);
expect(state.__listeners.length).toBe(0);
});
it("测试 selector 返回新引用且浅比较相等时给出开发警告", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const state = createAppState();
function FreshRefView() {
// 故意不给 isEqual:每次都是新对象,无关字段变化也会重渲染
const head = state.useSelector((s) => ({ name: s.name, age: s.age }));
return {`${head.name}:${head.age}`};
}
const { getByText } = render(
<>
>
);
// 挂载本身不算「无关变化」
expect(warnSpy).not.toHaveBeenCalled();
// 改无关字段:切片只因引用变化被判为「变了」→ 提示一次
await getByText("write theme").click();
expect(warnSpy).toHaveBeenCalledTimes(1);
// 同一 hook 实例不重复刷屏
await getByText("write theme").click();
expect(warnSpy).toHaveBeenCalledTimes(1);
// 改相关字段时不再提示(切片内容真的变了,不是这个坑)
await getByText("write age").click();
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
it("测试给了 isEqual 时不会给出开发警告", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const state = createAppState();
function ShallowRefView() {
const head = state.useSelector((s) => ({ name: s.name, age: s.age }), shallowEqual);
return {`${head.name}:${head.age}`};
}
const { getByText } = render(
<>
>
);
await getByText("write theme").click();
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
it("测试 subscribe 在任意变化时触发并支持退订", () => {
const state = createAppState();
const listener = vi.fn();
const unsubscribe = state.subscribe(listener);
state.set((prev) => ({ ...prev, theme: "dark" }));
state.set((prev) => ({ ...prev, age: 2 }));
expect(listener).toHaveBeenCalledTimes(2);
unsubscribe();
state.set((prev) => ({ ...prev, age: 3 }));
expect(listener).toHaveBeenCalledTimes(2);
});
it("测试 subscribeWithSelector 只在切片变化时触发", () => {
const state = createAppState();
const listener = vi.fn();
const unsubscribe = state.subscribeWithSelector((s) => s.age, listener);
// 无关字段变化:切片没变,不触发
state.set((prev) => ({ ...prev, theme: "dark" }));
expect(listener).not.toHaveBeenCalled();
state.set((prev) => ({ ...prev, age: 2 }));
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(2, 1);
// 写成同一个值:切片没变,不触发
state.set((prev) => ({ ...prev, age: 2 }));
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
state.set((prev) => ({ ...prev, age: 3 }));
expect(listener).toHaveBeenCalledTimes(1);
});
it("测试 subscribeWithSelector 的 fireImmediately 与自定义 isEqual", () => {
const state = createAppState();
const immediate = vi.fn();
state.subscribeWithSelector((s) => s.age, immediate, { fireImmediately: true });
expect(immediate).toHaveBeenCalledTimes(1);
expect(immediate).toHaveBeenCalledWith(1, 1);
const byLength = vi.fn();
state.subscribeWithSelector((s) => s.name, byLength, {
isEqual: (next, prev) => next.length === prev.length,
});
// "wwog" → "abcd":长度相同,视为未变化
state.set((prev) => ({ ...prev, name: "abcd" }));
expect(byLength).not.toHaveBeenCalled();
// 长度变了才触发,上一次切片仍是最初的 "wwog"
state.set((prev) => ({ ...prev, name: "abcde" }));
expect(byLength).toHaveBeenCalledTimes(1);
expect(byLength).toHaveBeenCalledWith("abcde", "wwog");
});
it("测试 createStorageState 也支持 useSelector", async () => {
localStorage.clear();
const state = createStorageState("selector-key", "");
let lengthRenders = 0;
function LengthView() {
const length = state.useSelector((s) => s.length);
lengthRenders++;
return {length};
}
const { getByTestId, getByText } = render(
<>
>
);
const baseline = lengthRenders;
expect(getByTestId("length").element().textContent).toBe("0");
await getByText("write hello").click();
expect(getByTestId("length").element().textContent).toBe("5");
expect(lengthRenders).toBe(baseline + 1);
expect(localStorage.getItem("selector-key")).toBe('"hello"');
// 长度没变的内容更新:切片相同不重渲染,但存储照常写入
await getByText("write world").click();
expect(lengthRenders).toBe(baseline + 1);
expect(localStorage.getItem("selector-key")).toBe('"world"');
});
});
describe("通知与容错", () => {
it("测试单个订阅者抛错不影响其他订阅者与回调", () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const onSet = vi.fn();
const state = createExternalState(0, { onSet });
const rawGood = vi.fn();
state.subscribe(() => {
throw new Error("raw listener boom");
});
state.subscribe(rawGood);
const gatedGood = vi.fn();
// 门控 listener 抛错
state.subscribeWithSelector(
(s) => s,
() => {
throw new Error("gated listener boom");
}
);
// 门控 selector 抛错:订阅时会先算一次基准切片,所以让它只在状态变化后抛,
// 这样抛错点在 set 的循环里(订阅时抛错会直接冒泡给调用方,且不会留下注册项)
state.subscribeWithSelector((s: number): number => {
if (s > 0) {
throw new Error("gated selector boom");
}
return s;
}, vi.fn());
state.subscribeWithSelector(
(s) => s * 10,
gatedGood
);
state.set(1);
// 后面的订阅者与 onSet 都必须照常执行
expect(rawGood).toHaveBeenCalledTimes(1);
expect(gatedGood).toHaveBeenCalledWith(10, 0);
expect(onSet).toHaveBeenCalledWith(1, 0);
// 三处抛错各自被记录,而不是中断整条通知链
expect(consoleSpy).toHaveBeenCalledTimes(3);
consoleSpy.mockRestore();
});
it("测试订阅者抛错时 storage 仍会写入", () => {
localStorage.clear();
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const state = createStorageState("throw-key", "");
state.subscribe(() => {
throw new Error("boom");
});
state.set("saved");
// 落盘由 onSet 完成,它排在通知之后,不能被前面的抛错掐掉
expect(localStorage.getItem("throw-key")).toBe('"saved"');
expect(state.get()).toBe("saved");
consoleSpy.mockRestore();
});
it("测试通知过程中退订其他订阅者不会漏通知", () => {
const state = createExternalState(0) as ExternalWithKernel;
const calls: string[] = [];
let unsubscribeFirst = () => {};
unsubscribeFirst = state.subscribe(() => {
calls.push("A");
});
// B 退订了排在它前面的 A:活数组的 splice 会让后面尚未访问的订阅者被跳过
state.subscribe(() => {
calls.push("B");
unsubscribeFirst();
});
state.subscribe(() => {
calls.push("C");
});
state.set(1);
expect(calls).toEqual(["A", "B", "C"]);
expect(state.__listeners.length).toBe(2);
});
it("测试 notify: microtask 时同一轮多次 set 只通知一次", async () => {
const onSet = vi.fn();
const state = createExternalState(0, { notify: "microtask", onSet });
const listener = vi.fn();
const gated = vi.fn();
state.subscribe(listener);
state.subscribeWithSelector((s) => s, gated);
state.set(1);
state.set(2);
state.set(3);
// 通知在微任务里:set 返回时订阅者还没收到,但回调是逐次同步的
expect(listener).not.toHaveBeenCalled();
expect(gated).not.toHaveBeenCalled();
expect(onSet).toHaveBeenCalledTimes(3);
expect(state.get()).toBe(3);
await Promise.resolve();
expect(listener).toHaveBeenCalledTimes(1);
expect(gated).toHaveBeenCalledTimes(1);
// 中间态被跳过:prevSlice 仍是订阅时的切片
expect(gated).toHaveBeenCalledWith(3, 0);
// 下一轮会重新安排,基准随之上移
state.set(4);
await Promise.resolve();
expect(gated).toHaveBeenCalledTimes(2);
expect(gated).toHaveBeenCalledWith(4, 3);
});
it("测试 notify: microtask 下组件仍会更新", async () => {
const state = createExternalState({ count: 0 }, { notify: "microtask" });
function Counter() {
const count = state.useSelector((s) => s.count);
return {count};
}
const { getByTestId } = render();
expect(getByTestId("count").element().textContent).toBe("0");
state.set({ count: 1 });
state.set({ count: 2 });
await vi.waitFor(() => {
expect(getByTestId("count").element().textContent).toBe("2");
});
});
it("测试回调抛错被捕获:同步抛错立刻记录,异步拒绝稍后记录", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const syncOnSet = vi.fn(() => {
throw new Error("sync boom");
});
const asyncOnChange = vi.fn(() => Promise.reject(new Error("async boom")));
const state = createExternalState(0, { onSet: syncOnSet, onChange: asyncOnChange });
state.set(1);
// 同步抛错在 set 返回时就已记录
expect(syncOnSet).toHaveBeenCalledTimes(1);
expect(consoleSpy).toHaveBeenCalledTimes(1);
// 返回的 Promise 被拒绝时也记录,不会变成未处理的 rejection
await vi.waitFor(() => {
expect(consoleSpy).toHaveBeenCalledTimes(2);
});
consoleSpy.mockRestore();
});
it("测试大量订阅与退订后通知次数正确", () => {
const state = createExternalState(0) as ExternalWithKernel;
const listeners = Array.from({ length: 100 }, () => vi.fn());
const unsubscribers = listeners.map((listener) => state.subscribe(listener));
expect(state.__listeners.length).toBe(100);
unsubscribers.forEach((unsubscribe, index) => {
if (index % 2 === 1) unsubscribe();
});
expect(state.__listeners.length).toBe(50);
state.set(1);
listeners.forEach((listener, index) => {
if (index % 2 === 0) {
expect(listener).toHaveBeenCalledTimes(1);
} else {
expect(listener).not.toHaveBeenCalled();
}
});
});
});
describe("createStorageState", () => {
beforeEach(() => {
// 清理localStorage和sessionStorage
localStorage.clear();
sessionStorage.clear();
vi.clearAllMocks();
});
it("测试localStorage初始状态", () => {
const state = createStorageState("test-key", "initial", {
storageType: "local",
});
expect(state.get()).toBe("initial");
expect(localStorage.getItem("test-key")).toBeNull();
});
it("测试localStorage状态持久化", () => {
const state = createStorageState("test-key", "initial", {
storageType: "local",
});
state.set("updated");
expect(state.get()).toBe("updated");
expect(localStorage.getItem("test-key")).toBe('"updated"');
});
it("测试从localStorage恢复状态", () => {
// 预先设置localStorage值
localStorage.setItem("test-key", '"stored-value"');
const state = createStorageState("test-key", "initial", {
storageType: "local",
});
expect(state.get()).toBe("stored-value");
});
it("测试sessionStorage状态持久化", () => {
const state = createStorageState("test-key", "initial", {
storageType: "session",
});
state.set("session-updated");
expect(state.get()).toBe("session-updated");
expect(sessionStorage.getItem("test-key")).toBe('"session-updated"');
});
it("测试从sessionStorage恢复状态", () => {
// 预先设置sessionStorage值
sessionStorage.setItem("test-key", '"session-stored"');
const state = createStorageState("test-key", "initial", {
storageType: "session",
});
expect(state.get()).toBe("session-stored");
});
it("测试复杂对象的存储和恢复", () => {
interface User {
name: string;
age: number;
}
const initialUser: User = { name: "张三", age: 25 };
const state = createStorageState("user-key", initialUser, {
storageType: "local",
});
const updatedUser: User = { name: "李四", age: 30 };
state.set(updatedUser);
expect(state.get()).toEqual(updatedUser);
expect(JSON.parse(localStorage.getItem("user-key")!)).toEqual(updatedUser);
// 创建新实例验证恢复
const newState = createStorageState("user-key", initialUser, {
storageType: "local",
});
expect(newState.get()).toEqual(updatedUser);
});
it("测试存储解析错误处理", () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
// 设置无效的JSON数据
localStorage.setItem("test-key", "invalid-json");
const state = createStorageState("test-key", "fallback", {
storageType: "local",
});
expect(state.get()).toBe("fallback");
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to parse localStorage value for key "test-key"'),
expect.any(Error)
);
consoleSpy.mockRestore();
});
it("测试存储 onSet 回调", () => {
const mockOnSet = vi.fn();
const state = createStorageState("test-key", "initial" as string, {
storageType: "local",
onSet: mockOnSet,
});
state.set("updated");
expect(mockOnSet).toHaveBeenCalledTimes(1);
expect(mockOnSet).toHaveBeenCalledWith("updated", "initial");
expect(localStorage.getItem("test-key")).toBe('"updated"');
});
it("测试存储状态在React组件中的使用", async () => {
const state = createStorageState("component-key", "initial", {
storageType: "local",
});
function TestComponent() {
const [value, setValue] = state.useState();
return (
{value}
);
}
const { getByTestId, getByText } = render();
const valueLocator = getByTestId("value");
const buttonLocator = getByText("Update");
expect(valueLocator.element().textContent).toBe("initial");
await buttonLocator.click();
expect(valueLocator.element().textContent).toBe("component-updated");
expect(state.get()).toBe("component-updated");
expect(localStorage.getItem("component-key")).toBe('"component-updated"');
});
it("测试默认storageType为local", () => {
const state = createStorageState("default-key", "initial");
state.set("default-updated");
expect(localStorage.getItem("default-key")).toBe('"default-updated"');
expect(sessionStorage.getItem("default-key")).toBeNull();
});
it("测试序列化结果未变时跳过重复写入", () => {
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
const state = createStorageState("skip-key", { count: 0, list: [1] });
// 存储里还没有内容:第一次写入照常落盘,建立基准
state.set({ count: 0, list: [1] });
expect(setItemSpy).toHaveBeenCalledTimes(1);
expect(localStorage.getItem("skip-key")).toBe('{"count":0,"list":[1]}');
state.set({ count: 1, list: [1] });
expect(setItemSpy).toHaveBeenCalledTimes(2);
// 内容相同的另一个对象:序列化结果一致,不落盘
state.set({ count: 1, list: [1] });
expect(setItemSpy).toHaveBeenCalledTimes(2);
// 内容真的变了:照常落盘
state.set({ count: 2, list: [1] });
expect(setItemSpy).toHaveBeenCalledTimes(3);
expect(localStorage.getItem("skip-key")).toBe('{"count":2,"list":[1]}');
setItemSpy.mockRestore();
});
it("测试从存储恢复后,等值写入不再重复落盘", () => {
localStorage.setItem("restore-key", '{"a":1}');
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
const state = createStorageState("restore-key", { a: 0 });
expect(state.get()).toEqual({ a: 1 });
state.set({ a: 1 });
expect(setItemSpy).not.toHaveBeenCalled();
setItemSpy.mockRestore();
});
it("测试解析失败后下一次 set 会覆写坏数据", () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
localStorage.setItem("bad-key", "not-json");
const state = createStorageState("bad-key", "fallback");
state.set("fixed");
expect(localStorage.getItem("bad-key")).toBe('"fixed"');
expect(state.get()).toBe("fixed");
consoleSpy.mockRestore();
});
it("测试省略 storageType 时默认为 local", () => {
const onSet = vi.fn();
const state = createStorageState("onset-only-key", "initial", { onSet });
state.set("next");
expect(localStorage.getItem("onset-only-key")).toBe('"next"');
expect(sessionStorage.getItem("onset-only-key")).toBeNull();
expect(onSet).toHaveBeenCalledWith("next", "initial");
});
});
describe("跨标签页同步", () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
/** 模拟「另一个标签页写了同一个键」——storage 事件只在其它标签页触发,测试里手动派发 */
const dispatchStorage = (init: StorageEventInit) => {
window.dispatchEvent(new StorageEvent("storage", init));
};
it("测试 syncAcrossTabs 跟随其它标签页的写入,且不写回存储", () => {
localStorage.setItem("tab-key", JSON.stringify("old"));
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
const onChange = vi.fn();
const state = createStorageState("tab-key", "initial", {
syncAcrossTabs: true,
onChange,
});
dispatchStorage({
key: "tab-key",
newValue: JSON.stringify("new"),
storageArea: localStorage,
});
expect(state.get()).toBe("new");
expect(onChange).toHaveBeenCalledWith("new", "old");
// 远端值不该被写回存储,否则两个标签页会来回弹
expect(setItemSpy).not.toHaveBeenCalled();
setItemSpy.mockRestore();
});
it("测试跨标签页同步只认自己那份存储", () => {
const state = createStorageState("tab-key-area", "initial", {
syncAcrossTabs: true,
});
// 同名键但来自 sessionStorage:不能应用到 localStorage 上的 store
dispatchStorage({
key: "tab-key-area",
newValue: JSON.stringify("from-session"),
storageArea: sessionStorage,
});
expect(state.get()).toBe("initial");
dispatchStorage({
key: "tab-key-area",
newValue: JSON.stringify("from-local"),
storageArea: localStorage,
});
expect(state.get()).toBe("from-local");
});
it("测试未开启 syncAcrossTabs 时忽略 storage 事件", () => {
const state = createStorageState("tab-key-off", "initial");
dispatchStorage({ key: "tab-key-off", newValue: JSON.stringify("other") });
expect(state.get()).toBe("initial");
});
it("测试跨标签页同步忽略其它键,clear 时回到初值且不写回", () => {
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
const state = createStorageState("tab-key-clear", "initial", {
syncAcrossTabs: true,
});
// 别的键:忽略
dispatchStorage({ key: "other-key", newValue: JSON.stringify("x") });
expect(state.get()).toBe("initial");
state.set("changed");
expect(state.get()).toBe("changed");
expect(setItemSpy).toHaveBeenCalledTimes(1);
// 另一个标签页调用了 clear():key 为 null
dispatchStorage({ key: null, newValue: null });
expect(state.get()).toBe("initial");
// 不回写,避免把对方清掉的内容重新写上去
expect(setItemSpy).toHaveBeenCalledTimes(1);
setItemSpy.mockRestore();
});
it("测试跨标签页同步遇到坏数据时忽略并告警", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const state = createStorageState("tab-key-bad", "initial", {
syncAcrossTabs: true,
});
dispatchStorage({ key: "tab-key-bad", newValue: "not-json" });
expect(state.get()).toBe("initial");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("from another tab"),
expect.any(Error)
);
warnSpy.mockRestore();
});
});