From d5a28ee7091c7a4ca0e22ee83932d9788c2c4f84 Mon Sep 17 00:00:00 2001 From: Nicholai Date: Sat, 14 Feb 2026 20:56:58 -0700 Subject: [PATCH] fix: extract useConversations hook to dedicated context file (#82) * fix: extract useConversations hook to dedicated context file Move the conversations context and hook from the layout file to src/contexts/conversations-context.tsx. Next.js layouts cannot export hooks or types - only the default component export is allowed. * fix(build): prevent esbuild from bundling memory-provider The memory-provider module imports better-sqlite3, a native Node.js module that cannot run in Cloudflare Workers. This causes OpenNext build failures when esbuild tries to resolve the module. Changes: - Use dynamically constructed import path to prevent static analysis - Remove memory provider from getServerDb (never used on Cloudflare) - Add memory-provider to serverExternalPackages in next.config.ts * fix(lint): rename module variable to loadedModule The variable name 'module' is reserved in Next.js and causes ESLint error @next/next/no-assign-module-variable. --------- Co-authored-by: Nicholai --- next.config.ts | 3 +- src/app/dashboard/conversations/layout.tsx | 79 +---------------- src/components/conversations/message-item.tsx | 2 +- src/components/conversations/thread-panel.tsx | 3 +- src/contexts/conversations-context.tsx | 84 +++++++++++++++++++ src/db/provider/context.tsx | 36 +++++--- 6 files changed, 116 insertions(+), 91 deletions(-) create mode 100644 src/contexts/conversations-context.tsx diff --git a/next.config.ts b/next.config.ts index f1cae72..ff2cbe4 100755 --- a/next.config.ts +++ b/next.config.ts @@ -17,7 +17,8 @@ const nextConfig: NextConfig = { ], }, // Node.js native modules that should not be bundled for edge/browser - serverExternalPackages: ["better-sqlite3"], + // memory-provider imports better-sqlite3 which cannot run in Cloudflare Workers + serverExternalPackages: ["better-sqlite3", "./src/db/provider/memory-provider"], }; export default nextConfig; diff --git a/src/app/dashboard/conversations/layout.tsx b/src/app/dashboard/conversations/layout.tsx index d11ba5f..24a84f8 100644 --- a/src/app/dashboard/conversations/layout.tsx +++ b/src/app/dashboard/conversations/layout.tsx @@ -1,86 +1,11 @@ "use client" -import * as React from "react" - -type ThreadMessage = { - readonly id: string - readonly channelId: string - readonly threadId: string | null - readonly content: string - readonly contentHtml: string | null - readonly editedAt: string | null - readonly deletedAt: string | null - readonly isPinned: boolean - readonly replyCount: number - readonly lastReplyAt: string | null - readonly createdAt: string - readonly user: { - readonly id: string - readonly displayName: string | null - readonly email: string - readonly avatarUrl: string | null - } | null -} - -type ConversationsContextType = { - readonly threadOpen: boolean - readonly threadMessageId: string | null - readonly threadParentMessage: ThreadMessage | null - readonly openThread: (messageId: string, parentMessage: ThreadMessage) => void - readonly closeThread: () => void -} - -const ConversationsContext = React.createContext( - undefined -) - -export function useConversations() { - const context = React.useContext(ConversationsContext) - if (!context) { - throw new Error("useConversations must be used within ConversationsProvider") - } - return context -} - -export type { ThreadMessage } +import { ConversationsProvider } from "@/contexts/conversations-context" export default function ConversationsLayout({ children, }: { readonly children: React.ReactNode }) { - const [threadOpen, setThreadOpen] = React.useState(false) - const [threadMessageId, setThreadMessageId] = React.useState(null) - const [threadParentMessage, setThreadParentMessage] = React.useState(null) - - const openThread = React.useCallback((messageId: string, parentMessage: ThreadMessage) => { - setThreadMessageId(messageId) - setThreadParentMessage(parentMessage) - setThreadOpen(true) - }, []) - - const closeThread = React.useCallback(() => { - setThreadOpen(false) - setThreadMessageId(null) - setThreadParentMessage(null) - }, []) - - const value = React.useMemo( - () => ({ - threadOpen, - threadMessageId, - threadParentMessage, - openThread, - closeThread, - }), - [threadOpen, threadMessageId, threadParentMessage, openThread, closeThread] - ) - - return ( - -
- {children} -
-
- ) + return {children} } diff --git a/src/components/conversations/message-item.tsx b/src/components/conversations/message-item.tsx index fc9e6e4..faea974 100644 --- a/src/components/conversations/message-item.tsx +++ b/src/components/conversations/message-item.tsx @@ -15,7 +15,7 @@ import { Button } from "@/components/ui/button" import { Textarea } from "@/components/ui/textarea" import { MarkdownRenderer } from "@/components/ui/markdown-renderer" import { cn } from "@/lib/utils" -import { useConversations } from "@/app/dashboard/conversations/layout" +import { useConversations } from "@/contexts/conversations-context" import { editMessage, deleteMessage, addReaction } from "@/app/actions/chat-messages" import { useRouter } from "next/navigation" diff --git a/src/components/conversations/thread-panel.tsx b/src/components/conversations/thread-panel.tsx index de1d8e6..52c848e 100644 --- a/src/components/conversations/thread-panel.tsx +++ b/src/components/conversations/thread-panel.tsx @@ -6,8 +6,7 @@ import { Button } from "@/components/ui/button" import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { cn } from "@/lib/utils" -import { useConversations } from "@/app/dashboard/conversations/layout" -import type { ThreadMessage } from "@/app/dashboard/conversations/layout" +import { useConversations, type ThreadMessage } from "@/contexts/conversations-context" import { getThreadMessages } from "@/app/actions/chat-messages" import { MessageItem } from "./message-item" import { MessageComposer } from "./message-composer" diff --git a/src/contexts/conversations-context.tsx b/src/contexts/conversations-context.tsx new file mode 100644 index 0000000..6bb6b25 --- /dev/null +++ b/src/contexts/conversations-context.tsx @@ -0,0 +1,84 @@ +"use client" + +import * as React from "react" + +export type ThreadMessage = { + readonly id: string + readonly channelId: string + readonly threadId: string | null + readonly content: string + readonly contentHtml: string | null + readonly editedAt: string | null + readonly deletedAt: string | null + readonly isPinned: boolean + readonly replyCount: number + readonly lastReplyAt: string | null + readonly createdAt: string + readonly user: { + readonly id: string + readonly displayName: string | null + readonly email: string + readonly avatarUrl: string | null + } | null +} + +type ConversationsContextType = { + readonly threadOpen: boolean + readonly threadMessageId: string | null + readonly threadParentMessage: ThreadMessage | null + readonly openThread: (messageId: string, parentMessage: ThreadMessage) => void + readonly closeThread: () => void +} + +const ConversationsContext = React.createContext( + undefined +) + +export function useConversations() { + const context = React.useContext(ConversationsContext) + if (!context) { + throw new Error("useConversations must be used within ConversationsProvider") + } + return context +} + +export function ConversationsProvider({ + children, +}: { + readonly children: React.ReactNode +}) { + const [threadOpen, setThreadOpen] = React.useState(false) + const [threadMessageId, setThreadMessageId] = React.useState(null) + const [threadParentMessage, setThreadParentMessage] = React.useState(null) + + const openThread = React.useCallback((messageId: string, parentMessage: ThreadMessage) => { + setThreadMessageId(messageId) + setThreadParentMessage(parentMessage) + setThreadOpen(true) + }, []) + + const closeThread = React.useCallback(() => { + setThreadOpen(false) + setThreadMessageId(null) + setThreadParentMessage(null) + }, []) + + const value = React.useMemo( + () => ({ + threadOpen, + threadMessageId, + threadParentMessage, + openThread, + closeThread, + }), + [threadOpen, threadMessageId, threadParentMessage, openThread, closeThread] + ) + + return ( + +
+ {children} +
+
+ ) +} diff --git a/src/db/provider/context.tsx b/src/db/provider/context.tsx index da2fccb..0587ccf 100644 --- a/src/db/provider/context.tsx +++ b/src/db/provider/context.tsx @@ -55,6 +55,23 @@ export interface DatabaseProviderProps { } } +// Lazy-loaded memory provider factory - only loaded when actually needed +// This avoids bundling better-sqlite3 (a native Node.js module) in +// environments like Cloudflare Workers where it can't run +let createMemoryProviderFn: typeof import("./memory-provider")["createMemoryProvider"] | null = null + +async function getMemoryProvider(config?: MemoryProviderConfig): Promise { + if (!createMemoryProviderFn) { + // Construct the path dynamically to prevent static analysis by bundlers + // The path segments are concatenated at runtime + const providerDir = "." + const providerFile = "memory-provider" + const loadedModule = await import(/* webpackIgnore: true */ `${providerDir}/${providerFile}`) + createMemoryProviderFn = loadedModule.createMemoryProvider + } + return createMemoryProviderFn!(config) +} + export function DatabaseProvider({ children, forcePlatform, @@ -91,11 +108,7 @@ export function DatabaseProvider({ case "memory": default: { - // Dynamic import to avoid bundling better-sqlite3 in browser - const { createMemoryProvider } = await import( - /* webpackIgnore: true */ "./memory-provider" - ) - newProvider = createMemoryProvider(config?.memory) + newProvider = await getMemoryProvider(config?.memory) break } } @@ -164,12 +177,15 @@ export async function getServerDb(): Promise { case "memory": default: { - // Dynamic import to avoid bundling better-sqlite3 in browser - const { createMemoryProvider } = await import( - /* webpackIgnore: true */ "./memory-provider" + // Memory provider is only for local development/testing. + // On Cloudflare Workers, detectPlatform() returns "d1", so this + // code path is never reached at runtime. However, bundlers like + // esbuild still try to resolve the import. We throw an error here + // since this code should never execute in production environments. + throw new Error( + "Memory provider not available in this environment. " + + "Ensure you have a local SQLite setup or use the D1 provider." ) - const provider = createMemoryProvider() - return provider.getDb() } } }