diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx index b1b9f658..6519a852 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx @@ -1,8 +1,9 @@ import { DataGrid } from '@/components/common/DataGrid'; -import { useWebsiteSessionsQuery } from '@/components/hooks'; +import { useSessionStream, useWebsiteSessionsQuery } from '@/components/hooks'; import { SessionsTable } from './SessionsTable'; export function SessionsDataTable({ websiteId }: { websiteId?: string; teamId?: string }) { + useSessionStream(websiteId); const queryResult = useWebsiteSessionsQuery(websiteId); return ( diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index a0becc2a..f5f7a0ac 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -12,6 +12,7 @@ import { parseRequest } from '@/lib/request'; import { badRequest, forbidden, json, serverError } from '@/lib/response'; import { anyObjectParam, urlOrPathParam } from '@/lib/schema'; import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url'; +import { emitSessionCreated } from '@/lib/session-events'; import { createSession, saveEvent, saveSessionData } from '@/queries/sql'; interface Cache { @@ -151,6 +152,7 @@ export async function POST(request: Request) { distinctId: id, createdAt, }); + emitSessionCreated(sourceId, sessionId); } // Visit info diff --git a/src/app/api/websites/[websiteId]/sessions/stream/route.ts b/src/app/api/websites/[websiteId]/sessions/stream/route.ts new file mode 100644 index 00000000..ec7b154c --- /dev/null +++ b/src/app/api/websites/[websiteId]/sessions/stream/route.ts @@ -0,0 +1,31 @@ +import { subscribeToSessions } from '@/lib/session-events'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { websiteId } = await params; + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + + const unsubscribe = subscribeToSessions(websiteId, (_, sessionId) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ sessionId })}\n\n`)); + }); + + request.signal.addEventListener('abort', () => { + unsubscribe(); + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); +} diff --git a/src/components/common/LoadingPanel.tsx b/src/components/common/LoadingPanel.tsx index fb37e140..e16c57a0 100644 --- a/src/components/common/LoadingPanel.tsx +++ b/src/components/common/LoadingPanel.tsx @@ -28,9 +28,11 @@ export function LoadingPanel({ ...props }: LoadingPanelProps): ReactNode { const empty = isEmpty ?? checkEmpty(data); + const hasData = data && !empty; - // Show loading spinner only if no data exists - if (isLoading || isFetching) { + // Show loading only on initial load when no data exists yet + // Don't show loading during background refetches when we already have data + if ((isLoading || isFetching) && !hasData) { return ( @@ -48,8 +50,8 @@ export function LoadingPanel({ return renderEmpty(); } - // Show main content when data exists - if (!isLoading && !isFetching && !error && !empty) { + // Show content when we have data (even during background refetch) + if (hasData) { return children; } diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index e8e5c135..ea284c1e 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -79,6 +79,7 @@ export * from './useNavigation'; export * from './usePagedQuery'; export * from './usePageParameters'; export * from './useRegionNames'; +export * from './useSessionStream'; export * from './useSlug'; export * from './useSticky'; export * from './useTimezone'; diff --git a/src/components/hooks/useSessionStream.ts b/src/components/hooks/useSessionStream.ts new file mode 100644 index 00000000..0abc991d --- /dev/null +++ b/src/components/hooks/useSessionStream.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +export function useSessionStream(websiteId: string) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!websiteId) return; + + const eventSource = new EventSource(`/api/websites/${websiteId}/sessions/stream`); + + eventSource.onmessage = () => { + queryClient.invalidateQueries({ queryKey: ['sessions'] }); + }; + + return () => eventSource.close(); + }, [websiteId, queryClient]); +} diff --git a/src/lib/session-events.ts b/src/lib/session-events.ts new file mode 100644 index 00000000..9b805cf4 --- /dev/null +++ b/src/lib/session-events.ts @@ -0,0 +1,18 @@ +type SessionListener = (websiteId: string, sessionId: string) => void; + +const listeners = new Map>(); + +export function subscribeToSessions(websiteId: string, callback: SessionListener) { + if (!listeners.has(websiteId)) { + listeners.set(websiteId, new Set()); + } + listeners.get(websiteId).add(callback); + + return () => { + listeners.get(websiteId)?.delete(callback); + }; +} + +export function emitSessionCreated(websiteId: string, sessionId: string) { + listeners.get(websiteId)?.forEach(cb => cb(websiteId, sessionId)); +}