@bquery/bquery 1.3.0 → 1.5.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.
Files changed (138) hide show
  1. package/README.md +546 -501
  2. package/dist/component/component.d.ts.map +1 -1
  3. package/dist/component/index.d.ts +2 -0
  4. package/dist/component/index.d.ts.map +1 -1
  5. package/dist/component/library.d.ts +34 -0
  6. package/dist/component/library.d.ts.map +1 -0
  7. package/dist/component/types.d.ts +10 -6
  8. package/dist/component/types.d.ts.map +1 -1
  9. package/dist/component-CY5MVoYN.js +531 -0
  10. package/dist/component-CY5MVoYN.js.map +1 -0
  11. package/dist/component.es.mjs +6 -184
  12. package/dist/config-DRmZZno3.js +40 -0
  13. package/dist/config-DRmZZno3.js.map +1 -0
  14. package/dist/core/collection.d.ts +19 -3
  15. package/dist/core/collection.d.ts.map +1 -1
  16. package/dist/core/element.d.ts +23 -4
  17. package/dist/core/element.d.ts.map +1 -1
  18. package/dist/core/index.d.ts +1 -0
  19. package/dist/core/index.d.ts.map +1 -1
  20. package/dist/core/utils/function.d.ts +21 -4
  21. package/dist/core/utils/function.d.ts.map +1 -1
  22. package/dist/core-CK2Mfpf4.js +648 -0
  23. package/dist/core-CK2Mfpf4.js.map +1 -0
  24. package/dist/core-DPdbItcq.js +112 -0
  25. package/dist/core-DPdbItcq.js.map +1 -0
  26. package/dist/core.es.mjs +45 -1218
  27. package/dist/full.d.ts +6 -6
  28. package/dist/full.d.ts.map +1 -1
  29. package/dist/full.es.mjs +98 -92
  30. package/dist/full.iife.js +173 -3
  31. package/dist/full.iife.js.map +1 -1
  32. package/dist/full.umd.js +173 -3
  33. package/dist/full.umd.js.map +1 -1
  34. package/dist/index.es.mjs +143 -139
  35. package/dist/motion/transition.d.ts +1 -1
  36. package/dist/motion/transition.d.ts.map +1 -1
  37. package/dist/motion/types.d.ts +11 -1
  38. package/dist/motion/types.d.ts.map +1 -1
  39. package/dist/motion-C5DRdPnO.js +415 -0
  40. package/dist/motion-C5DRdPnO.js.map +1 -0
  41. package/dist/motion.es.mjs +25 -361
  42. package/dist/object-qGpWr6-J.js +38 -0
  43. package/dist/object-qGpWr6-J.js.map +1 -0
  44. package/dist/platform/announcer.d.ts +59 -0
  45. package/dist/platform/announcer.d.ts.map +1 -0
  46. package/dist/platform/config.d.ts +92 -0
  47. package/dist/platform/config.d.ts.map +1 -0
  48. package/dist/platform/cookies.d.ts +45 -0
  49. package/dist/platform/cookies.d.ts.map +1 -0
  50. package/dist/platform/index.d.ts +8 -0
  51. package/dist/platform/index.d.ts.map +1 -1
  52. package/dist/platform/meta.d.ts +62 -0
  53. package/dist/platform/meta.d.ts.map +1 -0
  54. package/dist/platform/storage.d.ts.map +1 -1
  55. package/dist/platform-B7JhGBc7.js +361 -0
  56. package/dist/platform-B7JhGBc7.js.map +1 -0
  57. package/dist/platform.es.mjs +11 -243
  58. package/dist/reactive/async-data.d.ts +114 -0
  59. package/dist/reactive/async-data.d.ts.map +1 -0
  60. package/dist/reactive/core.d.ts +12 -0
  61. package/dist/reactive/core.d.ts.map +1 -1
  62. package/dist/reactive/effect.d.ts.map +1 -1
  63. package/dist/reactive/index.d.ts +2 -2
  64. package/dist/reactive/index.d.ts.map +1 -1
  65. package/dist/reactive/internals.d.ts +6 -0
  66. package/dist/reactive/internals.d.ts.map +1 -1
  67. package/dist/reactive/signal.d.ts +2 -0
  68. package/dist/reactive/signal.d.ts.map +1 -1
  69. package/dist/reactive-BDya-ia8.js +253 -0
  70. package/dist/reactive-BDya-ia8.js.map +1 -0
  71. package/dist/reactive.es.mjs +18 -34
  72. package/dist/router-CijiICxt.js +188 -0
  73. package/dist/router-CijiICxt.js.map +1 -0
  74. package/dist/router.es.mjs +11 -200
  75. package/dist/sanitize-jyJ2ryE2.js +302 -0
  76. package/dist/sanitize-jyJ2ryE2.js.map +1 -0
  77. package/dist/security/constants.d.ts.map +1 -1
  78. package/dist/security/sanitize-core.d.ts.map +1 -1
  79. package/dist/security.es.mjs +10 -56
  80. package/dist/store-CPK9E62U.js +262 -0
  81. package/dist/store-CPK9E62U.js.map +1 -0
  82. package/dist/store.es.mjs +12 -25
  83. package/dist/view/evaluate.d.ts.map +1 -1
  84. package/dist/view-Cdi0g-qo.js +396 -0
  85. package/dist/view-Cdi0g-qo.js.map +1 -0
  86. package/dist/view.es.mjs +10 -424
  87. package/package.json +136 -132
  88. package/src/component/component.ts +319 -289
  89. package/src/component/index.ts +42 -40
  90. package/src/component/library.ts +504 -0
  91. package/src/component/types.ts +91 -85
  92. package/src/core/collection.ts +44 -4
  93. package/src/core/element.ts +33 -5
  94. package/src/core/index.ts +1 -0
  95. package/src/core/utils/function.ts +56 -15
  96. package/src/full.ts +223 -187
  97. package/src/motion/transition.ts +97 -51
  98. package/src/motion/types.ts +208 -198
  99. package/src/platform/announcer.ts +208 -0
  100. package/src/platform/config.ts +163 -0
  101. package/src/platform/cookies.ts +165 -0
  102. package/src/platform/index.ts +39 -18
  103. package/src/platform/meta.ts +168 -0
  104. package/src/platform/storage.ts +8 -1
  105. package/src/reactive/async-data.ts +486 -0
  106. package/src/reactive/core.ts +21 -0
  107. package/src/reactive/effect.ts +18 -7
  108. package/src/reactive/index.ts +37 -23
  109. package/src/reactive/internals.ts +18 -1
  110. package/src/reactive/signal.ts +29 -20
  111. package/src/security/constants.ts +211 -209
  112. package/src/security/sanitize-core.ts +22 -1
  113. package/src/view/evaluate.ts +29 -13
  114. package/dist/batch-4LAvfLE7.js +0 -13
  115. package/dist/batch-4LAvfLE7.js.map +0 -1
  116. package/dist/component.es.mjs.map +0 -1
  117. package/dist/core-COenAZjD.js +0 -145
  118. package/dist/core-COenAZjD.js.map +0 -1
  119. package/dist/core.es.mjs.map +0 -1
  120. package/dist/full.es.mjs.map +0 -1
  121. package/dist/index.es.mjs.map +0 -1
  122. package/dist/motion.es.mjs.map +0 -1
  123. package/dist/persisted-Dz_ryNuC.js +0 -278
  124. package/dist/persisted-Dz_ryNuC.js.map +0 -1
  125. package/dist/platform.es.mjs.map +0 -1
  126. package/dist/reactive.es.mjs.map +0 -1
  127. package/dist/router.es.mjs.map +0 -1
  128. package/dist/sanitize-1FBEPAFH.js +0 -272
  129. package/dist/sanitize-1FBEPAFH.js.map +0 -1
  130. package/dist/security.es.mjs.map +0 -1
  131. package/dist/store.es.mjs.map +0 -1
  132. package/dist/type-guards-DRma3-Kc.js +0 -16
  133. package/dist/type-guards-DRma3-Kc.js.map +0 -1
  134. package/dist/untrack-BuEQKH7_.js +0 -6
  135. package/dist/untrack-BuEQKH7_.js.map +0 -1
  136. package/dist/view.es.mjs.map +0 -1
  137. package/dist/watch-CXyaBC_9.js +0 -58
  138. package/dist/watch-CXyaBC_9.js.map +0 -1
