From bd0abc4057fa3d1903dd985ce1f883bfa2049237 Mon Sep 17 00:00:00 2001 From: Bart Spaans Date: Sat, 2 May 2026 21:55:50 +0200 Subject: [PATCH 1/2] add useForesights --- .../devpage-react/src/pages/home/index.tsx | 98 ++++----- .../src/hooks/useForesights.test.tsx | 191 ++++++++++++++++++ .../src/hooks/useForesights.ts | 186 +++++++++++++++++ packages/foresightjs-react/src/index.ts | 1 + 4 files changed, 429 insertions(+), 47 deletions(-) create mode 100644 packages/foresightjs-react/src/hooks/useForesights.test.tsx create mode 100644 packages/foresightjs-react/src/hooks/useForesights.ts diff --git a/packages/devpage-react/src/pages/home/index.tsx b/packages/devpage-react/src/pages/home/index.tsx index 3cf3859..1351d9a 100644 --- a/packages/devpage-react/src/pages/home/index.tsx +++ b/packages/devpage-react/src/pages/home/index.tsx @@ -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" @@ -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({ - callback: () => prefetchPage(queryClient, slug), - name: slug, - hitSlop: 20, - reactivateAfter, - }) - - return ( - - ) -} - const PageContent = ({ slug }: { slug: string }) => { const { data, isLoading, isFetching } = useQuery(pageQueryOptions(slug)) @@ -123,6 +90,54 @@ 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(options) + + return ( + + ) +} + const Home = () => { const [activePage, setActivePage] = useState(null) @@ -130,18 +145,7 @@ const Home = () => {

Data prefetching

