Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Loader2 } from "lucide-react";
import { ChatLayout } from "@/components/chat/ChatLayout";
import { ChatUnavailable } from "@/components/chat/ChatUnavailable";
import { useStreamConnection } from "@/providers/StreamProvider";
import { StreamChatScope } from "@/components/stream/StreamChatScope";

interface MessagesTabProps {
userId: string;
Expand All @@ -24,7 +25,9 @@ export function MessagesTab({ userId: _userId }: Readonly<MessagesTabProps>) {
{error ? (
<ChatUnavailable description={error} onRetry={retryConnection} />
) : chatConnected ? (
<ChatLayout />
<StreamChatScope>
<ChatLayout />
</StreamChatScope>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Loader2 } from "lucide-react";
import { ChatLayout } from "@/components/chat/ChatLayout";
import { ChatUnavailable } from "@/components/chat/ChatUnavailable";
import { useStreamConnection } from "@/providers/StreamProvider";
import { StreamChatScope } from "@/components/stream/StreamChatScope";

/**
* Full-bleed chat surface: cancels PageScaffold padding and fills the
Expand All @@ -20,7 +21,9 @@ export default function MessagesTab() {
{error ? (
<ChatUnavailable description={error} onRetry={retryConnection} />
) : chatConnected ? (
<ChatLayout />
<StreamChatScope>
<ChatLayout />
</StreamChatScope>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Loader2 } from "lucide-react";

import { ChatLayout } from "@/components/chat/ChatLayout";
import { StreamChatScope } from "@/components/stream/StreamChatScope";
import { ChatUnavailable } from "@/components/chat/ChatUnavailable";
import { useStreamConnection } from "@/providers/StreamProvider";

Expand Down Expand Up @@ -38,5 +39,9 @@ export function MessagesClient() {
);
}

return <ChatLayout />;
return (
<StreamChatScope>
<ChatLayout />
</StreamChatScope>
);
}
6 changes: 5 additions & 1 deletion app/meetings/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import "@stream-io/video-react-sdk/dist/css/styles.css";
import StreamProvider from "@/providers/StreamProvider";
import { StreamVideoScope } from "@/components/stream/StreamVideoScope";
import { requireOnboarded } from "@/lib/auth-guard";

export default async function MeetingsLayout({
Expand All @@ -16,7 +17,10 @@ export default async function MeetingsLayout({
enableChat={false}
enableVideo={true}
>
{children}
{/* /meetings is the video surface, so the SDK context is mounted for the
whole route rather than per-page. StreamProvider no longer supplies it
— see components/stream/StreamVideoScope. */}
<StreamVideoScope>{children}</StreamVideoScope>
</StreamProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState } from "react";
import * as Sentry from "@sentry/nextjs";
import { useParams, useRouter } from "next/navigation";
import { useStreamVideoClient } from "@stream-io/video-react-sdk";
import { getGlobalVideoClient } from "@/lib/stream/disconnect";
import { useQueryClient } from "@tanstack/react-query";

import { useToast } from "@/hooks/use-toast";
Expand Down Expand Up @@ -113,7 +113,6 @@ const KIND_TO_REPORT_TYPE: Record<
export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter {
const router = useRouter();
const { toast } = useToast();
const client = useStreamVideoClient();
const { data: session } = useSession();
const queryClient = useQueryClient();
const params = useParams<{ consulteeId: string }>();
Expand Down Expand Up @@ -144,6 +143,10 @@ export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter {
// Join can't go through useEventActions — its args follow activeVm state,
// which wouldn't be committed yet on a same-click join from a row.
const joinNow = async (vm: AppointmentVM, slot: SlotLike) => {
// Read the singleton at click time rather than via useStreamVideoClient:
// the SDK context is now scoped to /meetings, and this is the same instance
// <StreamVideo> would hand back. Matches the #248 lazy-join idiom.
const client = getGlobalVideoClient();
if (!client) {
toast({
title: "Not signed in",
Expand Down
6 changes: 4 additions & 2 deletions components/appointments/consultee/useEventActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as Sentry from "@sentry/nextjs";
import { useToast } from "@/hooks/use-toast";
import { useParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useStreamVideoClient } from "@stream-io/video-react-sdk";
import { getGlobalVideoClient } from "@/lib/stream/disconnect";
import { getOrCreateAppointmentMeeting } from "@/lib/meeting";
import type { TAppointment } from "@/types/appointment";
import type { SlotOfAppointment } from "@prisma/client";
Expand Down Expand Up @@ -112,7 +112,6 @@ export function useEventActions({
}: UseEventActionsOptions) {
const { toast } = useToast();
const router = useRouter();
const client = useStreamVideoClient();
const queryClient = useQueryClient();
const params = useParams<{ consulteeId: string }>();
const consulteeId = params?.consulteeId;
Expand Down Expand Up @@ -310,6 +309,9 @@ export function useEventActions({
const handleJoinSession = async (forceSlot?: SlotOfAppointment) => {
const slotToUse = forceSlot || getJoinableSlot();

// Singleton at click time: the SDK context is scoped to /meetings now, and
// this is the same instance <StreamVideo> would return (#248 idiom).
const client = getGlobalVideoClient();
if (!client) {
toast({
title: "Not signed in",
Expand Down
43 changes: 43 additions & 0 deletions components/stream/StreamChatScope.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import dynamic from "next/dynamic";
import { useSyncExternalStore } from "react";
import {
getStreamConnectionServerSnapshot,
getStreamConnectionSnapshot,
subscribeStreamConnection,
} from "@/lib/stream/connection-store";

/**
* Mounts the Stream `<Chat>` context around a chat surface.
*
* This used to wrap the WHOLE dashboard from StreamProvider, which cost two
* things: `ssr: false` on that wrapper meant no dashboard markup was ever
* server-rendered, and the wrapper appearing once the socket settled changed
* the element type at that position and remounted everything under it (#248).
*
* Scoping it here is safe because every `useChatContext` consumer lives under
* `components/chat/`. The sidebar's unread badge is deliberately NOT one of
* them — `hooks/useChatUnreadCount` reads the `StreamChat` singleton directly
* and documents that it works outside the provider.
*
* Renders children unwrapped until the client connects; chat consumers already
* guard a null client, and this keeps the surface visible while connecting.
*/
const ChatProvider = dynamic(
() => import("stream-chat-react").then((m) => ({ default: m.Chat })),
{ ssr: false },
);

export function StreamChatScope({
children,
}: Readonly<{ children: React.ReactNode }>) {
const { clients } = useSyncExternalStore(
subscribeStreamConnection,
getStreamConnectionSnapshot,
getStreamConnectionServerSnapshot,
);

if (!clients?.chat) return <>{children}</>;
return <ChatProvider client={clients.chat}>{children}</ChatProvider>;
}
40 changes: 40 additions & 0 deletions components/stream/StreamVideoScope.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"use client";

import dynamic from "next/dynamic";
import { useSyncExternalStore } from "react";
import {
getStreamConnectionServerSnapshot,
getStreamConnectionSnapshot,
subscribeStreamConnection,
} from "@/lib/stream/connection-store";

/**
* Mounts the Stream `<StreamVideo>` context around a video surface.
*
* Sibling of StreamChatScope — see that file for why these contexts no longer
* wrap the entire dashboard. Video consumers (`useStreamVideoClient`, `useCall`)
* are confined to `/meetings` plus the consultee appointments adapter.
*
* Renders children unwrapped until the client connects; the existing video
* consumers already guard a null client.
*/
const VideoProvider = dynamic(
() =>
import("@stream-io/video-react-sdk").then((m) => ({
default: m.StreamVideo,
})),
{ ssr: false },
);

export function StreamVideoScope({
children,
}: Readonly<{ children: React.ReactNode }>) {
const { clients } = useSyncExternalStore(
subscribeStreamConnection,
getStreamConnectionSnapshot,
getStreamConnectionServerSnapshot,
);

if (!clients?.video) return <>{children}</>;
return <VideoProvider client={clients.video}>{children}</VideoProvider>;
}
79 changes: 79 additions & 0 deletions lib/stream/connection-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { StreamChat } from "stream-chat";
import type { StreamVideoClient } from "@stream-io/video-react-sdk";

/**
* Module-level store for the Stream connection, read via `useSyncExternalStore`.
*
* Why a store and not React state: the provider used to WRAP `children` and
* swap the wrapper set once the sockets settled (`children` → `<StreamVideo>` →
* `<Chat>`). React tears down a subtree when the element type at a position
* changes, so that swap remounted the whole dashboard — the remount storm
* behind "I pressed Join ten times" (#248). Publishing to a store instead lets
* the connector render `null` as a SIBLING of `children`, so nothing above the
* dashboard ever changes shape.
*
* It also un-blocks SSR. The connector is still `ssr: false`, but `ssr: false`
* skips server rendering for the component AND its children — so while it
* wrapped the dashboard, no dashboard markup reached the HTML at all. Measured
* on #1102: `<h1` never appeared in the document and FCP sat at ~6s regardless
* of Suspense boundaries.
*/
export interface StreamClients {
chat: StreamChat | null;
video: StreamVideoClient | null;
}

export interface StreamConnectionSnapshot {
clients: StreamClients | null;
chatConnected: boolean;
videoConnected: boolean;
isConnecting: boolean;
error: string | null;
}

const INITIAL: StreamConnectionSnapshot = {
clients: null,
chatConnected: false,
videoConnected: false,
isConnecting: false,
error: null,
};

let snapshot: StreamConnectionSnapshot = INITIAL;
const listeners = new Set<() => void>();

export function subscribeStreamConnection(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}

export function getStreamConnectionSnapshot(): StreamConnectionSnapshot {
return snapshot;
}

/**
* Server snapshot must be a STABLE reference, not a fresh object — React calls
* this during SSR and would loop forever on a new identity each time.
*/
export function getStreamConnectionServerSnapshot(): StreamConnectionSnapshot {
return INITIAL;
}

export function setStreamConnection(
patch: Partial<StreamConnectionSnapshot>,
): void {
const next = { ...snapshot, ...patch };
// Bail on no-op writes so consumers do not re-render on every heartbeat.
const unchanged = (
Object.keys(next) as (keyof StreamConnectionSnapshot)[]
).every((k) => next[k] === snapshot[k]);
if (unchanged) return;
snapshot = next;
for (const l of listeners) l();
}

/** Test/logout helper — drops connection state without touching the clients. */
export function resetStreamConnection(): void {
snapshot = INITIAL;
for (const l of listeners) l();
}
Loading
Loading