@fictjs/router 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +2373 -3
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1136 -2
- package/dist/index.d.ts +1136 -2
- package/dist/index.js +2305 -3
- package/dist/index.js.map +1 -0
- package/package.json +33 -7
- package/src/components.tsx +926 -0
- package/src/context.ts +404 -0
- package/src/data.ts +545 -0
- package/src/history.ts +659 -0
- package/src/index.ts +217 -0
- package/src/lazy.tsx +242 -0
- package/src/link.tsx +601 -0
- package/src/scroll.ts +245 -0
- package/src/types.ts +447 -0
- package/src/utils.ts +570 -0
package/src/data.ts
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Data loading utilities for @fictjs/router
|
|
3
|
+
*
|
|
4
|
+
* This module provides utilities for loading data in routes,
|
|
5
|
+
* including query caching, actions, and preloading.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createEffect, batch } from '@fictjs/runtime'
|
|
9
|
+
import { createSignal } from '@fictjs/runtime/advanced'
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
QueryFunction,
|
|
13
|
+
QueryCacheEntry,
|
|
14
|
+
ActionFunction,
|
|
15
|
+
Action,
|
|
16
|
+
Submission,
|
|
17
|
+
NavigationIntent,
|
|
18
|
+
Params,
|
|
19
|
+
} from './types'
|
|
20
|
+
import { hashParams } from './utils'
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Query Cache
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
/** Cache duration in milliseconds (default: 3 minutes) */
|
|
27
|
+
const CACHE_DURATION = 3 * 60 * 1000
|
|
28
|
+
|
|
29
|
+
/** Preload cache duration in milliseconds (default: 5 seconds) */
|
|
30
|
+
const PRELOAD_CACHE_DURATION = 5 * 1000
|
|
31
|
+
|
|
32
|
+
/** Maximum cache size to prevent memory spikes */
|
|
33
|
+
const MAX_CACHE_SIZE = 500
|
|
34
|
+
|
|
35
|
+
/** Cleanup interval when cache is small (60 seconds) */
|
|
36
|
+
const NORMAL_CLEANUP_INTERVAL = 60 * 1000
|
|
37
|
+
|
|
38
|
+
/** Cleanup interval when cache is large (10 seconds) */
|
|
39
|
+
const FAST_CLEANUP_INTERVAL = 10 * 1000
|
|
40
|
+
|
|
41
|
+
/** Global query cache */
|
|
42
|
+
const queryCache = new Map<string, QueryCacheEntry<unknown>>()
|
|
43
|
+
|
|
44
|
+
/** Cache cleanup timer */
|
|
45
|
+
let cacheCleanupTimer: ReturnType<typeof setInterval> | undefined
|
|
46
|
+
|
|
47
|
+
/** Current cleanup interval */
|
|
48
|
+
let currentCleanupInterval = NORMAL_CLEANUP_INTERVAL
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Evict oldest entries when cache exceeds max size
|
|
52
|
+
*/
|
|
53
|
+
function evictOldestEntries() {
|
|
54
|
+
if (queryCache.size <= MAX_CACHE_SIZE) return
|
|
55
|
+
|
|
56
|
+
// Sort entries by timestamp (oldest first)
|
|
57
|
+
const entries = Array.from(queryCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp)
|
|
58
|
+
|
|
59
|
+
// Remove oldest entries until we're under the limit
|
|
60
|
+
const toRemove = queryCache.size - MAX_CACHE_SIZE
|
|
61
|
+
for (let i = 0; i < toRemove && i < entries.length; i++) {
|
|
62
|
+
queryCache.delete(entries[i]![0])
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run cache cleanup
|
|
68
|
+
*/
|
|
69
|
+
function runCacheCleanup() {
|
|
70
|
+
const now = Date.now()
|
|
71
|
+
|
|
72
|
+
for (const [key, entry] of queryCache) {
|
|
73
|
+
const maxAge = entry.intent === 'preload' ? PRELOAD_CACHE_DURATION : CACHE_DURATION
|
|
74
|
+
|
|
75
|
+
if (now - entry.timestamp > maxAge) {
|
|
76
|
+
queryCache.delete(key)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Adjust cleanup interval based on cache size
|
|
81
|
+
const newInterval =
|
|
82
|
+
queryCache.size > MAX_CACHE_SIZE / 2 ? FAST_CLEANUP_INTERVAL : NORMAL_CLEANUP_INTERVAL
|
|
83
|
+
|
|
84
|
+
if (newInterval !== currentCleanupInterval) {
|
|
85
|
+
currentCleanupInterval = newInterval
|
|
86
|
+
// Restart with new interval
|
|
87
|
+
stopCacheCleanup()
|
|
88
|
+
startCacheCleanup()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Start the cache cleanup interval
|
|
94
|
+
*/
|
|
95
|
+
function startCacheCleanup() {
|
|
96
|
+
if (cacheCleanupTimer) return
|
|
97
|
+
|
|
98
|
+
cacheCleanupTimer = setInterval(runCacheCleanup, currentCleanupInterval)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Stop the cache cleanup interval
|
|
103
|
+
*/
|
|
104
|
+
function stopCacheCleanup() {
|
|
105
|
+
if (cacheCleanupTimer) {
|
|
106
|
+
clearInterval(cacheCleanupTimer)
|
|
107
|
+
cacheCleanupTimer = undefined
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// Query Function
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Create a cached query function
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```tsx
|
|
120
|
+
* const getUser = query(
|
|
121
|
+
* async (id: string) => {
|
|
122
|
+
* const response = await fetch(`/api/users/${id}`)
|
|
123
|
+
* return response.json()
|
|
124
|
+
* },
|
|
125
|
+
* 'getUser'
|
|
126
|
+
* )
|
|
127
|
+
*
|
|
128
|
+
* // In a component
|
|
129
|
+
* function UserProfile({ id }) {
|
|
130
|
+
* const user = getUser(id)
|
|
131
|
+
* return <div>{user()?.name}</div>
|
|
132
|
+
* }
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
export function query<T, Args extends unknown[]>(
|
|
136
|
+
fn: QueryFunction<T, Args>,
|
|
137
|
+
name: string,
|
|
138
|
+
): (...args: Args) => () => T | undefined {
|
|
139
|
+
startCacheCleanup()
|
|
140
|
+
|
|
141
|
+
return (...args: Args) => {
|
|
142
|
+
const cacheKey = `${name}:${hashParams(args as unknown as Record<string, unknown>)}`
|
|
143
|
+
|
|
144
|
+
// Check cache
|
|
145
|
+
const cached = queryCache.get(cacheKey) as QueryCacheEntry<T> | undefined
|
|
146
|
+
if (cached && cached.result !== undefined) {
|
|
147
|
+
// Check if cache is still valid
|
|
148
|
+
const maxAge = cached.intent === 'preload' ? PRELOAD_CACHE_DURATION : CACHE_DURATION
|
|
149
|
+
|
|
150
|
+
if (Date.now() - cached.timestamp < maxAge) {
|
|
151
|
+
return () => cached.result
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Create reactive signal for the result
|
|
156
|
+
const resultSignal = createSignal<T | undefined>(cached?.result)
|
|
157
|
+
const errorSignal = createSignal<unknown>(undefined)
|
|
158
|
+
const loadingSignal = createSignal<boolean>(true)
|
|
159
|
+
|
|
160
|
+
// Fetch the data
|
|
161
|
+
const promise = Promise.resolve(fn(...args))
|
|
162
|
+
.then(result => {
|
|
163
|
+
// Update cache
|
|
164
|
+
const entry: QueryCacheEntry<T> = {
|
|
165
|
+
timestamp: Date.now(),
|
|
166
|
+
promise,
|
|
167
|
+
result,
|
|
168
|
+
intent: 'navigate',
|
|
169
|
+
}
|
|
170
|
+
queryCache.set(cacheKey, entry)
|
|
171
|
+
evictOldestEntries()
|
|
172
|
+
|
|
173
|
+
// Update signals
|
|
174
|
+
batch(() => {
|
|
175
|
+
resultSignal(result)
|
|
176
|
+
loadingSignal(false)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
return result
|
|
180
|
+
})
|
|
181
|
+
.catch(error => {
|
|
182
|
+
batch(() => {
|
|
183
|
+
errorSignal(error)
|
|
184
|
+
loadingSignal(false)
|
|
185
|
+
})
|
|
186
|
+
throw error
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// Store promise in cache immediately for deduplication
|
|
190
|
+
if (!cached) {
|
|
191
|
+
queryCache.set(cacheKey, {
|
|
192
|
+
timestamp: Date.now(),
|
|
193
|
+
promise: promise as Promise<unknown>,
|
|
194
|
+
intent: 'navigate',
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return () => resultSignal()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Invalidate cached queries by key pattern
|
|
204
|
+
*/
|
|
205
|
+
export function revalidate(keys?: string | string[] | RegExp): void {
|
|
206
|
+
if (!keys) {
|
|
207
|
+
// Invalidate all
|
|
208
|
+
queryCache.clear()
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (typeof keys === 'string') {
|
|
213
|
+
// Single key or prefix
|
|
214
|
+
for (const cacheKey of queryCache.keys()) {
|
|
215
|
+
if (cacheKey.startsWith(keys)) {
|
|
216
|
+
queryCache.delete(cacheKey)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (Array.isArray(keys)) {
|
|
223
|
+
// Multiple keys
|
|
224
|
+
for (const key of keys) {
|
|
225
|
+
for (const cacheKey of queryCache.keys()) {
|
|
226
|
+
if (cacheKey.startsWith(key)) {
|
|
227
|
+
queryCache.delete(cacheKey)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (keys instanceof RegExp) {
|
|
235
|
+
// Regex pattern
|
|
236
|
+
for (const cacheKey of queryCache.keys()) {
|
|
237
|
+
if (keys.test(cacheKey)) {
|
|
238
|
+
queryCache.delete(cacheKey)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ============================================================================
|
|
245
|
+
// Action Function
|
|
246
|
+
// ============================================================================
|
|
247
|
+
|
|
248
|
+
/** Global action registry */
|
|
249
|
+
const actionRegistry = new Map<string, ActionFunction<unknown>>()
|
|
250
|
+
|
|
251
|
+
/** Submission counter for unique keys */
|
|
252
|
+
let submissionCounter = 0
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Create an action for form submissions
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* ```tsx
|
|
259
|
+
* const createUser = action(
|
|
260
|
+
* async (formData, { params }) => {
|
|
261
|
+
* const response = await fetch('/api/users', {
|
|
262
|
+
* method: 'POST',
|
|
263
|
+
* body: formData,
|
|
264
|
+
* })
|
|
265
|
+
* return response.json()
|
|
266
|
+
* },
|
|
267
|
+
* 'createUser'
|
|
268
|
+
* )
|
|
269
|
+
*
|
|
270
|
+
* // In a component
|
|
271
|
+
* <Form action={createUser}>
|
|
272
|
+
* <input name="name" />
|
|
273
|
+
* <button type="submit">Create</button>
|
|
274
|
+
* </Form>
|
|
275
|
+
* ```
|
|
276
|
+
*/
|
|
277
|
+
export function action<T>(fn: ActionFunction<T>, name?: string): Action<T> {
|
|
278
|
+
const actionName = name || `action-${++submissionCounter}`
|
|
279
|
+
const actionUrl = `/_action/${actionName}`
|
|
280
|
+
|
|
281
|
+
// Register the action
|
|
282
|
+
actionRegistry.set(actionUrl, fn as ActionFunction<unknown>)
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
url: actionUrl,
|
|
286
|
+
name: actionName,
|
|
287
|
+
submit: async (formData: FormData): Promise<T> => {
|
|
288
|
+
// Create a mock request with a base URL for Node.js/jsdom compatibility
|
|
289
|
+
const baseUrl =
|
|
290
|
+
typeof window !== 'undefined' && window.location
|
|
291
|
+
? window.location.origin
|
|
292
|
+
: 'http://localhost'
|
|
293
|
+
const request = new Request(new URL(actionUrl, baseUrl).href, {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
body: formData,
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
return fn(formData, { params: {}, request }) as Promise<T>
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Get a registered action by URL
|
|
305
|
+
*/
|
|
306
|
+
export function getAction(url: string): ActionFunction<unknown> | undefined {
|
|
307
|
+
return actionRegistry.get(url)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ============================================================================
|
|
311
|
+
// Submission Tracking
|
|
312
|
+
// ============================================================================
|
|
313
|
+
|
|
314
|
+
/** Active submissions */
|
|
315
|
+
const activeSubmissions = createSignal<Map<string, Submission<unknown>>>(new Map())
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Use submission state for an action
|
|
319
|
+
*/
|
|
320
|
+
export function useSubmission<T>(actionOrUrl: Action<T> | string): () => Submission<T> | undefined {
|
|
321
|
+
const url = typeof actionOrUrl === 'string' ? actionOrUrl : actionOrUrl.url
|
|
322
|
+
|
|
323
|
+
return () => {
|
|
324
|
+
const submissions = activeSubmissions()
|
|
325
|
+
return submissions.get(url) as Submission<T> | undefined
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Use all active submissions
|
|
331
|
+
*/
|
|
332
|
+
export function useSubmissions(): () => Submission<unknown>[] {
|
|
333
|
+
return () => Array.from(activeSubmissions().values())
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Submit an action and track the submission
|
|
338
|
+
*/
|
|
339
|
+
export async function submitAction<T>(
|
|
340
|
+
action: Action<T>,
|
|
341
|
+
formData: FormData,
|
|
342
|
+
params: Params = {},
|
|
343
|
+
): Promise<T> {
|
|
344
|
+
const key = `submission-${++submissionCounter}`
|
|
345
|
+
|
|
346
|
+
// Create submission object
|
|
347
|
+
const submission: Submission<T> = {
|
|
348
|
+
key,
|
|
349
|
+
formData,
|
|
350
|
+
state: 'submitting',
|
|
351
|
+
clear: () => {
|
|
352
|
+
const submissions = new Map(activeSubmissions())
|
|
353
|
+
submissions.delete(action.url)
|
|
354
|
+
activeSubmissions(submissions)
|
|
355
|
+
},
|
|
356
|
+
retry: () => {
|
|
357
|
+
submitAction(action, formData, params)
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Add to active submissions
|
|
362
|
+
const submissions = new Map(activeSubmissions())
|
|
363
|
+
submissions.set(action.url, submission as Submission<unknown>)
|
|
364
|
+
activeSubmissions(submissions)
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
// Execute the action
|
|
368
|
+
const result = await action.submit(formData)
|
|
369
|
+
|
|
370
|
+
// Update submission with result
|
|
371
|
+
submission.result = result
|
|
372
|
+
submission.state = 'idle'
|
|
373
|
+
|
|
374
|
+
// Update active submissions
|
|
375
|
+
const updatedSubmissions = new Map(activeSubmissions())
|
|
376
|
+
updatedSubmissions.set(action.url, submission as Submission<unknown>)
|
|
377
|
+
activeSubmissions(updatedSubmissions)
|
|
378
|
+
|
|
379
|
+
return result
|
|
380
|
+
} catch (error) {
|
|
381
|
+
// Update submission with error
|
|
382
|
+
submission.error = error
|
|
383
|
+
submission.state = 'idle'
|
|
384
|
+
|
|
385
|
+
// Update active submissions
|
|
386
|
+
const updatedSubmissions = new Map(activeSubmissions())
|
|
387
|
+
updatedSubmissions.set(action.url, submission as Submission<unknown>)
|
|
388
|
+
activeSubmissions(updatedSubmissions)
|
|
389
|
+
|
|
390
|
+
throw error
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ============================================================================
|
|
395
|
+
// Preloading
|
|
396
|
+
// ============================================================================
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Preload a query for faster navigation
|
|
400
|
+
*/
|
|
401
|
+
export function preloadQuery<T, Args extends unknown[]>(
|
|
402
|
+
queryFn: (...args: Args) => () => T | undefined,
|
|
403
|
+
...args: Args
|
|
404
|
+
): void {
|
|
405
|
+
// The query function handles caching internally
|
|
406
|
+
queryFn(...args)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Create a preload function for a route
|
|
411
|
+
*/
|
|
412
|
+
export function createPreload<T>(
|
|
413
|
+
fn: (args: { params: Params; intent: NavigationIntent }) => T | Promise<T>,
|
|
414
|
+
): (args: { params: Params; intent: NavigationIntent }) => Promise<T> {
|
|
415
|
+
return async args => {
|
|
416
|
+
return fn(args)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ============================================================================
|
|
421
|
+
// Resource (async data with Suspense support)
|
|
422
|
+
// ============================================================================
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Resource state
|
|
426
|
+
*/
|
|
427
|
+
export interface Resource<T> {
|
|
428
|
+
/** Get current data (undefined during loading or on error) */
|
|
429
|
+
(): T | undefined
|
|
430
|
+
/** Whether the resource is currently loading */
|
|
431
|
+
loading: () => boolean
|
|
432
|
+
/** Error if the fetch failed, undefined otherwise */
|
|
433
|
+
error: () => unknown
|
|
434
|
+
/** Latest successfully loaded value (persists during reloads) */
|
|
435
|
+
latest: () => T | undefined
|
|
436
|
+
/** Trigger a refetch, returns the result or undefined on error */
|
|
437
|
+
refetch: () => Promise<T | undefined>
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Create a resource for async data loading
|
|
442
|
+
* Integrates with Suspense for loading states
|
|
443
|
+
*
|
|
444
|
+
* @example
|
|
445
|
+
* ```tsx
|
|
446
|
+
* const userResource = createResource(
|
|
447
|
+
* () => userId,
|
|
448
|
+
* async (id) => fetch(`/api/users/${id}`).then(r => r.json())
|
|
449
|
+
* )
|
|
450
|
+
*
|
|
451
|
+
* function UserProfile() {
|
|
452
|
+
* const user = userResource()
|
|
453
|
+
* return <div>{user?.name}</div>
|
|
454
|
+
* }
|
|
455
|
+
* ```
|
|
456
|
+
*/
|
|
457
|
+
export function createResource<T, S = unknown>(
|
|
458
|
+
source: () => S,
|
|
459
|
+
fetcher: (source: S) => T | Promise<T>,
|
|
460
|
+
): Resource<T> {
|
|
461
|
+
const dataSignal = createSignal<T | undefined>(undefined)
|
|
462
|
+
const loadingSignal = createSignal<boolean>(true)
|
|
463
|
+
const errorSignal = createSignal<unknown>(undefined)
|
|
464
|
+
const latestSignal = createSignal<T | undefined>(undefined)
|
|
465
|
+
|
|
466
|
+
let currentSource: S
|
|
467
|
+
let fetchId = 0 // Used to prevent race conditions
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Internal fetch function with race condition protection
|
|
471
|
+
* Returns T on success, undefined on error (error is stored in errorSignal)
|
|
472
|
+
*/
|
|
473
|
+
const doFetch = async (s: S, id: number): Promise<T | undefined> => {
|
|
474
|
+
loadingSignal(true)
|
|
475
|
+
errorSignal(undefined)
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
const result = await fetcher(s)
|
|
479
|
+
|
|
480
|
+
// Only apply results if this fetch is still current
|
|
481
|
+
// (prevents race conditions when source changes rapidly)
|
|
482
|
+
if (id === fetchId) {
|
|
483
|
+
batch(() => {
|
|
484
|
+
dataSignal(result)
|
|
485
|
+
latestSignal(result)
|
|
486
|
+
loadingSignal(false)
|
|
487
|
+
})
|
|
488
|
+
return result
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// This fetch was superseded, return undefined
|
|
492
|
+
return undefined
|
|
493
|
+
} catch (err) {
|
|
494
|
+
// Only apply error if this fetch is still current
|
|
495
|
+
if (id === fetchId) {
|
|
496
|
+
batch(() => {
|
|
497
|
+
errorSignal(err)
|
|
498
|
+
loadingSignal(false)
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Return undefined on error - error is accessible via resource.error()
|
|
503
|
+
return undefined
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Initial fetch and tracking
|
|
508
|
+
createEffect(() => {
|
|
509
|
+
const s = source()
|
|
510
|
+
|
|
511
|
+
// Only refetch if source changed
|
|
512
|
+
if (s !== currentSource) {
|
|
513
|
+
currentSource = s
|
|
514
|
+
// Increment fetchId to invalidate any pending fetches
|
|
515
|
+
const currentFetchId = ++fetchId
|
|
516
|
+
doFetch(s, currentFetchId)
|
|
517
|
+
}
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
const resource = (() => dataSignal()) as Resource<T>
|
|
521
|
+
|
|
522
|
+
resource.loading = () => loadingSignal()
|
|
523
|
+
resource.error = () => errorSignal()
|
|
524
|
+
resource.latest = () => latestSignal()
|
|
525
|
+
resource.refetch = () => {
|
|
526
|
+
const currentFetchId = ++fetchId
|
|
527
|
+
return doFetch(currentSource, currentFetchId)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return resource
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ============================================================================
|
|
534
|
+
// Cleanup
|
|
535
|
+
// ============================================================================
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Clean up router data utilities
|
|
539
|
+
*/
|
|
540
|
+
export function cleanupDataUtilities(): void {
|
|
541
|
+
stopCacheCleanup()
|
|
542
|
+
queryCache.clear()
|
|
543
|
+
actionRegistry.clear()
|
|
544
|
+
activeSubmissions(new Map())
|
|
545
|
+
}
|