enlace 0.0.1-beta.3 → 0.0.1-beta.4

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
@@ -117,6 +117,34 @@ function Posts({ page, limit }: { page: number; limit: number }) {
117
117
  - Returns cached data while revalidating
118
118
  - **Request deduplication** — identical requests from multiple components trigger only one fetch
119
119
 
120
+ ### Conditional Fetching
121
+
122
+ Skip fetching with the `enabled` option:
123
+
124
+ ```typescript
125
+ function ProductForm({ id }: { id: string | "new" }) {
126
+ // Skip fetching when creating a new product
127
+ const { data, loading } = useAPI(
128
+ (api) => api.products[id].get(),
129
+ { enabled: id !== "new" }
130
+ );
131
+
132
+ if (id === "new") return <CreateProductForm />;
133
+ if (loading) return <div>Loading...</div>;
134
+ return <EditProductForm product={data} />;
135
+ }
136
+ ```
137
+
138
+ ```typescript
139
+ // Also useful when waiting for a dependency
140
+ function UserPosts({ userId }: { userId: string | undefined }) {
141
+ const { data } = useAPI(
142
+ (api) => api.users[userId!].posts.get(),
143
+ { enabled: userId !== undefined }
144
+ );
145
+ }
146
+ ```
147
+
120
148
  ```typescript
121
149
  function Post({ id }: { id: number }) {
122
150
  // Automatically re-fetches when `id` or query values change
@@ -186,6 +214,56 @@ function CreatePost() {
186
214
  }
187
215
  ```
188
216
 
217
+ ### Dynamic Path Parameters
218
+
219
+ Use `:paramName` syntax for dynamic IDs passed at trigger time:
220
+
221
+ ```typescript
222
+ function PostList({ posts }: { posts: Post[] }) {
223
+ // Define once with :id placeholder
224
+ const { trigger, loading } = useAPI((api) => api.posts[":id"].delete);
225
+
226
+ const handleDelete = (postId: number) => {
227
+ // Pass the actual ID when triggering
228
+ trigger({ pathParams: { id: postId } });
229
+ };
230
+
231
+ return (
232
+ <ul>
233
+ {posts.map((post) => (
234
+ <li key={post.id}>
235
+ {post.title}
236
+ <button onClick={() => handleDelete(post.id)} disabled={loading}>
237
+ Delete
238
+ </button>
239
+ </li>
240
+ ))}
241
+ </ul>
242
+ );
243
+ }
244
+ ```
245
+
246
+ **Multiple path parameters:**
247
+
248
+ ```typescript
249
+ const { trigger } = useAPI((api) => api.users[":userId"].posts[":postId"].delete);
250
+
251
+ trigger({ pathParams: { userId: "1", postId: "42" } });
252
+ // → DELETE /users/1/posts/42
253
+ ```
254
+
255
+ **With request body:**
256
+
257
+ ```typescript
258
+ const { trigger } = useAPI((api) => api.products[":id"].patch);
259
+
260
+ trigger({
261
+ pathParams: { id: "123" },
262
+ body: { name: "Updated Product" },
263
+ });
264
+ // → PATCH /products/123 with body
265
+ ```
266
+
189
267
  ## Caching & Auto-Revalidation
190
268
 
191
269
  ### Automatic Cache Tags (Zero Config)
@@ -288,6 +366,15 @@ const useAPI = createEnlaceHook<ApiSchema>(
288
366
  ### Query Mode
289
367
 
290
368
  ```typescript
369
+ // Basic usage
370
+ const result = useAPI((api) => api.posts.get());
371
+
372
+ // With options
373
+ const result = useAPI(
374
+ (api) => api.posts.get(),
375
+ { enabled: true } // Skip fetching when false
376
+ );
377
+
291
378
  type UseEnlaceQueryResult<TData, TError> = {
292
379
  loading: boolean; // No cached data and fetching
293
380
  fetching: boolean; // Request in progress
@@ -310,6 +397,18 @@ type UseEnlaceSelectorResult<TMethod> = {
310
397
  };
311
398
  ```
312
399
 
400
+ ### Request Options
401
+
402
+ ```typescript
403
+ type RequestOptions = {
404
+ query?: Record<string, unknown>; // Query parameters
405
+ body?: TBody; // Request body
406
+ tags?: string[]; // Cache tags (GET only)
407
+ revalidateTags?: string[]; // Tags to invalidate after mutation
408
+ pathParams?: Record<string, string | number>; // Dynamic path parameters
409
+ };
410
+ ```
411
+
313
412
  ---
314
413
 
315
414
  ## Next.js Integration
package/dist/index.d.mts CHANGED
@@ -11,6 +11,23 @@ type ReactRequestOptionsBase = {
11
11
  tags?: string[];
12
12
  /** Tags to invalidate after mutation (triggers refetch in matching queries) */
13
13
  revalidateTags?: string[];
14
+ /**
15
+ * Path parameters for dynamic URL segments.
16
+ * Used to replace :paramName placeholders in the URL path.
17
+ * @example
18
+ * // With path api.products[':id'].delete
19
+ * trigger({ pathParams: { id: '123' } }) // → DELETE /products/123
20
+ */
21
+ pathParams?: Record<string, string | number>;
22
+ };
23
+ /** Options for query mode hooks */
24
+ type UseEnlaceQueryOptions = {
25
+ /**
26
+ * Whether the query should execute.
27
+ * Set to false to skip fetching (useful when ID is "new" or undefined).
28
+ * @default true
29
+ */
30
+ enabled?: boolean;
14
31
  };
15
32
  type ApiClient<TSchema, TOptions = ReactRequestOptionsBase> = unknown extends TSchema ? WildcardClient<TOptions> : EnlaceClient<TSchema, TOptions>;
16
33
  type QueryFn<TSchema, TData, TError, TOptions = ReactRequestOptionsBase> = (api: ApiClient<TSchema, TOptions>) => Promise<EnlaceResponse<TData, TError>>;
@@ -70,7 +87,7 @@ type EnlaceHookOptions = {
70
87
  };
71
88
  type EnlaceHook<TSchema> = {
72
89
  <TMethod extends (...args: any[]) => Promise<EnlaceResponse<unknown, unknown>>>(selector: SelectorFn<TSchema, TMethod>): UseEnlaceSelectorResult<TMethod>;
73
- <TData, TError>(queryFn: QueryFn<TSchema, TData, TError>): UseEnlaceQueryResult<TData, TError>;
90
+ <TData, TError>(queryFn: QueryFn<TSchema, TData, TError>, options?: UseEnlaceQueryOptions): UseEnlaceQueryResult<TData, TError>;
74
91
  };
75
92
  /**
76
93
  * Creates a React hook for making API calls.
@@ -94,4 +111,4 @@ declare function onRevalidate(callback: Listener): () => void;
94
111
 
95
112
  declare function clearCache(key?: string): void;
96
113
 
97
- export { type ApiClient, type EnlaceHookOptions, HTTP_METHODS, type HookState, type QueryFn, type ReactRequestOptionsBase, type SelectorFn, type TrackedCall, type UseEnlaceQueryResult, type UseEnlaceSelectorResult, clearCache, createEnlaceHook, invalidateTags, onRevalidate };
114
+ export { type ApiClient, type EnlaceHookOptions, HTTP_METHODS, type HookState, type QueryFn, type ReactRequestOptionsBase, type SelectorFn, type TrackedCall, type UseEnlaceQueryOptions, type UseEnlaceQueryResult, type UseEnlaceSelectorResult, clearCache, createEnlaceHook, invalidateTags, onRevalidate };
package/dist/index.d.ts CHANGED
@@ -11,6 +11,23 @@ type ReactRequestOptionsBase = {
11
11
  tags?: string[];
12
12
  /** Tags to invalidate after mutation (triggers refetch in matching queries) */
13
13
  revalidateTags?: string[];
14
+ /**
15
+ * Path parameters for dynamic URL segments.
16
+ * Used to replace :paramName placeholders in the URL path.
17
+ * @example
18
+ * // With path api.products[':id'].delete
19
+ * trigger({ pathParams: { id: '123' } }) // → DELETE /products/123
20
+ */
21
+ pathParams?: Record<string, string | number>;
22
+ };
23
+ /** Options for query mode hooks */
24
+ type UseEnlaceQueryOptions = {
25
+ /**
26
+ * Whether the query should execute.
27
+ * Set to false to skip fetching (useful when ID is "new" or undefined).
28
+ * @default true
29
+ */
30
+ enabled?: boolean;
14
31
  };
15
32
  type ApiClient<TSchema, TOptions = ReactRequestOptionsBase> = unknown extends TSchema ? WildcardClient<TOptions> : EnlaceClient<TSchema, TOptions>;
16
33
  type QueryFn<TSchema, TData, TError, TOptions = ReactRequestOptionsBase> = (api: ApiClient<TSchema, TOptions>) => Promise<EnlaceResponse<TData, TError>>;
@@ -70,7 +87,7 @@ type EnlaceHookOptions = {
70
87
  };
71
88
  type EnlaceHook<TSchema> = {
72
89
  <TMethod extends (...args: any[]) => Promise<EnlaceResponse<unknown, unknown>>>(selector: SelectorFn<TSchema, TMethod>): UseEnlaceSelectorResult<TMethod>;
73
- <TData, TError>(queryFn: QueryFn<TSchema, TData, TError>): UseEnlaceQueryResult<TData, TError>;
90
+ <TData, TError>(queryFn: QueryFn<TSchema, TData, TError>, options?: UseEnlaceQueryOptions): UseEnlaceQueryResult<TData, TError>;
74
91
  };
75
92
  /**
76
93
  * Creates a React hook for making API calls.
@@ -94,4 +111,4 @@ declare function onRevalidate(callback: Listener): () => void;
94
111
 
95
112
  declare function clearCache(key?: string): void;
96
113
 
97
- export { type ApiClient, type EnlaceHookOptions, HTTP_METHODS, type HookState, type QueryFn, type ReactRequestOptionsBase, type SelectorFn, type TrackedCall, type UseEnlaceQueryResult, type UseEnlaceSelectorResult, clearCache, createEnlaceHook, invalidateTags, onRevalidate };
114
+ export { type ApiClient, type EnlaceHookOptions, HTTP_METHODS, type HookState, type QueryFn, type ReactRequestOptionsBase, type SelectorFn, type TrackedCall, type UseEnlaceQueryOptions, type UseEnlaceQueryResult, type UseEnlaceSelectorResult, clearCache, createEnlaceHook, invalidateTags, onRevalidate };
package/dist/index.js CHANGED
@@ -184,11 +184,26 @@ function onRevalidate(callback) {
184
184
  }
185
185
 
186
186
  // src/react/useQueryMode.ts
187
+ function resolvePath(path, pathParams) {
188
+ if (!pathParams) return path;
189
+ return path.map((segment) => {
190
+ if (segment.startsWith(":")) {
191
+ const paramName = segment.slice(1);
192
+ const value = pathParams[paramName];
193
+ if (value === void 0) {
194
+ throw new Error(`Missing path parameter: ${paramName}`);
195
+ }
196
+ return String(value);
197
+ }
198
+ return segment;
199
+ });
200
+ }
187
201
  function useQueryMode(api, trackedCall, options) {
188
- const { autoGenerateTags, staleTime } = options;
202
+ const { autoGenerateTags, staleTime, enabled } = options;
189
203
  const queryKey = createQueryKey(trackedCall);
190
204
  const requestOptions = trackedCall.options;
191
- const queryTags = requestOptions?.tags ?? (autoGenerateTags ? generateTags(trackedCall.path) : []);
205
+ const resolvedPath = resolvePath(trackedCall.path, requestOptions?.pathParams);
206
+ const queryTags = requestOptions?.tags ?? (autoGenerateTags ? generateTags(resolvedPath) : []);
192
207
  const getCacheState = (includeNeedsFetch = false) => {
193
208
  const cached = getCache(queryKey);
194
209
  const hasCachedData = cached?.data !== void 0;
@@ -207,6 +222,15 @@ function useQueryMode(api, trackedCall, options) {
207
222
  const fetchRef = (0, import_react.useRef)(null);
208
223
  (0, import_react.useEffect)(() => {
209
224
  mountedRef.current = true;
225
+ if (!enabled) {
226
+ dispatch({
227
+ type: "RESET",
228
+ state: { loading: false, fetching: false, ok: void 0, data: void 0, error: void 0 }
229
+ });
230
+ return () => {
231
+ mountedRef.current = false;
232
+ };
233
+ }
210
234
  dispatch({ type: "RESET", state: getCacheState(true) });
211
235
  const unsubscribe = subscribeCache(queryKey, () => {
212
236
  if (mountedRef.current) {
@@ -220,7 +244,7 @@ function useQueryMode(api, trackedCall, options) {
220
244
  }
221
245
  dispatch({ type: "FETCH_START" });
222
246
  let current = api;
223
- for (const segment of trackedCall.path) {
247
+ for (const segment of resolvedPath) {
224
248
  current = current[segment];
225
249
  }
226
250
  const method = current[trackedCall.method];
@@ -252,7 +276,7 @@ function useQueryMode(api, trackedCall, options) {
252
276
  fetchRef.current = null;
253
277
  unsubscribe();
254
278
  };
255
- }, [queryKey]);
279
+ }, [queryKey, enabled]);
256
280
  (0, import_react.useEffect)(() => {
257
281
  if (queryTags.length === 0) return;
258
282
  return onRevalidate((invalidatedTags) => {
@@ -299,23 +323,56 @@ function createTrackingProxy(onTrack) {
299
323
 
300
324
  // src/react/useSelectorMode.ts
301
325
  var import_react2 = require("react");
302
- function useSelectorMode(method, path, autoRevalidateTags) {
326
+ function resolvePath2(path, pathParams) {
327
+ if (!pathParams) return path;
328
+ return path.map((segment) => {
329
+ if (segment.startsWith(":")) {
330
+ const paramName = segment.slice(1);
331
+ const value = pathParams[paramName];
332
+ if (value === void 0) {
333
+ throw new Error(`Missing path parameter: ${paramName}`);
334
+ }
335
+ return String(value);
336
+ }
337
+ return segment;
338
+ });
339
+ }
340
+ function hasPathParams(path) {
341
+ return path.some((segment) => segment.startsWith(":"));
342
+ }
343
+ function useSelectorMode(config) {
344
+ const { method, api, path, methodName, autoRevalidateTags } = config;
303
345
  const [state, dispatch] = (0, import_react2.useReducer)(hookReducer, initialState);
304
346
  const methodRef = (0, import_react2.useRef)(method);
347
+ const apiRef = (0, import_react2.useRef)(api);
305
348
  const triggerRef = (0, import_react2.useRef)(null);
306
349
  const pathRef = (0, import_react2.useRef)(path);
350
+ const methodNameRef = (0, import_react2.useRef)(methodName);
307
351
  const autoRevalidateRef = (0, import_react2.useRef)(autoRevalidateTags);
308
352
  methodRef.current = method;
353
+ apiRef.current = api;
309
354
  pathRef.current = path;
355
+ methodNameRef.current = methodName;
310
356
  autoRevalidateRef.current = autoRevalidateTags;
311
357
  if (!triggerRef.current) {
312
358
  triggerRef.current = (async (...args) => {
313
359
  dispatch({ type: "FETCH_START" });
314
- const res = await methodRef.current(...args);
360
+ const options = args[0];
361
+ const resolvedPath = resolvePath2(pathRef.current, options?.pathParams);
362
+ let res;
363
+ if (hasPathParams(pathRef.current)) {
364
+ let current = apiRef.current;
365
+ for (const segment of resolvedPath) {
366
+ current = current[segment];
367
+ }
368
+ const resolvedMethod = current[methodNameRef.current];
369
+ res = await resolvedMethod(...args);
370
+ } else {
371
+ res = await methodRef.current(...args);
372
+ }
315
373
  if (res.ok) {
316
374
  dispatch({ type: "FETCH_SUCCESS", data: res.data });
317
- const options = args[0];
318
- const tagsToInvalidate = options?.revalidateTags ?? (autoRevalidateRef.current ? generateTags(pathRef.current) : []);
375
+ const tagsToInvalidate = options?.revalidateTags ?? (autoRevalidateRef.current ? generateTags(resolvedPath) : []);
319
376
  if (tagsToInvalidate.length > 0) {
320
377
  invalidateTags(tagsToInvalidate);
321
378
  }
@@ -339,7 +396,7 @@ function createEnlaceHook(baseUrl, defaultOptions = {}, hookOptions = {}) {
339
396
  autoRevalidateTags = true,
340
397
  staleTime = 0
341
398
  } = hookOptions;
342
- function useEnlaceHook(selectorOrQuery) {
399
+ function useEnlaceHook(selectorOrQuery, queryOptions) {
343
400
  let trackingResult = {
344
401
  trackedCall: null,
345
402
  selectorPath: null,
@@ -353,16 +410,18 @@ function createEnlaceHook(baseUrl, defaultOptions = {}, hookOptions = {}) {
353
410
  );
354
411
  if (typeof result === "function") {
355
412
  const actualResult = selectorOrQuery(api);
356
- return useSelectorMode(
357
- actualResult,
358
- trackingResult.selectorPath ?? [],
413
+ return useSelectorMode({
414
+ method: actualResult,
415
+ api,
416
+ path: trackingResult.selectorPath ?? [],
417
+ methodName: trackingResult.selectorMethod ?? "",
359
418
  autoRevalidateTags
360
- );
419
+ });
361
420
  }
362
421
  return useQueryMode(
363
422
  api,
364
423
  trackingResult.trackedCall,
365
- { autoGenerateTags, staleTime }
424
+ { autoGenerateTags, staleTime, enabled: queryOptions?.enabled ?? true }
366
425
  );
367
426
  }
368
427
  return useEnlaceHook;
package/dist/index.mjs CHANGED
@@ -157,11 +157,26 @@ function onRevalidate(callback) {
157
157
  }
158
158
 
159
159
  // src/react/useQueryMode.ts
160
+ function resolvePath(path, pathParams) {
161
+ if (!pathParams) return path;
162
+ return path.map((segment) => {
163
+ if (segment.startsWith(":")) {
164
+ const paramName = segment.slice(1);
165
+ const value = pathParams[paramName];
166
+ if (value === void 0) {
167
+ throw new Error(`Missing path parameter: ${paramName}`);
168
+ }
169
+ return String(value);
170
+ }
171
+ return segment;
172
+ });
173
+ }
160
174
  function useQueryMode(api, trackedCall, options) {
161
- const { autoGenerateTags, staleTime } = options;
175
+ const { autoGenerateTags, staleTime, enabled } = options;
162
176
  const queryKey = createQueryKey(trackedCall);
163
177
  const requestOptions = trackedCall.options;
164
- const queryTags = requestOptions?.tags ?? (autoGenerateTags ? generateTags(trackedCall.path) : []);
178
+ const resolvedPath = resolvePath(trackedCall.path, requestOptions?.pathParams);
179
+ const queryTags = requestOptions?.tags ?? (autoGenerateTags ? generateTags(resolvedPath) : []);
165
180
  const getCacheState = (includeNeedsFetch = false) => {
166
181
  const cached = getCache(queryKey);
167
182
  const hasCachedData = cached?.data !== void 0;
@@ -180,6 +195,15 @@ function useQueryMode(api, trackedCall, options) {
180
195
  const fetchRef = useRef(null);
181
196
  useEffect(() => {
182
197
  mountedRef.current = true;
198
+ if (!enabled) {
199
+ dispatch({
200
+ type: "RESET",
201
+ state: { loading: false, fetching: false, ok: void 0, data: void 0, error: void 0 }
202
+ });
203
+ return () => {
204
+ mountedRef.current = false;
205
+ };
206
+ }
183
207
  dispatch({ type: "RESET", state: getCacheState(true) });
184
208
  const unsubscribe = subscribeCache(queryKey, () => {
185
209
  if (mountedRef.current) {
@@ -193,7 +217,7 @@ function useQueryMode(api, trackedCall, options) {
193
217
  }
194
218
  dispatch({ type: "FETCH_START" });
195
219
  let current = api;
196
- for (const segment of trackedCall.path) {
220
+ for (const segment of resolvedPath) {
197
221
  current = current[segment];
198
222
  }
199
223
  const method = current[trackedCall.method];
@@ -225,7 +249,7 @@ function useQueryMode(api, trackedCall, options) {
225
249
  fetchRef.current = null;
226
250
  unsubscribe();
227
251
  };
228
- }, [queryKey]);
252
+ }, [queryKey, enabled]);
229
253
  useEffect(() => {
230
254
  if (queryTags.length === 0) return;
231
255
  return onRevalidate((invalidatedTags) => {
@@ -272,23 +296,56 @@ function createTrackingProxy(onTrack) {
272
296
 
273
297
  // src/react/useSelectorMode.ts
274
298
  import { useRef as useRef2, useReducer as useReducer2 } from "react";
275
- function useSelectorMode(method, path, autoRevalidateTags) {
299
+ function resolvePath2(path, pathParams) {
300
+ if (!pathParams) return path;
301
+ return path.map((segment) => {
302
+ if (segment.startsWith(":")) {
303
+ const paramName = segment.slice(1);
304
+ const value = pathParams[paramName];
305
+ if (value === void 0) {
306
+ throw new Error(`Missing path parameter: ${paramName}`);
307
+ }
308
+ return String(value);
309
+ }
310
+ return segment;
311
+ });
312
+ }
313
+ function hasPathParams(path) {
314
+ return path.some((segment) => segment.startsWith(":"));
315
+ }
316
+ function useSelectorMode(config) {
317
+ const { method, api, path, methodName, autoRevalidateTags } = config;
276
318
  const [state, dispatch] = useReducer2(hookReducer, initialState);
277
319
  const methodRef = useRef2(method);
320
+ const apiRef = useRef2(api);
278
321
  const triggerRef = useRef2(null);
279
322
  const pathRef = useRef2(path);
323
+ const methodNameRef = useRef2(methodName);
280
324
  const autoRevalidateRef = useRef2(autoRevalidateTags);
281
325
  methodRef.current = method;
326
+ apiRef.current = api;
282
327
  pathRef.current = path;
328
+ methodNameRef.current = methodName;
283
329
  autoRevalidateRef.current = autoRevalidateTags;
284
330
  if (!triggerRef.current) {
285
331
  triggerRef.current = (async (...args) => {
286
332
  dispatch({ type: "FETCH_START" });
287
- const res = await methodRef.current(...args);
333
+ const options = args[0];
334
+ const resolvedPath = resolvePath2(pathRef.current, options?.pathParams);
335
+ let res;
336
+ if (hasPathParams(pathRef.current)) {
337
+ let current = apiRef.current;
338
+ for (const segment of resolvedPath) {
339
+ current = current[segment];
340
+ }
341
+ const resolvedMethod = current[methodNameRef.current];
342
+ res = await resolvedMethod(...args);
343
+ } else {
344
+ res = await methodRef.current(...args);
345
+ }
288
346
  if (res.ok) {
289
347
  dispatch({ type: "FETCH_SUCCESS", data: res.data });
290
- const options = args[0];
291
- const tagsToInvalidate = options?.revalidateTags ?? (autoRevalidateRef.current ? generateTags(pathRef.current) : []);
348
+ const tagsToInvalidate = options?.revalidateTags ?? (autoRevalidateRef.current ? generateTags(resolvedPath) : []);
292
349
  if (tagsToInvalidate.length > 0) {
293
350
  invalidateTags(tagsToInvalidate);
294
351
  }
@@ -312,7 +369,7 @@ function createEnlaceHook(baseUrl, defaultOptions = {}, hookOptions = {}) {
312
369
  autoRevalidateTags = true,
313
370
  staleTime = 0
314
371
  } = hookOptions;
315
- function useEnlaceHook(selectorOrQuery) {
372
+ function useEnlaceHook(selectorOrQuery, queryOptions) {
316
373
  let trackingResult = {
317
374
  trackedCall: null,
318
375
  selectorPath: null,
@@ -326,16 +383,18 @@ function createEnlaceHook(baseUrl, defaultOptions = {}, hookOptions = {}) {
326
383
  );
327
384
  if (typeof result === "function") {
328
385
  const actualResult = selectorOrQuery(api);
329
- return useSelectorMode(
330
- actualResult,
331
- trackingResult.selectorPath ?? [],
386
+ return useSelectorMode({
387
+ method: actualResult,
388
+ api,
389
+ path: trackingResult.selectorPath ?? [],
390
+ methodName: trackingResult.selectorMethod ?? "",
332
391
  autoRevalidateTags
333
- );
392
+ });
334
393
  }
335
394
  return useQueryMode(
336
395
  api,
337
396
  trackingResult.trackedCall,
338
- { autoGenerateTags, staleTime }
397
+ { autoGenerateTags, staleTime, enabled: queryOptions?.enabled ?? true }
339
398
  );
340
399
  }
341
400
  return useEnlaceHook;
@@ -10,6 +10,23 @@ type ReactRequestOptionsBase = {
10
10
  tags?: string[];
11
11
  /** Tags to invalidate after mutation (triggers refetch in matching queries) */
12
12
  revalidateTags?: string[];
13
+ /**
14
+ * Path parameters for dynamic URL segments.
15
+ * Used to replace :paramName placeholders in the URL path.
16
+ * @example
17
+ * // With path api.products[':id'].delete
18
+ * trigger({ pathParams: { id: '123' } }) // → DELETE /products/123
19
+ */
20
+ pathParams?: Record<string, string | number>;
21
+ };
22
+ /** Options for query mode hooks */
23
+ type UseEnlaceQueryOptions = {
24
+ /**
25
+ * Whether the query should execute.
26
+ * Set to false to skip fetching (useful when ID is "new" or undefined).
27
+ * @default true
28
+ */
29
+ enabled?: boolean;
13
30
  };
