@pulse-js/core 0.2.1 → 0.3.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/dist/index.js CHANGED
@@ -102,7 +102,12 @@ function guard(nameOrFn, fn) {
102
102
  if (currentId === evaluationId) {
103
103
  persistDependencies();
104
104
  if (resolved === false) {
105
- const reason = name ? `${name} failed` : "condition failed";
105
+ const message = name ? `${name} failed` : "condition failed";
106
+ const reason = {
107
+ code: "GUARD_FAIL",
108
+ message,
109
+ toString: () => message
110
+ };
106
111
  state = { status: "fail", reason, lastReason: reason, updatedAt: Date.now() };
107
112
  } else if (resolved === void 0) {
108
113
  state = { ...state, status: "pending", updatedAt: Date.now() };
@@ -114,13 +119,22 @@ function guard(nameOrFn, fn) {
114
119
  }).catch((err) => {
115
120
  if (currentId === evaluationId) {
116
121
  persistDependencies();
117
- const message = err instanceof Error ? err.message : String(err);
118
- const reason = err.meta ? {
119
- code: err.code || "ERROR",
120
- message,
121
- meta: err.meta,
122
- toString: () => message
123
- } : message;
122
+ let reason;
123
+ if (err && err._pulseFail) {
124
+ reason = err._reason;
125
+ } else {
126
+ const message = err instanceof Error ? err.message : String(err);
127
+ reason = err && err.meta ? {
128
+ code: err.code || "ERROR",
129
+ message,
130
+ meta: err.meta,
131
+ toString: () => message
132
+ } : {
133
+ code: "ERROR",
134
+ message,
135
+ toString: () => message
136
+ };
137
+ }
124
138
  state = {
125
139
  status: "fail",
126
140
  reason,
@@ -133,7 +147,12 @@ function guard(nameOrFn, fn) {
133
147
  } else {
134
148
  persistDependencies();
135
149
  if (result === false) {
136
- const reason = name ? `${name} failed` : "condition failed";
150
+ const message = name ? `${name} failed` : "condition failed";
151
+ const reason = {
152
+ code: "GUARD_FAIL",
153
+ message,
154
+ toString: () => message
155
+ };
137
156
  state = { status: "fail", reason, lastReason: reason, updatedAt: Date.now() };
138
157
  } else if (result === void 0) {
139
158
  state = { ...state, status: "pending", updatedAt: Date.now() };
@@ -162,7 +181,11 @@ function guard(nameOrFn, fn) {
162
181
  message,
163
182
  meta: err.meta,
164
183
  toString: () => message
165
- } : message;
184
+ } : {
185
+ code: "ERROR",
186
+ message,
187
+ toString: () => message
188
+ };
166
189
  state = {
167
190
  status: "fail",
168
191
  reason,
@@ -257,9 +280,8 @@ function guard(nameOrFn, fn) {
257
280
  if (name) {
258
281
  registerGuardForHydration(name, g);
259
282
  }
260
- PulseRegistry.register(g);
261
283
  evaluate2();
262
- return g;
284
+ return PulseRegistry.register(g);
263
285
  }
264
286
  guard.map = function(source2, mapper, name) {
265
287
  const guardName = name || `map-${source2._name || "source"}`;
@@ -268,69 +290,132 @@ guard.map = function(source2, mapper, name) {
268
290
  return mapper(value);
269
291
  });
270
292
  };
293
+ guard.select = function(pulseObj, selector, name) {
294
+ const guardName = name || `select-${pulseObj._name || "pulse"}`;
295
+ return guard(guardName, () => {
296
+ return selector(pulseObj);
297
+ });
298
+ };
299
+ guard.from = function(getValue, options) {
300
+ const name = options?.name || "from-external";
301
+ return guard(name, () => {
302
+ const result = getValue();
303
+ if (result && typeof result === "object" && ("value" in result || "isLoading" in result || "error" in result)) {
304
+ const wrapped = result;
305
+ if (wrapped.isLoading) {
306
+ return void 0;
307
+ }
308
+ if (wrapped.error) {
309
+ guardFail(wrapped.error?.message || "External error");
310
+ }
311
+ return wrapped.value;
312
+ }
313
+ return result;
314
+ });
315
+ };
271
316
 
272
317
  // src/registry.ts
318
+ function generateUID(name, sourceInfo) {
319
+ if (sourceInfo?.file && sourceInfo?.line) {
320
+ return `${sourceInfo.file}:${sourceInfo.line}:${name}`;
321
+ }
322
+ return `pulse:${name}`;
323
+ }
273
324
  var Registry = class {
274
- units = /* @__PURE__ */ new Map();
325
+ targets = /* @__PURE__ */ new Map();
326
+ proxies = /* @__PURE__ */ new Map();
275
327
  listeners = /* @__PURE__ */ new Set();
276
328
  currentGeneration = 0;
277
329
  cleanupScheduled = false;
330
+ hmrDebounce = null;
278
331
  /**
279
- * Schedules cleanup of units that weren't re-registered (deleted from code).
332
+ * Registers a unit and returns a stable Identity Proxy.
333
+ *
334
+ * If a unit with the same UID already exists, it updates the internal
335
+ * target of the existing proxy and returns that same proxy.
336
+ */
337
+ register(unit) {
338
+ const meta = unit;
339
+ const name = meta._name;
340
+ if (!name) return unit;
341
+ const uid = generateUID(name, meta._sourceInfo);
342
+ meta._uid = uid;
343
+ const existingTarget = this.targets.get(uid);
344
+ const existingProxy = this.proxies.get(uid);
345
+ if (existingProxy) {
346
+ if (this.targets.get(uid) !== unit) {
347
+ if (this.currentGeneration === unit._generation) {
348
+ }
349
+ this.targets.set(uid, unit);
350
+ this.notifyListeners(unit, "update");
351
+ }
352
+ return existingProxy;
353
+ }
354
+ this.targets.set(uid, unit);
355
+ const self = this;
356
+ const proxy = new Proxy((() => {
357
+ }), {
358
+ get(_, prop) {
359
+ const target = self.targets.get(uid);
360
+ if (!target) return void 0;
361
+ const value = target[prop];
362
+ return typeof value === "function" ? value.bind(target) : value;
363
+ },
364
+ apply(_, thisArg, args) {
365
+ const target = self.targets.get(uid);
366
+ if (typeof target !== "function") return void 0;
367
+ return Reflect.apply(target, thisArg, args);
368
+ },
369
+ // Ensure type checking and other proxy traps work
370
+ getPrototypeOf(_) {
371
+ return Object.getPrototypeOf(self.targets.get(uid) || {});
372
+ },
373
+ has(_, prop) {
374
+ return Reflect.has(self.targets.get(uid) || {}, prop);
375
+ },
376
+ ownKeys(_) {
377
+ return Reflect.ownKeys(self.targets.get(uid) || {});
378
+ },
379
+ getOwnPropertyDescriptor(_, prop) {
380
+ return Reflect.getOwnPropertyDescriptor(self.targets.get(uid) || {}, prop);
381
+ }
382
+ });
383
+ this.proxies.set(uid, proxy);
384
+ this.notifyListeners(unit, "add");
385
+ return proxy;
386
+ }
387
+ /**
388
+ * Schedules cleanup of units that weren't re-registered.
280
389
  */
281
390
  scheduleCleanup() {
282
391
  if (this.cleanupScheduled) return;
283
392
  this.cleanupScheduled = true;
284
- setTimeout(() => {
393
+ if (this.hmrDebounce) clearTimeout(this.hmrDebounce);
394
+ this.hmrDebounce = setTimeout(() => {
285
395
  this.cleanupDeadUnits();
286
396
  this.cleanupScheduled = false;
287
- }, 100);
397
+ this.hmrDebounce = null;
398
+ }, 150);
288
399
  }
289
- /**
290
- * Removes units that weren't re-registered in the current generation.
291
- * Uses mark-and-sweep: units that were re-registered have current generation,
292
- * units that weren't are from old generation and should be removed.
293
- */
294
400
  cleanupDeadUnits() {
295
- const toDelete = [];
296
- this.units.forEach((unit, key) => {
297
- const gen = unit._generation;
298
- if (gen !== void 0 && gen < this.currentGeneration) {
299
- toDelete.push(key);
300
- }
301
- });
302
- toDelete.forEach((key) => this.units.delete(key));
303
- if (toDelete.length > 0) {
304
- console.log(`[Pulse] Cleaned up ${toDelete.length} deleted units after HMR`);
305
- }
306
401
  }
307
- /**
308
- * Registers a unit (only if it has an explicit name).
309
- */
310
- register(unit) {
311
- const unitWithMetadata = unit;
312
- const name = unitWithMetadata._name;
313
- if (!name) {
314
- return;
315
- }
316
- const existingUnit = this.units.get(name);
317
- if (existingUnit) {
318
- const existingGen = existingUnit?._generation;
319
- if (existingGen === this.currentGeneration) {
320
- unitWithMetadata._generation = this.currentGeneration;
321
- this.units.set(name, unit);
322
- this.listeners.forEach((l) => l(unit));
323
- return;
324
- }
325
- this.currentGeneration++;
326
- this.scheduleCleanup();
327
- }
328
- unitWithMetadata._generation = this.currentGeneration;
329
- this.units.set(name, unit);
330
- this.listeners.forEach((l) => l(unit));
402
+ notifyListeners(unit, event) {
403
+ this.listeners.forEach((l) => l(unit, event));
404
+ }
405
+ get(nameOrUid) {
406
+ const proxy = this.proxies.get(nameOrUid);
407
+ if (proxy) return proxy;
408
+ const uid = generateUID(nameOrUid);
409
+ return this.proxies.get(uid);
331
410
  }
332
411
  getAll() {
333
- return Array.from(this.units.values());
412
+ return Array.from(this.proxies.values());
413
+ }
414
+ getAllWithMeta() {
415
+ return Array.from(this.proxies.entries()).map(([uid, unit]) => ({
416
+ unit,
417
+ uid
418
+ }));
334
419
  }
335
420
  onRegister(listener) {
336
421
  this.listeners.add(listener);
@@ -339,7 +424,8 @@ var Registry = class {
339
424
  };
340
425
  }
341
426
  reset() {
342
- this.units.clear();
427
+ this.targets.clear();
428
+ this.proxies.clear();
343
429
  this.currentGeneration = 0;
344
430
  }
345
431
  };
@@ -381,8 +467,192 @@ function source(initialValue, options = {}) {
381
467
  return () => subscribers.delete(listener);
382
468
  };
383
469
  s._name = options.name;
384
- PulseRegistry.register(s);
385
- return s;
470
+ return PulseRegistry.register(s);
471
+ }
472
+
473
+ // src/signal.ts
474
+ var batchQueue = null;
475
+ var batchDepth = 0;
476
+ function batch(fn) {
477
+ batchDepth++;
478
+ if (!batchQueue) {
479
+ batchQueue = /* @__PURE__ */ new Set();
480
+ }
481
+ try {
482
+ fn();
483
+ } finally {
484
+ batchDepth--;
485
+ if (batchDepth === 0 && batchQueue) {
486
+ const queue = batchQueue;
487
+ batchQueue = null;
488
+ queue.forEach((notify) => notify());
489
+ }
490
+ }
491
+ }
492
+ function scheduleNotification(notify) {
493
+ if (batchQueue) {
494
+ batchQueue.add(notify);
495
+ } else {
496
+ notify();
497
+ }
498
+ }
499
+ function createSignal(initialValue, equals = (a, b) => a === b) {
500
+ let value = initialValue;
501
+ const subscribers = /* @__PURE__ */ new Set();
502
+ const dependents = /* @__PURE__ */ new Set();
503
+ const notify = () => {
504
+ subscribers.forEach((sub) => sub(value));
505
+ const deps = Array.from(dependents);
506
+ dependents.clear();
507
+ deps.forEach((dep) => dep.notify());
508
+ };
509
+ const signal = {
510
+ get() {
511
+ const activeGuard = getCurrentGuard();
512
+ if (activeGuard) {
513
+ dependents.add(activeGuard);
514
+ activeGuard.addDependency(signal);
515
+ }
516
+ return value;
517
+ },
518
+ peek() {
519
+ return value;
520
+ },
521
+ set(newValue) {
522
+ if (!equals(value, newValue)) {
523
+ value = newValue;
524
+ scheduleNotification(notify);
525
+ }
526
+ },
527
+ update(fn) {
528
+ signal.set(fn(value));
529
+ },
530
+ subscribe(listener) {
531
+ subscribers.add(listener);
532
+ return () => subscribers.delete(listener);
533
+ }
534
+ };
535
+ return signal;
536
+ }
537
+ function effect(fn) {
538
+ let cleanup;
539
+ let disposed = false;
540
+ const run = () => {
541
+ if (disposed) return;
542
+ if (cleanup) cleanup();
543
+ cleanup = fn();
544
+ };
545
+ run();
546
+ return () => {
547
+ disposed = true;
548
+ if (cleanup) cleanup();
549
+ };
550
+ }
551
+
552
+ // src/pulse.ts
553
+ var PULSE_META = /* @__PURE__ */ Symbol("PULSE_META");
554
+ function pulse(target, options = {}) {
555
+ const { name, deep = true } = options;
556
+ const signals = /* @__PURE__ */ new Map();
557
+ const subscribers = /* @__PURE__ */ new Set();
558
+ const dependents = /* @__PURE__ */ new Set();
559
+ const nestedCache = /* @__PURE__ */ new Map();
560
+ const meta = {
561
+ signals,
562
+ subscribers,
563
+ dependents,
564
+ name,
565
+ target
566
+ };
567
+ function getSignal(key) {
568
+ if (!signals.has(key)) {
569
+ const initialValue = target[key];
570
+ signals.set(key, createSignal(initialValue));
571
+ }
572
+ return signals.get(key);
573
+ }
574
+ function notify() {
575
+ subscribers.forEach((sub) => sub(proxy));
576
+ const deps = Array.from(dependents);
577
+ dependents.clear();
578
+ deps.forEach((dep) => dep.notify());
579
+ }
580
+ function trackAccess() {
581
+ const activeGuard = getCurrentGuard();
582
+ if (activeGuard) {
583
+ dependents.add(activeGuard);
584
+ activeGuard.addDependency(proxy);
585
+ }
586
+ }
587
+ const proxy = new Proxy(target, {
588
+ get(obj, prop) {
589
+ if (prop === PULSE_META) return meta;
590
+ if (prop === "$raw") return target;
591
+ if (prop === "$subscribe") {
592
+ return (listener) => {
593
+ subscribers.add(listener);
594
+ return () => subscribers.delete(listener);
595
+ };
596
+ }
597
+ if (prop === "$snapshot") {
598
+ return () => ({ ...target });
599
+ }
600
+ const value = obj[prop];
601
+ if (typeof value === "function") {
602
+ return value.bind(proxy);
603
+ }
604
+ trackAccess();
605
+ const signal = getSignal(prop);
606
+ const currentValue = signal.get();
607
+ if (deep && currentValue !== null && typeof currentValue === "object" && !isPulseObject(currentValue)) {
608
+ if (!nestedCache.has(prop)) {
609
+ nestedCache.set(prop, pulse(currentValue, { deep, name: name ? `${name}.${String(prop)}` : void 0 }));
610
+ }
611
+ return nestedCache.get(prop);
612
+ }
613
+ return currentValue;
614
+ },
615
+ set(obj, prop, value) {
616
+ const oldValue = obj[prop];
617
+ if (oldValue === value) return true;
618
+ obj[prop] = value;
619
+ getSignal(prop).set(value);
620
+ notify();
621
+ return true;
622
+ },
623
+ has(obj, prop) {
624
+ if (prop === PULSE_META) return true;
625
+ trackAccess();
626
+ return Reflect.has(obj, prop);
627
+ },
628
+ ownKeys(obj) {
629
+ trackAccess();
630
+ return Reflect.ownKeys(obj);
631
+ }
632
+ });
633
+ return PulseRegistry.register(proxy);
634
+ }
635
+ function isPulseObject(value) {
636
+ return value !== null && typeof value === "object" && PULSE_META in value;
637
+ }
638
+ function toRaw(pulseObj) {
639
+ return pulseObj.$raw;
640
+ }
641
+ function readonly(pulseObj) {
642
+ return new Proxy(pulseObj, {
643
+ set() {
644
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
645
+ console.warn("[Pulse] Attempted to mutate a readonly pulse object");
646
+ }
647
+ return false;
648
+ },
649
+ deleteProperty() {
650
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
651
+ console.warn("[Pulse] Attempted to delete from a readonly pulse object");
652
+ }
653
+ return false;
654
+ }
655
+ });
386
656
  }
387
657
 
388
658
  // src/compute.ts
@@ -450,14 +720,21 @@ var guardExtensions = {
450
720
  var extendedGuard = Object.assign(guard, guardExtensions);
451
721
  export {
452
722
  PulseRegistry,
723
+ batch,
453
724
  compute,
725
+ createSignal,
726
+ effect,
454
727
  evaluate,
455
728
  getCurrentGuard,
456
729
  extendedGuard as guard,
457
730
  guardFail,
458
731
  guardOk,
459
732
  hydrate,
733
+ isPulseObject,
734
+ pulse,
735
+ readonly,
460
736
  registerGuardForHydration,
461
737
  runInContext,
462
- source
738
+ source,
739
+ toRaw
463
740
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulse-js/core",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "module": "dist/index.js",
5
5
  "main": "dist/index.cjs",
6
6
  "types": "dist/index.d.ts",
@@ -45,7 +45,9 @@
45
45
  "build": "bun x tsup src/index.ts --format esm,cjs --dts --clean",
46
46
  "dev": "bun x tsup src/index.ts --format esm,cjs --watch --dts",
47
47
  "lint": "bun x tsc --noEmit",
48
- "test": "vitest run"
48
+ "test": "vitest run",
49
+ "test:v2": "bun test tests/v2.test.ts tests/astro.test.ts tests/tanstack.test.ts",
50
+ "benchmark": "bun run tests/v2-benchmarks.ts"
49
51
  },
50
52
  "devDependencies": {
51
53
  "@sveltejs/vite-plugin-svelte": "^6.2.4",