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 +41 -4
- package/app/routes/TestFor.js +2 -6
- package/auto-imports.d.ts +3 -0
- package/framework/auto-import-preset.js +1 -1
- package/framework/core/compiler.js +1 -12
- package/framework/core/component.js +5 -0
- package/framework/core/directives.js +1 -19
- package/framework/core/evaluator.js +0 -3
- package/framework/core/signal.js +185 -22
- package/framework/core/store.js +81 -0
- package/framework/index.js +2 -1
- package/framework/types/component.d.ts +20 -11
- package/framework/types/composable.d.ts +2 -2
- package/framework/types/evaluator.d.ts +0 -2
- package/framework/types/index.d.ts +2 -0
- package/framework/types/router.d.ts +0 -2
- package/framework/types/signal.d.ts +17 -4
- package/framework/types/store.d.ts +8 -0
- package/package.json +6 -4
- package/tsconfig.json +2 -0
- package/vite.config.js +9 -0
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Fusée Framework 🚀
|
|
2
|
-
**v1.
|
|
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
|
-
- [ ]
|
|
82
|
-
- [ ]
|
|
83
|
-
- [ ]
|
|
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.
|
package/app/routes/TestFor.js
CHANGED
|
@@ -6,10 +6,8 @@ export const TestFor = defineComponent({
|
|
|
6
6
|
{ id: 3, name: 'Item 3' }
|
|
7
7
|
])
|
|
8
8
|
const search = signal('')
|
|
9
|
-
|
|
10
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
|
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)
|
package/framework/core/signal.js
CHANGED
|
@@ -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 {
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
+
}
|
package/framework/index.js
CHANGED
|
@@ -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?:
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
14
|
-
export function assertSetupContext(name?: string):
|
|
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
|
-
// ─── 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[] ?
|
|
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.
|
|
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": "
|
|
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
package/vite.config.js
CHANGED