From 40d825f5c518825a1ca8566ac92dabc84923df32 Mon Sep 17 00:00:00 2001 From: Vamil Gandhi Date: Sat, 11 Apr 2026 18:42:03 -0400 Subject: [PATCH] perf(adapters): isolate shared contexts by events config - reuse one shared observer per normalized events set - keep private React contexts stable across rerenders - add React/Vue lifecycle regression tests - update package and docs references for the new sharing model Closes #163 --- packages/react/README.md | 2 +- .../react/src/__tests__/useAskable.test.tsx | 141 ++++++++++++++++++ packages/react/src/useAskable.ts | 100 ++++++++++--- packages/vue/README.md | 4 +- packages/vue/src/__tests__/useAskable.test.ts | 81 +++++++++- packages/vue/src/useAskable.ts | 64 ++++++-- site/docs/api/react.md | 2 +- site/docs/api/vue.md | 2 +- site/docs/guide/react.md | 4 +- 9 files changed, 355 insertions(+), 45 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index 5aa711c..fe6f38f 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -95,7 +95,7 @@ const { focus, promptContext } = useAskable({ events: ['click', 'focus'] }); - `ctx.toPromptContext(options?)` — full serialization options (format, maxTokens, excludeKeys, …) - `ctx.serializeFocus(options?)` — structured `AskableSerializedFocus` object -The hook manages a shared singleton context, so multiple calls across your app share the same observer. The context is automatically destroyed when the last consumer unmounts. +The hook manages a shared singleton context per `events` configuration. Multiple `useAskable()` consumers with the same `events` reuse one observer lifecycle, while differing `events` configurations get isolated shared contexts of their own. Each shared context is automatically destroyed when its last consumer unmounts. ### SSR note diff --git a/packages/react/src/__tests__/useAskable.test.tsx b/packages/react/src/__tests__/useAskable.test.tsx index f613d5f..a347af9 100644 --- a/packages/react/src/__tests__/useAskable.test.tsx +++ b/packages/react/src/__tests__/useAskable.test.tsx @@ -1,5 +1,7 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { useEffect } from 'react'; import { createAskableContext } from '@askable-ui/core'; +import type { AskableContext } from '@askable-ui/core'; import { useAskable } from '../useAskable'; function Consumer({ @@ -161,6 +163,145 @@ describe('useAskable', () => { ctxA.destroy(); ctxB.destroy(); }); + + it('observes the shared global context only once for multiple consumers with the same events', async () => { + let capturedCtx: ReturnType | null = null; + + function CaptureCtx({ label }: { label: string }) { + const { ctx } = useAskable(); + useEffect(() => { + capturedCtx = ctx; + }, [ctx]); + return ready; + } + + const first = render(); + await flushMicrotasks(); + expect(capturedCtx).not.toBeNull(); + + const observeSpy = vi.spyOn(capturedCtx!, 'observe'); + const second = render(); + await flushMicrotasks(); + + expect(observeSpy).not.toHaveBeenCalled(); + + second.unmount(); + first.unmount(); + }); + + it('reuses the same shared context when events rerender with the same logical values', async () => { + const seen: AskableContext[] = []; + + function DynamicCtx({ events }: { events: ('click' | 'focus')[] }) { + const { ctx } = useAskable({ events }); + useEffect(() => { + seen.push(ctx); + }, [ctx]); + return null; + } + + const view = render(); + await flushMicrotasks(); + + view.rerender(); + await flushMicrotasks(); + + expect(seen).toHaveLength(1); + + view.unmount(); + }); + + it('keeps private contexts stable across rerenders with inline creation options', async () => { + const seen: AskableContext[] = []; + + function PrivateCtx() { + const { ctx } = useAskable({ sanitizeText: (text) => text.trim() }); + useEffect(() => { + seen.push(ctx); + }, [ctx]); + return null; + } + + const view = render(); + await flushMicrotasks(); + + view.rerender(); + await flushMicrotasks(); + + expect(seen).toHaveLength(1); + + view.unmount(); + }); + + it('switches to the matching shared context when events change', async () => { + const seen: AskableContext[] = []; + + function DynamicCtx({ events }: { events: ('click' | 'focus')[] }) { + const { ctx } = useAskable({ events }); + useEffect(() => { + seen.push(ctx); + }, [ctx]); + return null; + } + + const view = render(); + await flushMicrotasks(); + + view.rerender(); + await flushMicrotasks(); + + expect(seen).toHaveLength(2); + expect(seen[0]).not.toBe(seen[1]); + + view.unmount(); + }); + + it('isolates differing shared event configurations and preserves the remaining config on unmount', async () => { + function EventConsumer({ + label, + events, + }: { + label: string; + events: ('click' | 'focus')[]; + }) { + const { focus } = useAskable({ events }); + return {focus ? JSON.stringify(focus.meta) : 'null'}; + } + + const clickView = render( + <> + + + + ); + await flushMicrotasks(); + + const focusView = render(); + await flushMicrotasks(); + + act(() => { + fireEvent.click(screen.getByTestId('event-target')); + }); + + await waitFor(() => { + expect(screen.getByTestId('event-click').textContent).toContain('shared-events'); + }); + expect(screen.getByTestId('event-focus').textContent).toBe('null'); + + focusView.unmount(); + + act(() => { + fireEvent.click(screen.getByTestId('event-target')); + }); + + await waitFor(() => { + expect(screen.getByTestId('event-click').textContent).toContain('shared-events'); + }); + + clickView.unmount(); + }); }); function ScopedView({ diff --git a/packages/react/src/useAskable.ts b/packages/react/src/useAskable.ts index b228427..0306382 100644 --- a/packages/react/src/useAskable.ts +++ b/packages/react/src/useAskable.ts @@ -1,20 +1,55 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import { createAskableContext, createAskableInspector } from '@askable-ui/core'; import type { AskableContextOptions, AskableEvent, AskableFocus, AskableContext, AskableInspectorOptions } from '@askable-ui/core'; -let globalCtx: AskableContext | null = null; -let refCount = 0; +const DEFAULT_EVENTS: AskableEvent[] = ['click', 'hover', 'focus']; +const globalCtxByEvents = new Map(); +const globalRefCountByEvents = new Map(); -function getGlobalCtx(): AskableContext { +function normalizeEvents(events?: AskableEvent[]): AskableEvent[] { + const configured = events ?? DEFAULT_EVENTS; + return DEFAULT_EVENTS.filter((event, index) => configured.includes(event) && configured.indexOf(event) === index); +} + +function getEventsKey(events?: AskableEvent[]): string { + return normalizeEvents(events).join('|'); +} + +function getGlobalCtx(events?: AskableEvent[]): AskableContext { // During SSR (no window), never persist to the module-level singleton — // each render gets a fresh throwaway context so requests don't share state. if (typeof window === 'undefined') { return createAskableContext(); } - if (!globalCtx) { - globalCtx = createAskableContext(); + const key = getEventsKey(events); + const existing = globalCtxByEvents.get(key); + if (existing) return existing; + const ctx = createAskableContext(); + globalCtxByEvents.set(key, ctx); + return ctx; +} + +function retainGlobalCtx(ctx: AskableContext, events?: AskableEvent[]): void { + const key = getEventsKey(events); + const nextCount = (globalRefCountByEvents.get(key) ?? 0) + 1; + globalRefCountByEvents.set(key, nextCount); + if (nextCount === 1 && typeof document !== 'undefined') { + ctx.observe(document, { events: normalizeEvents(events) }); + } +} + +function releaseGlobalCtx(events?: AskableEvent[]): void { + const key = getEventsKey(events); + const ctx = globalCtxByEvents.get(key); + if (!ctx) return; + const nextCount = (globalRefCountByEvents.get(key) ?? 0) - 1; + if (nextCount > 0) { + globalRefCountByEvents.set(key, nextCount); + return; } - return globalCtx; + globalRefCountByEvents.delete(key); + globalCtxByEvents.delete(key); + ctx.destroy(); } export interface UseAskableOptions extends AskableContextOptions { @@ -49,20 +84,40 @@ export function useAskable(options?: UseAskableOptions): UseAskableResult { // Use a private context when context-creation options are specified const usePrivateCtx = !usesProvidedCtx && hasContextCreationOptions(options); - const ctx = useRef( - options?.ctx ?? (usePrivateCtx ? createAskableContext(options) : getGlobalCtx()) - ); - const [focus, setFocus] = useState(() => ctx.current.getFocus()); + const eventsKey = getEventsKey(options?.events); + const privateCtxRef = useRef(null); + + const sharedCtx = useMemo(() => { + if (options?.ctx || usePrivateCtx) return null; + return getGlobalCtx(options?.events); + }, [options?.ctx, usePrivateCtx, eventsKey]); + + if (!options?.ctx && usePrivateCtx && !privateCtxRef.current) { + privateCtxRef.current = createAskableContext(options); + } + if (!usePrivateCtx && !options?.ctx) { + privateCtxRef.current = null; + } + + const ctx = options?.ctx ?? privateCtxRef.current ?? sharedCtx!; + const [focus, setFocus] = useState(() => ctx.getFocus()); const inspectorKey = JSON.stringify(options?.inspector ?? false); useEffect(() => { - const current = ctx.current; + setFocus(ctx.getFocus()); + }, [ctx]); + + useEffect(() => { + const current = ctx; if (!usesProvidedCtx) { - if (!usePrivateCtx) refCount++; - if (typeof document !== 'undefined') { - current.observe(document, { events: options?.events }); + if (usePrivateCtx) { + if (typeof document !== 'undefined') { + current.observe(document, { events: options?.events }); + } + } else { + retainGlobalCtx(current, options?.events); } } @@ -84,20 +139,19 @@ export function useAskable(options?: UseAskableOptions): UseAskableResult { if (!usesProvidedCtx) { if (usePrivateCtx) { current.destroy(); - } else { - refCount--; - if (refCount === 0) { - globalCtx?.destroy(); - globalCtx = null; + if (privateCtxRef.current === current) { + privateCtxRef.current = null; } + } else { + releaseGlobalCtx(options?.events); } } }; - }, [options?.events, usesProvidedCtx, usePrivateCtx, inspectorKey]); + }, [ctx, eventsKey, usesProvidedCtx, usePrivateCtx, inspectorKey]); return { focus, - promptContext: ctx.current.toPromptContext(), - ctx: ctx.current, + promptContext: ctx.toPromptContext(), + ctx, }; } diff --git a/packages/vue/README.md b/packages/vue/README.md index 49126f8..86e1605 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -44,7 +44,7 @@ Renders any element (default: `div`) with a `data-askable` attribute. ### `useAskable(options?)` -Returns reactive focus state from the shared global context. +Returns reactive focus state from the shared context for the requested `events` configuration. ```ts const { focus, promptContext, ctx } = useAskable(); @@ -67,6 +67,8 @@ const { focus, promptContext } = useAskable({ events: ['click'] }); - `ctx.toPromptContext(options?)` — full serialization options (format, maxTokens, excludeKeys, …) - `ctx.serializeFocus(options?)` — structured `AskableSerializedFocus` object +The composable manages a shared singleton context per `events` configuration. Multiple `useAskable()` consumers with the same `events` reuse one observer lifecycle, while differing `events` configurations get isolated shared contexts of their own. Each shared context is automatically destroyed when its last consumer unmounts. + ### "Ask AI" button pattern Use `ctx.select()` to set context explicitly when a user clicks a button: diff --git a/packages/vue/src/__tests__/useAskable.test.ts b/packages/vue/src/__tests__/useAskable.test.ts index 5121e1d..b3a9819 100644 --- a/packages/vue/src/__tests__/useAskable.test.ts +++ b/packages/vue/src/__tests__/useAskable.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { mount, flushPromises } from '@vue/test-utils'; import { defineComponent, nextTick } from 'vue'; import { useAskable } from '../useAskable.js'; @@ -138,4 +138,83 @@ describe('useAskable (Vue)', () => { await flushAll(); expect(wrapper2.text()).toBe('null'); }); + + it('observes the shared global context only once for multiple consumers with the same events', async () => { + let capturedCtx: ReturnType | null = null; + + const CaptureConsumer = defineComponent({ + name: 'CaptureConsumer', + setup() { + const { ctx } = useAskable(); + capturedCtx = ctx; + return {}; + }, + template: `ready`, + }); + + const wrapperA = track(mount(CaptureConsumer, { attachTo: document.body })); + await flushAll(); + expect(capturedCtx).not.toBeNull(); + + const observeSpy = vi.spyOn(capturedCtx!, 'observe'); + const wrapperB = track(mount(CaptureConsumer, { attachTo: document.body })); + await flushAll(); + + expect(observeSpy).not.toHaveBeenCalled(); + + wrapperB.unmount(); + wrapperA.unmount(); + }); + + it('isolates differing shared event configurations and preserves the remaining config on unmount', async () => { + const EventConsumer = defineComponent({ + name: 'EventConsumer', + props: { + label: { type: String, required: true }, + events: { type: Array as () => ('click' | 'focus')[], required: true }, + }, + setup(props) { + const { focus } = useAskable({ events: props.events }); + return { focus }; + }, + template: `{{ focus ? JSON.stringify(focus.meta) : 'null' }}`, + }); + + const clickWrapper = track( + mount( + defineComponent({ + components: { EventConsumer }, + template: ` +
+ + +
+ `, + }), + { attachTo: document.body } + ) + ); + await flushAll(); + + const focusWrapper = track(mount(EventConsumer, { + attachTo: document.body, + props: { label: 'focus', events: ['focus'] }, + })); + await flushAll(); + + await clickWrapper.find('[data-testid="event-target"]').trigger('click'); + await nextTick(); + + expect(clickWrapper.find('[data-testid="event-click"]').text()).toContain('shared-events'); + expect(focusWrapper.find('[data-testid="event-focus"]').text()).toBe('null'); + + focusWrapper.unmount(); + + await clickWrapper.find('[data-testid="event-target"]').trigger('click'); + await nextTick(); + + expect(clickWrapper.find('[data-testid="event-click"]').text()).toContain('shared-events'); + + clickWrapper.unmount(); + }); }); diff --git a/packages/vue/src/useAskable.ts b/packages/vue/src/useAskable.ts index c47ad17..5e1cbf1 100644 --- a/packages/vue/src/useAskable.ts +++ b/packages/vue/src/useAskable.ts @@ -2,19 +2,54 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'; import { createAskableContext, createAskableInspector } from '@askable-ui/core'; import type { AskableContextOptions, AskableEvent, AskableFocus, AskableContext, AskableInspectorOptions } from '@askable-ui/core'; -let globalCtx: AskableContext | null = null; -let refCount = 0; +const DEFAULT_EVENTS: AskableEvent[] = ['click', 'hover', 'focus']; +const globalCtxByEvents = new Map(); +const globalRefCountByEvents = new Map(); -function getGlobalCtx(): AskableContext { +function normalizeEvents(events?: AskableEvent[]): AskableEvent[] { + const configured = events ?? DEFAULT_EVENTS; + return DEFAULT_EVENTS.filter((event, index) => configured.includes(event) && configured.indexOf(event) === index); +} + +function getEventsKey(events?: AskableEvent[]): string { + return normalizeEvents(events).join('|'); +} + +function getGlobalCtx(events?: AskableEvent[]): AskableContext { // During SSR (no window), never persist to the module-level singleton — // each render gets a fresh throwaway context so requests don't share state. if (typeof window === 'undefined') { return createAskableContext(); } - if (!globalCtx) { - globalCtx = createAskableContext(); + const key = getEventsKey(events); + const existing = globalCtxByEvents.get(key); + if (existing) return existing; + const ctx = createAskableContext(); + globalCtxByEvents.set(key, ctx); + return ctx; +} + +function retainGlobalCtx(ctx: AskableContext, events?: AskableEvent[]): void { + const key = getEventsKey(events); + const nextCount = (globalRefCountByEvents.get(key) ?? 0) + 1; + globalRefCountByEvents.set(key, nextCount); + if (nextCount === 1 && typeof document !== 'undefined') { + ctx.observe(document, { events: normalizeEvents(events) }); } - return globalCtx; +} + +function releaseGlobalCtx(events?: AskableEvent[]): void { + const key = getEventsKey(events); + const ctx = globalCtxByEvents.get(key); + if (!ctx) return; + const nextCount = (globalRefCountByEvents.get(key) ?? 0) - 1; + if (nextCount > 0) { + globalRefCountByEvents.set(key, nextCount); + return; + } + globalRefCountByEvents.delete(key); + globalCtxByEvents.delete(key); + ctx.destroy(); } export interface UseAskableOptions extends AskableContextOptions { @@ -49,7 +84,7 @@ export function useAskable(options?: UseAskableOptions) { // Use a private context when context-creation options are specified const usePrivateCtx = !usesProvidedCtx && hasContextCreationOptions(options); - const ctx = options?.ctx ?? (usePrivateCtx ? createAskableContext(options) : getGlobalCtx()); + const ctx = options?.ctx ?? (usePrivateCtx ? createAskableContext(options) : getGlobalCtx(options?.events)); const focus = ref(ctx.getFocus()); // Reference focus.value so Vue tracks it as a reactive dependency; // ctx.toPromptContext() is a plain method and not itself reactive. @@ -69,9 +104,12 @@ export function useAskable(options?: UseAskableOptions) { onMounted(() => { if (!usesProvidedCtx) { - if (!usePrivateCtx) refCount++; - if (typeof document !== 'undefined') { - ctx.observe(document, { events: options?.events }); + if (usePrivateCtx) { + if (typeof document !== 'undefined') { + ctx.observe(document, { events: options?.events }); + } + } else { + retainGlobalCtx(ctx, options?.events); } } ctx.on('focus', handler); @@ -91,11 +129,7 @@ export function useAskable(options?: UseAskableOptions) { if (usePrivateCtx) { ctx.destroy(); } else { - refCount--; - if (refCount === 0) { - globalCtx?.destroy(); - globalCtx = null; - } + releaseGlobalCtx(options?.events); } } }); diff --git a/site/docs/api/react.md b/site/docs/api/react.md index 1e25981..8722472 100644 --- a/site/docs/api/react.md +++ b/site/docs/api/react.md @@ -39,7 +39,7 @@ import { Askable } from '@askable-ui/react'; ## `useAskable(options?)` -Hook that provides reactive access to the shared global `AskableContext`. Observation starts after mount; context is destroyed when the last consumer unmounts. +Hook that provides reactive access to a shared `AskableContext` for the requested `events` configuration. Observation starts after mount; additional consumers with the same `events` reuse the existing observer instead of re-observing the document. Differing `events` configurations get isolated shared contexts, each destroyed when its last consumer unmounts. ```ts import { useAskable } from '@askable-ui/react'; diff --git a/site/docs/api/vue.md b/site/docs/api/vue.md index 7c3f678..5d0ed55 100644 --- a/site/docs/api/vue.md +++ b/site/docs/api/vue.md @@ -35,7 +35,7 @@ Renders a wrapper element with `data-askable` managed reactively from `:meta`. ## `useAskable(options?)` -Composable that provides reactive access to the shared global `AskableContext`. Observation starts in `onMounted()`; context is destroyed when the last consumer unmounts. +Composable that provides reactive access to a shared `AskableContext` for the requested `events` configuration. Observation starts in `onMounted()`; additional consumers with the same `events` reuse the existing observer instead of re-observing the document. Differing `events` configurations get isolated shared contexts, each destroyed when its last consumer unmounts. ```ts import { useAskable } from '@askable-ui/vue'; diff --git a/site/docs/guide/react.md b/site/docs/guide/react.md index 7c07461..e5c81fd 100644 --- a/site/docs/guide/react.md +++ b/site/docs/guide/react.md @@ -69,7 +69,7 @@ const ref = useRef(null); ## `useAskable(options?)` -Hook that connects to the shared global context. Observation starts after mount and stops when the last consumer unmounts. +Hook that connects to a shared context for the requested `events` configuration. Observation starts after mount, consumers with the same `events` reuse one observer lifecycle, and each shared context stops when its last consumer unmounts. ```ts const { focus, promptContext, ctx } = useAskable(); @@ -85,7 +85,7 @@ const { focus } = useAskable({ sanitizeMeta: ({ secret, ...rest }) => rest }); const { focus } = useAskable({ ctx: myCtx }); ``` -When any `AskableContextOptions` are provided (`maxHistory`, `sanitizeMeta`, `sanitizeText`, `textExtractor`), a private context is created for that component instead of sharing the global singleton. +When any `AskableContextOptions` are provided (`maxHistory`, `sanitizeMeta`, `sanitizeText`, `textExtractor`), a private context is created for that component instead of sharing the per-`events` context. **Options:** | Option | Type | Description |