From 815e35b3ae3622395ddd70b64655f16f82757267 Mon Sep 17 00:00:00 2001 From: Bart Spaans Date: Tue, 19 May 2026 19:15:41 +0200 Subject: [PATCH 1/4] add enabled option to vue/react --- .../test-buttons/ForesightButtonEnabled.tsx | 55 +++++++ .../src/pages/elements/index.tsx | 5 + .../src/views/composable/index.vue | 2 + .../partials/EnabledToggleButton.vue | 42 ++++++ .../src/hooks/useForesight.test.tsx | 54 ++++++- .../src/hooks/useForesight.ts | 16 +- .../src/hooks/useForesights.ts | 15 +- packages/foresightjs-react/src/index.ts | 1 + packages/foresightjs-react/src/types.ts | 16 ++ .../src/composables/useForesight.test.ts | 140 ++++++++++++++++++ .../src/composables/useForesight.ts | 33 +++-- .../src/composables/useForesights.ts | 27 +++- packages/foresightjs-vue/src/index.ts | 3 +- packages/foresightjs-vue/src/types/index.ts | 19 ++- 14 files changed, 387 insertions(+), 41 deletions(-) create mode 100644 packages/devpage-react/src/components/test-buttons/ForesightButtonEnabled.tsx create mode 100644 packages/devpage-vue/src/views/composable/partials/EnabledToggleButton.vue create mode 100644 packages/foresightjs-react/src/types.ts diff --git a/packages/devpage-react/src/components/test-buttons/ForesightButtonEnabled.tsx b/packages/devpage-react/src/components/test-buttons/ForesightButtonEnabled.tsx new file mode 100644 index 0000000..df0f59a --- /dev/null +++ b/packages/devpage-react/src/components/test-buttons/ForesightButtonEnabled.tsx @@ -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({ + callback: async () => { + const randomTimeout = Math.floor(Math.random() * 1000) + await new Promise(resolve => setTimeout(resolve, randomTimeout)) + }, + hitSlop: 20, + name, + reactivateAfter, + enabled, + }) + + return ( +
+

Enabled

+ + + +
+ ) +} + +export default ForesightButtonEnabled diff --git a/packages/devpage-react/src/pages/elements/index.tsx b/packages/devpage-react/src/pages/elements/index.tsx index 49c93b2..255be30 100644 --- a/packages/devpage-react/src/pages/elements/index.tsx +++ b/packages/devpage-react/src/pages/elements/index.tsx @@ -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 = { @@ -74,6 +75,10 @@ export default function Elements() { +
+ +
+
diff --git a/packages/devpage-vue/src/views/composable/index.vue b/packages/devpage-vue/src/views/composable/index.vue index 38dcdc9..6ac0f22 100644 --- a/packages/devpage-vue/src/views/composable/index.vue +++ b/packages/devpage-vue/src/views/composable/index.vue @@ -1,4 +1,5 @@ + + diff --git a/packages/foresightjs-react/src/hooks/useForesight.test.tsx b/packages/foresightjs-react/src/hooks/useForesight.test.tsx index 8748ed8..1257a8e 100644 --- a/packages/foresightjs-react/src/hooks/useForesight.test.tsx +++ b/packages/foresightjs-react/src/hooks/useForesight.test.tsx @@ -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(() => { @@ -18,7 +15,7 @@ beforeEach(() => { }) type ProbeProps = { - options: ForesightRegisterOptionsWithoutElement + options: UseForesightOptions attach?: boolean } @@ -134,4 +131,49 @@ describe("useForesight", () => { const { getByTestId } = render() expect(getByTestId("state").getAttribute("data-registered")).toBe("false") }) + + describe("enabled option", () => { + it("does not register when enabled is false", () => { + render() + expect(registerSpy).not.toHaveBeenCalled() + }) + + it("registers when enabled is true (explicit)", () => { + render() + expect(registerSpy).toHaveBeenCalled() + expect(registerSpy.mock.calls[0][0].name).toBe("x") + }) + + it("registers when enabled is undefined (default)", () => { + render() + expect(registerSpy).toHaveBeenCalled() + }) + + it("returns unregistered snapshot when disabled", () => { + const { getByTestId } = render( + + ) + expect(getByTestId("el").getAttribute("data-registered")).toBe("false") + }) + + it("registers when enabled toggles from false to true", () => { + const { rerender } = render( + + ) + expect(registerSpy).not.toHaveBeenCalled() + + rerender() + expect(registerSpy).toHaveBeenCalledTimes(1) + }) + + it("unregisters when enabled toggles from true to false", () => { + const { rerender } = render( + + ) + expect(registerSpy).toHaveBeenCalledTimes(1) + + rerender() + expect(unregisterSpy).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/packages/foresightjs-react/src/hooks/useForesight.ts b/packages/foresightjs-react/src/hooks/useForesight.ts index 0d7841f..e51991f 100644 --- a/packages/foresightjs-react/src/hooks/useForesight.ts +++ b/packages/foresightjs-react/src/hooks/useForesight.ts @@ -3,24 +3,22 @@ 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 = ForesightElementState & { - elementRef: (node: T | null) => void -} - export const useForesight = ( - options: ForesightRegisterOptionsWithoutElement + options: UseForesightOptions ): UseForesightResult => { const optionsRef = useRef(options) optionsRef.current = options + const enabled = options.enabled !== false + const [element, setElement] = useState(null) const [registerResults, setRegisterResults] = useState(null) @@ -28,9 +26,9 @@ export const useForesight = ( 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 } @@ -45,7 +43,7 @@ export const useForesight = ( result.unregister() setRegisterResults(null) } - }, [element]) + }, [element, enabled]) // Patch options on the existing registration without tearing it down. useEffect(() => { diff --git a/packages/foresightjs-react/src/hooks/useForesights.ts b/packages/foresightjs-react/src/hooks/useForesights.ts index 33e1e05..f2ac425 100644 --- a/packages/foresightjs-react/src/hooks/useForesights.ts +++ b/packages/foresightjs-react/src/hooks/useForesights.ts @@ -3,10 +3,9 @@ import { ForesightManager, createUnregisteredSnapshot, type ForesightElementState, - type ForesightRegisterOptionsWithoutElement, type ForesightRegisterResult, } from "js.foresight" -import type { UseForesightResult } from "./useForesight" +import type { UseForesightOptions, UseForesightResult } from "../types" const INITIAL_SNAPSHOT = createUnregisteredSnapshot(false) const NOOP_SUBSCRIBE = () => () => {} @@ -18,7 +17,7 @@ type SlotEntry = { } export const useForesights = ( - optionsArray: ForesightRegisterOptionsWithoutElement[] + optionsArray: UseForesightOptions[] ): UseForesightResult[] => { const optionsRef = useRef(optionsArray) optionsRef.current = optionsArray @@ -57,15 +56,17 @@ export const useForesights = ( return existing }, []) - // Register/unregister when elements change or the array length changes + // Register/unregister when elements change, array length changes, or enabled toggles + const enabledFlags = optionsArray.map(o => o.enabled !== false) + useEffect(() => { const prevResults = new Map(slotsRef.current) const nextSlots = new Map() - // Register each slot that has an element + // Register each slot that has an element and is enabled for (let i = 0; i < optionsArray.length; i++) { const el = elements.get(i) - if (!el) { + if (!el || optionsRef.current[i].enabled === false) { continue } @@ -101,7 +102,7 @@ export const useForesights = ( } slotsRef.current = new Map() } - }, [optionsArray.length, elements]) + }, [optionsArray.length, elements, ...enabledFlags]) // Patch options on existing registrations without tearing them down useEffect(() => { diff --git a/packages/foresightjs-react/src/index.ts b/packages/foresightjs-react/src/index.ts index 3003d3e..5548dac 100644 --- a/packages/foresightjs-react/src/index.ts +++ b/packages/foresightjs-react/src/index.ts @@ -21,6 +21,7 @@ export { type TouchDeviceStrategy, type MinimumConnectionType, } from "js.foresight" +export { type UseForesightOptions, type UseForesightResult } from "./types" export { useForesight } from "./hooks/useForesight" export { useForesights } from "./hooks/useForesights" export { useForesightEvent } from "./hooks/useForesightEvent" diff --git a/packages/foresightjs-react/src/types.ts b/packages/foresightjs-react/src/types.ts new file mode 100644 index 0000000..4eda1f9 --- /dev/null +++ b/packages/foresightjs-react/src/types.ts @@ -0,0 +1,16 @@ +import type { ForesightElementState, ForesightRegisterOptionsWithoutElement } from "js.foresight" + +export type UseForesightOptions = ForesightRegisterOptionsWithoutElement & { + /** + * Set to `false` to prevent the element from being registered with ForesightManager. + * When disabled, the hook returns the unregistered initial snapshot. + * When toggled back to `true`, the element is registered again. + * + * @default true + */ + enabled?: boolean +} + +export type UseForesightResult = ForesightElementState & { + elementRef: (node: T | null) => void +} diff --git a/packages/foresightjs-vue/src/composables/useForesight.test.ts b/packages/foresightjs-vue/src/composables/useForesight.test.ts index e9c6b5c..8b58e3e 100644 --- a/packages/foresightjs-vue/src/composables/useForesight.test.ts +++ b/packages/foresightjs-vue/src/composables/useForesight.test.ts @@ -277,6 +277,146 @@ describe("useForesight", () => { }) }) + describe("enabled option", () => { + it("does not register when enabled is false", async () => { + const Component = defineComponent({ + setup() { + return useForesight({ + callback: vi.fn(), + name: "disabled-btn", + enabled: false, + }) + }, + render() { + return h("button", { ref: this.setRef, "data-testid": "el" }) + }, + }) + + mount(Component, { attachTo: document.body }) + await nextTick() + + expect(registerSpy).not.toHaveBeenCalled() + }) + + it("registers when enabled is true (explicit)", async () => { + const Component = defineComponent({ + setup() { + return useForesight({ + callback: vi.fn(), + name: "enabled-btn", + enabled: true, + }) + }, + render() { + return h("button", { ref: this.setRef, "data-testid": "el" }) + }, + }) + + mount(Component, { attachTo: document.body }) + await nextTick() + + expect(registerSpy).toHaveBeenCalledTimes(1) + }) + + it("registers when enabled is undefined (default)", async () => { + const Component = defineComponent({ + setup() { + return useForesight({ + callback: vi.fn(), + name: "default-btn", + }) + }, + render() { + return h("button", { ref: this.setRef, "data-testid": "el" }) + }, + }) + + mount(Component, { attachTo: document.body }) + await nextTick() + + expect(registerSpy).toHaveBeenCalledTimes(1) + }) + + it("returns unregistered snapshot when disabled", async () => { + const Component = defineComponent({ + setup() { + return useForesight({ + callback: vi.fn(), + enabled: false, + }) + }, + render() { + return h("button", { + ref: this.setRef, + "data-testid": "el", + "data-registered": this.isRegistered, + }) + }, + }) + + const wrapper = mount(Component, { attachTo: document.body }) + await nextTick() + + expect(wrapper.get("[data-testid=el]").attributes("data-registered")).toBe("false") + }) + + it("registers when enabled toggles from false to true", async () => { + const Component = defineComponent({ + setup() { + const enabled = ref(false) + const result = useForesight(() => ({ + callback: vi.fn(), + name: "toggle-btn", + enabled: enabled.value, + })) + + return { ...result, enabled } + }, + render() { + return h("button", { ref: this.setRef, "data-testid": "el" }) + }, + }) + + const wrapper = mount(Component, { attachTo: document.body }) + await nextTick() + expect(registerSpy).not.toHaveBeenCalled() + + wrapper.vm.enabled = true + await nextTick() + await nextTick() + + expect(registerSpy).toHaveBeenCalledTimes(1) + }) + + it("unregisters when enabled toggles from true to false", async () => { + const Component = defineComponent({ + setup() { + const enabled = ref(true) + const result = useForesight(() => ({ + callback: vi.fn(), + name: "toggle-btn", + enabled: enabled.value, + })) + + return { ...result, enabled } + }, + render() { + return h("button", { ref: this.setRef, "data-testid": "el" }) + }, + }) + + const wrapper = mount(Component, { attachTo: document.body }) + await nextTick() + expect(registerSpy).toHaveBeenCalledTimes(1) + + wrapper.vm.enabled = false + await nextTick() + await nextTick() + + expect(unregisterSpy).toHaveBeenCalledTimes(1) + }) + }) + describe("comment node filtering", () => { it("does not register when setRef receives a comment node component", async () => { const ChildComponent = defineComponent({ diff --git a/packages/foresightjs-vue/src/composables/useForesight.ts b/packages/foresightjs-vue/src/composables/useForesight.ts index c978a7f..f39da47 100644 --- a/packages/foresightjs-vue/src/composables/useForesight.ts +++ b/packages/foresightjs-vue/src/composables/useForesight.ts @@ -7,23 +7,16 @@ import { onScopeDispose, watch, type MaybeRefOrGetter, - type ToRefs, } from "vue" import { ForesightManager, createUnregisteredSnapshot, type ForesightElementState, - type ForesightRegisterOptionsWithoutElement, type ForesightRegisterResult, } from "js.foresight" -import type { MaybeElement } from "../types" +import type { UseForesightOptions, UseForesightReturn } from "../types" import { resolveElement } from "../utils/resolveElement" -export type UseForesightReturn = ToRefs> & { - /** Template ref function - bind to an element with `:ref="setRef"`. */ - setRef: (el: MaybeElement) => void -} - /** * Registers a single element with ForesightManager. * @@ -44,7 +37,7 @@ export type UseForesightReturn = ToRefs> & { * ``` */ export const useForesight = ( - options: MaybeRefOrGetter + options: MaybeRefOrGetter ): UseForesightReturn => { const state = reactive(createUnregisteredSnapshot(false)) @@ -90,12 +83,12 @@ export const useForesight = ( currentElement = resolved - if (resolved) { + if (resolved && toValue(options).enabled !== false) { registerElement(resolved) } } - // Watch options for changes - patch without re-registering. + // Watch options for changes - handle enabled toggle and patch without re-registering. // Skip when the raw reference hasn't changed (e.g. getter returning same object). watch( () => toValue(options), @@ -104,6 +97,24 @@ export const useForesight = ( return } + const wasEnabled = oldOptions ? oldOptions.enabled !== false : true + const isEnabled = newOptions.enabled !== false + + // enabled toggled off → unregister + if (wasEnabled && !isEnabled && registerResults) { + unregisterElement() + + return + } + + // enabled toggled on → register if element is available + if (!wasEnabled && isEnabled && currentElement && !registerResults) { + registerElement(currentElement) + + return + } + + // Still enabled, patch options if (!currentElement || !registerResults) { return } diff --git a/packages/foresightjs-vue/src/composables/useForesights.ts b/packages/foresightjs-vue/src/composables/useForesights.ts index 70ed94b..36b10f6 100644 --- a/packages/foresightjs-vue/src/composables/useForesights.ts +++ b/packages/foresightjs-vue/src/composables/useForesights.ts @@ -13,11 +13,10 @@ import { ForesightManager, createUnregisteredSnapshot, type ForesightElementState, - type ForesightRegisterOptionsWithoutElement, type ForesightRegisterResult, } from "js.foresight" import { resolveElement } from "../utils/resolveElement" -import type { MaybeElement } from "../types" +import type { MaybeElement, UseForesightOptions } from "../types" export type UseForesightSlot = Readonly & { /** Template ref function - bind to an element with `:ref="slot.setRef"`. */ @@ -59,7 +58,7 @@ type Slot = { * ``` */ export const useForesights = ( - options: MaybeRefOrGetter + options: MaybeRefOrGetter ): UseForesightSlot[] => { const resolvedOptions = computed(() => toValue(options)) const managed: Slot[] = [] @@ -67,7 +66,7 @@ export const useForesights = ( const register = (slot: Slot, index: number) => { const slotOptions = resolvedOptions.value[index] - if (!slotOptions) { + if (!slotOptions || slotOptions.enabled === false) { return } @@ -137,11 +136,27 @@ export const useForesights = ( // Update existing slots whose options changed for (let i = 0; i < Math.min(managed.length, newOptions.length); i++) { const slot = managed[i] - if (!slot.element || !slot.result) { + + if (oldOptions && toRaw(newOptions[i]) === toRaw(oldOptions[i])) { continue } - if (oldOptions && toRaw(newOptions[i]) === toRaw(oldOptions[i])) { + const wasEnabled = oldOptions ? oldOptions[i]?.enabled !== false : true + const isEnabled = newOptions[i].enabled !== false + + // enabled toggled off → unregister + if (wasEnabled && !isEnabled && slot.result) { + unregister(slot) + continue + } + + // enabled toggled on → register if element is available + if (!wasEnabled && isEnabled && slot.element && !slot.result) { + register(slot, i) + continue + } + + if (!slot.element || !slot.result) { continue } diff --git a/packages/foresightjs-vue/src/index.ts b/packages/foresightjs-vue/src/index.ts index 2e0a6af..adcd609 100644 --- a/packages/foresightjs-vue/src/index.ts +++ b/packages/foresightjs-vue/src/index.ts @@ -21,7 +21,8 @@ export { type TouchDeviceStrategy, type MinimumConnectionType, } from "js.foresight" -export { useForesight, type UseForesightReturn } from "./composables/useForesight" +export { type UseForesightOptions, type UseForesightReturn } from "./types" +export { useForesight } from "./composables/useForesight" export { useForesights, type UseForesightSlot } from "./composables/useForesights" export { useForesightEvent } from "./composables/useForesightEvent" export { vForesight } from "./directives/vForesight" diff --git a/packages/foresightjs-vue/src/types/index.ts b/packages/foresightjs-vue/src/types/index.ts index 86a114b..c9b942d 100644 --- a/packages/foresightjs-vue/src/types/index.ts +++ b/packages/foresightjs-vue/src/types/index.ts @@ -1,4 +1,5 @@ -import type { ComponentPublicInstance, MaybeRefOrGetter } from "vue" +import type { ComponentPublicInstance, MaybeRefOrGetter, ToRefs } from "vue" +import type { ForesightElementState, ForesightRegisterOptionsWithoutElement } from "js.foresight" export type MaybeElement = Element | ComponentPublicInstance | null | undefined @@ -7,3 +8,19 @@ export type MaybeElementRef = MaybeRefOrGetter export type ResolvedElement = T extends ComponentPublicInstance ? Exclude : T | undefined + +export type UseForesightOptions = ForesightRegisterOptionsWithoutElement & { + /** + * Set to `false` to prevent the element from being registered with ForesightManager. + * When disabled, the composable returns the unregistered initial snapshot. + * When toggled back to `true`, the element is registered again. + * + * @default true + */ + enabled?: boolean +} + +export type UseForesightReturn = ToRefs> & { + /** Template ref function - bind to an element with `:ref="setRef"`. */ + setRef: (el: MaybeElement) => void +} From 32b6bd74c1726194e761e42b1ae34d693077af29 Mon Sep 17 00:00:00 2001 From: Bart Spaans Date: Tue, 19 May 2026 19:19:56 +0200 Subject: [PATCH 2/4] fix devtools --- .../src/lit-entry/control-panel/element-tab/element-tab.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/js.foresight-devtools/src/lit-entry/control-panel/element-tab/element-tab.ts b/packages/js.foresight-devtools/src/lit-entry/control-panel/element-tab/element-tab.ts index 2fafa45..869b4f9 100644 --- a/packages/js.foresight-devtools/src/lit-entry/control-panel/element-tab/element-tab.ts +++ b/packages/js.foresight-devtools/src/lit-entry/control-panel/element-tab/element-tab.ts @@ -218,7 +218,7 @@ export class ElementTab extends LitElement { const unsubscribe = ForesightManager.instance.subscribeToElement(element, () => { const state = ForesightManager.instance.registeredElements.get(element) - if (state) { + if (state && state.isRegistered) { this._pendingElementUpdates.set(element, state) this._scheduleDebouncedUpdate() } From 45c2808d8fa464dcff2e280eb80e511e974d5a80 Mon Sep 17 00:00:00 2001 From: Bart Spaans Date: Tue, 19 May 2026 19:25:37 +0200 Subject: [PATCH 3/4] tests --- .../src/hooks/useForesights.test.tsx | 95 +++++++++- .../src/composables/useForesights.test.ts | 163 ++++++++++++++++++ 2 files changed, 253 insertions(+), 5 deletions(-) diff --git a/packages/foresightjs-react/src/hooks/useForesights.test.tsx b/packages/foresightjs-react/src/hooks/useForesights.test.tsx index a119be6..d875bc1 100644 --- a/packages/foresightjs-react/src/hooks/useForesights.test.tsx +++ b/packages/foresightjs-react/src/hooks/useForesights.test.tsx @@ -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(() => { @@ -17,7 +15,7 @@ beforeEach(() => { }) type ProbeProps = { - optionsArray: ForesightRegisterOptionsWithoutElement[] + optionsArray: UseForesightOptions[] } const MultiProbe = ({ optionsArray }: ProbeProps) => { @@ -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( + + ) + expect(registerSpy).not.toHaveBeenCalled() + }) + + it("only registers enabled slots", () => { + render( + + ) + 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( + + ) + expect(getByTestId("el-0").getAttribute("data-registered")).toBe("false") + }) + + it("registers when enabled toggles from false to true", () => { + const { rerender } = render( + + ) + expect(registerSpy).not.toHaveBeenCalled() + + rerender() + 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( + + ) + expect(registerSpy).toHaveBeenCalledTimes(1) + + rerender() + expect(unregisterSpy).toHaveBeenCalledTimes(1) + }) + + it("toggles individual slots independently", () => { + const { rerender } = render( + + ) + expect(registerSpy).toHaveBeenCalledTimes(2) + registerSpy.mockClear() + + // Disable only slot "b" + rerender( + + ) + + // "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") + }) + }) }) diff --git a/packages/foresightjs-vue/src/composables/useForesights.test.ts b/packages/foresightjs-vue/src/composables/useForesights.test.ts index 2155fda..0a7b615 100644 --- a/packages/foresightjs-vue/src/composables/useForesights.test.ts +++ b/packages/foresightjs-vue/src/composables/useForesights.test.ts @@ -298,6 +298,169 @@ describe("useForesights", () => { expect(registerSpy.mock.calls[2][0].name).toBe("z") }) + describe("enabled option", () => { + it("does not register when enabled is false", async () => { + const Component = defineComponent({ + setup() { + const slots = useForesights([ + { name: "a", callback: vi.fn(), enabled: false }, + { name: "b", callback: vi.fn(), enabled: false }, + ]) + + return { slots } + }, + render() { + return h("div", [ + h("button", { ref: this.slots[0].setRef, "data-testid": "el-0" }), + h("button", { ref: this.slots[1].setRef, "data-testid": "el-1" }), + ]) + }, + }) + + mount(Component, { attachTo: document.body }) + await nextTick() + + expect(registerSpy).not.toHaveBeenCalled() + }) + + it("only registers enabled slots", async () => { + const Component = defineComponent({ + setup() { + const slots = useForesights([ + { name: "a", callback: vi.fn(), enabled: true }, + { name: "b", callback: vi.fn(), enabled: false }, + { name: "c", callback: vi.fn() }, + ]) + + return { slots } + }, + render() { + return h("div", [ + h("button", { ref: this.slots[0].setRef, "data-testid": "el-0" }), + h("button", { ref: this.slots[1].setRef, "data-testid": "el-1" }), + h("button", { ref: this.slots[2].setRef, "data-testid": "el-2" }), + ]) + }, + }) + + mount(Component, { attachTo: document.body }) + await nextTick() + + 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", async () => { + const Component = defineComponent({ + setup() { + const slots = useForesights([{ name: "a", callback: vi.fn(), enabled: false }]) + + return { slots } + }, + render() { + return h("button", { + ref: this.slots[0].setRef, + "data-testid": "el", + "data-registered": this.slots[0]?.isRegistered, + }) + }, + }) + + const wrapper = mount(Component, { attachTo: document.body }) + await nextTick() + + expect(wrapper.get("[data-testid=el]").attributes("data-registered")).toBe("false") + }) + + it("registers when enabled toggles from false to true", async () => { + const Component = defineComponent({ + setup() { + const enabled = ref(false) + const slots = useForesights(() => [ + { name: "a", callback: vi.fn(), enabled: enabled.value }, + ]) + + return { slots, enabled } + }, + render() { + return h("button", { ref: this.slots[0].setRef, "data-testid": "el-0" }) + }, + }) + + const wrapper = mount(Component, { attachTo: document.body }) + await nextTick() + expect(registerSpy).not.toHaveBeenCalled() + + wrapper.vm.enabled = true + await nextTick() + await nextTick() + + expect(registerSpy).toHaveBeenCalledTimes(1) + expect(registerSpy.mock.calls[0][0].name).toBe("a") + }) + + it("unregisters when enabled toggles from true to false", async () => { + const Component = defineComponent({ + setup() { + const enabled = ref(true) + const slots = useForesights(() => [ + { name: "a", callback: vi.fn(), enabled: enabled.value }, + ]) + + return { slots, enabled } + }, + render() { + return h("button", { ref: this.slots[0].setRef, "data-testid": "el-0" }) + }, + }) + + const wrapper = mount(Component, { attachTo: document.body }) + await nextTick() + expect(registerSpy).toHaveBeenCalledTimes(1) + + wrapper.vm.enabled = false + await nextTick() + await nextTick() + + expect(unregisterSpy).toHaveBeenCalledTimes(1) + }) + + it("toggles individual slots independently", async () => { + const Component = defineComponent({ + setup() { + const enabledB = ref(true) + const slots = useForesights(() => [ + { name: "a", callback: vi.fn(), enabled: true }, + { name: "b", callback: vi.fn(), enabled: enabledB.value }, + ]) + + return { slots, enabledB } + }, + render() { + return h("div", [ + h("button", { ref: this.slots[0].setRef, "data-testid": "el-0" }), + h("button", { ref: this.slots[1].setRef, "data-testid": "el-1" }), + ]) + }, + }) + + const wrapper = mount(Component, { attachTo: document.body }) + await nextTick() + expect(registerSpy).toHaveBeenCalledTimes(2) + unregisterSpy.mockClear() + + // Disable only slot "b" + wrapper.vm.enabledB = false + await nextTick() + await nextTick() + + expect(unregisterSpy).toHaveBeenCalledTimes(1) + }) + }) + describe("comment node filtering", () => { it("does not register when setRef receives a comment node component", async () => { const ChildComponent = defineComponent({ From 01cd4f56556238c2c2dddc96c4631b960e68abcb Mon Sep 17 00:00:00 2001 From: Bart Spaans Date: Sat, 23 May 2026 23:29:23 +0200 Subject: [PATCH 4/4] fix --- .../foresightjs-react/src/hooks/useForesights.ts | 15 +++++++-------- .../src/composables/useForesight.ts | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/foresightjs-react/src/hooks/useForesights.ts b/packages/foresightjs-react/src/hooks/useForesights.ts index f2ac425..7e21d49 100644 --- a/packages/foresightjs-react/src/hooks/useForesights.ts +++ b/packages/foresightjs-react/src/hooks/useForesights.ts @@ -57,7 +57,7 @@ export const useForesights = ( }, []) // Register/unregister when elements change, array length changes, or enabled toggles - const enabledFlags = optionsArray.map(o => o.enabled !== false) + const enabledKey = optionsArray.map(o => o.enabled !== false).join() useEffect(() => { const prevResults = new Map(slotsRef.current) @@ -102,9 +102,13 @@ export const useForesights = ( } slotsRef.current = new Map() } - }, [optionsArray.length, elements, ...enabledFlags]) + }, [optionsArray.length, elements, enabledKey]) // Patch options on existing registrations without tearing them down + const patchKey = optionsArray + .map(o => `${o.reactivateAfter ?? ""},${o.name ?? ""},${o.meta ?? ""}`) + .join("|") + useEffect(() => { for (let i = 0; i < optionsArray.length; i++) { const slot = slotsRef.current.get(i) @@ -117,12 +121,7 @@ export const useForesights = ( 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), - ]) + }, [optionsArray.length, patchKey]) // Subscribe to all active registrations. Re-subscribes when the set of results changes. const subscribe = useCallback( diff --git a/packages/foresightjs-vue/src/composables/useForesight.ts b/packages/foresightjs-vue/src/composables/useForesight.ts index f39da47..37f7506 100644 --- a/packages/foresightjs-vue/src/composables/useForesight.ts +++ b/packages/foresightjs-vue/src/composables/useForesight.ts @@ -14,7 +14,7 @@ import { type ForesightElementState, type ForesightRegisterResult, } from "js.foresight" -import type { UseForesightOptions, UseForesightReturn } from "../types" +import type { MaybeElement, UseForesightOptions, UseForesightReturn } from "../types" import { resolveElement } from "../utils/resolveElement" /**