@freecorps/ember-react 0.0.0-stage → 0.2.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 +11 -2
- package/dist/index.d.ts +67 -0
- package/dist/index.js +174 -0
- package/package.json +47 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FreeCorps
|
|
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
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @freecorps/ember-react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
React bindings for Ember: EmberProvider, useQuery, useMutation, useAction, usePaginatedQuery.
|
|
4
|
+
|
|
5
|
+
Part of [Ember](https://github.com/freecorps/ember), a Convex-style backend on Cloudflare Workers and Durable
|
|
6
|
+
Objects. Source: [`packages/react`](https://github.com/freecorps/ember/tree/main/packages/react).
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install @freecorps/ember-react
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
License: MIT.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type ConnectionState, type EmberClient, type FunctionReference, type OptimisticUpdate, type QuerySnapshot, type ShardOptions } from "@freecorps/ember-client";
|
|
3
|
+
export interface EmberProviderProps {
|
|
4
|
+
client: EmberClient;
|
|
5
|
+
children?: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
export declare function EmberProvider({ client, children }: EmberProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<EmberClient | null>>;
|
|
8
|
+
export declare function useEmberClient(): EmberClient;
|
|
9
|
+
type QueryRef = FunctionReference<"query"> | string;
|
|
10
|
+
type ArgsOf<Q> = Q extends FunctionReference<any, infer A, any> ? A : Record<string, unknown>;
|
|
11
|
+
type ReturnOf<Q> = Q extends FunctionReference<any, any, infer R> ? R : unknown;
|
|
12
|
+
/**
|
|
13
|
+
* The full snapshot of a live query (`status`, `value`, `error`, `optimistic`, and `source` — `"local"` or `"cache"`
|
|
14
|
+
* while the value comes from offline storage, before the server answers). Args are compared by value, so an
|
|
15
|
+
* inline object is fine. `"skip"` subscribes to nothing.
|
|
16
|
+
*
|
|
17
|
+
* Offline-first: give the client a `StorageAdapter` (`new EmberClient({ url, storage: indexedDbStorage({ name,
|
|
18
|
+
* schema, queries }) })`, from `@freecorps/ember-offline/indexeddb`); `useQuery` then renders stored / locally
|
|
19
|
+
* computed results on a cold start without network.
|
|
20
|
+
*/
|
|
21
|
+
export declare function useQuerySnapshot<Q extends QueryRef>(ref: Q, args: ArgsOf<Q> | "skip", opts?: ShardOptions): QuerySnapshot<ReturnOf<Q>>;
|
|
22
|
+
/**
|
|
23
|
+
* Live query value: `undefined` while loading (or skipped). Errors are thrown to the nearest error boundary,
|
|
24
|
+
* like `convex/react`. Use `useQuerySnapshot` to handle them inline.
|
|
25
|
+
*/
|
|
26
|
+
export declare function useQuery<Q extends QueryRef>(ref: Q, args?: ArgsOf<Q> | "skip", opts?: ShardOptions): ReturnOf<Q> | undefined;
|
|
27
|
+
type MutationRef = FunctionReference<"mutation"> | string;
|
|
28
|
+
export interface ReactMutation<M extends MutationRef> {
|
|
29
|
+
(args?: ArgsOf<M>): Promise<ReturnOf<M>>;
|
|
30
|
+
/** A new mutation function that applies `update` optimistically on every call. */
|
|
31
|
+
withOptimisticUpdate(update: OptimisticUpdate<ArgsOf<M>>): ReactMutation<M>;
|
|
32
|
+
}
|
|
33
|
+
/** A stable function that runs the mutation. `.withOptimisticUpdate(fn)` adds an optimistic update. */
|
|
34
|
+
export declare function useMutation<M extends MutationRef>(ref: M, opts?: ShardOptions): ReactMutation<M>;
|
|
35
|
+
type ActionRef = FunctionReference<"action"> | string;
|
|
36
|
+
/** Calls the action over HTTP (`POST /api/action`); the shard defaults to the client's default shard. */
|
|
37
|
+
export declare function useAction<A extends ActionRef>(ref: A, opts?: ShardOptions): (args?: ArgsOf<A>) => Promise<ReturnOf<A>>;
|
|
38
|
+
/** Online flag, per-shard socket state and the number of unconfirmed mutations. */
|
|
39
|
+
export declare function useConnectionState(): ConnectionState;
|
|
40
|
+
/** What a paginated query receives in `args.paginationOpts`. */
|
|
41
|
+
export interface PaginationOptions {
|
|
42
|
+
numItems: number;
|
|
43
|
+
cursor: string | null;
|
|
44
|
+
}
|
|
45
|
+
/** What a paginated query must return. */
|
|
46
|
+
export interface PaginationResult<T> {
|
|
47
|
+
page: T[];
|
|
48
|
+
isDone: boolean;
|
|
49
|
+
continueCursor: string | null;
|
|
50
|
+
}
|
|
51
|
+
export type PaginationStatus = "LoadingFirstPage" | "CanLoadMore" | "LoadingMore" | "Exhausted";
|
|
52
|
+
export interface UsePaginatedQueryResult<T> {
|
|
53
|
+
results: T[];
|
|
54
|
+
status: PaginationStatus;
|
|
55
|
+
isLoading: boolean;
|
|
56
|
+
loadMore(numItems: number): void;
|
|
57
|
+
}
|
|
58
|
+
type PageItem<Q> = ReturnOf<Q> extends PaginationResult<infer T> ? T : unknown;
|
|
59
|
+
type PaginatedArgs<Q> = Omit<ArgsOf<Q>, "paginationOpts">;
|
|
60
|
+
/**
|
|
61
|
+
* Basic cursor pagination: page `i` is its own live query with `paginationOpts: {numItems, cursor}`, where the
|
|
62
|
+
* cursor is page `i-1`'s `continueCursor`. Pages are concatenated. Page boundaries do not split/merge yet (Phase 4).
|
|
63
|
+
*/
|
|
64
|
+
export declare function usePaginatedQuery<Q extends QueryRef>(ref: Q, args: PaginatedArgs<Q> | "skip", opts: ShardOptions & {
|
|
65
|
+
initialNumItems: number;
|
|
66
|
+
}): UsePaginatedQueryResult<PageItem<Q>>;
|
|
67
|
+
export type { QuerySnapshot, ConnectionState } from "@freecorps/ember-client";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// React bindings for @freecorps/ember-client. No DOM APIs: works in React DOM and React Native.
|
|
2
|
+
import { createContext, createElement, useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore, } from "react";
|
|
3
|
+
import { getFunctionName, stableStringify, } from "@freecorps/ember-client";
|
|
4
|
+
const EmberContext = createContext(null);
|
|
5
|
+
export function EmberProvider({ client, children }) {
|
|
6
|
+
return createElement(EmberContext.Provider, { value: client }, children);
|
|
7
|
+
}
|
|
8
|
+
export function useEmberClient() {
|
|
9
|
+
const c = useContext(EmberContext);
|
|
10
|
+
if (!c)
|
|
11
|
+
throw new Error("Ember hooks need an <EmberProvider client={...}> above them");
|
|
12
|
+
return c;
|
|
13
|
+
}
|
|
14
|
+
const LOADING = Object.freeze({ status: "loading", value: undefined, error: undefined, optimistic: false });
|
|
15
|
+
const noopSubscribe = () => () => { };
|
|
16
|
+
const getLoading = () => LOADING;
|
|
17
|
+
/**
|
|
18
|
+
* The full snapshot of a live query (`status`, `value`, `error`, `optimistic`, and `source` — `"local"` or `"cache"`
|
|
19
|
+
* while the value comes from offline storage, before the server answers). Args are compared by value, so an
|
|
20
|
+
* inline object is fine. `"skip"` subscribes to nothing.
|
|
21
|
+
*
|
|
22
|
+
* Offline-first: give the client a `StorageAdapter` (`new EmberClient({ url, storage: indexedDbStorage({ name,
|
|
23
|
+
* schema, queries }) })`, from `@freecorps/ember-offline/indexeddb`); `useQuery` then renders stored / locally
|
|
24
|
+
* computed results on a cold start without network.
|
|
25
|
+
*/
|
|
26
|
+
export function useQuerySnapshot(ref, args, opts) {
|
|
27
|
+
const client = useEmberClient();
|
|
28
|
+
const name = getFunctionName(ref);
|
|
29
|
+
const skip = args === "skip";
|
|
30
|
+
const argsKey = skip ? null : stableStringify(args ?? {});
|
|
31
|
+
const shard = opts?.shard;
|
|
32
|
+
const handle = useMemo(() => (skip ? null : client.watchQuery(name, args, shard === undefined ? undefined : { shard })),
|
|
33
|
+
// args are read through argsKey
|
|
34
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
35
|
+
[client, name, argsKey, shard]);
|
|
36
|
+
return useSyncExternalStore(handle?.subscribe ?? noopSubscribe, handle?.getSnapshot ?? getLoading, handle?.getSnapshot ?? getLoading);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Live query value: `undefined` while loading (or skipped). Errors are thrown to the nearest error boundary,
|
|
40
|
+
* like `convex/react`. Use `useQuerySnapshot` to handle them inline.
|
|
41
|
+
*/
|
|
42
|
+
export function useQuery(ref, args = {}, opts) {
|
|
43
|
+
const s = useQuerySnapshot(ref, args, opts);
|
|
44
|
+
if (s.status === "error")
|
|
45
|
+
throw s.error;
|
|
46
|
+
return s.value;
|
|
47
|
+
}
|
|
48
|
+
function makeMutation(client, name, opts, update) {
|
|
49
|
+
const f = ((args) => {
|
|
50
|
+
const o = {};
|
|
51
|
+
if (opts?.shard !== undefined)
|
|
52
|
+
o.shard = opts.shard;
|
|
53
|
+
if (update)
|
|
54
|
+
o.optimisticUpdate = update;
|
|
55
|
+
return client.mutation(name, args, o);
|
|
56
|
+
});
|
|
57
|
+
f.withOptimisticUpdate = (u) => makeMutation(client, name, opts, u);
|
|
58
|
+
return f;
|
|
59
|
+
}
|
|
60
|
+
/** A stable function that runs the mutation. `.withOptimisticUpdate(fn)` adds an optimistic update. */
|
|
61
|
+
export function useMutation(ref, opts) {
|
|
62
|
+
const client = useEmberClient();
|
|
63
|
+
const name = getFunctionName(ref);
|
|
64
|
+
const shard = opts?.shard;
|
|
65
|
+
return useMemo(() => makeMutation(client, name, shard === undefined ? undefined : { shard }), [client, name, shard]);
|
|
66
|
+
}
|
|
67
|
+
/** Calls the action over HTTP (`POST /api/action`); the shard defaults to the client's default shard. */
|
|
68
|
+
export function useAction(ref, opts) {
|
|
69
|
+
const client = useEmberClient();
|
|
70
|
+
const name = getFunctionName(ref);
|
|
71
|
+
const shard = opts?.shard;
|
|
72
|
+
return useCallback((args) => client.action(name, args, shard === undefined ? undefined : { shard }), [client, name, shard]);
|
|
73
|
+
}
|
|
74
|
+
/** Online flag, per-shard socket state and the number of unconfirmed mutations. */
|
|
75
|
+
export function useConnectionState() {
|
|
76
|
+
const client = useEmberClient();
|
|
77
|
+
const subscribe = useCallback((l) => client.onConnectionStateChange(l), [client]);
|
|
78
|
+
const get = useCallback(() => client.connectionState(), [client]);
|
|
79
|
+
return useSyncExternalStore(subscribe, get, get);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Basic cursor pagination: page `i` is its own live query with `paginationOpts: {numItems, cursor}`, where the
|
|
83
|
+
* cursor is page `i-1`'s `continueCursor`. Pages are concatenated. Page boundaries do not split/merge yet (Phase 4).
|
|
84
|
+
*/
|
|
85
|
+
export function usePaginatedQuery(ref, args, opts) {
|
|
86
|
+
const client = useEmberClient();
|
|
87
|
+
const name = getFunctionName(ref);
|
|
88
|
+
const skip = args === "skip";
|
|
89
|
+
const argsKey = skip ? null : stableStringify(args ?? {});
|
|
90
|
+
const shard = opts.shard;
|
|
91
|
+
const [state, setState] = useState({ key: argsKey, sizes: [opts.initialNumItems] });
|
|
92
|
+
const sizes = state.key === argsKey ? state.sizes : [opts.initialNumItems];
|
|
93
|
+
if (state.key !== argsKey)
|
|
94
|
+
setState({ key: argsKey, sizes });
|
|
95
|
+
// Each page's cursor depends on the previous page's result, so handles are derived from the snapshots.
|
|
96
|
+
const cache = useRef(null);
|
|
97
|
+
const sizesKey = sizes.join(",");
|
|
98
|
+
const handles = useCallback(() => {
|
|
99
|
+
if (skip)
|
|
100
|
+
return [];
|
|
101
|
+
const out = [];
|
|
102
|
+
let cursor = null;
|
|
103
|
+
for (const numItems of sizes) {
|
|
104
|
+
const h = client.watchQuery(name, { ...args, paginationOpts: { numItems, cursor } }, shard === undefined ? undefined : { shard });
|
|
105
|
+
out.push(h);
|
|
106
|
+
const s = h.getSnapshot();
|
|
107
|
+
const v = s.value;
|
|
108
|
+
if (!v || v.isDone || v.continueCursor === null)
|
|
109
|
+
break;
|
|
110
|
+
cursor = v.continueCursor;
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
114
|
+
}, [client, name, argsKey, shard, sizesKey, skip]);
|
|
115
|
+
const subscribe = useCallback((listener) => {
|
|
116
|
+
// Re-derive the handle list whenever a page changes (a new continueCursor adds the next page).
|
|
117
|
+
let offs = [];
|
|
118
|
+
const resubscribe = () => {
|
|
119
|
+
const next = handles().map((h) => h.subscribe(onChange));
|
|
120
|
+
for (const off of offs)
|
|
121
|
+
off();
|
|
122
|
+
offs = next;
|
|
123
|
+
};
|
|
124
|
+
const onChange = () => {
|
|
125
|
+
resubscribe();
|
|
126
|
+
listener();
|
|
127
|
+
};
|
|
128
|
+
resubscribe();
|
|
129
|
+
return () => {
|
|
130
|
+
for (const off of offs)
|
|
131
|
+
off();
|
|
132
|
+
offs = [];
|
|
133
|
+
};
|
|
134
|
+
}, [handles]);
|
|
135
|
+
const getSnapshot = useCallback(() => {
|
|
136
|
+
const snaps = handles().map((h) => h.getSnapshot());
|
|
137
|
+
const prev = cache.current;
|
|
138
|
+
if (prev && prev.snaps.length === snaps.length && prev.snaps.every((s, i) => s === snaps[i]))
|
|
139
|
+
return prev.result;
|
|
140
|
+
const results = [];
|
|
141
|
+
let status = skip ? "LoadingFirstPage" : "CanLoadMore";
|
|
142
|
+
let error;
|
|
143
|
+
for (let i = 0; i < snaps.length; i++) {
|
|
144
|
+
const s = snaps[i];
|
|
145
|
+
if (s.status === "error") {
|
|
146
|
+
error = s.error;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
const v = s.value;
|
|
150
|
+
if (!v) {
|
|
151
|
+
status = i === 0 ? "LoadingFirstPage" : "LoadingMore";
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
results.push(...v.page);
|
|
155
|
+
if (v.isDone)
|
|
156
|
+
status = "Exhausted";
|
|
157
|
+
}
|
|
158
|
+
if (status === "CanLoadMore" && snaps.length < sizes.length)
|
|
159
|
+
status = "LoadingMore";
|
|
160
|
+
const loadMore = (n) => {
|
|
161
|
+
if (status !== "CanLoadMore")
|
|
162
|
+
return;
|
|
163
|
+
setState((st) => ({ key: st.key, sizes: [...st.sizes, n] }));
|
|
164
|
+
};
|
|
165
|
+
const result = { results, status, isLoading: status === "LoadingFirstPage" || status === "LoadingMore", loadMore, error };
|
|
166
|
+
cache.current = { snaps, result };
|
|
167
|
+
return result;
|
|
168
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
169
|
+
}, [handles, skip, sizesKey]);
|
|
170
|
+
const { error, ...result } = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
171
|
+
if (error)
|
|
172
|
+
throw error;
|
|
173
|
+
return result;
|
|
174
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@freecorps/ember-react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "React bindings for Ember: EmberProvider, useQuery, useMutation, useAction, usePaginatedQuery.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ember",
|
|
7
|
+
"convex",
|
|
8
|
+
"cloudflare",
|
|
9
|
+
"workers",
|
|
10
|
+
"react",
|
|
11
|
+
"hooks",
|
|
12
|
+
"realtime"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/freecorps/ember/tree/main/packages/react#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/freecorps/ember/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/freecorps/ember.git",
|
|
21
|
+
"directory": "packages/react"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=22"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@freecorps/ember-client": "0.2.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"react": ">=18"
|
|
48
|
+
}
|
|
49
|
+
}
|