fix(characters): reset route actors on character change

This commit is contained in:
2026-07-17 19:00:39 +08:00
parent 11c1747a68
commit 5bfb71e2b8
5 changed files with 213 additions and 7 deletions
@@ -1,11 +1,23 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { notFound } from "next/navigation";
import { getCharacterBySlug } from "@/data/constants/character";
import { PaymentRouteProvider } from "@/providers/payment-route-provider"; import { PaymentRouteProvider } from "@/providers/payment-route-provider";
export default function CharacterTipLayout({ export default async function CharacterTipLayout({
children, children,
params,
}: { }: {
children: ReactNode; children: ReactNode;
params: Promise<{ characterSlug: string }>;
}) { }) {
return <PaymentRouteProvider>{children}</PaymentRouteProvider>; const { characterSlug } = await params;
const character = getCharacterBySlug(characterSlug);
if (!character) notFound();
return (
<PaymentRouteProvider scopeKey={character.id}>
{children}
</PaymentRouteProvider>
);
} }
@@ -0,0 +1,186 @@
/* @vitest-environment jsdom */
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const instances = vi.hoisted(() => ({
chat: 0,
payment: 0,
privateRoom: 0,
}));
vi.mock("@/stores/chat/chat-context", async () => {
const { createElement, useState } = await import("react");
return {
ChatProvider({
characterId,
children,
}: {
characterId: string;
children: ReactNode;
}) {
const [instanceId] = useState(() => ++instances.chat);
return createElement(
"div",
{
"data-character-id": characterId,
"data-instance-id": instanceId,
"data-testid": "chat-provider",
},
children,
);
},
};
});
vi.mock("@/stores/payment/payment-context", async () => {
const { createElement, useState } = await import("react");
return {
PaymentProvider({ children }: { children: ReactNode }) {
const [instanceId] = useState(() => ++instances.payment);
return createElement(
"div",
{
"data-instance-id": instanceId,
"data-testid": "payment-provider",
},
children,
);
},
};
});
vi.mock("@/stores/private-room", async () => {
const { createElement, useState } = await import("react");
return {
PrivateRoomProvider({
characterId,
children,
}: {
characterId: string;
children: ReactNode;
}) {
const [instanceId] = useState(() => ++instances.privateRoom);
return createElement(
"div",
{
"data-character-id": characterId,
"data-instance-id": instanceId,
"data-testid": "private-room-provider",
},
children,
);
},
};
});
vi.mock("@/stores/sync/chat-auth-sync", () => ({ ChatAuthSync: () => null }));
vi.mock("@/stores/sync/chat-payment-success-sync", () => ({
ChatPaymentSuccessSync: () => null,
}));
vi.mock("@/stores/sync/payment-success-sync", () => ({
PaymentSuccessSync: () => null,
}));
import { ChatRouteProviders } from "@/providers/chat-route-providers";
import { PaymentRouteProvider } from "@/providers/payment-route-provider";
import { PrivateRoomRouteProvider } from "@/providers/private-room-route-provider";
describe("character actor route providers", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
instances.chat = 0;
instances.payment = 0;
instances.privateRoom = 0;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("recreates chat and chat payment actors when the character changes", () => {
renderChat("character_elio");
const firstChatInstance = instanceId("chat-provider");
const firstPaymentInstance = instanceId("payment-provider");
renderChat("character_maya");
expect(instanceId("chat-provider")).not.toBe(firstChatInstance);
expect(instanceId("payment-provider")).not.toBe(firstPaymentInstance);
expect(testNode("chat-provider").dataset.characterId).toBe(
"character_maya",
);
});
it("recreates the private-room actor when the character changes", () => {
renderPrivateRoom("character_elio");
const firstInstance = instanceId("private-room-provider");
renderPrivateRoom("character_maya");
expect(instanceId("private-room-provider")).not.toBe(firstInstance);
expect(testNode("private-room-provider").dataset.characterId).toBe(
"character_maya",
);
});
it("recreates the route payment actor when its character scope changes", () => {
renderPayment("character_elio");
const firstInstance = instanceId("payment-provider");
renderPayment("character_maya");
expect(instanceId("payment-provider")).not.toBe(firstInstance);
});
function renderChat(characterId: string): void {
act(() => {
root.render(
<ChatRouteProviders characterId={characterId}>
<span>chat</span>
</ChatRouteProviders>,
);
});
}
function renderPrivateRoom(characterId: string): void {
act(() => {
root.render(
<PrivateRoomRouteProvider characterId={characterId}>
<span>private room</span>
</PrivateRoomRouteProvider>,
);
});
}
function renderPayment(scopeKey: string): void {
act(() => {
root.render(
<PaymentRouteProvider scopeKey={scopeKey}>
<span>payment</span>
</PaymentRouteProvider>,
);
});
}
function testNode(testId: string): HTMLElement {
const node = container.querySelector<HTMLElement>(
`[data-testid="${testId}"]`,
);
if (!node) throw new Error(`Missing ${testId}`);
return node;
}
function instanceId(testId: string): string | undefined {
return testNode(testId).dataset.instanceId;
}
});
+2 -2
View File
@@ -19,9 +19,9 @@ export function ChatRouteProviders({
characterId = DEFAULT_CHARACTER_ID, characterId = DEFAULT_CHARACTER_ID,
}: ChatRouteProvidersProps) { }: ChatRouteProvidersProps) {
return ( return (
<PaymentProvider> <PaymentProvider key={characterId}>
<PaymentSuccessSync /> <PaymentSuccessSync />
<ChatProvider characterId={characterId}> <ChatProvider key={characterId} characterId={characterId}>
<ChatAuthSync /> <ChatAuthSync />
<ChatPaymentSuccessSync /> <ChatPaymentSuccessSync />
{children} {children}
+10 -2
View File
@@ -5,9 +5,17 @@ import type { ReactNode } from "react";
import { PaymentProvider } from "@/stores/payment/payment-context"; import { PaymentProvider } from "@/stores/payment/payment-context";
import { PaymentSuccessSync } from "@/stores/sync/payment-success-sync"; import { PaymentSuccessSync } from "@/stores/sync/payment-success-sync";
export function PaymentRouteProvider({ children }: { children: ReactNode }) { export interface PaymentRouteProviderProps {
children: ReactNode;
scopeKey?: string;
}
export function PaymentRouteProvider({
children,
scopeKey,
}: PaymentRouteProviderProps) {
return ( return (
<PaymentProvider> <PaymentProvider key={scopeKey}>
<PaymentSuccessSync /> <PaymentSuccessSync />
{children} {children}
</PaymentProvider> </PaymentProvider>
@@ -15,7 +15,7 @@ export function PrivateRoomRouteProvider({
characterId = DEFAULT_CHARACTER_ID, characterId = DEFAULT_CHARACTER_ID,
}: PrivateRoomRouteProviderProps) { }: PrivateRoomRouteProviderProps) {
return ( return (
<PrivateRoomProvider characterId={characterId}> <PrivateRoomProvider key={characterId} characterId={characterId}>
{children} {children}
</PrivateRoomProvider> </PrivateRoomProvider>
); );