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
98 changes: 51 additions & 47 deletions packages/devpage-react/src/pages/home/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from "react"
import { useForesight } from "@foresightjs/react"
import { useMemo, useState } from "react"
import { useForesights } from "@foresightjs/react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { pageQueryOptions, prefetchPage } from "./api"
import { ForesightImageButton } from "./ForesightImageButton"
Expand All @@ -12,39 +12,6 @@ const PAGES = [
{ slug: "pricing", label: "Pricing" },
] as const

type ForesightPageButtonProps = {
slug: string
label: string
isActive: boolean
onSelect: (slug: string) => void
}

const ForesightPageButton = ({ slug, label, isActive, onSelect }: ForesightPageButtonProps) => {
const queryClient = useQueryClient()
const reactivateAfter = useReactivateAfter()

const { elementRef, isPredicted } = useForesight<HTMLButtonElement>({
callback: () => prefetchPage(queryClient, slug),
name: slug,
hitSlop: 20,
reactivateAfter,
})

return (
<button
ref={elementRef}
onClick={() => onSelect(slug)}
className={`px-5 py-3 border text-sm font-medium transition-colors cursor-pointer ${
isActive
? "border-gray-900 bg-gray-900 text-white"
: "border-gray-400 text-gray-800 hover:bg-gray-100"
} ${isPredicted ? "outline-1 outline-amber-500" : ""}`}
>
{label}
</button>
)
}

const PageContent = ({ slug }: { slug: string }) => {
const { data, isLoading, isFetching } = useQuery(pageQueryOptions(slug))

Expand Down Expand Up @@ -123,25 +90,62 @@ const ImageSection = () => {
)
}

const PageButtons = ({
activePage,
onSelect,
}: {
activePage: string | null
onSelect: (slug: string) => void
}) => {
const queryClient = useQueryClient()
const reactivateAfter = useReactivateAfter()

const options = useMemo(
() =>
PAGES.map(({ slug }) => ({
callback: () => prefetchPage(queryClient, slug),
name: slug,
hitSlop: 20 as const,
reactivateAfter,
})),
[queryClient, reactivateAfter]
)

const results = useForesights<HTMLButtonElement>(options)

return (
<nav className="flex gap-3">
{PAGES.map(({ slug, label }, i) => {
const { elementRef, isPredicted } = results[i]
const isActive = activePage === slug

return (
<button
key={slug}
ref={elementRef}
onClick={() => onSelect(slug)}
className={`px-5 py-3 border text-sm font-medium transition-colors cursor-pointer ${
isActive
? "border-gray-900 bg-gray-900 text-white"
: "border-gray-400 text-gray-800 hover:bg-gray-100"
} ${isPredicted ? "outline-1 outline-amber-500" : ""}`}
>
{label}
</button>
)
})}
</nav>
)
}

