@mmstack/primitives 19.1.2 → 19.2.1

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, Injector, runInInjectionContext } 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,163 @@ 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
+ const injector = options.injector ?? inject(Injector);
932
+ return new Promise((resolve, reject) => {
933
+ let effectRef;
934
+ let timeoutId;
935
+ let settled = false;
936
+ const cleanupAndReject = (reason) => {
937
+ if (!settled) {
938
+ settled = true;
939
+ if (timeoutId)
940
+ clearTimeout(timeoutId);
941
+ effectRef?.destroy();
942
+ reject(new Error(reason));
943
+ }
944
+ };
945
+ const cleanupAndResolve = (value) => {
946
+ if (!settled) {
947
+ settled = true;
948
+ if (timeoutId)
949
+ clearTimeout(timeoutId);
950
+ effectRef?.destroy();
951
+ resolve(value);
952
+ }
953
+ };
954
+ try {
955
+ const destroyRef = options.destroyRef ?? inject(DestroyRef, { optional: true });
956
+ destroyRef?.onDestroy(() => {
957
+ cleanupAndReject('until: Operation cancelled due to context destruction.');
958
+ });
959
+ }
960
+ catch {
961
+ // noop
962
+ }
963
+ const initialValue = untracked(sourceSignal);
964
+ if (predicate(initialValue)) {
965
+ cleanupAndResolve(initialValue);
966
+ return;
967
+ }
968
+ if (options?.timeout !== undefined) {
969
+ timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
970
+ }
971
+ runInInjectionContext(injector, () => {
972
+ effectRef = effect(() => {
973
+ if (settled) {
974
+ return effectRef?.destroy();
975
+ }
976
+ const currentValue = sourceSignal();
977
+ if (predicate(currentValue)) {
978
+ cleanupAndResolve(currentValue);
979
+ }
980
+ });
981
+ });
982
+ });
983
+ }
984
+
985
+ /**
986
+ * @interal
987
+ */
988
+ function getSignalEquality(sig) {
989
+ const internal = sig[SIGNAL];
990
+ if (internal && typeof internal.equal === 'function') {
991
+ return internal.equal;
992
+ }
993
+ return Object.is; // Default equality check
994
+ }
995
+
996
+ /**
997
+ * Enhances an existing `WritableSignal` by adding a complete undo/redo history
998
+ * stack and an API to control it.
999
+ *
1000
+ * @template T The type of value held by the signal.
1001
+ * @param source The source `WritableSignal` to add history tracking to.
1002
+ * @param options Optional configuration for the history behavior.
1003
+ * @returns A `SignalWithHistory<T>` instance, augmenting the source signal with history APIs.
1004
+ *
1005
+ * @remarks
1006
+ * - Any new `.set()` or `.update()` call on the signal will clear the entire redo stack.
1007
+ * - The primitive attempts to automatically use the source signal's own `equal` function,
1008
+ * but this relies on an internal Angular API. For maximum stability across Angular
1009
+ * versions, it is recommended to provide an explicit `equal` function in the options.
1010
+ *
1011
+ * @example
1012
+ * ```ts
1013
+ * import { signal } from '@angular/core';
1014
+ * import { withHistory } from '@mmstack/primitives';
1015
+ *
1016
+ * const name = withHistory(signal('John'), { maxSize: 5 });
1017
+ *
1018
+ * console.log('Initial value:', name()); // "John"
1019
+ *
1020
+ * name.set('John Doe');
1021
+ * name.set('Jane Doe');
1022
+ *
1023
+ * console.log('Current value:', name()); // "Jane Doe"
1024
+ * console.log('History:', name.history()); // ["John", "John Doe"]
1025
+ * console.log('Can undo:', name.canUndo()); // true
1026
+ * console.log('Can redo:', name.canRedo()); // false
1027
+ *
1028
+ * name.undo();
1029
+ * console.log('After undo:', name()); // "John Doe"
1030
+ * console.log('Can redo:', name.canRedo()); // true
1031
+ *
1032
+ * name.redo();
1033
+ * console.log('After redo:', name()); // "Jane Doe"
1034
+ *
1035
+ * // A new change will clear the redo history
1036
+ * name.set('Janine Doe');
1037
+ * console.log('Can redo:', name.canRedo()); // false
1038
+ *
1039
+ * name.clear();
1040
+ * console.log('Can undo:', name.canUndo()); // false
1041
+ * ```
1042
+ */
497
1043
  function withHistory(source, opt) {
498
- const { equal = Object.is, debugName } = source[SIGNAL];
1044
+ const equal = opt?.equal ?? getSignalEquality(source);
499
1045
  const maxSize = opt?.maxSize ?? Infinity;
500
- const history = mutable([], opt);
1046
+ const history = mutable([], {
1047
+ ...opt,
1048
+ equal: undefined,
1049
+ });
501
1050
  const redoArray = mutable([]);
1051
+ const originalSet = source.set;
502
1052
  const set = (value) => {
503
1053
  const current = untracked(source);
504
1054
  if (equal(value, current))
@@ -506,7 +1056,12 @@ function withHistory(source, opt) {
506
1056
  source.set(value);
507
1057
  history.mutate((c) => {
508
1058
  if (c.length >= maxSize) {
509
- c = c.slice(Math.floor(maxSize / 2));
1059
+ if (opt?.cleanupStrategy === 'shift') {
1060
+ c.shift();
1061
+ }
1062
+ else {
1063
+ c = c.slice(Math.floor(maxSize / 2));
1064
+ }
510
1065
  }
511
1066
  c.push(current);
512
1067
  return c;
@@ -518,26 +1073,39 @@ function withHistory(source, opt) {
518
1073
  };
519
1074
  const internal = toWritable(computed(() => source(), {
520
1075
  equal,
521
- debugName,
1076
+ debugName: opt?.debugName,
522
1077
  }), set, update);
523
1078
  internal.history = history;
524
1079
  internal.undo = () => {
525
- const last = untracked(history);
526
- if (last.length === 0)
1080
+ const historyStack = untracked(history);
1081
+ if (historyStack.length === 0)
527
1082
  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);
1083
+ const valueForRedo = untracked(source);
1084
+ const valueToRestore = historyStack.at(-1);
1085
+ originalSet.call(source, valueToRestore);
1086
+ history.inline((h) => h.pop());
1087
+ redoArray.inline((r) => r.push(valueForRedo));
533
1088
  };
534
1089
  internal.redo = () => {
535
- const last = untracked(redoArray);
536
- if (last.length === 0)
1090
+ const redoStack = untracked(redoArray);
1091
+ if (redoStack.length === 0)
537
1092
  return;
538
- const prev = last.at(-1);
539
- redoArray.inline((c) => c.pop());
540
- set(prev);
1093
+ const valueForUndo = untracked(source);
1094
+ const valueToRestore = redoStack.at(-1);
1095
+ originalSet.call(source, valueToRestore);
1096
+ redoArray.inline((r) => r.pop());
1097
+ history.mutate((h) => {
1098
+ if (h.length >= maxSize) {
1099
+ if (opt?.cleanupStrategy === 'shift') {
1100
+ h.shift();
1101
+ }
1102
+ else {
1103
+ h = h.slice(Math.floor(maxSize / 2));
1104
+ }
1105
+ }
1106
+ h.push(valueForUndo);
1107
+ return h;
1108
+ });
541
1109
  };
542
1110
  internal.clear = () => {
543
1111
  history.set([]);
@@ -553,5 +1121,5 @@ function withHistory(source, opt) {
553
1121
  * Generated bundle index. Do not edit.
554
1122
  */
555
1123
 
556
- export { debounce, debounced, derived, isDerivation, isMutable, mapArray, mutable, stored, toFakeDerivation, toFakeSignalDerivation, toWritable, withHistory };
1124
+ export { debounce, debounced, derived, isDerivation, isMutable, mapArray, mediaQuery, mousePosition, mutable, networkStatus, pageVisibility, prefersDarkMode, prefersReducedMotion, sensor, stored, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toWritable, until, withHistory };
557
1125
  //# sourceMappingURL=mmstack-primitives.mjs.map