@velajs/react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/context.d.ts +11 -0
- package/dist/context.js +17 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +5 -0
- package/dist/use-connection-status.d.ts +3 -0
- package/dist/use-connection-status.js +12 -0
- package/dist/use-live-mutation.d.ts +23 -0
- package/dist/use-live-mutation.js +59 -0
- package/dist/use-live-query.d.ts +17 -0
- package/dist/use-live-query.js +46 -0
- package/dist/use-presence.d.ts +17 -0
- package/dist/use-presence.js +41 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kauan Guesser
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createElement } from 'react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import type { LiveClient } from '@velajs/client';
|
|
4
|
+
import type { LiveContract } from '@velajs/client';
|
|
5
|
+
export interface LiveProviderProps {
|
|
6
|
+
client: LiveClient<LiveContract>;
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
/** Provides the LiveClient to the hook tree. `createElement`-based — no JSX toolchain required. */
|
|
10
|
+
export declare function LiveProvider(props: LiveProviderProps): ReturnType<typeof createElement>;
|
|
11
|
+
export declare function useLiveClient<C extends LiveContract = LiveContract>(): LiveClient<C>;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createContext, createElement, useContext } from "react";
|
|
2
|
+
// Deliberately `any`-typed inside the context: the contract generic is
|
|
3
|
+
// re-applied at the useLiveClient() boundary. One provider serves any app.
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
|
+
const LiveClientContext = createContext(null);
|
|
6
|
+
/** Provides the LiveClient to the hook tree. `createElement`-based — no JSX toolchain required. */ export function LiveProvider(props) {
|
|
7
|
+
return createElement(LiveClientContext.Provider, {
|
|
8
|
+
value: props.client
|
|
9
|
+
}, props.children);
|
|
10
|
+
}
|
|
11
|
+
export function useLiveClient() {
|
|
12
|
+
const client = useContext(LiveClientContext);
|
|
13
|
+
if (!client) {
|
|
14
|
+
throw new Error('useLiveClient: no LiveClient in context — wrap the tree in <LiveProvider client={…}>.');
|
|
15
|
+
}
|
|
16
|
+
return client;
|
|
17
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { LiveProvider, useLiveClient } from './context';
|
|
2
|
+
export type { LiveProviderProps } from './context';
|
|
3
|
+
export { useLiveQuery } from './use-live-query';
|
|
4
|
+
export type { UseLiveQueryOptions } from './use-live-query';
|
|
5
|
+
export { useLiveMutation } from './use-live-mutation';
|
|
6
|
+
export type { UseLiveMutationResult } from './use-live-mutation';
|
|
7
|
+
export { useConnectionStatus } from './use-connection-status';
|
|
8
|
+
export { usePresence } from './use-presence';
|
|
9
|
+
export type { UsePresenceOptions } from './use-presence';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { LiveProvider, useLiveClient } from "./context.js";
|
|
2
|
+
export { useLiveQuery } from "./use-live-query.js";
|
|
3
|
+
export { useLiveMutation } from "./use-live-mutation.js";
|
|
4
|
+
export { useConnectionStatus } from "./use-connection-status.js";
|
|
5
|
+
export { usePresence } from "./use-presence.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
import { useLiveClient } from "./context.js";
|
|
3
|
+
/** The client's aggregate connection status, live. */ export function useConnectionStatus() {
|
|
4
|
+
const client = useLiveClient();
|
|
5
|
+
const subscribe = useCallback((onStoreChange)=>client.onConnectionStatus(()=>onStoreChange()), [
|
|
6
|
+
client
|
|
7
|
+
]);
|
|
8
|
+
const getSnapshot = useCallback(()=>client.connectionStatus(), [
|
|
9
|
+
client
|
|
10
|
+
]);
|
|
11
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
12
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { MutateOptions } from '@velajs/client';
|
|
2
|
+
export interface UseLiveMutationResult<R> {
|
|
3
|
+
/** Fire the mutation. Per-call options (optimistic targets, method, …) merge over the hook defaults. */
|
|
4
|
+
mutate: (body?: unknown, options?: MutateOptions) => Promise<R>;
|
|
5
|
+
pending: boolean;
|
|
6
|
+
data: R | undefined;
|
|
7
|
+
error: unknown;
|
|
8
|
+
reset: () => void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* HTTP mutation hook with live-aware optimistic updates:
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* const { mutate: addTodo, pending } = useLiveMutation('/todos', {
|
|
15
|
+
* optimistic: { query: 'todos.list', args: { listId }, apply: (t = []) => [...t, temp] },
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* The optimistic layer is dropped exactly when a live frame's cursor passes
|
|
20
|
+
* the mutation's `Vela-Commit-Cursor` (see @velajs/client); failures roll it
|
|
21
|
+
* back and reject.
|
|
22
|
+
*/
|
|
23
|
+
export declare function useLiveMutation<R = unknown>(path: string, defaults?: MutateOptions): UseLiveMutationResult<R>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
import { useLiveClient } from "./context.js";
|
|
3
|
+
/**
|
|
4
|
+
* HTTP mutation hook with live-aware optimistic updates:
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* const { mutate: addTodo, pending } = useLiveMutation('/todos', {
|
|
8
|
+
* optimistic: { query: 'todos.list', args: { listId }, apply: (t = []) => [...t, temp] },
|
|
9
|
+
* });
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* The optimistic layer is dropped exactly when a live frame's cursor passes
|
|
13
|
+
* the mutation's `Vela-Commit-Cursor` (see @velajs/client); failures roll it
|
|
14
|
+
* back and reject.
|
|
15
|
+
*/ export function useLiveMutation(path, defaults) {
|
|
16
|
+
const client = useLiveClient();
|
|
17
|
+
const [state, setState] = useState({
|
|
18
|
+
pending: 0
|
|
19
|
+
});
|
|
20
|
+
const defaultsRef = useRef(defaults);
|
|
21
|
+
defaultsRef.current = defaults;
|
|
22
|
+
const mutate = useCallback(async (body, options)=>{
|
|
23
|
+
setState((current)=>({
|
|
24
|
+
...current,
|
|
25
|
+
pending: current.pending + 1
|
|
26
|
+
}));
|
|
27
|
+
try {
|
|
28
|
+
const data = await client.mutate(path, body, {
|
|
29
|
+
...defaultsRef.current,
|
|
30
|
+
...options
|
|
31
|
+
});
|
|
32
|
+
setState((current)=>({
|
|
33
|
+
pending: current.pending - 1,
|
|
34
|
+
data,
|
|
35
|
+
error: undefined
|
|
36
|
+
}));
|
|
37
|
+
return data;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
setState((current)=>({
|
|
40
|
+
...current,
|
|
41
|
+
pending: current.pending - 1,
|
|
42
|
+
error
|
|
43
|
+
}));
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}, [
|
|
47
|
+
client,
|
|
48
|
+
path
|
|
49
|
+
]);
|
|
50
|
+
return {
|
|
51
|
+
mutate,
|
|
52
|
+
pending: state.pending > 0,
|
|
53
|
+
data: state.data,
|
|
54
|
+
error: state.error,
|
|
55
|
+
reset: useCallback(()=>setState({
|
|
56
|
+
pending: 0
|
|
57
|
+
}), [])
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ArgsOf, LiveContract, ResultOf, SubscribeOptions } from '@velajs/client';
|
|
2
|
+
export interface UseLiveQueryOptions extends SubscribeOptions {
|
|
3
|
+
/** Render without subscribing (conditional queries). */
|
|
4
|
+
skip?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Subscribe to a live query. Returns `undefined` until the first
|
|
8
|
+
* snapshot/hydrated value, then re-renders on every server push. Built on
|
|
9
|
+
* `useSyncExternalStore` — the client guarantees referentially stable
|
|
10
|
+
* snapshots between notifications, so there is no tearing and no external
|
|
11
|
+
* cache library.
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* const todos = useLiveQuery('todos.list', { listId }) ?? [];
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export declare function useLiveQuery<C extends LiveContract, Q extends keyof C & string>(query: Q, args: ArgsOf<C, Q>, options?: UseLiveQueryOptions): ResultOf<C, Q> | undefined;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
|
2
|
+
import { argsKeyOf } from "@velajs/client";
|
|
3
|
+
import { useLiveClient } from "./context.js";
|
|
4
|
+
/**
|
|
5
|
+
* Subscribe to a live query. Returns `undefined` until the first
|
|
6
|
+
* snapshot/hydrated value, then re-renders on every server push. Built on
|
|
7
|
+
* `useSyncExternalStore` — the client guarantees referentially stable
|
|
8
|
+
* snapshots between notifications, so there is no tearing and no external
|
|
9
|
+
* cache library.
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* const todos = useLiveQuery('todos.list', { listId }) ?? [];
|
|
13
|
+
* ```
|
|
14
|
+
*/ export function useLiveQuery(query, args, options) {
|
|
15
|
+
const client = useLiveClient();
|
|
16
|
+
// Stable identity for the deps array — callers pass fresh object literals.
|
|
17
|
+
const argsKey = useMemo(()=>argsKeyOf(args), [
|
|
18
|
+
args
|
|
19
|
+
]);
|
|
20
|
+
const { room, key, skip, onError } = options ?? {};
|
|
21
|
+
const subscribe = useCallback((onStoreChange)=>{
|
|
22
|
+
if (skip) return ()=>{};
|
|
23
|
+
return client.subscribe(query, args, ()=>onStoreChange(), {
|
|
24
|
+
room,
|
|
25
|
+
key,
|
|
26
|
+
onError
|
|
27
|
+
});
|
|
28
|
+
}, // eslint-disable-next-line react-hooks/exhaustive-deps -- argsKey stands in for args; onError is deliberately unbound
|
|
29
|
+
[
|
|
30
|
+
client,
|
|
31
|
+
query,
|
|
32
|
+
argsKey,
|
|
33
|
+
room,
|
|
34
|
+
key,
|
|
35
|
+
skip
|
|
36
|
+
]);
|
|
37
|
+
const getSnapshot = useCallback(()=>skip ? undefined : client.peek(query, args, room), // eslint-disable-next-line react-hooks/exhaustive-deps -- argsKey stands in for args
|
|
38
|
+
[
|
|
39
|
+
client,
|
|
40
|
+
query,
|
|
41
|
+
argsKey,
|
|
42
|
+
room,
|
|
43
|
+
skip
|
|
44
|
+
]);
|
|
45
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
46
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PresenceMember } from '@velajs/client/presence';
|
|
2
|
+
export interface UsePresenceOptions {
|
|
3
|
+
/** Payload attached to this connection's roster entry (latest render's value rides each beat). */
|
|
4
|
+
meta?: unknown;
|
|
5
|
+
/** Keep well under the server TTL (default 10 000 ms vs 30 000 ms). */
|
|
6
|
+
heartbeatIntervalMs?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Join a room's presence and observe its roster. Heartbeats ride the room's
|
|
10
|
+
* live socket; departure is immediate on close; a tab regaining visibility
|
|
11
|
+
* beats right away so the roster recovers from a throttled background timer.
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* const people = usePresence(roomId, { meta: { name: user.name } });
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export declare function usePresence(room: string, options?: UsePresenceOptions): PresenceMember[];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { createPresence } from "@velajs/client/presence";
|
|
3
|
+
import { useLiveClient } from "./context.js";
|
|
4
|
+
/**
|
|
5
|
+
* Join a room's presence and observe its roster. Heartbeats ride the room's
|
|
6
|
+
* live socket; departure is immediate on close; a tab regaining visibility
|
|
7
|
+
* beats right away so the roster recovers from a throttled background timer.
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const people = usePresence(roomId, { meta: { name: user.name } });
|
|
11
|
+
* ```
|
|
12
|
+
*/ export function usePresence(room, options) {
|
|
13
|
+
const client = useLiveClient();
|
|
14
|
+
const [members, setMembers] = useState([]);
|
|
15
|
+
const metaRef = useRef(options?.meta);
|
|
16
|
+
metaRef.current = options?.meta;
|
|
17
|
+
const heartbeatIntervalMs = options?.heartbeatIntervalMs;
|
|
18
|
+
useEffect(()=>{
|
|
19
|
+
const handle = createPresence(client, {
|
|
20
|
+
room,
|
|
21
|
+
meta: ()=>metaRef.current,
|
|
22
|
+
heartbeatIntervalMs,
|
|
23
|
+
onRoster: setMembers
|
|
24
|
+
});
|
|
25
|
+
const doc = globalThis.document;
|
|
26
|
+
const onVisibility = ()=>{
|
|
27
|
+
if (doc?.visibilityState === 'visible') handle.beat();
|
|
28
|
+
};
|
|
29
|
+
doc?.addEventListener?.('visibilitychange', onVisibility);
|
|
30
|
+
return ()=>{
|
|
31
|
+
doc?.removeEventListener?.('visibilitychange', onVisibility);
|
|
32
|
+
handle.stop();
|
|
33
|
+
setMembers([]);
|
|
34
|
+
};
|
|
35
|
+
}, [
|
|
36
|
+
client,
|
|
37
|
+
room,
|
|
38
|
+
heartbeatIntervalMs
|
|
39
|
+
]);
|
|
40
|
+
return members;
|
|
41
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@velajs/react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React hooks for Vela live queries: useLiveQuery, useLiveMutation, usePresence, useConnectionStatus on useSyncExternalStore",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"CHANGELOG.md"
|
|
19
|
+
],
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"keywords": [
|
|
22
|
+
"vela",
|
|
23
|
+
"react",
|
|
24
|
+
"hooks",
|
|
25
|
+
"live-queries",
|
|
26
|
+
"realtime",
|
|
27
|
+
"optimistic-updates"
|
|
28
|
+
],
|
|
29
|
+
"author": "ksh",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/velajs/client.git",
|
|
34
|
+
"directory": "packages/react"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/velajs/client#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/velajs/client/issues"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@velajs/client": "^0.1.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"react": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@swc/cli": "^0.8.1",
|
|
51
|
+
"@swc/core": "^1.15.43",
|
|
52
|
+
"@testing-library/react": "^16.3.0",
|
|
53
|
+
"@types/react": "^19.0.0",
|
|
54
|
+
"happy-dom": "^20.0.0",
|
|
55
|
+
"react": "^19.0.0",
|
|
56
|
+
"react-dom": "^19.0.0",
|
|
57
|
+
"typescript": "^6.0.3",
|
|
58
|
+
"unplugin-swc": "^1.5.9",
|
|
59
|
+
"vitest": "^4.1.9"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "rm -rf dist && swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly",
|
|
63
|
+
"test": "vitest run",
|
|
64
|
+
"typecheck": "tsc --noEmit"
|
|
65
|
+
}
|
|
66
|
+
}
|