enlace 0.0.1-beta.10 → 0.0.1-beta.11

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/README.md CHANGED
@@ -85,37 +85,108 @@ api.users[123].get(); // GET /users/123
85
85
  api.users[123].profile.get(); // GET /users/123/profile
86
86
  ```
87
87
 
88
- ### Endpoint Type
88
+ ### Endpoint Types
89
89
 
90
- The `Endpoint` type helper lets you define response data, request body, and optionally override the error type:
90
+ The `Endpoint` type helpers let you define response data, request body, query params, formData, and error types.
91
+
92
+ #### `Endpoint<TData, TBody?, TError?>`
93
+
94
+ For endpoints with JSON body:
91
95
 
92
96
  ```typescript
93
- // Signature: Endpoint<TData, TBody?, TError?>
94
- type Endpoint<TData, TBody = never, TError = never>;
97
+ import { Endpoint } from "enlace";
98
+
99
+ type ApiSchema = {
100
+ posts: {
101
+ $get: Post[]; // Direct type (simplest)
102
+ $post: Endpoint<Post, CreatePost>; // Data + Body
103
+ $put: Endpoint<Post, UpdatePost, ValidationError>; // Data + Body + Error
104
+ $delete: void; // void response
105
+ $patch: Endpoint<Post, never, NotFoundError>; // Custom error without body
106
+ };
107
+ };
95
108
  ```
96
109
 
97
- **Three ways to define endpoints:**
110
+ #### `EndpointWithQuery<TData, TQuery, TError?>`
111
+
112
+ For endpoints with typed query parameters:
98
113
 
99
114
  ```typescript
115
+ import { EndpointWithQuery } from "enlace";
116
+
100
117
  type ApiSchema = {
118
+ users: {
119
+ $get: EndpointWithQuery<User[], { page: number; limit: number; search?: string }>;
120
+ };
101
121
  posts: {
102
- // 1. Direct type (simplest) - just the data type
103
- // Error comes from global default
104
- $get: Post[];
122
+ $get: EndpointWithQuery<Post[], { status: "draft" | "published" }, ApiError>;
123
+ };
124
+ };
125
+
126
+ // Usage - query params are fully typed
127
+ const { data } = useAPI((api) => api.users.get({ query: { page: 1, limit: 10 } }));
128
+ // api.users.get({ query: { foo: "bar" } }); // ✗ Error: 'foo' does not exist
129
+ ```
130
+
131
+ #### `EndpointWithFormData<TData, TFormData, TError?>`
132
+
133
+ For file uploads (multipart/form-data):
134
+
135
+ ```typescript
136
+ import { EndpointWithFormData } from "enlace";
137
+
138
+ type ApiSchema = {
139
+ uploads: {
140
+ $post: EndpointWithFormData<Upload, { file: Blob | File; name: string }>;
141
+ };
142
+ avatars: {
143
+ $post: EndpointWithFormData<Avatar, { image: File }, UploadError>;
144
+ };
145
+ };
146
+
147
+ // Usage - formData is automatically converted to FormData
148
+ const { trigger } = useAPI((api) => api.uploads.post);
149
+ trigger({
150
+ formData: {
151
+ file: selectedFile, // File object
152
+ name: "document.pdf", // String - converted automatically
153
+ }
154
+ });
155
+ // → Sends as multipart/form-data
156
+ ```
157
+
158
+ **FormData conversion rules:**
105
159
 
106
- // 2. Endpoint with body - Endpoint<Data, Body>
107
- // Error comes from global default
108
- $post: Endpoint<Post, CreatePost>;
160
+ | Type | Conversion |
161
+ |------|------------|
162
+ | `File` / `Blob` | Appended directly |
163
+ | `string` / `number` / `boolean` | Converted to string |
164
+ | `object` (nested) | JSON stringified |
165
+ | `array` of primitives | Each item appended separately |
166
+ | `array` of files | Each file appended with same key |
109
167
 
110
- // 3. Endpoint with custom error - Endpoint<Data, Body, Error>
111
- // Overrides global error type for this endpoint
112
- $put: Endpoint<Post, UpdatePost, ValidationError>;
168
+ #### `EndpointFull<T>`
113
169
 
114
- // void response - use void directly
115
- $delete: void;
170
+ Object-style for complex endpoints:
116
171
 
117
- // Custom error without body - use `never` for body
118
- $patch: Endpoint<Post, never, NotFoundError>;
172
+ ```typescript
173
+ import { EndpointFull } from "enlace";
174
+
175
+ type ApiSchema = {
176
+ products: {
177
+ $post: EndpointFull<{
178
+ data: Product;
179
+ body: CreateProduct;
180
+ query: { categoryId: string };
181
+ error: ValidationError;
182
+ }>;
183
+ };
184
+ files: {
185
+ $post: EndpointFull<{
186
+ data: FileUpload;
187
+ formData: { file: File; description: string };
188
+ query: { folder: string };
189
+ }>;
119
190
  };
120
191
  };
121
192
  ```
@@ -541,8 +612,9 @@ type UseEnlaceSelectorResult<TMethod> = {
541
612
 
542
613
  ```typescript
543
614
  type RequestOptions = {
544
- query?: Record<string, unknown>; // Query parameters
545
- body?: TBody; // Request body
615
+ query?: TQuery; // Query parameters (typed when using EndpointWithQuery/EndpointFull)
616
+ body?: TBody; // Request body (JSON)
617
+ formData?: TFormData; // FormData fields (auto-converted, for file uploads)
546
618
  headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>); // Request headers
