diff --git a/App.tsx b/App.tsx index 263c737..f28cff7 100644 --- a/App.tsx +++ b/App.tsx @@ -147,7 +147,7 @@ function App() {
- loadSavedQuiz(sharedQuiz)} /> + loadSavedQuiz(sharedQuiz)} shareToken={sharedMatch![1]} />
); diff --git a/components/SharedQuizView.tsx b/components/SharedQuizView.tsx index 6495aff..f9d5107 100644 --- a/components/SharedQuizView.tsx +++ b/components/SharedQuizView.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { useAuth } from 'react-oidc-context'; import { motion } from 'framer-motion'; import { BrainCircuit, Play, Loader2, BookmarkPlus, ChevronDown, ChevronUp, AlertCircle, LogIn } from 'lucide-react'; @@ -20,10 +20,11 @@ interface SharedQuizData { interface SharedQuizViewProps { onHostQuiz: (quiz: Quiz) => void; + shareToken: string; } -export const SharedQuizView: React.FC = ({ onHostQuiz }) => { - const { token } = useParams<{ token: string }>(); +export const SharedQuizView: React.FC = ({ onHostQuiz, shareToken }) => { + const token = shareToken; const navigate = useNavigate(); const auth = useAuth(); const { authFetch } = useAuthenticatedFetch(); diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 7d06ebb..f445285 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -124,8 +124,8 @@ export const useGame = () => { if (auth.isLoading) return; const getTargetPath = () => { - if (location.pathname === '/callback') { - return '/'; + if (location.pathname === '/callback' || location.pathname.startsWith('/shared/')) { + return null; } switch (gameState) { @@ -162,7 +162,7 @@ export const useGame = () => { }; const targetPath = getTargetPath(); - if (location.pathname !== targetPath) { + if (targetPath !== null && location.pathname !== targetPath) { const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(gameState); navigate(targetPath + location.search, { replace: useReplace }); } @@ -519,7 +519,7 @@ export const useGame = () => { const initializeFromUrl = async () => { const path = location.pathname; - if (path === '/callback') { + if (path === '/callback' || path.startsWith('/shared/')) { return; } diff --git a/index.tsx b/index.tsx index 0ad39af..c8fd3e4 100644 --- a/index.tsx +++ b/index.tsx @@ -31,6 +31,8 @@ if (!rootElement) { throw new Error("Could not find root element to mount to"); } +const shouldSkipSigninCallback = !window.location.pathname.startsWith('/callback'); + const onSigninCallback = () => { window.history.replaceState({}, document.title, '/'); }; @@ -41,6 +43,7 @@ root.render( { localStorage.removeItem('kaboot_session'); diff --git a/tests/components/SharedQuizView.test.tsx b/tests/components/SharedQuizView.test.tsx index ee8af61..5930701 100644 --- a/tests/components/SharedQuizView.test.tsx +++ b/tests/components/SharedQuizView.test.tsx @@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SharedQuizView } from '../../components/SharedQuizView'; -import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { MemoryRouter } from 'react-router-dom'; const mockNavigate = vi.fn(); vi.mock('react-router-dom', async () => { @@ -71,9 +71,7 @@ const renderWithRouter = (token: string = 'valid-token') => { render( - - } /> - + ); diff --git a/tests/hooks/useGame.navigation.test.tsx b/tests/hooks/useGame.navigation.test.tsx index bf3c639..07d97b0 100644 --- a/tests/hooks/useGame.navigation.test.tsx +++ b/tests/hooks/useGame.navigation.test.tsx @@ -8,7 +8,11 @@ describe('URL Routing and Navigation', () => { role: 'HOST' | 'CLIENT', currentPath: string, sourceQuizId: string | null = null - ): string => { + ): string | null => { + if (currentPath === '/callback' || currentPath.startsWith('/shared/')) { + return null; + } + switch (gameState) { case 'LANDING': if (gamePin && currentPath.startsWith('/play/')) { @@ -42,6 +46,26 @@ describe('URL Routing and Navigation', () => { } }; + describe('Shared quiz routes', () => { + it('should return null for /shared/:token routes to skip navigation', () => { + expect(getTargetPath('LANDING', null, 'HOST', '/shared/abc123')).toBeNull(); + }); + + it('should return null for /shared/:token with complex token', () => { + expect(getTargetPath('LANDING', null, 'HOST', '/shared/6yC50NixzVuA-E_Z0JhEUA')).toBeNull(); + }); + + it('should return null for /shared/:token regardless of gameState', () => { + expect(getTargetPath('LOBBY', '123456', 'HOST', '/shared/test-token')).toBeNull(); + expect(getTargetPath('EDITING', null, 'HOST', '/shared/test-token')).toBeNull(); + expect(getTargetPath('PODIUM', '123456', 'HOST', '/shared/test-token')).toBeNull(); + }); + + it('should return null for /callback route', () => { + expect(getTargetPath('LANDING', null, 'HOST', '/callback')).toBeNull(); + }); + }); + describe('LANDING state', () => { it('should return "/" for LANDING state without gamePin', () => { expect(getTargetPath('LANDING', null, 'HOST', '/')).toBe('/'); @@ -139,6 +163,10 @@ describe('URL Routing and Navigation', () => { path: string, storedSession: StoredSession | null ): { action: string; gamePin?: string; quizId?: string; shouldReconnect?: boolean } => { + if (path === '/callback' || path.startsWith('/shared/')) { + return { action: 'skipInit' }; + } + const hostMatch = path.match(/^\/host\/(\d+)$/); const playMatch = path.match(/^\/play\/(\d+)$/); @@ -182,6 +210,29 @@ describe('URL Routing and Navigation', () => { return { action: 'showLanding' }; }; + describe('/shared/:token URL', () => { + it('should skip initialization for shared quiz routes', () => { + const result = simulateUrlInit('/shared/abc123', null); + expect(result.action).toBe('skipInit'); + }); + + it('should skip initialization for shared routes with complex tokens', () => { + const result = simulateUrlInit('/shared/6yC50NixzVuA-E_Z0JhEUA', null); + expect(result.action).toBe('skipInit'); + }); + + it('should skip initialization for shared routes even with existing session', () => { + const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' }; + const result = simulateUrlInit('/shared/test-token', session); + expect(result.action).toBe('skipInit'); + }); + + it('should skip initialization for /callback route', () => { + const result = simulateUrlInit('/callback', null); + expect(result.action).toBe('skipInit'); + }); + }); + describe('/host/:pin URL', () => { it('should reconnect as host when session matches', () => { const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' }; @@ -551,6 +602,28 @@ describe('URL Routing and Navigation', () => { expect(navigatedTo).toBe('/'); }); + it('should NOT redirect /shared/:token to "/" (regression test)', () => { + const currentPath = '/shared/6yC50NixzVuA-E_Z0JhEUA'; + const gameState = 'LANDING'; + const gamePin: string | null = null; + + const getTargetPath = () => { + if (currentPath.startsWith('/shared/')) { + return null; + } + if (gameState === 'LANDING' && !gamePin) { + return '/'; + } + return currentPath; + }; + + const targetPath = getTargetPath(); + const shouldNavigate = targetPath !== null && currentPath !== targetPath; + + expect(targetPath).toBeNull(); + expect(shouldNavigate).toBe(false); + }); + it('should NOT get stuck on /play/:pin after goHomeFromDisconnected (regression test)', () => { let gameState = 'DISCONNECTED'; let gamePin: string | null = '123456';