From c754fdd3674a3266f33b3874b85ac4ab7d19d8ca Mon Sep 17 00:00:00 2001 From: Bart Spaans Date: Tue, 19 May 2026 18:43:47 +0200 Subject: [PATCH 1/2] update events --- .../ForesightButtonVisibility.tsx | 2 +- .../src/components/ui/ButtonStats.tsx | 2 +- .../devpage-react/src/pages/events/index.tsx | 12 --- .../src/pages/home/ForesightImageButton.tsx | 2 +- .../devpage-react/src/pages/mass/index.tsx | 2 +- .../src/components/ForesightStats.vue | 2 +- .../devpage-vue/src/views/events/index.vue | 11 --- .../src/views/foresights/index.vue | 2 +- packages/docs/docs/debugging/devtools.md | 2 - .../docs/docs/debugging/static-properties.md | 11 +-- packages/docs/docs/events.md | 47 ---------- .../getting-started/what-is-foresightjs.md | 2 +- .../Overview/Keyboard/SmallButton.tsx | 35 +++---- .../version-3.4/debugging/devtools.md | 2 - .../debugging/static-properties.md | 3 - .../docs/versioned_docs/version-3.4/events.md | 30 ------ .../getting-started/what-is-foresightjs.md | 2 +- packages/foresightjs-react/src/index.ts | 3 - .../src/composables/useForesight.ts | 4 +- .../src/composables/useForesightEvent.test.ts | 2 +- .../src/composables/useForesightEvent.ts | 2 +- .../src/composables/useForesights.ts | 4 +- packages/foresightjs-vue/src/index.ts | 3 - .../src/utils/resolveElement.ts | 2 +- packages/js.foresight-devtools/README.md | 2 - .../src/helpers/safeSerializeEventData.ts | 68 -------------- .../control-panel/element-tab/element-tab.ts | 94 ++++++++----------- .../control-panel/log-tab/log-tab.ts | 19 +--- .../control-panel/log-tab/single-log.ts | 6 -- .../debug-overlay/element-overlays.ts | 84 +++++++++-------- .../src/lit-entry/foresight-devtools.ts | 4 - .../src/helpers/createInitialState.ts | 2 +- packages/js.foresight/src/index.ts | 4 - .../src/managers/DesktopHandler.ts | 19 +--- .../src/managers/ForesightManager.test.ts | 47 ++++------ .../src/managers/ForesightManager.ts | 51 +++++----- packages/js.foresight/src/types/types.ts | 27 ------ 37 files changed, 167 insertions(+), 449 deletions(-) diff --git a/packages/devpage-react/src/components/test-buttons/ForesightButtonVisibility.tsx b/packages/devpage-react/src/components/test-buttons/ForesightButtonVisibility.tsx index 2fe30cf0..352d9dd2 100644 --- a/packages/devpage-react/src/components/test-buttons/ForesightButtonVisibility.tsx +++ b/packages/devpage-react/src/components/test-buttons/ForesightButtonVisibility.tsx @@ -24,7 +24,7 @@ const ForesightButtonVisibility = ({ name }: ForesightButtonVisibilityProps) => }} />