14
31
  type ApiClient<TSchema, TOptions = ReactRequestOptionsBase> = unknown extends TSchema ? WildcardClient<TOptions> : EnlaceClient<TSchema, TOptions>;
15
32
  type QueryFn<TSchema, TData, TError, TOptions = ReactRequestOptionsBase> = (api: ApiClient<TSchema, TOptions>) => Promise<EnlaceResponse<TData, TError>>;
@@ -101,7 +118,7 @@ type NextQueryFn<TSchema, TData, TError> = QueryFn<TSchema, TData, TError, NextR
101
118
  type NextSelectorFn<TSchema, TMethod> = SelectorFn<TSchema, TMethod, NextRequestOptionsBase>;
102
119
  type NextEnlaceHook<TSchema> = {
103
120
  <TMethod extends (...args: any[]) => Promise<EnlaceResponse<unknown, unknown>>>(selector: NextSelectorFn<TSchema, TMethod>): UseEnlaceSelectorResult<TMethod>;
104
- <TData, TError>(queryFn: NextQueryFn<TSchema, TData, TError>): UseEnlaceQueryResult<TData, TError>;
121
+ <TData, TError>(queryFn: NextQueryFn<TSchema, TData, TError>, options?: UseEnlaceQueryOptions): UseEnlaceQueryResult<TData, TError>;
105
122
  };
