Skip to content

Commit 13c9538

Browse files
authored
Merge pull request #202 from ut-code/fix-chat-revalidation
チャット時のキャッシュとページ遷移を修正
2 parents 57c8bd0 + aae1407 commit 13c9538

File tree

4 files changed

+94
-14
lines changed

4 files changed

+94
-14
lines changed

app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, FormEvent, useEffect } from "react";
3+
import { useState, FormEvent, useEffect, useRef, useCallback } from "react";
44
// import useSWR from "swr";
55
// import {
66
// getQuestionExample,
@@ -9,9 +9,10 @@ import { useState, FormEvent, useEffect } from "react";
99
// import { getLanguageName } from "../pagesList";
1010
import { useEmbedContext } from "@/terminal/embedContext";
1111
import { DynamicMarkdownSection, PagePath } from "@/lib/docs";
12-
import { useRouter } from "next/navigation";
12+
import { usePathname, useRouter } from "next/navigation";
1313
import { ChatStreamEvent } from "@/api/chat/route";
1414
import { useStreamingChatContext } from "@/(docs)/streamingChatContext";
15+
import { revalidateChatAction } from "@/actions/revalidateChat";
1516

1617
interface ChatFormProps {
1718
path: PagePath;
@@ -32,6 +33,37 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
3233
const router = useRouter();
3334
const streamingChatContext = useStreamingChatContext();
3435

36+
const pathname = usePathname();
37+
const pendingRouterPushTarget = useRef<null | string>(null);
38+
const pendingRouterPushResolver = useRef<null | (() => void)>(null);
39+
// router.pushの完了を待つ関数。pathnameの変化でページ遷移の完了を検知し、解決する。
40+
const asyncRouterPush = useCallback(
41+
(url: string, options?: { scroll?: boolean }) => {
42+
if (pendingRouterPushTarget.current) {
43+
console.error(
44+
"Already navigating to",
45+
pendingRouterPushTarget.current,
46+
"can't navigate to",
47+
url
48+
);
49+
return;
50+
}
51+
pendingRouterPushTarget.current = url;
52+
return new Promise<void>((resolve) => {
53+
pendingRouterPushResolver.current = resolve;
54+
router.push(url, options);
55+
});
56+
},
57+
[router]
58+
);
59+
useEffect(() => {
60+
if (pendingRouterPushTarget.current === pathname) {
61+
pendingRouterPushResolver.current?.();
62+
pendingRouterPushTarget.current = null;
63+
pendingRouterPushResolver.current = null;
64+
}
65+
}, [pathname]);
66+
3567
// const documentContentInView = sectionContent
3668
// .filter((s) => s.inView)
3769
// .map((s) => s.rawContent)
@@ -98,6 +130,7 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
98130
const reader = response.body!.getReader();
99131
const decoder = new TextDecoder();
100132
let buffer = "";
133+
let chatId: string | null = null;
101134
let navigated = false;
102135

103136
// ストリームを非同期で読み続ける(ナビゲーション後もバックグラウンドで継続)
@@ -118,11 +151,16 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
118151
const event = JSON.parse(line) as ChatStreamEvent;
119152

120153
if (event.type === "chat") {
154+
// revalidateChatは/api/chatの中では呼ばず、別のServerActionとして呼び出す
155+
await revalidateChatAction(event.chatId, path);
156+
chatId = event.chatId;
121157
streamingChatContext.startStreaming(event.chatId);
122158
document.getElementById(event.sectionId)?.scrollIntoView({
123159
behavior: "smooth",
124160
});
125-
router.push(`/chat/${event.chatId}`, { scroll: false });
161+
await asyncRouterPush(`/chat/${event.chatId}`, {
162+
scroll: false,
163+
});
126164
router.refresh();
127165
navigated = true;
128166
setIsLoading(false);
@@ -131,14 +169,21 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
131169
} else if (event.type === "chunk") {
132170
streamingChatContext.appendChunk(event.text);
133171
} else if (event.type === "done") {
172+
if (chatId) {
173+
await revalidateChatAction(chatId, path);
174+
}
134175
streamingChatContext.finishStreaming();
135176
router.refresh();
136177
} else if (event.type === "error") {
137178
if (!navigated) {
138179
setErrorMessage(event.message);
139180
setIsLoading(false);
140181
}
182+
if (chatId) {
183+
await revalidateChatAction(chatId, path);
184+
}
141185
streamingChatContext.finishStreaming();
186+
router.refresh();
142187
}
143188
} catch {
144189
// ignore JSON parse errors

app/actions/deleteChat.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
"use server";
22

33
import { z } from "zod";
4-
import { deleteChat, initContext } from "@/lib/chatHistory";
4+
import { deleteChat, initContext, revalidateChat } from "@/lib/chatHistory";
5+
import { section } from "@/schema/chat";
6+
import { eq } from "drizzle-orm";
57

68
export async function deleteChatAction(chatId: string) {
79
chatId = z.uuid().parse(chatId);
810
const ctx = await initContext();
9-
await deleteChat(chatId, ctx);
11+
if (!ctx.userId) {
12+
throw new Error("Not authenticated");
13+
}
14+
const deletedChat = await deleteChat(chatId, ctx);
15+
16+
const targetSection = await ctx.drizzle.query.section.findFirst({
17+
where: eq(section.sectionId, deletedChat[0].sectionId),
18+
});
19+
if (targetSection) {
20+
await revalidateChat(chatId, ctx.userId, targetSection.pagePath);
21+
}
1022
}

app/actions/revalidateChat.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"use server";
2+
3+
import { initContext, revalidateChat } from "@/lib/chatHistory";
4+
import { PagePath, PagePathSchema } from "@/lib/docs";
5+
import { z } from "zod";
6+
7+
export async function revalidateChatAction(
8+
chatId: string,
9+
pagePath: string | PagePath
10+
) {
11+
chatId = z.uuid().parse(chatId);
12+
if (typeof pagePath === "string") {
13+
if (!/^[a-z0-9_-]+\/[a-z0-9_-]+$/.test(pagePath)) {
14+
throw new Error("Invalid pagePath format");
15+
}
16+
const [lang, page] = pagePath.split("/");
17+
pagePath = PagePathSchema.parse({ lang, page });
18+
} else {
19+
pagePath = PagePathSchema.parse(pagePath);
20+
}
21+
const ctx = await initContext();
22+
if (!ctx.userId) {
23+
throw new Error("Not authenticated");
24+
}
25+
await revalidateChat(chatId, ctx.userId, pagePath);
26+
}

app/lib/chatHistory.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ export function cacheKeyForChat(chatId: string) {
2828
return `${CACHE_KEY_BASE}/getChatOne?chatId=${chatId}`;
2929
}
3030

31-
async function revalidateChat(
31+
// nextjsのキャッシュのrevalidateはRouteHandlerではなくServerActionから呼ばないと正しく動作しないらしい。
32+
// https://github.com/vercel/next.js/issues/69064
33+
// そのためlib/以下の関数では直接revalidateChatを呼ばず、ServerActionの関数から呼ぶようにする。
34+
// Nextjs 16 に更新したらこれをupdateTag()で置き換える。
35+
export async function revalidateChat(
3236
chatId: string,
3337
userId: string,
3438
pagePath: string | PagePath
@@ -126,8 +130,6 @@ export async function addChat(
126130
chatDiffs = [] as never[];
127131
}
128132

129-
await revalidateChat(newChat.chatId, userId, path);
130-
131133
return {
132134
...newChat,
133135
section: {
@@ -173,8 +175,6 @@ export async function addMessagesAndDiffs(
173175
}))
174176
);
175177
}
176-
177-
await revalidateChat(chatId, userId, path);
178178
}
179179

180180
export async function deleteChat(chatId: string, context: Context) {
@@ -192,10 +192,7 @@ export async function deleteChat(chatId: string, context: Context) {
192192
await drizzle.delete(message).where(eq(message.chatId, chatId));
193193
await drizzle.delete(diff).where(eq(diff.chatId, chatId));
194194

195-
const targetSection = await drizzle.query.section.findFirst({
196-
where: eq(section.sectionId, deletedChat[0].sectionId),
197-
});
198-
await revalidateChat(chatId, userId, targetSection?.pagePath ?? "");
195+
return deletedChat;
199196
}
200197

201198
export async function getAllChat(

0 commit comments

Comments
 (0)