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 +22 -4
- package/app/main.js +12 -12
- package/app/routes/TestFor.js +29 -5
- package/app/routes/TestFor.ts +32 -5
- package/auto-imports.d.ts +3 -0
- package/framework/auto-import-preset.js +1 -1
- package/framework/core/component.js +5 -0
- package/framework/core/signal.js +275 -82
- package/framework/core/store.js +81 -0
- package/framework/index.js +2 -1
- package/framework/router/router.js +4 -4
- package/framework/types/index.d.ts +2 -0
- package/framework/types/signal.d.ts +39 -2
- package/framework/types/store.d.ts +8 -0
- package/index.html +1 -1
- package/package.json +6 -4
- package/vite.config.js +5 -0
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Fusée Framework 🚀
|
|
2
|
-
**v1.
|
|
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
|
-
- [ ]
|
|
82
|
-
- [ ]
|
|
83
|
-
- [ ] Integrated
|
|
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',
|
package/app/routes/TestFor.js
CHANGED
|
@@ -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
|
-
<
|
|
26
|
-
|
|
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
|
|
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"
|
|
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>
|
package/app/routes/TestFor.ts
CHANGED
|
@@ -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
|
-
<
|
|
38
|
-
|
|
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
|
|
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"
|
|
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
|
}
|
|
@@ -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
|
|
package/framework/core/signal.js
CHANGED
|
@@ -52,78 +52,40 @@ export function signal(initialValue) {
|
|
|
52
52
|
|
|
53
53
|
accessor.isSignal = true
|
|
54
54
|
|
|
55
|
-
if (Array.isArray(initialValue))
|
|
56
|
-
|
|
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 {
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
+
}
|
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()')
|
|
@@ -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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fusee-framework",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Fusée - Signals-first JS Framework | Peak Performance",
|
|
5
5
|
"main": "framework/index.js",
|
|
6
6
|
"types": "framework/types/index.d.ts",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"dev": "vite",
|
|
13
13
|
"build": "vite build",
|
|
14
14
|
"preview": "vite preview",
|
|
15
|
-
"test": "
|
|
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
|
+
}
|