fusee-framework 1.4.1 → 1.5.1

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.1 — 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,47 @@ 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:** `360`
84
+ - **Status:** `All Passed`
85
+ - **Framework:** [Vitest](https://vitest.dev/)
86
+ - **Environment:** JSDOM (Browser simulation)
87
+
88
+ ### Running Tests
89
+
90
+ ```bash
91
+ # Run all tests (easiest)
92
+ npm test
93
+
94
+ # Run all tests with verbose output
95
+ npx vitest --reporter=verbose
96
+
97
+ # Run type tests only
98
+ npx vitest run --typecheck
99
+
100
+ # Run tests in watch mode
101
+ npx vitest
102
+ ```
103
+
104
+ ### Test Coverage includes:
105
+ * **Reactivity Engine:** 100% (Signals, Batching, Computed, Watchers).
106
+ * **Component System:** Props, Slots, Async Components, and Lifecycle Hooks.
107
+ * **Dependency Injection:** Nested Provide/Inject and Shadowing.
108
+ * **Directives:** `f-if`, `f-for`, `f-model`, and Event Modifiers.
109
+ * **Memory Safety:** Explicit checks for memory leaks and automatic effect disposal.
110
+ * **Type Safety:** Comprehensive TypeScript definitions and type tests.
111
+ * **Integration Tests:** Advanced module combinations and edge cases.
112
+
113
+ ---
114
+
79
115
  ## 🚀 Future Roadmap
80
116
 
81
- - [ ] Global Store Management
82
- - [ ] Advanced file-based routing support
83
- - [ ] Integrated lightweight compiler which transforms declarative HTML into optimized runtime instructions
117
+ - [ ] Advanced file-based routing support and state management
118
+ - [ ] SSR support
119
+ - [ ] Lightweight compiler which transforms declarative HTML into optimized runtime instructions
120
+ - [ ] Integrated backend development support
84
121
 
85
122
  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',
@@ -37,7 +37,6 @@ export function mountTemplate(template, container, context, components) {
37
37
  return { effects }
38
38
  }
39
39
 
40
- // Parses props string from component mustache into HTML attr string
41
40
  function parseComponentAttrs(propsStr) {
42
41
  let attrs = ''
43
42
  if (!propsStr || !propsStr.trim()) return attrs
@@ -49,13 +48,10 @@ function parseComponentAttrs(propsStr) {
49
48
  const attrValue = attrMatch[2]
50
49
 
51
50
  if (attrName.startsWith('@')) {
52
- // Event listener: @change="handler" → data-on-change="handler"
53
51
  attrs += ` data-on-${attrName.slice(1).toLowerCase()}="${attrValue}"`
54
52
  } else if (attrName.startsWith(':')) {
55
- // Dynamic prop
56
53
  attrs += ` data-bind-prop-${attrName.slice(1).toLowerCase()}="${attrValue}"`
57
54
  } else {
58
- // Static prop
59
55
  attrs += ` data-prop-${attrName.toLowerCase()}="${attrValue}"`
60
56
  }
61
57
  }
@@ -196,7 +192,6 @@ function bindComponents(el, components, context, effects) {
196
192
  const ComponentFn = components[name]
197
193
  if (!ComponentFn) return
198
194
 
199
- // ── Props ────────────────────────────────────────────────────────────────
200
195
  const props = {}
201
196
  const listeners = {}
202
197
 
@@ -228,10 +223,7 @@ function bindComponents(el, components, context, effects) {
228
223
  }
229
224
 
230
225
  } else if (attrName.startsWith('data-on-')) {
231
- // ── Emit listeners ───────────────────────────────────────────────
232
- // @change="handler" → data-on-change="handler"
233
- // When child emits 'change', calls context.handler(value)
234
- const eventName = attrName.slice(8) // "data-on-" → eventName
226
+ const eventName = attrName.slice(8)
235
227
  const handlerExpr = attr.value.trim()
236
228
 
237
229
  listeners[eventName] = (...args) => {
@@ -239,15 +231,12 @@ function bindComponents(el, components, context, effects) {
239
231
  if (typeof handler === 'function') {
240
232
  handler(...args)
241
233
  } else {
242
- // Support inline expressions: @change="count(count() + 1)"
243
234
  evaluateExpression(handlerExpr, context)
244
235
  }
245
236
  }
246
237
  }
247
238
  }
248
239
 
249
- // ── Slots ────────────────────────────────────────────────────────────────
250
- // data-slot attribute contains URI-encoded slot HTML from parent
251
240
  const rawSlot = el.getAttribute('data-slot')
252
241
  const slots = rawSlot ? parseSlots(decodeURIComponent(rawSlot)) : {}
253
242
  el.removeAttribute('data-slot')
@@ -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
 
@@ -161,7 +161,6 @@ export function processFor(el, context, components, effects) {
161
161
  const templateNode = el.cloneNode(true)
162
162
  parent.removeChild(el)
163
163
 
164
- // Cache of currently rendered items: Map<key, { node, effects, context }>
165
164
  let cache = new Map()
166
165
  let previousOrder = []
167
166
 
@@ -197,7 +196,6 @@ export function processFor(el, context, components, effects) {
197
196
  let entry = cache.get(key)
198
197
 
199
198
  if (entry) {
200
- // Update existing reactive signals for reused items
201
199
  batch(() => {
202
200
  if (typeof entry.context[itemName] === 'function' && entry.context[itemName].isSignal) {
203
201
  entry.context[itemName](item.value)
@@ -217,7 +215,6 @@ export function processFor(el, context, components, effects) {
217
215
  }
218
216
  cache.delete(key)
219
217
  } else {
220
- // Create new item context with signals for local state
221
218
  const itemContext = Object.create(context)
222
219
  itemContext[itemName] = signal(item.value)
223
220
  if (keyName) itemContext[keyName] = signal(item.hasOwnProperty('key') ? item.key : item.rawIndex)
@@ -347,7 +344,6 @@ export function processIf(el, context, components, effects) {
347
344
  export function processIs(el, context, components, effects) {
348
345
  if (el.nodeType !== 1 || !el.hasAttribute('f-is')) return false
349
346
 
350
- // 1. Prepare directive state
351
347
  const expr = el.getAttribute('f-is')
352
348
  const keepAlive = processKeepAlive(el)
353
349
 
@@ -355,8 +351,6 @@ export function processIs(el, context, components, effects) {
355
351
 
356
352
  const anchor = document.createComment('f-keepAlive-anchor')
357
353
  el.parentNode.insertBefore(anchor, el)
358
-
359
- // Keep a clone to reuse attributes (props/@events) for every dynamic instance
360
354
  const templateNode = el.cloneNode(true)
361
355
  el.remove()
362
356
 
@@ -364,14 +358,11 @@ export function processIs(el, context, components, effects) {
364
358
  let activeKey = null
365
359
  let activeCleanup = []
366
360
 
367
- // 2. Reactive loop
368
361
  effects.push(effect(() => {
369
362
  const rawValue = evaluateExpression(expr, context)
370
363
 
371
- // Skip if same component is already active
372
364
  if (rawValue === activeKey) return
373
365
 
374
- // 3. Handle exiting component
375
366
  if (activeKey !== null) {
376
367
  const currentEntry = cache.get(activeKey)
377
368
  if (currentEntry) {
@@ -391,15 +382,12 @@ export function processIs(el, context, components, effects) {
391
382
  return
392
383
  }
393
384
 
394
- // 4. Handle entering component
395
385
  let entry = cache.get(rawValue)
396
386
 
397
387
  if (entry && keepAlive) {
398
- // Restore from cache
399
388
  anchor.parentNode.insertBefore(entry.node, anchor.nextSibling)
400
389
  activeCleanup = entry.cleanup
401
390
  } else {
402
- // Create new instance
403
391
  let ComponentFn = typeof rawValue === 'string' ? components[rawValue] : rawValue
404
392
 
405
393
  if (typeof ComponentFn !== 'function') {
@@ -418,7 +406,6 @@ export function processIs(el, context, components, effects) {
418
406
  const childCleanup = []
419
407
  const dynamicComponents = { ...components, [tempName]: ComponentFn }
420
408
 
421
- // Trigger the internal compiler to bind props and events
422
409
  compileNode(compEl, context, dynamicComponents, childCleanup)
423
410
 
424
411
  entry = { node: compEl, cleanup: childCleanup }
@@ -429,7 +416,6 @@ export function processIs(el, context, components, effects) {
429
416
  activeKey = rawValue
430
417
  }))
431
418
 
432
- // Cleanup all cached components when this directive is destroyed
433
419
  effects.push(() => {
434
420
  for (const entry of cache.values()) {
435
421
  for (const c of entry.cleanup) if (typeof c === 'function') c()
@@ -504,7 +490,7 @@ export function processEvents(el, context, effects) {
504
490
  if (next.endsWith('ms')) return parseInt(next) || 250
505
491
  if (next.endsWith('s')) return (parseFloat(next) || 0.25) * 1000
506
492
  }
507
- return 250 // Default
493
+ return 250
508
494
  }
509
495
 
510
496
  const debounceIdx = modifiers.indexOf('debounce')
@@ -529,7 +515,6 @@ export function processEvents(el, context, effects) {
529
515
  if (modifiers.includes('stop')) e.stopPropagation()
530
516
  if (modifiers.includes('self') && e.target !== el) return
531
517
 
532
- // Specific handling for common modifiers like .enter, .escape etc on keyboard events
533
518
  if (e instanceof KeyboardEvent) {
534
519
  const key = e.key.toLowerCase()
535
520
  const keyModifiers = modifiers.filter(m => ['enter', 'escape', 'tab', 'space', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(m))
@@ -543,7 +528,6 @@ export function processEvents(el, context, effects) {
543
528
  }
544
529
  }
545
530
 
546
- // Throttle logic (ensures execution at most once every X ms)
547
531
  if (throttleMs > 0) {
548
532
  const now = Date.now()
549
533
  const remaining = throttleMs - (now - lastRun)
@@ -553,7 +537,6 @@ export function processEvents(el, context, effects) {
553
537
  lastRun = now
554
538
  executeLogic(e)
555
539
  } else {
556
- // Standard throttle with trailing edge support
557
540
  clearTimeout(throttleTimeoutId)
558
541
  throttleTimeoutId = setTimeout(() => {
559
542
  lastRun = Date.now()
@@ -563,7 +546,6 @@ export function processEvents(el, context, effects) {
563
546
  return
564
547
  }
565
548
 
566
- // Debounce logic (waits for X ms of inactivity)
567
549
  if (debounceMs > 0) {
568
550
  clearTimeout(timeoutId)
569
551
  timeoutId = setTimeout(() => executeLogic(e), debounceMs)
@@ -7,11 +7,9 @@ export function evaluateExpression(expr, context, extraContext = {}, unwrapSigna
7
7
  const keys = []
8
8
  const values = []
9
9
 
10
- // Remove string literals before extracting identifiers to avoid matching words inside strings
11
10
  const exprWithoutStrings = expr.replace(/'[^']*'|"[^"]*"/g, ' ')
12
11
  const identifiers = new Set(exprWithoutStrings.match(/[a-zA-Z_$][a-zA-Z0-9_$]*/g) || [])
13
12
 
14
- // Map global context (only what is used in expression)
15
13
  for (const k in context) {
16
14
  if (identifiers.has(k)) {
17
15
  keys.push(k)
@@ -20,7 +18,6 @@ export function evaluateExpression(expr, context, extraContext = {}, unwrapSigna
20
18
  }
21
19
  }
22
20
 
23
- // Map extra context (like $event)
24
21
  for (const k in extraContext) {
25
22
  if (!keys.includes(k)) {
26
23
  keys.push(k)
@@ -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,47 @@ function addMutatingArrayMethods(accessor) {
244
363
  return accessor
245
364
  }
246
365
  }
366
+
367
+ function normalizeWatchSource(source) {
368
+ if (Array.isArray(source)) {
369
+ const getters = source.map(normalizeSingleWatchSource)
370
+ return {
371
+ getter: () => getters.map(get => get()),
372
+ isMultiSource: true
373
+ }
374
+ }
375
+ return {
376
+ getter: normalizeSingleWatchSource(source),
377
+ isMultiSource: false
378
+ }
379
+ }
380
+
381
+ function normalizeSingleWatchSource(source) {
382
+ if (typeof source === 'function') return source
383
+ console.warn('[framework] watch() source should be a signal, getter, or an array of those')
384
+ return () => source
385
+ }
386
+
387
+ function cloneWatchValue(value, isMultiSource) {
388
+ if (isMultiSource && Array.isArray(value)) return value.slice()
389
+ return value
390
+ }
391
+
392
+ function hasWatchChanged(newValue, oldValue, isMultiSource, equals) {
393
+ if (isMultiSource) {
394
+ if (!Array.isArray(oldValue) || newValue.length !== oldValue.length) return true
395
+ for (let i = 0; i < newValue.length; i++) {
396
+ if (!equals(newValue[i], oldValue[i])) return true
397
+ }
398
+ return false
399
+ }
400
+ return !equals(newValue, oldValue)
401
+ }
402
+
403
+ function runWatchCleanup(cleanupRef) {
404
+ if (typeof cleanupRef.fn === 'function') {
405
+ const fn = cleanupRef.fn
406
+ cleanupRef.fn = null
407
+ untrack(() => fn())
408
+ }
409
+ }
@@ -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()')
@@ -12,16 +12,10 @@ export type PropConfig = {
12
12
  required?: boolean
13
13
  }
14
14
 
15
- // ── Emit ──────────────────────────────────────────────────────────────────────
16
- // emit('change', value) — calls parent listener registered with @change="handler"
17
15
  export type EmitFn = (eventName: string, ...args: any[]) => void
18
16
 
19
- // ── Slots ─────────────────────────────────────────────────────────────────────
20
- // slots.default — default slot HTML string
21
- // slots.header — named slot HTML string
22
17
  export type Slots = Record<string, string>
23
18
 
24
- // ── Setup context ─────────────────────────────────────────────────────────────
25
19
  export type SetupContext = {
26
20
  emit: EmitFn
27
21
  slots: Slots
@@ -42,8 +36,8 @@ export type ComponentResult = {
42
36
  [key: string]: any
43
37
  }
44
38
 
45
- export type ComponentOptions<TProps = ComponentProps> = {
46
- props?: PropSchema
39
+ export type ComponentOptions<TProps = ComponentProps, P = PropSchema> = {
40
+ props?: P
47
41
  components?: Record<string, ComponentFactory<any>>
48
42
  setup: (props: TProps, ctx: SetupContext) => ComponentResult
49
43
  }
@@ -62,13 +56,28 @@ export type ComponentApi = {
62
56
  export declare function onMount(fn: () => void): void
63
57
  export declare function onUnmount(fn: () => void): void
64
58
  export declare function parseSlots(slotHTML: string): Slots
59
+ export declare function getCurrentInstance(): ComponentInstance | null
65
60
 
66
- // ── Provide / Inject ───────────────────────────────────────────────────────────
67
61
  export declare function provide<T>(key: string, value: T): void
68
62
  export declare function inject<T>(key: string): T | null
69
63
 
70
- export declare function defineComponent<TProps = EmptyProps>(
71
- options: ComponentOptions<TProps>
64
+ type InferPropType<T> = T extends StringConstructor ? string :
65
+ T extends NumberConstructor ? number :
66
+ T extends BooleanConstructor ? boolean :
67
+ any;
68
+
69
+ type InferProps<P> = P extends string[]
70
+ ? Record<P[number], any>
71
+ : P extends Record<string, PropConfig>
72
+ ? {
73
+ [K in keyof P]: P[K]['required'] extends true
74
+ ? InferPropType<P[K]['type']>
75
+ : InferPropType<P[K]['type']> | (P[K] extends { default: any } ? never : undefined)
76
+ }
77
+ : Record<string, any>;
78
+
79
+ export declare function defineComponent<P extends PropSchema = any, TProps = InferProps<P>>(
80
+ options: ComponentOptions<TProps, P>
72
81
  ): ComponentFactory<TProps>
73
82
 
74
83
  export type AsyncComponentLoader<TProps = ComponentProps> = () => Promise<ComponentFactory<TProps> | { default: ComponentFactory<TProps> } | Record<string, any>>
@@ -10,5 +10,5 @@ declare global {
10
10
  }
11
11
  }
12
12
 
13
- export function defineComposable<T>(fn: (...args: any[]) => T): Composable<T>
14
- export function assertSetupContext(name?: string): any
13
+ export declare function defineComposable<T extends (...args: any[]) => any>(fn: T): T;
14
+ export declare function assertSetupContext(name?: string): void;
@@ -9,7 +9,5 @@ export type InterpolationPart = {
9
9
  export const MUSTACHE_RE: RegExp
10
10
 
11
11
  export function evaluateExpression(expr: string, context: Record<string, any>, extraContext?: Record<string, any>): any
12
-
13
12
  export function parseInterpolation(str: string): InterpolationPart[]
14
-
15
13
  export function sanitizeAttr(name: string, value: string): string
@@ -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
@@ -1,5 +1,3 @@
1
- // ─── Router Types ─────────────────────────────────────────────────────────────
2
-
3
1
  import type { ComponentApi } from './component'
4
2
 
5
3
  export type Route = {
@@ -1,5 +1,3 @@
1
- // ─── Signal Types ────────────────────────────────────────────────────────────
2
-
3
1
  export type SignalAccessor<T> = {
4
2
  (): T;
5
3
  (newValue: T): void;
@@ -46,20 +44,35 @@ export type ComputedAccessor<T> = {
46
44
  readonly: true;
47
45
  } & (T extends any[] ? ReactiveArrayMethods<T[number]> : {})
48
46
 
49
- export type Signal<T = any> = T extends any[] ? ArraySignalAccessor<T> : SignalAccessor<T>
50
- export type Computed<T = any> = ComputedAccessor<T>
47
+ export type Signal<T = any> = SignalAccessor<T> & (T extends any[] ? ReactiveArrayMethods<T[number]> : {})
48
+ export type Computed<T = any> = ComputedAccessor<T> & (T extends any[] ? ReactiveArrayMethods<T[number]> : {})
51
49
 
52
50
  type EffectRunner = {
53
51
  (): void;
54
52
  deps: Set<Set<EffectRunner>>;
55
53
  }
56
54
 
55
+ export type WatchSource<T = any> = (() => T) | Signal<T> | Computed<T>
56
+ export type WatchCallback<T = any> = (newValue: T, oldValue: T | undefined, onCleanup: (fn: () => void) => void) => void
57
+
58
+ export interface WatchOptions {
59
+ immediate?: boolean;
60
+ equals?: (a: any, b: any) => boolean;
61
+ }
62
+
57
63
  export declare function signal<T extends any[]>(initialValue: T): ArraySignalAccessor<T>
58
64
  export declare function signal<T>(initialValue: T): SignalAccessor<T>
59
65
  export declare function effect(fn: () => void): () => void
60
66
  export declare function computed<T>(fn: () => T): Computed<T>
61
67
  export declare function batch<T>(fn: () => T): T
62
68
  export declare function untrack<T>(fn: () => T): T
69
+ export declare function watch<T>(source: WatchSource<T>, callback: WatchCallback<T>, options?: WatchOptions): () => void
70
+ export declare function watch<T extends any[]>(
71
+ sources: [...{ [K in keyof T]: WatchSource<T[K]> }],
72
+ callback: (newValue: T, oldValue: T | undefined, onCleanup: (fn: () => void) => void) => void,
73
+ options?: WatchOptions
74
+ ): () => void
75
+ export declare function watchEffect(fn: (onCleanup: (fn: () => void) => void) => void): () => void
63
76
  export declare function inspect(...args: any[]): (() => void) | void
64
77
 
65
78
  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.1",
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/tsconfig.json CHANGED
@@ -24,6 +24,8 @@
24
24
  "declarationDir": "./dist/types",
25
25
 
26
26
  "resolveJsonModule": true,
27
+ "allowImportingTsExtensions": true,
28
+ "noEmit": true,
27
29
  "paths": {
28
30
  "fusee-framework": ["./framework/types/index.d.ts"],
29
31
  "fusee-framework/*": ["./framework/*"]
package/vite.config.js CHANGED
@@ -34,5 +34,14 @@ 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,
42
+ typecheck: {
43
+ enabled: true,
44
+ include: ['**/*.test-d.ts'],
45
+ }
37
46
  }
38
47
  })