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
127 changes: 127 additions & 0 deletions components/core/SlideshowViewer.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SlideshowViewer config={config} />);
});
}

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");
});
});
19 changes: 11 additions & 8 deletions components/core/SlideshowViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,28 @@ 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;
}

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 = () => {
Expand All @@ -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);
Expand Down
24 changes: 23 additions & 1 deletion lib/slide-url.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
13 changes: 13 additions & 0 deletions lib/slide-url.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading