fusee-framework 0.1.0-alpha.1 → 1.0.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 +76 -8
- package/app/components/Counter.js +111 -34
- package/app/components/Counter.ts +99 -0
- package/app/components/CounterSection.js +34 -0
- package/app/components/CounterSection.ts +45 -0
- package/app/components/UserForm.js +31 -0
- package/app/components/UserForm.ts +38 -0
- package/app/main.js +2 -2
- package/app/main.ts +15 -0
- package/app/routes/About.js +17 -17
- package/app/routes/About.ts +29 -0
- package/app/routes/Home.js +22 -17
- package/app/routes/Home.ts +34 -0
- package/framework/bin/create-fusee.js +147 -0
- package/framework/core/compiler.js +214 -0
- package/framework/core/component.js +98 -107
- package/framework/core/directives.js +335 -0
- package/framework/core/evaluator.js +67 -0
- package/framework/core/signal.js +125 -40
- package/framework/index.d.ts +3 -0
- package/framework/index.js +1 -1
- package/framework/router/router.js +52 -43
- package/framework/types/compiler.d.ts +25 -0
- package/framework/types/component.d.ts +44 -0
- package/framework/types/directives.d.ts +32 -0
- package/framework/types/evaluator.d.ts +15 -0
- package/framework/types/index.d.ts +9 -0
- package/framework/types/router.d.ts +17 -0
- package/framework/types/signal.d.ts +30 -0
- package/index.html +75 -8
- package/package.json +14 -3
- package/tsconfig.json +38 -0
- package/vite.config.js +35 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { effect, batch } from './signal.js'
|
|
2
|
+
import { evaluateExpression } from './evaluator.js'
|
|
3
|
+
import { compileNode } from './compiler.js'
|
|
4
|
+
|
|
5
|
+
export function processDirectives(root, context, components, effects) {
|
|
6
|
+
processFor(root, context, components, effects)
|
|
7
|
+
processIf(root, context, components, effects)
|
|
8
|
+
processShow(root, context, effects)
|
|
9
|
+
processModel(root, context, effects)
|
|
10
|
+
processRefs(root, context)
|
|
11
|
+
processHtml(root, context, effects)
|
|
12
|
+
processText(root, context, effects)
|
|
13
|
+
processEvents(root, context)
|
|
14
|
+
processCloak(root)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function processRefs(root, context) {
|
|
18
|
+
const refEls = root.querySelectorAll('[f-ref]')
|
|
19
|
+
for (const el of refEls) {
|
|
20
|
+
if (!el.parentNode) continue
|
|
21
|
+
|
|
22
|
+
const refName = el.getAttribute('f-ref')
|
|
23
|
+
el.removeAttribute('f-ref')
|
|
24
|
+
|
|
25
|
+
const refVar = context[refName]
|
|
26
|
+
if (typeof refVar === 'function' && refVar.isSignal && !refVar.readonly) {
|
|
27
|
+
refVar(el)
|
|
28
|
+
} else {
|
|
29
|
+
const reason = refVar?.readonly ? 'it is read-only (computed)' : 'it is not a signal'
|
|
30
|
+
console.warn(`[framework] f-ref requires a writable signal. Target "${refName}" is invalid: ${reason}`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function processModel(root, context, effects) {
|
|
36
|
+
const modelEls = root.querySelectorAll('[f-model]')
|
|
37
|
+
for (const el of modelEls) {
|
|
38
|
+
if (!el.parentNode) continue
|
|
39
|
+
|
|
40
|
+
const expr = el.getAttribute('f-model')
|
|
41
|
+
el.removeAttribute('f-model')
|
|
42
|
+
|
|
43
|
+
const signalMethod = context[expr]
|
|
44
|
+
if (typeof signalMethod === 'function' && signalMethod.isSignal && !signalMethod.readonly) {
|
|
45
|
+
effects.push(effect(() => {
|
|
46
|
+
const val = signalMethod()
|
|
47
|
+
if (el.type === 'checkbox') {
|
|
48
|
+
el.checked = !!val
|
|
49
|
+
} else {
|
|
50
|
+
el.value = val ?? ''
|
|
51
|
+
}
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
const eventName = (el.tagName === 'SELECT' || el.type === 'checkbox' || el.type === 'radio') ? 'change' : 'input'
|
|
55
|
+
el.addEventListener(eventName, () => {
|
|
56
|
+
const newVal = el.type === 'checkbox' ? el.checked : el.value
|
|
57
|
+
batch(() => signalMethod(newVal))
|
|
58
|
+
})
|
|
59
|
+
} else {
|
|
60
|
+
const reason = signalMethod?.readonly ? 'it is read-only (computed)' : 'it is not a signal'
|
|
61
|
+
console.warn(`[framework] f-model requires a writable signal. Expression "${expr}" is invalid: ${reason}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function processFor(root, context, components, effects) {
|
|
67
|
+
const forEls = root.querySelectorAll('[f-for]')
|
|
68
|
+
for (const el of forEls) {
|
|
69
|
+
if (!el.parentNode) continue
|
|
70
|
+
|
|
71
|
+
const expr = el.getAttribute('f-for')
|
|
72
|
+
el.removeAttribute('f-for')
|
|
73
|
+
|
|
74
|
+
const match = expr.match(/^\s*(?:(?:\(\s*([a-zA-Z_$][0-9a-zA-Z_$]*)(?:\s*,\s*([a-zA-Z_$][0-9a-zA-Z_$]*))?(?:\s*,\s*([a-zA-Z_$][0-9a-zA-Z_$]*))?\s*\))|([a-zA-Z_$][0-9a-zA-Z_$]*))\s+in\s+(.+)\s*$/)
|
|
75
|
+
if (!match) {
|
|
76
|
+
console.warn(`[framework] Invalid f-for expression: "${expr}"`)
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const itemName = match[4] || match[1]
|
|
81
|
+
const keyName = match[2]
|
|
82
|
+
const indexName = match[3]
|
|
83
|
+
const arrayExpr = match[5]
|
|
84
|
+
|
|
85
|
+
const anchor = document.createComment('f-for')
|
|
86
|
+
const parent = el.parentNode
|
|
87
|
+
parent.insertBefore(anchor, el)
|
|
88
|
+
|
|
89
|
+
const templateNode = el.cloneNode(true)
|
|
90
|
+
parent.removeChild(el)
|
|
91
|
+
|
|
92
|
+
let previousNodes = []
|
|
93
|
+
let previousEffects = []
|
|
94
|
+
|
|
95
|
+
effects.push(effect(() => {
|
|
96
|
+
const rawItems = evaluateExpression(arrayExpr, context)
|
|
97
|
+
let items = []
|
|
98
|
+
|
|
99
|
+
if (Array.isArray(rawItems)) {
|
|
100
|
+
items = rawItems.map((item, index) => ({ value: item, key: index, index }))
|
|
101
|
+
} else if (typeof rawItems === 'number' && !isNaN(rawItems)) {
|
|
102
|
+
items = Array.from({ length: Math.max(0, Math.floor(rawItems)) }, (_, i) => ({ value: i + 1, key: i, index: i }))
|
|
103
|
+
} else if (rawItems && typeof rawItems === 'object') {
|
|
104
|
+
items = Object.entries(rawItems).map(([key, value], index) => ({ value, key, index }))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const e of previousEffects) if (typeof e === 'function') e()
|
|
108
|
+
for (const node of previousNodes) node.parentNode?.removeChild(node)
|
|
109
|
+
|
|
110
|
+
previousNodes = []
|
|
111
|
+
previousEffects = []
|
|
112
|
+
|
|
113
|
+
const fragment = document.createDocumentFragment()
|
|
114
|
+
items.forEach((item) => {
|
|
115
|
+
const clone = templateNode.cloneNode(true)
|
|
116
|
+
const childContext = Object.create(context)
|
|
117
|
+
|
|
118
|
+
childContext[itemName] = item.value
|
|
119
|
+
if (keyName) childContext[keyName] = item.key
|
|
120
|
+
if (indexName) childContext[indexName] = item.index
|
|
121
|
+
|
|
122
|
+
const childEffects = []
|
|
123
|
+
compileNode(clone, childContext, components, childEffects)
|
|
124
|
+
|
|
125
|
+
fragment.appendChild(clone)
|
|
126
|
+
previousNodes.push(clone)
|
|
127
|
+
previousEffects.push(...childEffects)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
anchor.parentNode.insertBefore(fragment, anchor)
|
|
131
|
+
}))
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function processIf(root, context, components, effects) {
|
|
136
|
+
const ifEls = Array.from(root.querySelectorAll('[f-if]'))
|
|
137
|
+
const processedEls = new Set()
|
|
138
|
+
|
|
139
|
+
for (const ifEl of ifEls) {
|
|
140
|
+
if (processedEls.has(ifEl)) continue
|
|
141
|
+
|
|
142
|
+
const parent = ifEl.parentNode
|
|
143
|
+
if (!parent) continue
|
|
144
|
+
|
|
145
|
+
const group = []
|
|
146
|
+
let curr = ifEl
|
|
147
|
+
|
|
148
|
+
while (curr) {
|
|
149
|
+
if (curr.nodeType === 1) {
|
|
150
|
+
const type = curr.hasAttribute('f-if') ? 'if' :
|
|
151
|
+
curr.hasAttribute('f-elif') ? 'elif' :
|
|
152
|
+
curr.hasAttribute('f-else-if') ? 'elif' :
|
|
153
|
+
curr.hasAttribute('f-else') ? 'else' : null
|
|
154
|
+
|
|
155
|
+
if (type) {
|
|
156
|
+
const expr = type === 'else' ? 'true' : curr.getAttribute(`f-${type === 'elif' && curr.hasAttribute('f-else-if') ? 'else-if' : type}`)
|
|
157
|
+
group.push({ node: curr, expr, type, isElement: true })
|
|
158
|
+
processedEls.add(curr)
|
|
159
|
+
|
|
160
|
+
curr.removeAttribute('f-if')
|
|
161
|
+
curr.removeAttribute('f-elif')
|
|
162
|
+
curr.removeAttribute('f-else-if')
|
|
163
|
+
curr.removeAttribute('f-else')
|
|
164
|
+
|
|
165
|
+
if (type === 'else') break
|
|
166
|
+
} else {
|
|
167
|
+
break
|
|
168
|
+
}
|
|
169
|
+
} else if (curr.nodeType === 3 && curr.textContent.trim() === '' || curr.nodeType === 8) {
|
|
170
|
+
group.push({ node: curr, isElement: false })
|
|
171
|
+
} else {
|
|
172
|
+
break
|
|
173
|
+
}
|
|
174
|
+
curr = curr.nextSibling
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const templates = group
|
|
178
|
+
.filter(item => item.isElement)
|
|
179
|
+
.map(item => ({
|
|
180
|
+
template: item.node.cloneNode(true),
|
|
181
|
+
expr: item.expr,
|
|
182
|
+
type: item.type
|
|
183
|
+
}))
|
|
184
|
+
|
|
185
|
+
const anchor = document.createComment('f-condition-anchor')
|
|
186
|
+
parent.insertBefore(anchor, ifEl)
|
|
187
|
+
|
|
188
|
+
for (const item of group) item.node.remove()
|
|
189
|
+
|
|
190
|
+
let activeIndex = -1
|
|
191
|
+
let activeNode = null
|
|
192
|
+
let activeCleanup = []
|
|
193
|
+
|
|
194
|
+
effects.push(effect(() => {
|
|
195
|
+
let nextIndex = -1
|
|
196
|
+
for (let i = 0; i < templates.length; i++) {
|
|
197
|
+
const isMatch = templates[i].type === 'else' ? true : evaluateExpression(templates[i].expr, context)
|
|
198
|
+
if (isMatch) {
|
|
199
|
+
nextIndex = i
|
|
200
|
+
break
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (nextIndex !== activeIndex) {
|
|
205
|
+
for (const cleanup of activeCleanup) if (typeof cleanup === 'function') cleanup()
|
|
206
|
+
activeCleanup = []
|
|
207
|
+
if (activeNode) {
|
|
208
|
+
activeNode.remove()
|
|
209
|
+
activeNode = null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (nextIndex !== -1) {
|
|
213
|
+
activeNode = templates[nextIndex].template.cloneNode(true)
|
|
214
|
+
compileNode(activeNode, context, components, activeCleanup)
|
|
215
|
+
anchor.parentNode.insertBefore(activeNode, anchor.nextSibling)
|
|
216
|
+
}
|
|
217
|
+
activeIndex = nextIndex
|
|
218
|
+
}
|
|
219
|
+
}))
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function processShow(root, context, effects) {
|
|
224
|
+
const showEls = root.querySelectorAll('[f-show]')
|
|
225
|
+
for (const el of showEls) {
|
|
226
|
+
if (!el.parentNode) continue
|
|
227
|
+
|
|
228
|
+
const expr = el.getAttribute('f-show')
|
|
229
|
+
el.removeAttribute('f-show')
|
|
230
|
+
const originalDisplay = el.style.display === 'none' ? '' : el.style.display
|
|
231
|
+
|
|
232
|
+
effects.push(effect(() => {
|
|
233
|
+
const val = evaluateExpression(expr, context)
|
|
234
|
+
el.style.display = val ? originalDisplay : 'none'
|
|
235
|
+
}))
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function processHtml(root, context, effects) {
|
|
240
|
+
const els = root.querySelectorAll('[f-html]')
|
|
241
|
+
for (const el of els) {
|
|
242
|
+
if (!el.parentNode) continue
|
|
243
|
+
|
|
244
|
+
const expr = el.getAttribute('f-html')
|
|
245
|
+
el.removeAttribute('f-html')
|
|
246
|
+
|
|
247
|
+
const isDev = typeof import.meta.env !== 'undefined' ? !!import.meta.env.DEV : true
|
|
248
|
+
if (isDev) {
|
|
249
|
+
console.warn(`[framework] f-html can expose the site to XSS. Use only with trusted content.`)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
effects.push(effect(() => {
|
|
253
|
+
const val = evaluateExpression(expr, context)
|
|
254
|
+
el.innerHTML = String(val ?? '')
|
|
255
|
+
}))
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
export function processEvents(root, context) {
|
|
259
|
+
const els = root.querySelectorAll('*')
|
|
260
|
+
const targets = root.attributes ? [root, ...els] : els
|
|
261
|
+
|
|
262
|
+
for (const el of targets) {
|
|
263
|
+
if (!el.attributes) continue
|
|
264
|
+
|
|
265
|
+
for (const attr of [...el.attributes]) {
|
|
266
|
+
if (attr.name.startsWith('@')) {
|
|
267
|
+
const fullEventName = attr.name.slice(1)
|
|
268
|
+
const parts = fullEventName.split('.')
|
|
269
|
+
const eventName = parts[0]
|
|
270
|
+
const modifiers = parts.slice(1)
|
|
271
|
+
const expr = attr.value
|
|
272
|
+
|
|
273
|
+
el.removeAttribute(attr.name)
|
|
274
|
+
|
|
275
|
+
const handler = (e) => {
|
|
276
|
+
if (modifiers.includes('prevent')) e.preventDefault()
|
|
277
|
+
if (modifiers.includes('stop')) e.stopPropagation()
|
|
278
|
+
if (modifiers.includes('self') && e.target !== el) return
|
|
279
|
+
|
|
280
|
+
// Specific handling for common modifiers like .enter, .escape etc on keyboard events
|
|
281
|
+
if (e instanceof KeyboardEvent) {
|
|
282
|
+
const key = e.key.toLowerCase()
|
|
283
|
+
const keyModifiers = modifiers.filter(m => ['enter', 'escape', 'tab', 'space', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(m))
|
|
284
|
+
if (keyModifiers.length > 0) {
|
|
285
|
+
const match = keyModifiers.some(m => {
|
|
286
|
+
if (m === 'escape') return key === 'escape' || key === 'esc'
|
|
287
|
+
if (m === 'space') return key === ' ' || key === 'spacebar'
|
|
288
|
+
return key === m
|
|
289
|
+
})
|
|
290
|
+
if (!match) return
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
batch(() => {
|
|
295
|
+
const potentialFn = context[expr]
|
|
296
|
+
if (typeof potentialFn === 'function' && !potentialFn.isSignal) {
|
|
297
|
+
potentialFn(e)
|
|
298
|
+
} else {
|
|
299
|
+
evaluateExpression(expr, context, { $event: e }, false)
|
|
300
|
+
}
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const targetNode = modifiers.includes('window') ? window : (modifiers.includes('document') ? document : el)
|
|
305
|
+
const options = {
|
|
306
|
+
once: modifiers.includes('once'),
|
|
307
|
+
capture: modifiers.includes('capture') || modifiers.includes('away')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
targetNode.addEventListener(eventName, handler, options)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function processText(root, context, effects) {
|
|
317
|
+
const els = root.querySelectorAll('[f-text]')
|
|
318
|
+
for (const el of els) {
|
|
319
|
+
if (!el.parentNode) continue
|
|
320
|
+
const expr = el.getAttribute('f-text')
|
|
321
|
+
el.removeAttribute('f-text')
|
|
322
|
+
effects.push(effect(() => {
|
|
323
|
+
const val = evaluateExpression(expr, context)
|
|
324
|
+
el.textContent = String(val ?? '')
|
|
325
|
+
}))
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function processCloak(root) {
|
|
330
|
+
const els = root.querySelectorAll('[f-cloak]')
|
|
331
|
+
const targets = root.hasAttribute && root.hasAttribute('f-cloak') ? [root, ...els] : els
|
|
332
|
+
for (const el of targets) {
|
|
333
|
+
el.removeAttribute('f-cloak')
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const MUSTACHE_RE = /\{\{\s*(.+?)\s*\}\}/
|
|
2
|
+
|
|
3
|
+
const SENSITIVE_ATTRS = ['href', 'src', 'srcset', 'formaction', 'xlink:href', 'data']
|
|
4
|
+
const DANGEROUS_SCHEMES = /^(javascript|data|vbscript|file):/i
|
|
5
|
+
|
|
6
|
+
export function evaluateExpression(expr, context, extraContext = {}, unwrapSignals = true) {
|
|
7
|
+
const keys = []
|
|
8
|
+
const values = []
|
|
9
|
+
|
|
10
|
+
// Map global context
|
|
11
|
+
for (const k in context) {
|
|
12
|
+
keys.push(k)
|
|
13
|
+
const val = context[k]
|
|
14
|
+
values.push(unwrapSignals && typeof val === 'function' && val.isSignal ? val() : val)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Map extra context (like $event)
|
|
18
|
+
for (const k in extraContext) {
|
|
19
|
+
if (!keys.includes(k)) {
|
|
20
|
+
keys.push(k)
|
|
21
|
+
values.push(extraContext[k])
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const isMultiStatement = expr.includes(';') || expr.includes('console.')
|
|
27
|
+
const body = isMultiStatement ? expr : `return ${expr}`
|
|
28
|
+
const fn = new Function(...keys, body)
|
|
29
|
+
return fn(...values)
|
|
30
|
+
} catch (e) {
|
|
31
|
+
console.warn(`[framework] Error evaluating "${expr}":`, e)
|
|
32
|
+
return ''
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseInterpolation(str) {
|
|
37
|
+
const parts = []
|
|
38
|
+
const regex = /\{\{\s*(.+?)\s*\}\}/g
|
|
39
|
+
let lastIndex = 0
|
|
40
|
+
let match
|
|
41
|
+
|
|
42
|
+
while ((match = regex.exec(str)) !== null) {
|
|
43
|
+
if (match.index > lastIndex) {
|
|
44
|
+
parts.push({ type: 'static', value: str.slice(lastIndex, match.index) })
|
|
45
|
+
}
|
|
46
|
+
parts.push({ type: 'dynamic', key: match[1] })
|
|
47
|
+
lastIndex = regex.lastIndex
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (lastIndex < str.length) {
|
|
51
|
+
parts.push({ type: 'static', value: str.slice(lastIndex) })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return parts
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function sanitizeAttr(name, value) {
|
|
58
|
+
const attrName = name.toLowerCase()
|
|
59
|
+
if (SENSITIVE_ATTRS.includes(attrName)) {
|
|
60
|
+
const trimmed = value.trim()
|
|
61
|
+
if (DANGEROUS_SCHEMES.test(trimmed)) {
|
|
62
|
+
console.warn(`[framework] Blocked potential XSS on ${name}: "${value}"`)
|
|
63
|
+
return 'about:blank'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return value
|
|
67
|
+
}
|
package/framework/core/signal.js
CHANGED
|
@@ -1,52 +1,137 @@
|
|
|
1
|
-
// ─── Reactivity Engine ───────────────────────────────────────────────────────
|
|
2
|
-
// Solid-style signals: count() to read, count(val) to write.
|
|
3
|
-
// An "effect" is any function that should re-run when signals it reads change.
|
|
4
|
-
|
|
5
1
|
let currentEffect = null
|
|
2
|
+
let onEffectCreated = null
|
|
6
3
|
|
|
7
|
-
export function
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
4
|
+
export function setEffectHook(fn) { onEffectCreated = fn }
|
|
5
|
+
|
|
6
|
+
let batchDepth = 0
|
|
7
|
+
const pendingEffects = new Set()
|
|
8
|
+
|
|
9
|
+
export function batch(fn) {
|
|
10
|
+
batchDepth++
|
|
11
|
+
try {
|
|
12
|
+
return fn()
|
|
13
|
+
} finally {
|
|
14
|
+
batchDepth--
|
|
15
|
+
if (batchDepth === 0) {
|
|
16
|
+
while (pendingEffects.size > 0) {
|
|
17
|
+
const toRun = [...pendingEffects]
|
|
18
|
+
pendingEffects.clear()
|
|
19
|
+
for (const eff of toRun) eff()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function scheduleEffect(effectFn) {
|
|
26
|
+
if (batchDepth > 0) {
|
|
27
|
+
pendingEffects.add(effectFn)
|
|
19
28
|
} else {
|
|
20
|
-
|
|
21
|
-
if (newValue !== value) {
|
|
22
|
-
value = newValue
|
|
23
|
-
for (const sub of [...subscribers]) sub()
|
|
24
|
-
}
|
|
29
|
+
effectFn()
|
|
25
30
|
}
|
|
26
|
-
|
|
31
|
+
}
|
|
27
32
|
|
|
28
|
-
|
|
33
|
+
export function signal(initialValue) {
|
|
34
|
+
let value = initialValue
|
|
35
|
+
const subscribers = new Set()
|
|
36
|
+
|
|
37
|
+
function accessor() {
|
|
38
|
+
if (arguments.length === 0) {
|
|
39
|
+
if (currentEffect) {
|
|
40
|
+
subscribers.add(currentEffect)
|
|
41
|
+
currentEffect.deps.add(subscribers)
|
|
42
|
+
}
|
|
43
|
+
return value
|
|
44
|
+
} else {
|
|
45
|
+
const newValue = arguments[0]
|
|
46
|
+
if (!Object.is(value, newValue)) {
|
|
47
|
+
value = newValue
|
|
48
|
+
for (const sub of [...subscribers]) scheduleEffect(sub)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
accessor.isSignal = true
|
|
54
|
+
return accessor
|
|
29
55
|
}
|
|
30
56
|
|
|
31
57
|
export function effect(fn) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
const run = () => {
|
|
59
|
+
for (const dep of run.deps) dep.delete(run)
|
|
60
|
+
run.deps.clear()
|
|
61
|
+
|
|
62
|
+
currentEffect = run
|
|
63
|
+
try { fn() }
|
|
64
|
+
finally { currentEffect = null }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
run.deps = new Set()
|
|
68
|
+
const cleanup = () => {
|
|
69
|
+
for (const dep of run.deps) dep.delete(run)
|
|
70
|
+
run.deps.clear()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (onEffectCreated) onEffectCreated(cleanup)
|
|
74
|
+
run()
|
|
75
|
+
|
|
76
|
+
return cleanup
|
|
45
77
|
}
|
|
46
78
|
|
|
47
79
|
export function computed(fn) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
80
|
+
let value
|
|
81
|
+
let dirty = true
|
|
82
|
+
const subscribers = new Set()
|
|
83
|
+
|
|
84
|
+
const computedNode = () => {
|
|
85
|
+
if (!dirty) {
|
|
86
|
+
dirty = true
|
|
87
|
+
for (const sub of [...subscribers]) scheduleEffect(sub)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
computedNode.deps = new Set()
|
|
92
|
+
|
|
93
|
+
function accessor() {
|
|
94
|
+
if (arguments.length === 0) {
|
|
95
|
+
if (currentEffect) {
|
|
96
|
+
subscribers.add(currentEffect)
|
|
97
|
+
currentEffect.deps.add(subscribers)
|
|
98
|
+
}
|
|
99
|
+
if (dirty) {
|
|
100
|
+
for (const dep of computedNode.deps) dep.delete(computedNode)
|
|
101
|
+
computedNode.deps.clear()
|
|
102
|
+
|
|
103
|
+
const prevEffect = currentEffect
|
|
104
|
+
currentEffect = computedNode
|
|
105
|
+
try {
|
|
106
|
+
const newValue = fn()
|
|
107
|
+
if (newValue !== value) value = newValue
|
|
108
|
+
} finally {
|
|
109
|
+
currentEffect = prevEffect
|
|
110
|
+
dirty = false
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return value
|
|
114
|
+
}
|
|
115
|
+
console.warn('[framework] computed() is read-only')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
accessor.dispose = () => {
|
|
119
|
+
for (const dep of computedNode.deps) dep.delete(computedNode)
|
|
120
|
+
computedNode.deps.clear()
|
|
121
|
+
subscribers.clear()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
accessor.isSignal = true
|
|
125
|
+
accessor.readonly = true
|
|
126
|
+
return accessor
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function untrack(fn) {
|
|
130
|
+
const prevEffect = currentEffect
|
|
131
|
+
currentEffect = null
|
|
132
|
+
try {
|
|
133
|
+
return fn()
|
|
134
|
+
} finally {
|
|
135
|
+
currentEffect = prevEffect
|
|
136
|
+
}
|
|
52
137
|
}
|
package/framework/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// ─── Framework Public API ─────────────────────────────────────────────────────
|
|
2
|
-
export { signal, computed, effect } from './core/signal.js'
|
|
2
|
+
export { signal, computed, effect, batch, untrack } from './core/signal.js'
|
|
3
3
|
export { defineComponent, onMount, onUnmount } from './core/component.js'
|
|
4
4
|
export { createRouter, navigate, mountOutlet } from './router/router.js'
|