@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/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
+ }