fusee-framework 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # Fusée Framework 🚀
2
- **v1.4.1 — Signals-First JS Framework | Atomic Reactivity | Peak Performance**
2
+ **v1.5.0 — Signals-First JS Framework | Atomic Reactivity | Peak Performance**
3
3
 
4
4
  Fusée is a custom, high-performance fine-grained reactive JavaScript framework built for speed and simplicity. It features a recursive, non-greedy compiler, a signals-based reactivity engine, Dependency Injection for components and a comprehensive CLI for instant application scaffolding.
5
5
 
@@ -76,10 +76,28 @@ export const Welcome = defineComponent({
76
76
 
77
77
  ---
78
78
 
79
+ ## ✅ Quality Assurance & Testing
80
+
81
+ Fusée is built with a test-driven mindset to ensure the reliability of its reactivity engine and component lifecycle.
82
+
83
+ - **Total Tests:** `200`
84
+ - **Status:** `All Passed`
85
+ - **Framework:** [Vitest](https://vitest.dev/)
86
+ - **Environment:** JSDOM (Browser simulation)
87
+
88
+ ### Test Coverage includes:
89
+ * **Reactivity Engine:** 100% (Signals, Batching, Computed, Watchers).
90
+ * **Component System:** Props, Slots, Async Components, and Lifecycle Hooks.
91
+ * **Dependency Injection:** Nested Provide/Inject and Shadowing.
92
+ * **Directives:** `f-if`, `f-for`, `f-model`, and Event Modifiers.
93
+ * **Memory Safety:** Explicit checks for memory leaks and automatic effect disposal.
94
+
95
+ ---
96
+
79
97
  ## 🚀 Future Roadmap
80
98
 
81
- - [ ] Global Store Management
82
- - [ ] Advanced file-based routing support
83
- - [ ] Integrated lightweight compiler which transforms declarative HTML into optimized runtime instructions
99
+ - [ ] Advanced file-based routing support and state management
100
+ - [ ] Lightweight compiler which transforms declarative HTML into optimized runtime instructions
101
+ - [ ] Integrated backend development support
84
102
 
85
103
  Built with ❤️ by me, Sebi Somu, a forward-thinking JavaScript Architect.
@@ -6,10 +6,8 @@ export const TestFor = defineComponent({
6
6
  { id: 3, name: 'Item 3' }
7
7
  ])
8
8
  const search = signal('')
9
-
10
- // ✨ NEW: Using the reactive .filter() method on the signal!
11
- // This returns a Computed signal that updates automatically.
12
- const filteredItems = items.filter(item =>
9
+
10
+ const filteredItems = items.filter(item =>
13
11
  item.name.toLowerCase().includes(search().toLowerCase())
14
12
  )
15
13
 
@@ -19,11 +17,9 @@ export const TestFor = defineComponent({
19
17
  items,
20
18
  search,
21
19
  filteredItems,
22
- // 🔗 Chaining test: filter -> map -> join
23
20
  namesSummary: filteredItems.map(i => i.name).join(', '),
24
21
 
25
22
  updateItem: (id, newName) => {
26
- // We update the SOURCE signal so the .filter() can see the new values!
27
23
  items(items().map(i => i.id === id ? { ...i, name: newName } : i))
28
24
  },
29
25
  addItem: () => {
package/auto-imports.d.ts CHANGED
@@ -9,6 +9,7 @@ declare global {
9
9
  const batch: typeof import('fusee-framework').batch
10
10
  const computed: typeof import('fusee-framework').computed
11
11
  const defineComponent: typeof import('fusee-framework').defineComponent
12
+ const defineStore: typeof import('fusee-framework').defineStore
12
13
  const directive: typeof import('fusee-framework').directive
13
14
  const effect: typeof import('fusee-framework').effect
14
15
  const emit: typeof import('fusee-framework').emit
@@ -21,4 +22,6 @@ declare global {
21
22
  const provide: typeof import('fusee-framework').provide
22
23
  const signal: typeof import('fusee-framework').signal
23
24
  const untrack: typeof import('fusee-framework').untrack
25
+ const watch: typeof import('fusee-framework').watch
26
+ const watchEffect: typeof import('fusee-framework').watchEffect
24
27
  }
@@ -1,6 +1,6 @@
1
1
  export const FuseePreset = {
2
2
  'fusee-framework': [
3
- 'signal', 'computed', 'effect',
3
+ 'signal', 'computed', 'effect', 'watch', 'watchEffect',
4
4
  'batch', 'untrack', 'inspect',
5
5
  'defineComponent', 'onMount', 'onUnmount',
6
6
  'directive',
@@ -11,6 +11,10 @@ export function getCurrentInstance() {
11
11
  return currentInstance
12
12
  }
13
13
 
14
+ export function setCurrentInstance(instance) {
15
+ currentInstance = instance
16
+ }
17
+
14
18
  function resolveProps(schema, received) {
15
19
  const isArray = Array.isArray(schema)
16
20
  const resolved = {}
@@ -139,6 +143,7 @@ export function defineComponent(options) {
139
143
 
140
144
  currentInstance = instance
141
145
  const result = options.setup(instance.props, { emit, slots })
146
+ instance.state = result
142
147
  result._instance = instance
143
148
  currentInstance = null
144
149
 
@@ -59,17 +59,33 @@ export function signal(initialValue) {
59
59
  }
60
60
 
61
61
  export function effect(fn) {
62
+ let active = true
63
+ let running = false
64
+
62
65
  const run = () => {
66
+ if (!active || running) {
67
+ if (running) console.warn('[framework] Recursive effect detected')
68
+ return
69
+ }
70
+
71
+ running = true
63
72
  for (const dep of run.deps) dep.delete(run)
64
73
  run.deps.clear()
65
74
 
75
+ const prevEffect = currentEffect
66
76
  currentEffect = run
67
77
  try { fn() }
68
- finally { currentEffect = null }
78
+ finally {
79
+ currentEffect = prevEffect
80
+ running = false
81
+ }
69
82
  }
70
83
 
71
84
  run.deps = new Set()
85
+
72
86
  const cleanup = () => {
87
+ active = false
88
+ pendingEffects.delete(run)
73
89
  for (const dep of run.deps) dep.delete(run)
74
90
  run.deps.clear()
75
91
  }
@@ -83,53 +99,80 @@ export function effect(fn) {
83
99
  export function computed(fn) {
84
100
  let value
85
101
  let dirty = true
102
+ let active = true
86
103
  const subscribers = new Set()
104
+ const deps = new Set()
87
105
 
88
106
  const computedNode = () => {
89
- if (!dirty) {
107
+ if (!active || dirty) return
108
+
109
+ if (subscribers.size > 0) {
110
+ if (!runRecompute()) return
111
+ } else {
90
112
  dirty = true
91
- for (const dep of computedNode.deps) dep.delete(computedNode)
92
- computedNode.deps.clear()
93
- for (const sub of [...subscribers]) scheduleEffect(sub)
113
+ for (const d of deps) d.delete(computedNode)
114
+ deps.clear()
115
+ return
94
116
  }
117
+
118
+ for (const sub of [...subscribers]) scheduleEffect(sub)
95
119
  }
96
120
 
97
- computedNode.deps = new Set()
121
+ computedNode.deps = deps
122
+
123
+ let computing = false
124
+
125
+ function runRecompute() {
126
+ if (computing) {
127
+ console.warn('[framework] Circular dependency detected in computed()')
128
+ return false
129
+ }
130
+
131
+ computing = true
132
+ for (const d of deps) d.delete(computedNode)
133
+ deps.clear()
134
+
135
+ const prevEffect = currentEffect
136
+ currentEffect = active ? computedNode : null
137
+ try {
138
+ const newValue = fn()
139
+ const changed = !Object.is(newValue, value)
140
+ value = newValue
141
+ dirty = false
142
+ return changed
143
+ } finally {
144
+ currentEffect = prevEffect
145
+ computing = false
146
+ }
147
+ }
98
148
 
99
149
  function accessor() {
100
150
  if (arguments.length === 0) {
101
- if (currentEffect) {
151
+ if (active && currentEffect) {
102
152
  subscribers.add(currentEffect)
103
153
  currentEffect.deps.add(subscribers)
104
154
  }
155
+
105
156
  if (dirty) {
106
- for (const dep of computedNode.deps) dep.delete(computedNode)
107
- computedNode.deps.clear()
108
-
109
- const prevEffect = currentEffect
110
- currentEffect = computedNode
111
- try {
112
- const newValue = fn()
113
- if (newValue !== value) value = newValue
114
- } finally {
115
- currentEffect = prevEffect
116
- dirty = false
117
- }
157
+ runRecompute()
118
158
  }
159
+
119
160
  return value
120
161
  }
121
162
  console.warn('[framework] computed() is read-only')
122
163
  }
123
164
 
124
165
  accessor.dispose = () => {
125
- for (const dep of computedNode.deps) dep.delete(computedNode)
126
- computedNode.deps.clear()
166
+ active = false
167
+ dirty = true
168
+ pendingEffects.delete(computedNode)
169
+ for (const d of deps) d.delete(computedNode)
170
+ deps.clear()
127
171
  subscribers.clear()
128
172
  }
129
173
 
130
174
  accessor.isSignal = true
131
175
  accessor.readonly = true
132
-
133
176
  addReactiveArrayMethods(accessor)
134
177
 
135
178
  return accessor
@@ -145,6 +188,82 @@ export function untrack(fn) {
145
188
  }
146
189
  }
147
190
 
191
+ export function watch(source, callback, options = {}) {
192
+ if (typeof callback !== 'function') {
193
+ console.warn('[framework] watch() callback must be a function')
194
+ return () => { }
195
+ }
196
+
197
+ const { immediate = false, equals = Object.is } = options
198
+ const { getter, isMultiSource } = normalizeWatchSource(source)
199
+
200
+ let initialized = false
201
+ let oldValue
202
+ const cleanupRef = { fn: null }
203
+
204
+ const onCleanup = (fn) => {
205
+ if (typeof fn !== 'function') {
206
+ console.warn('[framework] onCleanup() expects a function')
207
+ return
208
+ }
209
+ cleanupRef.fn = fn
210
+ }
211
+
212
+ const stopEffect = effect(() => {
213
+ const newValue = getter()
214
+
215
+ if (!initialized) {
216
+ initialized = true
217
+ oldValue = cloneWatchValue(newValue, isMultiSource)
218
+ if (immediate) {
219
+ untrack(() => callback(newValue, undefined, onCleanup))
220
+ }
221
+ return
222
+ }
223
+
224
+ if (!hasWatchChanged(newValue, oldValue, isMultiSource, equals)) {
225
+ return
226
+ }
227
+
228
+ const previousValue = oldValue
229
+ oldValue = cloneWatchValue(newValue, isMultiSource)
230
+
231
+ runWatchCleanup(cleanupRef)
232
+ untrack(() => callback(newValue, previousValue, onCleanup))
233
+ })
234
+
235
+ return () => {
236
+ stopEffect()
237
+ runWatchCleanup(cleanupRef)
238
+ }
239
+ }
240
+
241
+ export function watchEffect(fn) {
242
+ if (typeof fn !== 'function') {
243
+ console.warn('[framework] watchEffect() expects a function')
244
+ return () => { }
245
+ }
246
+
247
+ const cleanupRef = { fn: null }
248
+ const onCleanup = (cleanup) => {
249
+ if (typeof cleanup !== 'function') {
250
+ console.warn('[framework] onCleanup() expects a function')
251
+ return
252
+ }
253
+ cleanupRef.fn = cleanup
254
+ }
255
+
256
+ const stopEffect = effect(() => {
257
+ runWatchCleanup(cleanupRef)
258
+ fn(onCleanup)
259
+ })
260
+
261
+ return () => {
262
+ stopEffect()
263
+ runWatchCleanup(cleanupRef)
264
+ }
265
+ }
266
+
148
267
  export function inspect(...args) {
149
268
  const isDev = typeof import.meta.env !== 'undefined' ? !!import.meta.env.DEV : true
150
269
  if (!isDev) return
@@ -244,3 +363,48 @@ function addMutatingArrayMethods(accessor) {
244
363
  return accessor
245
364
  }
246
365
  }
366
+
367
+ // Helpers for watch/watchEffect
368
+ function normalizeWatchSource(source) {
369
+ if (Array.isArray(source)) {
370
+ const getters = source.map(normalizeSingleWatchSource)
371
+ return {
372
+ getter: () => getters.map(get => get()),
373
+ isMultiSource: true
374
+ }
375
+ }
376
+ return {
377
+ getter: normalizeSingleWatchSource(source),
378
+ isMultiSource: false
379
+ }
380
+ }
381
+
382
+ function normalizeSingleWatchSource(source) {
383
+ if (typeof source === 'function') return source
384
+ console.warn('[framework] watch() source should be a signal, getter, or an array of those')
385
+ return () => source
386
+ }
387
+
388
+ function cloneWatchValue(value, isMultiSource) {
389
+ if (isMultiSource && Array.isArray(value)) return value.slice()
390
+ return value
391
+ }
392
+
393
+ function hasWatchChanged(newValue, oldValue, isMultiSource, equals) {
394
+ if (isMultiSource) {
395
+ if (!Array.isArray(oldValue) || newValue.length !== oldValue.length) return true
396
+ for (let i = 0; i < newValue.length; i++) {
397
+ if (!equals(newValue[i], oldValue[i])) return true
398
+ }
399
+ return false
400
+ }
401
+ return !equals(newValue, oldValue)
402
+ }
403
+
404
+ function runWatchCleanup(cleanupRef) {
405
+ if (typeof cleanupRef.fn === 'function') {
406
+ const fn = cleanupRef.fn
407
+ cleanupRef.fn = null
408
+ untrack(() => fn())
409
+ }
410
+ }
@@ -0,0 +1,81 @@
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,10 +1,11 @@
1
1
  // ─── Framework Public API ─────────────────────────────────────────────────────
2
- export { signal, computed, effect, batch, untrack, inspect } from './core/signal.js'
2
+ export { signal, computed, effect, batch, untrack, inspect, watch, watchEffect } from './core/signal.js'
3
3
  export { defineComponent, defineAsyncComponent, onMount, onUnmount, parseSlots, provide, inject, getCurrentInstance } from './core/component.js'
4
4
  export { createRouter, navigate, mountOutlet } from './router/router.js'
5
5
  export { mountTemplate } from './core/compiler.js'
6
6
 
7
7
  export { defineComposable, assertSetupContext } from './core/composable.js'
8
+ export { defineStore, resetStore, clearStores, registerStorePlugin } from './core/store.js'
8
9
  export { directive } from './core/directives.js'
9
10
 
10
11
  export const emit = () => console.warn('[framework] emit() can only be used inside component setup()')
@@ -4,6 +4,7 @@ export * from './composable'
4
4
  export * from './router'
5
5
  export * from './compiler'
6
6
  export * from './directives'
7
+ export * from './store'
7
8
 
8
9
  declare global {
9
10
  type Signal<T = any> = import('./signal').Signal<T>
@@ -12,6 +13,7 @@ declare global {
12
13
  type Composable<T extends (...args: any[]) => any> = import('./composable').Composable<T>
13
14
  type DirectiveBinding<V = any> = import('./directives').DirectiveBinding<V>
14
15
  type DirectiveHooks<V = any> = import('./directives').DirectiveHooks<V>
16
+ const defineStore: typeof import('./store').defineStore
15
17
  }
16
18
 
17
19
  export as namespace Fusee
@@ -54,12 +54,27 @@ type EffectRunner = {
54
54
  deps: Set<Set<EffectRunner>>;
55
55
  }
56
56
 
57
+ export type WatchSource<T = any> = (() => T) | Signal<T> | Computed<T>
58
+ export type WatchCallback<T = any> = (newValue: T, oldValue: T | undefined, onCleanup: (fn: () => void) => void) => void
59
+
60
+ export interface WatchOptions {
61
+ immediate?: boolean;
62
+ equals?: (a: any, b: any) => boolean;
63
+ }
64
+
57
65
  export declare function signal<T extends any[]>(initialValue: T): ArraySignalAccessor<T>
58
66
  export declare function signal<T>(initialValue: T): SignalAccessor<T>
59
67
  export declare function effect(fn: () => void): () => void
60
68
  export declare function computed<T>(fn: () => T): Computed<T>
61
69
  export declare function batch<T>(fn: () => T): T
62
70
  export declare function untrack<T>(fn: () => T): T
71
+ export declare function watch<T>(source: WatchSource<T>, callback: WatchCallback<T>, options?: WatchOptions): () => void
72
+ export declare function watch<T extends any[]>(
73
+ sources: [...{ [K in keyof T]: WatchSource<T[K]> }],
74
+ callback: (newValue: T, oldValue: T | undefined, onCleanup: (fn: () => void) => void) => void,
75
+ options?: WatchOptions
76
+ ): () => void
77
+ export declare function watchEffect(fn: (onCleanup: (fn: () => void) => void) => void): () => void
63
78
  export declare function inspect(...args: any[]): (() => void) | void
64
79
 
65
80
  declare function setEffectHook(fn: (eff: any) => void): void
@@ -0,0 +1,8 @@
1
+ export function defineStore<T>(id: string, setup: () => T): () => T;
2
+ export function resetStore(id: string): void;
3
+ export function clearStores(): void;
4
+ export function registerStorePlugin(plugin: (store: any, id: string) => void): void;
5
+
6
+ declare global {
7
+ function defineStore<T>(id: string, setup: () => T): () => T;
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fusee-framework",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Fusée - Signals-first JS Framework | Peak Performance",
5
5
  "main": "framework/index.js",
6
6
  "types": "framework/types/index.d.ts",
@@ -12,7 +12,7 @@
12
12
  "dev": "vite",
13
13
  "build": "vite build",
14
14
  "preview": "vite preview",
15
- "test": "echo \"Error: no test specified\" && exit 1"
15
+ "test": "vitest run"
16
16
  },
17
17
  "keywords": [],
18
18
  "author": "",
@@ -25,8 +25,10 @@
25
25
  "registry": "https://registry.npmjs.org/"
26
26
  },
27
27
  "devDependencies": {
28
+ "jsdom": "^29.0.2",
28
29
  "typescript": "^6.0.2",
29
30
  "unplugin-auto-import": "^21.0.0",
30
- "vite": "^5.0.0"
31
+ "vite": "^5.0.0",
32
+ "vitest": "^4.1.4"
31
33
  }
32
- }
34
+ }
package/vite.config.js CHANGED
@@ -34,5 +34,10 @@ export default defineConfig({
34
34
  main: path.resolve(__dirname, 'index.html'),
35
35
  }
36
36
  }
37
+ },
38
+
39
+ test: {
40
+ environment: 'jsdom',
41
+ globals: true
37
42
  }
38
43
  })