Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useState } from "react"
import { useForesight } from "@foresightjs/react"
import ButtonStats from "../ui/ButtonStats"
import { useReactivateAfter } from "../../stores/ButtonStateStore"

type ForesightButtonEnabledProps = {
name: string
}

const ForesightButtonEnabled = ({ name }: ForesightButtonEnabledProps) => {
const [enabled, setEnabled] = useState(true)
const reactivateAfter = useReactivateAfter()

const { elementRef, isPredicted, hitCount, isCallbackRunning, status } =
useForesight<HTMLButtonElement>({
callback: async () => {
const randomTimeout = Math.floor(Math.random() * 1000)
await new Promise(resolve => setTimeout(resolve, randomTimeout))
},
hitSlop: 20,
name,
reactivateAfter,
enabled,
})

return (
<article className="flex flex-col items-center gap-3 w-40">
<h4 className="text-sm font-medium text-gray-900 self-start">Enabled</h4>
<button
ref={elementRef}
id={name}
data-predicted={isPredicted}
className={`flex items-center justify-center size-40 text-sm font-medium ${
enabled ? "bg-teal-200 text-slate-900" : "bg-gray-300 text-gray-500"
} ${isPredicted ? "outline outline-1 outline-amber-500" : ""}`}
>
<span className="text-center leading-tight">{enabled ? name : "disabled"}</span>
</button>
<ButtonStats
hitCount={hitCount}
isPredicted={isPredicted}
isCallbackRunning={isCallbackRunning}
status={status}
/>
<button
onClick={() => setEnabled(e => !e)}
className="px-2 py-1 text-xs border border-gray-400 text-gray-800 hover:bg-gray-100"
>
enabled: {enabled ? "on" : "off"}
</button>
</article>
)
}

export default ForesightButtonEnabled
5 changes: 5 additions & 0 deletions packages/devpage-react/src/pages/elements/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
useIsVisible,
useResetKey,
} from "../../stores/ButtonStateStore"
import ForesightButtonEnabled from "../../components/test-buttons/ForesightButtonEnabled"
import ForesightButtonError from "../../components/test-buttons/ForesightButtonError"

