@convex-localfirst/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/README.md +28 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +320 -0
- package/dist/shadow.d.ts +1 -0
- package/dist/shadow.js +1 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fanzzzd
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @convex-localfirst/react
|
|
2
|
+
|
|
3
|
+
Convex-compatible React hooks for local-first, offline-capable apps. Keep writing
|
|
4
|
+
`useQuery` / `useMutation` — local-first tables read and write optimistically, work
|
|
5
|
+
offline, and sync in the background, with Convex as the source of truth.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @convex-localfirst/react
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { useMutation, useQuery, useSyncStatus } from "@convex-localfirst/react";
|
|
13
|
+
import { api } from "../convex/_generated/api";
|
|
14
|
+
|
|
15
|
+
export function Todos({ listId }: { listId: string }) {
|
|
16
|
+
const todos = useQuery(api.todos.list, { listId }, { initial: [] });
|
|
17
|
+
const create = useMutation(api.todos.create);
|
|
18
|
+
const sync = useSyncStatus();
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<button disabled={sync.blockedBySchemaMismatch} onClick={() => create({ listId, text: "Ship it" })}>
|
|
22
|
+
Add {todos.length} todos
|
|
23
|
+
</button>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Peer dependencies: `convex`, `react`. MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import * as ConvexReact from "convex/react";
|
|
3
|
+
import type { FunctionArgs, FunctionReference, FunctionReturnType } from "convex/server";
|
|
4
|
+
import { collection, many, manyToMany, one, type FunctionNameResolver, type LocalFirstManifest, type LocalFirstMutationCall, type LocalQueryPlan, type LocalStore, type RelationSpec, type RowValue, type SyncStatus, type SyncTransport } from "@convex-localfirst/core";
|
|
5
|
+
import { LocalFirstEngine } from "@convex-localfirst/core/internal";
|
|
6
|
+
export { collection, many, manyToMany, one };
|
|
7
|
+
export type { LocalQueryPlan, RelationSpec };
|
|
8
|
+
export declare const ConvexReactClient: typeof ConvexReact.ConvexReactClient;
|
|
9
|
+
export declare const Authenticated: typeof ConvexReact.Authenticated;
|
|
10
|
+
export declare const Unauthenticated: typeof ConvexReact.Unauthenticated;
|
|
11
|
+
export declare const AuthLoading: typeof ConvexReact.AuthLoading;
|
|
12
|
+
export declare const useConvex: typeof ConvexReact.useConvex;
|
|
13
|
+
export declare const useConvexAuth: typeof ConvexReact.useConvexAuth;
|
|
14
|
+
export type LocalFirstProviderConfig = {
|
|
15
|
+
readonly manifest: LocalFirstManifest;
|
|
16
|
+
readonly transport?: SyncTransport;
|
|
17
|
+
/** Local store. Defaults to an in-memory store; pass an IndexedDbStore in the browser. */
|
|
18
|
+
readonly store?: LocalStore;
|
|
19
|
+
readonly clientId?: string;
|
|
20
|
+
readonly userId?: string | null;
|
|
21
|
+
readonly nameOf?: FunctionNameResolver;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The convex-aware name resolver (`api.todos.list` → `"todos:list"`) the provider and the
|
|
25
|
+
* headless factory wire by default. Exported so an imperative consumer building its own
|
|
26
|
+
* engine doesn't have to inject one.
|
|
27
|
+
*/
|
|
28
|
+
export declare const convexFunctionName: FunctionNameResolver;
|
|
29
|
+
/**
|
|
30
|
+
* The engine from createConvexLocalFirst, with convex-typed `mutate`/`query`: args and
|
|
31
|
+
* result infer from the function reference, like the hooks — so headless consumers get the
|
|
32
|
+
* same inference instead of core's backend-agnostic `reference: unknown`. Core stays
|
|
33
|
+
* convex-free; the typing lives here in the adapter.
|
|
34
|
+
*/
|
|
35
|
+
export type ConvexLocalFirstEngine = Omit<LocalFirstEngine, "mutate" | "query"> & {
|
|
36
|
+
mutate<Mutation extends FunctionReference<"mutation">>(reference: Mutation, args: FunctionArgs<Mutation>): LocalFirstMutationCall<FunctionReturnType<Mutation>>;
|
|
37
|
+
query<Query extends FunctionReference<"query">>(reference: Query, args: FunctionArgs<Query>): Promise<FunctionReturnType<Query> | undefined>;
|
|
38
|
+
};
|
|
39
|
+
export type CreateConvexLocalFirstOptions = {
|
|
40
|
+
readonly manifest: LocalFirstManifest;
|
|
41
|
+
/** Pass a Convex client, or a `url` to construct a (reactive) ConvexReactClient. */
|
|
42
|
+
readonly client?: InstanceType<typeof ConvexReact.ConvexReactClient>;
|
|
43
|
+
readonly url?: string;
|
|
44
|
+
readonly userId?: string | null;
|
|
45
|
+
readonly clientId?: string;
|
|
46
|
+
/** Local store. Defaults to IndexedDb in the browser, in-memory elsewhere. */
|
|
47
|
+
readonly store?: LocalStore;
|
|
48
|
+
/** Names for the default browser IndexedDb store. */
|
|
49
|
+
readonly databaseName?: string;
|
|
50
|
+
readonly namespace?: string;
|
|
51
|
+
/** Sync function refs. Default to the conventional `sync:push` / `sync:pull`. */
|
|
52
|
+
readonly sync?: {
|
|
53
|
+
readonly push?: FunctionReference<"mutation">;
|
|
54
|
+
readonly pull?: FunctionReference<"query">;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* One-call headless setup for an imperative (non-hook) consumer — a service layer, store,
|
|
59
|
+
* or Node script. Wires the Convex transport, name resolver, a browser/Node store default,
|
|
60
|
+
* and a client id (the provider's plumbing minus the React lifecycle). Returns the engine
|
|
61
|
+
* plus the Convex client (for server-only, non-local-first functions).
|
|
62
|
+
*/
|
|
63
|
+
export declare function createConvexLocalFirst(options: CreateConvexLocalFirstOptions): {
|
|
64
|
+
readonly engine: ConvexLocalFirstEngine;
|
|
65
|
+
readonly client: InstanceType<typeof ConvexReact.ConvexReactClient>;
|
|
66
|
+
};
|
|
67
|
+
export declare function ConvexProvider(props: {
|
|
68
|
+
readonly client: InstanceType<typeof ConvexReact.ConvexReactClient>;
|
|
69
|
+
readonly children: React.ReactNode;
|
|
70
|
+
readonly localFirst?: LocalFirstProviderConfig;
|
|
71
|
+
}): React.JSX.Element;
|
|
72
|
+
export type UseLocalFirstQueryOptions<TResult> = {
|
|
73
|
+
readonly initial?: TResult;
|
|
74
|
+
/** `"auto"` (default): pull from the server on mount + subscribe to live changes.
|
|
75
|
+
* `"off"`: read local data only, never sync this query. (No silent middle ground —
|
|
76
|
+
* a "manual" mode with no trigger API would just behave as "auto", so it isn't offered.) */
|
|
77
|
+
readonly sync?: "auto" | "off";
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Convex-compatible useQuery. Args and result type are inferred from the Convex
|
|
81
|
+
* function reference (drop-in, no explicit generics — exactly like `convex/react`).
|
|
82
|
+
* Local-first functions read from the engine and subscribe to local changes;
|
|
83
|
+
* everything else falls through to Convex.
|
|
84
|
+
*
|
|
85
|
+
* All hooks below run unconditionally on every render (no rules-of-hooks
|
|
86
|
+
* violation): the Convex hook is fed "skip" for local-first functions, and the
|
|
87
|
+
* local subscription is inert when there is no engine/local definition.
|
|
88
|
+
*/
|
|
89
|
+
export declare function useQuery<Query extends FunctionReference<"query">>(reference: Query, args?: FunctionArgs<Query> | "skip", options?: UseLocalFirstQueryOptions<FunctionReturnType<Query>>): FunctionReturnType<Query> | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* Reactive local-first query for the chainable `collection(...)` builder. Re-renders on
|
|
92
|
+
* local data change and refines the derived view with where/order/limit on the client.
|
|
93
|
+
* The query is rebuilt inline each render, so effects key on the stable (table, scope)
|
|
94
|
+
* identity, never the per-render object — keeping dynamic predicates live without resubscribing.
|
|
95
|
+
*/
|
|
96
|
+
export type UseLiveQueryOptions = {
|
|
97
|
+
/**
|
|
98
|
+
* Real-time FALLBACK for non-reactive transports only. A reactive transport (the default
|
|
99
|
+
* ConvexReactClient) pushes changes and ignores this; an HTTP client re-pulls the scope
|
|
100
|
+
* every N ms while mounted. Leave unset for normal data.
|
|
101
|
+
*/
|
|
102
|
+
readonly pollMs?: number;
|
|
103
|
+
};
|
|
104
|
+
export declare function useLiveQuery<Row extends Record<string, unknown> = RowValue, Rel = unknown>(query: LocalQueryPlan<Row, Rel> | "skip", options?: UseLiveQueryOptions): Array<Row & Rel> | undefined;
|
|
105
|
+
/** Mutators always return the hybrid call shape (await it like Convex, or use .local/.server). */
|
|
106
|
+
export type LocalFirstMutator<TArgs, TResult> = (args: TArgs) => LocalFirstMutationCall<TResult>;
|
|
107
|
+
/**
|
|
108
|
+
* Convex-compatible useMutation. Args and result type are inferred from the
|
|
109
|
+
* function reference (no explicit generics). The returned mutator yields the
|
|
110
|
+
* hybrid call: `await it` resolves to the server result (Convex-identical), and
|
|
111
|
+
* `.local` / `.server` are separately awaitable.
|
|
112
|
+
*/
|
|
113
|
+
export declare function useMutation<Mutation extends FunctionReference<"mutation">>(reference: Mutation): LocalFirstMutator<FunctionArgs<Mutation>, FunctionReturnType<Mutation>>;
|
|
114
|
+
export declare function useSyncStatus(): SyncStatus;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import * as ConvexReact from "convex/react";
|
|
4
|
+
import { getFunctionName, makeFunctionReference } from "convex/server";
|
|
5
|
+
import { IndexedDbStore, MemoryLocalStore, collection, createClientId, createConvexTransport, createLocalFirstEngine, many, manyToMany, one } from "@convex-localfirst/core";
|
|
6
|
+
// Engine + low-level helpers are INTERNAL (I13): imported from the internal subpath,
|
|
7
|
+
// never re-exported to app authors. See @convex-localfirst/core/internal.
|
|
8
|
+
import { LocalFirstEngine, coordinationName, createFallbackMutationCall, createMultiTabSync, defaultFunctionName } from "@convex-localfirst/core/internal";
|
|
9
|
+
export { collection, many, manyToMany, one };
|
|
10
|
+
export const ConvexReactClient = ConvexReact.ConvexReactClient;
|
|
11
|
+
export const Authenticated = ConvexReact.Authenticated;
|
|
12
|
+
export const Unauthenticated = ConvexReact.Unauthenticated;
|
|
13
|
+
export const AuthLoading = ConvexReact.AuthLoading;
|
|
14
|
+
export const useConvex = ConvexReact.useConvex;
|
|
15
|
+
export const useConvexAuth = ConvexReact.useConvexAuth;
|
|
16
|
+
const EMPTY_STATUS = {
|
|
17
|
+
online: true,
|
|
18
|
+
syncing: false,
|
|
19
|
+
pendingMutations: 0,
|
|
20
|
+
lastPushAt: null,
|
|
21
|
+
lastPullAt: null,
|
|
22
|
+
lastError: null,
|
|
23
|
+
blockedBySchemaMismatch: false,
|
|
24
|
+
partial: false
|
|
25
|
+
};
|
|
26
|
+
const LocalFirstReactContext = createContext(null);
|
|
27
|
+
/**
|
|
28
|
+
* Default name resolver: use Convex's getFunctionName for real function
|
|
29
|
+
* references (api.todos.list -> "todos:list"); fall back to the core resolver
|
|
30
|
+
* for plain strings/objects (used in tests).
|
|
31
|
+
*/
|
|
32
|
+
function reactDefaultFunctionName(reference) {
|
|
33
|
+
try {
|
|
34
|
+
return getFunctionName(reference);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return defaultFunctionName(reference);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The convex-aware name resolver (`api.todos.list` → `"todos:list"`) the provider and the
|
|
42
|
+
* headless factory wire by default. Exported so an imperative consumer building its own
|
|
43
|
+
* engine doesn't have to inject one.
|
|
44
|
+
*/
|
|
45
|
+
export const convexFunctionName = reactDefaultFunctionName;
|
|
46
|
+
/**
|
|
47
|
+
* One-call headless setup for an imperative (non-hook) consumer — a service layer, store,
|
|
48
|
+
* or Node script. Wires the Convex transport, name resolver, a browser/Node store default,
|
|
49
|
+
* and a client id (the provider's plumbing minus the React lifecycle). Returns the engine
|
|
50
|
+
* plus the Convex client (for server-only, non-local-first functions).
|
|
51
|
+
*/
|
|
52
|
+
export function createConvexLocalFirst(options) {
|
|
53
|
+
const client = options.client ??
|
|
54
|
+
new ConvexReact.ConvexReactClient(options.url ?? raise("createConvexLocalFirst: pass either `client` or `url`."));
|
|
55
|
+
const clientId = options.clientId ?? createClientId();
|
|
56
|
+
const userId = options.userId ?? null;
|
|
57
|
+
const store = options.store ??
|
|
58
|
+
(typeof indexedDB !== "undefined"
|
|
59
|
+
? new IndexedDbStore({
|
|
60
|
+
databaseName: options.databaseName ?? "convex-localfirst",
|
|
61
|
+
namespace: options.namespace ?? userId ?? "default"
|
|
62
|
+
})
|
|
63
|
+
: new MemoryLocalStore());
|
|
64
|
+
const transport = createConvexTransport({
|
|
65
|
+
client,
|
|
66
|
+
push: options.sync?.push ?? makeFunctionReference("sync:push"),
|
|
67
|
+
pull: options.sync?.pull ?? makeFunctionReference("sync:pull"),
|
|
68
|
+
clientId,
|
|
69
|
+
// The transport envelope wants a string; an anonymous (null-userId) engine sends
|
|
70
|
+
// "" — the server resolves the real identity from auth and ignores this anyway.
|
|
71
|
+
userId: userId ?? ""
|
|
72
|
+
});
|
|
73
|
+
const engine = createLocalFirstEngine({
|
|
74
|
+
manifest: options.manifest,
|
|
75
|
+
store,
|
|
76
|
+
transport,
|
|
77
|
+
clientId,
|
|
78
|
+
userId,
|
|
79
|
+
nameOf: convexFunctionName
|
|
80
|
+
});
|
|
81
|
+
// Runtime is core's engine; the cast only adds the convex-typed mutate/query overloads
|
|
82
|
+
// (same methods, inferred arg/return types). Sound: the runtime signatures are wider.
|
|
83
|
+
return { engine: engine, client };
|
|
84
|
+
}
|
|
85
|
+
function raise(message) {
|
|
86
|
+
throw new Error(message);
|
|
87
|
+
}
|
|
88
|
+
export function ConvexProvider(props) {
|
|
89
|
+
if (!props.localFirst) {
|
|
90
|
+
return _jsx(ConvexReact.ConvexProvider, { client: props.client, children: props.children });
|
|
91
|
+
}
|
|
92
|
+
return (_jsx(ConvexReact.ConvexProvider, { client: props.client, children: _jsx(LocalFirstProvider, { ...props.localFirst, children: props.children }) }));
|
|
93
|
+
}
|
|
94
|
+
// Internal: the explicit-config provider. Users mount the local-first layer via
|
|
95
|
+
// the public `ConvexProvider` (drop-in name) + its `localFirst` prop.
|
|
96
|
+
function LocalFirstProvider(props) {
|
|
97
|
+
// Resolve the store ONCE per provider instance, with the SAME deps as the engine, so
|
|
98
|
+
// (a) an app that inlines a fresh store object each render doesn't thrash the engine,
|
|
99
|
+
// and (b) the multi-tab coordination key below is derived from the EXACT store the
|
|
100
|
+
// engine holds — never an old-engine-under-a-new-store-namespace mismatch.
|
|
101
|
+
const store = useMemo(() => props.store ?? new MemoryLocalStore(),
|
|
102
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
103
|
+
[props.manifest, props.userId, props.transport, props.nameOf]);
|
|
104
|
+
const engine = useMemo(() => {
|
|
105
|
+
return new LocalFirstEngine({
|
|
106
|
+
manifest: props.manifest,
|
|
107
|
+
store,
|
|
108
|
+
clientId: props.clientId ?? createClientId(),
|
|
109
|
+
userId: props.userId ?? null,
|
|
110
|
+
transport: props.transport,
|
|
111
|
+
nameOf: props.nameOf ?? reactDefaultFunctionName
|
|
112
|
+
});
|
|
113
|
+
// clientId is intentionally captured once; store moves in lockstep (same deps).
|
|
114
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
|
+
}, [props.manifest, props.userId, props.transport, props.nameOf, store]);
|
|
116
|
+
// The engine self-wires browser connectivity in its constructor — reflecting
|
|
117
|
+
// navigator.onLine into the sync status and flushing the offline outbox on reconnect
|
|
118
|
+
// (see LocalFirstEngine.wireConnectivity). The provider used to duplicate that here;
|
|
119
|
+
// it doesn't anymore. We only need to dispose the engine-owned listeners when the engine
|
|
120
|
+
// is replaced (manifest/user/transport/store change) or the provider unmounts, so they
|
|
121
|
+
// don't leak across recreations.
|
|
122
|
+
useEffect(() => () => engine.dispose(), [engine]);
|
|
123
|
+
// Multi-tab coordination: elect one leader (only it runs the background batch push)
|
|
124
|
+
// and poke other tabs to re-read the shared IndexedDB after a pull. Engaged only with
|
|
125
|
+
// the crash-safe Web Locks primitive present (every modern browser); without it — SSR,
|
|
126
|
+
// jsdom tests, old browsers — every tab syncs independently exactly as before.
|
|
127
|
+
const userId = props.userId ?? null;
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (typeof window === "undefined" || !("locks" in navigator)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Coordinate on the SHARED-data boundary (the store the engine actually holds), not
|
|
133
|
+
// just the user — see coordinationName. engine + store move in lockstep, so this can
|
|
134
|
+
// never key an engine under another store's namespace.
|
|
135
|
+
const dispose = createMultiTabSync(engine, { name: coordinationName(store, userId), id: engine.clientId });
|
|
136
|
+
return dispose;
|
|
137
|
+
}, [engine, userId, store]);
|
|
138
|
+
const value = useMemo(() => ({ engine }), [engine]);
|
|
139
|
+
return _jsx(LocalFirstReactContext.Provider, { value: value, children: props.children });
|
|
140
|
+
}
|
|
141
|
+
// Internal: the engine never appears in the public type surface (GOAL §6/I13).
|
|
142
|
+
function useLocalFirstEngine() {
|
|
143
|
+
return useContext(LocalFirstReactContext)?.engine ?? null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Convex-compatible useQuery. Args and result type are inferred from the Convex
|
|
147
|
+
* function reference (drop-in, no explicit generics — exactly like `convex/react`).
|
|
148
|
+
* Local-first functions read from the engine and subscribe to local changes;
|
|
149
|
+
* everything else falls through to Convex.
|
|
150
|
+
*
|
|
151
|
+
* All hooks below run unconditionally on every render (no rules-of-hooks
|
|
152
|
+
* violation): the Convex hook is fed "skip" for local-first functions, and the
|
|
153
|
+
* local subscription is inert when there is no engine/local definition.
|
|
154
|
+
*/
|
|
155
|
+
export function useQuery(reference, args, options) {
|
|
156
|
+
const engine = useLocalFirstEngine();
|
|
157
|
+
const isLocal = engine !== null && engine.hasLocalQuery(reference);
|
|
158
|
+
const resolvedArgs = (args ?? {});
|
|
159
|
+
const convexResult = ConvexReact.useQuery(reference, (isLocal ? "skip" : resolvedArgs));
|
|
160
|
+
const localResult = useLocalQuery(isLocal ? engine : null, reference, resolvedArgs, options);
|
|
161
|
+
return isLocal ? localResult : convexResult;
|
|
162
|
+
}
|
|
163
|
+
function useLocalQuery(engine, reference, args, options) {
|
|
164
|
+
const [value, setValue] = useState(options?.initial);
|
|
165
|
+
const argsKey = useMemo(() => JSON.stringify(args), [args]);
|
|
166
|
+
// Key the effect on the resolved function NAME, not the reference object:
|
|
167
|
+
// Convex's `api` proxy returns a fresh object per access, so using the object
|
|
168
|
+
// identity would re-run this effect every render (an infinite sync loop).
|
|
169
|
+
const refKey = useMemo(() => (engine ? engine.functionName(reference) : null), [engine, reference]);
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!engine || args === "skip") {
|
|
172
|
+
// "skip" must read as no data (Convex returns undefined), not the last
|
|
173
|
+
// value from before the query was skipped.
|
|
174
|
+
setValue(options?.initial);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
let alive = true;
|
|
178
|
+
const run = () => {
|
|
179
|
+
void engine.query(reference, args).then((result) => {
|
|
180
|
+
if (alive) {
|
|
181
|
+
setValue(result ?? options?.initial);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
run();
|
|
186
|
+
const unsubscribe = engine.subscribe(run);
|
|
187
|
+
let unwatch = null;
|
|
188
|
+
if (options?.sync !== "off") {
|
|
189
|
+
void engine.refreshQuery(reference, args);
|
|
190
|
+
// Reactive like convex/react: a reactive transport pushes server changes, which
|
|
191
|
+
// drain into the store and fire `run` via the local subscription above. Falls
|
|
192
|
+
// back to mount + local-change pulls when the transport isn't reactive.
|
|
193
|
+
unwatch = engine.watchQuery(reference, args);
|
|
194
|
+
}
|
|
195
|
+
return () => {
|
|
196
|
+
alive = false;
|
|
197
|
+
unsubscribe();
|
|
198
|
+
unwatch?.();
|
|
199
|
+
};
|
|
200
|
+
// refKey/argsKey are the stable identity of (function, args); reference and
|
|
201
|
+
// options are read at effect time. eslint-disable-next-line react-hooks/exhaustive-deps
|
|
202
|
+
}, [engine, refKey, argsKey]);
|
|
203
|
+
// "skip" must read as no data SYNCHRONOUSLY (Convex returns undefined): the
|
|
204
|
+
// effect's clear runs after render, so returning `value` here would surface the
|
|
205
|
+
// previous result for one render.
|
|
206
|
+
if (args === "skip") {
|
|
207
|
+
return options?.initial;
|
|
208
|
+
}
|
|
209
|
+
return value;
|
|
210
|
+
}
|
|
211
|
+
export function useLiveQuery(query, options) {
|
|
212
|
+
const engine = useLocalFirstEngine();
|
|
213
|
+
const [rowsByTable, setRowsByTable] = useState(undefined);
|
|
214
|
+
const lastResult = useRef(undefined);
|
|
215
|
+
// The tables this query reads: its base table + any relation targets/join
|
|
216
|
+
// tables. A stable sorted key so an inline-rebuilt query object (or added
|
|
217
|
+
// relations) re-subscribes only when the table SET actually changes.
|
|
218
|
+
const tables = query === "skip" || !engine ? [] : engine.tablesForPlan(query);
|
|
219
|
+
const tablesKey = tables.length ? [...tables].sort().join(",") : null;
|
|
220
|
+
// Subscribe to every read table's live rows; re-pull all on any local change.
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
if (!engine || query === "skip") {
|
|
223
|
+
setRowsByTable(undefined);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const wanted = engine.tablesForPlan(query);
|
|
227
|
+
let alive = true;
|
|
228
|
+
const pull = () => {
|
|
229
|
+
void Promise.all(wanted.map((t) => engine.tableRows(t).then((rows) => [t, rows]))).then((entries) => {
|
|
230
|
+
if (alive) {
|
|
231
|
+
setRowsByTable(Object.fromEntries(entries));
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
pull();
|
|
236
|
+
const unsubscribe = engine.subscribe(pull);
|
|
237
|
+
return () => {
|
|
238
|
+
alive = false;
|
|
239
|
+
unsubscribe();
|
|
240
|
+
};
|
|
241
|
+
// query is read at effect time; tablesKey is the stable identity of its read set.
|
|
242
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
243
|
+
}, [engine, tablesKey]);
|
|
244
|
+
// Background sync for this query's scope (push pending + pull). Keyed on the
|
|
245
|
+
// scope values + read set, not the per-render query object.
|
|
246
|
+
const scopeKey = query === "skip" ? null : JSON.stringify(query.scopeValues ?? null);
|
|
247
|
+
const pollMs = options?.pollMs;
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
if (!engine || query === "skip") {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
void engine.refreshPlan(query);
|
|
253
|
+
// Prefer true server-push: a reactive transport drains this scope the instant
|
|
254
|
+
// the server has a change — no idle polling, instant cross-client updates.
|
|
255
|
+
const unwatch = engine.watchPlan(query);
|
|
256
|
+
if (unwatch) {
|
|
257
|
+
return unwatch;
|
|
258
|
+
}
|
|
259
|
+
// Fallback for a non-reactive transport (e.g. the HTTP client, or tests): poll
|
|
260
|
+
// the scope when the caller opted in. refreshPlan never throws and pulls only
|
|
261
|
+
// changes after the cursor, so an idle poll is cheap.
|
|
262
|
+
if (!pollMs) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const timer = setInterval(() => {
|
|
266
|
+
void engine.refreshPlan(query);
|
|
267
|
+
}, pollMs);
|
|
268
|
+
return () => clearInterval(timer);
|
|
269
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
270
|
+
}, [engine, tablesKey, scopeKey, pollMs]);
|
|
271
|
+
if (query === "skip" || rowsByTable === undefined || !engine) {
|
|
272
|
+
lastResult.current = undefined;
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
// Route through the engine (not query.run directly) so the scoped fail-closed
|
|
276
|
+
// guard + relation attach are enforced. Return a stable array reference when the
|
|
277
|
+
// result is unchanged (no-relation case), so it's safe in downstream deps.
|
|
278
|
+
const next = engine.applyLocalQuery(query, rowsByTable);
|
|
279
|
+
const prev = lastResult.current;
|
|
280
|
+
if (prev && prev.length === next.length && prev.every((row, i) => row === next[i])) {
|
|
281
|
+
return prev;
|
|
282
|
+
}
|
|
283
|
+
lastResult.current = next;
|
|
284
|
+
return next;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Convex-compatible useMutation. Args and result type are inferred from the
|
|
288
|
+
* function reference (no explicit generics). The returned mutator yields the
|
|
289
|
+
* hybrid call: `await it` resolves to the server result (Convex-identical), and
|
|
290
|
+
* `.local` / `.server` are separately awaitable.
|
|
291
|
+
*/
|
|
292
|
+
export function useMutation(reference) {
|
|
293
|
+
const engine = useLocalFirstEngine();
|
|
294
|
+
const convexMutation = ConvexReact.useMutation(reference);
|
|
295
|
+
const isLocal = engine !== null && engine.hasLocalMutation(reference);
|
|
296
|
+
// Stable function NAME, not the per-access `api` proxy object — otherwise the
|
|
297
|
+
// returned mutator changes every render and re-runs any effect that depends on it.
|
|
298
|
+
const refKey = useMemo(() => (engine ? engine.functionName(reference) : null), [engine, reference]);
|
|
299
|
+
return useMemo(() => {
|
|
300
|
+
if (isLocal && engine) {
|
|
301
|
+
return (args) => engine.mutate(reference, args);
|
|
302
|
+
}
|
|
303
|
+
// Fallback to Convex, but keep the uniform return type so .local/.server work.
|
|
304
|
+
return (args) => createFallbackMutationCall(convexMutation(args));
|
|
305
|
+
// reference is read at call time; refKey is its stable identity.
|
|
306
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
307
|
+
}, [engine, convexMutation, isLocal, refKey]);
|
|
308
|
+
}
|
|
309
|
+
export function useSyncStatus() {
|
|
310
|
+
const engine = useLocalFirstEngine();
|
|
311
|
+
const [status, setStatus] = useState(() => engine?.getStatus() ?? EMPTY_STATUS);
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
if (!engine) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
setStatus(engine.getStatus());
|
|
317
|
+
return engine.subscribeStatus(() => setStatus(engine.getStatus()));
|
|
318
|
+
}, [engine]);
|
|
319
|
+
return status;
|
|
320
|
+
}
|
package/dist/shadow.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./index.js";
|
package/dist/shadow.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./index.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@convex-localfirst/react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Convex-compatible React hooks (useQuery/useMutation/useSyncStatus) for local-first, offline-capable apps.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"convex",
|
|
8
|
+
"local-first",
|
|
9
|
+
"offline",
|
|
10
|
+
"react",
|
|
11
|
+
"hooks"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./shadow": {
|
|
22
|
+
"types": "./dist/shadow.d.ts",
|
|
23
|
+
"import": "./dist/shadow.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"convex": ">=1.0.0",
|
|
34
|
+
"react": ">=18.0.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@convex-localfirst/core": "0.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@testing-library/dom": "^10.4.1",
|
|
41
|
+
"@testing-library/react": "^16.3.2",
|
|
42
|
+
"@types/react": "^19.0.0",
|
|
43
|
+
"@types/react-dom": "^19.0.0",
|
|
44
|
+
"fake-indexeddb": "^6.2.5",
|
|
45
|
+
"jsdom": "^29.1.1",
|
|
46
|
+
"react": "^19.2.7",
|
|
47
|
+
"react-dom": "^19.2.7",
|
|
48
|
+
"typescript": "^5.7.0",
|
|
49
|
+
"vitest": "^2.1.0"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsc -p tsconfig.json --noEmit false --emitDeclarationOnly false",
|
|
53
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
54
|
+
"test": "vitest run --passWithNoTests"
|
|
55
|
+
}
|
|
56
|
+
}
|