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