@classytic/arc-next 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hooks.js CHANGED
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { getAuthMode, getClientAuthContext } from "./client.js";
3
+ import { getAuthMode, getClientAuthContext, hasGlobalStaticAuth } from "./client.js";
4
4
  import { isKeysetPagination, isOffsetPagination } from "./api.js";
5
- import { DEFAULT_QUERY_CONFIG, createCacheUtils, createQueryKeys, extractItem, getItemId, updateListCache } from "./cache.js";
6
- import { useDetailQuery, useInfiniteListQuery, useListQuery } from "./query.js";
5
+ import { DEFAULT_QUERY_CONFIG, createCacheUtils, createQueryKeys, extractItem, getItemId, syncDetailToLists, updateListCache } from "./cache.js";
6
+ import { findItemInListCache, useDetailQuery, useInfiniteListQuery, useListQuery } from "./query.js";
7
7
  import { useMutationWithTransition, useOptimisticMutation } from "./mutation.js";
8
8
  import { subscribeToEvents } from "./sse.js";
9
9
  import { connectWs } from "./ws.js";
@@ -33,8 +33,19 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
33
33
  const pluralName = plural ?? `${singular}s`;
34
34
  /** Resolve auth context — per-client auth takes priority over global */
35
35
  const resolveAuth = () => getClientAuthContext(client);
36
- /** Whether auth is provided via static config (headers, internalApiKey, per-client auth) — no token needed for enablement */
37
- const hasStaticAuth = !!(client?.config?.defaultHeaders || client?.config?.internalApiKey || client?.auth);
36
+ /**
37
+ * Whether auth is provided via static config no per-request token needed.
38
+ * Sources (in priority order):
39
+ * 1. Per-client `defaultHeaders` / `internalApiKey` (when `client` is passed in).
40
+ * 2. Per-client custom auth (e.g. `createClient({ getToken })`).
41
+ * 3. Global `configureClient({ internalApiKey | defaultHeaders | authMode: 'cookie' })`.
42
+ *
43
+ * Resolved lazily on every render so that an app calling `configureClient`
44
+ * inside a "use client" provider after factory creation still picks up the
45
+ * global static-auth signal — previously a global `internalApiKey` was
46
+ * ignored, leaving every protected query stuck in a permanently-disabled state.
47
+ */
48
+ const resolveHasStaticAuth = () => !!(client?.config?.defaultHeaders || client?.config?.internalApiKey || client?.auth) || hasGlobalStaticAuth();
38
49
  /** Extract ID from an item using configured idField, falling back to _id → id */
39
50
  function resolveItemId(item) {
40
51
  if (!item || typeof item !== "object") return null;
@@ -99,7 +110,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
99
110
  ...requestOpts
100
111
  }
101
112
  }),
102
- enabled: createEnabledRule(token, queryOpts, resolveAuthMode(), hasStaticAuth),
113
+ enabled: createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
103
114
  options: {
104
115
  staleTime: queryOpts.staleTime ?? config.staleTime,
105
116
  gcTime: queryOpts.gcTime ?? config.gcTime,
@@ -108,9 +119,6 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
108
119
  refetchInterval: queryOpts.refetchInterval,
109
120
  refetchIntervalInBackground: queryOpts.refetchIntervalInBackground
110
121
  },
111
- prefillDetailCache: queryOpts.prefillDetailCache ?? true,
112
- detailKeyBuilder: (id) => KEYS.scopedDetail(id, organizationId ?? null),
113
- itemIdResolver: resolveItemId,
114
122
  select: queryOpts.select
115
123
  });
116
124
  }
@@ -131,8 +139,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
131
139
  }
132
140
  const { organizationId, params: queryParams, request: requestOpts, ...restOptions } = options;
133
141
  const detailKey = KEYS.scopedDetail(id || "", organizationId ?? null);