- Toggles via CSS only — MutationObserver should not unregister. + Toggles via CSS only - MutationObserver should not unregister.

) diff --git a/packages/devpage-react/src/components/ui/ButtonStats.tsx b/packages/devpage-react/src/components/ui/ButtonStats.tsx index 9cca7622..a4c5b194 100644 --- a/packages/devpage-react/src/components/ui/ButtonStats.tsx +++ b/packages/devpage-react/src/components/ui/ButtonStats.tsx @@ -20,7 +20,7 @@ const ButtonStats = ({ hitCount, isPredicted, isCallbackRunning, status }: Butto {row("hits", hitCount)} {row("predicted", isPredicted ? "yes" : "no")} {row("cb running", isCallbackRunning ? "yes" : "no")} - {row("status", status ?? "—")} + {row("status", status ?? "-")} ) } diff --git a/packages/devpage-react/src/pages/events/index.tsx b/packages/devpage-react/src/pages/events/index.tsx index 22dfd428..fa3eb9cd 100644 --- a/packages/devpage-react/src/pages/events/index.tsx +++ b/packages/devpage-react/src/pages/events/index.tsx @@ -18,7 +18,6 @@ const MAX_LOG_ENTRIES = 200 const ALL_EVENTS: ForesightEvent[] = [ "elementRegistered", - "elementReactivated", "elementUnregistered", "callbackInvoked", "callbackCompleted", @@ -39,9 +38,6 @@ const summarizeEvent = (event: ForesightEventMap[ForesightEvent]): string => { case "elementRegistered": return `"${formatElementName(event.state)}" registered` - case "elementReactivated": - return `"${formatElementName(event.state)}" reactivated` - case "elementUnregistered": return `"${formatElementName(event.state)}" unregistered (${event.unregisterReason})` @@ -64,9 +60,7 @@ const summarizeEvent = (event: ForesightEventMap[ForesightEvent]): string => { const EVENT_COLORS: Partial> = { elementRegistered: "text-green-700", - elementReactivated: "text-blue-700", elementUnregistered: "text-red-700", - elementDataUpdated: "text-gray-600", callbackInvoked: "text-amber-700", callbackCompleted: "text-purple-700", managerSettingsChanged: "text-cyan-700", @@ -216,15 +210,9 @@ export default function Events() { useForesightEvent("elementRegistered", e => { pushEntry(e.type, summarizeEvent(e), e.timestamp) }) - useForesightEvent("elementReactivated", e => { - pushEntry(e.type, summarizeEvent(e), e.timestamp) - }) useForesightEvent("elementUnregistered", e => { pushEntry(e.type, summarizeEvent(e), e.timestamp) }) - useForesightEvent("elementDataUpdated", e => { - pushEntry(e.type, summarizeEvent(e), Date.now()) - }) useForesightEvent("callbackInvoked", e => { pushEntry(e.type, summarizeEvent(e), e.timestamp) }) diff --git a/packages/devpage-react/src/pages/home/ForesightImageButton.tsx b/packages/devpage-react/src/pages/home/ForesightImageButton.tsx index 1eceaff6..a2da1b5e 100644 --- a/packages/devpage-react/src/pages/home/ForesightImageButton.tsx +++ b/packages/devpage-react/src/pages/home/ForesightImageButton.tsx @@ -83,7 +83,7 @@ export const ForesightImageButton = ({ image, setSelectedImage }: ForesightImage - + diff --git a/packages/devpage-react/src/pages/mass/index.tsx b/packages/devpage-react/src/pages/mass/index.tsx index 92f5e9f6..10605d09 100644 --- a/packages/devpage-react/src/pages/mass/index.tsx +++ b/packages/devpage-react/src/pages/mass/index.tsx @@ -75,7 +75,7 @@ const Mass = () => { {isDebugActive && (
- Debug mode is on with {buttonCount.toLocaleString()} elements — the overlay can tank frame + Debug mode is on with {buttonCount.toLocaleString()} elements - the overlay can tank frame rates.
)} diff --git a/packages/devpage-vue/src/components/ForesightStats.vue b/packages/devpage-vue/src/components/ForesightStats.vue index 5a3622a3..86d3e438 100644 --- a/packages/devpage-vue/src/components/ForesightStats.vue +++ b/packages/devpage-vue/src/components/ForesightStats.vue @@ -23,7 +23,7 @@ defineProps<{
status - {{ status ?? "—" }} + {{ status ?? "-" }}
diff --git a/packages/devpage-vue/src/views/events/index.vue b/packages/devpage-vue/src/views/events/index.vue index e377f038..b91698ff 100644 --- a/packages/devpage-vue/src/views/events/index.vue +++ b/packages/devpage-vue/src/views/events/index.vue @@ -22,7 +22,6 @@ const MAX_LOG_ENTRIES = 200 const ALL_EVENTS: ForesightEvent[] = [ "elementRegistered", - "elementReactivated", "elementUnregistered", "callbackInvoked", "callbackCompleted", @@ -32,9 +31,7 @@ const ALL_EVENTS: ForesightEvent[] = [ const EVENT_COLORS: Partial> = { elementRegistered: "text-green-700", - elementReactivated: "text-blue-700", elementUnregistered: "text-red-700", - elementDataUpdated: "text-gray-600", callbackInvoked: "text-amber-700", callbackCompleted: "text-purple-700", managerSettingsChanged: "text-cyan-700", @@ -53,8 +50,6 @@ const summarizeEvent = (event: ForesightEventMap[ForesightEvent]): string => { switch (event.type) { case "elementRegistered": return `"${formatElementName(event.state)}" registered` - case "elementReactivated": - return `"${formatElementName(event.state)}" reactivated` case "elementUnregistered": return `"${formatElementName(event.state)}" unregistered (${event.unregisterReason})` case "callbackInvoked": @@ -87,15 +82,9 @@ const pushEntry = (type: ForesightEvent, summary: string, timestamp: number) => useForesightEvent("elementRegistered", e => { pushEntry(e.type, summarizeEvent(e), e.timestamp) }) -useForesightEvent("elementReactivated", e => { - pushEntry(e.type, summarizeEvent(e), e.timestamp) -}) useForesightEvent("elementUnregistered", e => { pushEntry(e.type, summarizeEvent(e), e.timestamp) }) -useForesightEvent("elementDataUpdated", e => { - pushEntry(e.type, summarizeEvent(e), Date.now()) -}) useForesightEvent("callbackInvoked", e => { pushEntry(e.type, summarizeEvent(e), e.timestamp) }) diff --git a/packages/devpage-vue/src/views/foresights/index.vue b/packages/devpage-vue/src/views/foresights/index.vue index 8d404e60..d66437cb 100644 --- a/packages/devpage-vue/src/views/foresights/index.vue +++ b/packages/devpage-vue/src/views/foresights/index.vue @@ -19,7 +19,7 @@ import StaticForesightTargets from "./partials/StaticForesightTargets.vue"

Static targets

Bind elements via :ref="slots[i].setRef" - — no separate ref management needed. + - no separate ref management needed.

diff --git a/packages/docs/docs/debugging/devtools.md b/packages/docs/docs/debugging/devtools.md index d19f1da6..4bd17e58 100644 --- a/packages/docs/docs/debugging/devtools.md +++ b/packages/docs/docs/debugging/devtools.md @@ -57,9 +57,7 @@ ForesightDevtools.initialize({ logging: { logLocation: "controlPanel", // Where to log the Foresight Events callbackCompleted: true, - elementReactivated: true, callbackInvoked: true, - elementDataUpdated: false, elementRegistered: false, elementUnregistered: false, managerSettingsChanged: true, diff --git a/packages/docs/docs/debugging/static-properties.md b/packages/docs/docs/debugging/static-properties.md index 9ed6c829..df545a15 100644 --- a/packages/docs/docs/debugging/static-properties.md +++ b/packages/docs/docs/debugging/static-properties.md @@ -135,21 +135,18 @@ The return will look something like this: "elementUnregistered": [] }, "2": { - "elementDataUpdated": [] - }, - "3": { "mouseTrajectoryUpdate": [] }, - "4": { + "3": { "scrollTrajectoryUpdate": [] }, - "5": { + "4": { "managerSettingsChanged": [] }, - "6": { + "5": { "callbackInvoked": [] }, - "7": { + "6": { "callbackCompleted": [] } }, diff --git a/packages/docs/docs/events.md b/packages/docs/docs/events.md index 438ddf1b..2cdcf0c0 100644 --- a/packages/docs/docs/events.md +++ b/packages/docs/docs/events.md @@ -115,39 +115,6 @@ type ElementRegisteredEvent = { --- -#### elementOptionsUpdated - -Fired when an already-registered element is re-registered with different options (e.g. changed `name`, `callback`, `reactivateAfter`, or `meta`). - -```typescript -type ElementOptionsUpdatedEvent = { - type: "elementOptionsUpdated" - timestamp: number - element: ForesightElement - state: ForesightElementState -} -``` - -**Related Types:** [`ForesightElementState`](/docs/getting-started/typescript#foresightelementstate) - ---- - -#### elementReactivated - -Fired when an element is reactivated after its callback was triggered. This happens after `reactivateAfter` ms (default infinity) or with `ForesightManager.instance.reactivate(element)`. - -```typescript -type ElementReactivatedEvent = { - type: "elementReactivated" - timestamp: number - elementData: ForesightElementData -} -``` - -**Related Types:** [`ForesightElementData`](/docs/getting-started/typescript#foresightelementdata) - ---- - #### elementUnregistered Fired when an element is removed from `ForesightManager`'s tracking. This only happens when the element is removed from the `DOM` or via developer actions like `ForesightManager.instance.unregister(element)` @@ -166,20 +133,6 @@ type ElementUnregisteredEvent = { --- -#### elementDataUpdated - -Fired when tracked element data changes (bounds/visibility only). Does not fire on any updates regarding `callback` data. - -```typescript -type ElementDataUpdatedEvent = { - type: "elementDataUpdated" - elementData: ForesightElementData - updatedProps: UpdatedDataPropertyNames[] // "bounds" | "visibility" -} -``` - -**Related Types:** [`ForesightElementData`](/docs/getting-started/typescript#foresightelementdata) - ### Prediction Events Events fired during movement prediction calculations. diff --git a/packages/docs/docs/getting-started/what-is-foresightjs.md b/packages/docs/docs/getting-started/what-is-foresightjs.md index 62c15b5a..d1261ac7 100644 --- a/packages/docs/docs/getting-started/what-is-foresightjs.md +++ b/packages/docs/docs/getting-started/what-is-foresightjs.md @@ -79,7 +79,7 @@ ForesightJS is designed for developers who want to squeeze every drop of perform #### Problem 1: On-Hover Prefetching Still Has Latency -Traditional hover-based prefetching only triggers after the user's cursor reaches an element. This approach wastes the critical 100-200ms window between when a user begins moving toward a target and when the hover event actually fires—time that could be used for prefetching. +Traditional hover-based prefetching only triggers after the user's cursor reaches an element. This approach wastes the critical 100-200ms window between when a user begins moving toward a target and when the hover event actually fires-time that could be used for prefetching. #### Problem 2: Viewport-Based Prefetching is Wasteful diff --git a/packages/docs/src/components/ForesightOverview/Overview/Keyboard/SmallButton.tsx b/packages/docs/src/components/ForesightOverview/Overview/Keyboard/SmallButton.tsx index 0db0c9c0..85eba9ae 100644 --- a/packages/docs/src/components/ForesightOverview/Overview/Keyboard/SmallButton.tsx +++ b/packages/docs/src/components/ForesightOverview/Overview/Keyboard/SmallButton.tsx @@ -1,4 +1,4 @@ -import { ForesightManager, type ElementReactivatedEvent } from "js.foresight" +import { ForesightManager } from "js.foresight" import React, { useEffect, useRef, useState } from "react" import styles from "./styles.module.css" const SmallButton = ({ index }: { index: number }) => { @@ -22,23 +22,9 @@ const SmallButton = ({ index }: { index: number }) => { return "Element" } - const handleElementReactivated = (e: ElementReactivatedEvent) => { - if (e.element === cardRef.current) { - setIsLoaded(false) - setIsLoading(false) - } - } - useEffect(() => { - ForesightManager.instance.addEventListener("elementReactivated", handleElementReactivated) - - return () => { - ForesightManager.instance.removeEventListener("elementReactivated", handleElementReactivated) - } - }, []) - useEffect(() => { if (cardRef.current) { - const { unregister } = ForesightManager.instance.register({ + const { unregister, subscribe, getSnapshot } = ForesightManager.instance.register({ element: cardRef.current, callback: async () => { if (!stateRef.current.isLoading && !stateRef.current.isLoaded) { @@ -55,7 +41,22 @@ const SmallButton = ({ index }: { index: number }) => { meta: { buttonNr: index }, }) - return () => unregister() + let wasPredicted = getSnapshot().isPredicted + const unsubscribe = subscribe(() => { + const snap = getSnapshot() + // Detect reactivation: was predicted, now active and not predicted + if (wasPredicted && snap.isActive && !snap.isPredicted) { + setIsLoaded(false) + setIsLoading(false) + } + + wasPredicted = snap.isPredicted + }) + + return () => { + unsubscribe() + unregister() + } } }, [cardRef]) diff --git a/packages/docs/versioned_docs/version-3.4/debugging/devtools.md b/packages/docs/versioned_docs/version-3.4/debugging/devtools.md index d19f1da6..4bd17e58 100644 --- a/packages/docs/versioned_docs/version-3.4/debugging/devtools.md +++ b/packages/docs/versioned_docs/version-3.4/debugging/devtools.md @@ -57,9 +57,7 @@ ForesightDevtools.initialize({ logging: { logLocation: "controlPanel", // Where to log the Foresight Events callbackCompleted: true, - elementReactivated: true, callbackInvoked: true, - elementDataUpdated: false, elementRegistered: false, elementUnregistered: false, managerSettingsChanged: true, diff --git a/packages/docs/versioned_docs/version-3.4/debugging/static-properties.md b/packages/docs/versioned_docs/version-3.4/debugging/static-properties.md index b10caa11..c8ddab12 100644 --- a/packages/docs/versioned_docs/version-3.4/debugging/static-properties.md +++ b/packages/docs/versioned_docs/version-3.4/debugging/static-properties.md @@ -128,9 +128,6 @@ The return will look something like this: "elementUnregistered": [] }, "2": { - "elementDataUpdated": [] - }, - "3": { "mouseTrajectoryUpdate": [] }, "4": { diff --git a/packages/docs/versioned_docs/version-3.4/events.md b/packages/docs/versioned_docs/version-3.4/events.md index 3704ca37..2cdcf0c0 100644 --- a/packages/docs/versioned_docs/version-3.4/events.md +++ b/packages/docs/versioned_docs/version-3.4/events.md @@ -115,22 +115,6 @@ type ElementRegisteredEvent = { --- -#### elementReactivated - -Fired when an element is reactivated after its callback was triggered. This happens after `reactivateAfter` ms (default infinity) or with `ForesightManager.instance.reactivate(element)`. - -```typescript -type ElementReactivatedEvent = { - type: "elementReactivated" - timestamp: number - elementData: ForesightElementData -} -``` - -**Related Types:** [`ForesightElementData`](/docs/getting-started/typescript#foresightelementdata) - ---- - #### elementUnregistered Fired when an element is removed from `ForesightManager`'s tracking. This only happens when the element is removed from the `DOM` or via developer actions like `ForesightManager.instance.unregister(element)` @@ -149,20 +133,6 @@ type ElementUnregisteredEvent = { --- -#### elementDataUpdated - -Fired when tracked element data changes (bounds/visibility only). Does not fire on any updates regarding `callback` data. - -```typescript -type ElementDataUpdatedEvent = { - type: "elementDataUpdated" - elementData: ForesightElementData - updatedProps: UpdatedDataPropertyNames[] // "bounds" | "visibility" -} -``` - -**Related Types:** [`ForesightElementData`](/docs/getting-started/typescript#foresightelementdata) - ### Prediction Events Events fired during movement prediction calculations. diff --git a/packages/docs/versioned_docs/version-3.4/getting-started/what-is-foresightjs.md b/packages/docs/versioned_docs/version-3.4/getting-started/what-is-foresightjs.md index 62c15b5a..d1261ac7 100644 --- a/packages/docs/versioned_docs/version-3.4/getting-started/what-is-foresightjs.md +++ b/packages/docs/versioned_docs/version-3.4/getting-started/what-is-foresightjs.md @@ -79,7 +79,7 @@ ForesightJS is designed for developers who want to squeeze every drop of perform #### Problem 1: On-Hover Prefetching Still Has Latency -Traditional hover-based prefetching only triggers after the user's cursor reaches an element. This approach wastes the critical 100-200ms window between when a user begins moving toward a target and when the hover event actually fires—time that could be used for prefetching. +Traditional hover-based prefetching only triggers after the user's cursor reaches an element. This approach wastes the critical 100-200ms window between when a user begins moving toward a target and when the hover event actually fires-time that could be used for prefetching. #### Problem 2: Viewport-Based Prefetching is Wasteful diff --git a/packages/foresightjs-react/src/index.ts b/packages/foresightjs-react/src/index.ts index 674ebcbc..3003d3e9 100644 --- a/packages/foresightjs-react/src/index.ts +++ b/packages/foresightjs-react/src/index.ts @@ -8,11 +8,8 @@ export { type ForesightEvent, type ForesightEventMap, type ElementRegisteredEvent, - type ElementOptionsUpdatedEvent, type DeviceStrategyChangedEvent, - type ElementReactivatedEvent, type ElementUnregisteredEvent, - type ElementDataUpdatedEvent, type CallbackInvokedEvent, type CallbackCompletedEvent, type MouseTrajectoryUpdateEvent, diff --git a/packages/foresightjs-vue/src/composables/useForesight.ts b/packages/foresightjs-vue/src/composables/useForesight.ts index 5f6ed33e..c978a7fb 100644 --- a/packages/foresightjs-vue/src/composables/useForesight.ts +++ b/packages/foresightjs-vue/src/composables/useForesight.ts @@ -20,7 +20,7 @@ import type { MaybeElement } from "../types" import { resolveElement } from "../utils/resolveElement" export type UseForesightReturn = ToRefs> & { - /** Template ref function — bind to an element with `:ref="setRef"`. */ + /** Template ref function - bind to an element with `:ref="setRef"`. */ setRef: (el: MaybeElement) => void } @@ -95,7 +95,7 @@ export const useForesight = ( } } - // Watch options for changes — patch without re-registering. + // Watch options for changes - patch without re-registering. // Skip when the raw reference hasn't changed (e.g. getter returning same object). watch( () => toValue(options), diff --git a/packages/foresightjs-vue/src/composables/useForesightEvent.test.ts b/packages/foresightjs-vue/src/composables/useForesightEvent.test.ts index 80675e40..b9f24554 100644 --- a/packages/foresightjs-vue/src/composables/useForesightEvent.test.ts +++ b/packages/foresightjs-vue/src/composables/useForesightEvent.test.ts @@ -119,7 +119,7 @@ describe("useForesightEvent", () => { wrapper.vm.cb = listener2 await nextTick() - // Should still be only one subscription — the initial one + // Should still be only one subscription - the initial one expect(addEventListenerSpy).toHaveBeenCalledTimes(1) }) diff --git a/packages/foresightjs-vue/src/composables/useForesightEvent.ts b/packages/foresightjs-vue/src/composables/useForesightEvent.ts index 3e8c62fa..1242aeef 100644 --- a/packages/foresightjs-vue/src/composables/useForesightEvent.ts +++ b/packages/foresightjs-vue/src/composables/useForesightEvent.ts @@ -6,7 +6,7 @@ type ListenerArg = MaybeRef<(event: ForesightEventMap[ /** * Subscribes to a ForesightManager event for the lifetime of the calling scope. * - * The listener is always invoked with its latest reference — no stale closures + * The listener is always invoked with its latest reference - no stale closures * when passed as a `ref()`. Changing `eventType` automatically tears down the * previous subscription and creates a new one; changing only the `listener` * does not re-subscribe. diff --git a/packages/foresightjs-vue/src/composables/useForesights.ts b/packages/foresightjs-vue/src/composables/useForesights.ts index 793e6ecf..70ed94b5 100644 --- a/packages/foresightjs-vue/src/composables/useForesights.ts +++ b/packages/foresightjs-vue/src/composables/useForesights.ts @@ -20,7 +20,7 @@ import { resolveElement } from "../utils/resolveElement" import type { MaybeElement } from "../types" export type UseForesightSlot = Readonly & { - /** Template ref function — bind to an element with `:ref="slot.setRef"`. */ + /** Template ref function - bind to an element with `:ref="slot.setRef"`. */ setRef: (el: MaybeElement) => void } @@ -38,7 +38,7 @@ type Slot = { * The array length determines the number of slots. * * Returns a reactive array of `UseForesightSlot` objects. Each slot contains: - * - `setRef` — a template ref function to bind an element (`:ref="slot.setRef"`) + * - `setRef` - a template ref function to bind an element (`:ref="slot.setRef"`) * - All `ForesightElementState` properties (`isPredicted`, `hitCount`, etc.) * * @example diff --git a/packages/foresightjs-vue/src/index.ts b/packages/foresightjs-vue/src/index.ts index 8ee46869..2e0a6af0 100644 --- a/packages/foresightjs-vue/src/index.ts +++ b/packages/foresightjs-vue/src/index.ts @@ -8,11 +8,8 @@ export { type ForesightEvent, type ForesightEventMap, type ElementRegisteredEvent, - type ElementOptionsUpdatedEvent, type DeviceStrategyChangedEvent, - type ElementReactivatedEvent, type ElementUnregisteredEvent, - type ElementDataUpdatedEvent, type CallbackInvokedEvent, type CallbackCompletedEvent, type MouseTrajectoryUpdateEvent, diff --git a/packages/foresightjs-vue/src/utils/resolveElement.ts b/packages/foresightjs-vue/src/utils/resolveElement.ts index dd89d8fb..87a06e41 100644 --- a/packages/foresightjs-vue/src/utils/resolveElement.ts +++ b/packages/foresightjs-vue/src/utils/resolveElement.ts @@ -16,7 +16,7 @@ export const resolveElement = (target: T): ResolvedEleme } const el = (target as ComponentPublicInstance).$el - // Filter comment nodes — a component with v-if="false" or empty template + // Filter comment nodes - a component with v-if="false" or empty template // leaves a #comment placeholder that has no size or position. if (el instanceof Node && el.nodeType === Node.COMMENT_NODE) { return null diff --git a/packages/js.foresight-devtools/README.md b/packages/js.foresight-devtools/README.md index f2658268..bba8acc8 100644 --- a/packages/js.foresight-devtools/README.md +++ b/packages/js.foresight-devtools/README.md @@ -43,9 +43,7 @@ ForesightDevtools.initialize({ logging: { logLocation: "controlPanel", // Where to log the Foresight Events callbackCompleted: true, - elementReactivated: true, callbackInvoked: true, - elementDataUpdated: false, elementRegistered: false, elementUnregistered: false, managerSettingsChanged: true, diff --git a/packages/js.foresight-devtools/src/helpers/safeSerializeEventData.ts b/packages/js.foresight-devtools/src/helpers/safeSerializeEventData.ts index 7bbba1ce..46e1a2b0 100644 --- a/packages/js.foresight-devtools/src/helpers/safeSerializeEventData.ts +++ b/packages/js.foresight-devtools/src/helpers/safeSerializeEventData.ts @@ -9,7 +9,6 @@ import type { HitSlop, ForesightPoint, ScrollDirection, - UpdatedDataPropertyNames, UpdatedManagerSetting, } from "js.foresight" @@ -35,15 +34,6 @@ interface ElementRegisteredPayload extends PayloadBase { meta: Record } -interface ElementOptionsUpdatedPayload extends PayloadBase { - type: "elementOptionsUpdated" - name: string - id: string - state: ForesightElementState - hitslop: HitSlop - meta: Record -} - interface ElementUnregisteredEvent extends PayloadBase { type: "elementUnregistered" name: string @@ -53,23 +43,6 @@ interface ElementUnregisteredEvent extends PayloadBase { wasLastRegisteredElement: boolean } -interface ElementReactivatedPayload extends PayloadBase { - type: "elementReactivated" - name: string - id: string - state: ForesightElementState - meta: Record -} - -interface ElementDataUpdatedPayload extends PayloadBase { - type: "elementDataUpdated" - name: string - updatedProps: UpdatedDataPropertyNames[] - state: ForesightElementState - isIntersecting: boolean - meta: Record -} - interface CallbackInvokedPayload extends PayloadBase { type: "callbackInvoked" name: string @@ -135,10 +108,7 @@ interface ManagerDataPayload extends PayloadBase { export type SerializedEventData = | ElementRegisteredPayload - | ElementOptionsUpdatedPayload | ElementUnregisteredEvent - | ElementReactivatedPayload - | ElementDataUpdatedPayload | CallbackInvokedPayload | CallbackCompletedPayload | MouseTrajectoryUpdatePayload @@ -212,32 +182,6 @@ export const safeSerializeEventData = ( ? `${event.state.name} - ${getOrdinalSuffix(event.state.registerCount)} time` : event.state.name, } - case "elementOptionsUpdated": - return { - type: "elementOptionsUpdated", - name: event.state.name, - id: event.element.id || "", - state: event.state, - hitslop: event.state.elementBounds.hitSlop, - localizedTimestamp: new Date(event.timestamp).toLocaleTimeString(), - meta: event.state.meta, - logId: logId, - summary: event.state.name, - } - case "elementReactivated": - return { - type: "elementReactivated", - name: event.state.name, - id: event.element.id || "", - state: event.state, - localizedTimestamp: new Date(event.timestamp).toLocaleTimeString(), - meta: event.state.meta, - logId: logId, - summary: - event.state.registerCount > 1 - ? `${event.state.name} - ${getOrdinalSuffix(event.state.registerCount)} time` - : event.state.name, - } case "elementUnregistered": return { type: "elementUnregistered", @@ -250,18 +194,6 @@ export const safeSerializeEventData = ( logId: logId, summary: `${event.state.name} - ${event.unregisterReason}`, } - case "elementDataUpdated": - return { - type: "elementDataUpdated", - name: event.state.name, - updatedProps: event.updatedProps || [], - state: event.state, - isIntersecting: event.state.isIntersectingWithViewport, - meta: event.state.meta, - localizedTimestamp: new Date().toLocaleTimeString(), - logId: logId, - summary: `${event.state.name} - ${event.updatedProps.toString()}`, - } case "callbackInvoked": return { type: "callbackInvoked", 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 5b2f94e4..2fafa45b 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 @@ -7,11 +7,8 @@ import type { CallbackHits, CallbackHitType, CallbackInvokedEvent, - ElementDataUpdatedEvent, - ElementReactivatedEvent, ElementRegisteredEvent, ElementUnregisteredEvent, - ElementOptionsUpdatedEvent, } from "js.foresight" import { ForesightManager, type ForesightElement, type ForesightElementState } from "js.foresight" @@ -102,6 +99,7 @@ export class ElementTab extends LitElement { @state() private activeSectionCollapsed = false @state() private inactiveSectionCollapsed = false private _abortController: AbortController | null = null + private _elementSubscriptions: Map void> = new Map() private _pendingElementUpdates: Map = new Map() private _updateDebounceId: ReturnType | null = null // Cached sorted element lists to avoid repeated filtering in render @@ -213,61 +211,54 @@ export class ElementTab extends LitElement { return lines.join("\n") } + private _subscribeToElement(element: ForesightElement): void { + if (this._elementSubscriptions.has(element)) { + return + } + + const unsubscribe = ForesightManager.instance.subscribeToElement(element, () => { + const state = ForesightManager.instance.registeredElements.get(element) + if (state) { + this._pendingElementUpdates.set(element, state) + this._scheduleDebouncedUpdate() + } + }) + + if (unsubscribe) { + this._elementSubscriptions.set(element, unsubscribe) + } + } + + private _unsubscribeFromElement(element: ForesightElement): void { + this._elementSubscriptions.get(element)?.() + this._elementSubscriptions.delete(element) + } + connectedCallback() { super.connectedCallback() this._abortController = new AbortController() const { signal } = this._abortController this.updateElementListFromManager() + for (const element of this.elementListItems.keys()) { + this._subscribeToElement(element) + } + ForesightManager.instance.addEventListener( "elementRegistered", (e: ElementRegisteredEvent) => { this.elementListItems.set(e.element, e.state) + this._subscribeToElement(e.element) this._elementsCacheDirty = true this.requestUpdate() }, { signal } ) - ForesightManager.instance.addEventListener( - "elementOptionsUpdated", - (e: ElementOptionsUpdatedEvent) => { - if (!this.applyStateUpdate(e.element, e.state)) { - return - } - - this.requestUpdate() - }, - { signal } - ) - - ForesightManager.instance.addEventListener( - "elementDataUpdated", - (e: ElementDataUpdatedEvent) => { - if (this.elementListItems.has(e.element)) { - // Batch updates and debounce to avoid excessive re-renders during scroll - this._pendingElementUpdates.set(e.element, e.state) - this._scheduleDebouncedUpdate() - } - }, - { signal } - ) - - ForesightManager.instance.addEventListener( - "elementReactivated", - (e: ElementReactivatedEvent) => { - if (!this.applyStateUpdate(e.element, e.state)) { - return - } - - this.requestUpdate() - }, - { signal } - ) - ForesightManager.instance.addEventListener( "elementUnregistered", (e: ElementUnregisteredEvent) => { + this._unsubscribeFromElement(e.element) this.elementListItems.delete(e.element) if (!this.elementListItems.size) { this.noContentMessage = "No Elements Registered To The Foresight Manager" @@ -282,9 +273,8 @@ export class ElementTab extends LitElement { ForesightManager.instance.addEventListener( "callbackInvoked", (e: CallbackInvokedEvent) => { - this.applyStateUpdate(e.element, e.state) - this._elementsCacheDirty = true - this.requestUpdate() + this._pendingElementUpdates.set(e.element, e.state) + this._scheduleDebouncedUpdate() }, { signal } ) @@ -292,30 +282,22 @@ export class ElementTab extends LitElement { ForesightManager.instance.addEventListener( "callbackCompleted", (e: CallbackCompletedEvent) => { - this.applyStateUpdate(e.element, e.state) this.handleCallbackCompleted(e.hitType) - this._elementsCacheDirty = true - this.requestUpdate() }, { signal } ) } - private applyStateUpdate(element: ForesightElement, state: ForesightElementState): boolean { - if (!this.elementListItems.has(element)) { - return false - } - - this.elementListItems.set(element, state) - this._elementsCacheDirty = true - - return true - } - disconnectedCallback() { super.disconnectedCallback() this._abortController?.abort() this._abortController = null + + for (const unsub of this._elementSubscriptions.values()) { + unsub() + } + this._elementSubscriptions.clear() + if (this._updateDebounceId !== null) { clearTimeout(this._updateDebounceId) this._updateDebounceId = null diff --git a/packages/js.foresight-devtools/src/lit-entry/control-panel/log-tab/log-tab.ts b/packages/js.foresight-devtools/src/lit-entry/control-panel/log-tab/log-tab.ts index a7aebf80..22511d26 100644 --- a/packages/js.foresight-devtools/src/lit-entry/control-panel/log-tab/log-tab.ts +++ b/packages/js.foresight-devtools/src/lit-entry/control-panel/log-tab/log-tab.ts @@ -182,24 +182,12 @@ export class LogTab extends LitElement { title: "Show element registration events", icon: FILTER_SVG, }, - { - value: "elementOptionsUpdated", - label: "Element Updated", - title: "Show element re-registration (options changed) events", - icon: FILTER_SVG, - }, { value: "elementUnregistered", label: "Element Unregistered", title: "Show element unregistration events", icon: FILTER_SVG, }, - { - value: "elementReactivated", - label: "Element Reactivated", - title: "Show when element gets reactivated after stale time has passed", - icon: FILTER_SVG, - }, { value: "callbackInvoked", label: "Callback Invoked", @@ -265,9 +253,7 @@ export class LogTab extends LitElement { private shouldShowPerformanceWarning(): boolean { const hasConsoleOutput = this.logLocation === "console" || this.logLocation === "both" const hasFrequentEvents = - this.eventsEnabled.mouseTrajectoryUpdate || - this.eventsEnabled.scrollTrajectoryUpdate || - this.eventsEnabled.elementDataUpdated + this.eventsEnabled.mouseTrajectoryUpdate || this.eventsEnabled.scrollTrajectoryUpdate return hasConsoleOutput && hasFrequentEvents } @@ -359,11 +345,8 @@ export class LogTab extends LitElement { private getEventColor(eventType: ForesightEvent): string { const colorMap: Record = { elementRegistered: "#2196f3", - elementOptionsUpdated: "#2196f3", - elementReactivated: "#ff9800", callbackInvoked: "#00bcd4", callbackCompleted: "#4caf50", - elementDataUpdated: "#ffc107", elementUnregistered: "#ff9800", managerSettingsChanged: "#f44336", mouseTrajectoryUpdate: "#78909c", diff --git a/packages/js.foresight-devtools/src/lit-entry/control-panel/log-tab/single-log.ts b/packages/js.foresight-devtools/src/lit-entry/control-panel/log-tab/single-log.ts index 5cf85307..fc01d341 100644 --- a/packages/js.foresight-devtools/src/lit-entry/control-panel/log-tab/single-log.ts +++ b/packages/js.foresight-devtools/src/lit-entry/control-panel/log-tab/single-log.ts @@ -114,11 +114,8 @@ export class SingleLog extends LitElement { private getLogTypeColor(logType: string): string { const colorMap: Record = { elementRegistered: "#2196f3", - elementOptionsUpdated: "#2196f3", - elementReactivated: "#ff9800", callbackInvoked: "#00bcd4", callbackCompleted: "#4caf50", - elementDataUpdated: "#ffc107", elementUnregistered: "#ff9800", managerSettingsChanged: "#f44336", mouseTrajectoryUpdate: "#78909c", @@ -132,10 +129,7 @@ export class SingleLog extends LitElement { private getEventDisplayName(eventType: string): string { const eventNames: Record = { elementRegistered: "Registered", - elementOptionsUpdated: "Updated", - elementReactivated: "Reactivated", elementUnregistered: "Unregistered", - elementDataUpdated: "Data Updated", callbackInvoked: "Invoked", callbackCompleted: "Completed", mouseTrajectoryUpdate: "Mouse", diff --git a/packages/js.foresight-devtools/src/lit-entry/debug-overlay/element-overlays.ts b/packages/js.foresight-devtools/src/lit-entry/debug-overlay/element-overlays.ts index faf43f78..fb662b65 100644 --- a/packages/js.foresight-devtools/src/lit-entry/debug-overlay/element-overlays.ts +++ b/packages/js.foresight-devtools/src/lit-entry/debug-overlay/element-overlays.ts @@ -9,11 +9,8 @@ import { import type { CallbackCompletedEvent, CallbackInvokedEvent, - ElementDataUpdatedEvent, - ElementReactivatedEvent, ElementRegisteredEvent, ElementUnregisteredEvent, - ElementOptionsUpdatedEvent, } from "js.foresight" const STYLE_ID = "foresight-overlay-styles" @@ -79,6 +76,7 @@ interface CallbackAnimation { export class ElementOverlays extends LitElement { @state() private overlayMap: Map = new Map() @state() private callbackAnimations: Map = new Map() + private _elementSubscriptions: Map void> = new Map() @query("#overlays-container") private containerElement!: HTMLElement @property({ type: Boolean }) showNameTags = true @@ -147,56 +145,29 @@ export class ElementOverlays extends LitElement { ForesightManager.instance.addEventListener( "elementRegistered", (e: ElementRegisteredEvent) => { + this._subscribeToElement(e.element) if (e.state.isIntersectingWithViewport) { this.createOrUpdateElementOverlay(e.element, e.state) } }, { signal } ) - ForesightManager.instance.addEventListener( - "elementOptionsUpdated", - (e: ElementOptionsUpdatedEvent) => { - if (!e.state.isIntersectingWithViewport || !e.state.isActive) { - this.removeElementOverlay(e.element) - - return - } - - this.createOrUpdateElementOverlay(e.element, e.state) - }, - { signal } - ) ForesightManager.instance.addEventListener( "elementUnregistered", (e: ElementUnregisteredEvent) => { + this._unsubscribeFromElement(e.element) this.removeElementOverlay(e.element) }, { signal } ) - ForesightManager.instance.addEventListener( - "elementReactivated", - (e: ElementReactivatedEvent) => { - if (e.state.isIntersectingWithViewport) { - this.createOrUpdateElementOverlay(e.element, e.state) - } - }, - { signal } - ) - ForesightManager.instance.addEventListener( - "elementDataUpdated", - (e: ElementDataUpdatedEvent) => { - if (!e.state.isIntersectingWithViewport) { - this.removeElementOverlay(e.element) - return - } - - if (e.state.isActive) { - this.createOrUpdateElementOverlay(e.element, e.state) - } - }, - { signal } - ) + // Subscribe to already-registered elements + for (const [element, state] of ForesightManager.instance.registeredElements) { + this._subscribeToElement(element) + if (state.isIntersectingWithViewport && state.isActive) { + this.createOrUpdateElementOverlay(element, state) + } + } ForesightManager.instance.addEventListener( "callbackInvoked", (e: CallbackInvokedEvent) => { @@ -224,12 +195,12 @@ export class ElementOverlays extends LitElement { super.attributeChangedCallback(name, oldVal, newVal) if (name === "hidden") { if (newVal !== null) { - // Hidden — remove overlay styles from all elements + // Hidden - remove overlay styles from all elements for (const element of this.overlayMap.keys()) { this.removeOverlayFromElement(element) } } else { - // Shown — reapply overlay styles to all tracked elements + // Shown - reapply overlay styles to all tracked elements for (const [element] of this.overlayMap) { const state = ForesightManager.instance.registeredElements.get(element) if (state) { @@ -240,6 +211,32 @@ export class ElementOverlays extends LitElement { } } + private _subscribeToElement(element: ForesightElement): void { + if (this._elementSubscriptions.has(element)) { + return + } + + const unsubscribe = ForesightManager.instance.subscribeToElement(element, () => { + const state = ForesightManager.instance.registeredElements.get(element) + if (!state || !state.isIntersectingWithViewport || !state.isActive) { + this.removeElementOverlay(element) + + return + } + + this.createOrUpdateElementOverlay(element, state) + }) + + if (unsubscribe) { + this._elementSubscriptions.set(element, unsubscribe) + } + } + + private _unsubscribeFromElement(element: ForesightElement): void { + this._elementSubscriptions.get(element)?.() + this._elementSubscriptions.delete(element) + } + private applyOverlayToElement(element: ForesightElement, state: ForesightElementState): void { const { hitSlop } = state.elementBounds const maxSlop = Math.max(hitSlop.top, hitSlop.right, hitSlop.bottom, hitSlop.left) @@ -356,6 +353,11 @@ export class ElementOverlays extends LitElement { disconnectedCallback(): void { super.disconnectedCallback() + for (const unsub of this._elementSubscriptions.values()) { + unsub() + } + this._elementSubscriptions.clear() + // Clean up overlay attributes from all tracked elements for (const element of this.overlayMap.keys()) { this.removeOverlayFromElement(element) diff --git a/packages/js.foresight-devtools/src/lit-entry/foresight-devtools.ts b/packages/js.foresight-devtools/src/lit-entry/foresight-devtools.ts index 6a1ba870..13f1d82d 100644 --- a/packages/js.foresight-devtools/src/lit-entry/foresight-devtools.ts +++ b/packages/js.foresight-devtools/src/lit-entry/foresight-devtools.ts @@ -39,11 +39,8 @@ export class ForesightDevtools extends LitElement { logging: { logLocation: "controlPanel", callbackCompleted: true, - elementReactivated: true, callbackInvoked: true, - elementDataUpdated: false, elementRegistered: false, - elementOptionsUpdated: false, elementUnregistered: false, managerSettingsChanged: true, mouseTrajectoryUpdate: false, @@ -172,7 +169,6 @@ export class ForesightDevtools extends LitElement { this.updateLoggingSetting("callbackCompleted", props.logging.callbackCompleted) this.updateLoggingSetting("callbackInvoked", props.logging.callbackInvoked) - this.updateLoggingSetting("elementDataUpdated", props.logging.elementDataUpdated) this.updateLoggingSetting("elementRegistered", props.logging.elementRegistered) this.updateLoggingSetting("elementUnregistered", props.logging.elementUnregistered) this.updateLoggingSetting("managerSettingsChanged", props.logging.managerSettingsChanged) diff --git a/packages/js.foresight/src/helpers/createInitialState.ts b/packages/js.foresight/src/helpers/createInitialState.ts index 93438159..7d7664a2 100644 --- a/packages/js.foresight/src/helpers/createInitialState.ts +++ b/packages/js.foresight/src/helpers/createInitialState.ts @@ -132,7 +132,7 @@ const EMPTY_DOM_RECT: DOMRectReadOnly = { * * Used in two situations: * 1. The manager refuses to register the element (touch device, limited connection, - * etc.) — pass `isLimitedConnection` to reflect that. + * etc.) - pass `isLimitedConnection` to reflect that. * 2. Framework wrappers (React, Vue) need an initial snapshot before `register()` * has run. `register()` requires a real DOM element, which only exists after * the consumer's first render commits, so the wrapper returns this snapshot diff --git a/packages/js.foresight/src/index.ts b/packages/js.foresight/src/index.ts index 2451f380..b1bcf487 100644 --- a/packages/js.foresight/src/index.ts +++ b/packages/js.foresight/src/index.ts @@ -15,11 +15,8 @@ export type { ForesightEvent, ForesightEventMap, ElementRegisteredEvent, - ElementOptionsUpdatedEvent, DeviceStrategyChangedEvent, - ElementReactivatedEvent, ElementUnregisteredEvent, - ElementDataUpdatedEvent, CallbackInvokedEvent, CallbackCompletedEvent, MouseTrajectoryUpdateEvent, @@ -34,5 +31,4 @@ export type { Point as ForesightPoint, ScrollDirection, ForesightManagerData, - UpdatedDataPropertyNames, } from "./types/types" diff --git a/packages/js.foresight/src/managers/DesktopHandler.ts b/packages/js.foresight/src/managers/DesktopHandler.ts index a7dfe5d9..3901ad4f 100644 --- a/packages/js.foresight/src/managers/DesktopHandler.ts +++ b/packages/js.foresight/src/managers/DesktopHandler.ts @@ -9,7 +9,6 @@ import type { ForesightElementInternal, ForesightElementState, TrajectoryPositions, - UpdatedDataPropertyNames, } from "../types/types" import { CircularBuffer } from "../helpers/CircularBuffer" import { DEFAULT_POSITION_HISTORY_SIZE } from "../constants" @@ -87,7 +86,7 @@ export class DesktopHandler extends ElementObservingModule { this.checkForMouseHover(entry) } - // Must run AFTER handleScrollPrefetch — scroll direction is derived from + // Must run AFTER handleScrollPrefetch - scroll direction is derived from // the difference between the old and new originalRect. this.handlePositionChangeDataUpdates(entry, positionEntry) } @@ -115,13 +114,11 @@ export class DesktopHandler extends ElementObservingModule { entry: ForesightElementInternal, positionEntry: PositionObserverEntry ) => { - const updatedProps: UpdatedDataPropertyNames[] = [] const isNowIntersecting = positionEntry.isIntersecting const state = entry.state const patch: Partial = {} if (state.isIntersectingWithViewport !== isNowIntersecting) { - updatedProps.push("visibility") patch.isIntersectingWithViewport = isNowIntersecting } @@ -129,7 +126,6 @@ export class DesktopHandler extends ElementObservingModule { isNowIntersecting && !areRectsEqual(positionEntry.boundingClientRect, state.elementBounds.originalRect) ) { - updatedProps.push("bounds") patch.elementBounds = { hitSlop: state.elementBounds.hitSlop, originalRect: positionEntry.boundingClientRect, @@ -140,20 +136,11 @@ export class DesktopHandler extends ElementObservingModule { } } - if (updatedProps.length === 0) { + if (Object.keys(patch).length === 0) { return } - const next = this.updateElementState(entry, patch) - - if (this.hasListeners("elementDataUpdated")) { - this.emit({ - type: "elementDataUpdated", - element: entry.element, - state: next, - updatedProps, - }) - } + this.updateElementState(entry, patch) } protected onDisconnect(): void { diff --git a/packages/js.foresight/src/managers/ForesightManager.test.ts b/packages/js.foresight/src/managers/ForesightManager.test.ts index e09e94ff..f68e04af 100644 --- a/packages/js.foresight/src/managers/ForesightManager.test.ts +++ b/packages/js.foresight/src/managers/ForesightManager.test.ts @@ -4,6 +4,7 @@ import type { ForesightElement, ForesightElementInternal, ForesightElementState, + ForesightRegisterOptions, } from "../types/types" // Mock position-observer before importing ForesightManager @@ -93,7 +94,9 @@ const runReactivationCycle = async ( expectState(entry.state, { isPredicted: false, isCallbackRunning: false, isActive: true }) } -const setupBasicTest = (registerOpts: Record = {}) => { +const setupBasicTest = ( + registerOpts: Partial> = {} +) => { const manager = ForesightManager.initialize() const element = createMockElement() const result = manager.register({ element, callback: vi.fn(), ...registerOpts }) @@ -105,13 +108,11 @@ const setupBasicTest = (registerOpts: Record = {}) => { const setupReactivationTest = (reactivateAfter: number = 5000) => { const manager = ForesightManager.initialize() const element = createMockElement() - const reactivatedListener = vi.fn() - manager.addEventListener("elementReactivated", reactivatedListener) manager.register({ element, callback: vi.fn(), reactivateAfter }) const entry = getEntry(manager, element) - return { manager, element, entry, reactivatedListener } + return { manager, element, entry } } const setupReactivationAfterFire = async (reactivateAfter: number) => { @@ -631,7 +632,7 @@ describe("ForesightManager", () => { describe("Reactivation", () => { it("should reactivate element after timeout", async () => { - const { manager, entry, reactivatedListener } = setupReactivationTest(5000) + const { manager, entry } = setupReactivationTest(5000) fire(manager, entry) @@ -639,23 +640,17 @@ describe("ForesightManager", () => { await vi.advanceTimersByTimeAsync(100) expect(entry.state.isActive).toBe(false) - expect(reactivatedListener).not.toHaveBeenCalled() // Now advance past reactivateAfter await vi.advanceTimersByTimeAsync(5000) expect(entry.state.isActive).toBe(true) - expect(reactivatedListener).toHaveBeenCalledWith( - expect.objectContaining({ type: "elementReactivated" }) - ) }) it("should not reactivate if reactivateAfter is Infinity", async () => { const manager = ForesightManager.initialize() const element = createMockElement() - const reactivatedListener = vi.fn() - manager.addEventListener("elementReactivated", reactivatedListener) manager.register({ element, callback: vi.fn(), reactivateAfter: Infinity }) const entry = getEntry(manager, element) @@ -665,7 +660,6 @@ describe("ForesightManager", () => { await vi.advanceTimersByTimeAsync(100000) expect(entry.state.isActive).toBe(false) - expect(reactivatedListener).not.toHaveBeenCalled() }) it("should support manual reactivation", async () => { @@ -1237,7 +1231,7 @@ describe("ForesightManager", () => { fire(manager, entry) await vi.runAllTimersAsync() - // Resubscribe — getSnapshot must reflect state that changed while unsubscribed + // Resubscribe - getSnapshot must reflect state that changed while unsubscribed const listener = vi.fn() result.subscribe(listener) @@ -1252,7 +1246,7 @@ describe("ForesightManager", () => { manager.unregister(element) - // The unregister itself triggers a state update — listener is notified + // The unregister itself triggers a state update - listener is notified const callCount = listener.mock.calls.length expect(callCount).toBeGreaterThan(0) @@ -1354,44 +1348,40 @@ describe("ForesightManager", () => { }) it("should reschedule pending reactivation timeout when reactivateAfter changes", async () => { - const { manager, element, entry, reactivatedListener } = - await setupReactivationAfterFire(5000) + const { manager, element, entry } = await setupReactivationAfterFire(5000) manager.updateElementOptions(element, { reactivateAfter: 500 }) expect(entry.state.reactivateAfter).toBe(500) - // Original 5000ms timer should have been cleared + // Original 5000ms timer should have been cleared, new 500ms timer fires await vi.advanceTimersByTimeAsync(500) - expect(reactivatedListener).toHaveBeenCalledTimes(1) expect(entry.state.isActive).toBe(true) }) it("should cancel reactivation when updated with Infinity", async () => { - const { manager, element, entry, reactivatedListener } = - await setupReactivationAfterFire(2000) + const { manager, element, entry } = await setupReactivationAfterFire(2000) manager.updateElementOptions(element, { reactivateAfter: Infinity }) await vi.advanceTimersByTimeAsync(10000) - expect(reactivatedListener).not.toHaveBeenCalled() expect(entry.state.isActive).toBe(false) }) it("should not reschedule when there is no pending reactivation timeout", async () => { - const { manager, element, reactivatedListener } = setupReactivationTest(5000) + const { manager, element, entry } = setupReactivationTest(5000) // Element is still active (no callback fired yet), no pending timeout + expect(entry.state.isActive).toBe(true) manager.updateElementOptions(element, { reactivateAfter: 100 }) - // No spurious reactivation should happen + // No spurious reactivation should happen - element should stay active (never deactivated) await vi.advanceTimersByTimeAsync(200) - expect(reactivatedListener).not.toHaveBeenCalled() + expect(entry.state.isActive).toBe(true) }) it("should schedule reactivation when updated from Infinity to finite while predicted", async () => { // Register with Infinity (no reactivation), then fire callback - const { manager, element, entry, reactivatedListener } = - await setupReactivationAfterFire(Infinity) + const { manager, element, entry } = await setupReactivationAfterFire(Infinity) // Element is predicted, no timeout exists expect(entry.state.isPredicted).toBe(true) @@ -1402,14 +1392,12 @@ describe("ForesightManager", () => { // Timeout should now be scheduled and fire after 1000ms await vi.advanceTimersByTimeAsync(1000) - expect(reactivatedListener).toHaveBeenCalledTimes(1) expect(entry.state.isActive).toBe(true) expect(entry.state.isPredicted).toBe(false) }) it("should not clear pending reactivation timeout when reactivateAfter is unchanged", async () => { - const { manager, element, entry, reactivatedListener } = - await setupReactivationAfterFire(2000) + const { manager, element, entry } = await setupReactivationAfterFire(2000) // Element is predicted with a pending reactivation timeout expect(entry.state.isPredicted).toBe(true) @@ -1424,7 +1412,6 @@ describe("ForesightManager", () => { // After the original timeout period, element should reactivate await vi.advanceTimersByTimeAsync(2000) - expect(reactivatedListener).toHaveBeenCalledTimes(1) expect(entry.state.isActive).toBe(true) expect(entry.state.isPredicted).toBe(false) }) diff --git a/packages/js.foresight/src/managers/ForesightManager.ts b/packages/js.foresight/src/managers/ForesightManager.ts index ae2f0e60..f77ddf29 100644 --- a/packages/js.foresight/src/managers/ForesightManager.ts +++ b/packages/js.foresight/src/managers/ForesightManager.ts @@ -164,6 +164,30 @@ export class ForesightManager { return this.eventEmitter.hasListeners(eventType) } + /** + * Subscribe to state changes for a specific element. + * The listener is called (with no arguments) whenever the element's + * immutable state snapshot is replaced. Use {@link registeredElements} + * to read the latest state inside the listener. + * + * @returns An unsubscribe function, or `undefined` if the element is not registered. + */ + public subscribeToElement( + element: ForesightElement, + listener: () => void + ): (() => void) | undefined { + const entry = this.elementEntries.get(element) + if (!entry) { + return undefined + } + + entry.subscribers.add(listener) + + return () => { + entry.subscribers.delete(listener) + } + } + public get getManagerData(): Readonly { return { registeredElements: this.registeredElements, @@ -314,13 +338,6 @@ export class ForesightManager { } } - this.eventEmitter.emit({ - type: "elementOptionsUpdated", - timestamp: Date.now(), - element, - state: next, - }) - return next } @@ -340,7 +357,7 @@ export class ForesightManager { /** * Replace the immutable state ref for an element and notify subscribers. - * No-op when every patch value already matches current state — preserves the + * No-op when every patch value already matches current state - preserves the * stable-reference contract relied on by useSyncExternalStore and shallowRef. */ private updateElementState( @@ -448,17 +465,10 @@ export class ForesightManager { return } - const next = this.updateElementState(entry, { isActive: true, isPredicted: false }) + this.updateElementState(entry, { isActive: true, isPredicted: false }) this.activeElementCount++ this.updateCheckableStatus(entry) this.currentlyActiveHandler?.observeElement(element) - - this.eventEmitter.emit({ - type: "elementReactivated", - element: element, - state: next, - timestamp: Date.now(), - }) } private clearReactivateTimeout(entry: ForesightElementInternal): void { @@ -782,20 +792,13 @@ export class ForesightManager { return } - const next = this.updateElementState(entry, { + this.updateElementState(entry, { elementBounds: { hitSlop: entry.state.elementBounds.hitSlop, originalRect: newOriginalRect, expandedRect, }, }) - - this.eventEmitter.emit({ - type: "elementDataUpdated", - element: entry.element, - state: next, - updatedProps: ["bounds" as const], - }) } private devLog(message: string): void { diff --git a/packages/js.foresight/src/types/types.ts b/packages/js.foresight/src/types/types.ts index 7a20979f..6e113425 100644 --- a/packages/js.foresight/src/types/types.ts +++ b/packages/js.foresight/src/types/types.ts @@ -374,10 +374,7 @@ export type ManagerBooleanSettingKeys = { // This map connects the string name of an event to its data type export interface ForesightEventMap { elementRegistered: ElementRegisteredEvent - elementOptionsUpdated: ElementOptionsUpdatedEvent - elementReactivated: ElementReactivatedEvent elementUnregistered: ElementUnregisteredEvent - elementDataUpdated: ElementDataUpdatedEvent callbackInvoked: CallbackInvokedEvent callbackCompleted: CallbackCompletedEvent mouseTrajectoryUpdate: MouseTrajectoryUpdateEvent @@ -388,10 +385,7 @@ export interface ForesightEventMap { export type ForesightEvent = | "elementRegistered" - | "elementOptionsUpdated" - | "elementReactivated" | "elementUnregistered" - | "elementDataUpdated" | "callbackInvoked" | "callbackCompleted" | "mouseTrajectoryUpdate" @@ -411,18 +405,6 @@ export interface ElementRegisteredEvent extends ForesightBaseEvent { state: ForesightElementState } -export interface ElementOptionsUpdatedEvent extends ForesightBaseEvent { - type: "elementOptionsUpdated" - element: ForesightElement - state: ForesightElementState -} - -export interface ElementReactivatedEvent extends ForesightBaseEvent { - type: "elementReactivated" - element: ForesightElement - state: ForesightElementState -} - export interface ElementUnregisteredEvent extends ForesightBaseEvent { type: "elementUnregistered" element: ForesightElement @@ -441,15 +423,6 @@ export interface ElementUnregisteredEvent extends ForesightBaseEvent { */ export type ElementUnregisteredReason = "disconnected" | "apiCall" | "devtools" | (string & {}) -export interface ElementDataUpdatedEvent extends Omit { - type: "elementDataUpdated" - element: ForesightElement - state: ForesightElementState - updatedProps: UpdatedDataPropertyNames[] -} - -export type UpdatedDataPropertyNames = "bounds" | "visibility" - export interface CallbackInvokedEvent extends ForesightBaseEvent { type: "callbackInvoked" element: ForesightElement From 1f8106303934e166d2e6ea592c8f4a186b02df01 Mon Sep 17 00:00:00 2001 From: Bart Spaans Date: Tue, 19 May 2026 18:51:25 +0200 Subject: [PATCH 2/2] add tests --- .../src/managers/ForesightManager.test.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/packages/js.foresight/src/managers/ForesightManager.test.ts b/packages/js.foresight/src/managers/ForesightManager.test.ts index f68e04af..8d2683bc 100644 --- a/packages/js.foresight/src/managers/ForesightManager.test.ts +++ b/packages/js.foresight/src/managers/ForesightManager.test.ts @@ -1269,6 +1269,94 @@ describe("ForesightManager", () => { }) }) + describe("subscribeToElement", () => { + it("should return undefined for an unregistered element", () => { + const manager = ForesightManager.initialize() + const element = createMockElement() + + const result = manager.subscribeToElement(element, vi.fn()) + expect(result).toBeUndefined() + }) + + it("should notify listener when element state changes", () => { + const { manager, element, entry } = setupBasicTest() + const listener = vi.fn() + + manager.subscribeToElement(element, listener) + + fire(manager, entry) + expect(listener).toHaveBeenCalled() + }) + + it("should stop notifying after unsubscribe", async () => { + const { manager, element, entry } = setupBasicTest() + const listener = vi.fn() + + const unsubscribe = manager.subscribeToElement(element, listener)! + unsubscribe() + + fire(manager, entry) + await vi.runAllTimersAsync() + + expect(listener).not.toHaveBeenCalled() + }) + + it("should allow reading latest state via registeredElements inside listener", () => { + const { manager, element, entry } = setupBasicTest() + let capturedState: ForesightElementState | undefined + + manager.subscribeToElement(element, () => { + capturedState = manager.registeredElements.get(element) + }) + + fire(manager, entry) + + expect(capturedState).toBeDefined() + expect(capturedState!.isPredicted).toBe(true) + }) + + it("should clean up subscribers when element is unregistered", () => { + const { manager, element } = setupBasicTest() + const listener = vi.fn() + + manager.subscribeToElement(element, listener) + + manager.unregister(element) + + // Listener is called during unregister (state update to isRegistered: false) + const callCount = listener.mock.calls.length + expect(callCount).toBeGreaterThan(0) + + // After unregister, subscribing again should return undefined + const result = manager.subscribeToElement(element, vi.fn()) + expect(result).toBeUndefined() + }) + + it("should support multiple independent subscribers", async () => { + const { manager, element, entry } = setupBasicTest({ reactivateAfter: Infinity }) + const listener1 = vi.fn() + const listener2 = vi.fn() + + const unsub1 = manager.subscribeToElement(element, listener1)! + manager.subscribeToElement(element, listener2) + + fire(manager, entry) + await vi.runAllTimersAsync() + + expect(listener1).toHaveBeenCalled() + expect(listener2).toHaveBeenCalled() + + const count1 = listener1.mock.calls.length + unsub1() + + // Trigger another state change via manual reactivation + manager.reactivate(element) + + expect(listener1).toHaveBeenCalledTimes(count1) + expect(listener2.mock.calls.length).toBeGreaterThan(count1) + }) + }) + describe("updateElementOptions", () => { it("should update reactivateAfter", () => { const manager = ForesightManager.initialize()