From 91726d85b8ee7bf654db73b8cfc8bba57a9ce602 Mon Sep 17 00:00:00 2001 From: Daniel Bulant Date: Wed, 13 May 2026 11:48:18 +0200 Subject: [PATCH] attempt fix reactivity --- api/src/party-types.ts | 1 + api/src/routes/party-socket.ts | 64 +++++++++++++++++++++++-------- api/src/routes/quiz.ts | 31 +++++++-------- api/src/workflows/quiz.ts | 6 ++- web/src/hooks/use-party-socket.ts | 15 ++++++-- web/src/hooks/use-party.ts | 25 ++++++++++-- 6 files changed, 99 insertions(+), 43 deletions(-) diff --git a/api/src/party-types.ts b/api/src/party-types.ts index 03f23d4..9962ae5 100644 --- a/api/src/party-types.ts +++ b/api/src/party-types.ts @@ -24,6 +24,7 @@ export type PartyState = { export type PartySocketOutgoing = | { type: "ping" } + | { type: "subscribe_party"; partyId: string } | { type: "member_payload"; payload: unknown }; type BaseQuestion = { diff --git a/api/src/routes/party-socket.ts b/api/src/routes/party-socket.ts index 213e322..e4e150d 100644 --- a/api/src/routes/party-socket.ts +++ b/api/src/routes/party-socket.ts @@ -29,6 +29,40 @@ export const pubsub = { }, }; +async function subscribeWsToParty( + ws: { + subscribe: (topic: string) => void; + unsubscribe: (topic: string) => void; + send: (message: string) => void; + }, + userId: string, +) { + const membership = await getMemberRecord(db, userId); + if (!membership) return null; + + const nextPartyId = membership.partyId; + const currentPartyId = socketPartyId.get(ws as object); + if (currentPartyId && currentPartyId !== nextPartyId) { + ws.unsubscribe(partyTopic(currentPartyId)); + } + + socketPartyId.set(ws as object, nextPartyId); + ws.subscribe(partyTopic(nextPartyId)); + + const snapshot = await getPartyStatus(nextPartyId); + if (snapshot) { + ws.send( + JSON.stringify({ + type: "party_status", + party: snapshot.party, + members: snapshot.members, + } satisfies PartySocketEvent), + ); + } + + return nextPartyId; +} + export async function broadcastQuizState( ws: { publish: (topic: string, message: string) => void }, partyId: string, @@ -73,8 +107,8 @@ export const partySocketApp = new Elysia() ws.subscribe(userTopic(user.id)); - const membership = await getMemberRecord(db, user.id); - if (!membership) { + const subscribedPartyId = await subscribeWsToParty(ws, user.id); + if (!subscribedPartyId) { ws.send( JSON.stringify({ type: "party_status", @@ -85,21 +119,7 @@ export const partySocketApp = new Elysia() return; } - socketPartyId.set(ws, membership.partyId); - ws.subscribe(partyTopic(membership.partyId)); - - const snapshot = await getPartyStatus(membership.partyId); - if (snapshot) { - ws.send( - JSON.stringify({ - type: "party_status", - party: snapshot.party, - members: snapshot.members, - } as PartySocketEvent), - ); - - await broadcastQuizState(ws, membership.partyId); - } + await broadcastQuizState(ws, subscribedPartyId); }, message: async (ws, message) => { const data = ws.data; @@ -121,6 +141,16 @@ export const partySocketApp = new Elysia() return; } + if (parsed.type === "subscribe_party") { + const payload = parsed as { + type: "subscribe_party"; + partyId: string; + }; + if (typeof payload.partyId !== "string") return; + await subscribeWsToParty(ws, user.id); + return; + } + if (parsed.type !== "member_payload") return; const MAX_MEMBER_PAYLOAD_SIZE = 8_000; diff --git a/api/src/routes/quiz.ts b/api/src/routes/quiz.ts index 9de2059..16f96d0 100644 --- a/api/src/routes/quiz.ts +++ b/api/src/routes/quiz.ts @@ -3,8 +3,8 @@ import { eq } from "drizzle-orm"; import Elysia, { t } from "elysia"; import { betterAuthElysia } from "../auth"; import { db } from "../db"; -import { party, partyMember } from "../db/schema"; -import { getMemberRecord } from "../party-data"; +import { party } from "../db/schema"; +import { getMemberRecord, getPartyStatus } from "../party-data"; import type { QuizState } from "../party-types"; import { QuizWorkflow, quizQueue } from "../workflows/quiz"; import { pubsub } from "./party-socket"; @@ -49,22 +49,17 @@ export const quizRoutes = new Elysia() }) .where(eq(party.id, params.partyId)); - const members = await db - .select({ - id: partyMember.id, - userId: partyMember.userId, - }) - .from(partyMember) - .where(eq(partyMember.partyId, params.partyId)); - - pubsub.publish( - `party:${params.partyId}`, - JSON.stringify({ - type: "party_status", - party: { status: "started" }, - members, - }), - ); + const status = await getPartyStatus(params.partyId); + if (status) { + pubsub.publish( + `party:${params.partyId}`, + JSON.stringify({ + type: "party_status", + party: status.party, + members: status.members, + }), + ); + } return { message: "Quiz started", diff --git a/api/src/workflows/quiz.ts b/api/src/workflows/quiz.ts index 2604d80..32285bb 100644 --- a/api/src/workflows/quiz.ts +++ b/api/src/workflows/quiz.ts @@ -155,13 +155,17 @@ export class QuizWorkflow extends ConfiguredInstance { }, }); const analytics = (partyRecord?.analysisData ?? null) as PartyAnalytics; - return generatePartyQuestion({ + const question = await generatePartyQuestion({ db, partyId, quizState, analytics, index, }); + if (!question) { + throw new Error("Failed to generate quiz question"); + } + return question; } private static scoreRound(round: QuizRound): Array<[string, number]> { diff --git a/web/src/hooks/use-party-socket.ts b/web/src/hooks/use-party-socket.ts index 6691aa7..0d6db82 100644 --- a/web/src/hooks/use-party-socket.ts +++ b/web/src/hooks/use-party-socket.ts @@ -1,5 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { PartySocketEvent } from "../../../api/src/party-types"; +import type { + PartySocketEvent, + PartySocketOutgoing, +} from "../../../api/src/party-types"; type Handler = (event: PartySocketEvent) => void; @@ -24,6 +27,12 @@ export function usePartySocket({ const reconnectAttemptRef = useRef(0); const handlerRef = useRef(onMessage); + const send = useCallback((message: PartySocketOutgoing) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify(message)); + }, []); + useEffect(() => { handlerRef.current = onMessage; }, [onMessage]); @@ -42,7 +51,6 @@ export function usePartySocket({ ws.onmessage = (event) => { const parsed = JSON.parse(event.data) as PartySocketEvent; - console.log(parsed); handlerRef.current?.(parsed); }; @@ -120,8 +128,9 @@ export function usePartySocket({ isConnected: connectionState === "connected", isConnecting: connectionState === "connecting", isReconnecting: connectionState === "reconnecting", + send, }), - [connectionState], + [connectionState, send], ); return state; diff --git a/web/src/hooks/use-party.ts b/web/src/hooks/use-party.ts index bce0b18..85da5fe 100644 --- a/web/src/hooks/use-party.ts +++ b/web/src/hooks/use-party.ts @@ -6,6 +6,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from "react"; import type { @@ -75,6 +76,11 @@ export function PartyProvider({ children }: { children: ReactNode }) { if (!url) return null; return url; }, []); + const subscribedPartyIdRef = useRef(null); + const wsState = usePartySocket({ + apiUrl: userId ? apiUrl : null, + onMessage: userId ? handleMessage : null, + }); const resetParty = useCallback(() => { setState(emptyPartyState); @@ -84,10 +90,21 @@ export function PartyProvider({ children }: { children: ReactNode }) { if (!userId) resetParty(); }, [resetParty, userId]); - const wsState = usePartySocket({ - apiUrl: userId ? apiUrl : null, - onMessage: userId ? handleMessage : null, - }); + useEffect(() => { + if (wsState.connectionState !== "connected") { + subscribedPartyIdRef.current = null; + return; + } + + if (!userId || !state.party?.id) return; + if (subscribedPartyIdRef.current === state.party.id) return; + + wsState.send({ + type: "subscribe_party", + partyId: state.party.id, + }); + subscribedPartyIdRef.current = state.party.id; + }, [state.party?.id, userId, wsState.connectionState, wsState.send]); const value = useMemo( () => ({