Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
141 changes: 141 additions & 0 deletions packages/react/src/__tests__/useAskable.test.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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<typeof createAskableContext> | null = null;

function CaptureCtx({ label }: { label: string }) {
const { ctx } = useAskable();
useEffect(() => {
capturedCtx = ctx;
}, [ctx]);
return <span data-testid={`capture-${label}`}>ready</span>;
}

const first = render(<CaptureCtx label="one" />);
await flushMicrotasks();
expect(capturedCtx).not.toBeNull();

const observeSpy = vi.spyOn(capturedCtx!, 'observe');
const second = render(<CaptureCtx label="two" />);
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(<DynamicCtx events={['click']} />);
await flushMicrotasks();

view.rerender(<DynamicCtx events={['click']} />);
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(<PrivateCtx />);
await flushMicrotasks();

view.rerender(<PrivateCtx />);
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(<DynamicCtx events={['click']} />);
await flushMicrotasks();

view.rerender(<DynamicCtx events={['focus']} />);
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 <span data-testid={`event-${label}`}>{focus ? JSON.stringify(focus.meta) : 'null'}</span>;
}

const clickView = render(
<>
<button data-testid="event-target" data-askable='{"widget":"shared-events"}'>
Shared events
</button>
<EventConsumer label="click" events={['click']} />
</>
);
await flushMicrotasks();

const focusView = render(<EventConsumer label="focus" events={['focus']} />);
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({
Expand Down
100 changes: 77 additions & 23 deletions packages/react/src/useAskable.ts
Original file line number Diff line number Diff line change
@@ -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<string, AskableContext>();
const globalRefCountByEvents = new Map<string, number>();

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 {
Expand Down Expand Up @@ -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<AskableContext>(
options?.ctx ?? (usePrivateCtx ? createAskableContext(options) : getGlobalCtx())
);
const [focus, setFocus] = useState<AskableFocus | null>(() => ctx.current.getFocus());
const eventsKey = getEventsKey(options?.events);
const privateCtxRef = useRef<AskableContext | null>(null);

const sharedCtx = useMemo<AskableContext | null>(() => {
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<AskableFocus | null>(() => 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);
}
}

Expand All @@ -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,
};
}
4 changes: 3 additions & 1 deletion packages/vue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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:
Expand Down
Loading
Loading