Skip to content

Commit dae0e0e

Browse files
committed
react: update hooks to support context-based transport fallback
- Updated hooks (`useView`, `useTree`, `useCreateView`, `useAblyMessages`, `useActiveTurns`, etc.) to use nearest transport for transport fallback when no explicit transport is provided. - Modified `TransportProvider` - Adjusted all affected hooks and types to align with the updated transport resolution mechanism via context.
1 parent 2d47e48 commit dae0e0e

21 files changed

+581
-242
lines changed

README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,19 @@ export async function POST(req: Request) {
144144

145145
import { useChat } from '@ai-sdk/react';
146146
import type * as AI from 'ai';
147-
import { TransportProvider, useClientTransport, useActiveTurns, useView } from '@ably/ai-transport/react';
147+
import { createTransportHooks } from '@ably/ai-transport/react';
148148
import { useChatTransport, useMessageSync } from '@ably/ai-transport/vercel/react';
149149
import { UIMessageCodec } from '@ably/ai-transport/vercel';
150150

151+
const {
152+
TransportProvider,
153+
useClientTransport,
154+
useActiveTurns,
155+
useView
156+
} = createTransportHooks<AI.UIMessageChunk, AI.UIMessage>();
157+
151158
function ChatInner({ chatId }: { chatId: string }) {
152-
const transport = useClientTransport<AI.UIMessageChunk, AI.UIMessage>();
159+
const transport = useClientTransport();
153160
const chatTransport = useChatTransport(transport);
154161

155162
const { messages, setMessages, sendMessage, stop } = useChat({
@@ -159,8 +166,8 @@ function ChatInner({ chatId }: { chatId: string }) {
159166

160167
useMessageSync(transport, setMessages);
161168

162-
const activeTurns = useActiveTurns(transport);
163-
useView(transport, { limit: 30 });
169+
const activeTurns = useActiveTurns();
170+
useView({ limit: 30 });
164171

165172
return (
166173
<div>

demo/vercel/react/use-chat/src/app/chat.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { useClientTools } from './hooks/use-client-tools';
1515

1616
export function Chat({ chatId, clientId, historyLimit }: { chatId: string; clientId?: string; historyLimit?: number }) {
1717
// Transport is created by TransportProvider in page.tsx
18-
const transport = useClientTransport<AI.UIMessageChunk, AI.UIMessage>(chatId);
18+
const transport = useClientTransport<AI.UIMessageChunk, AI.UIMessage>({ channelName: chatId });
1919
const chatTransport = useChatTransport(transport);
2020

2121
const {
@@ -33,15 +33,15 @@ export function Chat({ chatId, clientId, historyLimit }: { chatId: string; clien
3333

3434
useMessageSync(transport, setMessages);
3535

36-
const activeTurns = useActiveTurns(transport);
36+
const activeTurns = useActiveTurns({ transport });
3737
const hasAnyTurns = activeTurns.size > 0;
3838

39-
// Auto-loads first page on mount (options provided = enabled)
40-
const { nodes, hasOlder, loading, loadOlder } = useView(transport, { limit: historyLimit ?? 30 });
39+
// Auto-loads first page on mount
40+
const { nodes, hasOlder, loading, loadOlder } = useView({ transport, limit: historyLimit ?? 30 });
4141

4242
useClientTools(chatMessages, addToolResult, nodes, clientId);
4343

44-
const ablyMessages = useAblyMessages(transport);
44+
const ablyMessages = useAblyMessages({ transport });
4545

4646
return (
4747
<div className="flex h-dvh">

demo/vercel/react/use-client-transport/src/app/components/chat.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,17 @@ interface ChatProps {
3030

3131
export function Chat({ chatId, clientId, historyLimit }: ChatProps) {
3232
// Transport is created by TransportProvider in page.tsx
33-
const transport = useClientTransport<AI.UIMessageChunk, AI.UIMessage>(chatId);
33+
const transport = useClientTransport<AI.UIMessageChunk, AI.UIMessage>({ channelName: chatId });
3434
const [split, setSplit] = useState(false);
3535

3636
const limit = historyLimit ?? 30;
37-
const view = useView(transport, { limit });
38-
const splitView = useCreateView(split ? transport : undefined, { limit });
37+
const view = useView({ transport, limit });
38+
const splitView = useCreateView({ transport, limit, skip: !split });
3939

4040
useClientTools(view, clientId);
4141

42-
const activeTurns = useActiveTurns(transport);
43-
const ablyMessages = useAblyMessages(transport);
42+
const activeTurns = useActiveTurns({ transport });
43+
const ablyMessages = useAblyMessages({ transport });
4444
const queue = useMessageQueue(transport, view.send);
4545

4646
const handleToolApproved = useCallback(

src/react/contexts/transport-context.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,11 @@ export type TransportContextValue = Readonly<Record<string, ClientTransport<unkn
1010
* Populated by {@link TransportProvider}; read by {@link useClientTransport}.
1111
*/
1212
export const TransportContext = createContext<TransportContextValue>({});
13+
14+
/**
15+
* Context that holds the nearest (innermost) registered {@link ClientTransport}.
16+
* Each {@link TransportProvider} sets this to its own transport, so descendants
17+
* can access the nearest transport without knowing its channel name.
18+
* Read by hooks whose `transport` argument is omitted.
19+
*/
20+
export const NearestTransportContext = createContext<ClientTransport<unknown, unknown> | undefined>(undefined);

src/react/contexts/transport-provider.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { type PropsWithChildren, type ReactNode, useContext, useEffect, useMemo,
2424

2525
import { createClientTransport } from '../../core/transport/client-transport.js';
2626
import type { ClientTransport, ClientTransportOptions } from '../../core/transport/types.js';
27-
import { TransportContext } from '../contexts/transport-context.js';
27+
import { NearestTransportContext, TransportContext } from '../contexts/transport-context.js';
2828

2929
/**
3030
* Props for {@link TransportProvider}.
@@ -81,7 +81,13 @@ const TransportProviderInner = <TEvent, TMessage>({
8181
[],
8282
);
8383

84-
return <TransportContext.Provider value={contextValue}>{children}</TransportContext.Provider>;
84+
return (
85+
<TransportContext.Provider value={contextValue}>
86+
<NearestTransportContext.Provider value={transportRef.current as ClientTransport<unknown, unknown>}>
87+
{children}
88+
</NearestTransportContext.Provider>
89+
</TransportContext.Provider>
90+
);
8591
};
8692

8793
/**

src/react/create-transport-hooks.ts

Lines changed: 64 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
* TransportProvider,
99
* useClientTransport,
1010
* useView,
11-
* useSend,
1211
* useActiveTurns,
1312
* } = createTransportHooks<UIMessageChunk, UIMessage>();
1413
*
@@ -17,27 +16,24 @@
1716
* <Chat />
1817
* </TransportProvider>
1918
*
20-
* // In Chat — no type params needed:
21-
* const transport = useClientTransport('ai:demo');
22-
* const { nodes } = useView(transport, { limit: 30 });
23-
* const send = useSend(transport);
19+
* // In Chat — no type params needed, transport is implicit from nearest provider:
20+
* const { nodes } = useView({ limit: 30 });
21+
* const turns = useActiveTurns();
2422
*/
2523

2624
import type * as Ably from 'ably';
2725
import type { ComponentType } from 'react';
2826

29-
import type { ActiveTurn, ClientTransport, SendOptions } from '../core/transport/types.js';
27+
import type { ClientTransport, View } from '../core/transport/types.js';
3028
import type { TransportProviderProps } from './contexts/transport-provider.js';
3129
import { TransportProvider as _TransportProvider } from './contexts/transport-provider.js';
3230
import { useAblyMessages as _useAblyMessages } from './use-ably-messages.js';
3331
import { useActiveTurns as _useActiveTurns } from './use-active-turns.js';
3432
import { useClientTransport as _useClientTransport } from './use-client-transport.js';
35-
import { useEdit as _useEdit } from './use-edit.js';
36-
import { useRegenerate as _useRegenerate } from './use-regenerate.js';
37-
import { useSend as _useSend } from './use-send.js';
33+
import { useCreateView as _useCreateView } from './use-create-view.js';
3834
import type { TreeHandle } from './use-tree.js';
3935
import { useTree as _useTree } from './use-tree.js';
40-
import type { ViewHandle, ViewOptions } from './use-view.js';
36+
import type { ViewHandle } from './use-view.js';
4137
import { useView as _useView } from './use-view.js';
4238

4339
/**
@@ -53,58 +49,71 @@ export interface TransportHooks<TEvent, TMessage> {
5349
TransportProvider: ComponentType<TransportProviderProps<TEvent, TMessage>>;
5450
/**
5551
* Read the transport from context. No type params needed.
56-
* @param channelName - The channel name passed to the enclosing `TransportProvider`.
57-
* @throws {Ably.ErrorInfo} if no `TransportProvider` with the given `channelName` is in the tree.
52+
* Omit `channelName` to use the nearest provider. Pass `skip: true` to return a stub
53+
* that throws on any access — safe to hold before conditions are ready.
54+
* @throws {Ably.ErrorInfo} if `skip` is falsy and no matching provider is found.
5855
*/
59-
useClientTransport: (channelName: string) => ClientTransport<TEvent, TMessage>;
56+
useClientTransport: (props?: {
57+
/** Channel name to look up; omit to use the nearest {@link TransportProvider}. */
58+
channelName?: string;
59+
/** When `true`, return a stub transport that throws on any access. */
60+
skip?: boolean;
61+
}) => ClientTransport<TEvent, TMessage>;
6062
/**
61-
* Subscribe to the transport's view and return the visible node list with pagination.
62-
* @param transport - The transport to read from.
63-
* @param options - When provided, auto-loads the first page on mount.
63+
* Subscribe to the nearest transport's view and return the visible node list with pagination.
64+
* Pass `transport` to use a transport's default view, `view` to subscribe to a specific view
65+
* directly. Pass `limit` to auto-load on mount. Pass `skip: true` for an empty handle.
6466
*/
65-
useView: (
66-
transport: ClientTransport<TEvent, TMessage> | null | undefined,
67-
options?: ViewOptions | null,
68-
) => ViewHandle<TMessage>;
69-
/**
70-
* Return a stable `send` callback.
71-
* The returned function sends messages and returns an {@link ActiveTurn} handle.
72-
* @param transport - The transport to send through.
73-
*/
74-
useSend: (
75-
transport: ClientTransport<TEvent, TMessage>,
76-
) => (messages: TMessage[], options?: SendOptions) => Promise<ActiveTurn<TEvent>>;
67+
useView: (props?: {
68+
/** Client transport whose default view to subscribe to; defaults to the nearest {@link TransportProvider}. */
69+
transport?: ClientTransport<TEvent, TMessage> | null;
70+
/** A specific {@link View} to subscribe to directly. Takes priority over `transport`. */
71+
view?: View<TEvent, TMessage> | null;
72+
/** When provided, auto-loads the first page on mount. */
73+
limit?: number;
74+
/** When `true`, skip all subscriptions and return an empty handle. */
75+
skip?: boolean;
76+
}) => ViewHandle<TEvent, TMessage>;
7777
/**
7878
* Track active turns across all clients on the channel.
79-
* @param transport - The transport to observe.
79+
* Pass `transport` to override; defaults to the nearest {@link TransportProvider}.
8080
*/
81-
useActiveTurns: (transport: ClientTransport<TEvent, TMessage> | null | undefined) => Map<string, Set<string>>;
81+
useActiveTurns: (props?: {
82+
/** Override transport; defaults to the nearest {@link TransportProvider}. */
83+
transport?: ClientTransport<TEvent, TMessage> | null;
84+
}) => Map<string, Set<string>>;
8285
/**
8386
* Navigate conversation branches in the transport tree.
84-
* @param transport - The transport to read from.
85-
*/
86-
useTree: (transport: ClientTransport<TEvent, TMessage>) => TreeHandle<TMessage>;
87-
/**
88-
* Return a stable `regenerate` callback.
89-
* The returned function regenerates the given message and returns an {@link ActiveTurn} handle.
90-
* @param transport - The transport to send through.
87+
* Pass `transport` to override; defaults to the nearest {@link TransportProvider}.
9188
*/
92-
useRegenerate: (
93-
transport: ClientTransport<TEvent, TMessage>,
94-
) => (messageId: string, options?: SendOptions) => Promise<ActiveTurn<TEvent>>;
89+
useTree: (props?: {
90+
/** Override transport; defaults to the nearest {@link TransportProvider}. */
91+
transport?: ClientTransport<TEvent, TMessage>;
92+
}) => TreeHandle<TMessage>;
9593
/**
96-
* Return a stable `edit` callback.
97-
* The returned function edits the given message and returns an {@link ActiveTurn} handle.
98-
* @param transport - The transport to send through.
94+
* Subscribe to raw Ably messages on the transport channel.
95+
* Pass `transport` to override; defaults to the nearest {@link TransportProvider}.
96+
* Pass `skip: true` to return an empty array without subscribing.
9997
*/
100-
useEdit: (
101-
transport: ClientTransport<TEvent, TMessage>,
102-
) => (messageId: string, newMessages: TMessage | TMessage[], options?: SendOptions) => Promise<ActiveTurn<TEvent>>;
98+
useAblyMessages: (props?: {
99+
/** Override transport; defaults to the nearest {@link TransportProvider}. */
100+
transport?: ClientTransport<TEvent, TMessage>;
101+
/** When `true`, skip all subscriptions and return an empty array. */
102+
skip?: boolean;
103+
}) => Ably.InboundMessage[];
103104
/**
104-
* Subscribe to raw Ably messages on the transport channel.
105-
* @param transport - The transport to observe.
105+
* Create an independent view over the same tree.
106+
* Pass `transport` to override; defaults to the nearest {@link TransportProvider}.
107+
* Pass `skip: true` to return an empty handle without creating a view.
106108
*/
107-
useAblyMessages: (transport: ClientTransport<TEvent, TMessage>) => Ably.InboundMessage[];
109+
useCreateView: (props?: {
110+
/** Override transport; defaults to the nearest {@link TransportProvider}. */
111+
transport?: ClientTransport<TEvent, TMessage> | null;
112+
/** When provided, auto-loads the first page on mount. */
113+
limit?: number;
114+
/** When `true`, skip view creation and return an empty handle. */
115+
skip?: boolean;
116+
}) => ViewHandle<TEvent, TMessage>;
108117
}
109118

110119
/**
@@ -118,12 +127,10 @@ export interface TransportHooks<TEvent, TMessage> {
118127
export const createTransportHooks = <TEvent, TMessage>(): TransportHooks<TEvent, TMessage> => ({
119128
// CAST: TransportProvider is generic; factory narrows it to TEvent/TMessage.
120129
TransportProvider: _TransportProvider as ComponentType<TransportProviderProps<TEvent, TMessage>>,
121-
useClientTransport: (channelName: string) => _useClientTransport<TEvent, TMessage>(channelName),
122-
useView: (transport, options) => _useView(transport, options),
123-
useSend: (transport) => _useSend(transport),
124-
useActiveTurns: (transport) => _useActiveTurns(transport),
125-
useTree: (transport) => _useTree(transport),
126-
useRegenerate: (transport) => _useRegenerate(transport),
127-
useEdit: (transport) => _useEdit(transport),
128-
useAblyMessages: (transport) => _useAblyMessages(transport),
130+
useClientTransport: (props) => _useClientTransport<TEvent, TMessage>(props ?? {}),
131+
useView: (props) => _useView<TEvent, TMessage>(props ?? {}),
132+
useActiveTurns: (props) => _useActiveTurns<TEvent, TMessage>(props ?? {}),
133+
useTree: (props) => _useTree<TEvent, TMessage>(props ?? {}),
134+
useAblyMessages: (props) => _useAblyMessages<TEvent, TMessage>(props ?? {}),
135+
useCreateView: (props) => _useCreateView<TEvent, TMessage>(props ?? {}),
129136
});

src/react/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
export type { EventsNode, MessageNode } from '../core/transport/types.js';
2+
export { NearestTransportContext } from './contexts/transport-context.js';
13
export type { TransportProviderProps } from './contexts/transport-provider.js';
24
export { TransportProvider } from './contexts/transport-provider.js';
35
export type { TransportHooks } from './create-transport-hooks.js';
46
export { createTransportHooks } from './create-transport-hooks.js';
5-
export type { EventsNode, MessageNode } from '../core/transport/types.js';
67
// eslint-disable-next-line @typescript-eslint/no-deprecated -- intentional re-export for backwards compatibility
78
export type { EventNode, TreeNode } from '../core/transport/types.js';
89
export { useAblyMessages } from './use-ably-messages.js';

src/react/use-ably-messages.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,36 @@
33
*
44
* Accumulates raw Ably InboundMessages from the transport's tree
55
* 'ably-message' event. Messages are appended in arrival order.
6+
*
7+
* When `transport` is omitted, defaults to the nearest
8+
* {@link TransportProvider}'s transport via context.
9+
* Pass `skip: true` to bypass all subscriptions and return an empty array.
610
*/
711

812
import type * as Ably from 'ably';
9-
import { useEffect, useRef, useState } from 'react';
13+
import { useContext, useEffect, useRef, useState } from 'react';
1014

1115
import type { ClientTransport } from '../core/transport/types.js';
16+
import { NearestTransportContext } from './contexts/transport-context.js';
1217

1318
/**
1419
* Subscribe to raw Ably message updates from a client transport's tree.
15-
* @param transport - The client transport to observe.
20+
* When `transport` is omitted, uses the nearest {@link TransportProvider}'s transport via context.
21+
* @param props - Options including optional `transport` and `skip`.
22+
* @param props.transport - Transport to subscribe to; defaults to the nearest provider.
23+
* @param props.skip - When `true`, skip all subscriptions and return an empty array.
1624
* @returns The accumulated raw Ably messages in chronological order.
1725
*/
18-
export const useAblyMessages = <TEvent, TMessage>(
19-
transport: ClientTransport<TEvent, TMessage>,
20-
): Ably.InboundMessage[] => {
26+
export const useAblyMessages = <TEvent, TMessage>({
27+
transport,
28+
skip,
29+
}: { transport?: ClientTransport<TEvent, TMessage>; skip?: boolean } = {}): Ably.InboundMessage[] => {
30+
const nearestTransport = useContext(NearestTransportContext);
31+
// CAST: NearestTransportContext stores transport with erased generics; types fixed at call site.
32+
const resolved = skip
33+
? undefined
34+
: ((transport ?? nearestTransport) as ClientTransport<TEvent, TMessage> | undefined);
35+
2136
const [messages, setMessages] = useState<Ably.InboundMessage[]>([]);
2237
const messagesRef = useRef<Ably.InboundMessage[]>([]);
2338

@@ -26,13 +41,15 @@ export const useAblyMessages = <TEvent, TMessage>(
2641
messagesRef.current = [];
2742
setMessages([]);
2843

29-
const unsub = transport.tree.on('ably-message', (msg: Ably.InboundMessage) => {
44+
if (!resolved) return;
45+
46+
const unsub = resolved.tree.on('ably-message', (msg: Ably.InboundMessage) => {
3047
const next = [...messagesRef.current, msg];
3148
messagesRef.current = next;
3249
setMessages(next);
3350
});
3451
return unsub;
35-
}, [transport]);
52+
}, [resolved]);
3653

3754
return messages;
3855
};

0 commit comments

Comments
 (0)