fusee-framework 1.4.0 → 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.0 — 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.
package/app/main.js CHANGED
@@ -18,33 +18,33 @@ directive('highlight', {
18
18
  })
19
19
 
20
20
  const routes = [
21
- {
22
- path: '/',
21
+ {
22
+ path: '/',
23
23
  component: defineAsyncComponent({
24
24
  loader: () => import('./routes/Home.js'),
25
25
  loadingComponent: Loading
26
- })
26
+ })
27
27
  },
28
- {
29
- path: '/about',
28
+ {
29
+ path: '/about',
30
30
  component: defineAsyncComponent({
31
31
  loader: () => import('./routes/About.js'),
32
32
  loadingComponent: Loading
33
- })
33
+ })
34
34
  },
35
- {
36
- path: '/test-for',
35
+ {
36
+ path: '/test-for',
37
37
  component: defineAsyncComponent({
38
38
  loader: () => import('./routes/TestFor.js'),
39
39
  loadingComponent: Loading
40
- })
40
+ })
41
41
  },
42
- {
43
- path: '/dynamic',
42
+ {
43
+ path: '/dynamic',
44
44
  component: defineAsyncComponent({
45
45
  loader: () => import('./routes/TestDynamic.js'),
46
46
  loadingComponent: Loading
47
- })
47
+ })
48
48
  },
