fict 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,293 @@
1
+ import { createSignal, createEffect, onCleanup, createSuspenseToken } from '@fictjs/runtime'
2
+
3
+ export interface ResourceResult<T> {
4
+ data: T | undefined
5
+ loading: boolean
6
+ error: unknown
7
+ refresh: () => void
8
+ }
9
+
10
+ export interface ResourceCacheOptions {
11
+ mode?: 'memory' | 'none'
12
+ ttlMs?: number
13
+ staleWhileRevalidate?: boolean
14
+ cacheErrors?: boolean
15
+ }
16
+
17
+ export interface ResourceOptions<T, Args> {
18
+ key?: unknown
19
+ fetch: (ctx: { signal: AbortSignal }, args: Args) => Promise<T>
20
+ suspense?: boolean
21
+ cache?: ResourceCacheOptions
22
+ reset?: unknown | (() => unknown)
23
+ }
24
+
25
+ interface ResourceEntry<T, Args> {
26
+ data: ReturnType<typeof createSignal<T | undefined>>
27
+ loading: ReturnType<typeof createSignal<boolean>>
28
+ error: ReturnType<typeof createSignal<unknown>>
29
+ version: ReturnType<typeof createSignal<number>>
30
+ pendingToken: ReturnType<typeof createSuspenseToken> | null
31
+ lastArgs: Args | undefined
32
+ lastVersion: number
33
+ lastReset: unknown
34
+ hasValue: boolean
35
+ status: 'idle' | 'pending' | 'success' | 'error'
36
+ generation: number
37
+ expiresAt: number | undefined
38
+ inFlight: Promise<void> | undefined
39
+ controller: AbortController | undefined
40
+ }
41
+
42
+ const defaultCacheOptions: Required<ResourceCacheOptions> = {
43
+ mode: 'memory',
44
+ ttlMs: Number.POSITIVE_INFINITY,
45
+ staleWhileRevalidate: false,
46
+ cacheErrors: false,
47
+ }
48
+
49
+ /**
50
+ * Creates a resource factory that can be read with arguments.
51
+ *
52
+ * @param optionsOrFetcher - Configuration object or fetcher function
53
+ */
54
+ export function resource<T, Args = void>(
55
+ optionsOrFetcher:
56
+ | ((ctx: { signal: AbortSignal }, args: Args) => Promise<T>)
57
+ | ResourceOptions<T, Args>,
58
+ ) {
59
+ const fetcher = typeof optionsOrFetcher === 'function' ? optionsOrFetcher : optionsOrFetcher.fetch
60
+ const useSuspense = typeof optionsOrFetcher === 'object' && !!optionsOrFetcher.suspense
61
+ const cacheOptions: ResourceCacheOptions =
62
+ typeof optionsOrFetcher === 'object' ? (optionsOrFetcher.cache ?? {}) : {}
63
+ const resolvedCacheOptions = { ...defaultCacheOptions, ...cacheOptions }
64
+ const cache = new Map<unknown, ResourceEntry<T, Args>>()
65
+
66
+ const readArgs = (argsAccessor: (() => Args) | Args): Args =>
67
+ typeof argsAccessor === 'function' ? (argsAccessor as () => Args)() : argsAccessor
68
+
69
+ const computeKey = (argsAccessor: (() => Args) | Args): unknown => {
70
+ const argsValue = readArgs(argsAccessor)
71
+ if (typeof optionsOrFetcher === 'object' && optionsOrFetcher.key !== undefined) {
72
+ const key = optionsOrFetcher.key
73
+ return typeof key === 'function' ? (key as (args: Args) => unknown)(argsValue) : key
74
+ }
75
+ return argsValue
76
+ }
77
+
78
+ const readResetToken = (): unknown => {
79
+ if (typeof optionsOrFetcher !== 'object') return undefined
80
+ const reset = optionsOrFetcher.reset
81
+ if (typeof reset === 'function' && (reset as () => unknown).length === 0) {
82
+ return (reset as () => unknown)()
83
+ }
84
+ return reset
85
+ }
86
+
87
+ const ensureEntry = (key: unknown): ResourceEntry<T, Args> => {
88
+ let state = cache.get(key)
89
+ if (!state) {
90
+ state = {
91
+ data: createSignal<T | undefined>(undefined),
92
+ loading: createSignal<boolean>(false),
93
+ error: createSignal<unknown>(undefined),
94
+ version: createSignal(0),
95
+ pendingToken: null,
96
+ lastArgs: undefined,
97
+ lastVersion: -1,
98
+ lastReset: undefined,
99
+ hasValue: false,
100
+ status: 'idle',
101
+ generation: 0,
102
+ expiresAt: undefined,
103
+ inFlight: undefined,
104
+ controller: undefined,
105
+ }
106
+ cache.set(key, state)
107
+ }
108
+ return state!
109
+ }
110
+
111
+ const isExpired = (entry: ResourceEntry<T, Args>): boolean => {
112
+ if (resolvedCacheOptions.mode === 'none') return true
113
+ if (!Number.isFinite(resolvedCacheOptions.ttlMs)) return false
114
+ if (entry.expiresAt === undefined) return false
115
+ return entry.expiresAt < Date.now()
116
+ }
117
+
118
+ const markExpiry = (entry: ResourceEntry<T, Args>) => {
119
+ if (resolvedCacheOptions.mode === 'none') {
120
+ entry.expiresAt = Date.now() - 1
121
+ return
122
+ }
123
+ entry.expiresAt = Number.isFinite(resolvedCacheOptions.ttlMs)
124
+ ? Date.now() + resolvedCacheOptions.ttlMs
125
+ : undefined
126
+ }
127
+
128
+ const startFetch = (entry: ResourceEntry<T, Args>, key: unknown, args: Args) => {
129
+ entry.controller?.abort()
130
+ entry.inFlight = undefined
131
+ const controller = new AbortController()
132
+ entry.controller = controller
133
+ entry.status = 'pending'
134
+ entry.loading(true)
135
+ entry.error(undefined)
136
+ entry.generation += 1
137
+ const currentGen = entry.generation
138
+
139
+ const shouldSuspend = useSuspense && !entry.hasValue
140
+ entry.pendingToken = shouldSuspend ? createSuspenseToken() : null
141
+
142
+ const fetchPromise = fetcher({ signal: controller.signal }, args)
143
+ .then(res => {
144
+ if (controller.signal.aborted || entry.generation !== currentGen) return
145
+ entry.data(res)
146
+ entry.hasValue = true
147
+ entry.status = 'success'
148
+ entry.loading(false)
149
+ markExpiry(entry)
150
+ if (entry.pendingToken) {
151
+ entry.pendingToken.resolve()
152
+ entry.pendingToken = null
153
+ }
154
+ })
155
+ .catch(err => {
156
+ if (controller.signal.aborted || entry.generation !== currentGen) return
157
+ entry.error(err)
158
+ entry.status = 'error'
159
+ entry.loading(false)
160
+ if (resolvedCacheOptions.cacheErrors) {
161
+ markExpiry(entry)
162
+ } else {
163
+ entry.expiresAt = Date.now() - 1
164
+ entry.hasValue = false
165
+ }
166
+ if (entry.pendingToken) {
167
+ entry.pendingToken.reject(err)
168
+ entry.pendingToken = null
169
+ }
170
+ })
171
+ .finally(() => {
172
+ entry.inFlight = undefined
173
+ entry.controller = undefined
174
+ })
175
+
176
+ entry.inFlight = fetchPromise
177
+
178
+ onCleanup(() => {
179
+ if (resolvedCacheOptions.mode === 'none') {
180
+ controller.abort()
181
+ cache.delete(key)
182
+ }
183
+ })
184
+ }
185
+
186
+ const invalidate = (key?: unknown) => {
187
+ if (key === undefined) {
188
+ cache.forEach(entry => {
189
+ entry.controller?.abort()
190
+ entry.version(entry.version() + 1)
191
+ entry.expiresAt = Date.now() - 1
192
+ })
193
+ cache.clear()
194
+ return
195
+ }
196
+ const entry = cache.get(key)
197
+ if (entry) {
198
+ entry.controller?.abort()
199
+ entry.version(entry.version() + 1)
200
+ entry.expiresAt = Date.now() - 1
201
+ cache.delete(key)
202
+ }
203
+ }
204
+
205
+ const prefetch = (args: Args, keyOverride?: unknown) => {
206
+ const key = keyOverride ?? computeKey(args)
207
+ const entry = ensureEntry(key)
208
+ const usableData = entry.hasValue && !isExpired(entry)
209
+ if (!usableData) {
210
+ entry.lastArgs = args
211
+ entry.lastVersion = entry.version()
212
+ startFetch(entry, key, args)
213
+ }
214
+ }
215
+
216
+ return {
217
+ read(argsAccessor: (() => Args) | Args): ResourceResult<T> {
218
+ const entryRef = createSignal<ResourceEntry<T, Args> | null>(null)
219
+
220
+ createEffect(() => {
221
+ const key = computeKey(argsAccessor)
222
+ const entry = ensureEntry(key)
223
+ entryRef(entry)
224
+ const args = readArgs(argsAccessor)
225
+ const currentVersion = entry.version()
226
+ const expired = isExpired(entry)
227
+ const argsChanged = entry.lastArgs !== args
228
+ const versionChanged = entry.lastVersion !== currentVersion
229
+ const resetToken = readResetToken()
230
+ const resetChanged = entry.lastReset !== resetToken
231
+ const shouldRefetch =
232
+ expired ||
233
+ argsChanged ||
234
+ versionChanged ||
235
+ resetChanged ||
236
+ (entry.status === 'error' && !resolvedCacheOptions.cacheErrors)
237
+
238
+ entry.lastArgs = args
239
+ entry.lastVersion = currentVersion
240
+ entry.lastReset = resetToken
241
+
242
+ if (shouldRefetch) {
243
+ if (entry.inFlight && (argsChanged || versionChanged)) {
244
+ entry.controller?.abort()
245
+ entry.inFlight = undefined
246
+ }
247
+ if (resetChanged) {
248
+ entry.hasValue = false
249
+ entry.expiresAt = Date.now() - 1
250
+ }
251
+ startFetch(entry, key, args as Args)
252
+ } else if (
253
+ entry.inFlight === undefined &&
254
+ resolvedCacheOptions.staleWhileRevalidate &&
255
+ expired &&
256
+ entry.hasValue
257
+ ) {
258
+ // stale-while-revalidate: return stale data but refresh.
259
+ startFetch(entry, key, args as Args)
260
+ }
261
+
262
+ if (resolvedCacheOptions.staleWhileRevalidate && entry.hasValue && expired) {
263
+ entry.loading(true)
264
+ }
265
+ })
266
+
267
+ return {
268
+ get data() {
269
+ const entry = entryRef()
270
+ if (!entry) return undefined
271
+ if (useSuspense && entry.pendingToken) {
272
+ throw entry.pendingToken.token
273
+ }
274
+ return entry.data()
275
+ },
276
+ get loading() {
277
+ const entry = entryRef()
278
+ return entry ? entry.loading() : false
279
+ },
280
+ get error() {
281
+ const entry = entryRef()
282
+ return entry ? entry.error() : undefined
283
+ },
284
+ refresh: () => {
285
+ const entry = entryRef()
286
+ if (entry) entry.version(entry.version() + 1)
287
+ },
288
+ }
289
+ },
290
+ invalidate,
291
+ prefetch,
292
+ }
293
+ }
package/src/slim.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Slim distribution that targets the fine-grained DOM runtime only.
2
+ export * from '@fictjs/runtime/slim'
package/src/store.ts ADDED
@@ -0,0 +1,165 @@
1
+ import { createSignal, type Signal } from '@fictjs/runtime'
2
+
3
+ type AnyFn = (...args: unknown[]) => unknown
4
+ interface BoundMethodEntry {
5
+ ref: AnyFn
6
+ bound: AnyFn
7
+ }
8
+
9
+ const PROXY_CACHE = new WeakMap<object, unknown>()
10
+ const SIGNAL_CACHE = new WeakMap<object, Record<string | symbol, Signal<unknown>>>()
11
+ const BOUND_METHOD_CACHE = new WeakMap<object, Map<string | symbol, BoundMethodEntry>>()
12
+ const ITERATE_KEY = Symbol('iterate')
13
+
14
+ function getSignal(target: object, prop: string | symbol): Signal<unknown> {
15
+ let signals = SIGNAL_CACHE.get(target)
16
+ if (!signals) {
17
+ signals = {}
18
+ SIGNAL_CACHE.set(target, signals)
19
+ }
20
+ if (!signals[prop]) {
21
+ const initial = prop === ITERATE_KEY ? 0 : (target as Record<string | symbol, unknown>)[prop]
22
+ signals[prop] = createSignal(initial)
23
+ }
24
+ return signals[prop]
25
+ }
26
+
27
+ function triggerIteration(target: object) {
28
+ const signals = SIGNAL_CACHE.get(target)
29
+ if (signals && signals[ITERATE_KEY]) {
30
+ const current = signals[ITERATE_KEY]() as number
31
+ signals[ITERATE_KEY](current + 1)
32
+ }
33
+ }
34
+
35
+ export function $store<T extends object>(initialValue: T): T {
36
+ if (typeof initialValue !== 'object' || initialValue === null) {
37
+ return initialValue
38
+ }
39
+
40
+ if (PROXY_CACHE.has(initialValue)) {
41
+ return PROXY_CACHE.get(initialValue) as T
42
+ }
43
+
44
+ const proxy = new Proxy(initialValue, {
45
+ get(target, prop, receiver) {
46
+ // Always touch the signal so reference changes to this property are tracked,
47
+ // even if the value is an object we proxy further.
48
+ const signal = getSignal(target, prop)
49
+ const trackedValue = signal()
50
+
51
+ const currentValue = Reflect.get(target, prop, receiver ?? proxy)
52
+ if (currentValue !== trackedValue) {
53
+ // If the value has changed (e.g. via direct mutation of the underlying object not via proxy),
54
+ // we update the signal to keep it in sync.
55
+ // Note: This is a bit of a heuristic. Ideally all mutations go through proxy.
56
+ signal(currentValue)
57
+ }
58
+
59
+ if (typeof currentValue === 'function') {
60
+ let boundMethods = BOUND_METHOD_CACHE.get(target)
61
+ if (!boundMethods) {
62
+ boundMethods = new Map()
63
+ BOUND_METHOD_CACHE.set(target, boundMethods)
64
+ }
65
+ const cached = boundMethods.get(prop)
66
+ if (cached && cached.ref === currentValue) {
67
+ return cached.bound
68
+ }
69
+
70
+ const bound = (currentValue as AnyFn).bind(receiver ?? proxy)
71
+ boundMethods.set(prop, { ref: currentValue as AnyFn, bound })
72
+ return bound
73
+ }
74
+
75
+ // If the value is an object/array, we recursively wrap it in a store
76
+ if (typeof currentValue === 'object' && currentValue !== null) {
77
+ return $store(currentValue as Record<string, unknown>)
78
+ }
79
+
80
+ // For primitives (and functions), we return the signal value (which tracks the read)
81
+ return currentValue
82
+ },
83
+
84
+ set(target, prop, newValue, receiver) {
85
+ const oldValue = Reflect.get(target, prop, receiver)
86
+ const hadKey = Object.prototype.hasOwnProperty.call(target, prop)
87
+
88
+ // If value hasn't changed, do nothing
89
+ if (oldValue === newValue && hadKey) {
90
+ return true
91
+ }
92
+
93
+ const result = Reflect.set(target, prop, newValue, receiver)
94
+
95
+ // IMPORTANT: Clear bound method cache BEFORE updating the signal
96
+ const boundMethods = BOUND_METHOD_CACHE.get(target)
97
+ if (boundMethods && boundMethods.has(prop)) {
98
+ boundMethods.delete(prop)
99
+ }
100
+
101
+ // Update the signal if it exists
102
+ const signals = SIGNAL_CACHE.get(target)
103
+ if (signals && signals[prop]) {
104
+ signals[prop](newValue)
105
+ }
106
+
107
+ // If new property, trigger iteration update
108
+ if (!hadKey) {
109
+ triggerIteration(target)
110
+ }
111
+
112
+ // Ensure array length subscribers are notified even if the native push/pop
113
+ // doesn't trigger a separate set trap for "length" (defensive).
114
+ if (Array.isArray(target) && prop !== 'length') {
115
+ const signals = SIGNAL_CACHE.get(target)
116
+ if (signals && signals.length) {
117
+ signals.length((target as unknown as { length: number }).length)
118
+ }
119
+ }
120
+
121
+ // If it's an array and length changed implicitly, we might need to handle it.
122
+ // But usually 'length' is set explicitly or handled by the runtime.
123
+ if (Array.isArray(target) && prop === 'length') {
124
+ triggerIteration(target)
125
+ }
126
+
127
+ return result
128
+ },
129
+
130
+ deleteProperty(target, prop) {
131
+ const hadKey = Object.prototype.hasOwnProperty.call(target, prop)
132
+ const result = Reflect.deleteProperty(target, prop)
133
+
134
+ if (result && hadKey) {
135
+ const signals = SIGNAL_CACHE.get(target)
136
+ if (signals && signals[prop]) {
137
+ signals[prop](undefined)
138
+ }
139
+
140
+ // Clear bound method cache
141
+ const boundMethods = BOUND_METHOD_CACHE.get(target)
142
+ if (boundMethods && boundMethods.has(prop)) {
143
+ boundMethods.delete(prop)
144
+ }
145
+
146
+ triggerIteration(target)
147
+ }
148
+
149
+ return result
150
+ },
151
+
152
+ ownKeys(target) {
153
+ getSignal(target, ITERATE_KEY)()
154
+ return Reflect.ownKeys(target)
155
+ },
156
+
157
+ has(target, prop) {
158
+ getSignal(target, prop)()
159
+ return Reflect.has(target, prop)
160
+ },
161
+ })
162
+
163
+ PROXY_CACHE.set(initialValue, proxy)
164
+ return proxy
165
+ }