type SectionProps = {
Expand Down Expand Up @@ -74,6 +75,10 @@ export default function Elements() {
<ForesightButtonVisibility name="visibility" />
</Section>

<Section title="Enabled toggle">
<ForesightButtonEnabled name="enabled" />
</Section>

<Section title="Edge cases">
<div className="flex flex-wrap gap-x-6 gap-y-8">
<ForesightButtonError name="callback error" />
Expand Down
2 changes: 2 additions & 0 deletions packages/devpage-vue/src/views/composable/index.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import EnabledToggleButton from "./partials/EnabledToggleButton.vue"
import FixedOptionsButton from "./partials/FixedOptionsButton.vue"
import SwappableOptionsButton from "./partials/SwappableOptionsButton.vue"
import GetterOptionsButton from "./partials/GetterOptionsButton.vue"
Expand All @@ -16,6 +17,7 @@ import GetterOptionsButton from "./partials/GetterOptionsButton.vue"
<FixedOptionsButton />
<SwappableOptionsButton />
<GetterOptionsButton />
<EnabledToggleButton />
</section>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { ref } from "vue"
import { useForesight } from "@foresightjs/vue"
import ForesightStats from "../../../components/ForesightStats.vue"

const enabled = ref(true)

const { isPredicted, hitCount, isCallbackRunning, status, setRef } = useForesight(() => ({
callback: () => console.log("Enabled-toggle prefetch"),
name: "enabled-toggle",
enabled: enabled.value,
}))
</script>

<template>
<article class="flex flex-col items-start gap-3 w-56">
<h4 class="text-sm font-medium">Enabled toggle</h4>
<p class="text-xs text-gray-500">Toggle <code>enabled</code> to control registration.</p>
<button
type="button"
:ref="setRef"
:class="[
'flex items-center justify-center size-40 text-sm font-medium',
enabled
? isPredicted
? 'bg-amber-500 text-white'
: 'bg-teal-400 hover:bg-teal-500 text-white'
: 'bg-gray-300 text-gray-500',
]"
>
{{ enabled ? "Hover to predict" : "Disabled" }}
</button>
<ForesightStats :is-predicted :hit-count :is-callback-running :status />
<button
type="button"
class="px-2 py-1 text-xs border border-gray-400 text-gray-800 hover:bg-gray-100"
@click="enabled = !enabled"
>
enabled: {{ enabled ? "on" : "off" }}
</button>
</article>
</template>
54 changes: 48 additions & 6 deletions packages/foresightjs-react/src/hooks/useForesight.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { act, render } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import {
createUnregisteredSnapshot,
type ForesightCallback,
type ForesightRegisterOptionsWithoutElement,
} from "js.foresight"
import { createUnregisteredSnapshot, type ForesightCallback } from "js.foresight"
import { mockState, registerSpy, updateElementOptionsSpy, unregisterSpy } from "../tests/setup"
import type { UseForesightOptions } from "../types"
import { useForesight } from "./useForesight"

beforeEach(() => {
Expand All @@ -18,7 +15,7 @@ beforeEach(() => {
})

type ProbeProps = {
options: ForesightRegisterOptionsWithoutElement
options: UseForesightOptions
attach?: boolean
}

Expand Down Expand Up @@ -134,4 +131,49 @@ describe("useForesight", () => {
const { getByTestId } = render(<Capture />)
expect(getByTestId("state").getAttribute("data-registered")).toBe("false")
})

describe("enabled option", () => {
it("does not register when enabled is false", () => {
render(<ButtonProbe options={{ callback: vi.fn(), enabled: false }} />)
expect(registerSpy).not.toHaveBeenCalled()
})

it("registers when enabled is true (explicit)", () => {
render(<ButtonProbe options={{ callback: vi.fn(), name: "x", enabled: true }} />)
expect(registerSpy).toHaveBeenCalled()
expect(registerSpy.mock.calls[0][0].name).toBe("x")
})

it("registers when enabled is undefined (default)", () => {
render(<ButtonProbe options={{ callback: vi.fn(), name: "x" }} />)
expect(registerSpy).toHaveBeenCalled()
})

it("returns unregistered snapshot when disabled", () => {
const { getByTestId } = render(
<ButtonProbe options={{ callback: vi.fn(), enabled: false }} />
)
expect(getByTestId("el").getAttribute("data-registered")).toBe("false")
})

it("registers when enabled toggles from false to true", () => {
const { rerender } = render(
<ButtonProbe options={{ callback: vi.fn(), name: "x", enabled: false }} />
)
expect(registerSpy).not.toHaveBeenCalled()

rerender(<ButtonProbe options={{ callback: vi.fn(), name: "x", enabled: true }} />)
expect(registerSpy).toHaveBeenCalledTimes(1)
})

it("unregisters when enabled toggles from true to false", () => {
const { rerender } = render(
<ButtonProbe options={{ callback: vi.fn(), name: "x", enabled: true }} />
)
expect(registerSpy).toHaveBeenCalledTimes(1)

rerender(<ButtonProbe options={{ callback: vi.fn(), name: "x", enabled: false }} />)
expect(unregisterSpy).toHaveBeenCalledTimes(1)
})
})
})
16 changes: 7 additions & 9 deletions packages/foresightjs-react/src/hooks/useForesight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,32 @@ import {
ForesightManager,
createUnregisteredSnapshot,
type ForesightElementState,
type ForesightRegisterOptionsWithoutElement,
type ForesightRegisterResult,
} from "js.foresight"
import type { UseForesightOptions, UseForesightResult } from "../types"

const NOOP_SUBSCRIBE = () => () => {}
const INITIAL_SNAPSHOT = createUnregisteredSnapshot(false)
const GET_INITIAL_SNAPSHOT = () => INITIAL_SNAPSHOT

export type UseForesightResult<T extends HTMLElement> = ForesightElementState & {
elementRef: (node: T | null) => void
}

export const useForesight = <T extends HTMLElement = HTMLElement>(
options: ForesightRegisterOptionsWithoutElement
options: UseForesightOptions
): UseForesightResult<T> => {
const optionsRef = useRef(options)
optionsRef.current = options

const enabled = options.enabled !== false

const [element, setElement] = useState<T | null>(null)
const [registerResults, setRegisterResults] = useState<ForesightRegisterResult | null>(null)

const elementRef = useCallback((node: T | null) => {
setElement(node)
}, [])

// Register/unregister when the DOM node attaches or swaps.
// Register/unregister when the DOM node attaches or swaps, or when enabled changes.
useEffect(() => {
if (!element) {
if (!element || !enabled) {
return
}

Expand All @@ -45,7 +43,7 @@ export const useForesight = <T extends HTMLElement = HTMLElement>(
result.unregister()
setRegisterResults(null)
}
}, [element])
}, [element, enabled])

// Patch options on the existing registration without tearing it down.
useEffect(() => {
Expand Down
95 changes: 90 additions & 5 deletions packages/foresightjs-react/src/hooks/useForesights.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { act, render } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import {
createUnregisteredSnapshot,
type ForesightRegisterOptionsWithoutElement,
} from "js.foresight"
import { createUnregisteredSnapshot } from "js.foresight"
import { mockState, registerSpy, updateElementOptionsSpy, unregisterSpy } from "../tests/setup"
import type { UseForesightOptions } from "../types"
import { useForesights } from "./useForesights"

beforeEach(() => {
Expand All @@ -17,7 +15,7 @@ beforeEach(() => {
})

type ProbeProps = {
optionsArray: ForesightRegisterOptionsWithoutElement[]
optionsArray: UseForesightOptions[]
}

const MultiProbe = ({ optionsArray }: ProbeProps) => {
Expand Down Expand Up @@ -184,4 +182,91 @@ describe("useForesights", () => {
expect(registerSpy.mock.calls[1][0].name).toBe("y")
expect(registerSpy.mock.calls[2][0].name).toBe("z")
})

describe("enabled option", () => {
it("does not register when enabled is false", () => {
render(
<MultiProbe
optionsArray={[
{ name: "a", callback: vi.fn(), enabled: false },
{ name: "b", callback: vi.fn(), enabled: false },
]}
/>
)
expect(registerSpy).not.toHaveBeenCalled()
})

it("only registers enabled slots", () => {
render(
<MultiProbe
optionsArray={[
{ name: "a", callback: vi.fn(), enabled: true },
{ name: "b", callback: vi.fn(), enabled: false },
{ name: "c", callback: vi.fn() },
]}
/>
)
expect(registerSpy).toHaveBeenCalledTimes(2)
const names = registerSpy.mock.calls.map(c => c[0].name)
expect(names).toContain("a")
expect(names).toContain("c")
expect(names).not.toContain("b")
})

it("returns unregistered snapshot for disabled slots", () => {
const { getByTestId } = render(
<MultiProbe optionsArray={[{ name: "a", callback: vi.fn(), enabled: false }]} />
)
expect(getByTestId("el-0").getAttribute("data-registered")).toBe("false")
})

it("registers when enabled toggles from false to true", () => {
const { rerender } = render(
<MultiProbe optionsArray={[{ name: "a", callback: vi.fn(), enabled: false }]} />
)
expect(registerSpy).not.toHaveBeenCalled()

rerender(<MultiProbe optionsArray={[{ name: "a", callback: vi.fn(), enabled: true }]} />)
expect(registerSpy).toHaveBeenCalledTimes(1)
expect(registerSpy.mock.calls[0][0].name).toBe("a")
})

it("unregisters when enabled toggles from true to false", () => {
const { rerender } = render(
<MultiProbe optionsArray={[{ name: "a", callback: vi.fn(), enabled: true }]} />
)
expect(registerSpy).toHaveBeenCalledTimes(1)

rerender(<MultiProbe optionsArray={[{ name: "a", callback: vi.fn(), enabled: false }]} />)
expect(unregisterSpy).toHaveBeenCalledTimes(1)
})

it("toggles individual slots independently", () => {
const { rerender } = render(
<MultiProbe
optionsArray={[
{ name: "a", callback: vi.fn(), enabled: true },
{ name: "b", callback: vi.fn(), enabled: true },
]}
/>
)
expect(registerSpy).toHaveBeenCalledTimes(2)
registerSpy.mockClear()

// Disable only slot "b"
rerender(
<MultiProbe
optionsArray={[
{ name: "a", callback: vi.fn(), enabled: true },
{ name: "b", callback: vi.fn(), enabled: false },
]}
/>
)

// "a" is re-registered, "b" is not
const registeredNames = registerSpy.mock.calls.map(c => c[0].name)
expect(registeredNames).toContain("a")
expect(registeredNames).not.toContain("b")
})
})
})
Loading
Loading