pulse-js-framework 1.0.0 → 1.4.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 +414 -182
- package/cli/analyze.js +499 -0
- package/cli/build.js +341 -199
- package/cli/format.js +704 -0
- package/cli/index.js +398 -324
- package/cli/lint.js +642 -0
- package/cli/mobile.js +1473 -0
- package/cli/utils/file-utils.js +298 -0
- package/compiler/lexer.js +766 -581
- package/compiler/parser.js +1797 -900
- package/compiler/transformer.js +1332 -552
- package/index.js +1 -1
- package/mobile/bridge/pulse-native.js +420 -0
- package/package.json +68 -58
- package/runtime/dom.js +363 -33
- package/runtime/index.js +2 -0
- package/runtime/native.js +368 -0
- package/runtime/pulse.js +247 -13
- package/runtime/router.js +596 -392
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse Native Runtime Module
|
|
3
|
+
* Reactive wrappers for native mobile APIs
|
|
4
|
+
* Integrates with Pulse reactivity system
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { pulse, effect, batch } from './pulse.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check if PulseMobile bridge is available
|
|
11
|
+
*/
|
|
12
|
+
export function isNativeAvailable() {
|
|
13
|
+
return typeof window !== 'undefined' && typeof window.PulseMobile !== 'undefined';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get the PulseMobile instance
|
|
18
|
+
*/
|
|
19
|
+
export function getNative() {
|
|
20
|
+
if (!isNativeAvailable()) {
|
|
21
|
+
throw new Error('PulseMobile is not available. Include pulse-native.js in your app.');
|
|
22
|
+
}
|
|
23
|
+
return window.PulseMobile;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get current platform
|
|
28
|
+
*/
|
|
29
|
+
export function getPlatform() {
|
|
30
|
+
if (!isNativeAvailable()) return 'web';
|
|
31
|
+
return getNative().platform;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if running in native environment
|
|
36
|
+
*/
|
|
37
|
+
export function isNative() {
|
|
38
|
+
return isNativeAvailable() && getNative().isNative;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create reactive native storage
|
|
43
|
+
* Syncs between native storage and Pulse reactivity
|
|
44
|
+
*/
|
|
45
|
+
export function createNativeStorage(prefix = '') {
|
|
46
|
+
const cache = new Map();
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
/**
|
|
50
|
+
* Get a reactive value from native storage
|
|
51
|
+
* Returns a Pulse signal that auto-persists
|
|
52
|
+
*/
|
|
53
|
+
get(key, defaultValue = null) {
|
|
54
|
+
const fullKey = prefix + key;
|
|
55
|
+
|
|
56
|
+
if (cache.has(fullKey)) {
|
|
57
|
+
return cache.get(fullKey);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const p = pulse(defaultValue);
|
|
61
|
+
cache.set(fullKey, p);
|
|
62
|
+
|
|
63
|
+
// Load initial value from storage
|
|
64
|
+
if (isNativeAvailable()) {
|
|
65
|
+
getNative().Storage.getItem(fullKey).then(value => {
|
|
66
|
+
if (value !== null) {
|
|
67
|
+
try {
|
|
68
|
+
p.set(JSON.parse(value));
|
|
69
|
+
} catch {
|
|
70
|
+
p.set(value);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
} else if (typeof localStorage !== 'undefined') {
|
|
75
|
+
const value = localStorage.getItem(fullKey);
|
|
76
|
+
if (value !== null) {
|
|
77
|
+
try {
|
|
78
|
+
p.set(JSON.parse(value));
|
|
79
|
+
} catch {
|
|
80
|
+
p.set(value);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Auto-persist on changes
|
|
86
|
+
let initialized = false;
|
|
87
|
+
effect(() => {
|
|
88
|
+
const value = p.get();
|
|
89
|
+
// Skip first effect run (initial load)
|
|
90
|
+
if (!initialized) {
|
|
91
|
+
initialized = true;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const serialized = JSON.stringify(value);
|
|
95
|
+
if (isNativeAvailable()) {
|
|
96
|
+
getNative().Storage.setItem(fullKey, serialized);
|
|
97
|
+
} else if (typeof localStorage !== 'undefined') {
|
|
98
|
+
localStorage.setItem(fullKey, serialized);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return p;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Remove a value from storage
|
|
107
|
+
*/
|
|
108
|
+
async remove(key) {
|
|
109
|
+
const fullKey = prefix + key;
|
|
110
|
+
cache.delete(fullKey);
|
|
111
|
+
if (isNativeAvailable()) {
|
|
112
|
+
await getNative().Storage.removeItem(fullKey);
|
|
113
|
+
} else if (typeof localStorage !== 'undefined') {
|
|
114
|
+
localStorage.removeItem(fullKey);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Clear all storage with prefix
|
|
120
|
+
*/
|
|
121
|
+
async clear() {
|
|
122
|
+
cache.clear();
|
|
123
|
+
if (isNativeAvailable()) {
|
|
124
|
+
const keys = await getNative().Storage.keys();
|
|
125
|
+
for (const key of keys) {
|
|
126
|
+
if (key.startsWith(prefix)) {
|
|
127
|
+
await getNative().Storage.removeItem(key);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} else if (typeof localStorage !== 'undefined') {
|
|
131
|
+
const keysToRemove = [];
|
|
132
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
133
|
+
const key = localStorage.key(i);
|
|
134
|
+
if (key && key.startsWith(prefix)) {
|
|
135
|
+
keysToRemove.push(key);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const key of keysToRemove) {
|
|
139
|
+
localStorage.removeItem(key);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Create reactive device info
|
|
148
|
+
*/
|
|
149
|
+
export function createDeviceInfo() {
|
|
150
|
+
const info = pulse(null);
|
|
151
|
+
const network = pulse({ connected: true, type: 'unknown' });
|
|
152
|
+
|
|
153
|
+
// Load device info
|
|
154
|
+
if (isNativeAvailable()) {
|
|
155
|
+
getNative().Device.getInfo().then(data => {
|
|
156
|
+
info.set(data);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
getNative().Device.getNetworkStatus().then(status => {
|
|
160
|
+
network.set(status);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Listen for network changes
|
|
164
|
+
getNative().Device.onNetworkChange(status => {
|
|
165
|
+
network.set(status);
|
|
166
|
+
});
|
|
167
|
+
} else {
|
|
168
|
+
// Web fallback
|
|
169
|
+
info.set({
|
|
170
|
+
platform: 'web',
|
|
171
|
+
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
|
172
|
+
language: typeof navigator !== 'undefined' ? navigator.language : 'en'
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (typeof navigator !== 'undefined') {
|
|
176
|
+
network.set({
|
|
177
|
+
connected: navigator.onLine,
|
|
178
|
+
type: 'unknown'
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
window.addEventListener('online', () => {
|
|
182
|
+
network.set({ connected: true, type: 'unknown' });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
window.addEventListener('offline', () => {
|
|
186
|
+
network.set({ connected: false, type: 'none' });
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
/** Device info as reactive Pulse */
|
|
193
|
+
info,
|
|
194
|
+
|
|
195
|
+
/** Network status as reactive Pulse */
|
|
196
|
+
network,
|
|
197
|
+
|
|
198
|
+
/** Current platform */
|
|
199
|
+
get platform() {
|
|
200
|
+
return getPlatform();
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
/** Is running in native app */
|
|
204
|
+
get isNative() {
|
|
205
|
+
return isNative();
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
/** Is currently online */
|
|
209
|
+
get isOnline() {
|
|
210
|
+
return network.get().connected;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Native UI helpers
|
|
217
|
+
*/
|
|
218
|
+
export const NativeUI = {
|
|
219
|
+
/**
|
|
220
|
+
* Show a toast message
|
|
221
|
+
*/
|
|
222
|
+
toast(message, isLong = false) {
|
|
223
|
+
if (isNativeAvailable()) {
|
|
224
|
+
return getNative().UI.showToast(message, isLong);
|
|
225
|
+
}
|
|
226
|
+
// Fallback: simple console log
|
|
227
|
+
console.log('[Toast]', message);
|
|
228
|
+
return Promise.resolve();
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Trigger haptic feedback / vibration
|
|
233
|
+
*/
|
|
234
|
+
vibrate(duration = 100) {
|
|
235
|
+
if (isNativeAvailable()) {
|
|
236
|
+
return getNative().UI.vibrate(duration);
|
|
237
|
+
}
|
|
238
|
+
// Web fallback
|
|
239
|
+
if (typeof navigator !== 'undefined' && navigator.vibrate) {
|
|
240
|
+
navigator.vibrate(duration);
|
|
241
|
+
}
|
|
242
|
+
return Promise.resolve();
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Native clipboard helpers
|
|
248
|
+
*/
|
|
249
|
+
export const NativeClipboard = {
|
|
250
|
+
/**
|
|
251
|
+
* Copy text to clipboard
|
|
252
|
+
*/
|
|
253
|
+
async copy(text) {
|
|
254
|
+
if (isNativeAvailable()) {
|
|
255
|
+
return getNative().Clipboard.copy(text);
|
|
256
|
+
}
|
|
257
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
|
258
|
+
return navigator.clipboard.writeText(text);
|
|
259
|
+
}
|
|
260
|
+
return Promise.reject(new Error('Clipboard not available'));
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Read text from clipboard
|
|
265
|
+
*/
|
|
266
|
+
async read() {
|
|
267
|
+
if (isNativeAvailable()) {
|
|
268
|
+
return getNative().Clipboard.read();
|
|
269
|
+
}
|
|
270
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
|
271
|
+
return navigator.clipboard.readText();
|
|
272
|
+
}
|
|
273
|
+
return '';
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* App lifecycle - pause handler
|
|
279
|
+
*/
|
|
280
|
+
export function onAppPause(callback) {
|
|
281
|
+
if (typeof document !== 'undefined') {
|
|
282
|
+
document.addEventListener('visibilitychange', () => {
|
|
283
|
+
if (document.hidden) callback();
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
if (isNativeAvailable()) {
|
|
287
|
+
getNative().App.onPause(callback);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* App lifecycle - resume handler
|
|
293
|
+
*/
|
|
294
|
+
export function onAppResume(callback) {
|
|
295
|
+
if (typeof document !== 'undefined') {
|
|
296
|
+
document.addEventListener('visibilitychange', () => {
|
|
297
|
+
if (!document.hidden) callback();
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
if (isNativeAvailable()) {
|
|
301
|
+
getNative().App.onResume(callback);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Handle Android back button
|
|
307
|
+
*/
|
|
308
|
+
export function onBackButton(callback) {
|
|
309
|
+
if (isNativeAvailable()) {
|
|
310
|
+
getNative().App.onBackButton(callback);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Wait for native bridge to be ready
|
|
316
|
+
*/
|
|
317
|
+
export function onNativeReady(callback) {
|
|
318
|
+
if (typeof window === 'undefined') return;
|
|
319
|
+
|
|
320
|
+
window.addEventListener('pulse:ready', (e) => {
|
|
321
|
+
callback(e.detail);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// If already ready (web or native initialized)
|
|
325
|
+
if (typeof window.PulseMobile !== 'undefined') {
|
|
326
|
+
const platform = window.PulseMobile.platform;
|
|
327
|
+
setTimeout(() => callback({ platform }), 0);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Exit the app (Android only)
|
|
333
|
+
*/
|
|
334
|
+
export function exitApp() {
|
|
335
|
+
if (isNativeAvailable() && getNative().isAndroid) {
|
|
336
|
+
return getNative().App.exit();
|
|
337
|
+
}
|
|
338
|
+
console.warn('exitApp is only available on Android');
|
|
339
|
+
return Promise.resolve();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Minimize the app
|
|
344
|
+
*/
|
|
345
|
+
export function minimizeApp() {
|
|
346
|
+
if (isNativeAvailable()) {
|
|
347
|
+
return getNative().App.minimize();
|
|
348
|
+
}
|
|
349
|
+
console.warn('minimizeApp is only available in native apps');
|
|
350
|
+
return Promise.resolve();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export default {
|
|
354
|
+
isNativeAvailable,
|
|
355
|
+
getNative,
|
|
356
|
+
getPlatform,
|
|
357
|
+
isNative,
|
|
358
|
+
createNativeStorage,
|
|
359
|
+
createDeviceInfo,
|
|
360
|
+
NativeUI,
|
|
361
|
+
NativeClipboard,
|
|
362
|
+
onAppPause,
|
|
363
|
+
onAppResume,
|
|
364
|
+
onBackButton,
|
|
365
|
+
onNativeReady,
|
|
366
|
+
exitApp,
|
|
367
|
+
minimizeApp
|
|
368
|
+
};
|
package/runtime/pulse.js
CHANGED
|
@@ -10,6 +10,17 @@ let currentEffect = null;
|
|
|
10
10
|
let batchDepth = 0;
|
|
11
11
|
let pendingEffects = new Set();
|
|
12
12
|
let isRunningEffects = false;
|
|
13
|
+
let cleanupQueue = [];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Register a cleanup function for the current effect
|
|
17
|
+
* Called when the effect re-runs or is disposed
|
|
18
|
+
*/
|
|
19
|
+
export function onCleanup(fn) {
|
|
20
|
+
if (currentEffect) {
|
|
21
|
+
currentEffect.cleanups.push(fn);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
13
24
|
|
|
14
25
|
/**
|
|
15
26
|
* Pulse - A reactive value container
|
|
@@ -104,6 +115,29 @@ export class Pulse {
|
|
|
104
115
|
_init(value) {
|
|
105
116
|
this.#value = value;
|
|
106
117
|
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Set from computed - propagates to subscribers (internal use)
|
|
121
|
+
*/
|
|
122
|
+
_setFromComputed(newValue) {
|
|
123
|
+
if (this.#equals(this.#value, newValue)) return;
|
|
124
|
+
this.#value = newValue;
|
|
125
|
+
this.#notify();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Add a subscriber directly (internal use)
|
|
130
|
+
*/
|
|
131
|
+
_addSubscriber(subscriber) {
|
|
132
|
+
this.#subscribers.add(subscriber);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Trigger notification to all subscribers (internal use)
|
|
137
|
+
*/
|
|
138
|
+
_triggerNotify() {
|
|
139
|
+
this.#notify();
|
|
140
|
+
}
|
|
107
141
|
}
|
|
108
142
|
|
|
109
143
|
/**
|
|
@@ -160,25 +194,95 @@ export function pulse(value, options) {
|
|
|
160
194
|
* Create a computed pulse that automatically updates
|
|
161
195
|
* when its dependencies change
|
|
162
196
|
*/
|
|
163
|
-
export function computed(fn) {
|
|
197
|
+
export function computed(fn, options = {}) {
|
|
198
|
+
const { lazy = false } = options;
|
|
164
199
|
const p = new Pulse(undefined);
|
|
165
200
|
let initialized = false;
|
|
201
|
+
let dirty = true;
|
|
202
|
+
let cachedValue;
|
|
203
|
+
let cleanup = null;
|
|
204
|
+
|
|
205
|
+
if (lazy) {
|
|
206
|
+
// Lazy computed - only evaluates when read
|
|
207
|
+
const originalGet = p.get.bind(p);
|
|
208
|
+
|
|
209
|
+
// Track which pulses this depends on
|
|
210
|
+
let trackedDeps = new Set();
|
|
211
|
+
|
|
212
|
+
p.get = function() {
|
|
213
|
+
if (dirty) {
|
|
214
|
+
// Run computation
|
|
215
|
+
const prevEffect = currentEffect;
|
|
216
|
+
const tempEffect = {
|
|
217
|
+
run: () => {},
|
|
218
|
+
dependencies: new Set(),
|
|
219
|
+
cleanups: []
|
|
220
|
+
};
|
|
221
|
+
currentEffect = tempEffect;
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
cachedValue = fn();
|
|
225
|
+
dirty = false;
|
|
226
|
+
|
|
227
|
+
// Cleanup old subscriptions
|
|
228
|
+
for (const dep of trackedDeps) {
|
|
229
|
+
dep._unsubscribe(markDirty);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Set up new subscriptions
|
|
233
|
+
trackedDeps = tempEffect.dependencies;
|
|
234
|
+
for (const dep of trackedDeps) {
|
|
235
|
+
dep.subscribe(() => {
|
|
236
|
+
dirty = true;
|
|
237
|
+
// Notify our own subscribers
|
|
238
|
+
p._triggerNotify();
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
p._init(cachedValue);
|
|
243
|
+
} finally {
|
|
244
|
+
currentEffect = prevEffect;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
166
247
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
248
|
+
// Track dependency on this computed
|
|
249
|
+
if (currentEffect) {
|
|
250
|
+
p._addSubscriber(currentEffect);
|
|
251
|
+
currentEffect.dependencies.add(p);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return cachedValue;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const markDirty = { run: () => { dirty = true; }, dependencies: new Set(), cleanups: [] };
|
|
258
|
+
} else {
|
|
259
|
+
// Eager computed - updates immediately when dependencies change
|
|
260
|
+
cleanup = effect(() => {
|
|
261
|
+
const newValue = fn();
|
|
262
|
+
if (!initialized) {
|
|
263
|
+
p._init(newValue);
|
|
264
|
+
initialized = true;
|
|
265
|
+
} else {
|
|
266
|
+
// Use set() to properly propagate to downstream subscribers
|
|
267
|
+
p._setFromComputed(newValue);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
176
271
|
|
|
177
272
|
// Override set to make it read-only
|
|
178
273
|
p.set = () => {
|
|
179
274
|
throw new Error('Cannot set a computed pulse directly');
|
|
180
275
|
};
|
|
181
276
|
|
|
277
|
+
p.update = () => {
|
|
278
|
+
throw new Error('Cannot update a computed pulse directly');
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Add dispose method
|
|
282
|
+
p.dispose = () => {
|
|
283
|
+
if (cleanup) cleanup();
|
|
284
|
+
};
|
|
285
|
+
|
|
182
286
|
return p;
|
|
183
287
|
}
|
|
184
288
|
|
|
@@ -188,6 +292,16 @@ export function computed(fn) {
|
|
|
188
292
|
export function effect(fn) {
|
|
189
293
|
const effectFn = {
|
|
190
294
|
run: () => {
|
|
295
|
+
// Run cleanup functions from previous run
|
|
296
|
+
for (const cleanup of effectFn.cleanups) {
|
|
297
|
+
try {
|
|
298
|
+
cleanup();
|
|
299
|
+
} catch (e) {
|
|
300
|
+
console.error('Cleanup error:', e);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
effectFn.cleanups = [];
|
|
304
|
+
|
|
191
305
|
// Clean up old dependencies
|
|
192
306
|
for (const dep of effectFn.dependencies) {
|
|
193
307
|
dep._unsubscribe(effectFn);
|
|
@@ -206,7 +320,8 @@ export function effect(fn) {
|
|
|
206
320
|
currentEffect = prevEffect;
|
|
207
321
|
}
|
|
208
322
|
},
|
|
209
|
-
dependencies: new Set()
|
|
323
|
+
dependencies: new Set(),
|
|
324
|
+
cleanups: []
|
|
210
325
|
};
|
|
211
326
|
|
|
212
327
|
// Run immediately to collect dependencies
|
|
@@ -214,6 +329,16 @@ export function effect(fn) {
|
|
|
214
329
|
|
|
215
330
|
// Return cleanup function
|
|
216
331
|
return () => {
|
|
332
|
+
// Run any pending cleanups
|
|
333
|
+
for (const cleanup of effectFn.cleanups) {
|
|
334
|
+
try {
|
|
335
|
+
cleanup();
|
|
336
|
+
} catch (e) {
|
|
337
|
+
console.error('Cleanup error:', e);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
effectFn.cleanups = [];
|
|
341
|
+
|
|
217
342
|
for (const dep of effectFn.dependencies) {
|
|
218
343
|
dep._unsubscribe(effectFn);
|
|
219
344
|
}
|
|
@@ -246,7 +371,62 @@ export function createState(obj) {
|
|
|
246
371
|
const pulses = {};
|
|
247
372
|
|
|
248
373
|
for (const [key, value] of Object.entries(obj)) {
|
|
249
|
-
if (
|
|
374
|
+
if (Array.isArray(value)) {
|
|
375
|
+
// Arrays get special handling with reactive methods
|
|
376
|
+
pulses[key] = new Pulse(value);
|
|
377
|
+
|
|
378
|
+
Object.defineProperty(state, key, {
|
|
379
|
+
get() {
|
|
380
|
+
return pulses[key].get();
|
|
381
|
+
},
|
|
382
|
+
set(newValue) {
|
|
383
|
+
pulses[key].set(newValue);
|
|
384
|
+
},
|
|
385
|
+
enumerable: true
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Add array helper methods
|
|
389
|
+
state[`${key}$push`] = (...items) => {
|
|
390
|
+
pulses[key].update(arr => [...arr, ...items]);
|
|
391
|
+
};
|
|
392
|
+
state[`${key}$pop`] = () => {
|
|
393
|
+
let popped;
|
|
394
|
+
pulses[key].update(arr => {
|
|
395
|
+
popped = arr[arr.length - 1];
|
|
396
|
+
return arr.slice(0, -1);
|
|
397
|
+
});
|
|
398
|
+
return popped;
|
|
399
|
+
};
|
|
400
|
+
state[`${key}$shift`] = () => {
|
|
401
|
+
let shifted;
|
|
402
|
+
pulses[key].update(arr => {
|
|
403
|
+
shifted = arr[0];
|
|
404
|
+
return arr.slice(1);
|
|
405
|
+
});
|
|
406
|
+
return shifted;
|
|
407
|
+
};
|
|
408
|
+
state[`${key}$unshift`] = (...items) => {
|
|
409
|
+
pulses[key].update(arr => [...items, ...arr]);
|
|
410
|
+
};
|
|
411
|
+
state[`${key}$splice`] = (start, deleteCount, ...items) => {
|
|
412
|
+
let removed;
|
|
413
|
+
pulses[key].update(arr => {
|
|
414
|
+
const copy = [...arr];
|
|
415
|
+
removed = copy.splice(start, deleteCount, ...items);
|
|
416
|
+
return copy;
|
|
417
|
+
});
|
|
418
|
+
return removed;
|
|
419
|
+
};
|
|
420
|
+
state[`${key}$filter`] = (fn) => {
|
|
421
|
+
pulses[key].update(arr => arr.filter(fn));
|
|
422
|
+
};
|
|
423
|
+
state[`${key}$map`] = (fn) => {
|
|
424
|
+
pulses[key].update(arr => arr.map(fn));
|
|
425
|
+
};
|
|
426
|
+
state[`${key}$sort`] = (fn) => {
|
|
427
|
+
pulses[key].update(arr => [...arr].sort(fn));
|
|
428
|
+
};
|
|
429
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
250
430
|
// Recursively create state for nested objects
|
|
251
431
|
state[key] = createState(value);
|
|
252
432
|
} else {
|
|
@@ -273,6 +453,57 @@ export function createState(obj) {
|
|
|
273
453
|
return state;
|
|
274
454
|
}
|
|
275
455
|
|
|
456
|
+
/**
|
|
457
|
+
* Memoize a function based on reactive dependencies
|
|
458
|
+
* Only recomputes when dependencies change
|
|
459
|
+
*/
|
|
460
|
+
export function memo(fn, options = {}) {
|
|
461
|
+
const { equals = Object.is } = options;
|
|
462
|
+
let cachedResult;
|
|
463
|
+
let cachedDeps = null;
|
|
464
|
+
let initialized = false;
|
|
465
|
+
|
|
466
|
+
return (...args) => {
|
|
467
|
+
// Check if args have changed
|
|
468
|
+
const depsChanged = !cachedDeps ||
|
|
469
|
+
args.length !== cachedDeps.length ||
|
|
470
|
+
args.some((arg, i) => !equals(arg, cachedDeps[i]));
|
|
471
|
+
|
|
472
|
+
if (!initialized || depsChanged) {
|
|
473
|
+
cachedResult = fn(...args);
|
|
474
|
+
cachedDeps = args;
|
|
475
|
+
initialized = true;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return cachedResult;
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Create a memoized computed value
|
|
484
|
+
* Combines memo with computed for expensive derivations
|
|
485
|
+
*/
|
|
486
|
+
export function memoComputed(fn, options = {}) {
|
|
487
|
+
const { deps = [], equals = Object.is } = options;
|
|
488
|
+
let lastDeps = null;
|
|
489
|
+
let lastResult;
|
|
490
|
+
|
|
491
|
+
return computed(() => {
|
|
492
|
+
const currentDeps = deps.map(d => typeof d === 'function' ? d() : d.get());
|
|
493
|
+
|
|
494
|
+
const depsChanged = !lastDeps ||
|
|
495
|
+
currentDeps.length !== lastDeps.length ||
|
|
496
|
+
currentDeps.some((d, i) => !equals(d, lastDeps[i]));
|
|
497
|
+
|
|
498
|
+
if (depsChanged) {
|
|
499
|
+
lastResult = fn();
|
|
500
|
+
lastDeps = currentDeps;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return lastResult;
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
276
507
|
/**
|
|
277
508
|
* Watch specific pulses and run a callback when they change
|
|
278
509
|
*/
|
|
@@ -335,5 +566,8 @@ export default {
|
|
|
335
566
|
createState,
|
|
336
567
|
watch,
|
|
337
568
|
fromPromise,
|
|
338
|
-
untrack
|
|
569
|
+
untrack,
|
|
570
|
+
onCleanup,
|
|
571
|
+
memo,
|
|
572
|
+
memoComputed
|
|
339
573
|
};
|