@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 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
@@ -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
+ }
@@ -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
+ }