@ciwergrp/nuxid 1.17.8 → 1.19.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/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ciwergrp/nuxid",
3
3
  "configKey": "nuxid",
4
- "version": "1.17.8",
4
+ "version": "1.19.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -1,18 +1,28 @@
1
1
  import type { NitroFetchOptions, NitroFetchRequest } from 'nitropack';
2
+ import type { MultiWatchSources, Ref } from 'vue';
2
3
  export interface APIResponseCursor<T> {
3
4
  data: T[];
4
- meta: {
5
- [key: string]: any;
6
- };
5
+ meta: Record<string, any>;
7
6
  }
8
7
  export interface CursorMetaConfig {
9
8
  cursorKey?: string;
10
9
  hasMoreKey?: string;
11
10
  }
12
- export interface CursorFetchOptions<TRequest extends NitroFetchRequest = NitroFetchRequest> extends NitroFetchOptions<TRequest> {
11
+ export interface CursorExecuteOptions {
12
+ dedupe?: 'cancel' | 'defer';
13
+ timeout?: number;
14
+ signal?: AbortSignal;
15
+ }
16
+ export interface CursorFetchOptions<TRequest extends NitroFetchRequest = NitroFetchRequest, TResponse extends APIResponseCursor<any> = APIResponseCursor<any>> {
17
+ cache?: 'none' | 'payload' | 'static' | 'both';
18
+ server?: boolean;
13
19
  lazy?: boolean;
14
20
  immediate?: boolean;
15
- pollInterval?: number;
21
+ default?: () => TResponse | Ref<TResponse | null> | null;
22
+ deep?: boolean;
23
+ dedupe?: 'cancel' | 'defer';
24
+ timeout?: number;
25
+ watch?: MultiWatchSources | false;
16
26
  fetcher?: typeof $fetch;
17
27
  fetchOptions?: NitroFetchOptions<TRequest>;
18
28
  meta?: CursorMetaConfig;
@@ -20,28 +30,41 @@ export interface CursorFetchOptions<TRequest extends NitroFetchRequest = NitroFe
20
30
  cursorParam?: string;
21
31
  query?: Record<string, any>;
22
32
  key?: string;
33
+ pollInterval?: number;
23
34
  }
24
35
  export interface FetchError<TData = any> {
25
36
  statusCode: number | undefined;
26
37
  statusMessage: string | undefined;
27
38
  _data: TData | undefined;
28
39
  }
29
- export declare function useCursorHttp<T extends Record<string, any> = any, TResponse extends APIResponseCursor<T> = APIResponseCursor<T>, TRequest extends NitroFetchRequest = NitroFetchRequest>(url: TRequest, options?: CursorFetchOptions<TRequest>): Promise<{
30
- data: import("vue").Ref<TResponse | undefined, TResponse | undefined>;
31
- loading: Readonly<import("vue").Ref<boolean, boolean>>;
32
- error: Readonly<import("vue").Ref<{
40
+ export declare const normalizeFetchError: (error: unknown) => FetchError;
41
+ export declare const ensureCursorResponse: <T extends APIResponseCursor<any>>(response: T) => T;
42
+ export declare const extractCursorState: (response: APIResponseCursor<any>, metaConfig?: CursorMetaConfig) => {
43
+ nextCursor: string | null;
44
+ hasNextPage: boolean;
45
+ };
46
+ export declare const mergeCursorResponse: <T extends APIResponseCursor<any>>(current: T | undefined, next: T, itemKey: string) => T;
47
+ export declare const buildKey: (key: string | undefined, url: NitroFetchRequest, query: Record<string, any> | undefined) => string;
48
+ export declare const buildFetchOptions: <TRequest extends NitroFetchRequest>(baseOptions: NitroFetchOptions<TRequest> | undefined, query: Record<string, any> | undefined, signal?: AbortSignal) => NitroFetchOptions<TRequest>;
49
+ export declare const resolveDefaultResponse: <TResponse extends APIResponseCursor<any>>(factory?: CursorFetchOptions<any, TResponse>["default"]) => TResponse | null | undefined;
50
+ export declare function useCursorHttp<T extends Record<string, any> = any, TResponse extends APIResponseCursor<T> = APIResponseCursor<T>, TRequest extends NitroFetchRequest = NitroFetchRequest>(url: TRequest, options?: CursorFetchOptions<TRequest, TResponse>): Promise<{
51
+ data: [TResponse | null | undefined] extends [Ref<any, any>] ? import("@vue/shared").IfAny<Ref<any, any> & TResponse, Ref<Ref<any, any> & TResponse, Ref<any, any> & TResponse>, Ref<any, any> & TResponse> : Ref<import("vue").UnwrapRef<TResponse> | null | undefined, TResponse | import("vue").UnwrapRef<TResponse> | null | undefined>;
52
+ loading: Readonly<Ref<boolean, boolean>>;
53
+ error: Readonly<Ref<{
33
54
  readonly statusCode: number | undefined;
34
55
  readonly statusMessage: string | undefined;
35
56
  readonly _data: any;
36
- } | null, {
57
+ } | undefined, {
37
58
  readonly statusCode: number | undefined;
38
59
  readonly statusMessage: string | undefined;
39
60
  readonly _data: any;
40
- } | null>>;
41
- hasNextPage: Readonly<import("vue").Ref<boolean, boolean>>;
42
- isLoadMoreTriggered: Readonly<import("vue").Ref<boolean, boolean>>;
43
- loadMore: () => Promise<void>;
44
- refresh: () => Promise<void>;
45
- init: () => Promise<void>;
61
+ } | undefined>>;
62
+ hasNextPage: Readonly<Ref<boolean, boolean>>;
63
+ isLoadMoreTriggered: Readonly<Ref<boolean, boolean>>;
64
+ loadMore: (executeOptions?: CursorExecuteOptions) => Promise<void>;
65
+ refresh: (executeOptions?: CursorExecuteOptions) => Promise<void>;
66
+ execute: (executeOptions?: CursorExecuteOptions) => Promise<void>;
67
+ clear: () => void;
68
+ init: (executeOptions?: CursorExecuteOptions) => Promise<void>;
46
69
  }>;
47
70
  export default useCursorHttp;
@@ -1,281 +1,485 @@
1
- import { isRef, onBeforeMount, onUnmounted, readonly, ref, shallowRef, watch } from "vue";
2
- export async function useCursorHttp(url, options) {
3
- function findReactiveSources(obj) {
4
- const sources = [];
5
- if (!obj || typeof obj !== "object") {
6
- return sources;
1
+ import { isRef, onMounted, onUnmounted, readonly, ref, shallowRef, watch } from "vue";
2
+ const META_CURSOR_CANDIDATES = ["afterCursor", "next_cursor", "nextCursor", "cursor"];
3
+ const META_HAS_MORE_CANDIDATES = ["hasMore", "has_next_page", "hasNextPage", "has_next"];
4
+ const normalizeString = (value, fallback) => {
5
+ if (typeof value !== "string") {
6
+ return fallback;
7
+ }
8
+ const trimmed = value.trim();
9
+ return trimmed.length > 0 ? trimmed : fallback;
10
+ };
11
+ const normalizeCursorValue = (value) => {
12
+ if (value === null || value === void 0) {
13
+ return null;
14
+ }
15
+ if (typeof value === "string") {
16
+ const trimmed = value.trim();
17
+ return trimmed.length > 0 ? trimmed : null;
18
+ }
19
+ return String(value);
20
+ };
21
+ const resolveItemKeyValue = (item, itemKey) => {
22
+ const normalizedKey = normalizeString(itemKey, "");
23
+ if (!normalizedKey || !item || typeof item !== "object") {
24
+ return void 0;
25
+ }
26
+ const record = item;
27
+ if (Object.prototype.hasOwnProperty.call(record, normalizedKey)) {
28
+ return record[normalizedKey];
29
+ }
30
+ if (!normalizedKey.includes(".")) {
31
+ return record[normalizedKey];
32
+ }
33
+ return normalizedKey.split(".").reduce((current, segment) => {
34
+ if (current === null || current === void 0) {
35
+ return void 0;
7
36
  }
8
- for (const key2 in obj) {
9
- if (Object.prototype.hasOwnProperty.call(obj, key2)) {
10
- const value = obj[key2];
11
- if (isRef(value)) {
12
- sources.push(value);
13
- } else if (typeof value === "object") {
14
- sources.push(...findReactiveSources(value));
15
- }
16
- }
37
+ return current[segment];
38
+ }, item);
39
+ };
40
+ const cloneResponse = (response) => ({
41
+ ...response,
42
+ data: [...response.data]
43
+ });
44
+ export const normalizeFetchError = (error) => {
45
+ if (error && typeof error === "object") {
46
+ const cast = error;
47
+ return {
48
+ statusCode: typeof cast.statusCode === "number" ? cast.statusCode : void 0,
49
+ statusMessage: typeof cast.statusMessage === "string" ? cast.statusMessage : cast.message ?? void 0,
50
+ _data: cast.data
51
+ };
52
+ }
53
+ if (typeof error === "string") {
54
+ return {
55
+ statusCode: void 0,
56
+ statusMessage: error,
57
+ _data: void 0
58
+ };
59
+ }
60
+ return {
61
+ statusCode: void 0,
62
+ statusMessage: "Unknown error",
63
+ _data: void 0
64
+ };
65
+ };
66
+ export const ensureCursorResponse = (response) => {
67
+ if (!response || typeof response !== "object" || !Array.isArray(response.data)) {
68
+ throw new Error("Cursor responses must include a data array");
69
+ }
70
+ return response;
71
+ };
72
+ const unwrapReactiveObject = (value) => {
73
+ if (!value || typeof value !== "object") {
74
+ return value;
75
+ }
76
+ const unwrapped = Array.isArray(value) ? [] : {};
77
+ for (const key in value) {
78
+ if (!Object.prototype.hasOwnProperty.call(value, key)) {
79
+ continue;
80
+ }
81
+ const current = value[key];
82
+ if (isRef(current)) {
83
+ ;
84
+ unwrapped[key] = current.value;
85
+ continue;
17
86
  }
87
+ if (current && typeof current === "object") {
88
+ ;
89
+ unwrapped[key] = unwrapReactiveObject(current);
90
+ continue;
91
+ }
92
+ ;
93
+ unwrapped[key] = current;
94
+ }
95
+ return unwrapped;
96
+ };
97
+ const findReactiveSources = (value) => {
98
+ const sources = [];
99
+ if (!value || typeof value !== "object") {
18
100
  return sources;
19
101
  }
20
- function unwrapReactiveObject(obj) {
21
- if (!obj || typeof obj !== "object") {
22
- return obj;
102
+ for (const key in value) {
103
+ if (!Object.prototype.hasOwnProperty.call(value, key)) {
104
+ continue;
23
105
  }
24
- const unwrapped = Array.isArray(obj) ? [] : {};
25
- for (const key2 in obj) {
26
- if (Object.prototype.hasOwnProperty.call(obj, key2)) {
27
- const value = obj[key2];
28
- if (isRef(value)) {
29
- unwrapped[key2] = value.value;
30
- } else if (typeof value === "object") {
31
- unwrapped[key2] = unwrapReactiveObject(value);
32
- } else {
33
- unwrapped[key2] = value;
34
- }
35
- }
106
+ const current = value[key];
107
+ if (isRef(current)) {
108
+ sources.push(current);
109
+ continue;
36
110
  }
37
- return unwrapped;
38
- }
39
- function normalizeFetchError(e) {
40
- if (e && typeof e === "object") {
41
- const cast = e;
42
- return {
43
- statusCode: typeof cast.statusCode === "number" ? cast.statusCode : void 0,
44
- statusMessage: typeof cast.statusMessage === "string" ? cast.statusMessage : cast.message ?? void 0,
45
- _data: cast.data
46
- };
111
+ if (current && typeof current === "object") {
112
+ sources.push(...findReactiveSources(current));
47
113
  }
48
- if (typeof e === "string") {
49
- return { statusCode: void 0, statusMessage: e, _data: void 0 };
114
+ }
115
+ return sources;
116
+ };
117
+ const uniqueSources = (sources) => Array.from(new Set(sources));
118
+ const resolveCursorMetaValue = (meta, explicitKey, candidates) => {
119
+ if (!meta || typeof meta !== "object") {
120
+ return void 0;
121
+ }
122
+ const normalizedKey = normalizeString(explicitKey, "");
123
+ if (normalizedKey) {
124
+ return meta[normalizedKey];
125
+ }
126
+ for (const candidate of candidates) {
127
+ if (candidate in meta) {
128
+ return meta[candidate];
50
129
  }
51
- return { statusCode: void 0, statusMessage: "Unknown error", _data: void 0 };
52
130
  }
131
+ return void 0;
132
+ };
133
+ export const extractCursorState = (response, metaConfig) => {
134
+ const cursorValue = resolveCursorMetaValue(response.meta, metaConfig?.cursorKey, META_CURSOR_CANDIDATES);
135
+ const hasMoreValue = resolveCursorMetaValue(response.meta, metaConfig?.hasMoreKey, META_HAS_MORE_CANDIDATES);
136
+ return {
137
+ nextCursor: normalizeCursorValue(cursorValue),
138
+ hasNextPage: Boolean(hasMoreValue)
139
+ };
140
+ };
141
+ export const mergeCursorResponse = (current, next, itemKey) => {
142
+ if (!current) {
143
+ return cloneResponse(next);
144
+ }
145
+ const knownKeys = new Set(
146
+ current.data.map((item) => resolveItemKeyValue(item, itemKey))
147
+ );
148
+ const newItems = next.data.filter((item) => !knownKeys.has(resolveItemKeyValue(item, itemKey)));
149
+ return {
150
+ ...next,
151
+ data: [...current.data, ...newItems]
152
+ };
153
+ };
154
+ export const buildKey = (key, url, query) => {
155
+ const normalizedKey = normalizeString(key, "");
156
+ if (normalizedKey) {
157
+ return normalizedKey;
158
+ }
159
+ try {
160
+ return `${String(url)}:${JSON.stringify(query ?? {})}`;
161
+ } catch {
162
+ return String(url);
163
+ }
164
+ };
165
+ export const buildFetchOptions = (baseOptions, query, signal) => {
166
+ const currentQuery = {
167
+ ...baseOptions?.query ?? {},
168
+ ...query ?? {}
169
+ };
170
+ return {
171
+ ...baseOptions,
172
+ query: currentQuery,
173
+ signal
174
+ };
175
+ };
176
+ export const resolveDefaultResponse = (factory) => {
177
+ const value = factory?.();
178
+ return isRef(value) ? value.value : value ?? void 0;
179
+ };
180
+ const createTimeoutAbortController = (timeout) => {
181
+ if (!timeout || timeout <= 0) {
182
+ return void 0;
183
+ }
184
+ const controller = new AbortController();
185
+ const timer = setTimeout(() => controller.abort(), timeout);
186
+ return {
187
+ controller,
188
+ clear: () => clearTimeout(timer)
189
+ };
190
+ };
191
+ const resolveCachedDataGetter = (cache) => {
192
+ if (cache === "none" || cache === void 0) {
193
+ return () => void 0;
194
+ }
195
+ if (cache === "payload") {
196
+ return (key, nuxtApp) => nuxtApp.isHydrating ? nuxtApp.payload.data[key] : void 0;
197
+ }
198
+ if (cache === "static") {
199
+ return (key, nuxtApp) => nuxtApp.static.data[key];
200
+ }
201
+ return (key, nuxtApp) => nuxtApp.isHydrating ? nuxtApp.payload.data[key] : nuxtApp.static.data[key];
202
+ };
203
+ export async function useCursorHttp(url, options) {
53
204
  const {
205
+ cache = "none",
206
+ server = true,
54
207
  lazy = false,
55
208
  immediate = true,
56
- pollInterval,
209
+ default: defaultFactory,
210
+ deep = false,
211
+ dedupe = "cancel",
212
+ timeout,
213
+ watch: watchSources = void 0,
57
214
  fetcher,
58
215
  fetchOptions,
59
216
  query,
60
217
  meta,
61
218
  itemKey = "id",
62
219
  cursorParam = "cursor",
63
- key
220
+ key,
221
+ pollInterval
64
222
  } = options ?? {};
65
- const initialUrl = url;
66
- const data = ref();
67
- const loading = ref(false);
68
- const error = ref(null);
223
+ const fetcherFn = fetcher ?? globalThis.$fetch;
224
+ if (!fetcherFn) {
225
+ throw new Error("Nuxt $fetch is not available in the current context");
226
+ }
227
+ const { useAsyncData } = await import("#app");
228
+ const { clearNuxtData } = await import("#app");
229
+ const resolvedKey = buildKey(key, url, query ?? fetchOptions?.query);
230
+ const instanceId = cache === "none" ? globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}` : "";
231
+ const stateKey = cache === "none" ? `${resolvedKey}:${instanceId}` : resolvedKey;
232
+ const resolvedBaseOptions = shallowRef(
233
+ unwrapReactiveObject(fetchOptions)
234
+ );
235
+ const reactiveSources = uniqueSources([
236
+ ...watchSources === false ? [] : Array.isArray(watchSources) ? watchSources : watchSources ? [watchSources] : [],
237
+ ...findReactiveSources(fetchOptions),
238
+ ...findReactiveSources(query)
239
+ ]);
240
+ const data = ref(resolveDefaultResponse(defaultFactory));
69
241
  const nextCursor = ref(null);
70
242
  const hasNextPage = ref(true);
243
+ const hasResolvedInitial = ref(false);
71
244
  const isLoadMoreTriggered = ref(false);
72
- const currentParams = shallowRef(
73
- unwrapReactiveObject(fetchOptions) ?? {}
245
+ const loadMorePending = ref(false);
246
+ const loadMoreError = ref(void 0);
247
+ const pollTimer = ref(null);
248
+ const loadMoreRequest = ref(null);
249
+ const loadMoreAbortController = ref(null);
250
+ const asyncData = await useAsyncData(
251
+ stateKey,
252
+ async (_nuxtApp, { signal }) => {
253
+ const requestOptions = buildFetchOptions(
254
+ resolvedBaseOptions.value,
255
+ unwrapReactiveObject(query),
256
+ signal
257
+ );
258
+ const response = await fetcherFn(url, requestOptions);
259
+ return ensureCursorResponse(response);
260
+ },
261
+ {
262
+ server,
263
+ lazy,
264
+ immediate,
265
+ default: defaultFactory,
266
+ deep,
267
+ dedupe,
268
+ timeout,
269
+ getCachedData: resolveCachedDataGetter(cache)
270
+ }
74
271
  );
75
- const normalizeStateKey = (value, fallback) => {
76
- if (typeof value !== "string") {
77
- return fallback;
272
+ const syncResponseState = (response) => {
273
+ if (!response) {
274
+ nextCursor.value = null;
275
+ hasNextPage.value = false;
276
+ hasResolvedInitial.value = false;
277
+ data.value = response ?? resolveDefaultResponse(defaultFactory);
278
+ return;
78
279
  }
79
- const trimmed = value.trim();
80
- return trimmed.length > 0 ? trimmed : fallback;
280
+ const clonedResponse = cloneResponse(response);
281
+ const cursorState = extractCursorState(clonedResponse, meta);
282
+ data.value = clonedResponse;
283
+ nextCursor.value = cursorState.nextCursor;
284
+ hasNextPage.value = cursorState.hasNextPage;
285
+ hasResolvedInitial.value = true;
81
286
  };
82
- const stateKey = normalizeStateKey(key, (() => {
83
- try {
84
- const queryValue = query ?? currentParams.value?.query ?? {};
85
- return `${String(url)}:${JSON.stringify(queryValue)}`;
86
- } catch {
87
- return String(url);
88
- }
89
- })());
90
- const { useNuxtApp, useState } = await import("#app");
91
- const nuxtApp = useNuxtApp();
92
- const initialResponseState = useState(stateKey, () => null);
93
- let pollTimer = null;
94
- let fetchPromise = null;
95
- const fetcherFn = fetcher ?? globalThis.$fetch;
96
- if (!fetcherFn) {
97
- throw new Error("Nuxt $fetch is not available in the current context");
287
+ watch(
288
+ () => asyncData.data.value,
289
+ (response) => {
290
+ syncResponseState(response);
291
+ loadMoreError.value = void 0;
292
+ isLoadMoreTriggered.value = false;
293
+ },
294
+ { immediate: true }
295
+ );
296
+ if (reactiveSources.length > 0) {
297
+ watch(
298
+ reactiveSources,
299
+ () => {
300
+ resolvedBaseOptions.value = unwrapReactiveObject(fetchOptions);
301
+ void refresh();
302
+ },
303
+ { flush: "sync" }
304
+ );
98
305
  }
99
- const normalizeKey = (value, fallback) => {
100
- if (typeof value !== "string") {
101
- return fallback;
102
- }
103
- const trimmed = value.trim();
104
- return trimmed.length > 0 ? trimmed : fallback;
306
+ if (cache === "none") {
307
+ onUnmounted(() => {
308
+ clearNuxtData(stateKey);
309
+ });
310
+ }
311
+ const resetCursorState = () => {
312
+ nextCursor.value = null;
313
+ hasNextPage.value = true;
314
+ hasResolvedInitial.value = false;
315
+ isLoadMoreTriggered.value = false;
316
+ loadMorePending.value = false;
317
+ loadMoreError.value = void 0;
318
+ data.value = resolveDefaultResponse(defaultFactory);
105
319
  };
106
- const cursorKey = normalizeKey(meta?.cursorKey, "afterCursor");
107
- const hasMoreKey = normalizeKey(meta?.hasMoreKey, "hasMore");
108
- const resolvedItemKey = normalizeKey(itemKey, "id");
109
- const resolvedCursorParam = normalizeKey(cursorParam, "cursor");
110
- const normalizeCursorValue = (value) => {
111
- if (value === null || value === void 0) {
112
- return null;
113
- }
114
- if (typeof value === "string") {
115
- const trimmed = value.trim();
116
- return trimmed.length > 0 ? trimmed : null;
117
- }
118
- return String(value);
320
+ const cancelLoadMore = () => {
321
+ loadMoreAbortController.value?.abort();
322
+ loadMoreAbortController.value = null;
323
+ loadMoreRequest.value = null;
324
+ loadMorePending.value = false;
119
325
  };
120
- const cloneResponse = (response) => ({
121
- ...response,
122
- data: [...response.data]
123
- });
124
- const applyResponse = (response, cacheInitial = false) => {
125
- data.value = cloneResponse(response);
126
- nextCursor.value = normalizeCursorValue(response.meta?.[cursorKey]);
127
- hasNextPage.value = response.meta?.[hasMoreKey] ?? false;
128
- if (cacheInitial) {
129
- initialResponseState.value = cloneResponse(response);
130
- }
326
+ const refresh = async (executeOptions) => {
327
+ cancelLoadMore();
328
+ resetCursorState();
329
+ await asyncData.refresh(executeOptions);
131
330
  };
132
- const fetchData = async (fetchUrl, params) => {
133
- if (loading.value) {
134
- return fetchPromise ?? Promise.resolve();
135
- }
136
- if (!hasNextPage.value && data.value) {
137
- return Promise.resolve();
138
- }
139
- loading.value = true;
140
- error.value = null;
141
- const promise = (async () => {
142
- try {
143
- const response = await fetcherFn(fetchUrl, params);
144
- data.value = {
145
- ...response,
146
- data: [...data.value?.data ?? [], ...response.data]
147
- };
148
- nextCursor.value = normalizeCursorValue(response.meta?.[cursorKey]);
149
- hasNextPage.value = response.meta?.[hasMoreKey] ?? false;
150
- } catch (e) {
151
- error.value = normalizeFetchError(e);
152
- } finally {
153
- loading.value = false;
154
- }
155
- })();
156
- fetchPromise = promise;
157
- try {
158
- await promise;
159
- } finally {
160
- if (fetchPromise === promise) {
161
- fetchPromise = null;
331
+ const execute = async (executeOptions) => {
332
+ await refresh(executeOptions);
333
+ };
334
+ const init = async (executeOptions) => {
335
+ await execute(executeOptions);
336
+ };
337
+ const clear = () => {
338
+ cancelLoadMore();
339
+ asyncData.clear();
340
+ clearNuxtData(stateKey);
341
+ resetCursorState();
342
+ };
343
+ const loadMore = async (executeOptions) => {
344
+ if (loadMorePending.value) {
345
+ if (dedupe === "defer") {
346
+ return loadMoreRequest.value ?? Promise.resolve();
162
347
  }
348
+ cancelLoadMore();
163
349
  }
164
- };
165
- const fetchInitialData = async (params, useCache = true) => {
166
- if (loading.value) {
167
- return fetchPromise ?? Promise.resolve();
350
+ if (!hasResolvedInitial.value) {
351
+ await init(executeOptions);
168
352
  }
169
- const canUseHydrationCache = import.meta.client && nuxtApp.isHydrating && nuxtApp.payload.serverRendered;
170
- if (useCache && canUseHydrationCache && initialResponseState.value) {
171
- applyResponse(initialResponseState.value);
353
+ if (!hasResolvedInitial.value || !data.value || !hasNextPage.value) {
172
354
  return Promise.resolve();
173
355
  }
174
- loading.value = true;
175
- error.value = null;
176
- const promise = (async () => {
356
+ isLoadMoreTriggered.value = true;
357
+ loadMorePending.value = true;
358
+ loadMoreError.value = void 0;
359
+ const pendingRequest = (async () => {
360
+ const timeoutHandle = createTimeoutAbortController(executeOptions?.timeout ?? timeout);
361
+ const requestAbortController = new AbortController();
362
+ loadMoreAbortController.value = requestAbortController;
363
+ if (executeOptions?.signal) {
364
+ if (executeOptions.signal.aborted) {
365
+ requestAbortController.abort();
366
+ } else {
367
+ executeOptions.signal.addEventListener("abort", () => requestAbortController.abort(), { once: true });
368
+ }
369
+ }
370
+ if (timeoutHandle) {
371
+ const timeoutSignal = timeoutHandle.controller.signal;
372
+ if (timeoutSignal.aborted) {
373
+ requestAbortController.abort();
374
+ } else {
375
+ timeoutSignal.addEventListener("abort", () => requestAbortController.abort(), { once: true });
376
+ }
377
+ }
177
378
  try {
178
- const response = await fetcherFn(initialUrl, params ?? currentParams.value);
179
- applyResponse(response, true);
180
- } catch (e) {
181
- error.value = normalizeFetchError(e);
379
+ const baseOptions = resolvedBaseOptions.value ?? {};
380
+ const nextQuery = {
381
+ ...unwrapReactiveObject(query) ?? {}
382
+ };
383
+ if (nextCursor.value !== null) {
384
+ nextQuery[normalizeString(cursorParam, "cursor")] = nextCursor.value;
385
+ }
386
+ const requestOptions = buildFetchOptions(baseOptions, nextQuery, requestAbortController.signal);
387
+ const response = ensureCursorResponse(await fetcherFn(url, requestOptions));
388
+ const mergedResponse = mergeCursorResponse(data.value ?? void 0, response, normalizeString(itemKey, "id"));
389
+ const cursorState = extractCursorState(mergedResponse, meta);
390
+ data.value = mergedResponse;
391
+ nextCursor.value = cursorState.nextCursor;
392
+ hasNextPage.value = cursorState.hasNextPage;
393
+ } catch (error2) {
394
+ loadMoreError.value = normalizeFetchError(error2);
182
395
  } finally {
183
- loading.value = false;
396
+ timeoutHandle?.clear();
397
+ loadMorePending.value = false;
398
+ loadMoreAbortController.value = null;
399
+ loadMoreRequest.value = null;
184
400
  }
185
401
  })();
186
- fetchPromise = promise;
187
- try {
188
- await promise;
189
- } finally {
190
- if (fetchPromise === promise) {
191
- fetchPromise = null;
192
- }
193
- }
402
+ loadMoreRequest.value = pendingRequest;
403
+ await pendingRequest;
194
404
  };
195
405
  const pollData = async () => {
196
406
  try {
197
- const response = await fetcherFn(initialUrl, currentParams.value);
407
+ const response = ensureCursorResponse(
408
+ await fetcherFn(
409
+ url,
410
+ buildFetchOptions(resolvedBaseOptions.value, unwrapReactiveObject(query))
411
+ )
412
+ );
198
413
  if (data.value) {
199
414
  const existingIds = new Set(
200
- data.value.data.map((item) => item[resolvedItemKey])
201
- );
202
- const newItems = response.data.filter(
203
- (item) => !existingIds.has(item[resolvedItemKey])
415
+ data.value.data.map((item) => resolveItemKeyValue(item, normalizeString(itemKey, "id")))
204
416
  );
417
+ const newItems = response.data.filter((item) => !existingIds.has(resolveItemKeyValue(item, normalizeString(itemKey, "id"))));
205
418
  if (newItems.length > 0) {
206
- data.value.data.unshift(...newItems);
419
+ data.value = {
420
+ ...response,
421
+ data: [...newItems, ...data.value.data]
422
+ };
207
423
  }
208
- } else {
209
- applyResponse(response, true);
210
- }
211
- } catch (e) {
212
- console.error("Polling error:", e);
213
- }
214
- };
215
- const loadMore = async () => {
216
- if (!hasNextPage.value) {
217
- return;
218
- }
219
- isLoadMoreTriggered.value = true;
220
- const baseParams = currentParams.value ?? {};
221
- const params = nextCursor.value ? {
222
- ...baseParams,
223
- query: {
224
- ...baseParams.query ?? {},
225
- [resolvedCursorParam]: nextCursor.value
424
+ const cursorState = extractCursorState(data.value, meta);
425
+ nextCursor.value = cursorState.nextCursor;
426
+ hasNextPage.value = cursorState.hasNextPage;
427
+ return;
226
428
  }
227
- } : baseParams;
228
- await fetchData(initialUrl, params);
229
- };
230
- const refresh = async () => {
231
- data.value = void 0;
232
- nextCursor.value = null;
233
- hasNextPage.value = true;
234
- isLoadMoreTriggered.value = false;
235
- await fetchInitialData(currentParams.value, false);
236
- };
237
- const init = async () => {
238
- if (data.value) {
239
- return;
429
+ syncResponseState(response);
430
+ } catch {
240
431
  }
241
- await fetchInitialData(currentParams.value);
242
432
  };
243
- const reactiveSources = findReactiveSources(fetchOptions);
244
- if (reactiveSources.length > 0) {
245
- watch(
246
- reactiveSources,
247
- () => {
248
- currentParams.value = unwrapReactiveObject(fetchOptions) ?? {};
249
- refresh();
250
- },
251
- { flush: "sync" }
252
- );
253
- }
254
433
  if (pollInterval) {
255
- pollTimer = setInterval(pollData, pollInterval);
256
- }
257
- onUnmounted(() => {
258
- if (pollTimer) {
259
- clearInterval(pollTimer);
260
- }
261
- });
262
- if (immediate) {
263
- if (import.meta.client && lazy) {
264
- onBeforeMount(() => {
265
- void fetchInitialData();
266
- });
267
- } else {
268
- await fetchInitialData();
269
- }
434
+ onMounted(() => {
435
+ pollTimer.value = setInterval(pollData, pollInterval);
436
+ });
437
+ onUnmounted(() => {
438
+ if (pollTimer.value) {
439
+ clearInterval(pollTimer.value);
440
+ pollTimer.value = null;
441
+ }
442
+ });
270
443
  }
444
+ const status = ref("idle");
445
+ watch(
446
+ [() => asyncData.status.value, loadMorePending, loadMoreError],
447
+ ([asyncStatus]) => {
448
+ if (loadMoreError.value) {
449
+ status.value = "error";
450
+ return;
451
+ }
452
+ status.value = loadMorePending.value ? "pending" : asyncStatus;
453
+ },
454
+ { immediate: true }
455
+ );
456
+ const error = ref(void 0);
457
+ watch(
458
+ [() => asyncData.error.value, loadMoreError],
459
+ ([initialError]) => {
460
+ error.value = loadMoreError.value ?? (initialError ? normalizeFetchError(initialError) : void 0);
461
+ },
462
+ { immediate: true }
463
+ );
464
+ const pending = ref(false);
465
+ watch(
466
+ [() => asyncData.pending.value, loadMorePending],
467
+ ([initialPending]) => {
468
+ pending.value = Boolean(initialPending || loadMorePending.value);
469
+ },
470
+ { immediate: true }
471
+ );
472
+ const loading = readonly(pending);
271
473
  return {
272
474
  data,
273
- loading: readonly(loading),
475
+ loading,
274
476
  error: readonly(error),
275
477
  hasNextPage: readonly(hasNextPage),
276
478
  isLoadMoreTriggered: readonly(isLoadMoreTriggered),
277
479
  loadMore,
278
480
  refresh,
481
+ execute,
482
+ clear,
279
483
  init
280
484
  };
281
485
  }
@@ -1,6 +1,6 @@
1
1
  # useCursorHttp
2
2
 
3
- `useCursorHttp` wraps cursor-based APIs for infinite lists. It merges new pages, exposes `loadMore` and `refresh`, and can poll for new items.
3
+ `useCursorHttp` wraps cursor-based APIs for infinite lists. It is Nuxt lifecycle-aware, so it can fetch on the server, hydrate on the client without duplicate requests, and keep `lazy`/`immediate` behavior familiar.
4
4
 
5
5
  ## Signature
6
6
 
@@ -10,15 +10,25 @@ const result = await useCursorHttp(url, options?)
10
10
 
11
11
  ## Options
12
12
 
13
- - `lazy` (boolean): skip the first fetch until `init()` is called.
13
+ - `server` (boolean): fetch during SSR. Default: `true`.
14
+ - `lazy` (boolean): do not block navigation while fetching. Default: `false`.
14
15
  - `immediate` (boolean): run the first fetch on creation. Default: `true`.
16
+ - `cache` (string): caching strategy. Default: `none`.
17
+ - `none`: always fresh; uses a unique instance key and clears cached data on unmount.
18
+ - `payload`: reuse SSR/hydration payload only.
19
+ - `static`: reuse static payload only.
20
+ - `both`: reuse Nuxt payload/static data.
21
+ - `default` (function): initial value before the request resolves.
22
+ - `watch` (sources): auto-refresh when reactive sources change.
23
+ - `dedupe` (string): control overlapping requests (`cancel` or `defer`).
24
+ - `timeout` (number): abort slow requests.
15
25
  - `pollInterval` (number): poll interval in ms for new items.
16
26
  - `fetcher` (function): custom fetcher. Defaults to Nuxt `$fetch`.
17
27
  - `fetchOptions` (object): passed to `$fetch`, supports refs for reactive params.
18
28
  - `meta` (object): response meta keys.
19
- - `meta.cursorKey` (string): key for the next cursor. Default: `afterCursor`.
20
- - `meta.hasMoreKey` (string): key for more data. Default: `hasMore`.
21
- - `itemKey` (string): unique item id to dedupe polls. Default: `id`.
29
+ - `meta.cursorKey` (string): key for the next cursor.
30
+ - `meta.hasMoreKey` (string): key for more data.
31
+ - `itemKey` (string): unique item id to dedupe polls. Supports dot paths like `customer.id`. Default: `id`.
22
32
  - `cursorParam` (string): query param for cursor. Default: `cursor`.
23
33
 
24
34
  ## Returns
@@ -30,6 +40,8 @@ const result = await useCursorHttp(url, options?)
30
40
  - `isLoadMoreTriggered` (readonly ref)
31
41
  - `loadMore()` (function)
32
42
  - `refresh()` (function)
43
+ - `execute()` (function)
44
+ - `clear()` (function)
33
45
  - `init()` (function)
34
46
 
35
47
  ## Examples
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciwergrp/nuxid",
3
- "version": "1.17.8",
3
+ "version": "1.19.0",
4
4
  "description": "All-in-one essential modules for Nuxt",
5
5
  "repository": {
6
6
  "type": "git",