547
619
  tags?: string[]; // Cache tags (GET only)
548
620
  revalidateTags?: string[]; // Tags to invalidate after mutation
@@ -623,7 +695,7 @@ const useAPI = createEnlaceHookNext<ApiSchema, ApiError>(
623
695
  "/api",
624
696
  {},
625
697
  {
626
- revalidator: revalidateAction,
698
+ serverRevalidator: revalidateAction,
627
699
  }
628
700
  );
629
701
  ```
@@ -637,13 +709,46 @@ function CreatePost() {
637
709
  const handleCreate = () => {
638
710
  trigger({
639
711
  body: { title: "New Post" },
640
- revalidateTags: ["posts"], // Passed to revalidator
641
- revalidatePaths: ["/posts"], // Passed to revalidator
712
+ revalidateTags: ["posts"], // Passed to serverRevalidator
713
+ revalidatePaths: ["/posts"], // Passed to serverRevalidator
642
714
  });
643
715
  };
644
716
  }
645
717
  ```
646
718
 
719
+ ### CSR-Heavy Projects
720
+
721
+ For projects that primarily use client-side rendering with minimal SSR, you can disable server-side revalidation by default:
722
+
723
+ ```typescript
724
+ const useAPI = createEnlaceHookNext<ApiSchema, ApiError>(
725
+ "/api",
726
+ {},
727
+ {
728
+ serverRevalidator: revalidateAction,
729
+ skipServerRevalidation: true, // Disable server revalidation by default
730
+ }
731
+ );
732
+
733
+ // Mutations won't trigger server revalidation by default
734
+ await trigger({ body: { title: "New Post" } });
735
+
736
+ // Opt-in to server revalidation when needed
737
+ await trigger({ body: { title: "New Post" }, serverRevalidate: true });
738
+ ```
739
+
740
+ ### Per-Request Server Revalidation Control
741
+
742
+ Override the global setting for individual requests:
743
+
744
+ ```typescript
745
+ // Skip server revalidation for this request
746
+ await trigger({ body: data, serverRevalidate: false });
747
+
748
+ // Force server revalidation for this request
749
+ await trigger({ body: data, serverRevalidate: true });
750
+ ```
751
+
647
752
  ### Next.js Request Options
648
753
 
649
754
  ```typescript
@@ -652,7 +757,7 @@ api.posts.get({
652
757
  revalidate: 60, // ISR revalidation (seconds)
653
758
  revalidateTags: ["posts"], // Tags to invalidate after mutation
654
759
  revalidatePaths: ["/"], // Paths to revalidate after mutation
655
- skipRevalidator: false, // Skip server-side revalidation
760
+ serverRevalidate: true, // Control server-side revalidation per-request
656
761
  });
657
762
  ```
658
763
 