106
123
  /**
107
124
  * Creates a React hook for making API calls in Next.js Client Components.
@@ -10,6 +10,23 @@ type ReactRequestOptionsBase = {
10
10
  tags?: string[];
11
11
  /** Tags to invalidate after mutation (triggers refetch in matching queries) */
12
12
  revalidateTags?: string[];
13
+ /**
14
+ * Path parameters for dynamic URL segments.
15
+ * Used to replace :paramName placeholders in the URL path.
16
+ * @example
17
+ * // With path api.products[':id'].delete
18
+ * trigger({ pathParams: { id: '123' } }) // → DELETE /products/123
19
+ */
20
+ pathParams?: Record<string, string | number>;
21
+ };
22
+ /** Options for query mode hooks */
23
+ type UseEnlaceQueryOptions = {
24
+ /**
25
+ * Whether the query should execute.
26
+ * Set to false to skip fetching (useful when ID is "new" or undefined).
27
+ * @default true
28
+ */
29
+ enabled?: boolean;
13
30
  };
14
31
  type ApiClient<TSchema, TOptions = ReactRequestOptionsBase> = unknown extends TSchema ? WildcardClient<TOptions> : EnlaceClient<TSchema, TOptions>;
15
32
  type QueryFn<TSchema, TData, TError, TOptions = ReactRequestOptionsBase> = (api: ApiClient<TSchema, TOptions>) => Promise<EnlaceResponse<TData, TError>>;
