fusee-framework 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +145 -139
  2. package/app/benchmark/benchmark.js +2 -2
  3. package/app/main.js +24 -3
  4. package/app/main.ts +7 -1
  5. package/app/pages/_layout.js +27 -0
  6. package/app/pages/async-work.js +65 -0
  7. package/app/pages/index.js +25 -5
  8. package/app/pages/index.ts +9 -0
  9. package/app/pages/router-example.js +24 -1
  10. package/app/pages/scroll-demo.ts +102 -0
  11. package/app/providers/ChildComponent.js +8 -2
  12. package/app/providers/ChildComponent.ts +47 -47
  13. package/app/providers/GrandchildComponent.js +19 -0
  14. package/app/providers/ParentComponent.js +5 -1
  15. package/app/routes/RouterExample.js +23 -1
  16. package/app/routes/TestResource.js +47 -0
  17. package/app/services.js +15 -0
  18. package/auto-imports.d.ts +8 -0
  19. package/framework/auto-import-preset.js +13 -9
  20. package/framework/core/component.js +27 -13
  21. package/framework/core/di.js +187 -0
  22. package/framework/core/directives.js +40 -96
  23. package/framework/core/evaluator.js +1 -1
  24. package/framework/core/reconcile.js +151 -0
  25. package/framework/core/signal.js +267 -0
  26. package/framework/core/store.js +322 -81
  27. package/framework/index.js +5 -3
  28. package/framework/router/file-router.js +1 -1
  29. package/framework/router/router.js +113 -21
  30. package/framework/types/component.d.ts +89 -88
  31. package/framework/types/di.d.ts +43 -0
  32. package/framework/types/index.d.ts +32 -22
  33. package/framework/types/reconcile.d.ts +7 -0
  34. package/framework/types/router.d.ts +11 -1
  35. package/framework/types/signal.d.ts +147 -79
  36. package/framework/types/store.d.ts +66 -8
  37. package/package.json +1 -1
  38. package/vite.config.js +7 -1
@@ -28,6 +28,34 @@ export function batch(fn) {
28
28
  }
29
29
  }
30
30
 
