@mmstack/primitives 19.1.2 → 19.2.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.
@@ -1,4 +1,4 @@
1
- import { untracked, signal, inject, DestroyRef, computed, isSignal, linkedSignal, PLATFORM_ID, isDevMode, effect } from '@angular/core';
1
+ import { untracked, signal, inject, DestroyRef, computed, isSignal, linkedSignal, PLATFORM_ID, ElementRef, isDevMode, effect } from '@angular/core';
2
2
  import { isPlatformServer } from '@angular/common';
3
3
  import { SIGNAL } from '@angular/core/primitives/signals';
4
4
 
@@ -336,6 +336,404 @@ function isMutable(value) {
336
336
  return 'mutate' in value && typeof value.mutate === 'function';
337
337
  }
338
338
 
339
+ /**
340
+ * Creates a read-only signal that reactively tracks whether a CSS media query
341
+ * string currently matches.
342
+ *
343
+ * It uses `window.matchMedia` to evaluate the query and listen for changes.
344
+ * The primitive is SSR-safe (defaults to `false` on the server) and automatically
345
+ * cleans up its event listeners when the creating context is destroyed.
346
+ *
347
+ * @param query The CSS media query string to evaluate (e.g., `'(min-width: 768px)'`, `'(prefers-color-scheme: dark)'`).
348
+ * @param debugName Optional debug name for the signal.
349
+ * @returns A read-only `Signal<boolean>` which is `true` if the media query
350
+ * currently matches, and `false` otherwise.
351
+ *
352
+ * @remarks
353
+ * - On the server, this signal will always return `false` by default.
354
+ * - It automatically updates if the match status of the media query changes in the browser.
355
+ * - Event listeners are cleaned up automatically via `DestroyRef` if created in an injection context.
356
+ *
357
+ * @example
358
+ * ```ts
359
+ * import { Component, effect } from '@angular/core';
360
+ * import { mediaQuery } from '@mmstack/primitives';
361
+ *
362
+ * @Component({
363
+ * selector: 'app-responsive-layout',
364
+ * template: `
365
+ * @if (isDesktop()) {
366
+ * <p>Showing desktop layout.</p>
367
+ * } @else {
368
+ * <p>Showing mobile layout.</p>
369
+ * }
370
+ * `
371
+ * })
372
+ * export class ResponsiveLayoutComponent {
373
+ * readonly isDesktop = mediaQuery('(min-width: 1024px)');
374
+ *
375
+ * constructor() {
376
+ * effect(() => {
377
+ * console.log('Is desktop view:', this.isDesktop());
378
+ * });
379
+ * }
380
+ * }
381
+ * ```
382
+ */
383
+ function mediaQuery(query, debugName) {
384
+ if (isPlatformServer(inject(PLATFORM_ID)))
385
+ return computed(() => false, { debugName });
386
+ const mediaQueryList = window.matchMedia(query);
387
+ const state = signal(mediaQueryList.matches, { debugName });
388
+ const handleChange = (event) => {
389
+ state.set(event.matches);
390
+ };
391
+ mediaQueryList.addEventListener('change', handleChange);
392
+ inject(DestroyRef).onDestroy(() => {
393
+ mediaQueryList.removeEventListener('change', handleChange);
394
+ });
395
+ return state.asReadonly();
396
+ }
397
+ /**
398
+ * Creates a read-only signal that tracks the user's OS/browser preference
399
+ * for a dark color scheme using the `(prefers-color-scheme: dark)` media query.
400
+ *
401
+ * This is a convenience wrapper around the generic `mediaQuery` primitive.
402
+ * It's SSR-safe (defaults to `false` on the server) and automatically
403
+ * cleans up its event listeners.
404
+ *
405
+ * @param debugName Optional debug name for the signal.
406
+ * @returns A read-only `Signal<boolean>` which is `true` if a dark theme
407
+ * is preferred, and `false` otherwise.
408
+ * @see {mediaQuery} for the underlying implementation.
409
+ *
410
+ * @example
411
+ * ```ts
412
+ * const isDarkMode = prefersDarkMode();
413
+ * effect(() => {
414
+ * document.body.classList.toggle('dark-theme', isDarkMode());
415
+ * });
416
+ * ```
417
+ */
418
+ function prefersDarkMode(debugName) {
419
+ return mediaQuery('(prefers-color-scheme: dark)', debugName);
420
+ }
421
+ /**
422
+ * Creates a read-only signal that tracks the user's OS/browser preference
423
+ * for reduced motion using the `(prefers-reduced-motion: reduce)` media query.
424
+ *
425
+ * This is a convenience wrapper around the generic `mediaQuery` primitive.
426
+ * It's SSR-safe (defaults to `false` on the server) and automatically
427
+ * cleans up its event listeners.
428
+ *
429
+ * @param debugName Optional debug name for the signal.
430
+ * @returns A read-only `Signal<boolean>` which is `true` if reduced motion
431
+ * is preferred, and `false` otherwise.
432
+ * @see {mediaQuery} for the underlying implementation.
433
+ *
434
+ * @example
435
+ * ```ts
436
+ * const reduceMotion = prefersReducedMotion();
437
+ * effect(() => {
438
+ * if (reduceMotion()) {
439
+ * // Apply simplified animations or disable them
440
+ * } else {
441
+ * // Apply full animations
442
+ * }
443
+ * });
444
+ * ```
445
+ */
446
+ function prefersReducedMotion(debugName) {
447
+ return mediaQuery('(prefers-reduced-motion: reduce)', debugName);
448
+ }
449
+
450
+ /**
451
+ * A convenience function that creates and throttles a new `WritableSignal` in one step.
452
+ *
453
+ * @see {throttle} for the core implementation details.
454
+ *
455
+ * @template T The type of value the signal holds.
456
+ * @param initial The initial value of the signal.
457
+ * @param opt Options for signal creation, including throttle time `ms`.
458
+ * @returns A `ThrottledSignal<T>` instance.
459
+ *
460
+ * @example
461
+ * const query = throttled('', { ms: 500 });
462
+ * effect(() => console.log('Throttled Query:', query()));
463
+ *
464
+ * query.set('a');
465
+ * query.set('b');
466
+ * query.set('c');
467
+ * // With a trailing-edge throttle, the final value 'c' would be set
468
+ * // after the 500ms cooldown.
469
+ */
470
+ function throttled(initial, opt) {
471
+ return throttle(signal(initial, opt), opt);
472
+ }
473
+ /**
474
+ * Wraps an existing `WritableSignal` to create a new one whose readable value is throttled.
475
+ *
476
+ * This implementation avoids using `effect` by pairing a trigger signal with an `untracked`
477
+ * read of the source signal to control when the throttled value is re-evaluated.
478
+ *
479
+ * @template T The type of value the signal holds.
480
+ * @param source The source `WritableSignal` to wrap. Writes are applied to this signal immediately.
481
+ * @param opt Options for throttling, including throttle time `ms` and an optional `DestroyRef`.
482
+ * @returns A new `ThrottledSignal<T>` whose read value is throttled. The `.original` property
483
+ * of the returned signal is a reference back to the provided `source` signal.
484
+ *
485
+ * @example
486
+ * const query = throttled('', { ms: 500 });
487
+ * effect(() => console.log('Throttled Query:', query()));
488
+ *
489
+ * query.set('a');
490
+ * query.set('b');
491
+ * query.set('c');
492
+ * // With a trailing-edge throttle, the final value 'c' would be set
493
+ * // after the 500ms cooldown.
494
+ */
495
+ function throttle(source, opt) {
496
+ const ms = opt?.ms ?? 0;
497
+ const trigger = signal(false);
498
+ let timeout;
499
+ try {
500
+ const destroyRef = opt?.destroyRef ?? inject(DestroyRef, { optional: true });
501
+ destroyRef?.onDestroy(() => {
502
+ if (timeout)
503
+ clearTimeout(timeout);
504
+ timeout = undefined;
505
+ });
506
+ }
507
+ catch {
508
+ // not in injection context & no destroyRef provided opting out of cleanup
509
+ }
510
+ const triggerFn = (updateSourceAction) => {
511
+ updateSourceAction();
512
+ if (timeout)
513
+ return;
514
+ timeout = setTimeout(() => {
515
+ trigger.update((c) => !c);
516
+ timeout = undefined;
517
+ }, ms);
518
+ };
519
+ const set = (value) => {
520
+ triggerFn(() => source.set(value));
521
+ };
522
+ const update = (fn) => {
523
+ triggerFn(() => source.update(fn));
524
+ };
525
+ const writable = toWritable(computed(() => {
526
+ trigger();
527
+ return untracked(source);
528
+ }, opt), set, update);
529
+ writable.original = source;
530
+ return writable;
531
+ }
532
+
533
+ /**
534
+ * Creates a read-only signal that tracks the mouse cursor's position.
535
+ *
536
+ * It can track mouse movements on a specific target (window, document, or element)
537
+ * and optionally include touch movements. The coordinate space ('client' or 'page')
538
+ * can also be configured.
539
+ * The primitive is SSR-safe and automatically cleans up its event listeners.
540
+ *
541
+ * @param options Optional configuration for the sensor.
542
+ * @returns A read-only `Signal<MousePosition>`. On the server, it returns a static
543
+ * signal with `{ x: 0, y: 0 }`.
544
+ *
545
+ * @example
546
+ * ```ts
547
+ * import { Component, effect } from '@angular/core';
548
+ * import { mousePosition } from '@mmstack/primitives';
549
+ *
550
+ * @Component({
551
+ * selector: 'app-mouse-tracker',
552
+ * template: `<p>Mouse Position: X: {{ pos().x }}, Y: {{ pos().y }}</p>`
553
+ * })
554
+ * export class MouseTrackerComponent {
555
+ * readonly pos = mousePosition({ coordinateSpace: 'page' });
556
+ *
557
+ * constructor() {
558
+ * effect(() => {
559
+ * console.log('Mouse moved to:', this.pos());
560
+ * });
561
+ * }
562
+ * }
563
+ * ```
564
+ */
565
+ function mousePosition(opt) {
566
+ if (isPlatformServer(inject(PLATFORM_ID))) {
567
+ const base = computed(() => ({
568
+ x: 0,
569
+ y: 0,
570
+ }), {
571
+ debugName: opt?.debugName,
572
+ });
573
+ base.unthrottled = base;
574
+ return base;
575
+ }
576
+ const { target = window, coordinateSpace = 'client', touch = false, debugName, throttle = 100, } = opt ?? {};
577
+ const eventTarget = target instanceof ElementRef ? target.nativeElement : target;
578
+ if (!eventTarget) {
579
+ if (isDevMode())
580
+ console.warn('mousePosition: Target element not found.');
581
+ const base = computed(() => ({
582
+ x: 0,
583
+ y: 0,
584
+ }), {
585
+ debugName,
586
+ });
587
+ base.unthrottled = base;
588
+ return base;
589
+ }
590
+ const pos = throttled({ x: 0, y: 0 }, {
591
+ ms: throttle,
592
+ equal: (a, b) => a.x === b.x && a.y === b.y,
593
+ debugName,
594
+ });
595
+ const updatePosition = (event) => {
596
+ let x, y;
597
+ if (event instanceof MouseEvent) {
598
+ x = coordinateSpace === 'page' ? event.pageX : event.clientX;
599
+ y = coordinateSpace === 'page' ? event.pageY : event.clientY;
600
+ }
601
+ else if (event.touches.length > 0) {
602
+ const firstTouch = event.touches[0];
603
+ x = coordinateSpace === 'page' ? firstTouch.pageX : firstTouch.clientX;
604
+ y = coordinateSpace === 'page' ? firstTouch.pageY : firstTouch.clientY;
605
+ }
606
+ else {
607
+ return;
608
+ }
609
+ pos.set({ x, y });
610
+ };
611
+ eventTarget.addEventListener('mousemove', updatePosition);
612
+ if (touch) {
613
+ eventTarget.addEventListener('touchmove', updatePosition);
614
+ }
615
+ inject(DestroyRef).onDestroy(() => {
616
+ eventTarget.removeEventListener('mousemove', updatePosition);
617
+ if (touch) {
618
+ eventTarget.removeEventListener('touchmove', updatePosition);
619
+ }
620
+ });
621
+ const base = pos.asReadonly();
622
+ base.unthrottled = pos.original;
623
+ return base;
624
+ }
625
+
626
+ const serverDate = new Date();
627
+ /**
628
+ * Creates a read-only signal that tracks the browser's online status.
629
+ *
630
+ * The main signal returns a boolean (`true` for online, `false` for offline).
631
+ * An additional `since` signal is attached, tracking when the status last changed.
632
+ * It's SSR-safe and automatically cleans up its event listeners.
633
+ *
634
+ * @param debugName Optional debug name for the signal.
635
+ * @returns A `NetworkStatusSignal` instance.
636
+ */
637
+ function networkStatus(debugName) {
638
+ if (isPlatformServer(inject(PLATFORM_ID))) {
639
+ const sig = computed(() => true, {
640
+ debugName,
641
+ });
642
+ sig.since = computed(() => serverDate);
643
+ return sig;
644
+ }
645
+ const state = signal(navigator.onLine, {
646
+ debugName,
647
+ });
648
+ const since = signal(new Date());
649
+ const goOnline = () => {
650
+ state.set(true);
651
+ since.set(new Date());
652
+ };
653
+ const goOffline = () => {
654
+ state.set(false);
655
+ since.set(new Date());
656
+ };
657
+ window.addEventListener('online', goOnline);
658
+ window.addEventListener('offline', goOffline);
659
+ inject(DestroyRef).onDestroy(() => {
660
+ window.removeEventListener('online', goOnline);
661
+ window.removeEventListener('offline', goOffline);
662
+ });
663
+ const sig = state.asReadonly();
664
+ sig.since = since.asReadonly();
665
+ return sig;
666
+ }
667
+
668
+ /**
669
+ * Creates a read-only signal that tracks the page's visibility state.
670
+ *
671
+ * It uses the browser's Page Visibility API to reactively report if the
672
+ * current document is `'visible'`, `'hidden'`, or in another state.
673
+ * The primitive is SSR-safe and automatically cleans up its event listeners
674
+ * when the creating context is destroyed.
675
+ *
676
+ * @param debugName Optional debug name for the signal.
677
+ * @returns A read-only `Signal<DocumentVisibilityState>`. On the server,
678
+ * it returns a static signal with a value of `'visible'`.
679
+ *
680
+ * @example
681
+ * ```ts
682
+ * import { Component, effect } from '@angular/core';
683
+ * import { pageVisibility } from '@mmstack/primitives';
684
+ *
685
+ * @Component({
686
+ * selector: 'app-visibility-tracker',
687
+ * template: `<p>Page is currently: {{ visibilityState() }}</p>`
688
+ * })
689
+ * export class VisibilityTrackerComponent {
690
+ * readonly visibilityState = pageVisibility();
691
+ *
692
+ * constructor() {
693
+ * effect(() => {
694
+ * if (this.visibilityState() === 'hidden') {
695
+ * console.log('Page is hidden, pausing expensive animations...');
696
+ * } else {
697
+ * console.log('Page is visible, resuming activity.');
698
+ * }
699
+ * });
700
+ * }
701
+ * }
702
+ * ```
703
+ */
704
+ function pageVisibility(debugName) {
705
+ if (isPlatformServer(inject(PLATFORM_ID))) {
706
+ return computed(() => 'visible', { debugName });
707
+ }
708
+ const visibility = signal(document.visibilityState, { debugName });
709
+ const onVisibilityChange = () => visibility.set(document.visibilityState);
710
+ document.addEventListener('visibilitychange', onVisibilityChange);
711
+ inject(DestroyRef).onDestroy(() => document.removeEventListener('visibilitychange', onVisibilityChange));
712
+ return visibility.asReadonly();
713
+ }
714
+
715
+ /**
716
+ * Implementation for sensor overloads.
717
+ * Users should refer to the specific overloads for detailed documentation.
718
+ * @internal
719
+ */
720
+ function sensor(type, options) {
721
+ switch (type) {
722
+ case 'mousePosition':
723
+ return mousePosition(options);
724
+ case 'networkStatus':
725
+ return networkStatus(options?.debugName);
726
+ case 'pageVisibility':
727
+ return pageVisibility(options?.debugName);
728
+ case 'dark-mode':
729
+ return prefersDarkMode(options?.debugName);
730
+ case 'reduced-motion':
731
+ return prefersReducedMotion(options?.debugName);
732
+ default:
733
+ throw new Error(`Unknown sensor type: ${type}`);
734
+ }
735
+ }
736
+
339
737
  // Internal dummy store for server-side rendering
