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.
@@ -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
- effect(() => {
168
- const newValue = fn();
169
- if (initialized) {
170
- p._init(newValue); // Use _init to avoid triggering notifications during compute
171
- } else {
172
- p._init(newValue);
173
- initialized = true;
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 (typeof value === 'object' && value !== null && !Array.isArray(value)) {
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
  };