@@ -101,7 +118,7 @@ type NextQueryFn<TSchema, TData, TError> = QueryFn<TSchema, TData, TError, NextR
101
118
  type NextSelectorFn<TSchema, TMethod> = SelectorFn<TSchema, TMethod, NextRequestOptionsBase>;
102
119
  type NextEnlaceHook<TSchema> = {
103
120
  <TMethod extends (...args: any[]) => Promise<EnlaceResponse<unknown, unknown>>>(selector: NextSelectorFn<TSchema, TMethod>): UseEnlaceSelectorResult<TMethod>;
104
- <TData, TError>(queryFn: NextQueryFn<TSchema, TData, TError>): UseEnlaceQueryResult<TData, TError>;
121
+ <TData, TError>(queryFn: NextQueryFn<TSchema, TData, TError>, options?: UseEnlaceQueryOptions): UseEnlaceQueryResult<TData, TError>;
105
122
  };
106
123
  /**
107
124
  * Creates a React hook for making API calls in Next.js Client Components.
@@ -257,11 +257,26 @@ function onRevalidate(callback) {
257
257
  }
258
258
 
259
259
  // src/react/useQueryMode.ts
260
+ function resolvePath(path, pathParams) {
261
+ if (!pathParams) return path;
262
+ return path.map((segment) => {
263
+ if (segment.startsWith(":")) {
264
+ const paramName = segment.slice(1);
265
+ const value = pathParams[paramName];
266
+ if (value === void 0) {
267
+ throw new Error(`Missing path parameter: ${paramName}`);
268
+ }
269
+ return String(value);
270
+ }
271
+ return segment;
272
+ });
273
+ }
260
274
  function useQueryMode(api, trackedCall, options) {
261
- const { autoGenerateTags, staleTime } = options;
275
+ const { autoGenerateTags, staleTime, enabled } = options;
262
276
  const queryKey = createQueryKey(trackedCall);
263
277
  const requestOptions = trackedCall.options;
264
- const queryTags = requestOptions?.tags ?? (autoGenerateTags ? generateTags(trackedCall.path) : []);
278
+ const resolvedPath = resolvePath(trackedCall.path, requestOptions?.pathParams);
279
+ const queryTags = requestOptions?.tags ?? (autoGenerateTags ? generateTags(resolvedPath) : []);
265
280
  const getCacheState = (includeNeedsFetch = false) => {
266
281
  const cached = getCache(queryKey);
267
282
  const hasCachedData = cached?.data !== void 0;
@@ -280,6 +295,15 @@ function useQueryMode(api, trackedCall, options) {
280
295
  const fetchRef = (0, import_react.useRef)(null);
281
296
  (0, import_react.useEffect)(() => {
282
297
  mountedRef.current = true;
298
+ if (!enabled) {
299
+ dispatch({
300
+ type: "RESET",
301
+ state: { loading: false, fetching: false, ok: void 0, data: void 0, error: void 0 }
302
+ });
303
+ return () => {
304
+ mountedRef.current = false;
305
+ };
306
+ }
283
307
  dispatch({ type: "RESET", state: getCacheState(true) });
284
308
  const unsubscribe = subscribeCache(queryKey, () => {
285
309
  if (mountedRef.current) {
@@ -293,7 +317,7 @@ function useQueryMode(api, trackedCall, options) {
293
317
  }
294
318
  dispatch({ type: "FETCH_START" });
295
319
  let current = api;
296
- for (const segment of trackedCall.path) {
320
+ for (const segment of resolvedPath) {
297
321
  current = current[segment];
298
322
  }
299
323
  const method = current[trackedCall.method];
@@ -325,7 +349,7 @@ function useQueryMode(api, trackedCall, options) {
325
349
  fetchRef.current = null;
326
350
  unsubscribe();
327
351
  };
328
- }, [queryKey]);
352
+ }, [queryKey, enabled]);
329
353
  (0, import_react.useEffect)(() => {
330
354
  if (queryTags.length === 0) return;
331
355
  return onRevalidate((invalidatedTags) => {
@@ -340,23 +364,56 @@ function useQueryMode(api, trackedCall, options) {
340
364
 
341
365
  // src/react/useSelectorMode.ts
342
366
  var import_react2 = require("react");
343
- function useSelectorMode(method, path, autoRevalidateTags) {
367
+ function resolvePath2(path, pathParams) {
368
+ if (!pathParams) return path;
369
+ return path.map((segment) => {
370
+ if (segment.startsWith(":")) {
371
+ const paramName = segment.slice(1);
372
+ const value = pathParams[paramName];
373
+ if (value === void 0) {
374
+ throw new Error(`Missing path parameter: ${paramName}`);
375
+ }
376
+ return String(value);
377
+ }
378
+ return segment;
379
+ });
380
+ }
381
+ function hasPathParams(path) {
382
+ return path.some((segment) => segment.startsWith(":"));
383
+ }
384
+ function useSelectorMode(config) {
385
+ const { method, api, path, methodName, autoRevalidateTags } = config;
344
386
  const [state, dispatch] = (0, import_react2.useReducer)(hookReducer, initialState);
345
387
  const methodRef = (0, import_react2.useRef)(method);
388
+ const apiRef = (0, import_react2.useRef)(api);
346
389
  const triggerRef = (0, import_react2.useRef)(null);
347
390
  const pathRef = (0, import_react2.useRef)(path);
391
+ const methodNameRef = (0, import_react2.useRef)(methodName);
348
392
  const autoRevalidateRef = (0, import_react2.useRef)(autoRevalidateTags);
349
393
  methodRef.current = method;
394
+ apiRef.current = api;
350
395
  pathRef.current = path;
396
+ methodNameRef.current = methodName;
351
397
  autoRevalidateRef.current = autoRevalidateTags;
352
398
  if (!triggerRef.current) {
353
399
  triggerRef.current = (async (...args) => {
354
400
  dispatch({ type: "FETCH_START" });
355
- const res = await methodRef.current(...args);
401
+ const options = args[0];
402
+ const resolvedPath = resolvePath2(pathRef.current, options?.pathParams);
403
+ let res;
404
+ if (hasPathParams(pathRef.current)) {
405
+ let current = apiRef.current;
406
+ for (const segment of resolvedPath) {
407
+ current = current[segment];
408
+ }
409
+ const resolvedMethod = current[methodNameRef.current];
410
+ res = await resolvedMethod(...args);
411
+ } else {
412
+ res = await methodRef.current(...args);
413
+ }
356
414
  if (res.ok) {
357
415
  dispatch({ type: "FETCH_SUCCESS", data: res.data });
358
- const options = args[0];
359
- const tagsToInvalidate = options?.revalidateTags ?? (autoRevalidateRef.current ? generateTags(pathRef.current) : []);
416
+ const tagsToInvalidate = options?.revalidateTags ?? (autoRevalidateRef.current ? generateTags(resolvedPath) : []);
360
417
  if (tagsToInvalidate.length > 0) {
361
418
  invalidateTags(tagsToInvalidate);
362
419
  }
@@ -417,26 +474,30 @@ function createEnlaceHook(baseUrl, defaultOptions = {}, hookOptions = {}) {
417
474
  autoRevalidateTags,
418
475
  ...nextOptions
419
476
  });
420
- function useEnlaceHook(selectorOrQuery) {
477
+ function useEnlaceHook(selectorOrQuery, queryOptions) {
421
478
  let trackedCall = null;
422
479
  let selectorPath = null;
480
+ let selectorMethod = null;
423
481
  const trackingProxy = createTrackingProxy((result2) => {
424
482
  trackedCall = result2.trackedCall;
425
483
  selectorPath = result2.selectorPath;
484
+ selectorMethod = result2.selectorMethod;
426
485
  });
427
486
  const result = selectorOrQuery(trackingProxy);
428
487
  if (typeof result === "function") {
429
488
  const actualResult = selectorOrQuery(api);
430
- return useSelectorMode(
431
- actualResult,
432
- selectorPath ?? [],
489
+ return useSelectorMode({
490
+ method: actualResult,
491
+ api,
492
+ path: selectorPath ?? [],
493
+ methodName: selectorMethod ?? "",
433
494
  autoRevalidateTags
434
- );
495
+ });
435
496
  }
436
497
  return useQueryMode(
437
498
  api,
438
499
  trackedCall,
439
- { autoGenerateTags, staleTime }
500
+ { autoGenerateTags, staleTime, enabled: queryOptions?.enabled ?? true }
440
501
  );
441
502
  }
442
503
  return useEnlaceHook;
@@ -255,11 +255,26 @@ function onRevalidate(callback) {
255
255
  }
256
256
 
257
257
  // src/react/useQueryMode.ts
258
+ function resolvePath(path, pathParams) {
259
+ if (!pathParams) return path;
260
+ return path.map((segment) => {
261
+ if (segment.startsWith(":")) {
262
+ const paramName = segment.slice(1);
263
+ const value = pathParams[paramName];
264
+ if (value === void 0) {
265
+ throw new Error(`Missing path parameter: ${paramName}`);
266
+ }
267
+ return String(value);
268
+ }
269
+ return segment;
270
+ });
271
+ }
258
272
  function useQueryMode(api, trackedCall, options) {
259
- const { autoGenerateTags, staleTime } = options;
273
+ const { autoGenerateTags, staleTime, enabled } = options;
260
274
  const queryKey = createQueryKey(trackedCall);
261
275
  const requestOptions = trackedCall.options;
262
- const queryTags = requestOptions?.tags ?? (autoGenerateTags ? generateTags(trackedCall.path) : []);
276
+ const resolvedPath = resolvePath(trackedCall.path, requestOptions?.pathParams);
277
+ const queryTags = requestOptions?.tags ?? (autoGenerateTags ? generateTags(resolvedPath) : []);
263
278
  const getCacheState = (includeNeedsFetch = false) => {
264
279
  const cached = getCache(queryKey);
265
280
  const hasCachedData = cached?.data !== void 0;
@@ -278,6 +293,15 @@ function useQueryMode(api, trackedCall, options) {
278
293
  const fetchRef = useRef(null);
279
294
  useEffect(() => {
280
295
  mountedRef.current = true;
296
+ if (!enabled) {
297
+ dispatch({
298
+ type: "RESET",
299
+ state: { loading: false, fetching: false, ok: void 0, data: void 0, error: void 0 }
300
+ });
301
+ return () => {
302
+ mountedRef.current = false;
303
+ };
304
+ }
281
305
  dispatch({ type: "RESET", state: getCacheState(true) });
282
306
  const unsubscribe = subscribeCache(queryKey, () => {
283
307
  if (mountedRef.current) {
@@ -291,7 +315,7 @@ function useQueryMode(api, trackedCall, options) {
291
315
  }
292
316
  dispatch({ type: "FETCH_START" });
293
317
  let current = api;
294
- for (const segment of trackedCall.path) {
318
+ for (const segment of resolvedPath) {
295
319
  current = current[segment];
296
320
  }
297
321
  const method = current[trackedCall.method];
@@ -323,7 +347,7 @@ function useQueryMode(api, trackedCall, options) {
323
347
  fetchRef.current = null;
324
348
  unsubscribe();
325
349
  };
326
- }, [queryKey]);
350
+ }, [queryKey, enabled]);
327
351
  useEffect(() => {
328
352
  if (queryTags.length === 0) return;
329
353
  return onRevalidate((invalidatedTags) => {
@@ -338,23 +362,56 @@ function useQueryMode(api, trackedCall, options) {
338
362
 
339
363
  // src/react/useSelectorMode.ts
340
364
  import { useRef as useRef2, useReducer as useReducer2 } from "react";
341
- function useSelectorMode(method, path, autoRevalidateTags) {
365
+ function resolvePath2(path, pathParams) {
366
+ if (!pathParams) return path;
367
+ return path.map((segment) => {
368
+ if (segment.startsWith(":")) {
369
+ const paramName = segment.slice(1);
370
+ const value = pathParams[paramName];
371
+ if (value === void 0) {
372
+ throw new Error(`Missing path parameter: ${paramName}`);
373
+ }
374
+ return String(value);
375
+ }
376
+ return segment;
377
+ });
378
+ }
379
+ function hasPathParams(path) {
380
+ return path.some((segment) => segment.startsWith(":"));
381
+ }
382
+ function useSelectorMode(config) {
383
+ const { method, api, path, methodName, autoRevalidateTags } = config;
342
384
  const [state, dispatch] = useReducer2(hookReducer, initialState);
343
385
  const methodRef = useRef2(method);
386
+ const apiRef = useRef2(api);
344
387
  const triggerRef = useRef2(null);
345
388
  const pathRef = useRef2(path);
389
+ const methodNameRef = useRef2(methodName);
346
390
  const autoRevalidateRef = useRef2(autoRevalidateTags);
347
391
  methodRef.current = method;
392
+ apiRef.current = api;
348
393
  pathRef.current = path;
394
+ methodNameRef.current = methodName;
349
395
  autoRevalidateRef.current = autoRevalidateTags;
350
396
  if (!triggerRef.current) {
351
397
  triggerRef.current = (async (...args) => {
352
398
  dispatch({ type: "FETCH_START" });
353
- const res = await methodRef.current(...args);
399
+ const options = args[0];
400
+ const resolvedPath = resolvePath2(pathRef.current, options?.pathParams);
401
+ let res;
402
+ if (hasPathParams(pathRef.current)) {
403
+ let current = apiRef.current;
404
+ for (const segment of resolvedPath) {
405
+ current = current[segment];
406
+ }
407
+ const resolvedMethod = current[methodNameRef.current];
408
+ res = await resolvedMethod(...args);
409
+ } else {
410
+ res = await methodRef.current(...args);
411
+ }
354
412
  if (res.ok) {
355
413
  dispatch({ type: "FETCH_SUCCESS", data: res.data });
356
- const options = args[0];
357
- const tagsToInvalidate = options?.revalidateTags ?? (autoRevalidateRef.current ? generateTags(pathRef.current) : []);
414
+ const tagsToInvalidate = options?.revalidateTags ?? (autoRevalidateRef.current ? generateTags(resolvedPath) : []);
358
415
  if (tagsToInvalidate.length > 0) {
359
416
  invalidateTags(tagsToInvalidate);
360
417
  }
@@ -415,26 +472,30 @@ function createEnlaceHook(baseUrl, defaultOptions = {}, hookOptions = {}) {
415
472
  autoRevalidateTags,
416
473
  ...nextOptions
417
474
  });
418
- function useEnlaceHook(selectorOrQuery) {
475
+ function useEnlaceHook(selectorOrQuery, queryOptions) {
419
476
  let trackedCall = null;
420
477
  let selectorPath = null;
478
+ let selectorMethod = null;
421
479
  const trackingProxy = createTrackingProxy((result2) => {
422
480
  trackedCall = result2.trackedCall;
423
481
  selectorPath = result2.selectorPath;
482
+ selectorMethod = result2.selectorMethod;
424
483
  });
425
484
  const result = selectorOrQuery(trackingProxy);
426
485
  if (typeof result === "function") {
427
486
  const actualResult = selectorOrQuery(api);
428
- return useSelectorMode(
429
- actualResult,
430
- selectorPath ?? [],
487
+ return useSelectorMode({
488
+ method: actualResult,
489
+ api,
490
+ path: selectorPath ?? [],
491
+ methodName: selectorMethod ?? "",
431
492
  autoRevalidateTags
432
- );
493
+ });
433
494
  }
434
495
  return useQueryMode(
435
496
  api,
436
497
  trackedCall,
437
- { autoGenerateTags, staleTime }
498
+ { autoGenerateTags, staleTime, enabled: queryOptions?.enabled ?? true }
438
499
  );
439
500
  }
440
501
  return useEnlaceHook;
@@ -12,6 +12,14 @@ type ReactRequestOptionsBase = {
12
12
  tags?: string[];
13
13
  /** Tags to invalidate after mutation (triggers refetch in matching queries) */