@@ -0,0 +1,486 @@
1
+ /**
2
+ * Async data and fetch composables built on bQuery signals.
3
+ *
4
+ * @module bquery/reactive
5
+ */
6
+
7
+ import { merge } from '../core/utils/object';
8
+ import { getBqueryConfig, type BqueryFetchParseAs } from '../platform/config';
9
+ import { computed } from './computed';
10
+ import { effect } from './effect';
11
+ import { Signal, signal } from './core';
12
+ import { untrack } from './untrack';
13
+
14
+ /** Allowed status values for async composables. */
15
+ export type AsyncDataStatus = 'idle' | 'pending' | 'success' | 'error';
16
+
17
+ /** Reactive source types that can trigger refreshes. */
18
+ export type AsyncWatchSource = (() => unknown) | { value: unknown };
19
+
20
+ /** Options shared by async composables. */
21
+ export interface UseAsyncDataOptions<TResult, TData = TResult> {
22
+ /** Run the handler immediately (default: true). */
23
+ immediate?: boolean;
24
+ /** Default data value before the first successful execution. */
25
+ defaultValue?: TData;
26
+ /** Optional reactive sources that trigger refreshes when they change. */
27
+ watch?: AsyncWatchSource[];
28
+ /** Transform the resolved value before storing it. */
29
+ transform?: (value: TResult) => TData;
30
+ /** Called after a successful execution. */
31
+ onSuccess?: (value: TData) => void;
32
+ /** Called after a failed execution. */
33
+ onError?: (error: Error) => void;
34
+ }
35
+
36
+ /** Return value of useAsyncData() and useFetch(). */
37
+ export interface AsyncDataState<TData> {
38
+ /** Reactive data signal. */
39
+ data: Signal<TData | undefined>;
40
+ /** Last error encountered by the composable. */
41
+ error: Signal<Error | null>;
42
+ /** Current lifecycle status. */
43
+ status: Signal<AsyncDataStatus>;
44
+ /** Computed boolean that mirrors `status === 'pending'`. */
45
+ pending: { readonly value: boolean; peek(): boolean };
46
+ /** Execute the handler manually. Returns the cached data value when called after dispose(). */
47
+ execute: () => Promise<TData | undefined>;
48
+ /** Alias for execute(). */
49
+ refresh: () => Promise<TData | undefined>;
50
+ /** Clear data, error, and status back to the initial state. */
51
+ clear: () => void;
52
+ /** Dispose reactive watchers and prevent future executions. */
53
+ dispose: () => void;
54
+ }
55
+
56
+ /** Options for useFetch(). */
57
+ export interface UseFetchOptions<TResponse = unknown, TData = TResponse>
58
+ extends UseAsyncDataOptions<TResponse, TData>, Omit<RequestInit, 'body' | 'headers'> {
59
+ /** Base URL prepended to relative URLs. */
60
+ baseUrl?: string;
61
+ /** Query parameters appended to the request URL. */
62
+ query?: Record<string, unknown>;
63
+ /** Request headers. */
64
+ headers?: HeadersInit;
65
+ /** Request body, including plain objects for JSON requests. */
66
+ body?: BodyInit | Record<string, unknown> | unknown[] | null;
67
+ /** Override the parser used for the response body. */
68
+ parseAs?: BqueryFetchParseAs;
69
+ /** Custom fetch implementation for testing or adapters. */
70
+ fetcher?: typeof fetch;
71
+ }
72
+
73
+ /** Input accepted by useFetch(). */
74
+ export type FetchInput = string | URL | Request | (() => string | URL | Request);
75
+
76
+ const normalizeError = (error: unknown): Error => {
77
+ if (error instanceof Error) return error;
78
+ if (typeof error === 'string') {
79
+ return new Error(error);
80
+ }
81
+
82
+ try {
83
+ return new Error(JSON.stringify(error));
84
+ } catch {
85
+ return new Error(String(error));
86
+ }
87
+ };
88
+
89
+ const readWatchSource = (source: AsyncWatchSource): unknown => {
90
+ if (typeof source === 'function') {
91
+ return source();
92
+ }
93
+ return source.value;
94
+ };
95
+
96
+ const toHeaders = (...sources: Array<HeadersInit | undefined>): Headers => {
97
+ const headers = new Headers();
98
+ for (const source of sources) {
99
+ if (!source) continue;
100
+ new Headers(source).forEach((value, key) => {
101
+ headers.set(key, value);
102
+ });
103
+ }
104
+ return headers;
105
+ };
106
+
107
+ const isBodyLike = (value: unknown): value is BodyInit => {
108
+ if (typeof value === 'string') return true;
109
+ if (value instanceof Blob || value instanceof FormData || value instanceof URLSearchParams) {
110
+ return true;
111
+ }
112
+ if (typeof ArrayBuffer !== 'undefined' && value instanceof ArrayBuffer) return true;
113
+ if (typeof ReadableStream !== 'undefined' && value instanceof ReadableStream) return true;
114
+ return typeof value === 'object' && value !== null && ArrayBuffer.isView(value);
115
+ };
116
+
117
+ const serializeBody = (
118
+ body: UseFetchOptions['body'],
119
+ headers: Headers
120
+ ): BodyInit | null | undefined => {
121
+ if (body == null) return body;
122
+ if (isBodyLike(body)) return body;
123
+
124
+ if (!headers.has('content-type')) {
125
+ headers.set('content-type', 'application/json');
126
+ }
127
+
128
+ return JSON.stringify(body);
129
+ };
130
+
131
+ const resolveInput = (input: FetchInput): string | URL | Request => {
132
+ return typeof input === 'function' ? input() : input;
133
+ };
134
+
135
+ const appendQuery = (url: URL, query: Record<string, unknown>): void => {
136
+ for (const [key, value] of Object.entries(query)) {
137
+ if (value == null) continue;
138
+
139
+ if (Array.isArray(value)) {
140
+ for (const item of value) {
141
+ if (item != null) {
142
+ url.searchParams.append(key, String(item));
143
+ }
144
+ }
145
+ continue;
146
+ }
147
+
148
+ url.searchParams.set(key, String(value));
149
+ }
150
+ };
151
+
152
+ const toUrl = (input: string | URL, baseUrl?: string): URL => {
153
+ const runtimeBase =
154
+ typeof window !== 'undefined' && /^https?:/i.test(window.location.href)
155
+ ? window.location.href
156
+ : 'http://localhost';
157
+ const base = baseUrl ? new URL(baseUrl, runtimeBase).toString() : runtimeBase;
158
+ return input instanceof URL ? new URL(input.toString(), base) : new URL(input, base);
159
+ };
160
+
161
+ const parseResponse = async <TResponse>(
162
+ response: Response,
163
+ parseAs: BqueryFetchParseAs
164
+ ): Promise<TResponse> => {
165
+ if (parseAs === 'response') return response as TResponse;
166
+ if (parseAs === 'text') return (await response.text()) as TResponse;
167
+ if (parseAs === 'blob') return (await response.blob()) as TResponse;
168
+ if (parseAs === 'arrayBuffer') return (await response.arrayBuffer()) as TResponse;
169
+ if (parseAs === 'formData') return (await response.formData()) as TResponse;
170
+
171
+ const text = await response.text();
172
+ if (!text) {
173
+ return undefined as TResponse;
174
+ }
175
+
176
+ try {
177
+ return JSON.parse(text) as TResponse;
178
+ } catch (error) {
179
+ const detail = response.url ? ` for ${response.url}` : '';
180
+ throw new Error(
181
+ `Failed to parse JSON response${detail} (status ${response.status}): ${error instanceof Error ? error.message : String(error)}`
182
+ );
183
+ }
184
+ };
185
+
186
+ const normalizeMethod = (method?: string): string | undefined => {
187
+ const normalized = method?.trim();
188
+ return normalized ? normalized.toUpperCase() : undefined;
189
+ };
190
+
191
+ const resolveMethod = (
192
+ explicitMethod: string | undefined,
193
+ requestInput: string | URL | Request,
194
+ bodyProvided: boolean
195
+ ): string | undefined => {
196
+ const requestMethod =
197
+ requestInput instanceof Request ? normalizeMethod(requestInput.method) : undefined;
198
+ return explicitMethod ?? requestMethod ?? (bodyProvided ? 'POST' : undefined);
199
+ };
200
+
201
+ const resolveRequestInitMethod = (
202
+ explicitMethod: string | undefined,
203
+ requestInput: string | URL | Request,
204
+ method: string | undefined
205
+ ): string | undefined => {
206
+ if (explicitMethod) return explicitMethod;
207
+ return requestInput instanceof Request ? undefined : method;
208
+ };
209
+
210
+ const toRequestInit = (request: Request): RequestInit => {
211
+ const requestMethod = normalizeMethod(request.method);
212
+ let body: BodyInit | undefined;
213
+ if (requestMethod !== 'GET' && requestMethod !== 'HEAD' && !request.bodyUsed) {
214
+ try {
215
+ body = request.clone().body ?? undefined;
216
+ } catch {
217
+ body = undefined;
218
+ }
219
+ }
220
+
221
+ return {
222
+ method: requestMethod,
223
+ headers: request.headers,
224
+ body,
225
+ cache: request.cache,
226
+ credentials: request.credentials,
227
+ integrity: request.integrity,
228
+ keepalive: request.keepalive,
229
+ mode: request.mode,
230
+ redirect: request.redirect,
231
+ referrer: request.referrer,
232
+ referrerPolicy: request.referrerPolicy,
233
+ signal: request.signal,
234
+ };
235
+ };
236
+
237
+ /**
238
+ * Create a reactive wrapper around an async resolver.
239
+ *
240
+ * @template TResult - Raw result type returned by the handler
241
+ * @template TData - Stored data type after optional transformation
242
+ * @param handler - Async function to execute
243
+ * @param options - Execution, transform, and refresh options
244
+ * @returns Reactive data state with execute(), refresh(), and clear()
245
+ *
246
+ * @example
247
+ * ```ts
248
+ * const user = useAsyncData(() => fetch('/api/user').then((res) => res.json()));
249
+ * ```
250
+ */
251
+ export const useAsyncData = <TResult, TData = TResult>(
252
+ handler: () => Promise<TResult>,
253
+ options: UseAsyncDataOptions<TResult, TData> = {}
254
+ ): AsyncDataState<TData> => {
255
+ const immediate = options.immediate ?? true;
256
+ const data = signal<TData | undefined>(options.defaultValue);
257
+ const error = signal<Error | null>(null);
258
+ const status = signal<AsyncDataStatus>('idle');
259
+ const pending = computed(() => status.value === 'pending');
260
+ let executionId = 0;
261
+ let disposed = false;
262
+ let stopWatching = (): void => {};
263
+
264
+ const clear = (): void => {
265
+ executionId += 1;
266
+ data.value = options.defaultValue;
267
+ error.value = null;
268
+ status.value = 'idle';
269
+ };
270
+
271
+ const dispose = (): void => {
272
+ if (disposed) return;
273
+ disposed = true;
274
+ executionId += 1;
275
+ stopWatching();
276
+ };
277
+
278
+ const execute = async (): Promise<TData | undefined> => {
279
+ if (disposed) {
280
+ return data.peek();
281
+ }
282
+
283
+ const currentExecution = ++executionId;
284
+ status.value = 'pending';
285
+ error.value = null;
286
+
287
+ try {
288
+ const resolved = await handler();
289
+ const transformed = options.transform
290
+ ? options.transform(resolved)
291
+ : (resolved as unknown as TData);
292
+
293
+ if (disposed || currentExecution !== executionId) {
294
+ return data.peek();
295
+ }
296
+
297
+ data.value = transformed;
298
+ status.value = 'success';
299
+ options.onSuccess?.(transformed);
300
+ return transformed;
301
+ } catch (caught) {
302
+ const normalizedError = normalizeError(caught);
303
+
304
+ if (disposed || currentExecution !== executionId) {
305
+ return data.peek();
306
+ }
307
+
308
+ error.value = normalizedError;
309
+ status.value = 'error';
310
+ options.onError?.(normalizedError);
311
+ return data.peek();
312
+ }
313
+ };
314
+
315
+ if (options.watch?.length) {
316
+ let initialized = false;
317
+ stopWatching = effect(() => {
318
+ for (const source of options.watch ?? []) {
319
+ readWatchSource(source);
320
+ }
321
+
322
+ if (!initialized) {
323
+ initialized = true;
324
+ if (immediate) {
325
+ void untrack(() => execute());
326
+ }
327
+ return;
328
+ }
329
+
330
+ void untrack(() => execute());
331
+ });
332
+ } else if (immediate) {
333
+ void execute();
334
+ }
335
+
336
+ return {
337
+ data,
338
+ error,
339
+ status,
340
+ pending,
341
+ execute,
342
+ refresh: execute,
343
+ clear,
344
+ dispose,
345
+ };
346
+ };
347
+
348
+ /**
349
+ * Reactive fetch composable using the browser Fetch API.
350
+ *
351
+ * @template TResponse - Raw parsed response type
352
+ * @template TData - Stored response type after optional transformation
353
+ * @param input - Request URL, Request object, or lazy input factory
354
+ * @param options - Request and reactive state options
355
+ * @returns Reactive fetch state with execute(), refresh(), and clear()
356
+ *
357
+ * @example
358
+ * ```ts
359
+ * const users = useFetch<{ id: number; name: string }[]>('/api/users');
360
+ * ```
361
+ */
362
+ export const useFetch = <TResponse = unknown, TData = TResponse>(
363
+ input: FetchInput,
364
+ options: UseFetchOptions<TResponse, TData> = {}
365
+ ): AsyncDataState<TData> => {
366
+ const fetchConfig = getBqueryConfig().fetch;
367
+ const parseAs = options.parseAs ?? fetchConfig?.parseAs ?? 'json';
368
+ const fetcher = options.fetcher ?? fetch;
369
+
370
+ return useAsyncData<TResponse, TData>(async () => {
371
+ const requestInput = resolveInput(input);
372
+ const requestUrl =
373
+ typeof requestInput === 'string' || requestInput instanceof URL
374
+ ? toUrl(requestInput, options.baseUrl ?? fetchConfig?.baseUrl)
375
+ : requestInput instanceof Request && options.query
376
+ ? new URL(requestInput.url)
377
+ : null;
378
+
379
+ if (requestUrl && options.query) {
380
+ appendQuery(requestUrl, options.query);
381
+ }
382
+
383
+ const headers = toHeaders(
384
+ fetchConfig?.headers,
385
+ requestInput instanceof Request ? requestInput.headers : undefined,
386
+ options.headers
387
+ );
388
+ const bodyProvided = options.body != null;
389
+ const explicitMethod = normalizeMethod(options.method);
390
+ const method = resolveMethod(explicitMethod, requestInput, bodyProvided);
391
+ const bodylessMethod = method === 'GET' || method === 'HEAD' ? method : null;
392
+ if (bodyProvided && bodylessMethod) {
393
+ throw new Error(`Cannot send a request body with ${bodylessMethod} requests`);
394
+ }
395
+ const requestInitMethod = resolveRequestInitMethod(explicitMethod, requestInput, method);
396
+ const requestInit: RequestInit = {
397
+ ...options,
398
+ method: requestInitMethod,
399
+ headers,
400
+ body: serializeBody(options.body, headers),
401
+ };
402
+
403
+ delete (requestInit as Partial<UseFetchOptions>).baseUrl;
404
+ delete (requestInit as Partial<UseFetchOptions>).query;
405
+ delete (requestInit as Partial<UseFetchOptions>).parseAs;
406
+ delete (requestInit as Partial<UseFetchOptions>).fetcher;
407
+ delete (requestInit as Partial<UseFetchOptions>).defaultValue;
408
+ delete (requestInit as Partial<UseFetchOptions>).immediate;
409
+ delete (requestInit as Partial<UseFetchOptions>).watch;
410
+ delete (requestInit as Partial<UseFetchOptions>).transform;
411
+ delete (requestInit as Partial<UseFetchOptions>).onSuccess;
412
+ delete (requestInit as Partial<UseFetchOptions>).onError;
413
+
414
+ let requestTarget: Request | string | URL = requestUrl ?? requestInput;
415
+ if (
416
+ requestInput instanceof Request &&
417
+ requestUrl &&
418
+ requestUrl.toString() !== requestInput.url
419
+ ) {
420
+ // Rebuild Request inputs when query params changed so the updated URL is preserved.
421
+ // String/URL inputs already use `requestUrl` directly, so only Request objects need rebuilding.
422
+ requestTarget = new Request(requestUrl.toString(), toRequestInit(requestInput));
423
+ }
424
+ const response = await fetcher(requestTarget, requestInit);
425
+
426
+ if (!response.ok) {
427
+ throw Object.assign(new Error(`Request failed with status ${response.status}`), {
428
+ response,
429
+ status: response.status,
430
+ statusText: response.statusText,
431
+ });
432
+ }
433
+
434
+ return parseResponse<TResponse>(response, parseAs);
435
+ }, options);
436
+ };
437
+
438
+ /**
439
+ * Create a preconfigured useFetch() helper.
440
+ *
441
+ * @param defaults - Default request options merged into every useFetch() call
442
+ * @returns A useFetch-compatible function with merged defaults
443
+ *
444
+ * @example
445
+ * ```ts
446
+ * const useApiFetch = createUseFetch({ baseUrl: 'https://api.example.com' });
447
+ * const profile = useApiFetch('/profile');
448
+ * ```
449
+ */
450
+ /** Overload for factories without a configured transform, preserving per-call `TResponse -> TData` inference. */
451
+ export function createUseFetch<TDefaultResponse = unknown>(
452
+ defaults?: UseFetchOptions<TDefaultResponse, TDefaultResponse>
453
+ ): <TResponse = TDefaultResponse, TData = TResponse>(
454
+ input: FetchInput,
455
+ options?: UseFetchOptions<TResponse, TData>
456
+ ) => AsyncDataState<TData>;
457
+
458
+ /** Overload for factories with a configured transform, preserving the transformed factory data type by default. */
459
+ export function createUseFetch<TDefaultResponse = unknown, TDefaultData = TDefaultResponse>(
460
+ defaults: UseFetchOptions<TDefaultResponse, TDefaultData>
461
+ ): <TResponse = TDefaultResponse, TData = TDefaultData>(
462
+ input: FetchInput,
463
+ options?: UseFetchOptions<TResponse, TData>
464
+ ) => AsyncDataState<TData>;
465
+
466
+ export function createUseFetch<TDefaultResponse = unknown, TDefaultData = TDefaultResponse>(
467
+ defaults: UseFetchOptions<TDefaultResponse, TDefaultData> = {}
468
+ ) {
469
+ return <TResponse = TDefaultResponse, TData = TDefaultData>(
470
+ input: FetchInput,
471
+ options: UseFetchOptions<TResponse, TData> = {}
472
+ ): AsyncDataState<TData> => {
473
+ const resolvedDefaults = defaults as unknown as UseFetchOptions<TResponse, TData>;
474
+ const mergedQuery = merge({}, resolvedDefaults.query ?? {}, options.query ?? {}) as Record<
475
+ string,
476
+ unknown
477
+ >;
478
+
479
+ return useFetch<TResponse, TData>(input, {
480
+ ...resolvedDefaults,
481
+ ...options,
482
+ headers: toHeaders(resolvedDefaults.headers, options.headers),
483
+ query: Object.keys(mergedQuery).length > 0 ? mergedQuery : undefined,
484
+ });
485
+ };
486
+ }
@@ -5,6 +5,7 @@
5
5
  import {
6
6
  getCurrentObserver,
7
7
  registerDependency,
8
+ removeDependency,
8
9
  scheduleObserver,
9
10
  type ReactiveSource,
10
11
  } from './internals';
