@terreno/rtk 0.13.3 → 0.14.1
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/authSlice.d.ts +4 -4
- package/dist/authSlice.d.ts.map +1 -1
- package/dist/authSlice.js +7 -7
- package/dist/authSlice.js.map +1 -1
- package/dist/authSlice.test.js +2 -10
- package/dist/authSlice.test.js.map +1 -1
- package/dist/authSliceNative.test.js +11 -3
- package/dist/authSliceNative.test.js.map +1 -1
- package/dist/betterAuthSlice.d.ts.map +1 -1
- package/dist/betterAuthSlice.js +3 -2
- package/dist/betterAuthSlice.js.map +1 -1
- package/dist/betterAuthSlice.test.js +1 -25
- package/dist/betterAuthSlice.test.js.map +1 -1
- package/dist/constants.d.ts +3 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +11 -3
- package/dist/constants.js.map +1 -1
- package/dist/constants.test.js +85 -2
- package/dist/constants.test.js.map +1 -1
- package/dist/emptyApi.d.ts +3 -1
- package/dist/emptyApi.d.ts.map +1 -1
- package/dist/emptyApi.js +7 -2
- package/dist/emptyApi.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/isolated/constants.isolated.js +7 -1
- package/dist/isolated/constants.isolated.js.map +1 -1
- package/dist/isolated/offlineMiddleware.isolated.d.ts +2 -0
- package/dist/isolated/offlineMiddleware.isolated.d.ts.map +1 -0
- package/dist/isolated/offlineMiddleware.isolated.js +516 -0
- package/dist/isolated/offlineMiddleware.isolated.js.map +1 -0
- package/dist/isolated/offlineMiddleware.native.isolated.d.ts +2 -0
- package/dist/isolated/offlineMiddleware.native.isolated.d.ts.map +1 -0
- package/dist/isolated/offlineMiddleware.native.isolated.js +70 -0
- package/dist/isolated/offlineMiddleware.native.isolated.js.map +1 -0
- package/dist/isolated/useUpgradeCheck.mobile.isolated.d.ts +2 -0
- package/dist/isolated/useUpgradeCheck.mobile.isolated.d.ts.map +1 -0
- package/dist/{useUpgradeCheck.mobile.test.js → isolated/useUpgradeCheck.mobile.isolated.js} +13 -5
- package/dist/isolated/useUpgradeCheck.mobile.isolated.js.map +1 -0
- package/dist/isolated/useUpgradeCheck.web.isolated.d.ts +2 -0
- package/dist/isolated/useUpgradeCheck.web.isolated.d.ts.map +1 -0
- package/dist/{useUpgradeCheck.test.js → isolated/useUpgradeCheck.web.isolated.js} +37 -7
- package/dist/isolated/useUpgradeCheck.web.isolated.js.map +1 -0
- package/dist/mongooseSlice.d.ts +3 -2
- package/dist/mongooseSlice.d.ts.map +1 -1
- package/dist/mongooseSlice.js +3 -4
- package/dist/mongooseSlice.js.map +1 -1
- package/dist/mongooseSlice.test.js +1 -1
- package/dist/mongooseSlice.test.js.map +1 -1
- package/dist/offlineGate.d.ts +8 -0
- package/dist/offlineGate.d.ts.map +1 -0
- package/dist/offlineGate.js +20 -0
- package/dist/offlineGate.js.map +1 -0
- package/dist/offlineGate.test.d.ts +2 -0
- package/dist/offlineGate.test.d.ts.map +1 -0
- package/dist/offlineGate.test.js +46 -0
- package/dist/offlineGate.test.js.map +1 -0
- package/dist/offlineMiddleware.d.ts +46 -0
- package/dist/offlineMiddleware.d.ts.map +1 -0
- package/dist/offlineMiddleware.js +589 -0
- package/dist/offlineMiddleware.js.map +1 -0
- package/dist/offlineMiddleware.test.d.ts +2 -0
- package/dist/offlineMiddleware.test.d.ts.map +1 -0
- package/dist/offlineMiddleware.test.js +145 -0
- package/dist/offlineMiddleware.test.js.map +1 -0
- package/dist/offlineSlice.d.ts +232 -0
- package/dist/offlineSlice.d.ts.map +1 -0
- package/dist/offlineSlice.js +65 -0
- package/dist/offlineSlice.js.map +1 -0
- package/dist/offlineSlice.test.d.ts +2 -0
- package/dist/offlineSlice.test.d.ts.map +1 -0
- package/dist/offlineSlice.test.js +153 -0
- package/dist/offlineSlice.test.js.map +1 -0
- package/dist/realtime.d.ts +123 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +299 -0
- package/dist/realtime.js.map +1 -0
- package/dist/realtime.test.d.ts +2 -0
- package/dist/realtime.test.d.ts.map +1 -0
- package/dist/realtime.test.js +339 -0
- package/dist/realtime.test.js.map +1 -0
- package/dist/socket.d.ts.map +1 -1
- package/dist/socket.js +29 -28
- package/dist/socket.js.map +1 -1
- package/dist/sync.d.ts +35 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +205 -0
- package/dist/sync.js.map +1 -0
- package/dist/tagGenerator.d.ts +20 -1
- package/dist/tagGenerator.d.ts.map +1 -1
- package/dist/tagGenerator.js +4 -12
- package/dist/tagGenerator.js.map +1 -1
- package/dist/useFeatureFlags.d.ts +4 -2
- package/dist/useFeatureFlags.d.ts.map +1 -1
- package/dist/useFeatureFlags.js +3 -5
- package/dist/useFeatureFlags.js.map +1 -1
- package/dist/useOfflineStatus.d.ts +33 -0
- package/dist/useOfflineStatus.d.ts.map +1 -0
- package/dist/useOfflineStatus.js +43 -0
- package/dist/useOfflineStatus.js.map +1 -0
- package/dist/useOfflineStatus.test.d.ts +2 -0
- package/dist/useOfflineStatus.test.d.ts.map +1 -0
- package/dist/useOfflineStatus.test.js +119 -0
- package/dist/useOfflineStatus.test.js.map +1 -0
- package/dist/useRealtimeDebug.d.ts +6 -0
- package/dist/useRealtimeDebug.d.ts.map +1 -0
- package/dist/useRealtimeDebug.js +41 -0
- package/dist/useRealtimeDebug.js.map +1 -0
- package/dist/useServerStatus.d.ts +29 -0
- package/dist/useServerStatus.d.ts.map +1 -0
- package/dist/useServerStatus.js +89 -0
- package/dist/useServerStatus.js.map +1 -0
- package/dist/useUpgradeCheck.d.ts +7 -3
- package/dist/useUpgradeCheck.d.ts.map +1 -1
- package/dist/useUpgradeCheck.js +27 -13
- package/dist/useUpgradeCheck.js.map +1 -1
- package/package.json +2 -2
- package/src/authSlice.test.ts +2 -11
- package/src/authSlice.ts +9 -9
- package/src/authSliceNative.test.ts +11 -4
- package/src/betterAuthSlice.test.ts +1 -25
- package/src/betterAuthSlice.ts +3 -2
- package/src/constants.test.ts +99 -1
- package/src/constants.ts +14 -3
- package/src/emptyApi.ts +11 -3
- package/src/index.ts +8 -0
- package/src/isolated/constants.isolated.ts +7 -3
- package/src/isolated/offlineMiddleware.isolated.ts +696 -0
- package/src/isolated/offlineMiddleware.native.isolated.ts +80 -0
- package/src/{useUpgradeCheck.mobile.test.ts → isolated/useUpgradeCheck.mobile.isolated.ts} +13 -4
- package/src/{useUpgradeCheck.test.ts → isolated/useUpgradeCheck.web.isolated.ts} +48 -6
- package/src/mongooseSlice.test.ts +1 -1
- package/src/mongooseSlice.ts +4 -5
- package/src/offlineGate.test.ts +55 -0
- package/src/offlineGate.ts +25 -0
- package/src/offlineMiddleware.test.ts +187 -0
- package/src/offlineMiddleware.ts +716 -0
- package/src/offlineSlice.test.ts +204 -0
- package/src/offlineSlice.ts +130 -0
- package/src/realtime.test.ts +404 -0
- package/src/realtime.ts +408 -0
- package/src/socket.ts +33 -32
- package/src/sync.ts +310 -0
- package/src/tagGenerator.ts +30 -18
- package/src/useFeatureFlags.ts +6 -6
- package/src/useOfflineStatus.test.ts +157 -0
- package/src/useOfflineStatus.ts +82 -0
- package/src/useRealtimeDebug.ts +51 -0
- package/src/useServerStatus.ts +122 -0
- package/src/useUpgradeCheck.ts +35 -13
- package/dist/isolated/useUpgradeCheck.isolated.d.ts +0 -2
- package/dist/isolated/useUpgradeCheck.isolated.d.ts.map +0 -1
- package/dist/isolated/useUpgradeCheck.isolated.js +0 -153
- package/dist/isolated/useUpgradeCheck.isolated.js.map +0 -1
- package/dist/useUpgradeCheck.mobile.test.d.ts +0 -2
- package/dist/useUpgradeCheck.mobile.test.d.ts.map +0 -1
- package/dist/useUpgradeCheck.mobile.test.js.map +0 -1
- package/dist/useUpgradeCheck.test.d.ts +0 -2
- package/dist/useUpgradeCheck.test.d.ts.map +0 -1
- package/dist/useUpgradeCheck.test.js.map +0 -1
- package/src/isolated/useUpgradeCheck.isolated.ts +0 -201
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
import {createListenerMiddleware, type Middleware} from "@reduxjs/toolkit";
|
|
2
|
+
import type {Api} from "@reduxjs/toolkit/query/react";
|
|
3
|
+
import {DateTime} from "luxon";
|
|
4
|
+
|
|
5
|
+
import {getAuthToken, selectCurrentUserId} from "./authSlice";
|
|
6
|
+
import {baseUrl, LOGOUT_ACTION_TYPE, type RootState} from "./constants";
|
|
7
|
+
import {configureOfflineMutationEndpoints} from "./offlineGate";
|
|
8
|
+
import {
|
|
9
|
+
addConflict,
|
|
10
|
+
type ConflictRecord,
|
|
11
|
+
clearConflicts,
|
|
12
|
+
clearQueue,
|
|
13
|
+
dequeue,
|
|
14
|
+
enqueue,
|
|
15
|
+
type OfflineState,
|
|
16
|
+
offlineReducer,
|
|
17
|
+
offlineSlice,
|
|
18
|
+
type QueuedMutation,
|
|
19
|
+
selectIsOnline,
|
|
20
|
+
selectIsSyncing,
|
|
21
|
+
selectOfflineQueue,
|
|
22
|
+
setOnlineStatus,
|
|
23
|
+
setSyncing,
|
|
24
|
+
} from "./offlineSlice";
|
|
25
|
+
import {IsWeb} from "./platform";
|
|
26
|
+
|
|
27
|
+
export interface OfflineMiddlewareConfig {
|
|
28
|
+
/** RTK Query mutation endpoint names to queue when offline */
|
|
29
|
+
endpoints: string[];
|
|
30
|
+
/** The RTK Query API instance */
|
|
31
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic API type
|
|
32
|
+
api: Api<any, any, any, any>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Infer the CRUD operation type from an RTK Query endpoint name.
|
|
37
|
+
* Convention: postX = create, patchX = update, deleteX = delete.
|
|
38
|
+
*/
|
|
39
|
+
const inferMutationType = (endpointName: string): "create" | "update" | "delete" => {
|
|
40
|
+
if (endpointName.startsWith("post")) {
|
|
41
|
+
return "create";
|
|
42
|
+
}
|
|
43
|
+
if (endpointName.startsWith("patch")) {
|
|
44
|
+
return "update";
|
|
45
|
+
}
|
|
46
|
+
if (endpointName.startsWith("delete")) {
|
|
47
|
+
return "delete";
|
|
48
|
+
}
|
|
49
|
+
return "update";
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* True when a rejected RTK Query action failed due to a network/transport error,
|
|
54
|
+
* not an auth or application-level FETCH_ERROR from emptyApi.
|
|
55
|
+
*/
|
|
56
|
+
export const isNetworkFetchError = (
|
|
57
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query error shapes vary by source
|
|
58
|
+
source: any
|
|
59
|
+
): boolean => {
|
|
60
|
+
if (source?.error?.name === "TypeError") {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const NETWORK_PATTERNS = [
|
|
65
|
+
"failed to fetch",
|
|
66
|
+
"fetch failed",
|
|
67
|
+
"network error",
|
|
68
|
+
"network unavailable",
|
|
69
|
+
"load failed",
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const candidates: string[] = [];
|
|
73
|
+
if (typeof source?.error?.message === "string") {
|
|
74
|
+
candidates.push(source.error.message);
|
|
75
|
+
}
|
|
76
|
+
if (typeof source?.error === "string") {
|
|
77
|
+
candidates.push(source.error);
|
|
78
|
+
}
|
|
79
|
+
if (typeof source?.payload?.error === "string") {
|
|
80
|
+
candidates.push(source.payload.error);
|
|
81
|
+
}
|
|
82
|
+
if (typeof source?.status === "string" && source.status === "FETCH_ERROR") {
|
|
83
|
+
if (typeof source?.error === "string") {
|
|
84
|
+
candidates.push(source.error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const combined = candidates.join(" ").toLowerCase();
|
|
89
|
+
return NETWORK_PATTERNS.some((p) => combined.includes(p));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether a queued mutation may be replayed for the currently signed-in user.
|
|
94
|
+
* Legacy entries without userId are discarded to avoid cross-account replay after account switch.
|
|
95
|
+
*/
|
|
96
|
+
export const shouldReplayQueuedMutation = (
|
|
97
|
+
mutation: QueuedMutation,
|
|
98
|
+
currentUserId: string | undefined
|
|
99
|
+
): boolean => {
|
|
100
|
+
if (!currentUserId) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (mutation.userId === undefined) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return mutation.userId === currentUserId;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Optimistic list item for a queued create; temp IDs are applied after body spread. */
|
|
110
|
+
export const buildOptimisticCreateItem = (
|
|
111
|
+
mutation: QueuedMutation,
|
|
112
|
+
body?: Record<string, unknown>
|
|
113
|
+
): Record<string, unknown> => {
|
|
114
|
+
const tempId = `temp-${mutation.id}`;
|
|
115
|
+
return {
|
|
116
|
+
...(body ?? {}),
|
|
117
|
+
_id: tempId,
|
|
118
|
+
created: mutation.timestamp,
|
|
119
|
+
id: tempId,
|
|
120
|
+
updated: mutation.timestamp,
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Derives the collection tag from an endpoint name (e.g. "patchTodosById" → "todos"). */
|
|
125
|
+
const inferTagType = (endpointName: string): string => {
|
|
126
|
+
let name = endpointName.replace(/^(post|patch|delete)/, "").replace(/ById$/, "");
|
|
127
|
+
name = name.charAt(0).toLowerCase() + name.slice(1);
|
|
128
|
+
return name;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const inferGetByIdEndpointName = (endpointName: string): string => {
|
|
132
|
+
const tagType = inferTagType(endpointName);
|
|
133
|
+
const capitalized = tagType.charAt(0).toUpperCase() + tagType.slice(1);
|
|
134
|
+
return `get${capitalized}ById`;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Last-seen document `updated` timestamp for conflict headers on replay.
|
|
139
|
+
* Prefer the patch body's `_updatedAt`, then a cached get-by-id entry.
|
|
140
|
+
*/
|
|
141
|
+
const extractBaseUpdatedAt = (
|
|
142
|
+
endpointName: string,
|
|
143
|
+
originalArgs: unknown,
|
|
144
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic getState
|
|
145
|
+
getState: () => any,
|
|
146
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic API type
|
|
147
|
+
api: Api<any, any, any, any>
|
|
148
|
+
): string | undefined => {
|
|
149
|
+
if (inferMutationType(endpointName) !== "update") {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const args = originalArgs as {body?: Record<string, unknown>; id?: string};
|
|
154
|
+
const bodyUpdatedAt = args?.body?._updatedAt;
|
|
155
|
+
if (typeof bodyUpdatedAt === "string") {
|
|
156
|
+
return bodyUpdatedAt;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!args?.id) {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const getByIdEndpoint = inferGetByIdEndpointName(endpointName);
|
|
164
|
+
const state = getState();
|
|
165
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query internal state shape
|
|
166
|
+
const queries: Record<string, any> = state[api.reducerPath]?.queries ?? {};
|
|
167
|
+
for (const key of Object.keys(queries)) {
|
|
168
|
+
if (!key.startsWith(`${getByIdEndpoint}(`)) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const queryEntry = queries[key];
|
|
172
|
+
if (queryEntry?.originalArgs?.id !== args.id) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const data = queryEntry?.data as {data?: {updated?: string}} | undefined;
|
|
176
|
+
const updated = data?.data?.updated;
|
|
177
|
+
if (typeof updated === "string") {
|
|
178
|
+
return updated;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return undefined;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Collect all cached query args for a given list endpoint so optimistic
|
|
187
|
+
* updates are applied to every active cache entry (regardless of the
|
|
188
|
+
* args the consumer passed to the query hook, e.g. `{}` vs `undefined`).
|
|
189
|
+
*/
|
|
190
|
+
const getCachedQueryArgs = (
|
|
191
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic API state shape
|
|
192
|
+
getState: () => any,
|
|
193
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic API type
|
|
194
|
+
api: Api<any, any, any, any>,
|
|
195
|
+
listEndpointName: string
|
|
196
|
+
): unknown[] => {
|
|
197
|
+
const state = getState();
|
|
198
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query internal state shape
|
|
199
|
+
const queries: Record<string, any> = state[api.reducerPath]?.queries ?? {};
|
|
200
|
+
const cachedArgs: unknown[] = [];
|
|
201
|
+
for (const key of Object.keys(queries)) {
|
|
202
|
+
if (key.startsWith(`${listEndpointName}(`)) {
|
|
203
|
+
cachedArgs.push(queries[key]?.originalArgs);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (cachedArgs.length === 0) {
|
|
207
|
+
cachedArgs.push(undefined);
|
|
208
|
+
}
|
|
209
|
+
return cachedArgs;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Apply an optimistic update to the RTK Query cache for a queued mutation.
|
|
214
|
+
*/
|
|
215
|
+
const applyOptimisticUpdate = (
|
|
216
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic API type
|
|
217
|
+
api: Api<any, any, any, any>,
|
|
218
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic dispatch
|
|
219
|
+
dispatch: any,
|
|
220
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic getState
|
|
221
|
+
getState: () => any,
|
|
222
|
+
mutation: QueuedMutation
|
|
223
|
+
): void => {
|
|
224
|
+
const tagType = inferTagType(mutation.endpointName);
|
|
225
|
+
const listEndpointName = `get${tagType.charAt(0).toUpperCase() + tagType.slice(1)}`;
|
|
226
|
+
const cachedArgs = getCachedQueryArgs(getState, api, listEndpointName);
|
|
227
|
+
|
|
228
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
|
|
229
|
+
const updateAllCacheEntries = (updater: (draft: any) => void): void => {
|
|
230
|
+
for (const queryArg of cachedArgs) {
|
|
231
|
+
dispatch(
|
|
232
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
|
|
233
|
+
api.util.updateQueryData(listEndpointName as any, queryArg, updater)
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
if (mutation.type === "create") {
|
|
239
|
+
const args = mutation.args as {body?: Record<string, unknown>};
|
|
240
|
+
const tempItem = buildOptimisticCreateItem(mutation, args?.body);
|
|
241
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
|
|
242
|
+
updateAllCacheEntries((draft: any) => {
|
|
243
|
+
if (draft?.data && Array.isArray(draft.data)) {
|
|
244
|
+
draft.data.unshift(tempItem);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
} else if (mutation.type === "update") {
|
|
248
|
+
const args = mutation.args as {id?: string; body?: Record<string, unknown>};
|
|
249
|
+
if (args?.id && args?.body) {
|
|
250
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
|
|
251
|
+
updateAllCacheEntries((draft: any) => {
|
|
252
|
+
if (draft?.data && Array.isArray(draft.data)) {
|
|
253
|
+
const item = draft.data.find(
|
|
254
|
+
(d: Record<string, unknown>) => d._id === args.id || d.id === args.id
|
|
255
|
+
);
|
|
256
|
+
if (item) {
|
|
257
|
+
Object.assign(item, args.body);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
} else if (mutation.type === "delete") {
|
|
263
|
+
const args = mutation.args as {id?: string};
|
|
264
|
+
if (args?.id) {
|
|
265
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
|
|
266
|
+
updateAllCacheEntries((draft: any) => {
|
|
267
|
+
if (draft?.data && Array.isArray(draft.data)) {
|
|
268
|
+
draft.data = draft.data.filter(
|
|
269
|
+
(d: Record<string, unknown>) => d._id !== args.id && d.id !== args.id
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Remove optimistic temp items from the cache after they've been replayed.
|
|
279
|
+
*/
|
|
280
|
+
const removeTempItems = (
|
|
281
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic API type
|
|
282
|
+
api: Api<any, any, any, any>,
|
|
283
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic dispatch
|
|
284
|
+
dispatch: any,
|
|
285
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic getState
|
|
286
|
+
getState: () => any,
|
|
287
|
+
mutations: QueuedMutation[]
|
|
288
|
+
): void => {
|
|
289
|
+
const tempIds = new Set(mutations.map((m) => `temp-${m.id}`));
|
|
290
|
+
const tagTypes = [...new Set(mutations.map((m) => inferTagType(m.endpointName)))];
|
|
291
|
+
|
|
292
|
+
for (const tagType of tagTypes) {
|
|
293
|
+
const listEndpointName = `get${tagType.charAt(0).toUpperCase() + tagType.slice(1)}`;
|
|
294
|
+
const cachedArgs = getCachedQueryArgs(getState, api, listEndpointName);
|
|
295
|
+
|
|
296
|
+
for (const queryArg of cachedArgs) {
|
|
297
|
+
dispatch(
|
|
298
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
|
|
299
|
+
api.util.updateQueryData(listEndpointName as any, queryArg, (draft: any) => {
|
|
300
|
+
if (draft?.data && Array.isArray(draft.data)) {
|
|
301
|
+
draft.data = draft.data.filter(
|
|
302
|
+
(d: Record<string, unknown>) =>
|
|
303
|
+
!tempIds.has(d._id as string) && !tempIds.has(d.id as string)
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Replay a single queued mutation by making a direct API call.
|
|
314
|
+
* Returns the response status and data for conflict handling.
|
|
315
|
+
*/
|
|
316
|
+
const replayMutation = async (
|
|
317
|
+
mutation: QueuedMutation
|
|
318
|
+
): Promise<{status: number; data?: unknown}> => {
|
|
319
|
+
const token = await getAuthToken();
|
|
320
|
+
const headers: Record<string, string> = {
|
|
321
|
+
"Content-Type": "application/json",
|
|
322
|
+
};
|
|
323
|
+
if (token) {
|
|
324
|
+
headers.authorization = `Bearer ${token}`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// For update operations, include If-Unmodified-Since from the document version at queue time
|
|
328
|
+
if (mutation.type === "update" && mutation.baseUpdatedAt) {
|
|
329
|
+
const timestamp = DateTime.fromISO(mutation.baseUpdatedAt);
|
|
330
|
+
if (timestamp.isValid) {
|
|
331
|
+
headers["If-Unmodified-Since"] = timestamp.toHTTP();
|
|
332
|
+
headers["X-Unmodified-Since-ISO"] = mutation.baseUpdatedAt;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const args = mutation.args as {id?: string; body?: unknown};
|
|
337
|
+
const tagType = inferTagType(mutation.endpointName);
|
|
338
|
+
const basePath = `/${tagType}`;
|
|
339
|
+
|
|
340
|
+
let url: string;
|
|
341
|
+
let method: string;
|
|
342
|
+
let body: string | undefined;
|
|
343
|
+
|
|
344
|
+
if (mutation.type === "create") {
|
|
345
|
+
url = `${baseUrl}${basePath}`;
|
|
346
|
+
method = "POST";
|
|
347
|
+
body = JSON.stringify(args?.body);
|
|
348
|
+
} else if (mutation.type === "update") {
|
|
349
|
+
url = `${baseUrl}${basePath}/${args?.id}`;
|
|
350
|
+
method = "PATCH";
|
|
351
|
+
body = JSON.stringify(args?.body);
|
|
352
|
+
} else {
|
|
353
|
+
url = `${baseUrl}${basePath}/${args?.id}`;
|
|
354
|
+
method = "DELETE";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const response = await fetch(url, {body, headers, method});
|
|
358
|
+
|
|
359
|
+
if (response.status === 204) {
|
|
360
|
+
return {status: 204};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const data = await response.json();
|
|
364
|
+
return {data, status: response.status};
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Set up native-only network monitoring (expo-network).
|
|
369
|
+
* Web apps should use `useServerStatus` for server-level health checking.
|
|
370
|
+
*/
|
|
371
|
+
const setupNativeNetworkMonitoring = (
|
|
372
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic dispatch
|
|
373
|
+
dispatch: any
|
|
374
|
+
): (() => void) => {
|
|
375
|
+
if (IsWeb) {
|
|
376
|
+
return () => {};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
let cleanup = (): void => {};
|
|
380
|
+
void import("expo-network").then((Network) => {
|
|
381
|
+
Network.getNetworkStateAsync().then((state) => {
|
|
382
|
+
dispatch(setOnlineStatus(state.isConnected ?? true));
|
|
383
|
+
});
|
|
384
|
+
const subscription = Network.addNetworkStateListener((state) => {
|
|
385
|
+
dispatch(setOnlineStatus(state.isConnected ?? true));
|
|
386
|
+
});
|
|
387
|
+
cleanup = () => subscription.remove();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
return () => cleanup();
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Creates an offline middleware system for RTK Query mutations.
|
|
395
|
+
*
|
|
396
|
+
* When the device is offline, configured mutation endpoints are queued instead of
|
|
397
|
+
* failing. When connectivity returns, queued mutations are replayed in order with
|
|
398
|
+
* LWW (Last-Writer-Wins) conflict detection via If-Unmodified-Since headers.
|
|
399
|
+
*
|
|
400
|
+
* Usage:
|
|
401
|
+
* ```typescript
|
|
402
|
+
* const offline = createOfflineMiddleware({
|
|
403
|
+
* endpoints: ["postTodos", "patchTodosById", "deleteTodosById"],
|
|
404
|
+
* api: terrenoApi,
|
|
405
|
+
* });
|
|
406
|
+
*
|
|
407
|
+
* // Add to store:
|
|
408
|
+
* // reducer: { offline: offline.offlineReducer }
|
|
409
|
+
* // middleware: [..., offline.middleware]
|
|
410
|
+
* ```
|
|
411
|
+
*/
|
|
412
|
+
export const createOfflineMiddleware = (
|
|
413
|
+
config: OfflineMiddlewareConfig
|
|
414
|
+
): {
|
|
415
|
+
middleware: Middleware;
|
|
416
|
+
offlineReducer: typeof offlineReducer;
|
|
417
|
+
offlineSlice: typeof offlineSlice;
|
|
418
|
+
} => {
|
|
419
|
+
const {endpoints, api} = config;
|
|
420
|
+
const endpointSet = new Set(endpoints);
|
|
421
|
+
configureOfflineMutationEndpoints(endpoints);
|
|
422
|
+
|
|
423
|
+
const listenerMiddleware = createListenerMiddleware();
|
|
424
|
+
let _networkCleanup: (() => void) | undefined;
|
|
425
|
+
let isReplayInProgress = false;
|
|
426
|
+
|
|
427
|
+
// Listener 1: Set up native network monitoring on first action (lazy init)
|
|
428
|
+
let networkInitialized = false;
|
|
429
|
+
listenerMiddleware.startListening({
|
|
430
|
+
effect: (_action, listenerApi) => {
|
|
431
|
+
networkInitialized = true;
|
|
432
|
+
_networkCleanup = setupNativeNetworkMonitoring(listenerApi.dispatch);
|
|
433
|
+
},
|
|
434
|
+
predicate: () => !networkInitialized,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// Listener 2: Intercept failed mutations on network errors (offline or server unreachable)
|
|
438
|
+
// RTK Query dispatches actions with type "terreno-rtk/executeMutation/rejected"
|
|
439
|
+
// when a mutation fails. Queue the mutation and apply an optimistic cache update.
|
|
440
|
+
listenerMiddleware.startListening({
|
|
441
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query action types are complex
|
|
442
|
+
effect: async (action: any, listenerApi) => {
|
|
443
|
+
const state = listenerApi.getState() as {offline: OfflineState};
|
|
444
|
+
const isOnline = selectIsOnline(state);
|
|
445
|
+
|
|
446
|
+
if (!isNetworkFetchError(action)) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const endpointName = action.meta.arg.endpointName as string;
|
|
451
|
+
const originalArgs = action.meta.arg.originalArgs;
|
|
452
|
+
|
|
453
|
+
const mutationType = inferMutationType(endpointName);
|
|
454
|
+
|
|
455
|
+
// For updates, use the document's last-known `updated` timestamp from cache
|
|
456
|
+
// rather than the current time. This ensures conflict detection compares against
|
|
457
|
+
// when the document was last fetched, not when the mutation was queued.
|
|
458
|
+
let timestamp = DateTime.now().toISO();
|
|
459
|
+
let listCacheBaseUpdatedAt: string | undefined;
|
|
460
|
+
if (mutationType === "update") {
|
|
461
|
+
const args = originalArgs as {id?: string};
|
|
462
|
+
if (args?.id) {
|
|
463
|
+
const tagType = inferTagType(endpointName);
|
|
464
|
+
const listEndpointName = `get${tagType.charAt(0).toUpperCase() + tagType.slice(1)}`;
|
|
465
|
+
const cachedArgs = getCachedQueryArgs(listenerApi.getState, api, listEndpointName);
|
|
466
|
+
for (const queryArg of cachedArgs) {
|
|
467
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape and endpoint types vary
|
|
468
|
+
const endpoint = (api.endpoints as any)[listEndpointName];
|
|
469
|
+
if (!endpoint?.select) {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies
|
|
473
|
+
const cacheEntry = endpoint.select(queryArg)(listenerApi.getState() as any) as any;
|
|
474
|
+
const items = cacheEntry?.data?.data;
|
|
475
|
+
if (Array.isArray(items)) {
|
|
476
|
+
const doc = items.find(
|
|
477
|
+
(d: Record<string, unknown>) => d._id === args.id || d.id === args.id
|
|
478
|
+
);
|
|
479
|
+
if (doc?.updated) {
|
|
480
|
+
timestamp =
|
|
481
|
+
typeof doc.updated === "string"
|
|
482
|
+
? doc.updated
|
|
483
|
+
: DateTime.fromJSDate(doc.updated).toISO();
|
|
484
|
+
listCacheBaseUpdatedAt = timestamp ?? undefined;
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const baseUpdatedAt =
|
|
493
|
+
extractBaseUpdatedAt(endpointName, originalArgs, listenerApi.getState, api) ??
|
|
494
|
+
listCacheBaseUpdatedAt;
|
|
495
|
+
|
|
496
|
+
const currentUserId = selectCurrentUserId(listenerApi.getState() as RootState);
|
|
497
|
+
|
|
498
|
+
const mutation: QueuedMutation = {
|
|
499
|
+
args: originalArgs,
|
|
500
|
+
baseUpdatedAt,
|
|
501
|
+
endpointName,
|
|
502
|
+
id: `${endpointName}-${DateTime.now().toMillis()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
503
|
+
timestamp,
|
|
504
|
+
type: mutationType,
|
|
505
|
+
userId: currentUserId,
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
listenerApi.dispatch(enqueue(mutation));
|
|
509
|
+
applyOptimisticUpdate(api, listenerApi.dispatch, listenerApi.getState, mutation);
|
|
510
|
+
|
|
511
|
+
// Browser may still report online when the API server is unreachable
|
|
512
|
+
if (isOnline) {
|
|
513
|
+
listenerApi.dispatch(setOnlineStatus(false));
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query internal action shape
|
|
517
|
+
predicate: (action: any) => {
|
|
518
|
+
if (typeof action?.type !== "string") {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
// Match RTK Query mutation rejected actions
|
|
522
|
+
return (
|
|
523
|
+
action.type.includes("/executeMutation/rejected") &&
|
|
524
|
+
action?.meta?.arg?.endpointName &&
|
|
525
|
+
endpointSet.has(action.meta.arg.endpointName)
|
|
526
|
+
);
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// Listener 3: Mark offline when any API request fails with a network error
|
|
531
|
+
listenerMiddleware.startListening({
|
|
532
|
+
effect: (_action, listenerApi) => {
|
|
533
|
+
const state = listenerApi.getState() as {offline: OfflineState};
|
|
534
|
+
if (selectIsOnline(state)) {
|
|
535
|
+
listenerApi.dispatch(setOnlineStatus(false));
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query internal action shape
|
|
539
|
+
predicate: (action: any) => {
|
|
540
|
+
if (typeof action?.type !== "string") {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
return (
|
|
544
|
+
action.type.startsWith(`${api.reducerPath}/`) &&
|
|
545
|
+
(action.type.includes("/executeQuery/rejected") ||
|
|
546
|
+
action.type.includes("/executeMutation/rejected")) &&
|
|
547
|
+
isNetworkFetchError(action)
|
|
548
|
+
);
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// Listener 4: Mark online when any API request succeeds (server reachable again)
|
|
553
|
+
listenerMiddleware.startListening({
|
|
554
|
+
effect: (_action, listenerApi) => {
|
|
555
|
+
const state = listenerApi.getState() as {offline: OfflineState};
|
|
556
|
+
if (!selectIsOnline(state)) {
|
|
557
|
+
listenerApi.dispatch(setOnlineStatus(true));
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query internal action shape
|
|
561
|
+
predicate: (action: any) => {
|
|
562
|
+
if (typeof action?.type !== "string") {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
return (
|
|
566
|
+
action.type.startsWith(`${api.reducerPath}/`) &&
|
|
567
|
+
action.type.endsWith("/fulfilled") &&
|
|
568
|
+
(action.type.includes("/executeQuery/") || action.type.includes("/executeMutation/"))
|
|
569
|
+
);
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// Listener 5: Sync queue when coming back online
|
|
574
|
+
listenerMiddleware.startListening({
|
|
575
|
+
actionCreator: setOnlineStatus,
|
|
576
|
+
effect: async (action, listenerApi) => {
|
|
577
|
+
if (!action.payload) {
|
|
578
|
+
// Going offline, nothing to do
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (isReplayInProgress) {
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
listenerApi.cancelActiveListeners();
|
|
586
|
+
|
|
587
|
+
const state = listenerApi.getState() as {offline: OfflineState};
|
|
588
|
+
const queue = selectOfflineQueue(state);
|
|
589
|
+
const currentUserId = selectCurrentUserId(listenerApi.getState() as RootState);
|
|
590
|
+
|
|
591
|
+
if (queue.length === 0) {
|
|
592
|
+
// Reset isSyncing if it was stuck from a crash/reload during sync
|
|
593
|
+
if (selectIsSyncing(state)) {
|
|
594
|
+
listenerApi.dispatch(setSyncing(false));
|
|
595
|
+
}
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
isReplayInProgress = true;
|
|
600
|
+
listenerApi.dispatch(setSyncing(true));
|
|
601
|
+
|
|
602
|
+
const replayedCreates: QueuedMutation[] = [];
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
// Replay mutations in FIFO order
|
|
606
|
+
for (const mutation of queue) {
|
|
607
|
+
if (!shouldReplayQueuedMutation(mutation, currentUserId)) {
|
|
608
|
+
console.warn(
|
|
609
|
+
`[offline] Dropping queued mutation ${mutation.endpointName} — not owned by current user`
|
|
610
|
+
);
|
|
611
|
+
listenerApi.dispatch(dequeue(mutation.id));
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
try {
|
|
616
|
+
const result = await replayMutation(mutation);
|
|
617
|
+
|
|
618
|
+
if (result.status === 409) {
|
|
619
|
+
const conflict: ConflictRecord = {
|
|
620
|
+
args: mutation.args,
|
|
621
|
+
dismissed: false,
|
|
622
|
+
endpointName: mutation.endpointName,
|
|
623
|
+
id: mutation.id,
|
|
624
|
+
serverDocument: (result.data as {data?: unknown})?.data ?? result.data,
|
|
625
|
+
timestamp: DateTime.now().toISO(),
|
|
626
|
+
};
|
|
627
|
+
listenerApi.dispatch(addConflict(conflict));
|
|
628
|
+
listenerApi.dispatch(dequeue(mutation.id));
|
|
629
|
+
if (mutation.type === "create") {
|
|
630
|
+
replayedCreates.push(mutation);
|
|
631
|
+
}
|
|
632
|
+
} else if (result.status >= 200 && result.status < 300) {
|
|
633
|
+
listenerApi.dispatch(dequeue(mutation.id));
|
|
634
|
+
if (mutation.type === "create") {
|
|
635
|
+
replayedCreates.push(mutation);
|
|
636
|
+
}
|
|
637
|
+
} else {
|
|
638
|
+
console.warn(
|
|
639
|
+
`[offline] Replay failed for ${mutation.endpointName}: status ${result.status}`
|
|
640
|
+
);
|
|
641
|
+
if (result.status >= 400 && result.status < 500 && result.status !== 409) {
|
|
642
|
+
listenerApi.dispatch(dequeue(mutation.id));
|
|
643
|
+
if (mutation.type === "create") {
|
|
644
|
+
replayedCreates.push(mutation);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
} catch (_error) {
|
|
649
|
+
console.warn(`[offline] Replay network error for ${mutation.endpointName}`);
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Remove optimistic temp items for creates that were replayed
|
|
655
|
+
if (replayedCreates.length > 0) {
|
|
656
|
+
removeTempItems(api, listenerApi.dispatch, listenerApi.getState, replayedCreates);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Invalidate tags to refresh cache with server state
|
|
660
|
+
const tagTypes = [...new Set(queue.map((m) => inferTagType(m.endpointName)))];
|
|
661
|
+
for (const tagType of tagTypes) {
|
|
662
|
+
listenerApi.dispatch(api.util.invalidateTags([tagType]));
|
|
663
|
+
}
|
|
664
|
+
} finally {
|
|
665
|
+
isReplayInProgress = false;
|
|
666
|
+
listenerApi.dispatch(setSyncing(false));
|
|
667
|
+
}
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// Listener 6: Clear persisted queue on logout so mutations are not replayed for the next user
|
|
672
|
+
listenerMiddleware.startListening({
|
|
673
|
+
effect: (_action, listenerApi) => {
|
|
674
|
+
listenerApi.dispatch(clearQueue());
|
|
675
|
+
listenerApi.dispatch(clearConflicts());
|
|
676
|
+
},
|
|
677
|
+
type: LOGOUT_ACTION_TYPE,
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// Listener 7: On startup, if the persisted queue has items and we're online,
|
|
681
|
+
// trigger a replay. This handles the case where the app reloads while online
|
|
682
|
+
// with a non-empty queue (rehydrated state already has isOnline: true, so no
|
|
683
|
+
// offline→online transition would otherwise occur).
|
|
684
|
+
let startupReplayTriggered = false;
|
|
685
|
+
listenerMiddleware.startListening({
|
|
686
|
+
effect: (_action, listenerApi) => {
|
|
687
|
+
startupReplayTriggered = true;
|
|
688
|
+
const state = listenerApi.getState() as {offline: OfflineState};
|
|
689
|
+
const queue = selectOfflineQueue(state);
|
|
690
|
+
const isOnline = selectIsOnline(state);
|
|
691
|
+
|
|
692
|
+
// Reset stuck isSyncing from a crash during prior sync
|
|
693
|
+
if (selectIsSyncing(state) && !isReplayInProgress) {
|
|
694
|
+
listenerApi.dispatch(setSyncing(false));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (queue.length > 0 && isOnline && !isReplayInProgress) {
|
|
698
|
+
// Re-dispatch setOnlineStatus(true) to trigger the sync listener
|
|
699
|
+
listenerApi.dispatch(setOnlineStatus(true));
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
predicate: (action) => {
|
|
703
|
+
if (startupReplayTriggered) {
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
706
|
+
// redux-persist dispatches "persist/REHYDRATE" after loading stored state
|
|
707
|
+
return typeof action?.type === "string" && action.type === "persist/REHYDRATE";
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
return {
|
|
712
|
+
middleware: listenerMiddleware.middleware,
|
|
713
|
+
offlineReducer,
|
|
714
|
+
offlineSlice,
|
|
715
|
+
};
|
|
716
|
+
};
|