diff --git a/components/core/SlideshowViewer.test.tsx b/components/core/SlideshowViewer.test.tsx new file mode 100644 index 00000000..df82ed3e --- /dev/null +++ b/components/core/SlideshowViewer.test.tsx @@ -0,0 +1,127 @@ +import { act, useMemo, useSyncExternalStore } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import SlideshowViewer from "./SlideshowViewer"; +import type { SlideshowConfig } from "@/lib/types"; + +// Next's app router mirrors window.history.replaceState into useSearchParams. +// Reproduce that here so the viewer sees its own URL writes, as it does in +// the browser. +const locationStore = vi.hoisted(() => { + const listeners = new Set<() => void>(); + return { + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + notify() { + listeners.forEach((listener) => listener()); + }, + }; +}); + +vi.mock("next/navigation", () => ({ + useSearchParams() { + const search = useSyncExternalStore( + locationStore.subscribe, + () => window.location.search, + ); + return useMemo(() => new URLSearchParams(search), [search]); + }, +})); + +const Blank = () => null; + +const config: SlideshowConfig = { + id: "demo", + title: "Demo", + description: "Demo deck", + date: "2026-10-05", + slides: [Blank, Blank, { component: Blank, builds: 3 }, Blank, Blank], +}; +const TOTAL_STEPS = 7; + +const originalReplaceState = window.history.replaceState; +let container: HTMLDivElement; +let root: Root; + +function counterText() { + return [...container.querySelectorAll("span")] + .map((span) => span.textContent?.trim()) + .find((text) => text && /^\d+ \/ \d+$/.test(text)); +} + +function currentUrl() { + return window.location.pathname + window.location.search; +} + +async function renderAt(url: string) { + originalReplaceState.call(window.history, null, "", url); + await act(async () => { + root.render(); + }); +} + +async function press(key: string) { + await act(async () => { + window.dispatchEvent(new KeyboardEvent("keydown", { key })); + }); +} + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + window.history.replaceState = function (...args) { + originalReplaceState.apply(window.history, args); + locationStore.notify(); + }; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + window.history.replaceState = originalReplaceState; +}); + +describe("SlideshowViewer deep links", () => { + it("opens at the step in the slide param and keeps it in the URL", async () => { + await renderAt("/slides/demo?slide=5"); + expect(counterText()).toBe(`6 / ${TOTAL_STEPS}`); + expect(currentUrl()).toBe("/slides/demo?slide=5"); + }); + + it("opens at the first step without a slide param", async () => { + await renderAt("/slides/demo"); + expect(counterText()).toBe(`1 / ${TOTAL_STEPS}`); + expect(currentUrl()).toBe("/slides/demo"); + }); + + it("clamps a slide param past the last step", async () => { + await renderAt("/slides/demo?slide=99"); + expect(counterText()).toBe(`${TOTAL_STEPS} / ${TOTAL_STEPS}`); + expect(currentUrl()).toBe(`/slides/demo?slide=${TOTAL_STEPS - 1}`); + }); + + it("updates the URL on arrow keys after a deep link", async () => { + await renderAt("/slides/demo?slide=2"); + await press("ArrowRight"); + expect(counterText()).toBe(`4 / ${TOTAL_STEPS}`); + expect(currentUrl()).toBe("/slides/demo?slide=3"); + await press("ArrowLeft"); + await press("ArrowLeft"); + expect(counterText()).toBe(`2 / ${TOTAL_STEPS}`); + expect(currentUrl()).toBe("/slides/demo?slide=1"); + }); + + it("jumps to the ends on Home and End", async () => { + await renderAt("/slides/demo?slide=3"); + await press("End"); + expect(counterText()).toBe(`${TOTAL_STEPS} / ${TOTAL_STEPS}`); + expect(currentUrl()).toBe(`/slides/demo?slide=${TOTAL_STEPS - 1}`); + await press("Home"); + expect(counterText()).toBe(`1 / ${TOTAL_STEPS}`); + expect(currentUrl()).toBe("/slides/demo"); + }); +}); diff --git a/components/core/SlideshowViewer.tsx b/components/core/SlideshowViewer.tsx index f7037d41..173361fe 100644 --- a/components/core/SlideshowViewer.tsx +++ b/components/core/SlideshowViewer.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useMemo, Suspense } from 'react'; import { useSearchParams } from 'next/navigation'; import { SlideshowProvider, SlideshowContextValue } from './SlideshowContext'; import { SlideshowConfig, flattenSlides, getSlideComponent } from '@/lib/types'; -import { buildSlideUrl } from '@/lib/slide-url'; +import { buildSlideUrl, parseSlideParam } from '@/lib/slide-url'; interface SlideshowViewerProps { config: SlideshowConfig; @@ -12,17 +12,20 @@ interface SlideshowViewerProps { function SlideshowViewerClient({ config }: SlideshowViewerProps) { const searchParams = useSearchParams(); - const [currentStep, setCurrentStep] = useState(0); - const [mounted, setMounted] = useState(false); - const [isFullscreen, setIsFullscreen] = useState(false); - const steps = useMemo(() => flattenSlides(config.slides), [config.slides]); const totalSteps = steps.length; + // Read the deep-link step once. After that, currentStep owns the URL: the + // effect below writes it with replaceState, and Next mirrors that write back + // into useSearchParams, so re-reading the param would undo navigation. + const [currentStep, setCurrentStep] = useState(() => + parseSlideParam(searchParams.get('slide'), totalSteps), + ); + const [mounted, setMounted] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + useEffect(() => { setMounted(true); - const initialSlide = parseInt(searchParams.get('slide') || '0', 10); - setCurrentStep(Math.max(0, Math.min(initialSlide, totalSteps - 1))); setIsFullscreen(!!document.fullscreenElement); const handleFullscreenChange = () => { @@ -31,7 +34,7 @@ function SlideshowViewerClient({ config }: SlideshowViewerProps) { document.addEventListener('fullscreenchange', handleFullscreenChange); return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); - }, [searchParams, totalSteps]); + }, []); useEffect(() => { const url = buildSlideUrl(window.location.href, currentStep); diff --git a/lib/slide-url.test.ts b/lib/slide-url.test.ts index 1b7be01b..a10a9b9d 100644 --- a/lib/slide-url.test.ts +++ b/lib/slide-url.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildSlideUrl } from "./slide-url"; +import { buildSlideUrl, parseSlideParam } from "./slide-url"; describe("buildSlideUrl", () => { it("preserves basePath when on step 0", () => { @@ -50,3 +50,25 @@ describe("buildSlideUrl", () => { expect(result).toBe("/slides/abundance-dmv?slide=4"); }); }); + +describe("parseSlideParam", () => { + it("returns the step for a valid param", () => { + expect(parseSlideParam("5", 10)).toBe(5); + }); + + it("returns the first step when the param is missing", () => { + expect(parseSlideParam(null, 10)).toBe(0); + }); + + it("returns the first step for a non-numeric param", () => { + expect(parseSlideParam("abc", 10)).toBe(0); + }); + + it("clamps a negative param to the first step", () => { + expect(parseSlideParam("-3", 10)).toBe(0); + }); + + it("clamps a param past the end to the last step", () => { + expect(parseSlideParam("99", 10)).toBe(9); + }); +}); diff --git a/lib/slide-url.ts b/lib/slide-url.ts index dd201ee7..18d963d0 100644 --- a/lib/slide-url.ts +++ b/lib/slide-url.ts @@ -1,3 +1,16 @@ +/** + * Parse the `slide` search param into a step index within [0, totalSteps - 1]. + * A missing or malformed value falls back to the first step. + */ +export function parseSlideParam( + value: string | null, + totalSteps: number, +): number { + const step = parseInt(value ?? "", 10); + if (Number.isNaN(step)) return 0; + return Math.max(0, Math.min(step, totalSteps - 1)); +} + /** * Build the URL to display in the address bar for the current slide step. * Preserves the existing pathname (including basePath) and only modifies