134
- return useDetailQuery({
135
- queryKey: queryParams ? [...detailKey, queryParams] : detailKey,
142
+ const fullDetailKey = queryParams ? [...detailKey, queryParams] : detailKey;
143
+ const queryClient = useQueryClient();
144
+ const listPlaceholder = useCallback(() => id ? findItemInListCache(queryClient, KEYS.lists(), id, idField) : void 0, [queryClient, id]);
145
+ const detailResult = useDetailQuery({
146
+ queryKey: fullDetailKey,
136
147
  queryFn: ({ signal }) => api.getById({
137
148
  id,
138
149
  token,
@@ -143,7 +154,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
143
154
  ...requestOpts
144
155
  }
145
156
  }),
146
- enabled: !!id && createEnabledRule(token, restOptions, resolveAuthMode(), hasStaticAuth),
157
+ enabled: !!id && createEnabledRule(token, restOptions, resolveAuthMode(), resolveHasStaticAuth()),
147
158
  options: {
148
159
  staleTime: restOptions.staleTime ?? config.staleTime,
149
160
  gcTime: restOptions.gcTime ?? config.gcTime,
@@ -152,8 +163,18 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
152
163
  refetchInterval: restOptions.refetchInterval,
153
164
  refetchIntervalInBackground: restOptions.refetchIntervalInBackground
154
165
  },
155
- select: restOptions.select
166
+ select: restOptions.select,
167
+ placeholderData: listPlaceholder
156
168
  });
169
+ useEffect(() => {
170
+ if (!detailResult.item || detailResult.isPlaceholderData) return;
171
+ syncDetailToLists(queryClient, KEYS.lists(), detailResult.item, idField ? { idField } : {});
172
+ }, [
173
+ detailResult.item,
174
+ detailResult.isPlaceholderData,
175
+ queryClient
176
+ ]);
177
+ return detailResult;
157
178
  }
158
179
  function useActions() {
159
180
  const queryClient = useQueryClient();
@@ -413,7 +434,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
413
434
  }
414
435
  });
415
436
  },
416
- enabled: createEnabledRule(token, queryOpts, resolveAuthMode(), hasStaticAuth),
437
+ enabled: createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
417
438
  initialPageParam: restParams.after ? restParams.after : 1,
418
439
  getNextPageParam: (lastPage) => {
419
440
  const page = lastPage;
@@ -498,7 +519,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
498
519
  }
499
520
  });
500
521
  },
