@linkedrecords/react 0.5.0 → 0.7.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/dist/index.d.mts CHANGED
@@ -26,13 +26,33 @@ interface LinkedRecordsProviderProps {
26
26
  }
27
27
  declare function LinkedRecordsProvider({ children, serverUrl, config }: LinkedRecordsProviderProps): react_jsx_runtime.JSX.Element;
28
28
 
29
- interface KVValue {
30
- [key: string]: KVValue | string | boolean | number | undefined;
31
- }
32
- declare function useKeyValueRecords(query: any[]): KVValue[];
29
+ type JSONValue = string | number | boolean | null | JSONValue[] | {
30
+ [key: string]: JSONValue;
31
+ };
32
+ type KVValue = {
33
+ [key: string]: JSONValue | undefined;
34
+ };
35
+ type KVRecord<T extends object = KVValue> = T & {
36
+ _id?: string;
37
+ _createdAt?: Date;
38
+ };
39
+ type CompoundRecordQuery = Parameters<LinkedRecords['Record']['subscribeToQuery']>[0];
40
+ type RecordQueryClause = Exclude<CompoundRecordQuery[string], string>[number];
41
+ declare function useKeyValueRecordsWithStatus<T extends object = KVValue>(query: RecordQueryClause[]): {
42
+ records: KVRecord<T>[];
43
+ isLoading: boolean;
44
+ };
45
+ declare function useKeyValueRecords<T extends object = KVValue>(query: RecordQueryClause[]): KVRecord<T>[];
33
46
  /** @deprecated Use useKeyValueRecords instead. */
34
47
  declare const useKeyValueAttributes: typeof useKeyValueRecords;
35
48
 
49
+ type FactQuery = Parameters<LinkedRecords['Fact']['findAll']>[0];
50
+ type Fact = Awaited<ReturnType<LinkedRecords['Fact']['findAll']>>[number];
51
+ declare function useFacts(query: FactQuery): {
52
+ facts: Fact[];
53
+ reload: () => void;
54
+ };
55
+
36
56
  declare function useLinkedRecords(): LinkedRecordsContextType;
37
57
 
38
58
  type UserInfo = {
@@ -40,4 +60,6 @@ type UserInfo = {
40
60
  };
41
61
  declare function useUserInfo(): UserInfo | null;
42
62
 
43
- export { type LinkedRecordsConfig, LinkedRecordsContext, type LinkedRecordsContextType, LinkedRecordsProvider, useKeyValueAttributes, useKeyValueRecords, useLinkedRecords, useUserInfo };
63
+ declare function useActorId(): string | undefined;
64
+
65
+ export { type KVRecord, type LinkedRecordsConfig, LinkedRecordsContext, type LinkedRecordsContextType, LinkedRecordsProvider, type RecordQueryClause, useActorId, useFacts, useKeyValueAttributes, useKeyValueRecords, useKeyValueRecordsWithStatus, useLinkedRecords, useUserInfo };
package/dist/index.d.ts CHANGED
@@ -26,13 +26,33 @@ interface LinkedRecordsProviderProps {
26
26
  }
27
27
  declare function LinkedRecordsProvider({ children, serverUrl, config }: LinkedRecordsProviderProps): react_jsx_runtime.JSX.Element;
28
28
 
29
- interface KVValue {
30
- [key: string]: KVValue | string | boolean | number | undefined;
31
- }
32
- declare function useKeyValueRecords(query: any[]): KVValue[];
29
+ type JSONValue = string | number | boolean | null | JSONValue[] | {
30
+ [key: string]: JSONValue;
31
+ };
32
+ type KVValue = {
33
+ [key: string]: JSONValue | undefined;
34
+ };
35
+ type KVRecord<T extends object = KVValue> = T & {
36
+ _id?: string;
37
+ _createdAt?: Date;
38
+ };
39
+ type CompoundRecordQuery = Parameters<LinkedRecords['Record']['subscribeToQuery']>[0];
40
+ type RecordQueryClause = Exclude<CompoundRecordQuery[string], string>[number];
41
+ declare function useKeyValueRecordsWithStatus<T extends object = KVValue>(query: RecordQueryClause[]): {
42
+ records: KVRecord<T>[];
43
+ isLoading: boolean;
44
+ };
45
+ declare function useKeyValueRecords<T extends object = KVValue>(query: RecordQueryClause[]): KVRecord<T>[];
33
46
  /** @deprecated Use useKeyValueRecords instead. */
34
47
  declare const useKeyValueAttributes: typeof useKeyValueRecords;
35
48
 
49
+ type FactQuery = Parameters<LinkedRecords['Fact']['findAll']>[0];
50
+ type Fact = Awaited<ReturnType<LinkedRecords['Fact']['findAll']>>[number];
51
+ declare function useFacts(query: FactQuery): {
52
+ facts: Fact[];
53
+ reload: () => void;
54
+ };
55
+
36
56
  declare function useLinkedRecords(): LinkedRecordsContextType;
37
57
 
38
58
  type UserInfo = {
@@ -40,4 +60,6 @@ type UserInfo = {
40
60
  };
41
61
  declare function useUserInfo(): UserInfo | null;
42
62
 
43
- export { type LinkedRecordsConfig, LinkedRecordsContext, type LinkedRecordsContextType, LinkedRecordsProvider, useKeyValueAttributes, useKeyValueRecords, useLinkedRecords, useUserInfo };
63
+ declare function useActorId(): string | undefined;
64
+
65
+ export { type KVRecord, type LinkedRecordsConfig, LinkedRecordsContext, type LinkedRecordsContextType, LinkedRecordsProvider, type RecordQueryClause, useActorId, useFacts, useKeyValueAttributes, useKeyValueRecords, useKeyValueRecordsWithStatus, useLinkedRecords, useUserInfo };
package/dist/index.js CHANGED
@@ -32,8 +32,11 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  LinkedRecordsContext: () => LinkedRecordsContext,
34
34
  LinkedRecordsProvider: () => LinkedRecordsProvider,
35
+ useActorId: () => useActorId,
36
+ useFacts: () => useFacts,
35
37
  useKeyValueAttributes: () => useKeyValueAttributes,
36
38
  useKeyValueRecords: () => useKeyValueRecords,
39
+ useKeyValueRecordsWithStatus: () => useKeyValueRecordsWithStatus,
37
40
  useLinkedRecords: () => useLinkedRecords,
38
41
  useUserInfo: () => useUserInfo
39
42
  });
