diff --git a/src/app/characters/[characterSlug]/tip/layout.tsx b/src/app/characters/[characterSlug]/tip/layout.tsx
index 68390c1a..884a916a 100644
--- a/src/app/characters/[characterSlug]/tip/layout.tsx
+++ b/src/app/characters/[characterSlug]/tip/layout.tsx
@@ -1,11 +1,23 @@
import type { ReactNode } from "react";
+import { notFound } from "next/navigation";
+import { getCharacterBySlug } from "@/data/constants/character";
import { PaymentRouteProvider } from "@/providers/payment-route-provider";
-export default function CharacterTipLayout({
+export default async function CharacterTipLayout({
children,
+ params,
}: {
children: ReactNode;
+ params: Promise<{ characterSlug: string }>;
}) {
- return {children};
+ const { characterSlug } = await params;
+ const character = getCharacterBySlug(characterSlug);
+ if (!character) notFound();
+
+ return (
+
+ {children}
+
+ );
}
diff --git a/src/providers/__tests__/character-actor-scope-providers.test.tsx b/src/providers/__tests__/character-actor-scope-providers.test.tsx
new file mode 100644
index 00000000..8f90ca07
--- /dev/null
+++ b/src/providers/__tests__/character-actor-scope-providers.test.tsx
@@ -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(
+
+ chat
+ ,
+ );
+ });
+ }
+
+ function renderPrivateRoom(characterId: string): void {
+ act(() => {
+ root.render(
+
+ private room
+ ,
+ );
+ });
+ }
+
+ function renderPayment(scopeKey: string): void {
+ act(() => {
+ root.render(
+
+ payment
+ ,
+ );
+ });
+ }
+
+ function testNode(testId: string): HTMLElement {
+ const node = container.querySelector(
+ `[data-testid="${testId}"]`,
+ );
+ if (!node) throw new Error(`Missing ${testId}`);
+ return node;
+ }
+
+ function instanceId(testId: string): string | undefined {
+ return testNode(testId).dataset.instanceId;
+ }
+});
diff --git a/src/providers/chat-route-providers.tsx b/src/providers/chat-route-providers.tsx
index 4971d88a..1faa7da0 100644
--- a/src/providers/chat-route-providers.tsx
+++ b/src/providers/chat-route-providers.tsx
@@ -19,9 +19,9 @@ export function ChatRouteProviders({
characterId = DEFAULT_CHARACTER_ID,
}: ChatRouteProvidersProps) {
return (
-
+
-
+
{children}
diff --git a/src/providers/payment-route-provider.tsx b/src/providers/payment-route-provider.tsx
index 7c4a11bb..32e88dff 100644
--- a/src/providers/payment-route-provider.tsx
+++ b/src/providers/payment-route-provider.tsx
@@ -5,9 +5,17 @@ import type { ReactNode } from "react";
import { PaymentProvider } from "@/stores/payment/payment-context";
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 (
-
+
{children}
diff --git a/src/providers/private-room-route-provider.tsx b/src/providers/private-room-route-provider.tsx
index cc1a70e1..ebec0732 100644
--- a/src/providers/private-room-route-provider.tsx
+++ b/src/providers/private-room-route-provider.tsx
@@ -15,7 +15,7 @@ export function PrivateRoomRouteProvider({
characterId = DEFAULT_CHARACTER_ID,
}: PrivateRoomRouteProviderProps) {
return (
-
+
{children}
);