501
- enabled: !!api.getDeleted && createEnabledRule(token, queryOpts, resolveAuthMode(), hasStaticAuth),
522
+ enabled: !!api.getDeleted && createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
502
523
  options: {
503
524
  staleTime: queryOpts.staleTime ?? config.staleTime,
504
525
  gcTime: queryOpts.gcTime ?? config.gcTime
@@ -512,7 +533,9 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
512
533
  const resolvedOptions = options ?? {};
513
534
  const organizationId = resolvedOptions.organizationId ?? auth.organizationId;
514
535
  const { params: queryParams, request: requestOpts, ...restOptions } = resolvedOptions;
515
- return useDetailQuery({
536
+ const queryClient = useQueryClient();
537
+ const listPlaceholder = useCallback(() => slug ? findItemInListCache(queryClient, KEYS.lists(), slug, "slug") : void 0, [queryClient, slug]);
538
+ const slugResult = useDetailQuery({
516
539
  queryKey: queryParams ? KEYS.custom("slug", slug, queryParams) : KEYS.custom("slug", slug),
517
540
  queryFn: ({ signal }) => {
518
541
  if (!api.getBySlug) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define a getBySlug method`));
@@ -527,15 +550,25 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
527
550
  }
528
551
  });
529
552
  },
530
- enabled: !!api.getBySlug && !!slug && createEnabledRule(token, restOptions, resolveAuthMode(), hasStaticAuth),
553
+ enabled: !!api.getBySlug && !!slug && createEnabledRule(token, restOptions, resolveAuthMode(), resolveHasStaticAuth()),
531
554
  options: {
532
555
  staleTime: restOptions.staleTime ?? config.staleTime,
533
556
  gcTime: restOptions.gcTime ?? config.gcTime,
534
557
  refetchOnWindowFocus: restOptions.refetchOnWindowFocus ?? config.refetchOnWindowFocus,
535
558
  structuralSharing: restOptions.structuralSharing ?? config.structuralSharing
536
559
  },
537
- select: restOptions.select
560
+ select: restOptions.select,
561
+ placeholderData: listPlaceholder
538
562
  });
563
+ useEffect(() => {
564
+ if (!slugResult.item || slugResult.isPlaceholderData) return;
565
+ syncDetailToLists(queryClient, KEYS.lists(), slugResult.item, idField ? { idField } : {});
566
+ }, [
567
+ slugResult.item,
568
+ slugResult.isPlaceholderData,
569
+ queryClient
570
+ ]);
571
+ return slugResult;
539
572
  }
540
573
  function useTree(params, options) {
541
574
  const auth = resolveAuth();
@@ -561,7 +594,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
561
594
  }
562
595
  });
563
596
  },
564
- enabled: !!api.getTree && createEnabledRule(token, queryOpts, resolveAuthMode(), hasStaticAuth),
597
+ enabled: !!api.getTree && createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
565
598
  options: {
566
599
  staleTime: queryOpts.staleTime ?? config.staleTime,
567
600
  gcTime: queryOpts.gcTime ?? config.gcTime
@@ -594,14 +627,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
594
627
  }
595
628
  });
596
629
  },
597
- enabled: !!api.getChildren && !!parentId && createEnabledRule(token, queryOpts, resolveAuthMode(), hasStaticAuth),
630
+ enabled: !!api.getChildren && !!parentId && createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
598
631
  options: {
599
632
  staleTime: queryOpts.staleTime ?? config.staleTime,
600
633
  gcTime: queryOpts.gcTime ?? config.gcTime
601
634
  },
602
- prefillDetailCache: queryOpts.prefillDetailCache ?? true,
603
- detailKeyBuilder: (id) => KEYS.scopedDetail(id, organizationId ?? null),
604
- itemIdResolver: resolveItemId,
605
635
  select: queryOpts.select
606
636
  });
607
637
  }
@@ -806,7 +836,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
806
836
  enabled: !!name && !!api.aggregate && createEnabledRule(auth.token, {
807
837
  public: isPublic,
808
838
  enabled
809
- }, resolveAuthMode(), hasStaticAuth),
839
+ }, resolveAuthMode(), resolveHasStaticAuth()),
810
840
  staleTime,
811
841
  gcTime,
812
842
  refetchOnWindowFocus,
@@ -912,8 +942,8 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
912
942
  const id = resolveItemId(item);
913
943
  if (id) {
914
944
  const orgId = resolveAuth().organizationId;
915
- queryClient.setQueryData(KEYS.scopedDetail(id, orgId), { data: item });
916
- if (orgId) queryClient.setQueryData(KEYS.detail(id), { data: item });
945
+ queryClient.setQueryData(KEYS.scopedDetail(id, orgId), item);
946
+ if (orgId) queryClient.setQueryData(KEYS.detail(id), item);
917
947
  }
918
948
  if (!router) return;
919
949
  const { scroll = true, replace = false } = options;
package/dist/query.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CacheUtils, DEFAULT_QUERY_CONFIG, PaginationData, QUERY_CONFIGS, QueryKeys, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache } from "./cache.js";
2
- import { InfiniteData, QueryKey } from "@tanstack/react-query";
2
+ import { InfiniteData, QueryClient, QueryKey } from "@tanstack/react-query";
3
3
 
4
4
  //#region src/query.d.ts
5
5
  /** Request-level options passed through to the fetch call */
@@ -17,6 +17,17 @@ interface ListQueryOptions<TData = unknown> {
17
17
  gcTime?: number;
18
18
  refetchOnWindowFocus?: boolean;
19
19
  structuralSharing?: boolean;
20
+ /**
21
+ * @deprecated No-op since 0.7. The old setQueryData-based prefill was
22
+ * removed because it (1) stored a `{ data: ... }` envelope that didn't
23
+ * match arc 2.13+'s raw doc wire, (2) blocked subsequent detail GETs from
24
+ * firing (fresh staleTime), and (3) clobbered detail responses every
25
+ * render due to an unstable effect dep. `useDetail` / `useDetailBySlug`
26
+ * now read list cache via `placeholderData` — the canonical TanStack
27
+ * pattern — so the GET always fires while consumers see an instant
28
+ * preview. Safe to remove from caller code. Retained for compile-time
29
+ * compatibility; will be deleted in a future major.
30
+ */
20
31
  prefillDetailCache?: boolean;
21
32
  refetchInterval?: number | false;
22
33
  refetchIntervalInBackground?: boolean;
@@ -46,8 +57,28 @@ interface DetailQueryOptions<TData = unknown> {
46
57
  /** Pass-through options for the underlying fetch request (cache, revalidate, tags, headers) */
47
58
  request?: RequestPassthrough;
48
59
  }
60
+ /**
61
+ * Result of `useList` (and `useListQuery`). Matches the same typed-end-to-end
62
+ * shape `repo-core` uses for its server-side result interfaces (`AggResult`,
63
+ * `OffsetPaginationResult`, etc.) — no raw-cache escape hatch on the public
64
+ * surface, just the typed extracted view.
65
+ *
66
+ * Need the raw cache entry? Use `useQueryClient().getQueryData(KEYS.list(...))`
67
+ * — it's typed against whatever `setQueryData` last wrote, and it makes the
68
+ * "I'm reaching past the SDK" intent explicit.
69
+ */
49
70
  interface ListQueryResult<T> {
71
+ /**
72
+ * Extracted items array — `T[]`. Always typed, always flat (infinite-list
73
+ * pages are pre-flattened), always consistent across response-shape
74
+ * variants (`data` / `items` / `results` / any-array fallback).
75
+ */
50
76
  items: T[];
77
+ /**
78
+ * Normalized pagination snapshot — offset / keyset / aggregate all map
79
+ * onto the same `{ method, total, pages, page, limit, hasNext, hasPrev, next? }`
80
+ * shape. `null` when the response carries no pagination signal.
81
+ */
51
82
  pagination: PaginationData | null;
52
83
  isLoading: boolean;
53
84
  isFetching: boolean;
@@ -56,18 +87,37 @@ interface ListQueryResult<T> {
56
87
  isStale: boolean;
57
88
  error: Error | null;
58
89
  refetch: () => Promise<unknown>;
59
- data: unknown;
60
90
  }
91
+ /**
92
+ * Result of `useDetail` (and `useDetailQuery`). Matches the typed-end-to-end
93
+ * shape repo-core uses for its result interfaces — no raw-cache escape
94
+ * hatch on the public surface, just the typed extracted view.
95
+ *
96
+ * Need the raw cache entry? `useQueryClient().getQueryData(KEYS.detail(id))`
97
+ * — typed against whatever `setQueryData` last wrote, and the explicit
98
+ * `getQueryData` call signals "I'm reaching past the SDK" at the call site.
99
+ */
61
100
  interface DetailQueryResult<T> {
101
+ /**
102
+ * The extracted entity — `T | null`. Always typed, always the raw doc
103
+ * (not a wrapper), always consistent with what `prefetchDetail` /
104
+ * `cache.getDetail` / `useNavigation` write back.
105
+ */
62
106
  item: T | null;
63
107
  isLoading: boolean;
64
108
  isFetching: boolean;
65
109
  isError: boolean;
66
110
  isSuccess: boolean;
67
111
  isStale: boolean;
112
+ /**
113
+ * `true` while the detail GET is in flight and the consumer is seeing a
114
+ * preview derived from a list cache entry (or any other `placeholderData`).
115
+ * Use to dim or label the preview, e.g. `<article aria-busy={isPlaceholderData}>`.
116
+ * Flips to `false` once the real detail payload resolves.
117
+ */
118
+ isPlaceholderData: boolean;
68
119
  error: Error | null;
69
120
  refetch: () => Promise<unknown>;
70
- data: unknown;
71
121
  }
72
122
  interface CreateListQueryConfig {
73
123
  queryKey: QueryKey;
@@ -76,23 +126,52 @@ interface CreateListQueryConfig {
76
126
  }) => Promise<unknown>;
77
127
  enabled?: boolean;
78
128
  options?: Record<string, unknown>;
79
- prefillDetailCache?: boolean;
80
- detailKeyBuilder?: (id: string) => QueryKey;
81
- /** Custom ID extractor for cache prefill. Falls back to getItemId (_id → id). */
82
- itemIdResolver?: (item: unknown) => string | null;
83
129
  select?: (data: unknown) => unknown;
84
130
  }
131
+ /**
132
+ * List query hook. Returns the canonical `{ items, pagination, ... }` shape
133
+ * derived from arc's `PaginatedResult` wire envelope.
134
+ *
135
+ * **No detail-cache prefill.** Older revisions of this hook wrote each list
136
+ * item into the detail cache via `setQueryData`. That pattern was wrong on
137
+ * three counts: (1) it stored a `{ data: ... }` envelope that didn't match
138
+ * arc 2.13+'s raw doc wire shape, (2) the fresh-default `staleTime` made
139
+ * subsequent `useDetail` calls reuse the partial list payload and never fetch
140
+ * the rich detail response, and (3) the unstable `detailKeyBuilder` ref made
141
+ * the prefill effect re-run on every render, clobbering successful detail
142
+ * fetches. The clean fix is on the read side: `useDetail` now reads list cache
143
+ * directly via `placeholderData`, so the detail GET still fires while the
144
+ * consumer sees an instant list-shaped preview. See `findItemInListCache`.
145
+ */
85
146
  declare function useListQuery<T>({
86
147
  queryKey,
87
148
  queryFn,
88
149
  enabled,
89
150
  options,
90
- prefillDetailCache,
91
- detailKeyBuilder,
92
- itemIdResolver,
93
151
  select
94
152
  }: CreateListQueryConfig): ListQueryResult<T>;
95
- interface CreateDetailQueryConfig {
153
+ /**
154
+ * Find an item in any list cache for this entity by ID.
155
+ *
156
+ * Walks every `[entity, 'list', ...]` query (including scoped variants and
157
+ * infinite-list page arrays) and extracts items via the permissive list
158
+ * detector. Returns the first match — list payloads are subsets of detail
159
+ * payloads, so the consumer sees an instant preview while the real detail
160
+ * GET runs in the background.
161
+ *
162
+ * This is the canonical TanStack pattern for "show list data while fetching
163
+ * detail": pass the returned value as `placeholderData` to `useDetail`. The
164
+ * value isn't persisted to the detail cache (no pollution), `staleTime`
165
+ * doesn't apply to it (always refetches), and `isPlaceholderData` is `true`
166
+ * until the GET resolves.
167
+ *
168
+ * @param qc TanStack QueryClient (typically from `useQueryClient()`)
169
+ * @param listsKey Prefix key for this entity's lists (e.g. `KEYS.lists()`)
170
+ * @param id Item ID being requested
171
+ * @param idField Optional custom ID field (matches `createCrudHooks({ idField })`)
172
+ */
173
+ declare function findItemInListCache<T>(qc: QueryClient, listsKey: QueryKey, id: string, idField?: string): T | undefined;
174
+ interface CreateDetailQueryConfig<T = unknown> {
96
175
  queryKey: QueryKey;
97
176
  queryFn: (context: {
98
177
  signal?: AbortSignal;
@@ -100,14 +179,22 @@ interface CreateDetailQueryConfig {
100
179
  enabled?: boolean;
101
180
  options?: Record<string, unknown>;
102
181
  select?: (data: unknown) => unknown;
182
+ /**
183
+ * Lazy placeholder. Returns a value (or `undefined`) on each render to show
184
+ * before the detail GET resolves. Use with `findItemInListCache` to derive
185
+ * an instant preview from list cache without polluting the detail cache —
186
+ * the canonical TanStack pattern for "show list data while fetching detail."
187
+ */
188
+ placeholderData?: () => T | undefined;
103
189
  }
104
190
  declare function useDetailQuery<T>({
105
191
  queryKey,
106
192
  queryFn,
107
193
  enabled,
108
194
  options,
109
- select
110
- }: CreateDetailQueryConfig): DetailQueryResult<T>;
195
+ select,
196
+ placeholderData
197
+ }: CreateDetailQueryConfig<T>): DetailQueryResult<T>;
111
198
  interface InfiniteListQueryOptions {
112
199
  public?: boolean;
113
200
  enabled?: boolean;
@@ -250,4 +337,4 @@ declare function useApiQuery<TResponse = unknown, TData = ExtractData<TResponse>
250
337
  options
251
338
  }: UseApiQueryConfig<TResponse, TData>): UseApiQueryResult<TData>;
252
339
  //#endregion
253
- export { type CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, ExtractData, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, type PaginationData, QUERY_CONFIGS, QueryFreshness, type QueryKeys, RequestPassthrough, UseApiQueryConfig, UseApiQueryOptions, UseApiQueryResult, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
340
+ export { type CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, ExtractData, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, type PaginationData, QUERY_CONFIGS, QueryFreshness, type QueryKeys, RequestPassthrough, UseApiQueryConfig, UseApiQueryOptions, UseApiQueryResult, createCacheUtils, createQueryKeys, extractItem, extractItems, findItemInListCache, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
package/dist/query.js CHANGED
@@ -1,12 +1,26 @@
1
1
  "use client";
2
2
 
3
3
  import { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache } from "./cache.js";
4
- import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
5
- import { useCallback, useEffect, useMemo, useRef } from "react";
4
+ import { keepPreviousData, useInfiniteQuery, useQuery } from "@tanstack/react-query";
5
+ import { useCallback, useMemo, useRef } from "react";
6
6
 
7
7
  //#region src/query.ts
8
- function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefillDetailCache = true, detailKeyBuilder, itemIdResolver, select }) {
9
- const queryClient = useQueryClient();
8
+ /**
9
+ * List query hook. Returns the canonical `{ items, pagination, ... }` shape
10
+ * derived from arc's `PaginatedResult` wire envelope.
11
+ *
12
+ * **No detail-cache prefill.** Older revisions of this hook wrote each list
13
+ * item into the detail cache via `setQueryData`. That pattern was wrong on
14
+ * three counts: (1) it stored a `{ data: ... }` envelope that didn't match
15
+ * arc 2.13+'s raw doc wire shape, (2) the fresh-default `staleTime` made
16
+ * subsequent `useDetail` calls reuse the partial list payload and never fetch
17
+ * the rich detail response, and (3) the unstable `detailKeyBuilder` ref made
18
+ * the prefill effect re-run on every render, clobbering successful detail
19
+ * fetches. The clean fix is on the read side: `useDetail` now reads list cache
20
+ * directly via `placeholderData`, so the detail GET still fires while the
21
+ * consumer sees an instant list-shaped preview. See `findItemInListCache`.
22
+ */
23
+ function useListQuery({ queryKey, queryFn, enabled = true, options = {}, select }) {
10
24
  const query = useQuery({
11
25
  queryKey,
12
26
  queryFn: ({ signal }) => queryFn({ signal }),
@@ -16,42 +30,62 @@ function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefill
16
30
  ...select ? { select } : {},
17
31
  placeholderData: keepPreviousData
18
32
  });
19
- const items = useMemo(() => extractItems(query.data), [query.data]);
20
- const pagination = useMemo(() => normalizePagination(query.data), [query.data]);
21
- useEffect(() => {
22
- if (!prefillDetailCache || !detailKeyBuilder || items.length === 0) return;
23
- const resolveId = itemIdResolver ?? getItemId;
24
- items.forEach((item) => {
25
- const id = resolveId(item);
26
- if (id) queryClient.setQueryData(detailKeyBuilder(id), { data: item });
27
- });
28
- }, [
29
- items,
30
- prefillDetailCache,
31
- detailKeyBuilder,
32
- queryClient
33
- ]);
34
33
  return {
35
- items,
36
- pagination,
34
+ items: useMemo(() => extractItems(query.data), [query.data]),
35
+ pagination: useMemo(() => normalizePagination(query.data), [query.data]),
37
36
  isLoading: query.isLoading,
38
37
  isFetching: query.isFetching,
39
38
  isError: query.isError,
40
39
  isSuccess: query.isSuccess,
41
40
  isStale: query.isStale,
42
41
  error: query.error,
43
- refetch: query.refetch,
44
- data: query.data
42
+ refetch: query.refetch
45
43
  };
46
44
  }
47
- function useDetailQuery({ queryKey, queryFn, enabled = true, options = {}, select }) {
45
+ /**
46
+ * Find an item in any list cache for this entity by ID.
47
+ *
48
+ * Walks every `[entity, 'list', ...]` query (including scoped variants and
49
+ * infinite-list page arrays) and extracts items via the permissive list
50
+ * detector. Returns the first match — list payloads are subsets of detail
51
+ * payloads, so the consumer sees an instant preview while the real detail
52
+ * GET runs in the background.
53
+ *
54
+ * This is the canonical TanStack pattern for "show list data while fetching
55
+ * detail": pass the returned value as `placeholderData` to `useDetail`. The
56
+ * value isn't persisted to the detail cache (no pollution), `staleTime`
57
+ * doesn't apply to it (always refetches), and `isPlaceholderData` is `true`
58
+ * until the GET resolves.
59
+ *
60
+ * @param qc TanStack QueryClient (typically from `useQueryClient()`)
61
+ * @param listsKey Prefix key for this entity's lists (e.g. `KEYS.lists()`)
62
+ * @param id Item ID being requested
63
+ * @param idField Optional custom ID field (matches `createCrudHooks({ idField })`)
64
+ */
65
+ function findItemInListCache(qc, listsKey, id, idField) {
66
+ if (!id) return void 0;
67
+ const entries = qc.getQueriesData({ queryKey: listsKey });
68
+ for (const [, raw] of entries) {
69
+ if (!raw) continue;
70
+ const pages = typeof raw === "object" && raw !== null && Array.isArray(raw.pages) ? raw.pages : [raw];
71
+ for (const page of pages) {
72
+ const items = extractItems(page);
73
+ for (const item of items) {
74
+ const got = idField ? item?.[idField] : getItemId(item);
75
+ if (got != null && String(got) === id) return item;
76
+ }
77
+ }
78
+ }
79
+ }
80
+ function useDetailQuery({ queryKey, queryFn, enabled = true, options = {}, select, placeholderData }) {
48
81
  const query = useQuery({
49
82
  queryKey,
50
83
  queryFn: ({ signal }) => queryFn({ signal }),
51
84
  enabled,
52
85
  ...DEFAULT_QUERY_CONFIG,
53
86
  ...options,
54
- ...select ? { select } : {}
87
+ ...select ? { select } : {},
88
+ ...placeholderData ? { placeholderData } : {}
55
89
  });
56
90
  return {
57
91
  item: extractItem(query.data),
@@ -60,9 +94,9 @@ function useDetailQuery({ queryKey, queryFn, enabled = true, options = {}, selec
60
94
  isError: query.isError,
61
95
  isSuccess: query.isSuccess,
62
96
  isStale: query.isStale,
97
+ isPlaceholderData: query.isPlaceholderData,
63
98
  error: query.error,
64
- refetch: query.refetch,
65
- data: query.data
99
+ refetch: query.refetch
66
100
  };
67
101
  }
68
102
  function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {}, initialPageParam = 1, getNextPageParam, getPreviousPageParam, maxPages }) {
@@ -155,4 +189,4 @@ function useApiQuery({ queryKey, queryFn, enabled = true, freshness, select, opt
155
189
  }
156
190
 
157
191
  //#endregion
158
- export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
192
+ export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, findItemInListCache, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
package/dist/sse.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { buildStreamUrl, getAuthMode } from "./client.js";
3
+ import { ArcApiError, _getAuthErrorHandler, _runAuthRecovery, buildStreamUrl, getAuthMode } from "./client.js";
4
4
  import { useQueryClient } from "@tanstack/react-query";
5
5
  import { useEffect, useMemo, useRef, useState } from "react";
6
6
 
@@ -21,6 +21,35 @@ function buildSseUrl(path, params = {}) {
21
21
  return buildStreamUrl(path, params, "http");
22
22
  }
23
23
  /**
24
+ * Send a minimal HEAD/GET probe to the SSE URL to classify an EventSource
25
+ * failure as either an auth failure (401, or 403 when `retryOn403`) or
26
+ * something transient (network, 5xx, CORS). EventSource itself doesn't
27
+ * expose status codes — this is the only cross-browser way to route SSE
28
+ * close events through `onAuthError`.
29
+ *
30
+ * The probe uses HEAD when supported (cheaper); falls back to GET with
31
+ * `Range: bytes=0-0` for servers that 405 on HEAD. Either way, the body
32
+ * is never read — only the status code matters.
33
+ */
34
+ async function probeForAuthFailure(url, retryOn403) {
35
+ try {
36
+ let res = await fetch(url, {
37
+ method: "HEAD",
38
+ credentials: "include"
39
+ });
40
+ if (res.status === 405) res = await fetch(url, {
41
+ method: "GET",
42
+ credentials: "include",
43
+ headers: { Range: "bytes=0-0" }
44
+ });
45
+ if (res.status === 401) return "auth-failure";
46
+ if (retryOn403 && res.status === 403) return "auth-failure";
47
+ return "not-auth";
48
+ } catch {
49
+ return "not-auth";
50
+ }
51
+ }
52
+ /**
24
53
  * Subscribe to an Arc SSE stream from any JS context (React, Node, Bun, tests).
25
54
  * Pure function — no React hook required. Returns a handle with `close()` /
26
55
  * `reconnect()` / `isConnected()`.
@@ -68,6 +97,7 @@ function subscribeToEvents(options) {
68
97
  es = new EventSource(buildUrl(), { withCredentials: credentials });
69
98
  es.onopen = () => {
70
99
  reconnectAttempts = 0;
100
+ sseAuthRetries = 0;
71
101
  connected = true;
72
102
  options.onConnectionChange?.(true);
73
103
  };
@@ -98,13 +128,49 @@ function subscribeToEvents(options) {
98
128
  connected = false;
99
129
  options.onConnectionChange?.(false);
100
130
  if (manualClose) return;
101
- if (reconnectAttempts < maxReconnectAttempts) {
102
- reconnectAttempts += 1;
103
- const delay = Math.min(reconnectDelay * Math.pow(1.5, reconnectAttempts - 1), 3e4);
104
- reconnectTimer = setTimeout(connect, delay);
131
+ const { handler, retryOn403, maxAuthRetries } = _getAuthErrorHandler();
132
+ if (handler && sseAuthRetries < maxAuthRetries) {
133
+ sseAuthRetries += 1;
134
+ probeForAuthFailure(buildUrl(), retryOn403).then(async (status) => {
135
+ if (status === "auth-failure") {
136
+ const { decision } = await _runAuthRecovery(handler, {
137
+ error: new ArcApiError("SSE pre-flight auth failure", {
138
+ status: 401,
139
+ statusText: "SSE auth failure",
140
+ json: { code: "arc.sse.unauthorized" },
141
+ endpoint: ssePath,
142
+ method: "GET"
143
+ }),
144
+ request: {
145
+ method: "GET",
146
+ endpoint: ssePath
147
+ },
148
+ attempt: sseAuthRetries
149
+ });
150
+ if (decision === "retry") {
151
+ reconnectAttempts = 0;
152
+ connect();
153
+ return;
154
+ }
155
+ }
156
+ scheduleReconnect();
157
+ }).catch(() => {
158
+ scheduleReconnect();
159
+ });
160
+ return;
105
161
  }
162
+ scheduleReconnect();
106
163
  };
107
164
  };
165
+ /** Standard backoff-reconnect — shared between non-auth errors and skipped recoveries. */
166
+ const scheduleReconnect = () => {
167
+ if (reconnectAttempts < maxReconnectAttempts) {
168
+ reconnectAttempts += 1;
169
+ const delay = Math.min(reconnectDelay * Math.pow(1.5, reconnectAttempts - 1), 3e4);
170
+ reconnectTimer = setTimeout(connect, delay);
171
+ }
172
+ };
173
+ let sseAuthRetries = 0;
108
174
  connect();
109
175
  return {
110
176
  close: () => {
package/dist/upload.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ArcApiError, getAuthMode, getBaseUrl, getClientAuthContext } from "./client.js";
1
+ import { ArcApiError, _getAuthErrorHandler, _isAuthRecoverable, _resolveRefreshedToken, _runAuthRecovery, getAuthMode, getBaseUrl, getClientAuthContext } from "./client.js";
2
2
  import { getToastHandler } from "./mutation.js";
3
3
  import { useQueryClient } from "@tanstack/react-query";
4
4
  import { useCallback, useRef, useState } from "react";
@@ -78,7 +78,38 @@ import { useCallback, useRef, useState } from "react";
78
78
  * onProgress: ({ percent }) => setUiProgress(percent),
79
79
  * });
80
80
  */
81
- function uploadWithProgress(options) {
81
+ async function uploadWithProgress(options) {
82
+ const { handler, retryOn403, maxAuthRetries } = _getAuthErrorHandler();
83
+ if (!handler) return uploadAttempt(options);
84
+ let currentOptions = options;
85
+ for (let attempt = 0; attempt <= maxAuthRetries; attempt++) try {
86
+ return await uploadAttempt(currentOptions);
87
+ } catch (error) {
88
+ if (attempt >= maxAuthRetries || !_isAuthRecoverable(error, retryOn403)) throw error;
89
+ if (currentOptions.signal?.aborted) throw error;
90
+ const { decision, overrideToken } = await _runAuthRecovery(handler, {
91
+ error,
92
+ request: {
93
+ method: currentOptions.method ?? "POST",
94
+ endpoint: currentOptions.url
95
+ },
96
+ attempt: attempt + 1
97
+ });
98
+ if (decision !== "retry") throw error;
99
+ currentOptions = {
100
+ ...currentOptions,
101
+ token: _resolveRefreshedToken(overrideToken)
102
+ };
103
+ }
104
+ throw new Error("arc-next: upload auth retry loop terminated without resolution");
105
+ }
106
+ /**
107
+ * Single XHR attempt. Identical to the pre-0.7 body of `uploadWithProgress`
108
+ * — extracted so the auth-retry loop above can re-run it with a refreshed
109
+ * token without duplicating the XHR setup. All auth-recovery semantics live
110
+ * in the outer loop; this function just performs one upload.
111
+ */
112
+ function uploadAttempt(options) {
82
113
  const { url, formData, method = "POST", onProgress, signal, client, headers: extraHeaders, elevated, idempotencyKey, responseType = "json" } = options;
83
114
  return new Promise((resolve, reject) => {
84
115
  if (signal?.aborted) {