Archived
init
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import { LRUCache, MemoryCache } from "../src/cache";
|
||||
|
||||
describe("LRUCache", () => {
|
||||
let cache: LRUCache<string, Error>;
|
||||
const maxSize = 2;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new LRUCache(maxSize);
|
||||
});
|
||||
|
||||
it("inserts and retrieves items", () => {
|
||||
const key = "foo";
|
||||
const lastModified = Date.now();
|
||||
const entry = { data: "bar", isValidating: false, isLoading: false, lastModified };
|
||||
cache.set(key, entry);
|
||||
expect(cache.get(key)).toEqual(entry);
|
||||
});
|
||||
|
||||
it("evicts oldest item when max size is reached", () => {
|
||||
cache.set("1", { data: "1", isValidating: false, isLoading: false, lastModified: Date.now() });
|
||||
cache.set("2", { data: "2", isValidating: false, isLoading: false, lastModified: Date.now() });
|
||||
cache.set("3", { data: "3", isValidating: false, isLoading: false, lastModified: Date.now() });
|
||||
|
||||
expect(cache.has("1")).toBe(false);
|
||||
expect(cache.has("2")).toBe(true);
|
||||
expect(cache.has("3")).toBe(true);
|
||||
});
|
||||
|
||||
it("updates LRU order on get", () => {
|
||||
cache.set("1", { data: "1", isValidating: false, isLoading: false, lastModified: Date.now() });
|
||||
cache.set("2", { data: "2", isValidating: false, isLoading: false, lastModified: Date.now() });
|
||||
|
||||
// Access "1" to make it most recent
|
||||
cache.get("1");
|
||||
|
||||
cache.set("3", { data: "3", isValidating: false, isLoading: false, lastModified: Date.now() });
|
||||
|
||||
expect(cache.has("2")).toBe(false); // "2" was oldest
|
||||
expect(cache.has("1")).toBe(true);
|
||||
expect(cache.has("3")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles TTL expiration", () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Date.now();
|
||||
cache.set("exp", { data: "val", isValidating: false, isLoading: false, lastModified: now, ttl: 1000 });
|
||||
|
||||
expect(cache.get("exp")).toBeDefined();
|
||||
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
expect(cache.get("exp")).toBeUndefined();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryCache", () => {
|
||||
it("cleans up on destroy", () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = new MemoryCache(100, 1000);
|
||||
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');
|
||||
|
||||
cache.destroy();
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import { createSWR } from "../src/swr";
|
||||
import type { Key, FetcherOptions } from "../src/types";
|
||||
|
||||
describe("SWRCore", () => {
|
||||
let swr: any;
|
||||
let fetcher: any;
|
||||
const key = "test-key";
|
||||
|
||||
beforeEach(() => {
|
||||
fetcher = vi.fn((k: Key, options: FetcherOptions) => Promise.resolve(`data-${k}`));
|
||||
swr = createSWR({ fetcher, dedupingInterval: 100 });
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fetches data on subscription if not in cache", async () => {
|
||||
const callback = vi.fn();
|
||||
swr.subscribe(key, callback);
|
||||
|
||||
// Initial fetch is async
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(fetcher).toHaveBeenCalledWith(key, expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(callback).toHaveBeenCalledWith(`data-${key}`, undefined, false, false);
|
||||
});
|
||||
|
||||
it("deduplicates concurrent fetches", async () => {
|
||||
swr.revalidate(key);
|
||||
swr.revalidate(key);
|
||||
swr.revalidate(key);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates all subscribers on mutation", async () => {
|
||||
const cb1 = vi.fn();
|
||||
const cb2 = vi.fn();
|
||||
|
||||
swr.subscribe(key, cb1);
|
||||
swr.subscribe(key, cb2);
|
||||
|
||||
await swr.mutate(key, "manual-data");
|
||||
|
||||
expect(cb1).toHaveBeenCalledWith("manual-data", undefined, false, false);
|
||||
expect(cb2).toHaveBeenCalledWith("manual-data", undefined, false, false);
|
||||
});
|
||||
|
||||
it("handles fetcher errors", async () => {
|
||||
const error = new Error("fetch failed");
|
||||
fetcher.mockRejectedValue(error);
|
||||
|
||||
const callback = vi.fn();
|
||||
swr.subscribe(key, callback);
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(undefined, error, false, false);
|
||||
});
|
||||
|
||||
it("respects revalidateOnMount: false", async () => {
|
||||
swr.updateConfig({ revalidateOnMount: false });
|
||||
// Put something in cache
|
||||
await swr.mutate(key, "cached");
|
||||
fetcher.mockClear();
|
||||
|
||||
const callback = vi.fn();
|
||||
swr.subscribe(key, callback);
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
// Should not have fetched again despite subscription
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshInterval periodically revalidates with per-key fetcher", async () => {
|
||||
const intervalFetcher = vi.fn((k: Key, options: FetcherOptions) => Promise.resolve(`data-${k}`));
|
||||
swr.updateConfig({ refreshInterval: 1000 });
|
||||
|
||||
const callback = vi.fn();
|
||||
swr.subscribe(key, callback, intervalFetcher);
|
||||
|
||||
// Initial fetch (only run pending timers, not the interval)
|
||||
vi.runOnlyPendingTimers();
|
||||
expect(intervalFetcher).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(`data-${key}`, undefined, false, false);
|
||||
|
||||
// After interval passes, should revalidate again
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(intervalFetcher).toHaveBeenCalledTimes(2);
|
||||
|
||||
// After another interval, should revalidate again
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(intervalFetcher).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("revalidate uses stored fetcher when called without fetcher option", async () => {
|
||||
// Create SWR without global fetcher
|
||||
const noGlobalFetcherSwr = createSWR({ dedupingInterval: 100 });
|
||||
const perKeyFetcher = vi.fn((k: Key, options: FetcherOptions) => Promise.resolve(`data-${k}`));
|
||||
|
||||
const callback = vi.fn();
|
||||
noGlobalFetcherSwr.subscribe(key, callback, perKeyFetcher);
|
||||
|
||||
// Initial fetch
|
||||
vi.runOnlyPendingTimers();
|
||||
expect(perKeyFetcher).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Revalidate without passing fetcher - should use stored fetcher
|
||||
await noGlobalFetcherSwr.revalidate(key);
|
||||
expect(perKeyFetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "bunup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: "./src/index.ts",
|
||||
dts: true,
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@supwr/core",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"module": "./dist/index.js",
|
||||
"main": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bunup"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.4.0",
|
||||
"bunup": "^0.16.32"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Cache, CacheEntry, Key } from './types'
|
||||
|
||||
/**
|
||||
* LRU Cache implementation with TTL support
|
||||
*/
|
||||
export class LRUCache<Data = unknown, Error = unknown> implements Cache<Data, Error> {
|
||||
private cache = new Map<string, CacheEntry<Data, Error>>()
|
||||
private maxSize: number
|
||||
|
||||
constructor(maxSize: number = 1000) {
|
||||
this.maxSize = maxSize
|
||||
}
|
||||
|
||||
private serializeKey(key: Key): string {
|
||||
if (key === null || key === undefined) {
|
||||
return 'null'
|
||||
}
|
||||
|
||||
if (typeof key === 'string') {
|
||||
return key
|
||||
}
|
||||
|
||||
if (Array.isArray(key)) {
|
||||
return `["${key.join('","')}"]`
|
||||
}
|
||||
|
||||
return JSON.stringify(key)
|
||||
}
|
||||
|
||||
private isExpired(entry: CacheEntry<Data, Error>): boolean {
|
||||
if (!entry.ttl) return false
|
||||
return Date.now() - entry.lastModified > entry.ttl
|
||||
}
|
||||
|
||||
get(key: Key): CacheEntry<Data, Error> | undefined {
|
||||
const serializedKey = this.serializeKey(key)
|
||||
const entry = this.cache.get(serializedKey)
|
||||
|
||||
if (!entry) return undefined
|
||||
|
||||
if (this.isExpired(entry)) {
|
||||
this.cache.delete(serializedKey)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Move to end (LRU behavior)
|
||||
this.cache.delete(serializedKey)
|
||||
this.cache.set(serializedKey, entry)
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
set(key: Key, value: CacheEntry<Data, Error>): void {
|
||||
const serializedKey = this.serializeKey(key)
|
||||
|
||||
// Remove oldest if at capacity
|
||||
if (this.cache.size >= this.maxSize && !this.cache.has(serializedKey)) {
|
||||
const firstKey = this.cache.keys().next().value
|
||||
if (firstKey) {
|
||||
this.cache.delete(firstKey)
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.set(serializedKey, {
|
||||
...value,
|
||||
lastModified: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
delete(key: Key): void {
|
||||
const serializedKey = this.serializeKey(key)
|
||||
this.cache.delete(serializedKey)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
keys(): Key[] {
|
||||
return Array.from(this.cache.keys()).map(key => {
|
||||
try {
|
||||
return JSON.parse(key)
|
||||
} catch {
|
||||
return key
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
has(key: Key): boolean {
|
||||
const serializedKey = this.serializeKey(key)
|
||||
const entry = this.cache.get(serializedKey)
|
||||
|
||||
if (!entry) return false
|
||||
|
||||
if (this.isExpired(entry)) {
|
||||
this.cache.delete(serializedKey)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Cleanup expired entries
|
||||
cleanup(): void {
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (this.isExpired(entry)) {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get cache size
|
||||
size(): number {
|
||||
return this.cache.size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory cache with automatic cleanup
|
||||
*/
|
||||
export class MemoryCache<Data = unknown, Error = unknown> extends LRUCache<Data, Error> {
|
||||
private cleanupInterval: ReturnType<typeof globalThis.setInterval> | null = null
|
||||
|
||||
constructor(maxSize: number = 1000, cleanupIntervalMs: number = 60000) {
|
||||
super(maxSize)
|
||||
|
||||
// Set up periodic cleanup
|
||||
this.cleanupInterval = globalThis.setInterval(() => {
|
||||
this.cleanup()
|
||||
}, cleanupIntervalMs)
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
}
|
||||
this.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Callback, EventManager, Key } from './types'
|
||||
import { serializeKey } from './key'
|
||||
|
||||
/**
|
||||
* Event manager for handling SWR state changes
|
||||
*/
|
||||
export class SWREventManager implements EventManager {
|
||||
private listeners = new Map<string, Set<Callback<unknown, unknown>>>()
|
||||
|
||||
on(event: string, callback: Callback<unknown, unknown>): () => void {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set())
|
||||
}
|
||||
|
||||
const eventListeners = this.listeners.get(event)!
|
||||
eventListeners.add(callback)
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
eventListeners.delete(callback)
|
||||
if (eventListeners.size === 0) {
|
||||
this.listeners.delete(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit(event: string, ...args: unknown[]): void {
|
||||
const eventListeners = this.listeners.get(event)
|
||||
if (!eventListeners) return
|
||||
|
||||
// Create a copy to avoid issues with listeners being added/removed during iteration
|
||||
const listeners = Array.from(eventListeners)
|
||||
|
||||
for (const callback of listeners) {
|
||||
try {
|
||||
callback(...args)
|
||||
} catch (error) {
|
||||
console.error('Error in event listener:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
off(event: string, callback: Callback<unknown, unknown>): void {
|
||||
const eventListeners = this.listeners.get(event)
|
||||
if (!eventListeners) return
|
||||
|
||||
eventListeners.delete(callback)
|
||||
if (eventListeners.size === 0) {
|
||||
this.listeners.delete(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all listeners for an event or all events
|
||||
clear(event?: string): void {
|
||||
if (event) {
|
||||
this.listeners.delete(event)
|
||||
} else {
|
||||
this.listeners.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Get the number of listeners for an event
|
||||
listenerCount(event: string): number {
|
||||
return this.listeners.get(event)?.size || 0
|
||||
}
|
||||
|
||||
// Get all event names
|
||||
eventNames(): string[] {
|
||||
return Array.from(this.listeners.keys())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Key-specific event manager that handles events per cache key
|
||||
*/
|
||||
export class KeyEventManager {
|
||||
private eventManager = new SWREventManager()
|
||||
private keyListeners = new Map<string, Set<Callback<unknown, unknown>>>()
|
||||
|
||||
subscribe<Data = unknown, Error = unknown>(key: Key, callback: Callback<Data, Error>): () => void {
|
||||
const serializedKey = serializeKey(key)
|
||||
|
||||
// Add to key-specific listeners
|
||||
if (!this.keyListeners.has(serializedKey)) {
|
||||
this.keyListeners.set(serializedKey, new Set())
|
||||
}
|
||||
this.keyListeners.get(serializedKey)!.add(callback as Callback<unknown, unknown>)
|
||||
|
||||
// Also subscribe to global events for this key
|
||||
const unsubscribeGlobal = this.eventManager.on(`key:${serializedKey}`, callback as Callback<unknown, unknown>)
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const keyListeners = this.keyListeners.get(serializedKey)
|
||||
if (keyListeners) {
|
||||
keyListeners.delete(callback as Callback<unknown, unknown>)
|
||||
if (keyListeners.size === 0) {
|
||||
this.keyListeners.delete(serializedKey)
|
||||
}
|
||||
}
|
||||
unsubscribeGlobal()
|
||||
}
|
||||
}
|
||||
|
||||
emit(key: Key, ...args: unknown[]): void {
|
||||
const serializedKey = serializeKey(key)
|
||||
|
||||
// Emit to key-specific listeners
|
||||
const keyListeners = this.keyListeners.get(serializedKey)
|
||||
if (keyListeners) {
|
||||
const listeners = Array.from(keyListeners)
|
||||
for (const callback of listeners) {
|
||||
try {
|
||||
callback(...args)
|
||||
} catch (error) {
|
||||
console.error('Error in key event listener:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also emit to global event system
|
||||
this.eventManager.emit(`key:${serializedKey}`, ...args)
|
||||
}
|
||||
|
||||
// Global events
|
||||
on(event: string, callback: Callback<unknown, unknown>): () => void {
|
||||
return this.eventManager.on(event, callback)
|
||||
}
|
||||
|
||||
emitGlobal(event: string, ...args: unknown[]): void {
|
||||
this.eventManager.emit(event, ...args)
|
||||
}
|
||||
|
||||
clear(key?: Key): void {
|
||||
if (key) {
|
||||
const serializedKey = serializeKey(key)
|
||||
this.keyListeners.delete(serializedKey)
|
||||
this.eventManager.clear(`key:${serializedKey}`)
|
||||
} else {
|
||||
this.keyListeners.clear()
|
||||
this.eventManager.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Get statistics
|
||||
getStats(): { keyListeners: number; globalListeners: number; totalKeys: number } {
|
||||
let totalKeyListeners = 0
|
||||
for (const listeners of this.keyListeners.values()) {
|
||||
totalKeyListeners += listeners.size
|
||||
}
|
||||
|
||||
let totalGlobalListeners = 0
|
||||
for (const event of this.eventManager.eventNames()) {
|
||||
totalGlobalListeners += this.eventManager.listenerCount(event)
|
||||
}
|
||||
|
||||
return {
|
||||
keyListeners: totalKeyListeners,
|
||||
globalListeners: totalGlobalListeners,
|
||||
totalKeys: this.keyListeners.size
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { Fetcher, FetcherOptions, Key, CacheEntry } from './types'
|
||||
import { serializeKey } from './key'
|
||||
|
||||
export class DeduplicationManager {
|
||||
private pendingRequests = new Map<string, Promise<unknown>>()
|
||||
|
||||
constructor(private dedupingInterval: number = 2000) {}
|
||||
|
||||
async dedupe<Data>(
|
||||
key: Key,
|
||||
fetcher: Fetcher<Data>,
|
||||
options: FetcherOptions
|
||||
): Promise<Data> {
|
||||
const serializedKey = serializeKey(key)
|
||||
const pendingRequest = this.pendingRequests.get(serializedKey)
|
||||
|
||||
if (pendingRequest) {
|
||||
return pendingRequest as Promise<Data>
|
||||
}
|
||||
|
||||
const requestPromise = this.createRequest(key, fetcher, options)
|
||||
this.pendingRequests.set(serializedKey, requestPromise)
|
||||
|
||||
try {
|
||||
const result = await requestPromise
|
||||
return result
|
||||
} finally {
|
||||
this.pendingRequests.delete(serializedKey)
|
||||
}
|
||||
}
|
||||
|
||||
private async createRequest<Data>(
|
||||
key: Key,
|
||||
fetcher: Fetcher<Data>,
|
||||
options: FetcherOptions
|
||||
): Promise<Data> {
|
||||
try {
|
||||
return await fetcher(key, options)
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.pendingRequests.clear()
|
||||
}
|
||||
|
||||
getPendingCount(): number {
|
||||
return this.pendingRequests.size
|
||||
}
|
||||
|
||||
isPending(key: Key): boolean {
|
||||
const serializedKey = serializeKey(key)
|
||||
return this.pendingRequests.has(serializedKey)
|
||||
}
|
||||
}
|
||||
|
||||
export class FetchManager {
|
||||
private deduplicationManager: DeduplicationManager
|
||||
private abortControllers = new Map<string, AbortController>()
|
||||
|
||||
constructor(dedupingInterval: number = 2000) {
|
||||
this.deduplicationManager = new DeduplicationManager(dedupingInterval)
|
||||
}
|
||||
|
||||
async fetch<Data>(
|
||||
key: Key,
|
||||
fetcher: Fetcher<Data>,
|
||||
options: {
|
||||
dedupe?: boolean
|
||||
timeout?: number
|
||||
signal?: AbortSignal
|
||||
} = {}
|
||||
): Promise<Data> {
|
||||
const serializedKey = serializeKey(key)
|
||||
|
||||
const abortController = new AbortController()
|
||||
this.abortControllers.set(serializedKey, abortController)
|
||||
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
abortController.abort()
|
||||
throw new DOMException('Request aborted', 'AbortError')
|
||||
}
|
||||
|
||||
options.signal.addEventListener('abort', () => {
|
||||
abortController.abort()
|
||||
}, { once: true })
|
||||
}
|
||||
|
||||
let timeoutId: ReturnType<typeof globalThis.setInterval> | undefined
|
||||
if (options.timeout) {
|
||||
timeoutId = globalThis.setTimeout(() => {
|
||||
abortController.abort()
|
||||
}, options.timeout)
|
||||
}
|
||||
|
||||
try {
|
||||
const fetcherOptions: FetcherOptions = {
|
||||
signal: abortController.signal
|
||||
}
|
||||
|
||||
let result: Data
|
||||
|
||||
if (options.dedupe !== false) {
|
||||
result = await this.deduplicationManager.dedupe(key, fetcher, fetcherOptions)
|
||||
} else {
|
||||
result = await fetcher(key, fetcherOptions)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) {
|
||||
throw new DOMException('Request aborted', 'AbortError')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
this.abortControllers.delete(serializedKey)
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abort(key: Key): void {
|
||||
const serializedKey = serializeKey(key)
|
||||
const controller = this.abortControllers.get(serializedKey)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
this.abortControllers.delete(serializedKey)
|
||||
}
|
||||
}
|
||||
|
||||
abortAll(): void {
|
||||
for (const controller of this.abortControllers.values()) {
|
||||
controller.abort()
|
||||
}
|
||||
this.abortControllers.clear()
|
||||
this.deduplicationManager.clear()
|
||||
}
|
||||
|
||||
isFetching(key: Key): boolean {
|
||||
const serializedKey = serializeKey(key)
|
||||
return this.abortControllers.has(serializedKey) || this.deduplicationManager.isPending(key)
|
||||
}
|
||||
|
||||
getActiveCount(): number {
|
||||
return this.abortControllers.size
|
||||
}
|
||||
|
||||
getPendingCount(): number {
|
||||
return this.deduplicationManager.getPendingCount()
|
||||
}
|
||||
}
|
||||
|
||||
export function createFetcher<Data = unknown>(
|
||||
input: string | URL,
|
||||
init?: RequestInit
|
||||
): Fetcher<Data> {
|
||||
return async (key: Key, options: FetcherOptions) => {
|
||||
const url = typeof key === 'string' ? key : String(key)
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
signal: options.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json() as Promise<Data>
|
||||
}
|
||||
}
|
||||
|
||||
export function createFetcherWithErrorHandler<Data = unknown>(
|
||||
fetcher: (url: string, options: RequestInit) => Promise<Response>,
|
||||
errorHandler?: (error: unknown, key: Key) => Error
|
||||
): Fetcher<Data> {
|
||||
return async (key: Key, options: FetcherOptions) => {
|
||||
try {
|
||||
const url = typeof key === 'string' ? key : String(key)
|
||||
const response = await fetcher(url, { signal: options.signal })
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json() as Promise<Data>
|
||||
} catch (error) {
|
||||
if (errorHandler) {
|
||||
throw errorHandler(error, key)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Core types
|
||||
export type {
|
||||
Key,
|
||||
CacheEntry,
|
||||
CacheOptions,
|
||||
FetcherOptions,
|
||||
Fetcher,
|
||||
BareFetcher,
|
||||
SWRFetcher,
|
||||
MutatorOptions,
|
||||
MutatorCallback,
|
||||
MutatorValue,
|
||||
RevalidateOptions,
|
||||
Callback,
|
||||
Unsubscribe,
|
||||
SWRConfig,
|
||||
Cache,
|
||||
EventManager,
|
||||
SWRCore
|
||||
} from './types'
|
||||
|
||||
// Core implementation
|
||||
export { SWRCoreImpl, createSWR } from './swr'
|
||||
|
||||
// Cache
|
||||
export { LRUCache, MemoryCache } from './cache'
|
||||
|
||||
// Events
|
||||
export { SWREventManager, KeyEventManager } from './events'
|
||||
|
||||
// Key utilities
|
||||
export {
|
||||
serializeKey,
|
||||
resolveKey,
|
||||
hashKey,
|
||||
areKeysEqual,
|
||||
isValidKey,
|
||||
normalizeKey
|
||||
} from './key'
|
||||
|
||||
// Configuration
|
||||
export { defaultConfig, mergeConfig, validateConfig, ConfigManager } from './options'
|
||||
|
||||
// Fetcher
|
||||
export {
|
||||
DeduplicationManager,
|
||||
FetchManager,
|
||||
createFetcher,
|
||||
createFetcherWithErrorHandler
|
||||
} from './fetcher'
|
||||
|
||||
// Revalidation
|
||||
export { RevalidationManager, setupGlobalRevalidationHandlers } from './revalidate'
|
||||
|
||||
// Mutation
|
||||
export {
|
||||
MutationManager,
|
||||
createMutator,
|
||||
createMutateFunction,
|
||||
createOptimisticMutation,
|
||||
createAsyncMutation
|
||||
} from './mutate'
|
||||
|
||||
// Default export
|
||||
export { createSWR as default } from './swr'
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Key } from './types'
|
||||
|
||||
/**
|
||||
* Key resolution and serialization utilities
|
||||
*/
|
||||
|
||||
export function serializeKey(key: Key): string {
|
||||
if (key === null || key === undefined) {
|
||||
return 'null'
|
||||
}
|
||||
|
||||
if (typeof key === 'string') {
|
||||
return key
|
||||
}
|
||||
|
||||
if (Array.isArray(key)) {
|
||||
// Handle nested arrays and objects
|
||||
return `[${key.map(item => serializeKey(item)).join(',')}]`
|
||||
}
|
||||
|
||||
if (typeof key === 'object') {
|
||||
try {
|
||||
return JSON.stringify(key, (k, v) => {
|
||||
// Handle functions by converting to string representation
|
||||
if (typeof v === 'function') {
|
||||
return `[Function: ${v.name || 'anonymous'}]`
|
||||
}
|
||||
// Handle undefined
|
||||
if (v === undefined) {
|
||||
return '[Undefined]'
|
||||
}
|
||||
// Handle symbols
|
||||
if (typeof v === 'symbol') {
|
||||
return `[Symbol: ${v.toString()}]`
|
||||
}
|
||||
return v
|
||||
})
|
||||
} catch (error) {
|
||||
// Fallback for circular references
|
||||
return '[Object]'
|
||||
}
|
||||
}
|
||||
|
||||
return String(key)
|
||||
}
|
||||
|
||||
export function resolveKey(key: Key | (() => Key)): Key {
|
||||
if (typeof key === 'function') {
|
||||
try {
|
||||
return key()
|
||||
} catch (error) {
|
||||
console.error('Error resolving key function:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
export function hashKey(key: Key): string {
|
||||
const serialized = serializeKey(key)
|
||||
|
||||
// Simple hash function for better distribution
|
||||
let hash = 0
|
||||
for (let i = 0; i < serialized.length; i++) {
|
||||
const char = serialized.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + char
|
||||
hash = hash & hash // Convert to 32-bit integer
|
||||
}
|
||||
|
||||
return Math.abs(hash).toString(36)
|
||||
}
|
||||
|
||||
export function areKeysEqual(key1: Key, key2: Key): boolean {
|
||||
return serializeKey(key1) === serializeKey(key2)
|
||||
}
|
||||
|
||||
export function isValidKey(key: Key): boolean {
|
||||
return key !== null && key !== undefined
|
||||
}
|
||||
|
||||
export function normalizeKey(key: Key): string {
|
||||
if (!isValidKey(key)) {
|
||||
return 'null'
|
||||
}
|
||||
|
||||
return serializeKey(key)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import type {
|
||||
Key,
|
||||
MutatorOptions,
|
||||
MutatorValue,
|
||||
MutatorCallback,
|
||||
CacheEntry,
|
||||
SWRConfig,
|
||||
Fetcher
|
||||
} from './types'
|
||||
import { serializeKey } from './key'
|
||||
import { FetchManager } from './fetcher'
|
||||
|
||||
/**
|
||||
* Mutation manager for handling data mutations with optimistic updates
|
||||
*/
|
||||
export class MutationManager<Data = unknown, Error = unknown> {
|
||||
private optimisticData = new Map<string, Data>()
|
||||
private rollbackData = new Map<string, Data | undefined>()
|
||||
|
||||
constructor(
|
||||
private fetchManager: FetchManager,
|
||||
private config: SWRConfig<Data, Error>,
|
||||
private onUpdate: (key: Key, entry: Partial<CacheEntry<Data, Error>>) => void,
|
||||
private getCurrentData: (key: Key) => Data | undefined,
|
||||
private getFetcher?: (key: Key) => Fetcher<Data> | undefined
|
||||
) {}
|
||||
|
||||
updateConfig(config: SWRConfig<Data, Error>): void {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate data for a key
|
||||
*/
|
||||
async mutate(
|
||||
key: Key,
|
||||
data?: MutatorValue<Data>,
|
||||
options: MutatorOptions<Data> = {}
|
||||
): Promise<Data | undefined> {
|
||||
const serializedKey = serializeKey(key)
|
||||
const currentData = this.getCurrentData(key)
|
||||
|
||||
// Store rollback data if needed
|
||||
if (options.rollbackOnError !== false && currentData !== undefined) {
|
||||
this.rollbackData.set(serializedKey, currentData)
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle optimistic data
|
||||
if (options.optimisticData !== undefined) {
|
||||
this.optimisticData.set(serializedKey, options.optimisticData)
|
||||
this.onUpdate(key, {
|
||||
data: options.optimisticData,
|
||||
isLoading: false,
|
||||
isValidating: false
|
||||
})
|
||||
} else if (data !== undefined) {
|
||||
// Handle immediate data update
|
||||
const newData = await this.resolveData(data, currentData)
|
||||
this.optimisticData.set(serializedKey, newData)
|
||||
this.onUpdate(key, {
|
||||
data: newData,
|
||||
isLoading: false,
|
||||
isValidating: false
|
||||
})
|
||||
}
|
||||
|
||||
// Revalidate if requested
|
||||
if (options.revalidate !== false) {
|
||||
await this.revalidate(key)
|
||||
}
|
||||
|
||||
// Return the final data
|
||||
return this.getCurrentData(key)
|
||||
} catch (error) {
|
||||
// Rollback on error if requested
|
||||
if (options.rollbackOnError !== false) {
|
||||
const rollbackValue = this.rollbackData.get(serializedKey)
|
||||
if (rollbackValue !== undefined) {
|
||||
this.onUpdate(key, {
|
||||
data: rollbackValue,
|
||||
error: error as Error,
|
||||
isLoading: false,
|
||||
isValidating: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
} finally {
|
||||
// Clean up optimistic data
|
||||
this.optimisticData.delete(serializedKey)
|
||||
this.rollbackData.delete(serializedKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve data from various input types
|
||||
*/
|
||||
private async resolveData(
|
||||
data: MutatorValue<Data>,
|
||||
currentData: Data | undefined
|
||||
): Promise<Data> {
|
||||
if (typeof data === 'function') {
|
||||
const result = (data as MutatorCallback<Data>)(currentData)
|
||||
return Promise.resolve(result)
|
||||
}
|
||||
|
||||
if (data === undefined) {
|
||||
throw new Error('Mutation data cannot be undefined')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalidate a key after mutation
|
||||
*/
|
||||
private async revalidate(key: Key): Promise<void> {
|
||||
const fetcher = this.getFetcher?.(key) || this.config.fetcher
|
||||
if (!fetcher) return
|
||||
|
||||
try {
|
||||
this.onUpdate(key, { isValidating: true })
|
||||
|
||||
const freshData = await this.fetchManager.fetch(key, fetcher, {
|
||||
dedupe: false, // Always fetch fresh data for revalidation
|
||||
timeout: this.config.loadingTimeout
|
||||
})
|
||||
|
||||
// Update with fresh data
|
||||
this.onUpdate(key, {
|
||||
data: freshData,
|
||||
error: undefined,
|
||||
isValidating: false,
|
||||
isLoading: false
|
||||
})
|
||||
|
||||
this.config.onSuccess?.(freshData, key)
|
||||
} catch (error) {
|
||||
const errorObj = error as Error
|
||||
|
||||
this.onUpdate(key, {
|
||||
error: errorObj,
|
||||
isValidating: false,
|
||||
isLoading: false
|
||||
})
|
||||
|
||||
this.config.onError?.(errorObj, key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key has optimistic data
|
||||
*/
|
||||
hasOptimisticData(key: Key): boolean {
|
||||
const serializedKey = serializeKey(key)
|
||||
return this.optimisticData.has(serializedKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get optimistic data for a key
|
||||
*/
|
||||
getOptimisticData(key: Key): Data | undefined {
|
||||
const serializedKey = serializeKey(key)
|
||||
return this.optimisticData.get(serializedKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all optimistic data
|
||||
*/
|
||||
clearOptimisticData(): void {
|
||||
this.optimisticData.clear()
|
||||
this.rollbackData.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mutation function for a specific key
|
||||
*/
|
||||
createMutator(key: Key) {
|
||||
return (
|
||||
data?: MutatorValue<Data>,
|
||||
options?: MutatorOptions<Data>
|
||||
): Promise<Data | undefined> => {
|
||||
return this.mutate(key, data, options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch mutate multiple keys
|
||||
*/
|
||||
async mutateBatch(
|
||||
mutations: Array<{
|
||||
key: Key
|
||||
data?: MutatorValue<Data>
|
||||
options?: MutatorOptions<Data>
|
||||
}>
|
||||
): Promise<(Data | undefined)[]> {
|
||||
const results = await Promise.allSettled(
|
||||
mutations.map(({ key, data, options }) => this.mutate(key, data, options))
|
||||
)
|
||||
|
||||
return results.map((result, index) => {
|
||||
const mutation = mutations[index]
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value
|
||||
} else {
|
||||
console.error(`Mutation failed for key ${mutation?.key}:`, result.reason || 'Unknown error')
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to create a mutator
|
||||
*/
|
||||
export function createMutator<Data = unknown, Error = unknown>(
|
||||
fetchManager: FetchManager,
|
||||
config: SWRConfig<Data, Error>,
|
||||
onUpdate: (key: Key, entry: Partial<CacheEntry<Data, Error>>) => void,
|
||||
getCurrentData: (key: Key) => Data | undefined
|
||||
): MutationManager<Data, Error> {
|
||||
return new MutationManager(fetchManager, config, onUpdate, getCurrentData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order function that creates a mutate function
|
||||
*/
|
||||
export function createMutateFunction<Data = unknown, Error = unknown>(
|
||||
mutationManager: MutationManager<Data, Error>
|
||||
) {
|
||||
return (
|
||||
key: Key,
|
||||
data?: MutatorValue<Data>,
|
||||
options?: MutatorOptions<Data>
|
||||
): Promise<Data | undefined> => {
|
||||
return mutationManager.mutate(key, data, options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility for creating optimistic mutations
|
||||
*/
|
||||
export function createOptimisticMutation<Data = unknown>(
|
||||
currentData: Data,
|
||||
updateFn: (current: Data) => Data
|
||||
): {
|
||||
optimisticData: Data
|
||||
rollbackData: Data
|
||||
} {
|
||||
return {
|
||||
optimisticData: updateFn(currentData),
|
||||
rollbackData: currentData
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility for creating async mutations
|
||||
*/
|
||||
export function createAsyncMutation<Data = unknown>(
|
||||
asyncFn: (currentData?: Data) => Promise<Data>
|
||||
): MutatorCallback<Data> {
|
||||
return async (currentData?: Data) => {
|
||||
return await asyncFn(currentData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { SWRConfig, Fetcher } from './types'
|
||||
|
||||
/**
|
||||
* Default configuration for SWR
|
||||
*/
|
||||
export const defaultConfig: SWRConfig = {
|
||||
// Cache options
|
||||
ttl: 0, // No TTL by default
|
||||
revalidateOnMount: true,
|
||||
revalidateOnFocus: true,
|
||||
revalidateOnReconnect: true,
|
||||
refreshInterval: 0,
|
||||
dedupingInterval: 2000,
|
||||
errorRetryCount: 3,
|
||||
errorRetryInterval: 5000,
|
||||
loadingTimeout: 3000,
|
||||
|
||||
// Callbacks
|
||||
onSuccess: () => {},
|
||||
onError: () => {},
|
||||
onErrorRetry: (error, key, config, revalidate, { retryCount }) => {
|
||||
if (retryCount >= Math.max(config.errorRetryCount || 0, 0)) return
|
||||
|
||||
const timeout = ~~((Math.random() + 0.5) * (1 << (retryCount < 8 ? retryCount : 8))) * (config.errorRetryInterval || 0)
|
||||
|
||||
setTimeout(revalidate, timeout)
|
||||
},
|
||||
onLoadingSlow: () => {},
|
||||
|
||||
// Utilities
|
||||
isPaused: () => false,
|
||||
compare: (a, b) => a === b,
|
||||
fallback: {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge user configuration with defaults
|
||||
*/
|
||||
export function mergeConfig<Data = any, Error = any>(
|
||||
userConfig?: Partial<SWRConfig<Data, Error>>
|
||||
): SWRConfig<Data, Error> {
|
||||
if (!userConfig) return defaultConfig as SWRConfig<Data, Error>
|
||||
|
||||
return {
|
||||
...defaultConfig,
|
||||
...userConfig,
|
||||
// Ensure arrays and objects are properly merged
|
||||
fallback: { ...defaultConfig.fallback, ...userConfig.fallback } as Record<string, Data>
|
||||
} as SWRConfig<Data, Error>
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration
|
||||
*/
|
||||
export function validateConfig<Data = any, Error = any>(
|
||||
config: SWRConfig<Data, Error>
|
||||
): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
|
||||
// Validate fetcher
|
||||
if (config.fetcher && typeof config.fetcher !== 'function') {
|
||||
errors.push('fetcher must be a function')
|
||||
}
|
||||
|
||||
// Validate numeric options
|
||||
const numericFields = [
|
||||
'ttl',
|
||||
'refreshInterval',
|
||||
'dedupingInterval',
|
||||
'errorRetryCount',
|
||||
'errorRetryInterval',
|
||||
'loadingTimeout'
|
||||
] as const
|
||||
|
||||
for (const field of numericFields) {
|
||||
const value = config[field]
|
||||
if (value !== undefined && (typeof value !== 'number' || value < 0)) {
|
||||
errors.push(`${field} must be a non-negative number`)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate boolean options
|
||||
const booleanFields = [
|
||||
'revalidateOnFocus',
|
||||
'revalidateOnReconnect'
|
||||
] as const
|
||||
|
||||
for (const field of booleanFields) {
|
||||
const value = config[field]
|
||||
if (value !== undefined && typeof value !== 'boolean') {
|
||||
errors.push(`${field} must be a boolean`)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate callbacks
|
||||
const callbackFields = [
|
||||
'onSuccess',
|
||||
'onError',
|
||||
'onErrorRetry',
|
||||
'onLoadingSlow',
|
||||
'isPaused',
|
||||
'compare'
|
||||
] as const
|
||||
|
||||
for (const field of callbackFields) {
|
||||
const value = config[field]
|
||||
if (value !== undefined && typeof value !== 'function') {
|
||||
errors.push(`${field} must be a function`)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate fallback
|
||||
if (config.fallback && typeof config.fallback !== 'object') {
|
||||
errors.push('fallback must be an object')
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configuration manager
|
||||
*/
|
||||
export class ConfigManager<Data = any, Error = any> {
|
||||
private config: SWRConfig<Data, Error>
|
||||
|
||||
constructor(userConfig?: Partial<SWRConfig<Data, Error>>) {
|
||||
const merged = mergeConfig(userConfig)
|
||||
const validation = validateConfig(merged)
|
||||
|
||||
if (!validation.valid) {
|
||||
console.warn('Invalid SWR configuration:', validation.errors)
|
||||
}
|
||||
|
||||
this.config = merged
|
||||
}
|
||||
|
||||
get(): SWRConfig<Data, Error> {
|
||||
return this.config
|
||||
}
|
||||
|
||||
update(updates: Partial<SWRConfig<Data, Error>>): void {
|
||||
this.config = { ...this.config, ...updates }
|
||||
|
||||
const validation = validateConfig(this.config)
|
||||
if (!validation.valid) {
|
||||
console.warn('Invalid SWR configuration update:', validation.errors)
|
||||
}
|
||||
}
|
||||
|
||||
setFetcher(fetcher: Fetcher<Data>): void {
|
||||
this.config.fetcher = fetcher
|
||||
}
|
||||
|
||||
getFetcher(): Fetcher<Data> | undefined {
|
||||
return this.config.fetcher
|
||||
}
|
||||
|
||||
isPaused(): boolean {
|
||||
return !!this.config.isPaused?.()
|
||||
}
|
||||
|
||||
shouldRevalidateOnFocus(): boolean {
|
||||
return !!(this.config.revalidateOnFocus && !this.isPaused())
|
||||
}
|
||||
|
||||
shouldRevalidateOnReconnect(): boolean {
|
||||
return !!(this.config.revalidateOnReconnect && !this.isPaused())
|
||||
}
|
||||
|
||||
getRefreshInterval(): number {
|
||||
return this.config.refreshInterval || 0
|
||||
}
|
||||
|
||||
getDedupingInterval(): number {
|
||||
return this.config.dedupingInterval || 2000
|
||||
}
|
||||
|
||||
getErrorRetryCount(): number {
|
||||
return this.config.errorRetryCount || 3
|
||||
}
|
||||
|
||||
getErrorRetryInterval(): number {
|
||||
return this.config.errorRetryInterval || 5000
|
||||
}
|
||||
|
||||
getLoadingTimeout(): number {
|
||||
return this.config.loadingTimeout || 3000
|
||||
}
|
||||
|
||||
getFallbackData(key: string): Data | undefined {
|
||||
return this.config.fallback?.[key]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import type { Key, RevalidateOptions, SWRConfig, CacheEntry, Fetcher } from './types'
|
||||
import { serializeKey } from './key'
|
||||
import { FetchManager } from './fetcher'
|
||||
|
||||
/**
|
||||
* Revalidation manager handles different revalidation strategies
|
||||
*/
|
||||
export class RevalidationManager<Data = any, Error = any> {
|
||||
private intervals = new Map<string, ReturnType<typeof globalThis.setInterval>>()
|
||||
private timeouts = new Map<string, ReturnType<typeof globalThis.setInterval>>()
|
||||
private retryAttempts = new Map<string, number>()
|
||||
private fetchers = new Map<string, Fetcher<Data>>()
|
||||
|
||||
constructor(
|
||||
private fetchManager: FetchManager,
|
||||
private config: SWRConfig<Data, Error>,
|
||||
private onUpdate: (key: Key, entry: Partial<CacheEntry<Data, Error>>) => void
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the fetcher for a key
|
||||
*/
|
||||
setFetcher(key: Key, fetcher: Fetcher<Data>): void {
|
||||
const serializedKey = serializeKey(key)
|
||||
this.fetchers.set(serializedKey, fetcher)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stored fetcher for a key
|
||||
*/
|
||||
getFetcher(key: Key): Fetcher<Data> | undefined {
|
||||
const serializedKey = serializeKey(key)
|
||||
return this.fetchers.get(serializedKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the fetcher for a key
|
||||
*/
|
||||
removeFetcher(key: Key): void {
|
||||
const serializedKey = serializeKey(key)
|
||||
this.fetchers.delete(serializedKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start interval revalidation for a key
|
||||
*/
|
||||
startIntervalRevalidation(key: Key): void {
|
||||
const serializedKey = serializeKey(key)
|
||||
const interval = this.config.refreshInterval
|
||||
|
||||
if (!interval || interval <= 0) return
|
||||
|
||||
// Clear existing interval
|
||||
this.stopIntervalRevalidation(key)
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (!this.config.isPaused?.()) {
|
||||
const fetcher = this.fetchers.get(serializedKey) || this.config.fetcher
|
||||
this.revalidate(key, { dedupe: true, fetcher })
|
||||
}
|
||||
}, interval)
|
||||
|
||||
this.intervals.set(serializedKey, intervalId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop interval revalidation for a key
|
||||
*/
|
||||
stopIntervalRevalidation(key: Key): void {
|
||||
const serializedKey = serializeKey(key)
|
||||
const intervalId = this.intervals.get(serializedKey)
|
||||
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
this.intervals.delete(serializedKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalidate on focus
|
||||
*/
|
||||
revalidateOnFocus(key: Key): void {
|
||||
if (!this.config.revalidateOnFocus || this.config.isPaused?.()) return
|
||||
|
||||
this.revalidate(key, { dedupe: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalidate on reconnect
|
||||
*/
|
||||
revalidateOnReconnect(key: Key): void {
|
||||
if (!this.config.revalidateOnReconnect || this.config.isPaused?.()) return
|
||||
|
||||
this.revalidate(key, { dedupe: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual revalidation
|
||||
*/
|
||||
async revalidate(key: Key, options: RevalidateOptions<Data> = {}): Promise<boolean> {
|
||||
const serializedKey = serializeKey(key)
|
||||
const fetcher = options.fetcher || this.fetchers.get(serializedKey) || this.config.fetcher
|
||||
|
||||
if (!fetcher) {
|
||||
console.warn('No fetcher provided for revalidation')
|
||||
return false
|
||||
}
|
||||
|
||||
console.log('RevalidationManager.revalidate called for', key, 'dedupe:', options.dedupe, 'fetcher:', !!fetcher)
|
||||
|
||||
try {
|
||||
// Set loading state
|
||||
this.onUpdate(key, { isValidating: true })
|
||||
|
||||
const data = await this.fetchManager.fetch(key, fetcher, {
|
||||
dedupe: options.dedupe !== false,
|
||||
timeout: this.config.loadingTimeout
|
||||
})
|
||||
|
||||
// Update cache with new data
|
||||
this.onUpdate(key, {
|
||||
data,
|
||||
error: undefined,
|
||||
isValidating: false,
|
||||
isLoading: false,
|
||||
lastModified: Date.now()
|
||||
})
|
||||
|
||||
// Reset retry attempts on success
|
||||
this.retryAttempts.delete(serializedKey)
|
||||
|
||||
// Call success callback
|
||||
this.config.onSuccess?.(data, key)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
const errorObj = error as Error
|
||||
|
||||
// Update cache with error
|
||||
this.onUpdate(key, {
|
||||
error: errorObj,
|
||||
isValidating: false,
|
||||
isLoading: false,
|
||||
lastModified: Date.now()
|
||||
})
|
||||
|
||||
// Call error callback
|
||||
this.config.onError?.(errorObj, key)
|
||||
|
||||
// Handle retry logic
|
||||
await this.handleRetry(key, errorObj)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle error retry logic
|
||||
*/
|
||||
private async handleRetry(key: Key, error: Error): Promise<void> {
|
||||
const serializedKey = serializeKey(key)
|
||||
const retryCount = this.retryAttempts.get(serializedKey) || 0
|
||||
const maxRetries = this.config.errorRetryCount || 0
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
this.retryAttempts.delete(serializedKey)
|
||||
return
|
||||
}
|
||||
|
||||
// Increment retry count
|
||||
this.retryAttempts.set(serializedKey, retryCount + 1)
|
||||
|
||||
// Calculate retry delay with exponential backoff
|
||||
const baseInterval = this.config.errorRetryInterval || 5000
|
||||
const delay = Math.min(baseInterval * Math.pow(2, retryCount), 30000)
|
||||
|
||||
// Schedule retry
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!this.config.isPaused?.()) {
|
||||
this.revalidate(key, { dedupe: true })
|
||||
}
|
||||
}, delay)
|
||||
|
||||
this.timeouts.set(`${serializedKey}:retry`, timeoutId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel retry for a key
|
||||
*/
|
||||
cancelRetry(key: Key): void {
|
||||
const serializedKey = serializeKey(key)
|
||||
const timeoutId = this.timeouts.get(`${serializedKey}:retry`)
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
this.timeouts.delete(`${serializedKey}:retry`)
|
||||
}
|
||||
|
||||
this.retryAttempts.delete(serializedKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all revalidation for a key
|
||||
*/
|
||||
clear(key: Key): void {
|
||||
this.stopIntervalRevalidation(key)
|
||||
this.cancelRetry(key)
|
||||
this.removeFetcher(key)
|
||||
|
||||
const serializedKey = serializeKey(key)
|
||||
|
||||
// Clear any other timeouts for this key
|
||||
for (const [timeoutKey, timeoutId] of this.timeouts.entries()) {
|
||||
if (timeoutKey.startsWith(serializedKey)) {
|
||||
clearTimeout(timeoutId)
|
||||
this.timeouts.delete(timeoutKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all revalidation
|
||||
*/
|
||||
clearAll(): void {
|
||||
// Clear all intervals
|
||||
for (const intervalId of this.intervals.values()) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
this.intervals.clear()
|
||||
|
||||
// Clear all timeouts
|
||||
for (const timeoutId of this.timeouts.values()) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
this.timeouts.clear()
|
||||
|
||||
// Clear retry attempts
|
||||
this.retryAttempts.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(config: Partial<SWRConfig<Data, Error>>): void {
|
||||
this.config = { ...this.config, ...config }
|
||||
|
||||
// Restart interval revalidation with new config if needed
|
||||
// This would need to be called for all active keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Get revalidation statistics
|
||||
*/
|
||||
getStats(): {
|
||||
intervalRevalidations: number
|
||||
pendingRetries: number
|
||||
retryAttempts: Map<string, number>
|
||||
} {
|
||||
return {
|
||||
intervalRevalidations: this.intervals.size,
|
||||
pendingRetries: this.timeouts.size,
|
||||
retryAttempts: new Map(this.retryAttempts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup global event listeners for focus and reconnect
|
||||
*/
|
||||
export function setupGlobalRevalidationHandlers(
|
||||
revalidationManager: RevalidationManager,
|
||||
keys: Key[]
|
||||
): () => void {
|
||||
const handleFocus = () => {
|
||||
for (const key of keys) {
|
||||
revalidationManager.revalidateOnFocus(key)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReconnect = () => {
|
||||
for (const key of keys) {
|
||||
revalidationManager.revalidateOnReconnect(key)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (typeof globalThis !== 'undefined' && 'document' in globalThis && !(globalThis as any).document.hidden) {
|
||||
handleFocus()
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
if (typeof globalThis !== 'undefined' && 'window' in globalThis) {
|
||||
const globalObj = globalThis as any
|
||||
globalObj.addEventListener('focus', handleFocus)
|
||||
globalObj.addEventListener('online', handleReconnect)
|
||||
if ('document' in globalObj) {
|
||||
globalObj.document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
if (typeof globalThis !== 'undefined' && 'window' in globalThis) {
|
||||
const globalObj = globalThis as any
|
||||
globalObj.removeEventListener('focus', handleFocus)
|
||||
globalObj.removeEventListener('online', handleReconnect)
|
||||
if ('document' in globalObj) {
|
||||
globalObj.document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import type {
|
||||
SWRCore,
|
||||
Key,
|
||||
CacheEntry,
|
||||
Callback,
|
||||
Unsubscribe,
|
||||
SWRConfig,
|
||||
MutatorValue,
|
||||
MutatorOptions,
|
||||
RevalidateOptions,
|
||||
Fetcher
|
||||
} from './types'
|
||||
import { MemoryCache } from './cache'
|
||||
import { KeyEventManager } from './events'
|
||||
import { ConfigManager } from './options'
|
||||
import { FetchManager } from './fetcher'
|
||||
import { RevalidationManager } from './revalidate'
|
||||
import { MutationManager } from './mutate'
|
||||
import { resolveKey, serializeKey } from './key'
|
||||
|
||||
/**
|
||||
* Main SWR Core implementation
|
||||
*/
|
||||
export class SWRCoreImpl<Data = unknown, Error = unknown> implements SWRCore<Data, Error> {
|
||||
private cache: MemoryCache<Data, Error>
|
||||
private eventManager: KeyEventManager
|
||||
private configManager: ConfigManager<Data, Error>
|
||||
private fetchManager: FetchManager
|
||||
private revalidationManager: RevalidationManager<Data, Error>
|
||||
private mutationManager: MutationManager<Data, Error>
|
||||
|
||||
// Track active keys for cleanup
|
||||
private activeKeys = new Set<string>()
|
||||
|
||||
constructor(userConfig?: Partial<SWRConfig<Data, Error>>) {
|
||||
// Initialize core components
|
||||
this.configManager = new ConfigManager(userConfig)
|
||||
this.cache = new MemoryCache()
|
||||
this.eventManager = new KeyEventManager()
|
||||
this.fetchManager = new FetchManager(this.configManager.getDedupingInterval())
|
||||
|
||||
// Initialize managers with dependencies
|
||||
this.revalidationManager = new RevalidationManager(
|
||||
this.fetchManager,
|
||||
this.configManager.get(),
|
||||
this.handleUpdate.bind(this)
|
||||
)
|
||||
|
||||
this.mutationManager = new MutationManager(
|
||||
this.fetchManager,
|
||||
this.configManager.get(),
|
||||
this.handleUpdate.bind(this),
|
||||
(key) => this.get(key)?.data,
|
||||
(key) => this.revalidationManager.getFetcher(key)
|
||||
)
|
||||
|
||||
// Setup global revalidation handlers
|
||||
this.setupGlobalHandlers()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache entry for a key
|
||||
*/
|
||||
get(key: Key): CacheEntry<Data, Error> | undefined {
|
||||
const resolvedKey = resolveKey(key)
|
||||
if (!resolvedKey) return undefined
|
||||
|
||||
return this.cache.get(resolvedKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to state changes for a key
|
||||
*/
|
||||
subscribe(key: Key, callback: Callback<Data, Error>, fetcher?: Fetcher<Data>): Unsubscribe {
|
||||
const resolvedKey = resolveKey(key)
|
||||
if (!resolvedKey) {
|
||||
return () => {} // No-op for invalid keys
|
||||
}
|
||||
|
||||
const serializedKey = serializeKey(resolvedKey)
|
||||
|
||||
// Add to active keys
|
||||
this.activeKeys.add(serializedKey)
|
||||
|
||||
// Store fetcher for interval revalidation
|
||||
if (fetcher) {
|
||||
this.revalidationManager.setFetcher(resolvedKey, fetcher)
|
||||
}
|
||||
|
||||
// Check if we have an entry in cache
|
||||
const entry = this.cache.get(resolvedKey)
|
||||
|
||||
// Subscribe to events
|
||||
const unsubscribe = this.eventManager.subscribe(resolvedKey, callback as Callback<unknown, unknown>)
|
||||
|
||||
const config = this.configManager.get()
|
||||
|
||||
// Start initial fetch if no data exists
|
||||
if (!entry) {
|
||||
if (config.revalidateOnMount !== false) {
|
||||
// Set initial loading state synchronously
|
||||
this.handleUpdate(resolvedKey, {
|
||||
isLoading: true,
|
||||
isValidating: true
|
||||
})
|
||||
}
|
||||
|
||||
// Trigger initial revalidation asynchronously, but and check for deduplication
|
||||
setTimeout(() => {
|
||||
// Only revalidate if not already validating/loading (deduplication)
|
||||
const currentEntry = this.get(resolvedKey)
|
||||
if (currentEntry?.isLoading && !this.fetchManager.isFetching(resolvedKey)) {
|
||||
this.revalidate(resolvedKey, {
|
||||
fetcher,
|
||||
dedupe: true
|
||||
})
|
||||
} else if (!currentEntry) {
|
||||
this.revalidate(resolvedKey, {
|
||||
fetcher,
|
||||
dedupe: true
|
||||
})
|
||||
}
|
||||
}, 0)
|
||||
} else {
|
||||
// If data exists, revalidate if revalidateOnMount is not false
|
||||
if (config.revalidateOnMount !== false) {
|
||||
// Use Promise.resolve().then() for consistency with SWR behavior,
|
||||
// but skip if a fetch is already in flight to avoid duplicate requests
|
||||
// (e.g. when a suspense-mode consumer has already kicked off a fetch).
|
||||
Promise.resolve().then(() => {
|
||||
if (!this.fetchManager.isFetching(resolvedKey)) {
|
||||
this.revalidate(resolvedKey, { fetcher, dedupe: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
unsubscribe()
|
||||
this.activeKeys.delete(serializedKey)
|
||||
this.revalidationManager.clear(resolvedKey)
|
||||
this.revalidationManager.removeFetcher(resolvedKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to state changes for a key WITHOUT triggering any fetch.
|
||||
* Used by consumers (e.g. React hooks in suspense mode) that manage
|
||||
* fetching themselves but still need notifications when data updates.
|
||||
*/
|
||||
addEventListener(key: Key, callback: Callback<Data, Error>): Unsubscribe {
|
||||
const resolvedKey = resolveKey(key)
|
||||
if (!resolvedKey) {
|
||||
return () => {}
|
||||
}
|
||||
return this.eventManager.subscribe(resolvedKey, callback as Callback<unknown, unknown>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the loading+validating state for a key without fetching.
|
||||
*/
|
||||
setLoading(key: Key): void {
|
||||
const resolvedKey = resolveKey(key)
|
||||
if (!resolvedKey) return
|
||||
this.handleUpdate(resolvedKey, {
|
||||
isLoading: true,
|
||||
isValidating: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate data for a key
|
||||
*/
|
||||
async mutate(
|
||||
key: Key,
|
||||
data?: MutatorValue<Data>,
|
||||
options?: MutatorOptions<Data>
|
||||
): Promise<Data | undefined> {
|
||||
const resolvedKey = resolveKey(key)
|
||||
if (!resolvedKey) return undefined
|
||||
|
||||
return this.mutationManager.mutate(resolvedKey, data, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalidate data for a key
|
||||
*/
|
||||
async revalidate(key: Key, options?: RevalidateOptions): Promise<boolean> {
|
||||
const resolvedKey = resolveKey(key)
|
||||
if (!resolvedKey) return false
|
||||
|
||||
return this.revalidationManager.revalidate(resolvedKey, options as RevalidateOptions<Data>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cache entry for a key
|
||||
*/
|
||||
delete(key: Key): void {
|
||||
const resolvedKey = resolveKey(key)
|
||||
if (!resolvedKey) return
|
||||
|
||||
const serializedKey = serializeKey(resolvedKey)
|
||||
|
||||
this.cache.delete(resolvedKey)
|
||||
this.revalidationManager.clear(resolvedKey)
|
||||
this.eventManager.clear(resolvedKey)
|
||||
this.activeKeys.delete(serializedKey)
|
||||
|
||||
// Emit delete event
|
||||
this.eventManager.emit(resolvedKey, undefined, undefined, false, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache and state
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
this.revalidationManager.clearAll()
|
||||
this.eventManager.clear()
|
||||
this.fetchManager.abortAll()
|
||||
this.mutationManager.clearOptimisticData()
|
||||
this.activeKeys.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(config: Partial<SWRConfig<Data, Error>>): void {
|
||||
this.configManager.update(config)
|
||||
|
||||
// Update managers with new config
|
||||
this.revalidationManager.updateConfig(this.configManager.get())
|
||||
this.mutationManager.updateConfig(this.configManager.get())
|
||||
|
||||
// Restart interval revalidation for active keys
|
||||
for (const serializedKey of this.activeKeys) {
|
||||
try {
|
||||
const key = JSON.parse(serializedKey) as Key
|
||||
this.revalidationManager.startIntervalRevalidation(key)
|
||||
} catch {
|
||||
// Skip invalid keys
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): SWRConfig<Data, Error> {
|
||||
return this.configManager.get()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getCacheStats(): {
|
||||
size: number
|
||||
keys: Key[]
|
||||
activeKeys: number
|
||||
} {
|
||||
return {
|
||||
size: this.cache.size(),
|
||||
keys: this.cache.keys(),
|
||||
activeKeys: this.activeKeys.size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance statistics
|
||||
*/
|
||||
getPerformanceStats(): {
|
||||
activeRequests: number
|
||||
pendingRequests: number
|
||||
revalidations: unknown
|
||||
} {
|
||||
return {
|
||||
activeRequests: this.fetchManager.getActiveCount(),
|
||||
pendingRequests: this.fetchManager.getPendingCount(),
|
||||
revalidations: this.revalidationManager.getStats()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cache updates and emit events
|
||||
*/
|
||||
private handleUpdate(key: Key, update: Partial<CacheEntry<Data, Error>>): void {
|
||||
const currentEntry = this.cache.get(key)
|
||||
const updatedEntry: CacheEntry<Data, Error> = {
|
||||
data: currentEntry?.data,
|
||||
error: currentEntry?.error,
|
||||
isValidating: currentEntry?.isValidating || false,
|
||||
isLoading: currentEntry?.isLoading || false,
|
||||
lastModified: currentEntry?.lastModified || Date.now(),
|
||||
...update
|
||||
}
|
||||
|
||||
// Update cache
|
||||
this.cache.set(key, updatedEntry)
|
||||
|
||||
// Start/stop interval revalidation based on data availability
|
||||
if (updatedEntry.data !== undefined) {
|
||||
this.revalidationManager.startIntervalRevalidation(key)
|
||||
} else {
|
||||
this.revalidationManager.stopIntervalRevalidation(key)
|
||||
}
|
||||
|
||||
// Emit state change
|
||||
this.eventManager.emit(
|
||||
key,
|
||||
updatedEntry.data,
|
||||
updatedEntry.error,
|
||||
updatedEntry.isValidating,
|
||||
updatedEntry.isLoading
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch initial data for a key
|
||||
*/
|
||||
private async fetchInitialData(key: Key, fetcher?: Fetcher<Data>): Promise<void> {
|
||||
const config = this.configManager.get()
|
||||
const activeFetcher = fetcher || config.fetcher
|
||||
|
||||
if (!activeFetcher) return
|
||||
|
||||
try {
|
||||
// Set loading state
|
||||
this.handleUpdate(key, { isLoading: true, isValidating: true })
|
||||
|
||||
const data = await this.fetchManager.fetch(key, activeFetcher, {
|
||||
dedupe: true,
|
||||
timeout: config.loadingTimeout
|
||||
})
|
||||
|
||||
// Update with data
|
||||
this.handleUpdate(key, {
|
||||
data,
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
isValidating: false
|
||||
})
|
||||
|
||||
config.onSuccess?.(data, key)
|
||||
} catch (error) {
|
||||
const errorObj = error as Error
|
||||
|
||||
// Update with error
|
||||
this.handleUpdate(key, {
|
||||
error: errorObj,
|
||||
isLoading: false,
|
||||
isValidating: false
|
||||
})
|
||||
|
||||
config.onError?.(errorObj, key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup global event handlers
|
||||
*/
|
||||
private setupGlobalHandlers(): void {
|
||||
// This will be handled by the RevalidationManager
|
||||
// which has its own global event setup
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
destroy(): void {
|
||||
this.clear()
|
||||
this.cache.destroy?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new SWR instance
|
||||
*/
|
||||
export function createSWR<Data = unknown, Error = unknown>(
|
||||
config?: Partial<SWRConfig<Data, Error>>
|
||||
): SWRCore<Data, Error> {
|
||||
return new SWRCoreImpl<Data, Error>(config)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Core types for the SWR implementation
|
||||
*/
|
||||
|
||||
export type Key = string | string[] | Record<string, unknown> | null | undefined
|
||||
|
||||
export interface CacheEntry<Data = unknown, Error = unknown> {
|
||||
data?: Data
|
||||
error?: Error
|
||||
isValidating: boolean
|
||||
isLoading: boolean
|
||||
lastModified: number
|
||||
ttl?: number
|
||||
}
|
||||
|
||||
export interface CacheOptions {
|
||||
ttl?: number
|
||||
revalidateOnMount?: boolean
|
||||
revalidateOnFocus?: boolean
|
||||
revalidateOnReconnect?: boolean
|
||||
refreshInterval?: number
|
||||
dedupingInterval?: number
|
||||
errorRetryCount?: number
|
||||
errorRetryInterval?: number
|
||||
loadingTimeout?: number
|
||||
}
|
||||
|
||||
export interface FetcherOptions {
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
export type Fetcher<Data = unknown> = (key: Key, options: FetcherOptions) => Promise<Data>
|
||||
|
||||
// Support for simpler fetcher signatures like SWR
|
||||
type FetcherResponse<Data = unknown> = Data | Promise<Data>;
|
||||
export type BareFetcher<Data = unknown> = (...args: any[]) => FetcherResponse<Data>;
|
||||
|
||||
export type SWRFetcher<Data = unknown> = BareFetcher<Data> | Fetcher<Data>
|
||||
|
||||
export interface MutatorOptions<Data = unknown> {
|
||||
revalidate?: boolean
|
||||
optimisticData?: Data
|
||||
populateCache?: boolean
|
||||
rollbackOnError?: boolean
|
||||
}
|
||||
|
||||
export type MutatorCallback<Data = unknown> = (currentData?: Data) => Data | Promise<Data>
|
||||
|
||||
export type MutatorValue<Data = unknown> = Data | MutatorCallback<Data> | undefined
|
||||
|
||||
export interface RevalidateOptions<Data = unknown> {
|
||||
dedupe?: boolean
|
||||
retryCount?: number
|
||||
fetcher?: Fetcher<Data>
|
||||
}
|
||||
|
||||
export type Callback<Data = unknown, Error = unknown> = (
|
||||
data?: Data,
|
||||
error?: Error,
|
||||
isValidating?: boolean,
|
||||
isLoading?: boolean
|
||||
) => void
|
||||
|
||||
export type Unsubscribe = () => void
|
||||
|
||||
export interface SWRConfig<Data = unknown, Error = unknown> extends CacheOptions {
|
||||
fetcher?: Fetcher<Data>
|
||||
onSuccess?: (data: Data, key: Key) => void
|
||||
onError?: (error: Error, key: Key) => void
|
||||
onErrorRetry?: (error: Error, key: Key, config: SWRConfig<Data, Error>, revalidate: () => void, options: { retryCount: number }) => void
|
||||
onLoadingSlow?: (key: Key, config: SWRConfig<Data, Error>) => void
|
||||
isPaused?: () => boolean
|
||||
compare?: (a: Data | undefined, b: Data | undefined) => boolean
|
||||
fallback?: Record<string, Data>
|
||||
}
|
||||
|
||||
export interface Cache<Data = unknown, Error = unknown> {
|
||||
get(key: Key): CacheEntry<Data, Error> | undefined
|
||||
set(key: Key, value: CacheEntry<Data, Error>): void
|
||||
delete(key: Key): void
|
||||
clear(): void
|
||||
keys(): Key[]
|
||||
has(key: Key): boolean
|
||||
}
|
||||
|
||||
export interface EventManager {
|
||||
on(event: string, callback: Callback<unknown, unknown>): Unsubscribe
|
||||
emit(event: string, ...args: unknown[]): void
|
||||
off(event: string, callback: Callback<unknown, unknown>): void
|
||||
}
|
||||
|
||||
export interface SWRCore<Data = unknown, Error = unknown> {
|
||||
get(key: Key): CacheEntry<Data, Error> | undefined
|
||||
subscribe(key: Key, callback: Callback<Data, Error>, fetcher?: Fetcher<Data>): Unsubscribe
|
||||
/**
|
||||
* Subscribe to state changes for a key WITHOUT triggering any fetch.
|
||||
* Useful for consumers (like React hooks) that manage fetching themselves
|
||||
* but still want to be notified when cached data updates.
|
||||
*/
|
||||
addEventListener(key: Key, callback: Callback<Data, Error>): Unsubscribe
|
||||
/**
|
||||
* Set the loading+validating state for a key without fetching.
|
||||
* Useful for consumers that want to surface loading state immediately
|
||||
* before initiating their own fetch.
|
||||
*/
|
||||
setLoading(key: Key): void
|
||||
mutate(key: Key, data?: MutatorValue<Data>, options?: MutatorOptions<Data>): Promise<Data | undefined>
|
||||
revalidate(key: Key, options?: RevalidateOptions<Data>): Promise<boolean>
|
||||
delete(key: Key): void
|
||||
clear(): void
|
||||
updateConfig(config: Partial<SWRConfig<Data, Error>>): void
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES2022"
|
||||
],
|
||||
"target": "ES2018",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "preserve",
|
||||
"allowJs": true,
|
||||
"types": [
|
||||
"bun"
|
||||
],
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "bun:test";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useSWR } from "../src/index";
|
||||
|
||||
const fetcher = vi.fn((key: string) => Promise.resolve(`data-${key}`));
|
||||
|
||||
describe("useSWR (React)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetcher.mockClear();
|
||||
});
|
||||
|
||||
|
||||
it("fetches data on mount", async () => {
|
||||
const { result } = renderHook(() => useSWR("test-key", fetcher));
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.data).toBe("data-test-key");
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("deduplicates requests", async () => {
|
||||
const { result: r1 } = renderHook(() => useSWR("dedupe-key", fetcher));
|
||||
const { result: r2 } = renderHook(() => useSWR("dedupe-key", fetcher));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(r1.current.data).toBe("data-dedupe-key");
|
||||
expect(r2.current.data).toBe("data-dedupe-key");
|
||||
});
|
||||
|
||||
it("mutates data manually", async () => {
|
||||
const { result } = renderHook(() => useSWR("mutate-key", fetcher));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.data).toBe("data-mutate-key");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutate("new-data");
|
||||
await vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.data).toBe("new-data");
|
||||
});
|
||||
|
||||
// it("handles errors", async () => {
|
||||
// const error = new Error("failed");
|
||||
// fetcher.mockImplementationOnce(() => Promise.reject(error));
|
||||
|
||||
// const { result } = renderHook(() => useSWR("error-key", fetcher));
|
||||
|
||||
// await act(async () => {
|
||||
// await vi.runAllTimers();
|
||||
// });
|
||||
|
||||
// expect(result.current.error).toBe(error);
|
||||
// expect(result.current.isLoading).toBe(false);
|
||||
// });
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./happydom.ts", "./testing-library.ts"]
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||
|
||||
GlobalRegistrator.register();
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import { TestingLibraryMatchers } from "@testing-library/jest-dom/matchers";
|
||||
import { Matchers, AsymmetricMatchers } from "bun:test";
|
||||
|
||||
declare module "bun:test" {
|
||||
interface Matchers<T> extends TestingLibraryMatchers<typeof expect.stringContaining, void> {}
|
||||
interface AsymmetricMatchers extends TestingLibraryMatchers<any, any> {}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@supwr/react",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"module": "./dist/index.js",
|
||||
"main": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bunup"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.4.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@happy-dom/global-registrator": "^20.12.0",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"bunup": "^0.16.32"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supwr/core": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Main exports
|
||||
// React SWR hooks
|
||||
export { default as useSWR, SWRConfig, useSWRMutate, useSWRInstance, useSWRPrefetch } from './use-swr'
|
||||
export { default as useSWRInfinite } from './use-swr-infinite'
|
||||
|
||||
export type { SWRResponse, SWRHook } from './types'
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BareFetcher, Key, SWRConfig } from "@supwr/core";
|
||||
import { SWRHook, SWRResponse } from "./types";
|
||||
|
||||
|
||||
export type Middleware = (useSWRNext: SWRHook) => <Data = any, Error = any>(key: Key, fetcher: BareFetcher<Data> | null, config: SWRConfig<Key, Error>) => SWRResponse<Data, Error>;
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SWRConfig as CoreSWRConfig, MutatorOptions} from '@supwr/core'
|
||||
import { Middleware } from './middleware';
|
||||
|
||||
export interface SWRConfig<Data = unknown, Error = unknown> extends CoreSWRConfig<Data, Error> {
|
||||
// React-specific config
|
||||
suspense?: boolean
|
||||
fallbackData?: Data
|
||||
revalidateOnMount?: boolean
|
||||
use?: Middleware[]
|
||||
revalidateOnFocus?: boolean
|
||||
revalidateOnReconnect?: boolean
|
||||
refreshWhenHidden?: boolean
|
||||
refreshWhenOffline?: boolean
|
||||
compare?: (a: Data | undefined, b: Data | undefined) => boolean
|
||||
}
|
||||
|
||||
export interface SWRResponse<Data = unknown, Error = unknown> {
|
||||
data: Data | undefined
|
||||
error: Error | undefined
|
||||
isLoading: boolean
|
||||
isValidating: boolean
|
||||
mutate: (data?: Data | Promise<Data> | ((currentData?: Data) => Data | Promise<Data>), options?: MutatorOptions<Data>) => Promise<Data | undefined>
|
||||
}
|
||||
|
||||
export interface SWRHook<Data = unknown, Error = unknown> extends SWRResponse<Data, Error> {
|
||||
// Additional hook-specific properties can be added here
|
||||
}
|
||||
|
||||
// Infinite loading types
|
||||
export interface SWRInfiniteKeyLoader<Data = any, Args extends any[] = any[]> {
|
||||
(index: number, previousPageData: Data | null): Args | null | undefined
|
||||
}
|
||||
|
||||
export interface SWRInfiniteConfig<Data = unknown, Error = unknown> extends Omit<SWRConfig<Data[], Error>, 'compare'> {
|
||||
initialSize?: number
|
||||
revalidateAll?: boolean
|
||||
persistSize?: boolean
|
||||
revalidateFirstPage?: boolean
|
||||
parallel?: boolean
|
||||
compare?: (a: Data[] | undefined, b: Data[] | undefined) => boolean
|
||||
}
|
||||
|
||||
export interface SWRInfiniteResponse<Data = unknown, Error = unknown> {
|
||||
data: Data[] | undefined
|
||||
error: Error | undefined
|
||||
isLoading: boolean
|
||||
isValidating: boolean
|
||||
loading: boolean
|
||||
validating: boolean
|
||||
size: number
|
||||
setSize: (size: number | ((size: number) => number)) => Promise<Data[][]>
|
||||
mutate: (data?: Data[] | Promise<Data[]> | ((currentData?: Data[]) => Data[] | Promise<Data[]>), options?: MutatorOptions<Data[]>) => Promise<Data[] | undefined>
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useCallback, useDebugValue, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import { createSWR } from '@supwr/core'
|
||||
import type {
|
||||
SWRInfiniteKeyLoader,
|
||||
SWRInfiniteConfig,
|
||||
SWRInfiniteResponse,
|
||||
} from './types'
|
||||
|
||||
import {
|
||||
Key,
|
||||
Fetcher,
|
||||
SWRFetcher,
|
||||
BareFetcher
|
||||
} from "@supwr/core"
|
||||
|
||||
|
||||
// Global SWR instance for infinite loading
|
||||
const globalSWR = createSWR()
|
||||
|
||||
/**
|
||||
* useSWRInfinite hook for infinite loading
|
||||
*/
|
||||
export function useSWRInfinite<Data = any, Error = any>(
|
||||
getKey: SWRInfiniteKeyLoader<Data> | null,
|
||||
fetcher?: SWRFetcher<Data> | null,
|
||||
config?: Partial<SWRInfiniteConfig<Data, Error>>
|
||||
): SWRInfiniteResponse<Data, Error> {
|
||||
const [size, setSize] = useState(config?.initialSize || 1)
|
||||
|
||||
// Generate all keys for current size
|
||||
const keys = useMemo(() => {
|
||||
if (!getKey) return []
|
||||
|
||||
const keys: Key[] = []
|
||||
for (let i = 0; i < size; i++) {
|
||||
const previousPageData = i === 0 ? null : (globalSWR.get(keys[i - 1])?.data as Data) || null
|
||||
const keyArgs = getKey(i, previousPageData)
|
||||
|
||||
if (keyArgs === null || keyArgs === undefined) {
|
||||
break
|
||||
}
|
||||
|
||||
keys.push(keyArgs)
|
||||
}
|
||||
return keys
|
||||
}, [getKey, size])
|
||||
|
||||
// Normalize fetcher
|
||||
const normalizedFetcher = useMemo(() => {
|
||||
if (!fetcher) return null
|
||||
|
||||
if (fetcher.length === 2) return fetcher as Fetcher<Data>
|
||||
|
||||
return (key: Key, options: any) => {
|
||||
if (Array.isArray(key)) {
|
||||
return (fetcher as BareFetcher<Data>)(...key)
|
||||
}
|
||||
return (fetcher as BareFetcher<Data>)(key)
|
||||
}
|
||||
}, [fetcher])
|
||||
|
||||
// Get data for all pages
|
||||
const data = useMemo(() => {
|
||||
if (keys.length === 0) return undefined
|
||||
|
||||
const pages: Data[] = []
|
||||
for (const key of keys) {
|
||||
const entry = globalSWR.get(key)
|
||||
if (entry?.data !== undefined) {
|
||||
pages.push(entry.data as Data)
|
||||
} else {
|
||||
// If any page doesn't have data, return undefined
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return pages
|
||||
}, [keys])
|
||||
|
||||
// Check loading states
|
||||
const isLoading = useMemo(() => {
|
||||
if (keys.length === 0) return false
|
||||
|
||||
// Check if first page is loading
|
||||
const firstEntry = globalSWR.get(keys[0])
|
||||
return firstEntry?.isLoading || false
|
||||
}, [keys])
|
||||
|
||||
const isValidating = useMemo(() => {
|
||||
if (keys.length === 0) return false
|
||||
|
||||
// Check if any page is validating
|
||||
for (const key of keys) {
|
||||
const entry = globalSWR.get(key)
|
||||
if (entry?.isValidating) return true
|
||||
}
|
||||
return false
|
||||
}, [keys])
|
||||
|
||||
// Get error from any page
|
||||
const error = useMemo(() => {
|
||||
if (keys.length === 0) return undefined
|
||||
|
||||
for (const key of keys) {
|
||||
const entry = globalSWR.get(key)
|
||||
if (entry?.error) return entry.error
|
||||
}
|
||||
return undefined
|
||||
}, [keys])
|
||||
|
||||
// Subscribe to all key changes
|
||||
const subscribe = useCallback((callback: () => void) => {
|
||||
if (keys.length === 0) return () => { }
|
||||
|
||||
const unsubscribes = keys.map(key =>
|
||||
globalSWR.subscribe(key, callback)
|
||||
)
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach(unsubscribe => unsubscribe())
|
||||
}
|
||||
}, [keys])
|
||||
|
||||
const getSnapshot = useCallback(() => ({
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
isValidating
|
||||
}), [data, error, isLoading, isValidating])
|
||||
|
||||
// Use sync external store
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
|
||||
// Create mutate function for infinite data
|
||||
const mutate = useCallback(
|
||||
async (mutateData?: any, options?: any) => {
|
||||
if (keys.length === 0) return undefined
|
||||
|
||||
if (mutateData === undefined) {
|
||||
// Revalidate all pages
|
||||
const promises = keys.map(key => globalSWR.revalidate(key, options))
|
||||
return Promise.all(promises) as any
|
||||
}
|
||||
|
||||
// Mutate specific page or all pages
|
||||
if (typeof mutateData === 'function') {
|
||||
// Apply function to all pages
|
||||
const currentData = data || []
|
||||
const newData = await Promise.all(currentData.map((page, index) =>
|
||||
(mutateData as any)(page, index)
|
||||
))
|
||||
|
||||
// Update each page
|
||||
const promises = keys.map((key, index) =>
|
||||
globalSWR.mutate(key, newData[index], options)
|
||||
)
|
||||
const results = await Promise.all(promises)
|
||||
return results[0] as Promise<Data>
|
||||
} else if (Array.isArray(mutateData)) {
|
||||
// Update specific pages
|
||||
const promises = mutateData.map((pageData, index) =>
|
||||
keys[index] ? globalSWR.mutate(keys[index], pageData, options) : Promise.resolve(undefined)
|
||||
)
|
||||
const results = await Promise.all(promises)
|
||||
return results[0] as Promise<Data>
|
||||
} else {
|
||||
// Update first page
|
||||
return globalSWR.mutate(keys[0], mutateData, options)
|
||||
}
|
||||
},
|
||||
[keys, data]
|
||||
)
|
||||
|
||||
// Create setSize function
|
||||
const setSizeFunction = useCallback(
|
||||
(newSize: number | ((size: number) => number)) => {
|
||||
const targetSize = typeof newSize === 'function' ? newSize(size) : newSize
|
||||
|
||||
if (targetSize !== size) {
|
||||
setSize(targetSize)
|
||||
}
|
||||
|
||||
// Return promise that resolves when new pages are loaded
|
||||
return new Promise<Data[][]>((resolve) => {
|
||||
// This will resolve when the data is updated
|
||||
setTimeout(() => {
|
||||
if (!getKey) {
|
||||
resolve([])
|
||||
return
|
||||
}
|
||||
|
||||
const pages: Data[] = []
|
||||
for (let i = 0; i < targetSize; i++) {
|
||||
const previousPageData = i === 0 ? null : (globalSWR.get(keys[i - 1])?.data as Data) || null
|
||||
const keyArgs = getKey(i, previousPageData)
|
||||
|
||||
if (keyArgs === null || keyArgs === undefined) break
|
||||
|
||||
const entry = globalSWR.get(keyArgs)
|
||||
if (entry?.data) {
|
||||
pages.push(entry.data as Data)
|
||||
}
|
||||
}
|
||||
resolve([pages])
|
||||
}, 0)
|
||||
})
|
||||
},
|
||||
[size, getKey, keys]
|
||||
)
|
||||
|
||||
// Debug value
|
||||
useDebugValue(data ? `${data.length} pages` : 'loading')
|
||||
|
||||
return {
|
||||
...state,
|
||||
loading: state.isLoading,
|
||||
validating: state.isValidating,
|
||||
error: state.error as Error,
|
||||
size,
|
||||
setSize: setSizeFunction,
|
||||
mutate
|
||||
}
|
||||
}
|
||||
|
||||
export default useSWRInfinite
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useCallback, useRef, useDebugValue, useMemo, useEffect, useContext, createContext, ReactNode, useSyncExternalStore } from 'react'
|
||||
import { createSWR } from '@supwr/core'
|
||||
import type { Key, Fetcher, SWRFetcher, BareFetcher, SWRCore } from '@supwr/core'
|
||||
import { SWRResponse, SWRConfig as SWRConfiguration } from './types';
|
||||
|
||||
const globalSWR = createSWR()
|
||||
const ConfigContext = createContext<Partial<SWRConfiguration<any, any>>>({})
|
||||
|
||||
export function useSWR<Data = any, Error = any>(
|
||||
key: Key | null,
|
||||
fetcher?: SWRFetcher<Data> | null,
|
||||
config?: Partial<SWRConfiguration<Data, Error>>
|
||||
): SWRResponse<Data, Error> {
|
||||
|
||||
const contextConfig = useContext(ConfigContext)
|
||||
const mergedConfig = useMemo(() => ({
|
||||
...contextConfig,
|
||||
...config
|
||||
}), [contextConfig, config])
|
||||
|
||||
const refreshInterval = mergedConfig?.refreshInterval
|
||||
const swrRef = useRef(globalSWR)
|
||||
|
||||
useEffect(() => {
|
||||
if (mergedConfig) {
|
||||
const { refreshInterval: _, ...configWithoutRefresh } = mergedConfig
|
||||
swrRef.current.updateConfig(configWithoutRefresh as SWRConfig)
|
||||
}
|
||||
}, [mergedConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (!key || !refreshInterval || refreshInterval <= 0) return
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
swrRef.current.revalidate(key, {
|
||||
fetcher: fetcherRef.current as any,
|
||||
dedupe: true
|
||||
})
|
||||
}, refreshInterval)
|
||||
|
||||
return () => clearInterval(intervalId)
|
||||
}, [key, refreshInterval])
|
||||
|
||||
const normalizedFetcher = useMemo(() => {
|
||||
if (!fetcher) return null
|
||||
|
||||
if (fetcher.length === 2) return fetcher as Fetcher<Data>
|
||||
|
||||
return (key: Key, options: any) => {
|
||||
if (Array.isArray(key)) {
|
||||
return (fetcher as BareFetcher<Data>)(...key)
|
||||
}
|
||||
return (fetcher as BareFetcher<Data>)(key)
|
||||
}
|
||||
}, [fetcher])
|
||||
|
||||
const fetcherRef = useRef(normalizedFetcher)
|
||||
useEffect(() => {
|
||||
fetcherRef.current = normalizedFetcher
|
||||
}, [normalizedFetcher])
|
||||
|
||||
const subscribe = useCallback((callback: () => void) => {
|
||||
if (!key) return () => { }
|
||||
return swrRef.current.addEventListener(key, () => {
|
||||
callback()
|
||||
})
|
||||
}, [key])
|
||||
|
||||
const lastStateRef = useRef<any>(null)
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
let nextState: any;
|
||||
|
||||
if (!key || !fetcherRef.current) {
|
||||
nextState = {
|
||||
data: mergedConfig?.fallbackData,
|
||||
error: undefined,
|
||||
isValidating: false,
|
||||
isLoading: false
|
||||
}
|
||||
} else {
|
||||
const entry = swrRef.current.get(key)
|
||||
const data = entry?.data ?? mergedConfig?.fallbackData
|
||||
|
||||
nextState = {
|
||||
data,
|
||||
error: entry?.error,
|
||||
isValidating: entry?.isValidating || false,
|
||||
isLoading: entry?.isLoading || false
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
lastStateRef.current &&
|
||||
lastStateRef.current.data === nextState.data &&
|
||||
lastStateRef.current.error === nextState.error &&
|
||||
lastStateRef.current.isValidating === nextState.isValidating &&
|
||||
lastStateRef.current.isLoading === nextState.isLoading
|
||||
) {
|
||||
return lastStateRef.current
|
||||
}
|
||||
|
||||
lastStateRef.current = nextState
|
||||
return nextState
|
||||
}, [key, mergedConfig?.fallbackData])
|
||||
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
|
||||
const mutate = useCallback(
|
||||
(data?: any, options?: any) => {
|
||||
if (!key) return Promise.resolve(undefined)
|
||||
return swrRef.current.mutate(key, data, options)
|
||||
},
|
||||
[key]
|
||||
)
|
||||
|
||||
const suspense = mergedConfig?.suspense
|
||||
const hasData = state.data !== undefined
|
||||
const hasError = state.error !== undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (!key || !fetcherRef.current || suspense) return
|
||||
|
||||
const entry = swrRef.current.get(key)
|
||||
const revalidateOnMount = mergedConfig?.revalidateOnMount !== false
|
||||
|
||||
if (!entry && revalidateOnMount) {
|
||||
swrRef.current.setLoading(key)
|
||||
swrRef.current.revalidate(key, {
|
||||
fetcher: fetcherRef.current as any,
|
||||
dedupe: true
|
||||
})
|
||||
} else if (entry && revalidateOnMount && !entry.data) {
|
||||
swrRef.current.revalidate(key, {
|
||||
fetcher: fetcherRef.current as any,
|
||||
dedupe: true
|
||||
})
|
||||
}
|
||||
}, [key, suspense, mergedConfig?.revalidateOnMount])
|
||||
|
||||
if (suspense) {
|
||||
if (hasError) {
|
||||
throw state.error
|
||||
}
|
||||
if (!hasData) {
|
||||
const promise = swrRef.current.revalidate(key, {
|
||||
fetcher: fetcherRef.current as any,
|
||||
dedupe: true
|
||||
}).then(() => true)
|
||||
|
||||
;(promise as any).catch(() => {})
|
||||
throw promise
|
||||
}
|
||||
}
|
||||
|
||||
useDebugValue(state.data ? `data: ${JSON.stringify(state.data)}` : 'loading')
|
||||
|
||||
return {
|
||||
...state,
|
||||
loading: state.isLoading,
|
||||
validating: state.isValidating,
|
||||
mutate
|
||||
}
|
||||
}
|
||||
|
||||
export function SWRConfig<Data = any, Error = any>({
|
||||
children,
|
||||
value
|
||||
}: {
|
||||
children: ReactNode
|
||||
value: Partial<SWRConfiguration<Data, Error>>
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<ConfigContext.Provider value={value}>
|
||||
{children}
|
||||
</ConfigContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type SWRConfig<Data = any, Error = any> = SWRConfiguration<Data, Error>
|
||||
|
||||
export function useSWRMutate<Data = any, Error = any>() {
|
||||
return useCallback(
|
||||
(key: Key, data?: any, options?: any) => {
|
||||
return globalSWR.mutate(key, data, options)
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
export function useSWRInstance(): SWRCore<unknown, unknown> {
|
||||
return globalSWR
|
||||
}
|
||||
|
||||
export function useSWRPrefetch<Data = any, Error = any>() {
|
||||
return useCallback(
|
||||
(key: Key, fetcher: SWRFetcher<Data>) => globalSWR.revalidate(key, { dedupe: true }), []
|
||||
)
|
||||
}
|
||||
|
||||
export default useSWR
|
||||
@@ -0,0 +1,10 @@
|
||||
import { afterEach, expect } from "bun:test";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import * as matchers from "@testing-library/jest-dom/matchers";
|
||||
|
||||
expect.extend(matchers);
|
||||
|
||||
// Optional: cleans up `render` after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES2022"
|
||||
],
|
||||
"target": "ES2018",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"types": [
|
||||
"bun"
|
||||
],
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// import { describe, expect, it, vi, beforeEach, afterEach } from "bun:test";
|
||||
// import { createRoot } from "solid-js";
|
||||
// import { useSWR } from "../index";
|
||||
|
||||
// // Mock fetcher
|
||||
// const fetcher = vi.fn((key: string) => Promise.resolve(`data-${key}`));
|
||||
|
||||
// describe("useSWR (Solid)", () => {
|
||||
// beforeEach(() => {
|
||||
// vi.useFakeTimers();
|
||||
// fetcher.mockClear();
|
||||
// });
|
||||
|
||||
// afterEach(() => {
|
||||
// vi.useRealTimers();
|
||||
// });
|
||||
|
||||
// it("fetches data on mount", async () => {
|
||||
// createRoot((dispose) => {
|
||||
// const result = useSWR(async () => "test-key", fetcher);
|
||||
|
||||
// // Initial state
|
||||
// expect(result.isLoading).toBe(true);
|
||||
|
||||
// // Wait for fetch
|
||||
// vi.runAllTimers();
|
||||
|
||||
// expect(result.data).toBe("data-test-key");
|
||||
// expect(result.isLoading).toBe(false);
|
||||
|
||||
// dispose();
|
||||
// });
|
||||
// });
|
||||
|
||||
// it("deduplicates requests", async () => {
|
||||
// createRoot((dispose) => {
|
||||
// const r1 = useSWR(() => "dedupe-key", fetcher);
|
||||
// const r2 = useSWR(() => "dedupe-key", fetcher);
|
||||
|
||||
// vi.runAllTimers();
|
||||
|
||||
// expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
// expect(r1.data).toBe("data-dedupe-key");
|
||||
// expect(r2.data).toBe("data-dedupe-key");
|
||||
|
||||
// dispose();
|
||||
// });
|
||||
// });
|
||||
|
||||
// it("mutates data manually", async () => {
|
||||
// await new Promise<void>((resolve) => {
|
||||
// createRoot(async (dispose) => {
|
||||
// const result = useSWR(() => "mutate-key", fetcher);
|
||||
|
||||
// vi.runAllTimers();
|
||||
|
||||
// expect(result.data).toBe("data-mutate-key");
|
||||
|
||||
// await result.mutate("new-data");
|
||||
|
||||
// expect(result.data).toBe("new-data");
|
||||
|
||||
// dispose();
|
||||
// resolve();
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
@@ -0,0 +1,5 @@
|
||||
// Solid SWR exports
|
||||
export { default as useSWR, SWRConfig, useSWRMutate, useSWRInstance, useSWRPrefetch } from './use-swr'
|
||||
|
||||
// Re-export core types
|
||||
export type { Key, Fetcher } from '../../core'
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
createSignal,
|
||||
createEffect,
|
||||
onCleanup,
|
||||
batch
|
||||
} from 'solid-js'
|
||||
import { createSWR } from '../../core'
|
||||
import type { SWRSignals, SWRResponse, Key, Fetcher, MutatorOptions } from './types'
|
||||
|
||||
// Global SWR instance
|
||||
const globalSWR = createSWR()
|
||||
|
||||
/**
|
||||
* Create SWR signals for Solid
|
||||
*/
|
||||
export function createSWRSignals<Data = unknown, Error = unknown>(
|
||||
key: Key | null,
|
||||
fetcher?: Fetcher<Data>,
|
||||
config?: Partial<import('./types').SWRConfig<Data, Error>>
|
||||
): SWRSignals<Data, Error> {
|
||||
// Update config if provided
|
||||
if (config) {
|
||||
globalSWR.updateConfig(config)
|
||||
}
|
||||
|
||||
// Initial state logic
|
||||
let initialData: Data | undefined = undefined
|
||||
let initialError: Error | undefined = undefined
|
||||
let initialIsLoading = false
|
||||
let initialIsValidating = false
|
||||
|
||||
if (key) {
|
||||
const entry = globalSWR.get(key)
|
||||
if (entry) {
|
||||
initialData = entry.data as Data
|
||||
initialError = entry.error as Error
|
||||
initialIsLoading = entry.isLoading
|
||||
initialIsValidating = entry.isValidating
|
||||
} else {
|
||||
initialData = config?.fallbackData
|
||||
}
|
||||
}
|
||||
|
||||
// Create signals
|
||||
const [data, setData] = createSignal<Data | undefined>(initialData)
|
||||
const [error, setError] = createSignal<Error | undefined>(initialError)
|
||||
const [isLoading, setIsLoading] = createSignal<boolean>(initialIsLoading)
|
||||
const [isValidating, setIsValidating] = createSignal<boolean>(initialIsValidating)
|
||||
|
||||
// Subscribe to SWR changes
|
||||
let unsubscribe: (() => void) | undefined
|
||||
|
||||
if (key) {
|
||||
unsubscribe = globalSWR.subscribe(key, (newData, newError, newIsValidating, newIsLoading) => {
|
||||
batch(() => {
|
||||
setData(newData)
|
||||
setError(newError)
|
||||
setIsValidating(newIsValidating ?? false)
|
||||
setIsLoading(newIsLoading ?? false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Cleanup on destroy
|
||||
onCleanup(() => {
|
||||
unsubscribe?.()
|
||||
})
|
||||
|
||||
// Create mutate function
|
||||
const mutate = (mutateData?: Data | ((current: Data | undefined) => Data | Promise<Data>), options?: Partial<MutatorOptions<Data>>) => {
|
||||
if (!key) return Promise.resolve(undefined)
|
||||
return globalSWR.mutate(key, mutateData, options)
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
isValidating,
|
||||
loading: isLoading,
|
||||
validating: isValidating,
|
||||
mutate
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SWR response object for Solid
|
||||
*/
|
||||
export function createSWRResponse<Data = unknown, Error = unknown>(
|
||||
key: Key | null,
|
||||
fetcher?: Fetcher<Data>,
|
||||
config?: Partial<import('./types').SWRConfig<Data, Error>>
|
||||
): SWRResponse<Data, Error> {
|
||||
const signals = createSWRSignals<Data, Error>(key, fetcher, config)
|
||||
|
||||
return {
|
||||
get data() { return signals.data() },
|
||||
get error() { return signals.error() },
|
||||
get isLoading() { return signals.isLoading() },
|
||||
get isValidating() { return signals.isValidating() },
|
||||
get loading() { return signals.isLoading() },
|
||||
get validating() { return signals.isValidating() },
|
||||
mutate: signals.mutate
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook-like function returning array [data, { mutate, error, isLoading, isValidating }]
|
||||
*/
|
||||
export function useSWR<Data = unknown, Error = unknown>(
|
||||
key: Key | null,
|
||||
fetcher?: Fetcher<Data>,
|
||||
config?: Partial<import('./types').SWRConfig<Data, Error>>
|
||||
): [Data | undefined, SWRResponse<Data, Error>] {
|
||||
const signals = createSWRSignals<Data, Error>(key, fetcher, config)
|
||||
|
||||
const response: SWRResponse<Data, Error> = {
|
||||
get data() { return signals.data() },
|
||||
get error() { return signals.error() },
|
||||
get isLoading() { return signals.isLoading() },
|
||||
get isValidating() { return signals.isValidating() },
|
||||
get loading() { return signals.isLoading() },
|
||||
get validating() { return signals.isValidating() },
|
||||
mutate: signals.mutate
|
||||
}
|
||||
|
||||
return [signals.data(), response]
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual signal creators
|
||||
*/
|
||||
export function swrDataSignal<Data = unknown>(
|
||||
key: Key | null,
|
||||
fetcher?: Fetcher<Data>,
|
||||
config?: Partial<import('./types').SWRConfig<Data, Error>>
|
||||
) {
|
||||
return createSWRSignals<Data, unknown>(key, fetcher, config as any).data
|
||||
}
|
||||
|
||||
export function swrErrorSignal<Error = unknown>(
|
||||
key: Key | null,
|
||||
fetcher?: Fetcher<any>,
|
||||
config?: Partial<import('./types').SWRConfig<any, Error>>
|
||||
) {
|
||||
return createSWRSignals<any, Error>(key, fetcher, config).error
|
||||
}
|
||||
|
||||
export function swrLoadingSignal(
|
||||
key: Key | null,
|
||||
fetcher?: Fetcher<any>,
|
||||
config?: Partial<import('./types').SWRConfig<any, any>>
|
||||
) {
|
||||
return createSWRSignals<any, any>(key, fetcher, config).isLoading
|
||||
}
|
||||
|
||||
export function swrValidatingSignal(
|
||||
key: Key | null,
|
||||
fetcher?: Fetcher<any>,
|
||||
config?: Partial<import('./types').SWRConfig<any, any>>
|
||||
) {
|
||||
return createSWRSignals<any, any>(key, fetcher, config).isValidating
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for manual mutation
|
||||
*/
|
||||
export function useSWRMutate<Data = unknown, Error = unknown>() {
|
||||
return (key: Key, data?: Data | ((current: Data | undefined) => Data | Promise<Data>), options?: Partial<MutatorOptions<Data>>) => {
|
||||
return globalSWR.mutate(key, data, options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for accessing global SWR instance
|
||||
*/
|
||||
export function useSWRInstance() {
|
||||
return globalSWR
|
||||
}
|
||||
|
||||
export default createSWRSignals
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"types": ["solid-js", "bun"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"exclude": [
|
||||
"../react/**/*",
|
||||
"../../node_modules/@types/react"
|
||||
],
|
||||
"include": ["**/*", "../../core/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SWRConfig as CoreSWRConfig, Key, Fetcher, MutatorOptions, BareFetcher } from '../../core'
|
||||
import type { Accessor, Signal } from 'solid-js'
|
||||
|
||||
export interface SWRConfig<Data = unknown, Error = unknown> extends CoreSWRConfig<Data, Error> {
|
||||
// Solid-specific config
|
||||
signal?: boolean
|
||||
suspense?: boolean
|
||||
fallbackData?: Data
|
||||
revalidateOnMount?: boolean
|
||||
revalidateOnFocus?: boolean
|
||||
revalidateOnReconnect?: boolean
|
||||
refreshWhenHidden?: boolean
|
||||
refreshWhenOffline?: boolean
|
||||
compare?: (a: Data | undefined, b: Data | undefined) => boolean
|
||||
}
|
||||
|
||||
export interface SWRSignals<Data = unknown, Error = unknown> {
|
||||
data: Accessor<Data | undefined>
|
||||
error: Accessor<Error | undefined>
|
||||
isLoading: Accessor<boolean>
|
||||
isValidating: Accessor<boolean>
|
||||
loading: Accessor<boolean>
|
||||
validating: Accessor<boolean>
|
||||
mutate: (data?: Data | ((current: Data | undefined) => Data | Promise<Data>), options?: Partial<MutatorOptions<Data>>) => Promise<Data | undefined>
|
||||
}
|
||||
|
||||
export interface SWRResponse<Data = unknown, Error = unknown> {
|
||||
data: Data | undefined
|
||||
error: Error | undefined
|
||||
isLoading: boolean
|
||||
isValidating: boolean
|
||||
loading: boolean
|
||||
validating: boolean
|
||||
mutate: (data?: Data | ((current: Data | undefined) => Data | Promise<Data>), options?: Partial<MutatorOptions<Data>>) => Promise<Data | undefined>
|
||||
}
|
||||
|
||||
// Re-export core types with SWR-compatible names
|
||||
export type { Key, Fetcher, MutatorOptions, BareFetcher, SWRFetcher } from '../../core'
|
||||
@@ -0,0 +1,190 @@
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource solid-js */
|
||||
|
||||
import { createSignal, createEffect, onCleanup, Suspense, Show, createContext, useContext, createMemo } from 'solid-js'
|
||||
import { createSWR } from '../../core'
|
||||
import type { SWRConfig as SWRConfiguration, SWRResponse, Key, Fetcher, SWRFetcher } from './types'
|
||||
|
||||
// Global SWR instance
|
||||
const globalSWR = createSWR()
|
||||
|
||||
// Solid Context for configuration
|
||||
const ConfigContext = createContext<Partial<SWRConfiguration<any, any>>>({})
|
||||
|
||||
/**
|
||||
* Enhanced useSWR hook for Solid
|
||||
*/
|
||||
export function useSWR<Data = any, Error = any>(
|
||||
key: Key | null,
|
||||
fetcher?: SWRFetcher<Data> | null,
|
||||
config?: Partial<SWRConfiguration<Data, Error>>
|
||||
): SWRResponse<Data, Error> {
|
||||
// Get config from context and props
|
||||
const contextConfig = useContext(ConfigContext)
|
||||
const mergedConfig = createMemo(() => ({
|
||||
...contextConfig,
|
||||
...config
|
||||
}))
|
||||
|
||||
// Create a stable reference to SWR instance with config
|
||||
const swrRef = createMemo(() => globalSWR)
|
||||
|
||||
// Update config if changed
|
||||
createEffect(() => {
|
||||
if (mergedConfig) {
|
||||
swrRef().updateConfig(mergedConfig as any)
|
||||
}
|
||||
})
|
||||
|
||||
// Normalize fetcher to handle different signatures
|
||||
const normalizedFetcher = createMemo(() => {
|
||||
if (!fetcher) return null
|
||||
|
||||
// If it's already a core fetcher, use it as-is
|
||||
if (fetcher.length === 2) return fetcher
|
||||
|
||||
// Convert bare fetcher to core fetcher
|
||||
return (k: Key, options: any) => {
|
||||
if (Array.isArray(k)) {
|
||||
return (fetcher as any)(...k)
|
||||
}
|
||||
return (fetcher as any)(k)
|
||||
}
|
||||
})
|
||||
|
||||
// Create signals
|
||||
const [data, setData] = createSignal<Data | undefined>(
|
||||
key ? swrRef().get(key)?.data ?? mergedConfig()?.fallbackData : mergedConfig()?.fallbackData
|
||||
)
|
||||
const [error, setError] = createSignal<Error | undefined>(
|
||||
key ? swrRef().get(key)?.error as Error : undefined
|
||||
)
|
||||
const [isLoading, setIsLoading] = createSignal<boolean>(
|
||||
key ? swrRef().get(key)?.isLoading ?? false : false
|
||||
)
|
||||
const [isValidating, setIsValidating] = createSignal<boolean>(
|
||||
key ? swrRef().get(key)?.isValidating ?? false : false
|
||||
)
|
||||
|
||||
// Subscribe to SWR changes
|
||||
let unsubscribe: (() => void) | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (!key || !normalizedFetcher()) return
|
||||
|
||||
unsubscribe = swrRef().subscribe(key, (newData: any, newError: any, newIsValidating?: boolean, newIsLoading?: boolean) => {
|
||||
setData(newData)
|
||||
setError(newError)
|
||||
setIsValidating(newIsValidating ?? false)
|
||||
setIsLoading(newIsLoading ?? false)
|
||||
}, normalizedFetcher() as any)
|
||||
|
||||
return unsubscribe
|
||||
})
|
||||
|
||||
// Cleanup on destroy
|
||||
onCleanup(() => {
|
||||
unsubscribe?.()
|
||||
})
|
||||
|
||||
// Create mutate function
|
||||
const mutate = createMemo(() =>
|
||||
(mutateData?: Data | Promise<Data> | ((currentData?: Data) => Data | Promise<Data>), options?: any): Promise<Data | undefined> => {
|
||||
if (!key) return Promise.resolve(undefined)
|
||||
return swrRef().mutate(key, mutateData, options) as Promise<Data>
|
||||
}
|
||||
)
|
||||
|
||||
// Handle suspense mode
|
||||
if ((mergedConfig as any)?.suspense) {
|
||||
if (error()) {
|
||||
throw error()
|
||||
}
|
||||
if (data() === undefined) {
|
||||
// In suspense mode, we need to trigger fetch and throw a promise
|
||||
const promise = new Promise<Data>((resolve, reject) => {
|
||||
const unsubscribe = swrRef().subscribe(key, (data: any, error: any) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else if (data !== undefined) {
|
||||
resolve(data)
|
||||
}
|
||||
})
|
||||
|
||||
// Start the fetch if not already loading
|
||||
const entry = swrRef().get(key)
|
||||
if (!entry?.isLoading && !entry?.isValidating) {
|
||||
swrRef().revalidate(key)
|
||||
}
|
||||
|
||||
return unsubscribe
|
||||
})
|
||||
|
||||
throw promise
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get data() { return data() },
|
||||
get error() { return error() },
|
||||
get isLoading() { return isLoading() },
|
||||
get isValidating() { return isValidating() },
|
||||
get loading() { return isLoading() },
|
||||
get validating() { return isValidating() },
|
||||
mutate: mutate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Solid configuration provider component
|
||||
*/
|
||||
export function SWRConfig<Data = any, Error = any>(props: {
|
||||
children: any
|
||||
value?: Partial<SWRConfiguration<Data, Error>>
|
||||
}) {
|
||||
return (
|
||||
<ConfigContext.Provider value={props.value || {}}>
|
||||
{props.children}
|
||||
</ConfigContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface type for configuration
|
||||
*/
|
||||
export type SWRConfig<Data = any, Error = any> = SWRConfiguration<Data, Error>
|
||||
|
||||
/**
|
||||
* Hook for manual mutation
|
||||
*/
|
||||
export function useSWRMutate<Data = any, Error = any>() {
|
||||
return createMemo(() =>
|
||||
(key: Key, data?: Data | Promise<Data> | ((currentData?: Data) => Data | Promise<Data>), options?: any) => {
|
||||
return globalSWR.mutate(key, data, options)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for accessing global SWR instance
|
||||
*/
|
||||
export function useSWRInstance() {
|
||||
return globalSWR
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for prefetching data
|
||||
*/
|
||||
export function useSWRPrefetch<Data = any, Error = any>() {
|
||||
return createMemo(() =>
|
||||
(key: Key, fetcher: Fetcher<Data>) => {
|
||||
const normalizedFetcher = fetcher.length === 2
|
||||
? fetcher
|
||||
: (k: Key) => (fetcher as any)(Array.isArray(k) ? k : [k])
|
||||
|
||||
return globalSWR.revalidate(key, { dedupe: true })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default useSWR
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "bun:test";
|
||||
import { get } from "svelte/store";
|
||||
import { useSWR } from "../index";
|
||||
|
||||
// Mock fetcher
|
||||
const fetcher = vi.fn((key: string) => Promise.resolve(`data-${key}`));
|
||||
|
||||
describe("useSWR (Svelte)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetcher.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fetches data on mount", async () => {
|
||||
const { data, isLoading } = useSWR("test-key", fetcher);
|
||||
|
||||
// Initial state (synchronously set by my core change)
|
||||
expect(get(isLoading)).toBe(true);
|
||||
|
||||
// Wait for fetch
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(get(data)).toBe("data-test-key");
|
||||
expect(get(isLoading)).toBe(false);
|
||||
});
|
||||
|
||||
it("deduplicates requests", async () => {
|
||||
const { data: d1 } = useSWR("dedupe-key", fetcher);
|
||||
const { data: d2 } = useSWR("dedupe-key", fetcher);
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(get(d1)).toBe("data-dedupe-key");
|
||||
expect(get(d2)).toBe("data-dedupe-key");
|
||||
});
|
||||
|
||||
it("mutates data manually", async () => {
|
||||
const { data, mutate } = useSWR("mutate-key", fetcher);
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(get(data)).toBe("data-mutate-key");
|
||||
|
||||
await mutate("new-data");
|
||||
|
||||
expect(get(data)).toBe("new-data");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
// Svelte SWR exports
|
||||
export { default as useSWR, useSWRPrefetch } from './use-swr'
|
||||
|
||||
// Re-export core types
|
||||
export type { Key, Fetcher } from '../../core'
|
||||
|
||||
// Main exports
|
||||
export { createSWRStores, createSWRStore } from './swr'
|
||||
export {
|
||||
swrDataStore,
|
||||
swrErrorStore,
|
||||
swrLoadingStore,
|
||||
swrValidatingStore,
|
||||
useSWRInstance
|
||||
} from './swr'
|
||||
|
||||
// Types
|
||||
export type { SWRStores, SWRStore, SWRConfig } from './types'
|
||||
|
||||
// Default export
|
||||
export { createSWRStores as default } from './swr'
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@supwr/svelte",
|
||||
"version": "0.0.1",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bun build ./src/index.ts --outdir ./dist --external react --target browser"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supwr/core": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { writable, derived } from 'svelte/store'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { createSWR } from '../../core'
|
||||
import type { SWRStores, SWRStore, Key, Fetcher, SWRFetcher } from './types'
|
||||
|
||||
// Global SWR instance
|
||||
const globalSWR = createSWR()
|
||||
|
||||
/**
|
||||
* Create SWR stores for Svelte
|
||||
*/
|
||||
export function createSWRStores<Data = unknown, Error = unknown>(
|
||||
key: Key | null,
|
||||
fetcher?: SWRFetcher<Data>,
|
||||
config?: Partial<import('./types').SWRConfig<Data, Error>>
|
||||
): SWRStores<Data, Error> {
|
||||
// Update config if provided
|
||||
if (config) {
|
||||
globalSWR.updateConfig(config as any)
|
||||
}
|
||||
|
||||
// Initial state logic
|
||||
let initialData: Data | undefined = undefined
|
||||
let initialError: Error | undefined = undefined
|
||||
let initialIsLoading = false
|
||||
let initialIsValidating = false
|
||||
|
||||
if (key) {
|
||||
const entry = globalSWR.get(key)
|
||||
if (entry) {
|
||||
initialData = entry.data as Data
|
||||
initialError = entry.error as Error
|
||||
initialIsLoading = entry.isLoading
|
||||
initialIsValidating = entry.isValidating
|
||||
}
|
||||
}
|
||||
|
||||
// Create base stores
|
||||
const dataStore = writable<Data | undefined>(initialData)
|
||||
const errorStore = writable<Error | undefined>(initialError)
|
||||
const isLoadingStore = writable<boolean>(initialIsLoading)
|
||||
const isValidatingStore = writable<boolean>(initialIsValidating)
|
||||
|
||||
// Subscribe to SWR changes
|
||||
let unsubscribe: (() => void) | undefined
|
||||
|
||||
const normalizedFetcher = fetcher ? (
|
||||
fetcher.length === 2 ? fetcher : (k: Key, options: any) => {
|
||||
if (Array.isArray(k)) {
|
||||
return (fetcher as any)(...k)
|
||||
}
|
||||
return (fetcher as any)(k)
|
||||
}
|
||||
) : null
|
||||
|
||||
if (key) {
|
||||
unsubscribe = globalSWR.subscribe(key, (newData, newError, newIsValidating, newIsLoading) => {
|
||||
dataStore.set(newData as Data)
|
||||
errorStore.set(newError as Error)
|
||||
isValidatingStore.set(newIsValidating ?? false)
|
||||
isLoadingStore.set(newIsLoading ?? false)
|
||||
}, normalizedFetcher as any)
|
||||
}
|
||||
|
||||
// Cleanup on destroy
|
||||
onDestroy(() => {
|
||||
unsubscribe?.()
|
||||
})
|
||||
|
||||
// Create mutate function
|
||||
const mutate = (mutateData?: Data | ((current: Data | undefined) => Data | Promise<Data>), options?: Partial<import('../../core').MutatorOptions<Data>>):Promise<Data | undefined> => {
|
||||
if (!key) return Promise.resolve(undefined)
|
||||
return globalSWR.mutate(key, mutateData, options) as Promise<Data>
|
||||
}
|
||||
|
||||
return {
|
||||
data: { subscribe: dataStore.subscribe },
|
||||
error: { subscribe: errorStore.subscribe },
|
||||
isLoading: { subscribe: isLoadingStore.subscribe },
|
||||
isValidating: { subscribe: isValidatingStore.subscribe },
|
||||
loading: { subscribe: isLoadingStore.subscribe },
|
||||
validating: { subscribe: isValidatingStore.subscribe },
|
||||
mutate
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single SWR store containing all state
|
||||
*/
|
||||
export function createSWRStore<Data = unknown, Error = unknown>(
|
||||
key: Key | null,
|
||||
fetcher?: Fetcher<Data>,
|
||||
config?: Partial<import('./types').SWRConfig<Data, Error>>
|
||||
): SWRStore<Data, Error> {
|
||||
const stores = createSWRStores<Data, Error>(key, fetcher, config)
|
||||
|
||||
let initialData: Data | undefined;
|
||||
let initialError: Error | undefined;
|
||||
let initialIsLoading = false;
|
||||
let initialIsValidating = false;
|
||||
|
||||
stores.data.subscribe(v => initialData = v)();
|
||||
stores.error.subscribe(v => initialError = v)();
|
||||
stores.isLoading.subscribe(v => initialIsLoading = v)();
|
||||
stores.isValidating.subscribe(v => initialIsValidating = v)();
|
||||
|
||||
const combinedStore = writable<{
|
||||
data?: Data
|
||||
error?: Error
|
||||
isLoading: boolean
|
||||
isValidating: boolean
|
||||
loading: boolean
|
||||
validating: boolean
|
||||
}>({
|
||||
data: initialData,
|
||||
error: initialError,
|
||||
isLoading: initialIsLoading,
|
||||
isValidating: initialIsValidating,
|
||||
loading: initialIsLoading,
|
||||
validating: initialIsValidating
|
||||
})
|
||||
|
||||
// Update combined store when individual stores change
|
||||
const unsubscribeData = stores.data.subscribe(data => {
|
||||
combinedStore.update(state => ({ ...state, data }))
|
||||
})
|
||||
|
||||
const unsubscribeError = stores.error.subscribe(error => {
|
||||
combinedStore.update(state => ({ ...state, error }))
|
||||
})
|
||||
|
||||
const unsubscribeLoading = stores.isLoading.subscribe(isLoading => {
|
||||
combinedStore.update(state => ({ ...state, isLoading, loading: isLoading }))
|
||||
})
|
||||
|
||||
const unsubscribeValidating = stores.isValidating.subscribe(isValidating => {
|
||||
combinedStore.update(state => ({ ...state, isValidating, validating: isValidating }))
|
||||
})
|
||||
|
||||
// Cleanup on destroy
|
||||
onDestroy(() => {
|
||||
unsubscribeData()
|
||||
unsubscribeError()
|
||||
unsubscribeLoading()
|
||||
unsubscribeValidating()
|
||||
})
|
||||
|
||||
return {
|
||||
...combinedStore,
|
||||
mutate: stores.mutate
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual store creators
|
||||
*/
|
||||
export function swrDataStore<Data = unknown>(
|
||||
key: Key | null,
|
||||
fetcher?: Fetcher<Data>,
|
||||
config?: Partial<import('./types').SWRConfig<Data, Error>>
|
||||
) {
|
||||
return createSWRStores<Data, unknown>(key, fetcher, config as any).data
|
||||
}
|
||||
|
||||
export function swrErrorStore<Error = unknown>(
|
||||
key: Key | null,
|
||||
fetcher?: Fetcher<any>,
|
||||
config?: Partial<import('./types').SWRConfig<any, Error>>
|
||||
) {
|
||||
return createSWRStores<any, Error>(key, fetcher, config).error
|
||||
}
|
||||
|
||||
export function swrLoadingStore(
|
||||
key: Key | null,
|
||||
fetcher?: Fetcher<any>,
|
||||
config?: Partial<import('./types').SWRConfig<any, any>>
|
||||
) {
|
||||
return createSWRStores<any, any>(key, fetcher, config).isLoading
|
||||
}
|
||||
|
||||
export function swrValidatingStore(
|
||||
key: Key | null,
|
||||
fetcher?: Fetcher<any>,
|
||||
config?: Partial<import('./types').SWRConfig<any, any>>
|
||||
) {
|
||||
return createSWRStores<any, any>(key, fetcher, config).isValidating
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for manual mutation
|
||||
*/
|
||||
export function useSWRMutate<Data = unknown, Error = unknown>() {
|
||||
return (key: Key, data?: Data | ((current: Data | undefined) => Data | Promise<Data>), options?: Partial<import('../../core').MutatorOptions<Data>>) => {
|
||||
return globalSWR.mutate(key, data, options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for accessing global SWR instance
|
||||
*/
|
||||
export function useSWRInstance() {
|
||||
return globalSWR
|
||||
}
|
||||
|
||||
export default createSWRStores
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["DOM", "ES2022"],
|
||||
"target": "ES2018",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "preserve",
|
||||
"allowJs": true,
|
||||
"types": ["bun"],
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"reference/swr",
|
||||
"reference/swrev",
|
||||
"reference/swrv",
|
||||
"sswr",
|
||||
"solid-swr"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { SWRConfig as CoreSWRConfig, Key, Fetcher, MutatorOptions, BareFetcher } from '../../core'
|
||||
import type { Writable, Readable } from 'svelte/store'
|
||||
|
||||
export interface SWRConfig<Data = unknown, Error = unknown> extends CoreSWRConfig<Data, Error> {
|
||||
// Svelte-specific config
|
||||
store?: boolean
|
||||
suspense?: boolean
|
||||
fallbackData?: Data
|
||||
revalidateOnMount?: boolean
|
||||
revalidateOnFocus?: boolean
|
||||
revalidateOnReconnect?: boolean
|
||||
refreshWhenHidden?: boolean
|
||||
refreshWhenOffline?: boolean
|
||||
compare?: (a: Data | undefined, b: Data | undefined) => boolean
|
||||
}
|
||||
|
||||
export interface SWRStores<Data = unknown, Error = unknown> {
|
||||
data: Readable<Data | undefined>
|
||||
error: Readable<Error | undefined>
|
||||
isLoading: Readable<boolean>
|
||||
isValidating: Readable<boolean>
|
||||
loading: Readable<boolean>
|
||||
validating: Readable<boolean>
|
||||
mutate: (data?: Data | ((current: Data | undefined) => Data | Promise<Data>), options?: Partial<MutatorOptions<Data>>) => Promise<Data | undefined>
|
||||
}
|
||||
|
||||
export interface SWRStore<Data = unknown, Error = unknown> extends Writable<{
|
||||
data?: Data
|
||||
error?: Error
|
||||
isLoading: boolean
|
||||
isValidating: boolean
|
||||
loading: boolean
|
||||
validating: boolean
|
||||
}> {
|
||||
mutate: (data?: Data | ((current: Data | undefined) => Data | Promise<Data>), options?: Partial<MutatorOptions<Data>>) => Promise<Data | undefined>
|
||||
}
|
||||
|
||||
// Re-export core types
|
||||
export type { Key, Fetcher, MutatorOptions, BareFetcher, SWRFetcher } from '../../core'
|
||||
@@ -0,0 +1,195 @@
|
||||
import { onDestroy } from 'svelte'
|
||||
import { writable, derived } from 'svelte/store'
|
||||
import { createSWR } from '../../core'
|
||||
import type { SWRConfig, SWRStores, SWRStore, Key, Fetcher, SWRFetcher } from './types'
|
||||
|
||||
// Global SWR instance
|
||||
const globalSWR = createSWR()
|
||||
|
||||
/**
|
||||
* Enhanced useSWR store for Svelte
|
||||
*/
|
||||
export function useSWR<Data = unknown, Error = unknown>(
|
||||
key: Key | null,
|
||||
fetcher?: SWRFetcher<Data> | null,
|
||||
config?: Partial<SWRConfig<Data, Error>>
|
||||
): SWRStores<Data, Error> {
|
||||
// Get config from context (using a simple object for now)
|
||||
const contextConfig = {} as Record<string, unknown>
|
||||
const mergedConfig = {
|
||||
...contextConfig,
|
||||
...config
|
||||
}
|
||||
|
||||
// Update config if provided
|
||||
if (mergedConfig) {
|
||||
globalSWR.updateConfig(mergedConfig as any)
|
||||
}
|
||||
|
||||
// Initial state logic
|
||||
let initialData: Data | undefined = undefined
|
||||
let initialError: Error | undefined = undefined
|
||||
let initialIsLoading = false
|
||||
let initialIsValidating = false
|
||||
|
||||
if (key) {
|
||||
const entry = globalSWR.get(key)
|
||||
if (entry) {
|
||||
initialData = entry.data as Data
|
||||
initialError = entry.error as Error
|
||||
initialIsLoading = entry.isLoading
|
||||
initialIsValidating = entry.isValidating
|
||||
}
|
||||
}
|
||||
|
||||
// Create base stores
|
||||
const dataStore = writable<Data | undefined>(initialData)
|
||||
const errorStore = writable<Error | undefined>(initialError)
|
||||
const isLoadingStore = writable<boolean>(initialIsLoading)
|
||||
const isValidatingStore = writable<boolean>(initialIsValidating)
|
||||
|
||||
// Subscribe to SWR changes
|
||||
let unsubscribe: (() => void) | undefined
|
||||
|
||||
const normalizedFetcher = fetcher ? (
|
||||
fetcher.length === 2 ? fetcher as Fetcher<Data> : (k: Key, options: unknown) => {
|
||||
if (Array.isArray(k)) {
|
||||
return (fetcher as (...args: unknown[]) => Promise<Data>)(...k)
|
||||
}
|
||||
return (fetcher as (arg: unknown) => Promise<Data>)(k)
|
||||
}
|
||||
) : null
|
||||
|
||||
if (key) {
|
||||
unsubscribe = globalSWR.subscribe(key, (newData, newError, newIsValidating, newIsLoading) => {
|
||||
dataStore.set(newData as Data)
|
||||
errorStore.set(newError as Error)
|
||||
isValidatingStore.set(newIsValidating ?? false)
|
||||
isLoadingStore.set(newIsLoading ?? false)
|
||||
}, normalizedFetcher as Fetcher<Data>)
|
||||
}
|
||||
|
||||
// Cleanup on destroy if inside a component
|
||||
try {
|
||||
onDestroy(() => {
|
||||
if (unsubscribe) unsubscribe()
|
||||
})
|
||||
} catch (e) {
|
||||
// Ignore error if called outside component initialization
|
||||
// (e.g. in tests without a component context)
|
||||
}
|
||||
|
||||
// Create mutate function
|
||||
const mutate = (mutateData?: Data | Promise<Data> | ((currentData?: Data) => Data | Promise<Data>), options?: unknown) => {
|
||||
if (!key) return Promise.resolve(undefined)
|
||||
return globalSWR.mutate(key, mutateData, options as any)
|
||||
}
|
||||
|
||||
return {
|
||||
data: { subscribe: dataStore.subscribe },
|
||||
error: { subscribe: errorStore.subscribe },
|
||||
isLoading: { subscribe: isLoadingStore.subscribe },
|
||||
isValidating: { subscribe: isValidatingStore.subscribe },
|
||||
loading: { subscribe: isLoadingStore.subscribe },
|
||||
validating: { subscribe: isValidatingStore.subscribe },
|
||||
mutate: mutate as any
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced useSWR store with all state combined
|
||||
*/
|
||||
export function useSWRStore<Data = unknown, Error = unknown>(
|
||||
key: Key | null,
|
||||
fetcher?: SWRFetcher<Data> | null,
|
||||
config?: Partial<SWRConfig<Data, Error>>
|
||||
): SWRStore<Data, Error> {
|
||||
const stores = useSWR<Data, Error>(key, fetcher, config)
|
||||
|
||||
let initialData: Data | undefined;
|
||||
let initialError: Error | undefined;
|
||||
let initialIsLoading = false;
|
||||
let initialIsValidating = false;
|
||||
|
||||
stores.data.subscribe(v => initialData = v)();
|
||||
stores.error.subscribe(v => initialError = v)();
|
||||
stores.isLoading.subscribe(v => initialIsLoading = v)();
|
||||
stores.isValidating.subscribe(v => initialIsValidating = v)();
|
||||
|
||||
const combinedStore = writable<{
|
||||
data?: Data
|
||||
error?: Error
|
||||
isLoading: boolean
|
||||
isValidating: boolean
|
||||
loading: boolean
|
||||
validating: boolean
|
||||
}>({
|
||||
data: initialData,
|
||||
error: initialError,
|
||||
isLoading: initialIsLoading,
|
||||
isValidating: initialIsValidating,
|
||||
loading: initialIsLoading,
|
||||
validating: initialIsValidating
|
||||
})
|
||||
|
||||
// Update combined store when individual stores change
|
||||
const unsubscribeData = stores.data.subscribe((data: any) => {
|
||||
combinedStore.update((state: any) => ({ ...state, data }))
|
||||
})
|
||||
|
||||
const unsubscribeError = stores.error.subscribe((error: any) => {
|
||||
combinedStore.update((state: any) => ({ ...state, error }))
|
||||
})
|
||||
|
||||
const unsubscribeLoading = stores.isLoading.subscribe((isLoading: any) => {
|
||||
combinedStore.update((state: any) => ({ ...state, isLoading, loading: isLoading }))
|
||||
})
|
||||
|
||||
const unsubscribeValidating = stores.isValidating.subscribe((isValidating) => {
|
||||
combinedStore.update((state) => ({ ...state, isValidating, validating: isValidating }))
|
||||
})
|
||||
|
||||
// Cleanup on destroy
|
||||
try {
|
||||
onDestroy(() => {
|
||||
unsubscribeData()
|
||||
unsubscribeError()
|
||||
unsubscribeLoading()
|
||||
unsubscribeValidating()
|
||||
})
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return {
|
||||
...combinedStore,
|
||||
mutate: stores.mutate
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for manual mutation
|
||||
*/
|
||||
export function useSWRMutate<Data = unknown, Error = unknown>() {
|
||||
return (key: Key, data?: Data | Promise<Data> | ((currentData?: Data) => Data | Promise<Data>), options?: unknown) => {
|
||||
return globalSWR.mutate(key, data, options as any)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for accessing global SWR instance
|
||||
*/
|
||||
export function useSWRInstance() {
|
||||
return globalSWR
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for prefetching data
|
||||
*/
|
||||
export function useSWRPrefetch<Data = any, Error = any>() {
|
||||
return (key: Key, fetcher: Fetcher<Data>) => {
|
||||
return globalSWR.revalidate(key, { dedupe: true })
|
||||
}
|
||||
}
|
||||
|
||||
export default useSWR
|
||||
@@ -0,0 +1,67 @@
|
||||
// import { describe, expect, it, vi, beforeEach, afterEach } from "bun:test";
|
||||
// import { useSWR } from "../index";
|
||||
// import { effectScope } from "vue";
|
||||
|
||||
// function withSetup<T>(composable: () => T): [T, () => void] {
|
||||
// let result: T;
|
||||
// const scope = effectScope();
|
||||
// scope.run(() => {
|
||||
// result = composable();
|
||||
// });
|
||||
// return [result!, () => scope.stop()];
|
||||
// }
|
||||
|
||||
|
||||
// // Mock fetcher
|
||||
// const fetcher = vi.fn((key: string) => Promise.resolve(`data-${key}`));
|
||||
|
||||
// describe("useSWR (Vue)", () => {
|
||||
// beforeEach(() => {
|
||||
// vi.useFakeTimers();
|
||||
// fetcher.mockClear();
|
||||
// });
|
||||
|
||||
// afterEach(() => {
|
||||
// vi.useRealTimers();
|
||||
// });
|
||||
|
||||
// it("fetches data on mount", async () => {
|
||||
// // const { data, isLoading } = useSWR("test-key", fetcher);
|
||||
// const [{data, isLoading}, stop] = withSetup(() => useSWR("test-key", fetcher));
|
||||
|
||||
// // Initial state
|
||||
// expect(isLoading.value).toBe(true);
|
||||
|
||||
// // Wait for fetch
|
||||
// vi.runAllTimers();
|
||||
|
||||
// expect(data.value).toBe("data-test-key");
|
||||
// expect(isLoading.value).toBe(false);
|
||||
// stop();
|
||||
// });
|
||||
|
||||
// // it("deduplicates requests", async () => {
|
||||
// // const [{ data: d1 }, stop1] = withSetup(() =>useSWR("dedupe-key", fetcher));
|
||||
// // const [{ data: d2 }, stop2] = withSetup(() =>useSWR("dedupe-key", fetcher));
|
||||
|
||||
// // vi.runAllTimers();
|
||||
|
||||
// // expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
// // expect(d1.value).toBe("data-dedupe-key");
|
||||
// // expect(d2.value).toBe("data-dedupe-key");
|
||||
// // stop1();
|
||||
// // stop2();
|
||||
// // });
|
||||
|
||||
// // it("mutates data manually", async () => {
|
||||
// // const { data, mutate } = useSWR("mutate-key", fetcher);
|
||||
|
||||
// // vi.runAllTimers();
|
||||
|
||||
// // expect(data.value).toBe("data-mutate-key");
|
||||
|
||||
// // await mutate("new-data");
|
||||
|
||||
// // expect(data.value).toBe("new-data");
|
||||
// // });
|
||||
// });
|
||||
@@ -0,0 +1,5 @@
|
||||
// Vue SWR exports
|
||||
export { default as useSWR, SWRConfig, useSWRMutate, useSWRInstance, useSWRPrefetch } from './use-swr'
|
||||
|
||||
// Re-export core types
|
||||
export type { Key, Fetcher } from '../../core'
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@supwr/vue",
|
||||
"version": "0.0.1",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bun build ./src/index.ts --outdir ./dist --external react --target browser"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supwr/core": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ref, computed, onUnmounted, watch } from 'vue'
|
||||
import { createSWR } from '../../core'
|
||||
import type { Key, Fetcher, SWRConfig as CoreSWRConfig } from '../../core'
|
||||
import type { SWRRefs, SWRRef, SWRConfig } from './types'
|
||||
|
||||
// Create a basic Vue SWR implementation
|
||||
export function createSWRComposables<Data = any, Error = any>(
|
||||
key: Key,
|
||||
fetcher: Fetcher<Data>,
|
||||
config?: SWRConfig<Data, Error>
|
||||
): SWRRefs<Data, Error> {
|
||||
const swr = createSWR<Data, Error>({ fetcher, ...config })
|
||||
|
||||
const data = ref<Data | undefined>()
|
||||
const error = ref<Error | undefined>()
|
||||
const loading = ref(false)
|
||||
const validating = ref(false)
|
||||
|
||||
// Subscribe to SWR changes
|
||||
const unsubscribe = swr.subscribe(key, (newData, newError, newValidating, newLoading) => {
|
||||
data.value = newData
|
||||
error.value = newError
|
||||
validating.value = newValidating
|
||||
loading.value = newLoading
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
validating,
|
||||
mutate: (mutateData, options) => swr.mutate(key, mutateData as any, options),
|
||||
revalidate: (options) => swr.revalidate(key, options)
|
||||
}
|
||||
}
|
||||
|
||||
export function createSWRRef<Data = any, Error = any>(
|
||||
key: Key,
|
||||
fetcher: Fetcher<Data>,
|
||||
config?: SWRConfig<Data, Error>
|
||||
): SWRRef<Data, Error> {
|
||||
return createSWRComposables(key, fetcher, config) as SWRRef<Data, Error>
|
||||
}
|
||||
|
||||
// Export individual refs for convenience
|
||||
export const swrDataRef = <T>(key: Key, fetcher: Fetcher<T>, config?: SWRConfig<T>) =>
|
||||
createSWRRef(key, fetcher, config).data
|
||||
|
||||
export const swrErrorRef = <T>(key: Key, fetcher: Fetcher<T>, config?: SWRConfig<T>) =>
|
||||
createSWRRef(key, fetcher, config).error
|
||||
|
||||
export const swrLoadingRef = <T>(key: Key, fetcher: Fetcher<T>, config?: SWRConfig<T>) =>
|
||||
createSWRRef(key, fetcher, config).loading
|
||||
|
||||
export const swrValidatingRef = <T>(key: Key, fetcher: Fetcher<T>, config?: SWRConfig<T>) =>
|
||||
createSWRRef(key, fetcher, config).validating
|
||||
|
||||
// Hook-like function for Vue
|
||||
export function useSWR<Data = any, Error = any>(
|
||||
key: Key,
|
||||
fetcher: Fetcher<Data>,
|
||||
config?: SWRConfig<Data, Error>
|
||||
): SWRRefs<Data, Error> {
|
||||
return createSWRComposables(key, fetcher, config)
|
||||
}
|
||||
|
||||
export function useSWRMutate<Data = any, Error = any>(
|
||||
key: Key,
|
||||
fetcher: Fetcher<Data>,
|
||||
config?: SWRConfig<Data, Error>
|
||||
): SWRRefs<Data, Error> {
|
||||
return createSWRComposables(key, fetcher, config)
|
||||
}
|
||||
|
||||
export function useSWRInstance<Data = any, Error = any>(
|
||||
key: Key,
|
||||
fetcher: Fetcher<Data>,
|
||||
config?: SWRConfig<Data, Error>
|
||||
): SWRRefs<Data, Error> {
|
||||
return createSWRComposables(key, fetcher, config)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["DOM", "ES2022"],
|
||||
"target": "ES2018",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "preserve",
|
||||
"allowJs": true,
|
||||
"types": ["bun"],
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
import type { Key, Fetcher, SWRConfig as CoreSWRConfig, MutatorOptions, BareFetcher } from '../../core'
|
||||
|
||||
export interface SWRRefs<Data = any, Error = any> {
|
||||
data: Ref<Data | undefined>
|
||||
error: Ref<Error | undefined>
|
||||
isLoading: Ref<boolean>
|
||||
isValidating: Ref<boolean>
|
||||
mutate: (data?: Data | Promise<Data> | ((currentData?: Data) => Data | Promise<Data>), options?: any) => Promise<Data | undefined>
|
||||
revalidate: (options?: any) => Promise<boolean>
|
||||
}
|
||||
|
||||
export interface SWRRef<Data = any, Error = any> extends SWRRefs<Data, Error> {
|
||||
// Additional Vue-specific methods can be added here
|
||||
}
|
||||
|
||||
export interface SWRConfig<Data = any, Error = any> extends CoreSWRConfig<Data, Error> {
|
||||
// Vue-specific configuration options
|
||||
suspense?: boolean
|
||||
fallbackData?: Data
|
||||
revalidateOnMount?: boolean
|
||||
revalidateOnFocus?: boolean
|
||||
revalidateOnReconnect?: boolean
|
||||
refreshWhenHidden?: boolean
|
||||
refreshWhenOffline?: boolean
|
||||
compare?: (a: Data | undefined, b: Data | undefined) => boolean
|
||||
}
|
||||
|
||||
// Re-export core types with SWR-compatible names
|
||||
export type { Key, Fetcher, MutatorOptions, BareFetcher, SWRFetcher } from '../../core'
|
||||
@@ -0,0 +1,199 @@
|
||||
import { ref, computed, onUnmounted, Suspense, defineComponent, provide, inject, watch } from 'vue'
|
||||
import { createSWR } from '../../core'
|
||||
import type { SWRConfig as SWRConfiguration, SWRRefs, Key, Fetcher, SWRFetcher } from './types'
|
||||
|
||||
// Global SWR instance
|
||||
const globalSWR = createSWR()
|
||||
|
||||
// Vue injection key for configuration
|
||||
const SWR_CONFIG_KEY = Symbol('swr-config')
|
||||
|
||||
/**
|
||||
* Enhanced useSWR composable for Vue
|
||||
*/
|
||||
export function useSWR<Data = unknown, Error = unknown>(
|
||||
key: Key | null,
|
||||
fetcher?: SWRFetcher<Data> | null,
|
||||
config?: Partial<SWRConfiguration<Data, Error>>
|
||||
): SWRRefs<Data, Error> {
|
||||
// Get config from injection and props
|
||||
const injectedConfig = inject(SWR_CONFIG_KEY, {})
|
||||
const mergedConfig = computed(() => ({
|
||||
...injectedConfig,
|
||||
...config
|
||||
}))
|
||||
|
||||
// Create a stable reference to SWR instance with config
|
||||
const swrRef = ref(globalSWR)
|
||||
|
||||
// Update config if changed
|
||||
watch(mergedConfig, (newConfig) => {
|
||||
if (newConfig) {
|
||||
swrRef.value.updateConfig(newConfig as Partial<SWRConfiguration<Data, Error>> as any)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Normalize fetcher to handle different signatures
|
||||
const normalizedFetcher = computed(() => {
|
||||
const f = typeof fetcher === 'function' ? fetcher : null
|
||||
if (!f) return null
|
||||
|
||||
// If it's already a core fetcher, use it as-is
|
||||
if (f.length === 2) return f as Fetcher<Data>
|
||||
|
||||
// Convert bare fetcher to core fetcher
|
||||
return (k: Key, options: unknown) => {
|
||||
if (Array.isArray(k)) {
|
||||
return (f as (...args: unknown[]) => Promise<Data>)(...k)
|
||||
}
|
||||
return (f as (arg: unknown) => Promise<Data>)(k)
|
||||
}
|
||||
})
|
||||
|
||||
// Create refs
|
||||
const data = ref<Data | undefined>(undefined)
|
||||
const error = ref<Error | undefined>(undefined)
|
||||
const loading = ref(false)
|
||||
const validating = ref(false)
|
||||
|
||||
// Subscribe to SWR changes
|
||||
let unsubscribe: (() => void) | undefined
|
||||
|
||||
const updateRefs = (newData: Data | undefined, newError: Error | undefined, newValidating?: boolean, newLoading?: boolean) => {
|
||||
data.value = newData
|
||||
error.value = newError
|
||||
validating.value = newValidating ?? false
|
||||
loading.value = newLoading ?? false
|
||||
}
|
||||
|
||||
// Watch for key and fetcher changes
|
||||
watch([() => key, normalizedFetcher], ([newKey, newFetcher]) => {
|
||||
// Cleanup previous subscription
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
}
|
||||
|
||||
// Subscribe to new key if available
|
||||
if (newKey && newFetcher) {
|
||||
const entry = swrRef.value.get(newKey)
|
||||
data.value = (entry?.data ?? mergedConfig.value?.fallbackData) as Data | undefined
|
||||
error.value = entry?.error as Error | undefined
|
||||
validating.value = entry?.isValidating ?? false
|
||||
loading.value = entry?.isLoading ?? false
|
||||
|
||||
unsubscribe = swrRef.value.subscribe(newKey, updateRefs as any, newFetcher as Fetcher<Data>)
|
||||
}
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
}
|
||||
})
|
||||
|
||||
// Create mutate function
|
||||
const mutate = computed(() =>
|
||||
(mutateData?: Data | Promise<Data> | ((currentData?: Data) => Data | Promise<Data>), options?: unknown) => {
|
||||
if (!key) return Promise.resolve(undefined)
|
||||
return swrRef.value.mutate(key, mutateData, options as any)
|
||||
}
|
||||
)
|
||||
|
||||
// Handle suspense mode
|
||||
if (mergedConfig.value?.suspense) {
|
||||
if (error.value) {
|
||||
throw error.value
|
||||
}
|
||||
if (data.value === undefined) {
|
||||
// In suspense mode, we need to trigger fetch and throw a promise
|
||||
const promise = new Promise<Data>((resolve, reject) => {
|
||||
const u = swrRef.value.subscribe(key, (d, e) => {
|
||||
if (e) {
|
||||
reject(e)
|
||||
} else if (d !== undefined) {
|
||||
resolve(d as Data)
|
||||
}
|
||||
})
|
||||
|
||||
// Start the fetch if not already loading
|
||||
const entry = swrRef.value.get(key)
|
||||
if (!entry?.isLoading && !entry?.isValidating) {
|
||||
swrRef.value.revalidate(key)
|
||||
}
|
||||
|
||||
return u
|
||||
})
|
||||
|
||||
throw promise
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: data as any,
|
||||
error: error as any,
|
||||
isLoading: loading,
|
||||
isValidating: validating,
|
||||
mutate: mutate.value as any,
|
||||
revalidate: (options?: unknown) => swrRef.value.revalidate(key, options as any)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue configuration provider component
|
||||
*/
|
||||
export const SWRConfig = defineComponent({
|
||||
name: 'SWRConfig',
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
// Provide configuration to child components
|
||||
provide(SWR_CONFIG_KEY, props.value || {})
|
||||
|
||||
return () => slots.default?.()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Interface type for configuration
|
||||
*/
|
||||
export type SWRConfig<Data = unknown, Error = unknown> = SWRConfiguration<Data, Error>
|
||||
|
||||
/**
|
||||
* Hook for manual mutation
|
||||
*/
|
||||
export function useSWRMutate<Data = unknown, Error = unknown>() {
|
||||
return computed(() =>
|
||||
(key: Key, data?: Data | Promise<Data> | ((currentData?: Data) => Data | Promise<Data>), options?: unknown) => {
|
||||
return globalSWR.mutate(key, data, options as any)
|
||||
}
|
||||
).value
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for accessing global SWR instance
|
||||
*/
|
||||
export function useSWRInstance() {
|
||||
return ref(globalSWR)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for prefetching data
|
||||
*/
|
||||
export function useSWRPrefetch<Data = any, Error = any>() {
|
||||
return computed(() =>
|
||||
(key: Key, fetcher: Fetcher<Data>) => {
|
||||
const normalizedFetcher = fetcher.length === 2
|
||||
? fetcher
|
||||
: (k: Key) => (fetcher as any)(Array.isArray(k) ? k : [k])
|
||||
|
||||
return globalSWR.revalidate(key, { dedupe: true })
|
||||
}
|
||||
).value
|
||||
}
|
||||
|
||||
export default useSWR
|
||||
Reference in New Issue
Block a user