49
49
  {
50
50
  path: '/composables',
@@ -5,10 +5,23 @@ export const TestFor = defineComponent({
5
5
  { id: 2, name: 'Item 2' },
6
6
  { id: 3, name: 'Item 3' }
7
7
  ])
8
+ const search = signal('')
9
+
10
+ const filteredItems = items.filter(item =>
11
+ item.name.toLowerCase().includes(search().toLowerCase())
12
+ )
13
+
8
14
  inspect(items)
9
15
 
10
16
  return {
11
17
  items,
18
+ search,
19
+ filteredItems,
20
+ namesSummary: filteredItems.map(i => i.name).join(', '),
21
+
22
+ updateItem: (id, newName) => {
23
+ items(items().map(i => i.id === id ? { ...i, name: newName } : i))
24
+ },
12
25
  addItem: () => {
13
26
  const nextId = Math.max(0, ...items().map(i => i.id)) + 1
14
27
  items.push({ id: nextId, name: `Item ${nextId}` })
@@ -20,17 +33,28 @@ export const TestFor = defineComponent({
20
33
  <div class="page">
21
34
  <h1>Keyed f-for Test</h1>
22
35
  <p>Focus an input below, then click "Shuffle" or "Add". The focus should stay!</p>
36
+ <p style="color: #8b8bff; background: #8b8bff11; padding: 10px; border-radius: 4px; border-left: 3px solid #8b8bff;">
37
+ 🔗 <strong>Reactive Summary:</strong> {{ namesSummary || 'No items found' }}
38
+ </p>
23
39
 
24
- <div style="margin-bottom: 20px; display: flex; gap: 10px;">
25
- <button @click="addItem" class="primary-btn">Add Item</button>
26
- <button @click="shuffleItems" class="primary-btn">Shuffle Items</button>
40
+ <div style="margin-bottom: 20px; display: flex; flex-direction: column; gap: 10px;">
41
+ <input type="text" f-model="search" placeholder="🔍 Filter items..."
42
+ style="padding: 10px; border-radius: 4px; border: 1px solid #444; background: #111; color: #fff; width: 100%;" />
43
+
44
+ <div style="display: flex; gap: 10px;">
45
+ <button @click="addItem" class="primary-btn">Add Item</button>
46
+ <button @click="shuffleItems" class="primary-btn">Shuffle Items</button>
47
+ </div>
27
48
  </div>
28
49
 
29
50
  <div style="display: flex; flex-direction: column; gap: 10px;">
30
- <div f-for="item in items" :key="item.id"
51
+ <div f-for="item in filteredItems" :key="item.id"
31
52
  style="padding: 10px; border: 1px solid #333; display: flex; gap: 10px; align-items: center;">
32
53
  <span>ID: {{ item.id }}</span>
33
- <input type="text" :value="item.name" placeholder="Type here..."
54
+ <input type="text"
55
+ :value="item.name"
56
+ @input="updateItem(item().id, $event.target.value)"
57
+ placeholder="Type here..."
34
58
  style="background: #111; color: #fff; border: 1px solid #444; padding: 5px;" />
35
59
  </div>
36
60
  </div>
@@ -5,6 +5,10 @@ type Item = {
5
5
 
6
6
  type TestForResult = {
7
7
  items: Signal<Item[]>
8
+ search: Signal<string>
9
+ filteredItems: Computed<Item[]>
10
+ namesSummary: Computed<string>
11
+ updateItem: (id: number, name: string) => void
8
12
  addItem: () => void
9
13
  shuffleItems: () => void
10
14
  template: string
@@ -17,9 +21,20 @@ export const TestFor = defineComponent({
17
21
  { id: 2, name: 'Item 2' },
18
22
  { id: 3, name: 'Item 3' }
19
23
  ])
24
+ const search = signal('')
25
+ const filteredItems = items.filter(i =>
26
+ i.name.toLowerCase().includes(search().toLowerCase())
27
+ )
28
+ const namesSummary = filteredItems.map(i => i.name).join(', ')
20
29
 
21
30
  return {
22
31
  items,
32
+ search,
33
+ filteredItems,
34
+ namesSummary,
35
+ updateItem: (id, newName) => {
36
+ items(items().map(i => i.id === id ? { ...i, name: newName } : i))
37
+ },
23
38
  addItem: () => {
24
39
  const nextIds = items().map(i => i.id)
25
40
  const nextId = nextIds.length > 0 ? Math.max(...nextIds) + 1 : 1
@@ -32,17 +47,29 @@ export const TestFor = defineComponent({
32
47
  <div class="page">
33
48
  <h1>Keyed f-for Test (TypeScript)</h1>
34
49
  <p>Focus an input below, then click "Shuffle" or "Add". The focus should stay!</p>
50
+
51
+ <p style="color: #8b8bff; background: #8b8bff11; padding: 10px; border-radius: 4px; border-left: 3px solid #8b8bff;">
52
+ 🔗 <strong>Reactive Summary:</strong> {{ namesSummary || 'No items found' }}
53
+ </p>
35
54
 
36
- <div style="margin-bottom: 20px; display: flex; gap: 10px;">
37
- <button @click="addItem" class="primary-btn">Add Item</button>
38
- <button @click="shuffleItems" class="primary-btn">Shuffle Items</button>
55
+ <div style="margin-bottom: 20px; display: flex; flex-direction: column; gap: 10px;">
56
+ <input type="text" f-model="search" placeholder="🔍 Filter items..."
57
+ style="padding: 10px; border-radius: 4px; border: 1px solid #444; background: #111; color: #fff; width: 100%;" />
58
+
59
+ <div style="display: flex; gap: 10px;">
60
+ <button @click="addItem" class="primary-btn">Add Item</button>
61
+ <button @click="shuffleItems" class="primary-btn">Shuffle Items</button>
62
+ </div>
39
63
  </div>
40
64
 
41
65
  <div style="display: flex; flex-direction: column; gap: 10px;">
42
- <div f-for="item in items" :key="item.id"
66
+ <div f-for="item in filteredItems" :key="item.id"
43
67
  style="padding: 10px; border: 1px solid #333; display: flex; gap: 10px; align-items: center;">
44
68
  <span>ID: {{ item.id }}</span>
45
- <input type="text" :value="item.name" placeholder="Type here..."
69
+ <input type="text"
70
+ :value="item.name"
71
+ @input="updateItem(item().id, $event.target.value)"
72
+ placeholder="Type here..."
46
73
  style="background: #111; color: #fff; border: 1px solid #444; padding: 5px;" />
47
74
  </div>
48
75
  </div>
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
 
@@ -52,78 +52,40 @@ export function signal(initialValue) {
52
52
 
53
53
  accessor.isSignal = true
54
54
 
55
- if (Array.isArray(initialValue)) {
56
- accessor.push = (...items) => {
57
- const next = [...accessor(), ...items]
58
- accessor(next)
59
- return next.length
60
- }
61
-
62
- accessor.pop = () => {
63
- const arr = accessor()
64
- if (arr.length === 0) return undefined
65
- const item = arr[arr.length - 1]
66
- accessor(arr.slice(0, -1))
67
- return item
68
- }
69
-
70
- accessor.shift = () => {
71
- const arr = accessor()
72
- if (arr.length === 0) return undefined
73
- const item = arr[0]
74
- accessor(arr.slice(1))
75
- return item
76
- }
77
-
78
- accessor.unshift = (...items) => {
79
- const next = [...items, ...accessor()]
80
- accessor(next)
81
- return next.length
82
- }
83
-
84
- accessor.splice = (start, deleteCount, ...items) => {
85
- const arr = [...accessor()]
86
- const removed = arr.splice(start, deleteCount, ...items)
87
- accessor(arr)
88
- return removed
89
- }
90
-
91
- accessor.remove = (predicate) => {
92
- accessor(accessor().filter((item, i) => !predicate(item, i)))
93
- }
94
-
95
- accessor.clear = () => {
96
- accessor([])
97
- }
98
-
99
- accessor.sort = (compareFn) => {
100
- const next = [...accessor()].sort(compareFn)
101
- accessor(next)
102
- return accessor
103
- }
104
-
105
- accessor.reverse = () => {
106
- const next = [...accessor()].reverse()
107
- accessor(next)
108
- return accessor
109
- }
110
- }
55
+ if (Array.isArray(initialValue)) addMutatingArrayMethods(accessor)
56
+ addReactiveArrayMethods(accessor)
111
57
 
112
58
  return accessor
113
59
  }
114
60
 
115
61
  export function effect(fn) {
62
+ let active = true
63
+ let running = false
64
+
116
65
  const run = () => {
66
+ if (!active || running) {
67
+ if (running) console.warn('[framework] Recursive effect detected')
68
+ return
69
+ }
70
+
71
+ running = true
117
72
  for (const dep of run.deps) dep.delete(run)
118
73
  run.deps.clear()
119
74
 
75
+ const prevEffect = currentEffect
120
76
  currentEffect = run
121
77
  try { fn() }
122
- finally { currentEffect = null }
78
+ finally {
79
+ currentEffect = prevEffect
80
+ running = false
81
+ }
123
82
  }
124
83
 
125
84
  run.deps = new Set()
85
+
126
86
  const cleanup = () => {
87
+ active = false
88
+ pendingEffects.delete(run)
127
89
  for (const dep of run.deps) dep.delete(run)
128
90
  run.deps.clear()
129
91
  }
@@ -137,52 +99,82 @@ export function effect(fn) {
137
99
  export function computed(fn) {
138
100
  let value
139
101
  let dirty = true
102
+ let active = true
140
103
  const subscribers = new Set()
104
+ const deps = new Set()
141
105
 
142
106
  const computedNode = () => {
143
- if (!dirty) {
107
+ if (!active || dirty) return
108
+
109
+ if (subscribers.size > 0) {
110
+ if (!runRecompute()) return
111
+ } else {
144
112
  dirty = true
145
- for (const dep of computedNode.deps) dep.delete(computedNode)
146
- computedNode.deps.clear()
147
- for (const sub of [...subscribers]) scheduleEffect(sub)
113
+ for (const d of deps) d.delete(computedNode)
114
+ deps.clear()
115
+ return
148
116
  }
117
+
118
+ for (const sub of [...subscribers]) scheduleEffect(sub)
149
119
  }
150
120
 
151
- 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
+ }
152
148
 
153
149
  function accessor() {
154
150
  if (arguments.length === 0) {
155
- if (currentEffect) {
151
+ if (active && currentEffect) {
156
152
  subscribers.add(currentEffect)
157
153
  currentEffect.deps.add(subscribers)
158
154
  }
155
+
159
156
  if (dirty) {
160
- for (const dep of computedNode.deps) dep.delete(computedNode)
161
- computedNode.deps.clear()
162
-
163
- const prevEffect = currentEffect
164
- currentEffect = computedNode
165
- try {
166
- const newValue = fn()
167
- if (newValue !== value) value = newValue
168
- } finally {
169
- currentEffect = prevEffect
170
- dirty = false
171
- }
157
+ runRecompute()
172
158
  }
159
+
173
160
  return value
174
161
  }
175
162
  console.warn('[framework] computed() is read-only')
176
163
  }
177
164
 
178
165
  accessor.dispose = () => {
179
- for (const dep of computedNode.deps) dep.delete(computedNode)
180
- 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()
181
171
  subscribers.clear()
182
172
  }
183
173
 
184
174
  accessor.isSignal = true
185
175
  accessor.readonly = true
176
+ addReactiveArrayMethods(accessor)
177
+
186
178
  return accessor
187
179
  }
188
180
 
@@ -196,11 +188,82 @@ export function untrack(fn) {
196
188
  }
197
189
  }
198
190
 
199
- /**
200
- * Reactive log helper similar to Svelte 5's $inspect.
201
- * Watches signals or functions and logs their current values on change.
202
- * Only active in development mode.
203
- */
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
+
204
267
  export function inspect(...args) {
205
268
  const isDev = typeof import.meta.env !== 'undefined' ? !!import.meta.env.DEV : true
206
269
  if (!isDev) return
@@ -215,3 +278,133 @@ export function inspect(...args) {
215
278
  console.log('[inspect]', ...values)
216
279
  })
217
280
  }
281
+
282
+ function addReactiveArrayMethods(accessor) {
283
+ // Transformations
284
+ accessor.map = (fn) => computed(() => accessor()?.map?.(fn) ?? [])
285
+ accessor.filter = (fn) => computed(() => accessor()?.filter?.(fn) ?? [])
286
+ accessor.slice = (...args) => computed(() => accessor()?.slice?.(...args) ?? [])
287
+ accessor.concat = (...args) => computed(() => accessor()?.concat?.(...args) ?? [])
288
+ accessor.flat = (depth) => computed(() => accessor()?.flat?.(depth) ?? [])
289
+ accessor.flatMap = (fn) => computed(() => accessor()?.flatMap?.(fn) ?? [])
290
+
291
+ // Searches
292
+ accessor.find = (fn) => computed(() => accessor()?.find?.(fn))
293
+ accessor.findLast = (fn) => computed(() => accessor()?.findLast?.(fn))
294
+ accessor.findIndex = (fn) => computed(() => accessor()?.findIndex?.(fn))
295
+ accessor.findLastIndex = (fn) => computed(() => accessor()?.findLastIndex?.(fn))
296
+ accessor.indexOf = (searchElement, fromIdx) => computed(() => accessor()?.indexOf?.(searchElement, fromIdx))
297
+ accessor.lastIndexOf = (searchElement, fromIdx) => computed(() => accessor()?.lastIndexOf?.(searchElement, fromIdx))
298
+ accessor.includes = (searchElement, fromIdx) => computed(() => accessor()?.includes?.(searchElement, fromIdx))
299
+
300
+ // Validations
301
+ accessor.every = (fn) => computed(() => accessor()?.every?.(fn))
302
+ accessor.some = (fn) => computed(() => accessor()?.some?.(fn))
303
+
304
+ // Accumulators & Access
305
+ accessor.reduce = (...args) => computed(() => accessor()?.reduce?.(...args))
306
+ accessor.at = (index) => computed(() => accessor()?.at?.(index))
307
+ accessor.join = (separator) => computed(() => accessor()?.join?.(separator) ?? '')
308
+ }
309
+
310
+ function addMutatingArrayMethods(accessor) {
311
+ accessor.push = (...items) => {
312
+ const next = [...accessor(), ...items]
313
+ accessor(next)
314
+ return next.length
315
+ }
316
+
317
+ accessor.pop = () => {
318
+ const arr = accessor()
319
+ if (arr.length === 0) return undefined
320
+ const item = arr[arr.length - 1]
321
+ accessor(arr.slice(0, -1))
322
+ return item
323
+ }
324
+
325
+ accessor.shift = () => {
326
+ const arr = accessor()
327
+ if (arr.length === 0) return undefined
328
+ const item = arr[0]
329
+ accessor(arr.slice(1))
330
+ return item
331
+ }
332
+
333
+ accessor.unshift = (...items) => {
334
+ const next = [...items, ...accessor()]
335
+ accessor(next)
336
+ return next.length
337
+ }
338
+
339
+ accessor.splice = (start, deleteCount, ...items) => {
340
+ const arr = [...accessor()]
341
+ const removed = arr.splice(start, deleteCount, ...items)
342
+ accessor(arr)
343
+ return removed
344
+ }
345
+
346
+ accessor.remove = (predicate) => {
347
+ accessor(accessor().filter((item, i) => !predicate(item, i)))
348
+ }
349
+
350
+ accessor.clear = () => {
351
+ accessor([])
352
+ }
353
+
354
+ accessor.sort = (compareFn) => {
355
+ const next = [...accessor()].sort(compareFn)
356
+ accessor(next)
357
+ return accessor
358
+ }
359
+
360
+ accessor.reverse = () => {
361
+ const next = [...accessor()].reverse()
362
+ accessor(next)
363
+ return accessor
364
+ }
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()')
@@ -22,9 +22,9 @@ export function createRouter(routes) {
22
22
  window.removeEventListener('hashchange', handler)
23
23
  window.removeEventListener('DOMContentLoaded', handler)
24
24
  if (_currentInstance) {
25
- try {
26
- _currentInstance.unmount()
27
- } catch {}
25
+ try {
26
+ _currentInstance.unmount()
27
+ } catch { }
28
28
  }
29
29
  _routes = []
30
30
  _outlet = null
@@ -58,7 +58,7 @@ function _resolve() {
58
58
  }
59
59
 
60
60
  if (_currentInstance) {
61
- try { _currentInstance.unmount() } catch {}
61
+ try { _currentInstance.unmount() } catch { }
62
62
  }
63
63
 
64
64
  _outlet.innerHTML = ''
@@ -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
@@ -6,7 +6,29 @@ export type SignalAccessor<T> = {
6
6
  isSignal: boolean;
7
7
  }
8
8
 
9
- export type ArraySignalAccessor<T extends any[]> = SignalAccessor<T> & {
9
+ export interface ReactiveArrayMethods<T> {
10
+ map<U>(callbackfn: (value: T, index: number, array: T[]) => U): Computed<U[]>;
11
+ filter(predicate: (value: T, index: number, array: T[]) => boolean): Computed<T[]>;
12
+ slice(start?: number, end?: number): Computed<T[]>;
13
+ reduce<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): Computed<U>;
14
+ reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): Computed<T>;
15
+ find(predicate: (value: T, index: number, obj: T[]) => boolean): Computed<T | undefined>;
16
+ findLast(predicate: (value: T, index: number, obj: T[]) => boolean): Computed<T | undefined>;
17
+ findIndex(predicate: (value: T, index: number, obj: T[]) => boolean): Computed<number>;
18
+ findLastIndex(predicate: (value: T, index: number, obj: T[]) => boolean): Computed<number>;
19
+ every(predicate: (value: T, index: number, array: T[]) => boolean): Computed<boolean>;
20
+ some(predicate: (value: T, index: number, array: T[]) => boolean): Computed<boolean>;
21
+ includes(searchElement: T, fromIndex?: number): Computed<boolean>;
22
+ indexOf(searchElement: T, fromIndex?: number): Computed<number>;
23
+ lastIndexOf(searchElement: T, fromIndex?: number): Computed<number>;
24
+ at(index: number): Computed<T | undefined>;
25
+ concat(...items: (T | T[])[]): Computed<T[]>;
26
+ flat<depth extends number = 1>(depth?: depth): Computed<any[]>;
27
+ flatMap<U>(callback: (value: T, index: number, array: T[]) => U | U[]): Computed<U[]>;
28
+ join(separator?: string): Computed<string>;
29
+ }
30
+
31
+ export type ArraySignalAccessor<T extends any[]> = SignalAccessor<T> & ReactiveArrayMethods<T[number]> & {
10
32
  push(...items: T[number][]): number;
11
33
  pop(): T[number] | undefined;
12
34
  shift(): T[number] | undefined;
@@ -22,7 +44,7 @@ export type ComputedAccessor<T> = {
22
44
  (): T;
23
45
  isSignal: boolean;
24
46
  readonly: true;
25
- }
47
+ } & (T extends any[] ? ReactiveArrayMethods<T[number]> : {})
26
48
 
27
49
  export type Signal<T = any> = T extends any[] ? ArraySignalAccessor<T> : SignalAccessor<T>
28
50
  export type Computed<T = any> = ComputedAccessor<T>
@@ -32,12 +54,27 @@ type EffectRunner = {
32
54
  deps: Set<Set<EffectRunner>>;
33
55
  }
34
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
+
35
65
  export declare function signal<T extends any[]>(initialValue: T): ArraySignalAccessor<T>
36
66
  export declare function signal<T>(initialValue: T): SignalAccessor<T>
37
67
  export declare function effect(fn: () => void): () => void
38
68
  export declare function computed<T>(fn: () => T): Computed<T>
39
69
  export declare function batch<T>(fn: () => T): T
40
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
41
78
  export declare function inspect(...args: any[]): (() => void) | void
42
79
 
43
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/index.html CHANGED
@@ -305,7 +305,7 @@
305
305
  <a href="#/composables" style="color: #34d399;">Composables</a>
306
306
  </nav>
307
307
  <div id="app"></div>
308
- <script type="module" src="./app/main.ts"></script>
308
+ <script type="module" src="./app/main.js"></script>
309
309
  </body>
310
310
 
311
311
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fusee-framework",
3
- "version": "1.4.0",
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
  })