const Home = () => {
const [activePage, setActivePage] = useState<string | null>(null)

return (
<main className="max-w-4xl mx-auto px-6 py-8 space-y-8">
<section className="space-y-4">
<h1 className="text-xl font-semibold">Data prefetching</h1>
<nav className="flex gap-3">
{PAGES.map(({ slug, label }) => (
<ForesightPageButton
key={slug}
slug={slug}
label={label}
isActive={activePage === slug}
onSelect={setActivePage}
/>
))}
</nav>

<PageButtons activePage={activePage} onSelect={setActivePage} />
{activePage && <PageContent slug={activePage} />}
</section>

Expand Down
185 changes: 185 additions & 0 deletions packages/foresightjs-react/src/hooks/useForesights.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { act, render } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import {
createUnregisteredSnapshot,
type ForesightRegisterOptionsWithoutElement,
} from "js.foresight"
import { mockState, registerSpy, unregisterSpy } from "../tests/setup"
import { useForesights } from "./useForesights"

beforeEach(() => {
registerSpy.mockClear()
unregisterSpy.mockClear()
mockState.listeners = []
mockState.lastCallbackWrapper = null
mockState.currentSnapshot = createUnregisteredSnapshot(false)
})

type ProbeProps = {
optionsArray: ForesightRegisterOptionsWithoutElement[]
}

const MultiProbe = ({ optionsArray }: ProbeProps) => {
const results = useForesights<HTMLButtonElement>(optionsArray)

return (
<div>
{results.map((r, i) => (
<button
key={i}
data-testid={`el-${i}`}
data-predicted={r.isPredicted}
data-registered={r.isRegistered}
ref={r.elementRef}
/>
))}
</div>
)
}

describe("useForesights", () => {
it("registers all elements when refs attach", () => {
render(
<MultiProbe
optionsArray={[
{ name: "a", callback: vi.fn() },
{ name: "b", callback: vi.fn() },
]}
/>
)
expect(registerSpy).toHaveBeenCalledTimes(2)
expect(registerSpy.mock.calls[0][0].name).toBe("a")
expect(registerSpy.mock.calls[0][0].element).toBeInstanceOf(HTMLButtonElement)
expect(registerSpy.mock.calls[1][0].name).toBe("b")
expect(registerSpy.mock.calls[1][0].element).toBeInstanceOf(HTMLButtonElement)
})

it("unregisters all on unmount", () => {
const { unmount } = render(
<MultiProbe
optionsArray={[
{ name: "a", callback: vi.fn() },
{ name: "b", callback: vi.fn() },
]}
/>
)
unmount()
expect(unregisterSpy).toHaveBeenCalledTimes(2)
})

it("returns unregistered initial snapshot before nodes attach", () => {
const NoRefProbe = () => {
const results = useForesights([{ callback: vi.fn() }])

return <span data-testid="state" data-registered={results[0].isRegistered} />
}
const { getByTestId } = render(<NoRefProbe />)
expect(getByTestId("state").getAttribute("data-registered")).toBe("false")
})

it("reflects state updates pushed through subscribe", () => {
const { getByTestId } = render(
<MultiProbe
optionsArray={[
{ name: "a", callback: vi.fn() },
{ name: "b", callback: vi.fn() },
]}
/>
)

expect(getByTestId("el-0").getAttribute("data-predicted")).toBe("false")

act(() => {
mockState.currentSnapshot = { ...createUnregisteredSnapshot(false), isPredicted: true }
mockState.listeners.forEach(l => l())
})

expect(getByTestId("el-0").getAttribute("data-predicted")).toBe("true")
})

it("forwards the latest callback (no stale closure)", () => {
const cb1 = vi.fn()
const cb2 = vi.fn()
const { rerender } = render(<MultiProbe optionsArray={[{ name: "a", callback: cb1 }]} />)
rerender(<MultiProbe optionsArray={[{ name: "a", callback: cb2 }]} />)

const fired = { ...createUnregisteredSnapshot(false), isPredicted: true }
act(() => {
mockState.lastCallbackWrapper?.(fired)
})

expect(cb1).not.toHaveBeenCalled()
expect(cb2).toHaveBeenCalledWith(fired)
})

it("handles growing the array (new items added)", () => {
const { rerender } = render(<MultiProbe optionsArray={[{ name: "a", callback: vi.fn() }]} />)
expect(registerSpy).toHaveBeenCalledTimes(1)

registerSpy.mockClear()
rerender(
<MultiProbe
optionsArray={[
{ name: "a", callback: vi.fn() },
{ name: "b", callback: vi.fn() },
]}
/>
)

// Should register the new element (and possibly re-register existing)
const names = registerSpy.mock.calls.map(c => c[0].name)
expect(names).toContain("b")
})

it("handles shrinking the array (items removed)", () => {
const { rerender } = render(
<MultiProbe
optionsArray={[
{ name: "a", callback: vi.fn() },
{ name: "b", callback: vi.fn() },
]}
/>
)
unregisterSpy.mockClear()

rerender(<MultiProbe optionsArray={[{ name: "a", callback: vi.fn() }]} />)

// The removed element should be unregistered
expect(unregisterSpy).toHaveBeenCalled()
})

it("patches options without unregistering", () => {
const { rerender } = render(<MultiProbe optionsArray={[{ name: "a", callback: vi.fn() }]} />)
registerSpy.mockClear()
unregisterSpy.mockClear()

rerender(<MultiProbe optionsArray={[{ name: "b", callback: vi.fn() }]} />)

expect(registerSpy).toHaveBeenCalled()
const lastCall = registerSpy.mock.calls[registerSpy.mock.calls.length - 1]
expect(lastCall?.[0].name).toBe("b")
expect(unregisterSpy).not.toHaveBeenCalled()
})

it("works with an empty array", () => {
const { container } = render(<MultiProbe optionsArray={[]} />)
expect(registerSpy).not.toHaveBeenCalled()
expect(container.querySelectorAll("button")).toHaveLength(0)
})

it("registers three elements", () => {
render(
<MultiProbe
optionsArray={[
{ name: "x", callback: vi.fn() },
{ name: "y", callback: vi.fn() },
{ name: "z", callback: vi.fn() },
]}
/>
)
expect(registerSpy).toHaveBeenCalledTimes(3)
expect(registerSpy.mock.calls[0][0].name).toBe("x")
expect(registerSpy.mock.calls[1][0].name).toBe("y")
expect(registerSpy.mock.calls[2][0].name).toBe("z")
})
})
Loading
Loading