@@ -710,7 +815,10 @@ type EnlaceHookOptions = {
710
815
 
711
816
  ### Re-exports from enlace-core
712
817
 
713
- - `Endpoint` — Type helper for schema definition
818
+ - `Endpoint` — Type helper for endpoints with JSON body
819
+ - `EndpointWithQuery` — Type helper for endpoints with typed query params
820
+ - `EndpointWithFormData` — Type helper for file upload endpoints
821
+ - `EndpointFull` — Object-style type helper for complex endpoints
714
822
  - `EnlaceResponse` — Response type
715
823
  - `EnlaceOptions` — Fetch options type
716
824
 
@@ -108,7 +108,7 @@ declare function createEnlaceHookReact<TSchema = unknown, TDefaultError = unknow
108
108
  * @param tags - Cache tags to revalidate
109
109
  * @param paths - URL paths to revalidate
110
110
  */
111
- type RevalidateHandler = (tags: string[], paths: string[]) => void | Promise<void>;
111
+ type ServerRevalidateHandler = (tags: string[], paths: string[]) => void | Promise<void>;
112
112
  /** Next.js-specific options (third argument for createEnlaceNext) */
113
113
  type NextOptions = Pick<EnlaceHookOptions, "autoGenerateTags" | "autoRevalidateTags"> & EnlaceCallbacks & {
114
114
  /**
@@ -117,32 +117,37 @@ type NextOptions = Pick<EnlaceHookOptions, "autoGenerateTags" | "autoRevalidateT
117
117
  * @example
118
118
  * ```ts
119
119
  * createEnlaceNext("http://localhost:3000/api/", {}, {
120
- * revalidator: (tags, paths) => revalidateServerAction(tags, paths)
120
+ * serverRevalidator: (tags, paths) => revalidateServerAction(tags, paths)
121
121
  * });
122
122
  * ```
123
123
  */
124
- revalidator?: RevalidateHandler;
124
+ serverRevalidator?: ServerRevalidateHandler;
125
+ /**
126
+ * Skip server-side revalidation by default for all mutations.
127
+ * Individual requests can override with serverRevalidate: true.
128
+ * Useful for CSR-heavy apps where server cache invalidation is rarely needed.
129
+ * @default false
130
+ */
131
+ skipServerRevalidation?: boolean;
125
132
  };
126
133
  /** Next.js hook options (third argument for createEnlaceHookNext) - extends React's EnlaceHookOptions */
127
- type NextHookOptions = EnlaceHookOptions & Pick<NextOptions, "revalidator">;
134
+ type NextHookOptions = EnlaceHookOptions & Pick<NextOptions, "serverRevalidator" | "skipServerRevalidation">;
128
135
  /** Per-request options for Next.js fetch - extends React's base options */
129
136
  type NextRequestOptionsBase = ReactRequestOptionsBase & {
130
137
  /** Time in seconds to revalidate, or false to disable */
131
138
  revalidate?: number | false;
132
139
  /**
133
- * URL paths to revalidate after mutation
134
- * This doesn't do anything on the client by itself - it's passed to the revalidator handler.
135
- * You must implement the revalidation logic in the revalidator.
140
+ * URL paths to revalidate after mutation.
141
+ * Passed to the serverRevalidator handler.
136
142
  */
137
143
  revalidatePaths?: string[];
138
144
  /**
139
- * Skip server-side revalidation for this request.
140
- * Useful when autoRevalidateTags is enabled but you want to opt-out for specific mutations.
141
- * You can still pass empty [] to revalidateTags to skip triggering revalidation.
142
- * But this flag can be used if you want to revalidate client-side and skip server-side entirely.
143
- * Eg. you don't fetch any data on server component and you might want to skip the overhead of revalidation.
145
+ * Control server-side revalidation for this specific request.
146
+ * - true: Force server revalidation
147
+ * - false: Skip server revalidation
148
+ * When undefined, follows the global skipServerRevalidation setting.
144
149
  */
145
- skipRevalidator?: boolean;
150
+ serverRevalidate?: boolean;
146
151
  };
147
152
  type NextQueryFn<TSchema, TData, TError, TDefaultError = unknown> = QueryFn<TSchema, TData, TError, TDefaultError, NextRequestOptionsBase>;
148
153
  type NextSelectorFn<TSchema, TMethod, TDefaultError = unknown> = SelectorFn<TSchema, TMethod, TDefaultError, NextRequestOptionsBase>;
@@ -154,11 +159,11 @@ type NextEnlaceHook<TSchema, TDefaultError = unknown> = {
154
159
 
155
160
  /**
156
161
  * Creates a React hook for making API calls in Next.js Client Components.
157
- * Uses Next.js-specific features like revalidator for server-side cache invalidation.
162
+ * Uses Next.js-specific features like serverRevalidator for server-side cache invalidation.
158
163
  *
159
164
  * @example
160
165
  * const useAPI = createEnlaceHookNext<ApiSchema>('https://api.com', {}, {
161
- * revalidator: (tags) => revalidateTagsAction(tags),
166
+ * serverRevalidator: (tags) => revalidateTagsAction(tags),
162
167
  * staleTime: 5000,
163
168
  * });
164
169
  *
@@ -108,7 +108,7 @@ declare function createEnlaceHookReact<TSchema = unknown, TDefaultError = unknow
108
108
  * @param tags - Cache tags to revalidate
109
109
  * @param paths - URL paths to revalidate
110
110
  */
111
- type RevalidateHandler = (tags: string[], paths: string[]) => void | Promise<void>;
111
+ type ServerRevalidateHandler = (tags: string[], paths: string[]) => void | Promise<void>;
112
112
  /** Next.js-specific options (third argument for createEnlaceNext) */
113
113
  type NextOptions = Pick<EnlaceHookOptions, "autoGenerateTags" | "autoRevalidateTags"> & EnlaceCallbacks & {
114
114
  /**
@@ -117,32 +117,37 @@ type NextOptions = Pick<EnlaceHookOptions, "autoGenerateTags" | "autoRevalidateT
117
117
  * @example
118
118
  * ```ts
119
119
  * createEnlaceNext("http://localhost:3000/api/", {}, {
120
- * revalidator: (tags, paths) => revalidateServerAction(tags, paths)
120
+ * serverRevalidator: (tags, paths) => revalidateServerAction(tags, paths)
121
121
  * });
122
122
  * ```
123
123
  */
124
- revalidator?: RevalidateHandler;
124
+ serverRevalidator?: ServerRevalidateHandler;
125
+ /**
126
+ * Skip server-side revalidation by default for all mutations.
127
+ * Individual requests can override with serverRevalidate: true.
128
+ * Useful for CSR-heavy apps where server cache invalidation is rarely needed.
129
+ * @default false
130
+ */
131
+ skipServerRevalidation?: boolean;
125
132
  };
126
133
  /** Next.js hook options (third argument for createEnlaceHookNext) - extends React's EnlaceHookOptions */
127
- type NextHookOptions = EnlaceHookOptions & Pick<NextOptions, "revalidator">;
134
+ type NextHookOptions = EnlaceHookOptions & Pick<NextOptions, "serverRevalidator" | "skipServerRevalidation">;
128
135
  /** Per-request options for Next.js fetch - extends React's base options */
129
136
  type NextRequestOptionsBase = ReactRequestOptionsBase & {
130
137
  /** Time in seconds to revalidate, or false to disable */
131
138
  revalidate?: number | false;
132
139
  /**
133
- * URL paths to revalidate after mutation
134
- * This doesn't do anything on the client by itself - it's passed to the revalidator handler.
135
- * You must implement the revalidation logic in the revalidator.
140
+ * URL paths to revalidate after mutation.
141
+ * Passed to the serverRevalidator handler.
136
142
  */
137
143
  revalidatePaths?: string[];
138
144
  /**
139
- * Skip server-side revalidation for this request.
140
- * Useful when autoRevalidateTags is enabled but you want to opt-out for specific mutations.
141
- * You can still pass empty [] to revalidateTags to skip triggering revalidation.
142
- * But this flag can be used if you want to revalidate client-side and skip server-side entirely.
143
- * Eg. you don't fetch any data on server component and you might want to skip the overhead of revalidation.
145
+ * Control server-side revalidation for this specific request.
146
+ * - true: Force server revalidation
147
+ * - false: Skip server revalidation
148
+ * When undefined, follows the global skipServerRevalidation setting.
144
149
  */
145
- skipRevalidator?: boolean;
150
+ serverRevalidate?: boolean;
146
151
  };
147
152
  type NextQueryFn<TSchema, TData, TError, TDefaultError = unknown> = QueryFn<TSchema, TData, TError, TDefaultError, NextRequestOptionsBase>;
148
153
  type NextSelectorFn<TSchema, TMethod, TDefaultError = unknown> = SelectorFn<TSchema, TMethod, TDefaultError, NextRequestOptionsBase>;
@@ -154,11 +159,11 @@ type NextEnlaceHook<TSchema, TDefaultError = unknown> = {
154
159
 
155
160
  /**
156
161
  * Creates a React hook for making API calls in Next.js Client Components.
157
- * Uses Next.js-specific features like revalidator for server-side cache invalidation.
162
+ * Uses Next.js-specific features like serverRevalidator for server-side cache invalidation.
158
163
  *
159
164
  * @example
160
165
  * const useAPI = createEnlaceHookNext<ApiSchema>('https://api.com', {}, {
161
- * revalidator: (tags) => revalidateTagsAction(tags),
166
+ * serverRevalidator: (tags) => revalidateTagsAction(tags),
162
167
  * staleTime: 5000,
163
168
  * });
164
169
  *
@@ -49,7 +49,8 @@ function hookReducer(state, action) {
49
49
  return {
50
50
  ...state,
51
51
  loading: state.data === void 0,
52
- fetching: true
52
+ fetching: true,
53
+ error: void 0
53
54
  };
54
55
  case "FETCH_SUCCESS":
55
56
  return {
@@ -78,12 +79,21 @@ function generateTags(path) {
78
79
  }
79
80
 
80
81
  // src/utils/sortObjectKeys.ts
81
- function sortObjectKeys(obj) {
82
+ function sortObjectKeys(obj, seen = /* @__PURE__ */ new WeakSet()) {
82
83
  if (obj === null || typeof obj !== "object") return obj;
83
- if (Array.isArray(obj)) return obj.map(sortObjectKeys);
84
+ if (seen.has(obj)) {
85
+ return "[Circular]";
86
+ }
87
+ seen.add(obj);
88
+ if (Array.isArray(obj)) {
89
+ return obj.map((item) => sortObjectKeys(item, seen));
90
+ }
84
91
  return Object.keys(obj).sort().reduce(
85
92
  (sorted, key) => {
86
- sorted[key] = sortObjectKeys(obj[key]);
93
+ sorted[key] = sortObjectKeys(
94
+ obj[key],
95
+ seen
96
+ );
87
97
  return sorted;
88
98
  },
89
99
  {}
@@ -149,10 +159,9 @@ function clearCacheByTags(tags) {
149
159
  cache.forEach((entry) => {
150
160
  const hasMatch = entry.tags.some((tag) => tags.includes(tag));
151
161
  if (hasMatch) {
152
- entry.data = void 0;
153
- entry.error = void 0;
154
162
  entry.timestamp = 0;
155
163
  delete entry.promise;
164
+ entry.subscribers.forEach((cb) => cb());
156
165
  }
157
166
  });
158
167
  }
@@ -196,7 +205,8 @@ function useQueryMode(api, trackedCall, options) {
196
205
  const cached = getCache(queryKey);
197
206
  const hasCachedData = cached?.data !== void 0;
198
207
  const isFetching = !!cached?.promise;
199
- const needsFetch = includeNeedsFetch && (!hasCachedData || isStale(queryKey, staleTime));
208
+ const stale = isStale(queryKey, staleTime);
209
+ const needsFetch = includeNeedsFetch && (!hasCachedData || stale);
200
210
  return {
201
211
  loading: !hasCachedData && (isFetching || needsFetch),
202
212
  fetching: isFetching || needsFetch,
@@ -240,6 +250,15 @@ function useQueryMode(api, trackedCall, options) {
240
250
  tags: queryTags
241
251
  });
242
252
  }
253
+ }).catch((err) => {
254
+ if (mountedRef.current) {
255
+ setCache(queryKey, {
256
+ data: void 0,
257
+ error: err,
258
+ timestamp: Date.now(),
259
+ tags: queryTags
260
+ });
261
+ }
243
262
  });
244
263
  setCache(queryKey, {
245
264
  promise: fetchPromise,
@@ -384,7 +403,10 @@ function createEnlaceHookReact(baseUrl, defaultOptions = {}, hookOptions = {}) {
384
403
  onSuccess,
385
404
  onError
386
405
  } = hookOptions;
387
- const api = (0, import_enlace_core.createEnlace)(baseUrl, defaultOptions, { onSuccess, onError });
406
+ const api = (0, import_enlace_core.createEnlace)(baseUrl, defaultOptions, {
407
+ onSuccess,
408
+ onError
409
+ });
388
410
  function useEnlaceHook(selectorOrQuery, queryOptions) {
389
411
  let trackingResult = {
390
412
  trackedCall: null,
@@ -394,9 +416,7 @@ function createEnlaceHookReact(baseUrl, defaultOptions = {}, hookOptions = {}) {
394
416
  const trackingProxy = createTrackingProxy((result2) => {
395
417
  trackingResult = result2;
396
418
  });
397
- const result = selectorOrQuery(
398
- trackingProxy
399
- );
419
+ const result = selectorOrQuery(trackingProxy);
400
420
  if (typeof result === "function") {
401
421
  const actualResult = selectorOrQuery(api);
402
422
  return useSelectorMode({
@@ -407,6 +427,11 @@ function createEnlaceHookReact(baseUrl, defaultOptions = {}, hookOptions = {}) {
407
427
  autoRevalidateTags
408
428
  });
409
429
  }
430
+ if (!trackingResult.trackedCall) {
431
+ throw new Error(
432
+ "useAPI query mode requires calling an HTTP method (get, post, etc.). Did you mean to use selector mode? Example: useAPI((api) => api.posts.get())"
433
+ );
434
+ }
410
435
  return useQueryMode(
411
436
  api,
412
437
  trackingResult.trackedCall,
@@ -425,18 +450,22 @@ async function executeNextFetch(baseUrl, path, method, combinedOptions, requestO
425
450
  const {
426
451
  autoGenerateTags = true,
427
452
  autoRevalidateTags = true,
428
- revalidator,
453
+ skipServerRevalidation = false,
454
+ serverRevalidator,
429
455
  onSuccess,
430
456
  ...coreOptions
431
457
  } = combinedOptions;
432
458
  const isGet = method === "GET";
433
459
  const autoTags = generateTags(path);
434
460
  const nextOnSuccess = (payload) => {
435
- if (!isGet && !requestOptions?.skipRevalidator) {
436
- const revalidateTags = requestOptions?.revalidateTags ?? (autoRevalidateTags ? autoTags : []);
437
- const revalidatePaths = requestOptions?.revalidatePaths ?? [];
438
- if (revalidateTags.length || revalidatePaths.length) {
439
- revalidator?.(revalidateTags, revalidatePaths);
461
+ if (!isGet) {
462
+ const shouldRevalidateServer = requestOptions?.serverRevalidate ?? !skipServerRevalidation;
463
+ if (shouldRevalidateServer) {
464
+ const revalidateTags = requestOptions?.revalidateTags ?? (autoRevalidateTags ? autoTags : []);
465
+ const revalidatePaths = requestOptions?.revalidatePaths ?? [];
466
+ if (revalidateTags.length || revalidatePaths.length) {
467
+ serverRevalidator?.(revalidateTags, revalidatePaths);
468
+ }
440
469
  }
441
470
  }
442
471
  onSuccess?.(payload);
@@ -481,11 +510,15 @@ function createEnlaceHookNext(baseUrl, defaultOptions = {}, hookOptions = {}) {
481
510
  staleTime = 0,
482
511
  ...nextOptions
483
512
  } = hookOptions;
484
- const api = createEnlaceNext(baseUrl, defaultOptions, {
485
- autoGenerateTags,
486
- autoRevalidateTags,
487
- ...nextOptions
488
- });
513
+ const api = createEnlaceNext(
514
+ baseUrl,
515
+ defaultOptions,
516
+ {
517
+ autoGenerateTags,
518
+ autoRevalidateTags,
519
+ ...nextOptions
520
+ }
521
+ );
489
522
  function useEnlaceHook(selectorOrQuery, queryOptions) {
490
523
  let trackedCall = null;
491
524
  let selectorPath = null;
@@ -506,6 +539,11 @@ function createEnlaceHookNext(baseUrl, defaultOptions = {}, hookOptions = {}) {
506
539
  autoRevalidateTags
507
540
  });
508
541
  }
542
+ if (!trackedCall) {
543
+ throw new Error(
544
+ "useAPI query mode requires calling an HTTP method (get, post, etc.). Did you mean to use selector mode? Example: useAPI((api) => api.posts.get())"
545
+ );
546
+ }
509
547
  return useQueryMode(
510
548
  api,
511
549
  trackedCall,
@@ -2,7 +2,9 @@
2
2
  "use client";
3
3
 
4
4
  // src/react/createEnlaceHookReact.ts
5
- import { createEnlace } from "enlace-core";
5
+ import {
6
+ createEnlace
7
+ } from "enlace-core";
6
8
 
7
9
  // src/react/useQueryMode.ts
8
10
  import { useRef, useReducer, useEffect } from "react";
@@ -22,7 +24,8 @@ function hookReducer(state, action) {
22
24
  return {
23
25
  ...state,
24
26
  loading: state.data === void 0,
25
- fetching: true
27
+ fetching: true,
28
+ error: void 0
26
29
  };
27
30
  case "FETCH_SUCCESS":
28
31
  return {
@@ -51,12 +54,21 @@ function generateTags(path) {
51
54
  }
52
55
 
53
56
  // src/utils/sortObjectKeys.ts
54
- function sortObjectKeys(obj) {
57
+ function sortObjectKeys(obj, seen = /* @__PURE__ */ new WeakSet()) {
55
58
  if (obj === null || typeof obj !== "object") return obj;
56
- if (Array.isArray(obj)) return obj.map(sortObjectKeys);
59
+ if (seen.has(obj)) {
60
+ return "[Circular]";
61
+ }
62
+ seen.add(obj);
63
+ if (Array.isArray(obj)) {
64
+ return obj.map((item) => sortObjectKeys(item, seen));
65
+ }
57
66
  return Object.keys(obj).sort().reduce(
58
67
  (sorted, key) => {
59
- sorted[key] = sortObjectKeys(obj[key]);
68
+ sorted[key] = sortObjectKeys(
69
+ obj[key],
70
+ seen
71
+ );
60
72
  return sorted;
61
73
  },
62
74
  {}
@@ -122,10 +134,9 @@ function clearCacheByTags(tags) {
122
134
  cache.forEach((entry) => {
123
135
  const hasMatch = entry.tags.some((tag) => tags.includes(tag));
124
136
  if (hasMatch) {
125
- entry.data = void 0;
126
- entry.error = void 0;
127
137
  entry.timestamp = 0;
128
138
  delete entry.promise;
139
+ entry.subscribers.forEach((cb) => cb());
129
140
  }
130
141
  });
131
142
  }
@@ -169,7 +180,8 @@ function useQueryMode(api, trackedCall, options) {
169
180
  const cached = getCache(queryKey);
170
181
  const hasCachedData = cached?.data !== void 0;
171
182
  const isFetching = !!cached?.promise;
172
- const needsFetch = includeNeedsFetch && (!hasCachedData || isStale(queryKey, staleTime));
183
+ const stale = isStale(queryKey, staleTime);
184
+ const needsFetch = includeNeedsFetch && (!hasCachedData || stale);
173
185
  return {
174
186
  loading: !hasCachedData && (isFetching || needsFetch),
175
187
  fetching: isFetching || needsFetch,
@@ -213,6 +225,15 @@ function useQueryMode(api, trackedCall, options) {
213
225
  tags: queryTags
214
226
  });
215
227
  }
228
+ }).catch((err) => {
229
+ if (mountedRef.current) {
230
+ setCache(queryKey, {
231
+ data: void 0,
232
+ error: err,
233
+ timestamp: Date.now(),
234
+ tags: queryTags
235
+ });
236
+ }
216
237
  });
217
238
  setCache(queryKey, {
218
239
  promise: fetchPromise,
@@ -357,7 +378,10 @@ function createEnlaceHookReact(baseUrl, defaultOptions = {}, hookOptions = {}) {
357
378
  onSuccess,
358
379
  onError
359
380
  } = hookOptions;
360
- const api = createEnlace(baseUrl, defaultOptions, { onSuccess, onError });
381
+ const api = createEnlace(baseUrl, defaultOptions, {
382
+ onSuccess,
383
+ onError
384
+ });
361
385
  function useEnlaceHook(selectorOrQuery, queryOptions) {
362
386
  let trackingResult = {
363
387
  trackedCall: null,
@@ -367,9 +391,7 @@ function createEnlaceHookReact(baseUrl, defaultOptions = {}, hookOptions = {}) {
367
391
  const trackingProxy = createTrackingProxy((result2) => {
368
392
  trackingResult = result2;
369
393
  });
370
- const result = selectorOrQuery(
371
- trackingProxy
372
- );
394
+ const result = selectorOrQuery(trackingProxy);
373
395
  if (typeof result === "function") {
374
396
  const actualResult = selectorOrQuery(api);
375
397
  return useSelectorMode({
@@ -380,6 +402,11 @@ function createEnlaceHookReact(baseUrl, defaultOptions = {}, hookOptions = {}) {
380
402
  autoRevalidateTags
381
403
  });
382
404
  }
405
+ if (!trackingResult.trackedCall) {
406
+ throw new Error(
407
+ "useAPI query mode requires calling an HTTP method (get, post, etc.). Did you mean to use selector mode? Example: useAPI((api) => api.posts.get())"
408
+ );
409
+ }
383
410
  return useQueryMode(
384
411
  api,
385
412
  trackingResult.trackedCall,
@@ -402,18 +429,22 @@ async function executeNextFetch(baseUrl, path, method, combinedOptions, requestO
402
429
  const {
403
430
  autoGenerateTags = true,
404
431
  autoRevalidateTags = true,
405
- revalidator,
432
+ skipServerRevalidation = false,
433
+ serverRevalidator,
406
434
  onSuccess,
407
435
  ...coreOptions
408
436
  } = combinedOptions;
409
437
  const isGet = method === "GET";
410
438
  const autoTags = generateTags(path);
411
439
  const nextOnSuccess = (payload) => {
412
- if (!isGet && !requestOptions?.skipRevalidator) {
413
- const revalidateTags = requestOptions?.revalidateTags ?? (autoRevalidateTags ? autoTags : []);
414
- const revalidatePaths = requestOptions?.revalidatePaths ?? [];
415
- if (revalidateTags.length || revalidatePaths.length) {
416
- revalidator?.(revalidateTags, revalidatePaths);
440
+ if (!isGet) {
441
+ const shouldRevalidateServer = requestOptions?.serverRevalidate ?? !skipServerRevalidation;
442
+ if (shouldRevalidateServer) {
443
+ const revalidateTags = requestOptions?.revalidateTags ?? (autoRevalidateTags ? autoTags : []);
444
+ const revalidatePaths = requestOptions?.revalidatePaths ?? [];
445
+ if (revalidateTags.length || revalidatePaths.length) {
446
+ serverRevalidator?.(revalidateTags, revalidatePaths);
447
+ }
417
448
  }
418
449
  }
419
450
  onSuccess?.(payload);
@@ -458,11 +489,15 @@ function createEnlaceHookNext(baseUrl, defaultOptions = {}, hookOptions = {}) {
458
489
  staleTime = 0,
459
490
  ...nextOptions
460
491
  } = hookOptions;
461
- const api = createEnlaceNext(baseUrl, defaultOptions, {
462
- autoGenerateTags,
463
- autoRevalidateTags,
464
- ...nextOptions
465
- });
492
+ const api = createEnlaceNext(
493
+ baseUrl,
494
+ defaultOptions,
495
+ {
496
+ autoGenerateTags,
497
+ autoRevalidateTags,
498
+ ...nextOptions
499
+ }
500
+ );
466
501
  function useEnlaceHook(selectorOrQuery, queryOptions) {
467
502
  let trackedCall = null;
468
503
  let selectorPath = null;
@@ -483,6 +518,11 @@ function createEnlaceHookNext(baseUrl, defaultOptions = {}, hookOptions = {}) {
483
518
  autoRevalidateTags
484
519
  });
485
520
  }
521
+ if (!trackedCall) {
522
+ throw new Error(
523
+ "useAPI query mode requires calling an HTTP method (get, post, etc.). Did you mean to use selector mode? Example: useAPI((api) => api.posts.get())"
524
+ );
525
+ }
486
526
  return useQueryMode(
487
527
  api,
488
528
  trackedCall,
package/dist/index.d.mts CHANGED
@@ -43,7 +43,7 @@ type EnlaceHookOptions = {
43
43
  * @param tags - Cache tags to revalidate
44
44
  * @param paths - URL paths to revalidate
45
45
  */
46
- type RevalidateHandler = (tags: string[], paths: string[]) => void | Promise<void>;
46
+ type ServerRevalidateHandler = (tags: string[], paths: string[]) => void | Promise<void>;
47
47
  /** Next.js-specific options (third argument for createEnlaceNext) */
48
48
  type NextOptions = Pick<EnlaceHookOptions, "autoGenerateTags" | "autoRevalidateTags"> & EnlaceCallbacks & {
49
49
  /**
@@ -52,30 +52,35 @@ type NextOptions = Pick<EnlaceHookOptions, "autoGenerateTags" | "autoRevalidateT
52
52
  * @example
53
53
  * ```ts
54
54
  * createEnlaceNext("http://localhost:3000/api/", {}, {
55
- * revalidator: (tags, paths) => revalidateServerAction(tags, paths)
55
+ * serverRevalidator: (tags, paths) => revalidateServerAction(tags, paths)
56
56
  * });
57
57
  * ```
58
58
  */
59
- revalidator?: RevalidateHandler;
59
+ serverRevalidator?: ServerRevalidateHandler;
60
+ /**
61
+ * Skip server-side revalidation by default for all mutations.
62
+ * Individual requests can override with serverRevalidate: true.
63
+ * Useful for CSR-heavy apps where server cache invalidation is rarely needed.
64
+ * @default false
65
+ */
66
+ skipServerRevalidation?: boolean;
60
67
  };
61
68
  /** Per-request options for Next.js fetch - extends React's base options */
62
69
  type NextRequestOptionsBase = ReactRequestOptionsBase & {
63
70
  /** Time in seconds to revalidate, or false to disable */
64
71
  revalidate?: number | false;
65
72
  /**
66
- * URL paths to revalidate after mutation
67
- * This doesn't do anything on the client by itself - it's passed to the revalidator handler.
68
- * You must implement the revalidation logic in the revalidator.
73
+ * URL paths to revalidate after mutation.
74
+ * Passed to the serverRevalidator handler.
69
75
  */
70
76
  revalidatePaths?: string[];
71
77
  /**
72
- * Skip server-side revalidation for this request.
73
- * Useful when autoRevalidateTags is enabled but you want to opt-out for specific mutations.
74
- * You can still pass empty [] to revalidateTags to skip triggering revalidation.
75
- * But this flag can be used if you want to revalidate client-side and skip server-side entirely.
76
- * Eg. you don't fetch any data on server component and you might want to skip the overhead of revalidation.
78
+ * Control server-side revalidation for this specific request.
79
+ * - true: Force server revalidation
80
+ * - false: Skip server revalidation
81
+ * When undefined, follows the global skipServerRevalidation setting.
77
82
  */
78
- skipRevalidator?: boolean;
83
+ serverRevalidate?: boolean;
79
84
  };
80
85
 
81
86
  declare function createEnlaceNext<TSchema = unknown, TDefaultError = unknown>(baseUrl: string, defaultOptions?: EnlaceOptions | null, nextOptions?: NextOptions): unknown extends TSchema ? WildcardClient<NextRequestOptionsBase> : EnlaceClient<TSchema, TDefaultError, NextRequestOptionsBase>;
package/dist/index.d.ts CHANGED
@@ -43,7 +43,7 @@ type EnlaceHookOptions = {
43
43
  * @param tags - Cache tags to revalidate
44
44
  * @param paths - URL paths to revalidate
45
45
  */
46
- type RevalidateHandler = (tags: string[], paths: string[]) => void | Promise<void>;
46
+ type ServerRevalidateHandler = (tags: string[], paths: string[]) => void | Promise<void>;
47
47
  /** Next.js-specific options (third argument for createEnlaceNext) */
48
48
  type NextOptions = Pick<EnlaceHookOptions, "autoGenerateTags" | "autoRevalidateTags"> & EnlaceCallbacks & {
49
49
  /**
@@ -52,30 +52,35 @@ type NextOptions = Pick<EnlaceHookOptions, "autoGenerateTags" | "autoRevalidateT
52
52
  * @example
53
53
  * ```ts
54
54
  * createEnlaceNext("http://localhost:3000/api/", {}, {
55
- * revalidator: (tags, paths) => revalidateServerAction(tags, paths)
55
+ * serverRevalidator: (tags, paths) => revalidateServerAction(tags, paths)
56
56
  * });
57
57
  * ```
58
58
  */
59
- revalidator?: RevalidateHandler;
59
+ serverRevalidator?: ServerRevalidateHandler;
60
+ /**
61
+ * Skip server-side revalidation by default for all mutations.
62
+ * Individual requests can override with serverRevalidate: true.
63
+ * Useful for CSR-heavy apps where server cache invalidation is rarely needed.
64
+ * @default false
65
+ */
66
+ skipServerRevalidation?: boolean;
60
67
  };
61
68
  /** Per-request options for Next.js fetch - extends React's base options */
62
69
  type NextRequestOptionsBase = ReactRequestOptionsBase & {
63
70
  /** Time in seconds to revalidate, or false to disable */
64
71
  revalidate?: number | false;
65
72
  /**
66
- * URL paths to revalidate after mutation
67
- * This doesn't do anything on the client by itself - it's passed to the revalidator handler.
68
- * You must implement the revalidation logic in the revalidator.
73
+ * URL paths to revalidate after mutation.
74
+ * Passed to the serverRevalidator handler.
69
75
  */
70
76
  revalidatePaths?: string[];
71
77
  /**
72
- * Skip server-side revalidation for this request.
73
- * Useful when autoRevalidateTags is enabled but you want to opt-out for specific mutations.
74
- * You can still pass empty [] to revalidateTags to skip triggering revalidation.
75
- * But this flag can be used if you want to revalidate client-side and skip server-side entirely.
76
- * Eg. you don't fetch any data on server component and you might want to skip the overhead of revalidation.
78
+ * Control server-side revalidation for this specific request.
79
+ * - true: Force server revalidation
80
+ * - false: Skip server revalidation
81
+ * When undefined, follows the global skipServerRevalidation setting.
77
82
  */
78
- skipRevalidator?: boolean;
83
+ serverRevalidate?: boolean;
79
84
  };
80
85
 
81
86
  declare function createEnlaceNext<TSchema = unknown, TDefaultError = unknown>(baseUrl: string, defaultOptions?: EnlaceOptions | null, nextOptions?: NextOptions): unknown extends TSchema ? WildcardClient<NextRequestOptionsBase> : EnlaceClient<TSchema, TDefaultError, NextRequestOptionsBase>;
package/dist/index.js CHANGED
@@ -42,18 +42,22 @@ async function executeNextFetch(baseUrl, path, method, combinedOptions, requestO
42
42
  const {
43
43
  autoGenerateTags = true,
44
44
  autoRevalidateTags = true,
45
- revalidator,
45
+ skipServerRevalidation = false,
46
+ serverRevalidator,
46
47
  onSuccess,
47
48
  ...coreOptions
48
49
  } = combinedOptions;
49
50
  const isGet = method === "GET";
50
51
  const autoTags = generateTags(path);
51
52
  const nextOnSuccess = (payload) => {
52
- if (!isGet && !requestOptions?.skipRevalidator) {
53
- const revalidateTags = requestOptions?.revalidateTags ?? (autoRevalidateTags ? autoTags : []);
54
- const revalidatePaths = requestOptions?.revalidatePaths ?? [];
55
- if (revalidateTags.length || revalidatePaths.length) {
56
- revalidator?.(revalidateTags, revalidatePaths);
53
+ if (!isGet) {
54
+ const shouldRevalidateServer = requestOptions?.serverRevalidate ?? !skipServerRevalidation;
55
+ if (shouldRevalidateServer) {
56
+ const revalidateTags = requestOptions?.revalidateTags ?? (autoRevalidateTags ? autoTags : []);
57
+ const revalidatePaths = requestOptions?.revalidatePaths ?? [];
58
+ if (revalidateTags.length || revalidatePaths.length) {
59
+ serverRevalidator?.(revalidateTags, revalidatePaths);
60
+ }
57
61
  }
58
62
  }
59
63
  onSuccess?.(payload);
package/dist/index.mjs CHANGED
@@ -21,18 +21,22 @@ async function executeNextFetch(baseUrl, path, method, combinedOptions, requestO
21
21
  const {
22
22
  autoGenerateTags = true,
23
23
  autoRevalidateTags = true,
24
- revalidator,
24
+ skipServerRevalidation = false,
25
+ serverRevalidator,
25
26
  onSuccess,
26
27
  ...coreOptions
27
28
  } = combinedOptions;
28
29
  const isGet = method === "GET";
29
30
  const autoTags = generateTags(path);
30
31
  const nextOnSuccess = (payload) => {
31
- if (!isGet && !requestOptions?.skipRevalidator) {
32
- const revalidateTags = requestOptions?.revalidateTags ?? (autoRevalidateTags ? autoTags : []);
33
- const revalidatePaths = requestOptions?.revalidatePaths ?? [];
34
- if (revalidateTags.length || revalidatePaths.length) {
35
- revalidator?.(revalidateTags, revalidatePaths);
32
+ if (!isGet) {
33
+ const shouldRevalidateServer = requestOptions?.serverRevalidate ?? !skipServerRevalidation;
34
+ if (shouldRevalidateServer) {
35
+ const revalidateTags = requestOptions?.revalidateTags ?? (autoRevalidateTags ? autoTags : []);
36
+ const revalidatePaths = requestOptions?.revalidatePaths ?? [];
37
+ if (revalidateTags.length || revalidatePaths.length) {
38
+ serverRevalidator?.(revalidateTags, revalidatePaths);
39
+ }
36
40
  }
37
41
  }
38
42
  onSuccess?.(payload);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "enlace",
3
- "version": "0.0.1-beta.10",
3
+ "version": "0.0.1-beta.11",
4
4
  "license": "MIT",
5
5
  "files": [
6
6
  "dist"