14
14
  revalidateTags?: string[];
15
+ /**
16
+ * Path parameters for dynamic URL segments.
17
+ * Used to replace :paramName placeholders in the URL path.
18
+ * @example
19
+ * // With path api.products[':id'].delete
20
+ * trigger({ pathParams: { id: '123' } }) // → DELETE /products/123
21
+ */
22
+ pathParams?: Record<string, string | number>;
15
23
  };
16
24
 
17
25
  type EnlaceHookOptions = {
@@ -12,6 +12,14 @@ type ReactRequestOptionsBase = {
12
12
  tags?: string[];
13
13
  /** Tags to invalidate after mutation (triggers refetch in matching queries) */
14
14
  revalidateTags?: string[];
15
+ /**
16
+ * Path parameters for dynamic URL segments.
17
+ * Used to replace :paramName placeholders in the URL path.
18
+ * @example
19
+ * // With path api.products[':id'].delete
20
+ * trigger({ pathParams: { id: '123' } }) // → DELETE /products/123
21
+ */
22
+ pathParams?: Record<string, string | number>;
15
23
  };
16
24
 
17
25
  type EnlaceHookOptions = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "enlace",
3
- "version": "0.0.1-beta.3",
3
+ "version": "0.0.1-beta.4",
4
4
  "license": "MIT",
5
5
  "files": [
6
6
  "dist"