@@ -74,6 +75,26 @@ export class Signal<T> implements ReactiveSource {
74
75
  this.value = updater(this._value);
75
76
  }
76
77
 
78
+ /**
79
+ * Removes all subscribers from this signal.
80
+ * Use this when a signal is no longer needed to prevent memory leaks.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * const count = signal(0);
85
+ * effect(() => console.log(count.value));
86
+ * count.dispose(); // All subscribers removed
87
+ * ```
88
+ */
89
+ dispose(): void {
90
+ // Remove this signal from each subscriber's dependency set
91
+ // so the observer no longer holds a strong reference to it
92
+ for (const subscriber of this.subscribers) {
93
+ removeDependency(subscriber, this);
94
+ }
95
+ this.subscribers.clear();
96
+ }
97
+
77
98
  /**
78
99
  * Removes an observer from this signal's subscriber set.
79
100
  * @internal
@@ -17,26 +17,37 @@ export const effect = (fn: () => void | CleanupFn): CleanupFn => {
17
17
  let cleanupFn: CleanupFn | void;
18
18
  let isDisposed = false;
19
19
 
20
+ const runCleanup = (): void => {
21
+ if (cleanupFn) {
22
+ try {
23
+ cleanupFn();
24
+ } catch (error) {
25
+ console.error('bQuery reactive: Error in effect cleanup', error);
26
+ }
27
+ cleanupFn = undefined;
28
+ }
29
+ };
30
+
20
31
  const observer: Observer = () => {
21
32
  if (isDisposed) return;
22
33
 
23
- if (cleanupFn) {
24
- cleanupFn();
25
- }
34
+ runCleanup();
26
35
 
27
36
  // Clear old dependencies before running to avoid stale subscriptions
28
37
  clearDependencies(observer);
29
38
 
30
- cleanupFn = track(observer, fn);
39
+ try {
40
+ cleanupFn = track(observer, fn);
41
+ } catch (error) {
42
+ console.error('bQuery reactive: Error in effect', error);
43
+ }
31
44
  };
32
45
 
33
46
  observer();
34
47
 
35
48
  return () => {
36
49
  isDisposed = true;
37
- if (cleanupFn) {
38
- cleanupFn();
39
- }
50
+ runCleanup();
40
51
  // Clean up all dependencies when effect is disposed
41
52
  clearDependencies(observer);
42
53
  };
@@ -1,23 +1,37 @@
1
- /**
2
- * Reactive module providing fine-grained reactivity primitives.
3
- *
4
- * @module bquery/reactive
5
- */
6
-
7
- export {
8
- Computed,
9
- Signal,
10
- batch,
11
- computed,
12
- effect,
13
- isComputed,
14
- isSignal,
15
- linkedSignal,
16
- persistedSignal,
17
- readonly,
18
- signal,
19
- untrack,
20
- watch,
21
- } from './signal';
22
-
23
- export type { CleanupFn, LinkedSignal, Observer, ReadonlySignal } from './signal';
1
+ /**
2
+ * Reactive module providing fine-grained reactivity primitives.
3
+ *
4
+ * @module bquery/reactive
5
+ */
6
+
7
+ export {
8
+ Computed,
9
+ Signal,
10
+ batch,
11
+ computed,
12
+ createUseFetch,
13
+ effect,
14
+ isComputed,
15
+ isSignal,
16
+ linkedSignal,
17
+ persistedSignal,
18
+ readonly,
19
+ signal,
20
+ useAsyncData,
21
+ useFetch,
22
+ untrack,
23
+ watch,
24
+ } from './signal';
25
+
26
+ export type {
27
+ AsyncDataState,
28
+ AsyncDataStatus,
29
+ AsyncWatchSource,
30
+ CleanupFn,
31
+ FetchInput,
32
+ LinkedSignal,
33
+ Observer,
34
+ ReadonlySignal,
35
+ UseAsyncDataOptions,
36
+ UseFetchOptions,
37
+ } from './signal';
@@ -62,7 +62,11 @@ export const scheduleObserver = (observer: Observer): void => {
62
62
  const flushObservers = (): void => {
63
63
  for (const observer of Array.from(pendingObservers)) {
64
64
  pendingObservers.delete(observer);
65
- observer();
65
+ try {
66
+ observer();
67
+ } catch (error) {
68
+ console.error('bQuery reactive: Error in observer during batch flush', error);
69
+ }
66
70
  }
67
71
  };
68
72
 
@@ -71,6 +75,7 @@ export const beginBatch = (): void => {
71
75
  };
72
76
 
73
77
  export const endBatch = (): void => {
78
+ if (batchDepth <= 0) return;
74
79
  batchDepth -= 1;
75
80
  if (batchDepth === 0) {
76
81
  flushObservers();
@@ -90,6 +95,18 @@ export const registerDependency = (observer: Observer, source: ReactiveSource):
90
95
  deps.add(source);
91
96
  };
92
97
 
98
+ /**
99
+ * Removes a specific source from an observer's dependency set.
100
+ * Used when a source (e.g. Signal) is disposed to prevent stale references.
101
+ * @internal
102
+ */
103
+ export const removeDependency = (observer: Observer, source: ReactiveSource): void => {
104
+ const deps = observerDependencies.get(observer);
105
+ if (deps) {
106
+ deps.delete(source);
107
+ }
108
+ };
109
+
93
110
  /**
94
111
  * Clears all dependencies for an observer, unsubscribing from all sources.
95
112
  * @internal