Skip to content

Commit d99fd22

Browse files
committed
react: add error states and onError callbacks to hooks
useClientTransport now returns { transport, transportError } instead of throwing when no provider is found or when createClientTransport throws. A new onError option subscribes to post-construction transport errors via transport.on('error', ...) with automatic cleanup on unmount. useView adds an error field to ViewHandle set when loadOlder fails and cleared on the next successful load. useSend switches to an options-based API ({ view?, onError? }) with context-fallback view resolution and an onError callback that fires before re-throwing. TransportSlot replaces the raw transport in both TransportContext and NearestTransportContext, holding { transport, error }. TransportProvider wraps createClientTransport in try/catch so construction errors surface as transportError in useClientTransport rather than crashing the tree. Tests cover missing provider, construction failure, onError subscription and cleanup, loadOlder error/clear cycle, and useSend context fallback.
1 parent 2740a29 commit d99fd22

22 files changed

+571
-160
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const { useClientTransport, useActiveTurns, useView, useAblyMessages } = Transpo
1717

1818
export function Chat({ chatId, clientId, historyLimit }: { chatId: string; clientId?: string; historyLimit?: number }) {
1919
// Transport is created by TransportProvider in page.tsx
20-
const transport = useClientTransport();
20+
const { transport } = useClientTransport();
2121
const chatTransport = useChatTransport(transport);
2222

2323
// -- Callback & status logging for debug pane ----------------------------

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ interface ChatProps {
3131

3232
export function Chat({ clientId, historyLimit }: ChatProps) {
3333
// Transport is created by TransportProvider in page.tsx
34-
const transport = useClientTransport();
34+
const { transport } = useClientTransport();
3535
const [split, setSplit] = useState(false);
3636

3737
const limit = historyLimit ?? 30;
Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,37 @@
1+
import type * as Ably from 'ably';
12
import { createContext } from 'react';
23

34
import type { ClientTransport } from '../../core/transport/types.js';
45

5-
/** The shape of the TransportContext value — a record of channelName → transport. */
6-
export type TransportContextValue = Readonly<Record<string, ClientTransport<unknown, unknown>>>;
6+
/**
7+
* A single entry in the transport registry, holding the transport and any
8+
* error that occurred during its construction.
9+
*
10+
* `transport` is `undefined` when construction failed.
11+
* `error` is set when `createClientTransport` threw during provider render.
12+
*/
13+
export interface TransportSlot {
14+
/** The constructed transport, or `undefined` if construction failed. */
15+
transport: ClientTransport<unknown, unknown> | undefined;
16+
/** Construction error from `createClientTransport`, or `undefined` on success. */
17+
error: Ably.ErrorInfo | undefined;
18+
}
19+
20+
/** The shape of the TransportContext value — a record of channelName → slot. */
21+
export type TransportContextValue = Readonly<Record<string, TransportSlot>>;
722

823
/**
9-
* Context that holds the registered {@link ClientTransport} instances, keyed by channelName.
24+
* Context that holds the registered {@link ClientTransport} slots, keyed by channelName.
25+
* Each slot contains the transport (or `undefined` on construction failure) and any error.
1026
* Populated by {@link TransportProvider}; read by {@link useClientTransport}.
1127
*/
1228
export const TransportContext = createContext<TransportContextValue>({});
1329

1430
/**
15-
* Context that holds the nearest (innermost) registered {@link ClientTransport}.
16-
* Each {@link TransportProvider} sets this to its own transport, so descendants
31+
* Context that holds the nearest (innermost) transport slot.
32+
* Each {@link TransportProvider} sets this to its own slot, so descendants
1733
* can access the nearest transport without knowing its channel name.
34+
* `undefined` when no provider is present.
1835
* Read by hooks whose `transport` argument is omitted.
1936
*/
20-
export const NearestTransportContext = createContext<ClientTransport<unknown, unknown> | undefined>(undefined);
37+
export const NearestTransportContext = createContext<TransportSlot | undefined>(undefined);

src/react/contexts/transport-provider.tsx

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,27 @@
77
* to get the stable channel reference and creates the transport once on
88
* first render (via useRef).
99
*
10+
* If createClientTransport throws, the error is stored in the TransportSlot
11+
* (alongside an undefined transport) so that useClientTransport can surface it
12+
* as transportError without crashing the component tree.
13+
*
1014
* The transport is closed when the provider truly unmounts. The close is
1115
* scheduled as a microtask so that React Strict Mode's synchronous
1216
* remount cycle (mount → fake-unmount → remount) can cancel it before it
1317
* fires, avoiding unnecessary transport teardown in development.
1418
*
1519
* Multiple TransportProviders can be nested using distinct channelNames.
16-
* Each provider merges its transport into the parent record, so descendants
20+
* Each provider merges its slot into the parent record so descendants
1721
* can access all registered transports via useClientTransport(channelName).
1822
*/
1923

24+
import * as Ably from 'ably';
2025
import { ChannelProvider, useChannel } from 'ably/react';
2126
import { type PropsWithChildren, type ReactNode, useContext, useEffect, useMemo, useRef } from 'react';
2227

2328
import { createClientTransport } from '../../core/transport/client-transport.js';
2429
import type { ClientTransport, ClientTransportOptions } from '../../core/transport/types.js';
30+
import type { TransportSlot } from '../contexts/transport-context.js';
2531
import { NearestTransportContext, TransportContext } from '../contexts/transport-context.js';
2632

2733
/**
@@ -47,25 +53,35 @@ const TransportProviderInner = <TEvent, TMessage>({
4753
const transportChannelRef = useRef<string>(channelName);
4854
const transportsToDisposeRef = useRef<ClientTransport<unknown, unknown>[]>([]);
4955
const pendingCloseRef = useRef(false);
56+
const constructionErrorRef = useRef<Ably.ErrorInfo | undefined>(undefined);
5057

5158
if (!transportRef.current || transportChannelRef.current !== channelName) {
5259
transportChannelRef.current = channelName;
5360
if (transportRef.current) transportsToDisposeRef.current.push(transportRef.current);
54-
transportRef.current = createClientTransport({ ...transportOptions, channel });
61+
try {
62+
transportRef.current = createClientTransport({ ...transportOptions, channel });
63+
constructionErrorRef.current = undefined;
64+
} catch (error) {
65+
transportRef.current = undefined;
66+
constructionErrorRef.current = error as Ably.ErrorInfo;
67+
}
5568
}
5669

5770
const parentMap = useContext(TransportContext);
5871

59-
const contextValue = useMemo(
60-
() => ({
61-
...parentMap,
62-
// CAST: TransportContext stores transports with erased generics.
63-
// The generic types are fixed at the TransportProvider<TEvent, TMessage> boundary.
64-
[channelName]: transportRef.current as ClientTransport<unknown, unknown>,
65-
}),
66-
[channelName, parentMap],
72+
// Capture ref values as locals so useMemo deps track changes correctly.
73+
// CAST: TransportContext stores transports with erased generics.
74+
// The generic types are fixed at the TransportProvider<TEvent, TMessage> boundary.
75+
const currentTransport = transportRef.current as ClientTransport<unknown, unknown> | undefined;
76+
const currentError = constructionErrorRef.current;
77+
78+
const slot = useMemo<TransportSlot>(
79+
() => ({ transport: currentTransport, error: currentError }),
80+
[currentTransport, currentError],
6781
);
6882

83+
const contextValue = useMemo(() => ({ ...parentMap, [channelName]: slot }), [channelName, parentMap, slot]);
84+
6985
useEffect(
7086
() => () => {
7187
for (const transport of transportsToDisposeRef.current) void transport.close();
@@ -93,9 +109,7 @@ const TransportProviderInner = <TEvent, TMessage>({
93109

94110
return (
95111
<TransportContext.Provider value={contextValue}>
96-
<NearestTransportContext.Provider value={transportRef.current as ClientTransport<unknown, unknown>}>
97-
{children}
98-
</NearestTransportContext.Provider>
112+
<NearestTransportContext.Provider value={slot}>{children}</NearestTransportContext.Provider>
99113
</TransportContext.Provider>
100114
);
101115
};
@@ -108,13 +122,17 @@ const TransportProviderInner = <TEvent, TMessage>({
108122
* in `TransportContext` under `channelName`. Descendants call
109123
* {@link useClientTransport} with the same `channelName` to access the transport.
110124
*
125+
* If `createClientTransport` throws during construction, the error is surfaced
126+
* through `useClientTransport` as `transportError` — the component tree does not
127+
* crash and children are still rendered.
128+
*
111129
* ```tsx
112130
* <TransportProvider channelName="ai:demo" codec={UIMessageCodec}>
113131
* <Chat />
114132
* </TransportProvider>
115133
*
116134
* // Inside Chat:
117-
* const transport = useClientTransport({ channelName: 'ai:demo' });
135+
* const { transport, transportError } = useClientTransport({ channelName: 'ai:demo' });
118136
* ```
119137
*
120138
* For multiple transports, nest providers with distinct channelNames:
@@ -127,8 +145,8 @@ const TransportProviderInner = <TEvent, TMessage>({
127145
* </TransportProvider>
128146
*
129147
* // Inside App:
130-
* const main = useClientTransport({ channelName: 'ai:main' });
131-
* const aux = useClientTransport({ channelName: 'ai:aux' });
148+
* const { transport: main } = useClientTransport({ channelName: 'ai:main' });
149+
* const { transport: aux } = useClientTransport({ channelName: 'ai:aux' });
132150
* ```
133151
* @param props - Provider configuration including `channelName`, `codec`, and all other {@link ClientTransportOptions}.
134152
* @returns A React element wrapping children with ChannelProvider and TransportContext.

src/react/create-transport-hooks.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type { TransportProviderProps } from './contexts/transport-provider.js';
2929
import { TransportProvider as _TransportProvider } from './contexts/transport-provider.js';
3030
import { useAblyMessages as _useAblyMessages } from './use-ably-messages.js';
3131
import { useActiveTurns as _useActiveTurns } from './use-active-turns.js';
32+
import type { ClientTransportHandle } from './use-client-transport.js';
3233
import { useClientTransport as _useClientTransport } from './use-client-transport.js';
3334
import { useCreateView as _useCreateView } from './use-create-view.js';
3435
import type { TreeHandle } from './use-tree.js';
@@ -49,16 +50,23 @@ export interface TransportHooks<TEvent, TMessage> {
4950
TransportProvider: ComponentType<TransportProviderProps<TEvent, TMessage>>;
5051
/**
5152
* Read the transport from context. No type params needed.
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.
53+
*
54+
* Returns `{ transport, transportError }`. When no provider is found,
55+
* `transportError` is set and `transport` is a stub that throws on access —
56+
* the hook never throws during render.
57+
*
58+
* Pass `onError` to subscribe to post-construction transport errors
59+
* (e.g. send failures, channel continuity loss) without wiring
60+
* `transport.on('error', …)` manually.
5561
*/
5662
useClientTransport: (props?: {
5763
/** Channel name to look up; omit to use the nearest {@link TransportProvider}. */
5864
channelName?: string;
5965
/** When `true`, return a stub transport that throws on any access. */
6066
skip?: boolean;
61-
}) => ClientTransport<TEvent, TMessage>;
67+
/** Called whenever the resolved transport emits an error event. */
68+
onError?: (error: Ably.ErrorInfo) => void;
69+
}) => ClientTransportHandle<TEvent, TMessage>;
6270
/**
6371
* Subscribe to the nearest transport's view and return the visible node list with pagination.
6472
* Pass `transport` to use a transport's default view, `view` to subscribe to a specific view

src/react/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export type { EventsNode, MessageNode } from '../core/transport/types.js';
2+
export type { TransportSlot } from './contexts/transport-context.js';
23
export { NearestTransportContext } from './contexts/transport-context.js';
34
export type { TransportProviderProps } from './contexts/transport-provider.js';
45
export { TransportProvider } from './contexts/transport-provider.js';
@@ -8,6 +9,7 @@ export { createTransportHooks } from './create-transport-hooks.js';
89
export type { EventNode, TreeNode } from '../core/transport/types.js';
910
export { useAblyMessages } from './use-ably-messages.js';
1011
export { useActiveTurns } from './use-active-turns.js';
12+
export type { ClientTransportHandle } from './use-client-transport.js';
1113
export { useClientTransport } from './use-client-transport.js';
1214
export { useCreateView } from './use-create-view.js';
1315
export { useEdit } from './use-edit.js';

src/react/use-ably-messages.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ export const useAblyMessages = <TEvent, TMessage>({
2727
transport,
2828
skip,
2929
}: { transport?: ClientTransport<TEvent, TMessage>; skip?: boolean } = {}): Ably.InboundMessage[] => {
30-
const nearestTransport = useContext(NearestTransportContext);
30+
const nearestSlot = useContext(NearestTransportContext);
3131
// CAST: NearestTransportContext stores transport with erased generics; types fixed at call site.
3232
const resolved = skip
3333
? undefined
34-
: ((transport ?? nearestTransport) as ClientTransport<TEvent, TMessage> | undefined);
34+
: ((transport ?? nearestSlot?.transport) as ClientTransport<TEvent, TMessage> | undefined);
3535

3636
const [messages, setMessages] = useState<Ably.InboundMessage[]>([]);
3737
const messagesRef = useRef<Ably.InboundMessage[]>([]);

src/react/use-active-turns.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ import { NearestTransportContext } from './contexts/transport-context.js';
2828
export const useActiveTurns = <TEvent, TMessage>({
2929
transport,
3030
}: { transport?: ClientTransport<TEvent, TMessage> | null } = {}): Map<string, Set<string>> => {
31-
const nearestTransport = useContext(NearestTransportContext);
31+
const nearestSlot = useContext(NearestTransportContext);
3232
// CAST: NearestTransportContext stores transport with erased generics; types fixed at call site.
33-
const resolved = (transport ?? nearestTransport) as ClientTransport<TEvent, TMessage> | undefined;
33+
const resolved = (transport ?? nearestSlot?.transport) as ClientTransport<TEvent, TMessage> | undefined;
3434

3535
const [turns, setTurns] = useState<Map<string, Set<string>>>(() => new Map());
3636

0 commit comments

Comments
 (0)