31
+ const asyncJobQueueHigh = []
32
+ const asyncJobQueueLow = []
33
+ let asyncJobFlushScheduled = false
34
+
35
+ function flushAsyncJobs() {
36
+ asyncJobFlushScheduled = false
37
+
38
+ while (asyncJobQueueHigh.length || asyncJobQueueLow.length) {
39
+ const job = asyncJobQueueHigh.shift() || asyncJobQueueLow.shift()
40
+ try {
41
+ job()
42
+ } catch (e) {
43
+ console.warn('[framework] Async job error:', e)
44
+ }
45
+ }
46
+ }
47
+
48
+ export function scheduleAsyncJob(fn, opts = {}) {
49
+ const priority = opts.priority || 'high'
50
+ if (priority === 'low') asyncJobQueueLow.push(fn)
51
+ else asyncJobQueueHigh.push(fn)
52
+
53
+ if (!asyncJobFlushScheduled) {
54
+ asyncJobFlushScheduled = true
55
+ setTimeout(flushAsyncJobs, 0)
56
+ }
57
+ }
58
+
31
59
  function scheduleEffect(effectFn) {
32
60
  if (batchDepth > 0) {
33
61
  pendingEffects.add(effectFn)
@@ -403,6 +431,189 @@ function hasWatchChanged(newValue, oldValue, isMultiSource, equals) {
403
431
  return !equals(newValue, oldValue)
404
432
  }
405
433
 
434
+ const globalInFlightByKey = new Map()
435
+ const globalInFlightByFetcher = new WeakMap()
436
+
437
+ export function resource(sourceOrFetcher, fetcherOrOptions, optionsObj) {
438
+ let source = null
439
+ let actualFetcher = null
440
+ let options = {}
441
+
442
+ if (arguments.length === 1) {
443
+ actualFetcher = sourceOrFetcher
444
+ } else if (arguments.length === 2) {
445
+ if (typeof fetcherOrOptions === 'function') {
446
+ source = sourceOrFetcher
447
+ actualFetcher = fetcherOrOptions
448
+ } else {
449
+ actualFetcher = sourceOrFetcher
450
+ options = fetcherOrOptions || {}
451
+ }
452
+ } else if (arguments.length >= 3) {
453
+ source = sourceOrFetcher
454
+ actualFetcher = fetcherOrOptions
455
+ options = optionsObj || {}
456
+ }
457
+
458
+ const data = signal(undefined)
459
+ const loading = signal(false)
460
+ const isFetching = signal(false)
461
+ const error = signal(undefined)
462
+
463
+ let currentPromiseId = 0
464
+ let currentPromise = null
465
+ const cache = new Map()
466
+ const staleTime = typeof options.staleTime === 'number' ? options.staleTime : 0
467
+
468
+ function serializeKey(input) {
469
+ if (input === undefined) return 'undefined'
470
+ if (input === null) return 'null'
471
+ if (typeof input === 'object') return JSON.stringify(input)
472
+ return String(input)
473
+ }
474
+
475
+ function makeScheduledPromise(runFetcher) {
476
+ try {
477
+ return Promise.resolve(runFetcher())
478
+ } catch (e) {
479
+ return Promise.reject(e)
480
+ }
481
+ }
482
+
483
+ async function load(input, force = false) {
484
+ const key = serializeKey(input)
485
+ const cached = force ? null : cache.get(key)
486
+ const isStale = !cached || (Date.now() - cached.updatedAt > staleTime)
487
+
488
+ if (cached) {
489
+ batch(() => {
490
+ data(cached.data)
491
+ error(undefined)
492
+ loading(false)
493
+ })
494
+ if (!isStale && !force) return
495
+ batch(() => { isFetching(true) })
496
+ } else {
497
+ batch(() => {
498
+ loading(true)
499
+ isFetching(true)
500
+ error(undefined)
501
+ })
502
+ }
503
+
504
+ const id = ++currentPromiseId
505
+
506
+ batch(() => {
507
+ isFetching(true)
508
+ })
509
+
510
+ let promise
511
+
512
+ if (options.key !== undefined) {
513
+ const dedupeKey = options.key ? `key_${options.key}_${key}` : `fn_${actualFetcher.name}_${key}`;
514
+ promise = globalInFlightByKey.get(dedupeKey)
515
+
516
+ if (!promise) {
517
+ promise = makeScheduledPromise(() => actualFetcher(input))
518
+ globalInFlightByKey.set(dedupeKey, promise)
519
+
520
+ promise.finally(() => {
521
+ if (globalInFlightByKey.get(dedupeKey) === promise) {
522
+ globalInFlightByKey.delete(dedupeKey)
523
+ }
524
+ }).catch(() => {})
525
+ }
526
+ } else {
527
+ let inFlightMap = globalInFlightByFetcher.get(actualFetcher)
528
+ if (!inFlightMap) {
529
+ inFlightMap = new Map()
530
+ globalInFlightByFetcher.set(actualFetcher, inFlightMap)
531
+ }
532
+
533
+ promise = inFlightMap.get(key)
534
+ if (!promise) {
535
+ promise = makeScheduledPromise(() => actualFetcher(input))
536
+ inFlightMap.set(key, promise)
537
+
538
+ promise.finally(() => {
539
+ if (inFlightMap.get(key) === promise) {
540
+ inFlightMap.delete(key)
541
+ }
542
+ }).catch(() => {})
543
+ }
544
+ }
545
+
546
+ currentPromise = promise
547
+
548
+ try {
549
+ const result = await promise
550
+
551
+ if (id !== currentPromiseId) return
552
+
553
+ cache.set(key, { data: result, updatedAt: Date.now() })
554
+
555
+ batch(() => {
556
+ data(result)
557
+ loading(false)
558
+ isFetching(false)
559
+ })
560
+
561
+ currentPromise = null
562
+ } catch (err) {
563
+ if (id !== currentPromiseId) return
564
+
565
+ batch(() => {
566
+ error(err)
567
+ loading(false)
568
+ isFetching(false)
569
+ })
570
+
571
+ currentPromise = null
572
+ }
573
+ }
574
+
575
+ if (source) {
576
+ effect(() => {
577
+ const input = typeof source === 'function' ? source() : source
578
+ if (input !== null && input !== false && input !== undefined) {
579
+ load(input)
580
+ }
581
+ })
582
+ } else {
583
+ load()
584
+ }
585
+
586
+ const accessor = () => data()
587
+ accessor.isSignal = true
588
+ accessor.loading = loading
589
+ accessor.isFetching = isFetching
590
+ accessor.error = error
591
+ accessor.read = () => {
592
+ if (loading() && currentPromise) {
593
+ throw currentPromise
594
+ }
595
+ const err = error()
596
+ if (err !== undefined) {
597
+ throw err
598
+ }
599
+ return data()
600
+ }
601
+
602
+ const mutate = (val) => {
603
+ const input = source ? (typeof source === 'function' ? source() : source) : undefined
604
+ const key = serializeKey(input)
605
+ cache.set(key, { data: val, updatedAt: Date.now() })
606
+ data(val)
607
+ }
608
+
609
+ const refetch = () => {
610
+ const input = source ? (typeof source === 'function' ? source() : source) : undefined
611
+ load(input, true)
612
+ }
613
+
614
+ return [accessor, { mutate, refetch }]
615
+ }
616
+
406
617
  function runWatchCleanup(cleanupRef) {
407
618
  if (typeof cleanupRef.fn === 'function') {
408
619
  const fn = cleanupRef.fn
@@ -410,3 +621,59 @@ function runWatchCleanup(cleanupRef) {
410
621
  untrack(() => fn())
411
622
  }
412
623
  }
624
+
625
+ export function createSuspense(renderFn, fallbackFn, options = {}) {
626
+ const out = signal(undefined)
627
+ const pending = signal(false)
628
+ const errSig = signal(undefined)
629
+
630
+ const retryTick = signal(0)
631
+ let token = 0
632
+
633
+ const dispose = effect(() => {
634
+ retryTick()
635
+ const myToken = ++token
636
+ let canceled = false
637
+ onCleanup(() => { canceled = true })
638
+
639
+ try {
640
+ pending(false)
641
+ errSig(undefined)
642
+ const v = renderFn()
643
+ out(v)
644
+ } catch (e) {
645
+ if (e instanceof Promise) {
646
+ pending(true)
647
+ errSig(undefined)
648
+ out(typeof fallbackFn === 'function' ? fallbackFn() : fallbackFn)
649
+
650
+ e.then(
651
+ () => {
652
+ if (canceled) return
653
+ if (myToken !== token) return
654
+ retryTick(retryTick() + 1)
655
+ },
656
+ (err) => {
657
+ if (canceled) return
658
+ if (myToken !== token) return
659
+ errSig(err)
660
+ pending(false)
661
+ if (options.onError) options.onError(err)
662
+ retryTick(retryTick() + 1)
663
+ }
664
+ )
665
+
666
+ return
667
+ }
668
+
669
+ errSig(e)
670
+ pending(false)
671
+ if (options.onError) options.onError(e)
672
+ }
673
+ })
674
+
675
+ out.pending = pending
676
+ out.error = errSig
677
+
678
+ return [out, dispose]
679
+ }
@@ -1,81 +1,322 @@
1
- import { getCurrentInstance, setCurrentInstance } from './component.js'
2
-
3
- const storesRegistry = new Map()
4
- const storePlugins = []
5
-
6
- export function registerStorePlugin(plugin) {
7
- if (typeof plugin === 'function') {
8
- storePlugins.push(plugin)
9
- }
10
- }
11
-
12
- const isDev = typeof import.meta.env !== 'undefined' ? !!import.meta.env.DEV : true
13
- if (isDev && typeof window !== 'undefined') {
14
- window.__FRAMEWORK_STORES__ = storesRegistry
15
- }
16
-
17
- export function defineStore(id, setup) {
18
- if (typeof id !== 'string' || !id.trim()) {
19
- console.error('[framework] defineStore requires a valid string ID.')
20
- }
21
-
22
- function useStore() {
23
- let store = storesRegistry.get(id)
24
-
25
- if (!store) {
26
- const prevInstance = getCurrentInstance()
27
-
28
- setCurrentInstance(null)
29
-
30
- try {
31
- store = setup()
32
-
33
- if (store instanceof Promise) {
34
- console.error(`[framework] Store "${id}" cannot be async.`)
35
- throw new Error(`Async store "${id}" is not supported.`)
36
- }
37
-
38
- } catch (err) {
39
- console.error(`[framework] Error initializing store "${id}":`, err)
40
- throw err
41
- } finally {
42
- setCurrentInstance(prevInstance)
43
- }
44
-
45
- Object.defineProperties(store, {
46
- $id: {
47
- value: id,
48
- enumerable: false
49
- },
50
- $type: {
51
- value: 'store',
52
- enumerable: false
53
- }
54
- })
55
-
56
- for (const plugin of storePlugins) {
57
- try {
58
- plugin(store, id)
59
- } catch (e) {
60
- console.warn(`[framework] Store plugin error for "${id}":`, e)
61
- }
62
- }
63
-
64
- storesRegistry.set(id, store)
65
- }
66
-
67
- return store
68
- }
69
-
70
- useStore.$id = id
71
-
72
- return useStore
73
- }
74
-
75
- export function resetStore(id) {
76
- storesRegistry.delete(id)
77
- }
78
-
79
- export function clearStores() {
80
- storesRegistry.clear()
81
- }
1
+ import { getCurrentInstance, setCurrentInstance } from './component.js'
2
+ import { batch, effect } from './signal.js'
3
+
4
+
5
+ const storesRegistry = new Map()
6
+ const storePlugins = []
7
+ const initializationStack = new Set()
8
+
9
+ export const MutationType = {
10
+ DIRECT: 'direct',
11
+ PATCH_OBJECT: 'patch object',
12
+ PATCH_FUNCTION: 'patch function'
13
+ }
14
+
15
+ export function registerStorePlugin(plugin) {
16
+ if (typeof plugin === 'function') {
17
+ storePlugins.push(plugin)
18
+ }
19
+ }
20
+
21
+ export function useNestedStore(useStoreFn) {
22
+ if (typeof useStoreFn !== 'function' || !useStoreFn.$id) {
23
+ console.error('[framework] useNestedStore requires a valid store hook (defineStore result)')
24
+ throw new Error('Invalid store hook provided to useNestedStore')
25
+ }
26
+
27
+ const targetId = useStoreFn.$id
28
+
29
+ if (initializationStack.has(targetId)) {
30
+ const chain = Array.from(initializationStack).concat(targetId).join(' -> ')
31
+ console.error(`[framework] Circular dependency detected: ${chain}`)
32
+ throw new Error(`Circular store dependency detected: ${chain}`)
33
+ }
34
+
35
+ return useStoreFn()
36
+ }
37
+
38
+ const isDev = typeof import.meta.env !== 'undefined' ? !!import.meta.env.DEV : true
39
+ if (isDev && typeof window !== 'undefined') {
40
+ window.__FRAMEWORK_STORES__ = storesRegistry
41
+ }
42
+
43
+ export function defineStore(id, setup) {
44
+ if (typeof id !== 'string' || !id.trim()) {
45
+ console.error('[framework] defineStore requires a valid string ID.')
46
+ }
47
+
48
+ function useStore() {
49
+ let store = storesRegistry.get(id)
50
+
51
+ if (!store) {
52
+ const prevInstance = getCurrentInstance()
53
+
54
+ setCurrentInstance(null)
55
+ initializationStack.add(id)
56
+
57
+ try {
58
+ store = setup()
59
+
60
+ if (store instanceof Promise) {
61
+ console.error(`[framework] Store "${id}" cannot be async.`)
62
+ throw new Error(`Async store "${id}" is not supported.`)
63
+ }
64
+
65
+ } catch (err) {
66
+ console.error(`[framework] Error initializing store "${id}":`, err)
67
+ throw err
68
+ } finally {
69
+ initializationStack.delete(id)
70
+ setCurrentInstance(prevInstance)
71
+ }
72
+
73
+ const initialState = {}
74
+ const signalKeys = []
75
+ const getterKeys = []
76
+ for (const key in store) {
77
+ if (typeof store[key] === 'function' && store[key].isSignal) {
78
+ if (store[key].readonly) {
79
+ getterKeys.push(key)
80
+ } else {
81
+ initialState[key] = store[key]()
82
+ signalKeys.push(key)
83
+ }
84
+ }
85
+ }
86
+
87
+ // Subscription management
88
+ const subscriptions = new Map()
89
+ let subscriptionId = 0
90
+ let currentMutation = null
91
+
92
+ function notifySubscribers() {
93
+ if (!currentMutation) return
94
+ const mutation = currentMutation
95
+ currentMutation = null
96
+
97
+ const state = {}
98
+ for (const key of signalKeys) {
99
+ state[key] = store[key]()
100
+ }
101
+
102
+ for (const [subId, sub] of subscriptions) {
103
+ if (sub.flush === 'sync') {
104
+ sub.callback(mutation, state)
105
+ } else {
106
+ // Default: post (after all batched updates)
107
+ queueMicrotask(() => {
108
+ if (subscriptions.has(subId)) {
109
+ sub.callback(mutation, state)
110
+ }
111
+ })
112
+ }
113
+ }
114
+ }
115
+
116
+ function wrapSignal(key, originalSignal) {
117
+ const wrapped = function(newValue) {
118
+ if (arguments.length === 0) {
119
+ return originalSignal()
120
+ } else {
121
+ const result = originalSignal(newValue)
122
+ if (!currentMutation) {
123
+ currentMutation = {
124
+ type: MutationType.DIRECT,
125
+ storeId: id,
126
+ payload: { [key]: newValue }
127
+ }
128
+ notifySubscribers()
129
+ }
130
+ return result
131
+ }
132
+ }
133
+ wrapped.isSignal = true
134
+ for (const method of ['map', 'filter', 'slice', 'concat', 'flat', 'flatMap',
135
+ 'find', 'findLast', 'findIndex', 'findLastIndex',
136
+ 'indexOf', 'lastIndexOf', 'includes', 'every', 'some',
137
+ 'reduce', 'at', 'join', 'push', 'pop', 'shift', 'unshift',
138
+ 'splice', 'remove', 'clear', 'sort', 'reverse']) {
139
+ if (originalSignal[method]) {
140
+ wrapped[method] = originalSignal[method]
141
+ }
142
+ }
143
+ return wrapped
144
+ }
145
+
146
+ function wrapGetter(key, originalGetter) {
147
+ const wrapped = function(newValue) {
148
+ if (arguments.length === 0) {
149
+ return originalGetter()
150
+ } else {
151
+ console.warn(`[framework] Getter "${key}" is read-only`)
152
+ return originalGetter()
153
+ }
154
+ }
155
+ wrapped.isSignal = true
156
+ wrapped.readonly = true
157
+ for (const method of ['map', 'filter', 'slice', 'concat', 'flat', 'flatMap',
158
+ 'find', 'findLast', 'findIndex', 'findLastIndex',
159
+ 'indexOf', 'lastIndexOf', 'includes', 'every', 'some',
160
+ 'reduce', 'at', 'join']) {
161
+ if (originalGetter[method]) {
162
+ wrapped[method] = originalGetter[method]
163
+ }
164
+ }
165
+ return wrapped
166
+ }
167
+
168
+ const wrappedSignals = {}
169
+ for (const key of signalKeys) {
170
+ wrappedSignals[key] = wrapSignal(key, store[key])
171
+ store[key] = wrappedSignals[key]
172
+ }
173
+
174
+ for (const key of getterKeys) {
175
+ store[key] = wrapGetter(key, store[key])
176
+ }
177
+
178
+ Object.defineProperties(store, {
179
+ id: {
180
+ value: id,
181
+ enumerable: false
182
+ },
183
+ type: {
184
+ value: 'store',
185
+ enumerable: false
186
+ },
187
+ patch: {
188
+ value: (arg) => {
189
+ const isObjectPatch = arg && typeof arg === 'object'
190
+ currentMutation = {
191
+ type: isObjectPatch ? MutationType.PATCH_OBJECT : MutationType.PATCH_FUNCTION,
192
+ storeId: id,
193
+ payload: isObjectPatch ? { ...arg } : undefined
194
+ }
195
+
196
+ batch(() => {
197
+ if (typeof arg === 'function') {
198
+ arg(store)
199
+ } else if (isObjectPatch) {
200
+ for (const key in arg) {
201
+ if (typeof store[key] === 'function' && store[key].isSignal && !store[key].readonly) {
202
+ const originalSignal = wrappedSignals[key]
203
+ originalSignal(arg[key])
204
+ }
205
+ }
206
+ }
207
+ })
208
+
209
+ notifySubscribers()
210
+ },
211
+ enumerable: false
212
+ },
213
+ reset: {
214
+ value: () => {
215
+ currentMutation = {
216
+ type: MutationType.PATCH_FUNCTION,
217
+ storeId: id,
218
+ payload: undefined
219
+ }
220
+
221
+ batch(() => {
222
+ for (const key in initialState) {
223
+ const originalSignal = wrappedSignals[key]
224
+ originalSignal(initialState[key])
225
+ }
226
+ })
227
+
228
+ notifySubscribers()
229
+ },
230
+ enumerable: false
231
+ },
232
+ subscribe: {
233
+ value: (callback, options = {}) => {
234
+ if (typeof callback !== 'function') {
235
+ console.warn('[framework] subscribe callback must be a function')
236
+ return () => {}
237
+ }
238
+
239
+ const subId = ++subscriptionId
240
+ const sub = {
241
+ callback,
242
+ flush: options.flush || 'post',
243
+ detached: options.detached || false
244
+ }
245
+ subscriptions.set(subId, sub)
246
+
247
+ if (!sub.detached) {
248
+ const instance = getCurrentInstance()
249
+ if (instance && instance._unmountHooks) {
250
+ instance._unmountHooks.push(() => {
251
+ subscriptions.delete(subId)
252
+ })
253
+ }
254
+ }
255
+
256
+ return () => {
257
+ subscriptions.delete(subId)
258
+ }
259
+ },
260
+ enumerable: false
261
+ }
262
+ })
263
+
264
+ for (const plugin of storePlugins) {
265
+ try {
266
+ plugin(store, id)
267
+ } catch (e) {
268
+ console.warn(`[framework] Store plugin error for "${id}":`, e)
269
+ }
270
+ }
271
+
272
+ storesRegistry.set(id, store)
273
+ }
274
+
275
+ return store
276
+ }
277
+
278
+ useStore.$id = id
279
+
280
+ return useStore
281
+ }
282
+
283
+ export function resetStore(id) {
284
+ storesRegistry.delete(id)
285
+ }
286
+
287
+ export function clearStores() {
288
+ storesRegistry.clear()
289
+ }
290
+
291
+ export function storeToRefs(store) {
292
+ const refs = {}
293
+ for (const key in store) {
294
+ const value = store[key]
295
+ if (typeof value === 'function' && value.isSignal) {
296
+ refs[key] = value
297
+ }
298
+ }
299
+ return refs
300
+ }
301
+
302
+ export function storeToState(store) {
303
+ const state = {}
304
+ for (const key in store) {
305
+ const value = store[key]
306
+ if (typeof value === 'function' && value.isSignal && !value.readonly) {
307
+ state[key] = value
308
+ }
309
+ }
310
+ return state
311
+ }
312
+
313
+ export function storeToGetters(store) {
314
+ const getters = {}
315
+ for (const key in store) {
316
+ const value = store[key]
317
+ if (typeof value === 'function' && value.isSignal && value.readonly) {
318
+ getters[key] = value
319
+ }
320
+ }
321
+ return getters
322
+ }