@@ -63,11 +66,13 @@ function createClient(serverUrl, config) {
63
66
  }
64
67
  function LinkedRecordsProvider({ children, serverUrl, config }) {
65
68
  const [lr] = (0, import_react2.useState)(() => config ? createClient(serverUrl, config) : import_browser.default.getPublicClient(serverUrl));
66
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LinkedRecordsContext.Provider, { value: { lr }, children });
69
+ const value = (0, import_react2.useMemo)(() => ({ lr }), [lr]);
70
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LinkedRecordsContext.Provider, { value, children });
67
71
  }
68
72
 
69
73
  // src/useRecords.ts
70
74
  var import_react4 = require("react");
75
+ var import_browser2 = require("@linkedrecords/browser");
71
76
 
72
77
  // src/useLinkedRecords.ts
73
78
  var import_react3 = require("react");
@@ -79,93 +84,136 @@ function useLinkedRecords() {
79
84
  return context;
80
85
  }
81
86
 
82
- // src/useRecords.ts
83
- var import_browser2 = require("@linkedrecords/browser");
87
+ // src/queryKey.ts
84
88
  function getQueryKey(query) {
85
89
  return JSON.stringify(query, (_, v) => typeof v === "function" ? `fn:${v.name}` : v);
86
90
  }
87
- function useKeyValueRecords(query) {
91
+
92
+ // src/useRecords.ts
93
+ function useKeyValueRecordsWithStatus(query) {
88
94
  const { lr } = useLinkedRecords();
89
95
  const [records, setRecords] = (0, import_react4.useState)([]);
96
+ const [isLoading, setIsLoading] = (0, import_react4.useState)(true);
90
97
  const queryKey = getQueryKey(query);
91
98
  (0, import_react4.useEffect)(() => {
99
+ setIsLoading(true);
92
100
  let cancelled = false;
93
- const subscribedRecordIds = /* @__PURE__ */ new Set();
94
- const unsubscribeFnPromise = new Promise((resolve) => {
95
- const checkActorId = () => {
96
- if (cancelled) {
97
- return;
98
- }
99
- if (lr.actorId !== void 0) {
100
- resolve();
101
- } else {
102
- setTimeout(checkActorId, 50);
101
+ const recordUnsubscribes = /* @__PURE__ */ new Map();
102
+ const toKVRecord = async (r) => ({
103
+ _id: r.id,
104
+ _createdAt: r.createdAt,
105
+ ...await r.getValue()
106
+ });
107
+ const queryUnsubscribe = lr.Record.subscribeToQuery({
108
+ records: [
109
+ ["$it", "$hasDataType", import_browser2.KeyValueRecord],
110
+ ...query
111
+ ]
112
+ }, async ({ records: records2 }) => {
113
+ const kvRecords = records2;
114
+ const values = await Promise.all(kvRecords.map(toKVRecord));
115
+ if (cancelled) {
116
+ return;
117
+ }
118
+ setRecords(values);
119
+ setIsLoading(false);
120
+ const ids = new Set(kvRecords.map((r) => r.id));
121
+ recordUnsubscribes.forEach((unsubscribe, id) => {
122
+ if (!ids.has(id)) {
123
+ recordUnsubscribes.delete(id);
124
+ unsubscribe.then((fn) => fn());
103
125
  }
104
- };
105
- checkActorId();
106
- }).then(() => {
107
- const queryUnsubscribe = lr.Record.subscribeToQuery({
108
- records: [
109
- ["$it", "$hasDataType", import_browser2.KeyValueRecord],
110
- ...query
111
- ]
112
- }, async ({ records: records2 }) => {
113
- const values = await Promise.all(records2.map(async (r) => ({
114
- _id: r.id,
115
- ...await r.getValue()
116
- })));
117
- if (cancelled) {
126
+ });
127
+ kvRecords.forEach((r) => {
128
+ if (recordUnsubscribes.has(r.id)) {
118
129
  return;
119
130
  }
120
- setRecords(values);
121
- records2.forEach((r) => {
122
- if (subscribedRecordIds.has(r.id)) {
131
+ recordUnsubscribes.set(r.id, r.subscribe(async () => {
132
+ const newValue = await toKVRecord(r);
133
+ if (cancelled) {
123
134
  return;
124
135
  }
125
- subscribedRecordIds.add(r.id);
126
- r.subscribe(async () => {
127
- if (cancelled) {
128
- return;
129
- }
130
- const newValue = await r.getValue();
131
- if (cancelled) {
132
- return;
133
- }
134
- setRecords((prev) => prev.map(
135
- (v) => v._id === r.id ? { _id: r.id, ...newValue } : v
136
- ));
137
- });
138
- });
136
+ setRecords((prev) => prev.map((v) => v._id === r.id ? newValue : v));
137
+ }));
139
138
  });
140
- return () => {
141
- queryUnsubscribe.then((unsubscribe) => unsubscribe());
142
- };
139
+ });
140
+ queryUnsubscribe.catch((err) => {
141
+ console.error("useKeyValueRecords: could not subscribe to query", err);
143
142
  });
144
143
  return () => {
145
144
  cancelled = true;
146
- unsubscribeFnPromise.then((fn) => fn());
145
+ queryUnsubscribe.then((unsubscribe) => unsubscribe(), () => {
146
+ });
147
+ recordUnsubscribes.forEach((unsubscribe) => unsubscribe.then((fn) => fn()));
147
148
  };
148
149
  }, [lr, queryKey]);
149
- return records;
150
+ return { records, isLoading };
151
+ }
152
+ function useKeyValueRecords(query) {
153
+ return useKeyValueRecordsWithStatus(query).records;
150
154
  }
151
155
  var useKeyValueAttributes = useKeyValueRecords;
152
156
 
153
- // src/useUserInfo.ts
157
+ // src/useFacts.ts
154
158
  var import_react5 = require("react");
155
- function useUserInfo() {
159
+ function useFacts(query) {
156
160
  const { lr } = useLinkedRecords();
157
- const [userInfo, setUserInfo] = (0, import_react5.useState)(null);
161
+ const [facts, setFacts] = (0, import_react5.useState)([]);
162
+ const [reloadCount, setReloadCount] = (0, import_react5.useState)(0);
163
+ const queryKey = getQueryKey(query);
158
164
  (0, import_react5.useEffect)(() => {
165
+ let cancelled = false;
166
+ lr.Fact.findAll(query).then((result) => {
167
+ if (!cancelled) {
168
+ setFacts(result);
169
+ }
170
+ });
171
+ return () => {
172
+ cancelled = true;
173
+ };
174
+ }, [lr, queryKey, reloadCount]);
175
+ const reload = (0, import_react5.useCallback)(() => setReloadCount((c) => c + 1), []);
176
+ return { facts, reload };
177
+ }
178
+
179
+ // src/useUserInfo.ts
180
+ var import_react6 = require("react");
181
+ function useUserInfo() {
182
+ const { lr } = useLinkedRecords();
183
+ const [userInfo, setUserInfo] = (0, import_react6.useState)(null);
184
+ (0, import_react6.useEffect)(() => {
159
185
  lr.getCurrentUserEmail().then((email) => setUserInfo({ email })).catch(() => setUserInfo(null));
160
186
  }, [lr]);
161
187
  return userInfo;
162
188
  }
189
+
190
+ // src/useActorId.ts
191
+ var import_react7 = require("react");
192
+ function useActorId() {
193
+ const { lr } = useLinkedRecords();
194
+ const [actorId, setActorId] = (0, import_react7.useState)(lr.actorId);
195
+ (0, import_react7.useEffect)(() => {
196
+ let cancelled = false;
197
+ lr.ensureUserIdIsKnown().then((id) => {
198
+ if (!cancelled) {
199
+ setActorId(id);
200
+ }
201
+ });
202
+ return () => {
203
+ cancelled = true;
204
+ };
205
+ }, [lr]);
206
+ return actorId;
207
+ }
163
208
  // Annotate the CommonJS export names for ESM import in node:
164
209
  0 && (module.exports = {
165
210
  LinkedRecordsContext,
166
211
  LinkedRecordsProvider,
212
+ useActorId,
213
+ useFacts,
167
214
  useKeyValueAttributes,
168
215
  useKeyValueRecords,
216
+ useKeyValueRecordsWithStatus,
169
217
  useLinkedRecords,
170
218
  useUserInfo
171
219
  });
package/dist/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { createContext } from "react";
3
3
  var LinkedRecordsContext = createContext(void 0);
4
4
 
5
5
  // src/LinkedRecordsProvider.tsx
6
- import { useState } from "react";
6
+ import { useMemo, useState } from "react";
7
7
  import LinkedRecords from "@linkedrecords/browser";
8
8
  import { jsx } from "react/jsx-runtime";
9
9
  function createClient(serverUrl, config) {
@@ -22,11 +22,13 @@ function createClient(serverUrl, config) {
22
22
  }
23
23
  function LinkedRecordsProvider({ children, serverUrl, config }) {
24
24
  const [lr] = useState(() => config ? createClient(serverUrl, config) : LinkedRecords.getPublicClient(serverUrl));
25
- return /* @__PURE__ */ jsx(LinkedRecordsContext.Provider, { value: { lr }, children });
25
+ const value = useMemo(() => ({ lr }), [lr]);
26
+ return /* @__PURE__ */ jsx(LinkedRecordsContext.Provider, { value, children });
26
27
  }
27
28
 
28
29
  // src/useRecords.ts
29
30
  import { useEffect, useState as useState2 } from "react";
31
+ import { KeyValueRecord } from "@linkedrecords/browser";
30
32
 
31
33
  // src/useLinkedRecords.ts
32
34
  import { useContext } from "react";
@@ -38,92 +40,135 @@ function useLinkedRecords() {
38
40
  return context;
39
41
  }
40
42
 
41
- // src/useRecords.ts
42
- import { KeyValueRecord } from "@linkedrecords/browser";
43
+ // src/queryKey.ts
43
44
  function getQueryKey(query) {
44
45
  return JSON.stringify(query, (_, v) => typeof v === "function" ? `fn:${v.name}` : v);
45
46
  }
46
- function useKeyValueRecords(query) {
47
+
48
+ // src/useRecords.ts
49
+ function useKeyValueRecordsWithStatus(query) {
47
50
  const { lr } = useLinkedRecords();
48
51
  const [records, setRecords] = useState2([]);
52
+ const [isLoading, setIsLoading] = useState2(true);
49
53
  const queryKey = getQueryKey(query);
50
54
  useEffect(() => {
55
+ setIsLoading(true);
51
56
  let cancelled = false;
52
- const subscribedRecordIds = /* @__PURE__ */ new Set();
53
- const unsubscribeFnPromise = new Promise((resolve) => {
54
- const checkActorId = () => {
55
- if (cancelled) {
56
- return;
57
- }
58
- if (lr.actorId !== void 0) {
59
- resolve();
60
- } else {
61
- setTimeout(checkActorId, 50);
57
+ const recordUnsubscribes = /* @__PURE__ */ new Map();
58
+ const toKVRecord = async (r) => ({
59
+ _id: r.id,
60
+ _createdAt: r.createdAt,
61
+ ...await r.getValue()
62
+ });
63
+ const queryUnsubscribe = lr.Record.subscribeToQuery({
64
+ records: [
65
+ ["$it", "$hasDataType", KeyValueRecord],
66
+ ...query
67
+ ]
68
+ }, async ({ records: records2 }) => {
69
+ const kvRecords = records2;
70
+ const values = await Promise.all(kvRecords.map(toKVRecord));
71
+ if (cancelled) {
72
+ return;
73
+ }
74
+ setRecords(values);
75
+ setIsLoading(false);
76
+ const ids = new Set(kvRecords.map((r) => r.id));
77
+ recordUnsubscribes.forEach((unsubscribe, id) => {
78
+ if (!ids.has(id)) {
79
+ recordUnsubscribes.delete(id);
80
+ unsubscribe.then((fn) => fn());
62
81
  }
63
- };
64
- checkActorId();
65
- }).then(() => {
66
- const queryUnsubscribe = lr.Record.subscribeToQuery({
67
- records: [
68
- ["$it", "$hasDataType", KeyValueRecord],
69
- ...query
70
- ]
71
- }, async ({ records: records2 }) => {
72
- const values = await Promise.all(records2.map(async (r) => ({
73
- _id: r.id,
74
- ...await r.getValue()
75
- })));
76
- if (cancelled) {
82
+ });
83
+ kvRecords.forEach((r) => {
84
+ if (recordUnsubscribes.has(r.id)) {
77
85
  return;
78
86
  }
79
- setRecords(values);
80
- records2.forEach((r) => {
81
- if (subscribedRecordIds.has(r.id)) {
87
+ recordUnsubscribes.set(r.id, r.subscribe(async () => {
88
+ const newValue = await toKVRecord(r);
89
+ if (cancelled) {
82
90
  return;
83
91
  }
84
- subscribedRecordIds.add(r.id);
85
- r.subscribe(async () => {
86
- if (cancelled) {
87
- return;
88
- }
89
- const newValue = await r.getValue();
90
- if (cancelled) {
91
- return;
92
- }
93
- setRecords((prev) => prev.map(
94
- (v) => v._id === r.id ? { _id: r.id, ...newValue } : v
95
- ));
96
- });
97
- });
92
+ setRecords((prev) => prev.map((v) => v._id === r.id ? newValue : v));
93
+ }));
98
94
  });
99
- return () => {
100
- queryUnsubscribe.then((unsubscribe) => unsubscribe());
101
- };
95
+ });
96
+ queryUnsubscribe.catch((err) => {
97
+ console.error("useKeyValueRecords: could not subscribe to query", err);
102
98
  });
103
99
  return () => {
104
100
  cancelled = true;
105
- unsubscribeFnPromise.then((fn) => fn());
101
+ queryUnsubscribe.then((unsubscribe) => unsubscribe(), () => {
102
+ });
103
+ recordUnsubscribes.forEach((unsubscribe) => unsubscribe.then((fn) => fn()));
106
104
  };
107
105
  }, [lr, queryKey]);
108
- return records;
106
+ return { records, isLoading };
107
+ }
108
+ function useKeyValueRecords(query) {
109
+ return useKeyValueRecordsWithStatus(query).records;
109
110
  }
110
111
  var useKeyValueAttributes = useKeyValueRecords;
111
112
 
113
+ // src/useFacts.ts
114
+ import { useCallback, useEffect as useEffect2, useState as useState3 } from "react";
115
+ function useFacts(query) {
116
+ const { lr } = useLinkedRecords();
117
+ const [facts, setFacts] = useState3([]);
118
+ const [reloadCount, setReloadCount] = useState3(0);
119
+ const queryKey = getQueryKey(query);
120
+ useEffect2(() => {
121
+ let cancelled = false;
122
+ lr.Fact.findAll(query).then((result) => {
123
+ if (!cancelled) {
124
+ setFacts(result);
125
+ }
126
+ });
127
+ return () => {
128
+ cancelled = true;
129
+ };
130
+ }, [lr, queryKey, reloadCount]);
131
+ const reload = useCallback(() => setReloadCount((c) => c + 1), []);
132
+ return { facts, reload };
133
+ }
134
+
112
135
  // src/useUserInfo.ts
113
- import { useEffect as useEffect2, useState as useState3 } from "react";
136
+ import { useEffect as useEffect3, useState as useState4 } from "react";
114
137
  function useUserInfo() {
115
138
  const { lr } = useLinkedRecords();
116
- const [userInfo, setUserInfo] = useState3(null);
117
- useEffect2(() => {
139
+ const [userInfo, setUserInfo] = useState4(null);
140
+ useEffect3(() => {
118
141
  lr.getCurrentUserEmail().then((email) => setUserInfo({ email })).catch(() => setUserInfo(null));
119
142
  }, [lr]);
120
143
  return userInfo;
121
144
  }
145
+
146
+ // src/useActorId.ts
147
+ import { useEffect as useEffect4, useState as useState5 } from "react";
148
+ function useActorId() {
149
+ const { lr } = useLinkedRecords();
150
+ const [actorId, setActorId] = useState5(lr.actorId);
151
+ useEffect4(() => {
152
+ let cancelled = false;
153
+ lr.ensureUserIdIsKnown().then((id) => {
154
+ if (!cancelled) {
155
+ setActorId(id);
156
+ }
157
+ });
158
+ return () => {
159
+ cancelled = true;
160
+ };
161
+ }, [lr]);
162
+ return actorId;
163
+ }
122
164
  export {
123
165
  LinkedRecordsContext,
124
166
  LinkedRecordsProvider,
167
+ useActorId,
168
+ useFacts,
125
169
  useKeyValueAttributes,
126
170
  useKeyValueRecords,
171
+ useKeyValueRecordsWithStatus,
127
172
  useLinkedRecords,
128
173
  useUserInfo
129
174
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linkedrecords/react",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -25,11 +25,11 @@
25
25
  "license": "MIT",
26
26
  "description": "",
27
27
  "peerDependencies": {
28
- "@linkedrecords/browser": "^0.3.0",
28
+ "@linkedrecords/browser": "^0.4.0",
29
29
  "react": ">=18"
30
30
  },
31
31
  "devDependencies": {
32
- "@linkedrecords/browser": "^0.3.0",
32
+ "@linkedrecords/browser": "^0.4.0",
33
33
  "@types/react": "^19.1.12",
34
34
  "react": "^19.1.1",
35
35
  "tsup": "^8.5.0",
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { type ReactNode, useState } from 'react';
2
+ import { type ReactNode, useMemo, useState } from 'react';
3
3
  import LinkedRecords from '@linkedrecords/browser';
4
4
  import { LinkedRecordsContext } from './linkedRecordsContext';
5
5
 
@@ -47,9 +47,11 @@ export function LinkedRecordsProvider({ children, serverUrl, config }: LinkedRec
47
47
  const [lr] = useState(() => (config
48
48
  ? createClient(serverUrl, config)
49
49
  : LinkedRecords.getPublicClient(serverUrl)));
50
+ // A new context value would re-render every consumer on each render.
51
+ const value = useMemo(() => ({ lr }), [lr]);
50
52
 
51
53
  return (
52
- <LinkedRecordsContext.Provider value={{ lr }}>
54
+ <LinkedRecordsContext.Provider value={value}>
53
55
  {children}
54
56
  </LinkedRecordsContext.Provider>
55
57
  );
package/src/index.tsx CHANGED
@@ -1,5 +1,7 @@
1
1
  export * from './linkedRecordsContext';
2
2
  export * from './LinkedRecordsProvider';
3
3
  export * from './useRecords';
4
+ export * from './useFacts';
4
5
  export * from './useLinkedRecords';
5
6
  export * from './useUserInfo';
7
+ export * from './useActorId';
@@ -0,0 +1,6 @@
1
+ // Callers usually pass a new query on every render, so effects depend on a
2
+ // serialized key instead of the query identity. Functions (e.g. record
3
+ // classes) would serialize to null, so they are keyed by name.
4
+ export function getQueryKey(query: unknown): string {
5
+ return JSON.stringify(query, (_, v) => (typeof v === 'function' ? `fn:${v.name}` : v));
6
+ }
@@ -0,0 +1,24 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useLinkedRecords } from "./useLinkedRecords";
3
+
4
+ // The id of the logged in user, undefined until it is known.
5
+ export function useActorId(): string | undefined {
6
+ const { lr } = useLinkedRecords();
7
+ const [ actorId, setActorId ] = useState<string | undefined>(lr.actorId);
8
+
9
+ useEffect(() => {
10
+ let cancelled = false;
11
+
12
+ lr.ensureUserIdIsKnown().then((id) => {
13
+ if (!cancelled) {
14
+ setActorId(id);
15
+ }
16
+ });
17
+
18
+ return () => {
19
+ cancelled = true;
20
+ }
21
+ }, [ lr ]);
22
+
23
+ return actorId;
24
+ }
@@ -0,0 +1,39 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import type LinkedRecords from '@linkedrecords/browser';
3
+ import { useLinkedRecords } from "./useLinkedRecords";
4
+ import { getQueryKey } from "./queryKey";
5
+
6
+ type FactQuery = Parameters<LinkedRecords['Fact']['findAll']>[0];
7
+ type Fact = Awaited<ReturnType<LinkedRecords['Fact']['findAll']>>[number];
8
+
9
+ // The SDK can not subscribe to facts, so the facts are fetched whenever the
10
+ // query changes. Call reload after writing facts which affect the result.
11
+ export function useFacts(query: FactQuery): { facts: Fact[], reload: () => void } {
12
+ const { lr } = useLinkedRecords();
13
+ const [ facts, setFacts ] = useState<Fact[]>([]);
14
+ const [ reloadCount, setReloadCount ] = useState(0);
15
+ const queryKey = getQueryKey(query);
16
+
17
+ useEffect(() => {
18
+ // Ignore responses which arrive after the query changed or the
19
+ // component unmounted.
20
+ let cancelled = false;
21
+
22
+ lr.Fact.findAll(query).then((result) => {
23
+ if (!cancelled) {
24
+ setFacts(result);
25
+ }
26
+ });
27
+
28
+ return () => {
29
+ cancelled = true;
30
+ }
31
+ // query is represented by queryKey; depending on the object itself would
32
+ // refetch on every render.
33
+ // eslint-disable-next-line react-hooks/exhaustive-deps
34
+ }, [ lr, queryKey, reloadCount ]);
35
+
36
+ const reload = useCallback(() => setReloadCount((c) => c + 1), []);
37
+
38
+ return { facts, reload };
39
+ }
package/src/useRecords.ts CHANGED
@@ -1,106 +1,107 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
1
  import { useEffect, useState } from "react";
3
- import { useLinkedRecords } from "./useLinkedRecords";
2
+ import type LinkedRecords from '@linkedrecords/browser';
4
3
  import { KeyValueRecord } from '@linkedrecords/browser';
4
+ import { useLinkedRecords } from "./useLinkedRecords";
5
+ import { getQueryKey } from "./queryKey";
5
6
 
6
- interface KVValue {
7
- [key: string]: KVValue | string | boolean | number | undefined;
8
- }
7
+ type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue };
9
8
 
10
- // Callers usually pass a new query array on every render, so the effect
11
- // depends on a serialized key instead of the array identity. Functions
12
- // (e.g. record classes) would serialize to null, so they are keyed by name.
13
- function getQueryKey(query: any[]): string {
14
- return JSON.stringify(query, (_, v) => (typeof v === 'function' ? `fn:${v.name}` : v));
15
- }
9
+ type KVValue = { [key: string]: JSONValue | undefined };
10
+
11
+ export type KVRecord<T extends object = KVValue> = T & {
12
+ _id?: string;
13
+ _createdAt?: Date;
14
+ };
16
15
 
17
- export function useKeyValueRecords(query: any[]): KVValue[] {
16
+ type CompoundRecordQuery = Parameters<LinkedRecords['Record']['subscribeToQuery']>[0];
17
+
18
+ // One [subject, predicate, object] clause of a record query, e.g. ['$it', 'isA', 'Todo'].
19
+ export type RecordQueryClause = Exclude<CompoundRecordQuery[string], string>[number];
20
+
21
+ export function useKeyValueRecordsWithStatus<T extends object = KVValue>(query: RecordQueryClause[]): { records: KVRecord<T>[], isLoading: boolean } {
18
22
  const { lr } = useLinkedRecords();
19
- const [ records, setRecords ] = useState<KVValue[]>([]);
23
+ const [ records, setRecords ] = useState<KVRecord<T>[]>([]);
24
+ const [ isLoading, setIsLoading ] = useState(true);
20
25
  const queryKey = getQueryKey(query);
21
26
 
22
27
  useEffect(() => {
23
- // The SDK has no way to remove a record observer, so observers registered
24
- // by this effect run check this flag and become no-ops after cleanup.
25
- let cancelled = false;
26
- // Records this effect run already observes, so repeated query results
27
- // do not stack up observers on the same record.
28
- const subscribedRecordIds = new Set<string | undefined>();
28
+ setIsLoading(true);
29
29
 
30
- const unsubscribeFnPromise = new Promise<void>((resolve) => {
31
- const checkActorId = () => {
32
- if (cancelled) {
33
- return;
30
+ // Values are read asynchronously; results which arrive after the query
31
+ // changed or the component unmounted are dropped.
32
+ let cancelled = false;
33
+ // Record observers by record id, so each record in the result is
34
+ // observed once and records which leave the result are unobserved.
35
+ const recordUnsubscribes = new Map<string | undefined, Promise<() => void>>();
36
+
37
+ // T is the value shape declared by the caller; the hook can not check it.
38
+ const toKVRecord = async (r: KeyValueRecord) => ({
39
+ _id: r.id,
40
+ _createdAt: r.createdAt,
41
+ ...(await r.getValue()),
42
+ }) as KVRecord<T>;
43
+
44
+ const queryUnsubscribe = lr.Record.subscribeToQuery({
45
+ records: [
46
+ ['$it', '$hasDataType', KeyValueRecord],
47
+ ...query
48
+ ],
49
+ }, async ({ records }) => {
50
+ const kvRecords = records as KeyValueRecord[];
51
+ const values = await Promise.all(kvRecords.map(toKVRecord));
52
+
53
+ if (cancelled) {
54
+ return;
55
+ }
56
+
57
+ setRecords(values);
58
+ setIsLoading(false);
59
+
60
+ const ids = new Set(kvRecords.map((r) => r.id));
61
+
62
+ recordUnsubscribes.forEach((unsubscribe, id) => {
63
+ if (!ids.has(id)) {
64
+ recordUnsubscribes.delete(id);
65
+ unsubscribe.then((fn) => fn());
34
66
  }
67
+ });
35
68
 
36
- if (lr.actorId !== undefined) {
37
- resolve();
38
- } else {
39
- setTimeout(checkActorId, 50);
40
- }
41
- };
42
- checkActorId();
43
- }).then(() => {
44
- const queryUnsubscribe = lr.Record.subscribeToQuery({
45
- records: [
46
- ['$it', '$hasDataType', KeyValueRecord],
47
- ...query
48
- ],
49
- }, async ({ records }) => {
50
- const values = await Promise.all(records.map(async (r) => ({
51
- _id: r.id,
52
- ...(await r.getValue()),
53
- })));
54
-
55
- if (cancelled) {
69
+ kvRecords.forEach((r) => {
70
+ if (recordUnsubscribes.has(r.id)) {
56
71
  return;
57
72
  }
58
73
 
59
- setRecords(values);
74
+ recordUnsubscribes.set(r.id, r.subscribe(async () => {
75
+ const newValue = await toKVRecord(r);
60
76
 
61
- records.forEach((r) => {
62
- if (subscribedRecordIds.has(r.id)) {
77
+ if (cancelled) {
63
78
  return;
64
79
  }
65
80
 
66
- subscribedRecordIds.add(r.id);
67
-
68
- r.subscribe(async () => {
69
- if (cancelled) {
70
- return;
71
- }
72
-
73
- const newValue = await r.getValue();
74
-
75
- if (cancelled) {
76
- return;
77
- }
78
-
79
- // Use functional update to avoid stale closure
80
- setRecords(prev => prev.map(v =>
81
- v._id === r.id
82
- ? { _id: r.id, ...newValue }
83
- : v
84
- ));
85
- });
86
- });
81
+ setRecords(prev => prev.map(v => (v._id === r.id ? newValue : v)));
82
+ }));
87
83
  });
84
+ });
88
85
 
89
- return () => {
90
- queryUnsubscribe.then(unsubscribe => unsubscribe());
91
- };
86
+ queryUnsubscribe.catch((err) => {
87
+ console.error('useKeyValueRecords: could not subscribe to query', err);
92
88
  });
93
89
 
94
90
  return () => {
95
91
  cancelled = true;
96
- unsubscribeFnPromise.then(fn => fn());
92
+ queryUnsubscribe.then((unsubscribe) => unsubscribe(), () => {});
93
+ recordUnsubscribes.forEach((unsubscribe) => unsubscribe.then((fn) => fn()));
97
94
  }
98
95
  // query is represented by queryKey; depending on the array itself would
99
96
  // resubscribe on every render.
100
97
  // eslint-disable-next-line react-hooks/exhaustive-deps
101
98
  }, [ lr, queryKey ]);
102
99
 
103
- return records;
100
+ return { records, isLoading };
101
+ }
102
+
103
+ export function useKeyValueRecords<T extends object = KVValue>(query: RecordQueryClause[]): KVRecord<T>[] {
104
+ return useKeyValueRecordsWithStatus<T>(query).records;
104
105
  }
105
106
 
106
107
  /** @deprecated Use useKeyValueRecords instead. */