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