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 <nicholaivogelfilms@gmail.com>
This commit is contained in:
Nicholai 2026-02-14 20:56:58 -07:00 committed by GitHub
parent 40fdf48cbf
commit d5a28ee709
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 116 additions and 91 deletions

View File

@ -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;

View File

@ -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<ConversationsContextType | undefined>(
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<string | null>(null)
const [threadParentMessage, setThreadParentMessage] = React.useState<ThreadMessage | null>(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 (
<ConversationsContext.Provider value={value}>
<div className="flex min-h-0 flex-1 overflow-hidden">
{children}
</div>
</ConversationsContext.Provider>
)
return <ConversationsProvider>{children}</ConversationsProvider>
}

View File

@ -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"

View File

@ -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"

View File

@ -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<ConversationsContextType | undefined>(
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<string | null>(null)
const [threadParentMessage, setThreadParentMessage] = React.useState<ThreadMessage | null>(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 (
<ConversationsContext.Provider value={value}>
<div className="flex min-h-0 flex-1 overflow-hidden">
{children}
</div>
</ConversationsContext.Provider>
)
}

View File

@ -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<DatabaseProvider> {
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<DrizzleDB> {
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()
}
}
}