- - + {activePage && }
diff --git a/packages/foresightjs-react/src/hooks/useForesights.test.tsx b/packages/foresightjs-react/src/hooks/useForesights.test.tsx new file mode 100644 index 0000000..5e74e33 --- /dev/null +++ b/packages/foresightjs-react/src/hooks/useForesights.test.tsx @@ -0,0 +1,191 @@ +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(optionsArray) + + return ( +
+ {results.map((r, i) => ( +
+ ) +} + +describe("useForesights", () => { + it("registers all elements when refs attach", () => { + render( + + ) + 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( + + ) + unmount() + expect(unregisterSpy).toHaveBeenCalledTimes(2) + }) + + it("returns unregistered initial snapshot before nodes attach", () => { + const NoRefProbe = () => { + const results = useForesights([{ callback: vi.fn() }]) + + return + } + const { getByTestId } = render() + expect(getByTestId("state").getAttribute("data-registered")).toBe("false") + }) + + it("reflects state updates pushed through subscribe", () => { + const { getByTestId } = render( + + ) + + 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( + + ) + rerender() + + 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( + + ) + expect(registerSpy).toHaveBeenCalledTimes(1) + + registerSpy.mockClear() + rerender( + + ) + + // 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( + + ) + unregisterSpy.mockClear() + + rerender() + + // The removed element should be unregistered + expect(unregisterSpy).toHaveBeenCalled() + }) + + it("patches options without unregistering", () => { + const { rerender } = render( + + ) + registerSpy.mockClear() + unregisterSpy.mockClear() + + rerender() + + 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() + expect(registerSpy).not.toHaveBeenCalled() + expect(container.querySelectorAll("button")).toHaveLength(0) + }) + + it("registers three elements", () => { + render( + + ) + 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") + }) +}) diff --git a/packages/foresightjs-react/src/hooks/useForesights.ts b/packages/foresightjs-react/src/hooks/useForesights.ts new file mode 100644 index 0000000..7d39666 --- /dev/null +++ b/packages/foresightjs-react/src/hooks/useForesights.ts @@ -0,0 +1,186 @@ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react" +import { + ForesightManager, + createUnregisteredSnapshot, + type ForesightElementState, + type ForesightRegisterOptionsWithoutElement, + type ForesightRegisterResult, +} from "js.foresight" +import type { UseForesightResult } from "./useForesight" + +const INITIAL_SNAPSHOT = createUnregisteredSnapshot(false) +const NOOP_SUBSCRIBE = () => () => {} +const EMPTY_SNAPSHOTS: ForesightElementState[] = [] + +type SlotEntry = { + element: Element + result: ForesightRegisterResult +} + +export const useForesights = ( + optionsArray: ForesightRegisterOptionsWithoutElement[] +): UseForesightResult[] => { + const optionsRef = useRef(optionsArray) + optionsRef.current = optionsArray + + const elementsRef = useRef(new Map()) + const elementRefCallbacksRef = useRef(new Map void>()) + const cachedSnapshotsRef = useRef(EMPTY_SNAPSHOTS) + const slotsRef = useRef(new Map()) + + const [elements, setElements] = useState>(new Map()) + const [resultsList, setResultsList] = useState>( + new Map() + ) + + // Stable elementRef factory - one callback per index, reused across renders + const getElementRef = useCallback((index: number): ((node: T | null) => void) => { + let existing = elementRefCallbacksRef.current.get(index) + if (!existing) { + existing = (node: T | null) => { + const prev = elementsRef.current.get(index) ?? null + if (prev === node) { + return + } + + if (node) { + elementsRef.current.set(index, node) + } else { + elementsRef.current.delete(index) + } + + setElements(new Map(elementsRef.current)) + } + elementRefCallbacksRef.current.set(index, existing) + } + + return existing + }, []) + + // Register/unregister when elements change or the array length changes + useEffect(() => { + const prevResults = new Map(slotsRef.current) + const nextSlots = new Map() + + // Register each slot that has an element + for (let i = 0; i < optionsArray.length; i++) { + const el = elements.get(i) + if (!el) { + continue + } + + const prev = prevResults.get(i) + + // If same element is already registered, keep the existing result + if (prev && prev.element === el) { + nextSlots.set(i, prev) + prevResults.delete(i) + continue + } + + // New or swapped element - register it + const result = ForesightManager.instance.register({ + ...optionsRef.current[i], + element: el, + callback: (state: ForesightElementState) => optionsRef.current[i].callback(state), + }) + nextSlots.set(i, { element: el, result }) + } + + // Unregister everything that's no longer needed + for (const [, slot] of prevResults) { + slot.result.unregister() + } + + slotsRef.current = nextSlots + setResultsList(new Map(Array.from(nextSlots.entries()).map(([k, v]) => [k, v.result]))) + + return () => { + for (const [, slot] of slotsRef.current) { + slot.result.unregister() + } + slotsRef.current = new Map() + } + }, [optionsArray.length, elements]) + + // Patch options on existing registrations without tearing them down + useEffect(() => { + for (let i = 0; i < optionsArray.length; i++) { + const slot = slotsRef.current.get(i) + if (!slot) { + continue + } + + ForesightManager.instance.register({ + ...optionsRef.current[i], + element: slot.element, + callback: (state: ForesightElementState) => optionsRef.current[i].callback(state), + }) + } + }, [ + optionsArray.length, + ...optionsArray.map(o => o.reactivateAfter), + ...optionsArray.map(o => o.name), + ...optionsArray.map(o => o.meta), + ]) + + // Subscribe to all active registrations. Re-subscribes when the set of results changes. + const subscribe = useCallback( + (onStoreChange: () => void) => { + if (resultsList.size === 0) { + return NOOP_SUBSCRIBE() + } + + const unsubs: (() => void)[] = [] + for (const [, result] of resultsList) { + unsubs.push(result.subscribe(onStoreChange)) + } + + return () => unsubs.forEach(u => u()) + }, + [resultsList] + ) + + // getSnapshot must return a referentially stable value when nothing changed. + const getSnapshot = useCallback((): ForesightElementState[] => { + const length = optionsRef.current.length + const cached = cachedSnapshotsRef.current + + let changed = cached.length !== length + if (!changed) { + for (let i = 0; i < length; i++) { + const result = resultsList.get(i) + const current = result?.getSnapshot() ?? INITIAL_SNAPSHOT + if (current !== cached[i]) { + changed = true + break + } + } + } + + if (!changed) { + return cached + } + + const next: ForesightElementState[] = [] + for (let i = 0; i < length; i++) { + const result = resultsList.get(i) + next.push(result?.getSnapshot() ?? INITIAL_SNAPSHOT) + } + cachedSnapshotsRef.current = next + + return next + }, [resultsList]) + + const getServerSnapshot = useCallback( + (): ForesightElementState[] => optionsRef.current.map(() => INITIAL_SNAPSHOT), + [] + ) + + const states = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + + return optionsArray.map((_, i) => ({ + elementRef: getElementRef(i), + ...(states[i] ?? INITIAL_SNAPSHOT), + })) +} diff --git a/packages/foresightjs-react/src/index.ts b/packages/foresightjs-react/src/index.ts index 31a761a..fb6d60b 100644 --- a/packages/foresightjs-react/src/index.ts +++ b/packages/foresightjs-react/src/index.ts @@ -1,3 +1,4 @@ export * from "js.foresight" export { useForesight } from "./hooks/useForesight" +export { useForesights } from "./hooks/useForesights" export { useForesightEvent } from "./hooks/useForesightEvent" From 9f9dfd15fd8ec8f440cd30737233c824407fc6bb Mon Sep 17 00:00:00 2001 From: Bart Spaans Date: Sat, 2 May 2026 21:57:23 +0200 Subject: [PATCH 2/2] prettier --- .../src/hooks/useForesights.test.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/foresightjs-react/src/hooks/useForesights.test.tsx b/packages/foresightjs-react/src/hooks/useForesights.test.tsx index 5e74e33..d22ad69 100644 --- a/packages/foresightjs-react/src/hooks/useForesights.test.tsx +++ b/packages/foresightjs-react/src/hooks/useForesights.test.tsx @@ -100,9 +100,7 @@ describe("useForesights", () => { it("forwards the latest callback (no stale closure)", () => { const cb1 = vi.fn() const cb2 = vi.fn() - const { rerender } = render( - - ) + const { rerender } = render() rerender() const fired = { ...createUnregisteredSnapshot(false), isPredicted: true } @@ -115,9 +113,7 @@ describe("useForesights", () => { }) it("handles growing the array (new items added)", () => { - const { rerender } = render( - - ) + const { rerender } = render() expect(registerSpy).toHaveBeenCalledTimes(1) registerSpy.mockClear() @@ -153,9 +149,7 @@ describe("useForesights", () => { }) it("patches options without unregistering", () => { - const { rerender } = render( - - ) + const { rerender } = render() registerSpy.mockClear() unregisterSpy.mockClear()