340
738
  const noopStore = {
341
739
  getItem: () => null,
@@ -494,11 +892,160 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
494
892
  return writable;
495
893
  }
496
894
 
895
+ /**
896
+ * Creates a Promise that resolves when a signal's value satisfies a given predicate.
897
+ *
898
+ * This is useful for imperatively waiting for a reactive state to change,
899
+ * for example, in tests or to orchestrate complex asynchronous operations.
900
+ *
901
+ * @template T The type of the signal's value.
902
+ * @param sourceSignal The signal to observe.
903
+ * @param predicate A function that takes the signal's value and returns `true` if the condition is met.
904
+ * @param options Optional configuration for timeout and explicit destruction.
905
+ * @returns A Promise that resolves with the signal's value when the predicate is true,
906
+ * or rejects on timeout or context destruction.
907
+ *
908
+ * @example
909
+ * ```ts
910
+ * const count = signal(0);
911
+ *
912
+ * async function waitForCount() {
913
+ * console.log('Waiting for count to be >= 3...');
914
+ * try {
915
+ * const finalCount = await until(count, c => c >= 3, { timeout: 5000 });
916
+ * console.log(`Count reached: ${finalCount}`);
917
+ * } catch (e: any) { // Ensure 'e' is typed if you access properties like e.message
918
+ * console.error(e.message); // e.g., "until: Timeout after 5000ms."
919
+ * }
920
+ * }
921
+ *
922
+ * // Simulate updates
923
+ * setTimeout(() => count.set(1), 500);
924
+ * setTimeout(() => count.set(2), 1000);
925
+ * setTimeout(() => count.set(3), 1500);
926
+ *
927
+ * waitForCount();
928
+ * ```
929
+ */
930
+ function until(sourceSignal, predicate, options = {}) {
931
+ return new Promise((resolve, reject) => {
932
+ let effectRef;
933
+ let timeoutId;
934
+ let settled = false;
935
+ const cleanupAndReject = (reason) => {
936
+ if (!settled) {
937
+ settled = true;
938
+ if (timeoutId)
939
+ clearTimeout(timeoutId);
940
+ effectRef?.destroy();
941
+ reject(new Error(reason));
942
+ }
943
+ };
944
+ const cleanupAndResolve = (value) => {
945
+ if (!settled) {
946
+ settled = true;
947
+ if (timeoutId)
948
+ clearTimeout(timeoutId);
949
+ effectRef?.destroy();
950
+ resolve(value);
951
+ }
952
+ };
953
+ try {
954
+ const destroyRef = options.destroyRef ?? inject(DestroyRef, { optional: true });
955
+ destroyRef?.onDestroy(() => {
956
+ cleanupAndReject('until: Operation cancelled due to context destruction.');
957
+ });
958
+ }
959
+ catch {
960
+ // noop
961
+ }
962
+ const initialValue = untracked(sourceSignal);
963
+ if (predicate(initialValue)) {
964
+ cleanupAndResolve(initialValue);
965
+ return;
966
+ }
967
+ if (options?.timeout !== undefined) {
968
+ timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
969
+ }
970
+ effectRef = effect(() => {
971
+ if (settled) {
972
+ return effectRef?.destroy();
973
+ }
974
+ const currentValue = sourceSignal();
975
+ if (predicate(currentValue)) {
976
+ cleanupAndResolve(currentValue);
977
+ }
978
+ });
979
+ });
980
+ }
981
+
982
+ /**
983
+ * @interal
984
+ */
985
+ function getSignalEquality(sig) {
986
+ const internal = sig[SIGNAL];
987
+ if (internal && typeof internal.equal === 'function') {
988
+ return internal.equal;
989
+ }
990
+ return Object.is; // Default equality check
991
+ }
992
+
993
+ /**
994
+ * Enhances an existing `WritableSignal` by adding a complete undo/redo history
995
+ * stack and an API to control it.
996
+ *
997
+ * @template T The type of value held by the signal.
998
+ * @param source The source `WritableSignal` to add history tracking to.
999
+ * @param options Optional configuration for the history behavior.
1000
+ * @returns A `SignalWithHistory<T>` instance, augmenting the source signal with history APIs.
1001
+ *
1002
+ * @remarks
1003
+ * - Any new `.set()` or `.update()` call on the signal will clear the entire redo stack.
1004
+ * - The primitive attempts to automatically use the source signal's own `equal` function,
1005
+ * but this relies on an internal Angular API. For maximum stability across Angular
1006
+ * versions, it is recommended to provide an explicit `equal` function in the options.
1007
+ *
1008
+ * @example
1009
+ * ```ts
1010
+ * import { signal } from '@angular/core';
1011
+ * import { withHistory } from '@mmstack/primitives';
1012
+ *
1013
+ * const name = withHistory(signal('John'), { maxSize: 5 });
1014
+ *
1015
+ * console.log('Initial value:', name()); // "John"
1016
+ *
1017
+ * name.set('John Doe');
1018
+ * name.set('Jane Doe');
1019
+ *
1020
+ * console.log('Current value:', name()); // "Jane Doe"
1021
+ * console.log('History:', name.history()); // ["John", "John Doe"]
1022
+ * console.log('Can undo:', name.canUndo()); // true
1023
+ * console.log('Can redo:', name.canRedo()); // false
1024
+ *
1025
+ * name.undo();
1026
+ * console.log('After undo:', name()); // "John Doe"
1027
+ * console.log('Can redo:', name.canRedo()); // true
1028
+ *
1029
+ * name.redo();
1030
+ * console.log('After redo:', name()); // "Jane Doe"
1031
+ *
1032
+ * // A new change will clear the redo history
1033
+ * name.set('Janine Doe');
1034
+ * console.log('Can redo:', name.canRedo()); // false
1035
+ *
1036
+ * name.clear();
1037
+ * console.log('Can undo:', name.canUndo()); // false
1038
+ * ```
1039
+ */
497
1040
  function withHistory(source, opt) {
498
- const { equal = Object.is, debugName } = source[SIGNAL];
1041
+ const equal = opt?.equal ?? getSignalEquality(source);
499
1042
  const maxSize = opt?.maxSize ?? Infinity;
500
- const history = mutable([], opt);
1043
+ const history = mutable([], {
1044
+ ...opt,
1045
+ equal: undefined,
1046
+ });
501
1047
  const redoArray = mutable([]);
1048
+ const originalSet = source.set;
502
1049
  const set = (value) => {
503
1050
  const current = untracked(source);
504
1051
  if (equal(value, current))
@@ -506,7 +1053,12 @@ function withHistory(source, opt) {
506
1053
  source.set(value);
507
1054
  history.mutate((c) => {
508
1055
  if (c.length >= maxSize) {
509
- c = c.slice(Math.floor(maxSize / 2));
1056
+ if (opt?.cleanupStrategy === 'shift') {
1057
+ c.shift();
1058
+ }
1059
+ else {
1060
+ c = c.slice(Math.floor(maxSize / 2));
1061
+ }
510
1062
  }
511
1063
  c.push(current);
512
1064
  return c;
@@ -518,26 +1070,39 @@ function withHistory(source, opt) {
518
1070
  };
519
1071
  const internal = toWritable(computed(() => source(), {
520
1072
  equal,
521
- debugName,
1073
+ debugName: opt?.debugName,
522
1074
  }), set, update);
523
1075
  internal.history = history;
524
1076
  internal.undo = () => {
525
- const last = untracked(history);
526
- if (last.length === 0)
1077
+ const historyStack = untracked(history);
1078
+ if (historyStack.length === 0)
527
1079
  return;
528
- const prev = last.at(-1);
529
- const cur = untracked(source);
530
- history.inline((c) => c.pop());
531
- redoArray.inline((c) => c.push(cur));
532
- source.set(prev);
1080
+ const valueForRedo = untracked(source);
1081
+ const valueToRestore = historyStack.at(-1);
1082
+ originalSet.call(source, valueToRestore);
1083
+ history.inline((h) => h.pop());
1084
+ redoArray.inline((r) => r.push(valueForRedo));
533
1085
  };
534
1086
  internal.redo = () => {
535
- const last = untracked(redoArray);
536
- if (last.length === 0)
1087
+ const redoStack = untracked(redoArray);
1088
+ if (redoStack.length === 0)
537
1089
  return;
538
- const prev = last.at(-1);
539
- redoArray.inline((c) => c.pop());
540
- set(prev);
1090
+ const valueForUndo = untracked(source);
1091
+ const valueToRestore = redoStack.at(-1);
1092
+ originalSet.call(source, valueToRestore);
1093
+ redoArray.inline((r) => r.pop());
1094
+ history.mutate((h) => {
1095
+ if (h.length >= maxSize) {
1096
+ if (opt?.cleanupStrategy === 'shift') {
1097
+ h.shift();
1098
+ }
1099
+ else {
1100
+ h = h.slice(Math.floor(maxSize / 2));
1101
+ }
1102
+ }
1103
+ h.push(valueForUndo);
1104
+ return h;
1105
+ });
541
1106
  };
542
1107
  internal.clear = () => {
543
1108
  history.set([]);
@@ -553,5 +1118,5 @@ function withHistory(source, opt) {
553
1118
  * Generated bundle index. Do not edit.
554
1119
  */
555
1120
 
556
- export { debounce, debounced, derived, isDerivation, isMutable, mapArray, mutable, stored, toFakeDerivation, toFakeSignalDerivation, toWritable, withHistory };
1121
+ export { debounce, debounced, derived, isDerivation, isMutable, mapArray, mediaQuery, mousePosition, mutable, networkStatus, pageVisibility, prefersDarkMode, prefersReducedMotion, sensor, stored, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toWritable, until, withHistory };
557
1122
  //# sourceMappingURL=mmstack-primitives.mjs.map