Skip to content

Commit bbfd24f

Browse files
authored
Add useForesights to @foresightjs/react (#92)
* add useForesights * prettier
1 parent 330458d commit bbfd24f

4 files changed

Lines changed: 423 additions & 47 deletions

File tree

packages/devpage-react/src/pages/home/index.tsx

Lines changed: 51 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { useState } from "react"
2-
import { useForesight } from "@foresightjs/react"
1+
import { useMemo, useState } from "react"
2+
import { useForesights } from "@foresightjs/react"
33
import { useQuery, useQueryClient } from "@tanstack/react-query"
44
import { pageQueryOptions, prefetchPage } from "./api"
55
import { ForesightImageButton } from "./ForesightImageButton"
@@ -12,39 +12,6 @@ const PAGES = [
1212
{ slug: "pricing", label: "Pricing" },
1313
] as const
1414

15-
type ForesightPageButtonProps = {
16-
slug: string
17-
label: string
18-
isActive: boolean
19-
onSelect: (slug: string) => void
20-
}
21-
22-
const ForesightPageButton = ({ slug, label, isActive, onSelect }: ForesightPageButtonProps) => {
23-
const queryClient = useQueryClient()
24-
const reactivateAfter = useReactivateAfter()
25-
26-
const { elementRef, isPredicted } = useForesight<HTMLButtonElement>({
27-
callback: () => prefetchPage(queryClient, slug),
28-
name: slug,
29-
hitSlop: 20,
30-
reactivateAfter,
31-
})
32-
33-
return (
34-
<button
35-
ref={elementRef}
36-
onClick={() => onSelect(slug)}
37-
className={`px-5 py-3 border text-sm font-medium transition-colors cursor-pointer ${
38-
isActive
39-
? "border-gray-900 bg-gray-900 text-white"
40-
: "border-gray-400 text-gray-800 hover:bg-gray-100"
41-
} ${isPredicted ? "outline-1 outline-amber-500" : ""}`}
42-
>
43-
{label}
44-
</button>
45-
)
46-
}
47-
4815
const PageContent = ({ slug }: { slug: string }) => {
4916
const { data, isLoading, isFetching } = useQuery(pageQueryOptions(slug))
5017

@@ -123,25 +90,62 @@ const ImageSection = () => {
12390
)
12491
}
12592

93+
const PageButtons = ({
94+
activePage,
95+
onSelect,
96+
}: {
97+
activePage: string | null
98+
onSelect: (slug: string) => void
99+
}) => {
100+
const queryClient = useQueryClient()
101+
const reactivateAfter = useReactivateAfter()
102+
103+
const options = useMemo(
104+
() =>
105+
PAGES.map(({ slug }) => ({
106+
callback: () => prefetchPage(queryClient, slug),
107+
name: slug,
108+
hitSlop: 20 as const,
109+
reactivateAfter,
110+
})),
111+
[queryClient, reactivateAfter]
112+
)
113+
114+
const results = useForesights<HTMLButtonElement>(options)
115+
116+
return (
117+
<nav className="flex gap-3">
118+
{PAGES.map(({ slug, label }, i) => {
119+
const { elementRef, isPredicted } = results[i]
120+
const isActive = activePage === slug
121+
122+
return (
123+
<button
124+
key={slug}
125+
ref={elementRef}
126+
onClick={() => onSelect(slug)}
127+
className={`px-5 py-3 border text-sm font-medium transition-colors cursor-pointer ${
128+
isActive
129+
? "border-gray-900 bg-gray-900 text-white"
130+
: "border-gray-400 text-gray-800 hover:bg-gray-100"
131+
} ${isPredicted ? "outline-1 outline-amber-500" : ""}`}
132+
>
133+
{label}
134+
</button>
135+
)
136+
})}
137+
</nav>
138+
)
139+
}
140+
126141
const Home = () => {
127142
const [activePage, setActivePage] = useState<string | null>(null)
128143

129144
return (
130145
<main className="max-w-4xl mx-auto px-6 py-8 space-y-8">
131146
<section className="space-y-4">
132147
<h1 className="text-xl font-semibold">Data prefetching</h1>
133-
<nav className="flex gap-3">
134-
{PAGES.map(({ slug, label }) => (
135-
<ForesightPageButton
136-
key={slug}
137-
slug={slug}
138-
label={label}
139-
isActive={activePage === slug}
140-
onSelect={setActivePage}
141-
/>
142-
))}
143-
</nav>
144-
148+
<PageButtons activePage={activePage} onSelect={setActivePage} />
145149
{activePage && <PageContent slug={activePage} />}
146150
</section>
147151

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { act, render } from "@testing-library/react"
2+
import { beforeEach, describe, expect, it, vi } from "vitest"
3+
import {
4+
createUnregisteredSnapshot,
5+
type ForesightRegisterOptionsWithoutElement,
6+
} from "js.foresight"
7+
import { mockState, registerSpy, unregisterSpy } from "../tests/setup"
8+
import { useForesights } from "./useForesights"
9+
10+
beforeEach(() => {
11+
registerSpy.mockClear()
12+
unregisterSpy.mockClear()
13+
mockState.listeners = []
14+
mockState.lastCallbackWrapper = null
15+
mockState.currentSnapshot = createUnregisteredSnapshot(false)
16+
})
17+
18+
type ProbeProps = {
19+
optionsArray: ForesightRegisterOptionsWithoutElement[]
20+
}
21+
22+
const MultiProbe = ({ optionsArray }: ProbeProps) => {
23+
const results = useForesights<HTMLButtonElement>(optionsArray)
24+
25+
return (
26+
<div>
27+
{results.map((r, i) => (
28+
<button
29+
key={i}
30+
data-testid={`el-${i}`}
31+
data-predicted={r.isPredicted}
32+
data-registered={r.isRegistered}
33+
ref={r.elementRef}
34+
/>
35+
))}
36+
</div>
37+
)
38+
}
39+
40+
describe("useForesights", () => {
41+
it("registers all elements when refs attach", () => {
42+
render(
43+
<MultiProbe
44+
optionsArray={[
45+
{ name: "a", callback: vi.fn() },
46+
{ name: "b", callback: vi.fn() },
47+
]}
48+
/>
49+
)
50+
expect(registerSpy).toHaveBeenCalledTimes(2)
51+
expect(registerSpy.mock.calls[0][0].name).toBe("a")
52+
expect(registerSpy.mock.calls[0][0].element).toBeInstanceOf(HTMLButtonElement)
53+
expect(registerSpy.mock.calls[1][0].name).toBe("b")
54+
expect(registerSpy.mock.calls[1][0].element).toBeInstanceOf(HTMLButtonElement)
55+
})
56+
57+
it("unregisters all on unmount", () => {
58+
const { unmount } = render(
59+
<MultiProbe
60+
optionsArray={[
61+
{ name: "a", callback: vi.fn() },
62+
{ name: "b", callback: vi.fn() },
63+
]}
64+
/>
65+
)
66+
unmount()
67+
expect(unregisterSpy).toHaveBeenCalledTimes(2)
68+
})
69+
70+
it("returns unregistered initial snapshot before nodes attach", () => {
71+
const NoRefProbe = () => {
72+
const results = useForesights([{ callback: vi.fn() }])
73+
74+
return <span data-testid="state" data-registered={results[0].isRegistered} />
75+
}
76+
const { getByTestId } = render(<NoRefProbe />)
77+
expect(getByTestId("state").getAttribute("data-registered")).toBe("false")
78+
})
79+
80+
it("reflects state updates pushed through subscribe", () => {
81+
const { getByTestId } = render(
82+
<MultiProbe
83+
optionsArray={[
84+
{ name: "a", callback: vi.fn() },
85+
{ name: "b", callback: vi.fn() },
86+
]}
87+
/>
88+
)
89+
90+
expect(getByTestId("el-0").getAttribute("data-predicted")).toBe("false")
91+
92+
act(() => {
93+
mockState.currentSnapshot = { ...createUnregisteredSnapshot(false), isPredicted: true }
94+
mockState.listeners.forEach(l => l())
95+
})
96+
97+
expect(getByTestId("el-0").getAttribute("data-predicted")).toBe("true")
98+
})
99+
100+
it("forwards the latest callback (no stale closure)", () => {
101+
const cb1 = vi.fn()
102+
const cb2 = vi.fn()
103+
const { rerender } = render(<MultiProbe optionsArray={[{ name: "a", callback: cb1 }]} />)
104+
rerender(<MultiProbe optionsArray={[{ name: "a", callback: cb2 }]} />)
105+
106+
const fired = { ...createUnregisteredSnapshot(false), isPredicted: true }
107+
act(() => {
108+
mockState.lastCallbackWrapper?.(fired)
109+
})
110+
111+
expect(cb1).not.toHaveBeenCalled()
112+
expect(cb2).toHaveBeenCalledWith(fired)
113+
})
114+
115+
it("handles growing the array (new items added)", () => {
116+
const { rerender } = render(<MultiProbe optionsArray={[{ name: "a", callback: vi.fn() }]} />)
117+
expect(registerSpy).toHaveBeenCalledTimes(1)
118+
119+
registerSpy.mockClear()
120+
rerender(
121+
<MultiProbe
122+
optionsArray={[
123+
{ name: "a", callback: vi.fn() },
124+
{ name: "b", callback: vi.fn() },
125+
]}
126+
/>
127+
)
128+
129+
// Should register the new element (and possibly re-register existing)
130+
const names = registerSpy.mock.calls.map(c => c[0].name)
131+
expect(names).toContain("b")
132+
})
133+
134+
it("handles shrinking the array (items removed)", () => {
135+
const { rerender } = render(
136+
<MultiProbe
137+
optionsArray={[
138+
{ name: "a", callback: vi.fn() },
139+
{ name: "b", callback: vi.fn() },
140+
]}
141+
/>
142+
)
143+
unregisterSpy.mockClear()
144+
145+
rerender(<MultiProbe optionsArray={[{ name: "a", callback: vi.fn() }]} />)
146+
147+
// The removed element should be unregistered
148+
expect(unregisterSpy).toHaveBeenCalled()
149+
})
150+
151+
it("patches options without unregistering", () => {
152+
const { rerender } = render(<MultiProbe optionsArray={[{ name: "a", callback: vi.fn() }]} />)
153+
registerSpy.mockClear()
154+
unregisterSpy.mockClear()
155+
156+
rerender(<MultiProbe optionsArray={[{ name: "b", callback: vi.fn() }]} />)
157+
158+
expect(registerSpy).toHaveBeenCalled()
159+
const lastCall = registerSpy.mock.calls[registerSpy.mock.calls.length - 1]
160+
expect(lastCall?.[0].name).toBe("b")
161+
expect(unregisterSpy).not.toHaveBeenCalled()
162+
})
163+
164+
it("works with an empty array", () => {
165+
const { container } = render(<MultiProbe optionsArray={[]} />)
166+
expect(registerSpy).not.toHaveBeenCalled()
167+
expect(container.querySelectorAll("button")).toHaveLength(0)
168+
})
169+
170+
it("registers three elements", () => {
171+
render(
172+
<MultiProbe
173+
optionsArray={[
174+
{ name: "x", callback: vi.fn() },
175+
{ name: "y", callback: vi.fn() },
176+
{ name: "z", callback: vi.fn() },
177+
]}
178+
/>
179+
)
180+
expect(registerSpy).toHaveBeenCalledTimes(3)
181+
expect(registerSpy.mock.calls[0][0].name).toBe("x")
182+
expect(registerSpy.mock.calls[1][0].name).toBe("y")
183+
expect(registerSpy.mock.calls[2][0].name).toBe("z")
184+
})
185+
})

0 commit comments

Comments
 (0)