@peter.naydenov/cuts 2.1.3 → 2.1.4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@peter.naydenov/cuts",
3
3
  "description": "SPA scene manager",
4
- "version": "2.1.3",
4
+ "version": "2.1.4",
5
5
  "license": "MIT",
6
6
  "author": "Peter Naydenov",
7
7
  "main": "./src/main.js",
@@ -12,50 +12,51 @@ function hide ( dependencies, state ) {
12
12
  return function hide ( endSteps=1 ) {
13
13
  const
14
14
  { askForPromise, shortcutMngr, setInstruction } = dependencies
15
- , { currentScene, currentParents, scenes } = state
15
+ , { currentScene, scenes } = state
16
16
  , hideTask = askForPromise ()
17
- , instructions = []
18
- , hasParents = currentParents.length > 0
17
+ , unloadTask = askForPromise ()
18
+ , hasBeforeHide = typeof scenes[currentScene].beforeHide === 'function'
19
19
  ;
20
20
 
21
- shortcutMngr.pause () // Stop all shortcuts
22
- instructions.push ( setInstruction ( scenes[currentScene].hide )) // Set instruction to hide current scene
21
+ if ( hasBeforeHide ) { // Execute 'beforeHide' function if exists - same guard 'show' honors when navigating away
22
+ const closingFn = scenes[currentScene].beforeHide;
23
+ closingFn ({ done:unloadTask.done, dependencies: shortcutMngr.getDependencies() })
24
+ }
25
+ else unloadTask.done ( true )
23
26
 
24
- if ( !hasParents ) state.currentScene = null
27
+ unloadTask.onComplete ( continueHiding => {
28
+ if ( !continueHiding ) {
29
+ // 'beforeHide' returned false - cancel, nothing was torn down or changed
30
+ hideTask.done ()
31
+ return hideTask.promise
32
+ }
25
33
 
26
- let namesToHide = []
27
- if ( hasParents && endSteps === '*' ) {
28
- namesToHide = [...currentParents].reverse ()
29
- }
30
- if ( hasParents && ![ '*', 1].includes(endSteps) ) {
31
- namesToHide = currentParents.slice ( `-${endSteps-1}` ).reverse ()
32
- }
34
+ const
35
+ instructions = []
36
+ // Work on a copy of the recorded ancestor chain - it's the source of truth for what to
37
+ // climb back to, including for wildcard-overlay scenes ('*' itself is never stored here,
38
+ // only the real scene name that was current when the overlay was shown).
39
+ , remaining = [ ...state.currentParents ]
40
+ ;
33
41
 
34
- if ( hasParents && namesToHide.length === 0 && scenes[currentScene].parents[0] === '*' ) {
35
- // A wildcard overlay has no fixed scene to 'stay pointed at' once hidden - unlike a
36
- // regular single-step hide(), always climb back to whatever scene it was shown on top
37
- // of, so the shortcuts context returns to the (still visible) underlying scene.
38
- state.currentScene = state.currentParents.pop ()
39
- state.currentParents = state.currentScene ? [ ...scenes[state.currentScene].parents ] : []
40
- }
42
+ shortcutMngr.pause () // Stop all shortcuts
43
+ instructions.push ( setInstruction ( scenes[currentScene].hide )) // Hiding the current scene is always at least 1 step
44
+
45
+ const ancestorsToHide = ( endSteps === '*' ) ? remaining.length : Math.min ( endSteps - 1, remaining.length )
46
+ for ( let i = 0; i < ancestorsToHide; i++ ) {
47
+ const name = remaining.pop ()
48
+ instructions.push ( setInstruction ( scenes[name].hide ))
49
+ }
41
50
 
42
- namesToHide.forEach ( name => {
43
- instructions.push ( setInstruction ( scenes[name].hide))
44
- // Climb to the parent of the scene just hidden - same technique 'show' uses for its 'hide' steps.
45
- // A wildcard overlay isn't climbing its own declared parent ('*' isn't a real scene) - and
46
- // there's no reliable way to recover what it originally covered once it's this deep in the
47
- // chain, so fall back to treating it as the end of the chain rather than crashing.
48
- state.currentScene = ( scenes[name].parents[0] === '*' )
49
- ? null
50
- : ( scenes[name].parents.at ( -1 ) ?? null )
51
- state.currentParents = state.currentScene ? scenes[state.currentScene].parents : []
52
- })
51
+ state.currentScene = remaining.length > 0 ? remaining.pop () : null
52
+ state.currentParents = remaining
53
53
 
54
- const goingTask = askForPromise.sequence ( instructions );
55
- goingTask.onComplete ( () => {
56
- shortcutMngr.changeContext ( state.currentScene ) // If state.currentScene is null, then all shortcuts are switched off and currentContext is null
57
- hideTask.done ()
58
- })
54
+ const goingTask = askForPromise.sequence ( instructions );
55
+ goingTask.onComplete ( () => {
56
+ shortcutMngr.changeContext ( state.currentScene ) // If state.currentScene is null, then all shortcuts are switched off and currentContext is null
57
+ hideTask.done ()
58
+ })
59
+ }) // unloadTask onComplete
59
60
  return hideTask.promise
60
61
  }} // hide func.
61
62
 
@@ -63,4 +64,3 @@ return function hide ( endSteps=1 ) {
63
64
 
64
65
  export default hide
65
66
 
66
-
@@ -8,8 +8,13 @@ function jump ( state, show ) {
8
8
  * @returns {Promise} - promise that resolves when the jump is completed
9
9
  */
10
10
  function jump ( { scene }, ...args ) {
11
- state.jumpStack.push ( state.currentScene )
12
- return show ( { scene }, ...args )
11
+ const previousScene = state.currentScene
12
+ return show ( { scene }, ...args ).then ( () => {
13
+ // Only record the jump if it actually landed - 'show' silently no-ops on a
14
+ // blocked (beforeHide) or invalid navigation, and pushing anyway would leave
15
+ // a phantom entry that desyncs jumpBack() from what actually happened.
16
+ if ( state.currentScene === scene ) state.jumpStack.push ( previousScene )
17
+ })
13
18
  }
14
19
  return jump
15
20
  } // jump func.
@@ -8,7 +8,7 @@
8
8
  /**
9
9
  * @typedef {Object} SceneObject
10
10
  * @property {function({task: AskObject, dependencies: *}): void} show - load interface and prerequisites for the scene;
11
- * @property {function():boolean} [afterShow] - run after scene is loaded. Optional. Returns true to continue loading, false to cancel;
11
+ * @property {function({dependencies: *, done: function}):void} [afterShow] - run after the scene is shown. Optional. Fire-and-forget - its return value is not used, and it cannot cancel or affect the already-completed 'show';
12
12
  * @property {function({task: AskObject, dependencies: *}): void} hide - Reverse the settings from 'show';
13
13
  * @property {function():void} [beforeHide] - Run before scene is hidden. Optional;
14
14
  * @property {Array.<string>} [parents] - list of parent scene names.
@@ -53,6 +53,9 @@ function show ( dependencies, state ) {
53
53
  // Executes only once when the scene manager is started
54
54
  state.opened = true
55
55
  state.currentScene = requestedScene
56
+ // The server-rendered scene's ancestors are assumed already rendered too -
57
+ // record them, or navigating away later won't know to hide them.
58
+ state.currentParents = scenes[requestedScene].parents ? [ ...scenes[requestedScene].parents ] : []
56
59
  shortcutMngr.changeContext ( requestedScene )
57
60
  showTask.done ()
58
61
  return showTask.promise
@@ -65,7 +68,11 @@ function show ( dependencies, state ) {
65
68
  if ( parents[0] === '*' ) {
66
69
  const overlayTask = setInstruction(show, ...args)()
67
70
  overlayTask.then ( () => showTask.done () )
68
- state.currentParents.push ( state.currentScene )
71
+ // Only remember a real underlying scene - pushing 'null' as a placeholder
72
+ // makes later navigation treat it as a scene name to hide, and crash.
73
+ // Re-showing the SAME overlay that's already current must not push it onto
74
+ // its own ancestor chain either, or hide() later climbs back onto itself.
75
+ if ( state.currentScene && state.currentScene !== requestedScene ) state.currentParents.push ( state.currentScene )
69
76
  state.currentScene = requestedScene
70
77
  shortcutMngr.changeContext ( requestedScene ) // Activate the overlay scene's own shortcuts
71
78
  return showTask.promise
@@ -101,7 +108,10 @@ function show ( dependencies, state ) {
101
108
  } // getStep func.
102
109
 
103
110
  const
104
- g = findInstructions ( state.currentScene, state.currentParents, requestedScene, parents )
111
+ // Snapshot - findInstructions is a lazy generator, and getStep (in the loop below)
112
+ // mutates state.currentParents in place; without a copy, that mutation corrupts
113
+ // the generator's still-pending reads of the very array it was given.
114
+ g = findInstructions ( state.currentScene, [ ...state.currentParents ], requestedScene, parents )
105
115
  , instructions = []
106
116
  ;
107
117
 
@@ -455,7 +455,7 @@ describe ( 'Cuts integration', () => {
455
455
  await script.show ({ scene: 'overlay' });
456
456
  expect ( calls ).toEqual (['show overlay']);
457
457
  expect ( script.getState().scene ).toEqual ( 'overlay' );
458
- expect ( script.getState().parents ).toEqual ( [null] ); // No previous scene, saved as null
458
+ expect ( script.getState().parents ).toEqual ( [] ); // No underlying scene to remember
459
459
  }) // it Show scene with wildcard parent as the very first scene
460
460
 
461
461
 
@@ -658,4 +658,273 @@ describe ( 'Cuts integration', () => {
658
658
 
659
659
 
660
660
 
661
+ it ( 'hide() with the default single step climbs to the parent and restores its shortcuts context', async () => {
662
+ // Regression test: hide() used to leave currentScene/currentParents pointing at
663
+ // the just-hidden scene instead of climbing to its (still visible) parent, so
664
+ // NEITHER scene's shortcuts context was active afterward.
665
+ const seen = [];
666
+ const script = cuts();
667
+ const scenes = [
668
+ {
669
+ name: 'A'
670
+ , scene: {
671
+ show : ({task}) => task.done(),
672
+ hide : ({task}) => task.done(),
673
+ '@ping' : () => seen.push ( 'A' )
674
+ }
675
+ },
676
+ {
677
+ name: 'B'
678
+ , scene: {
679
+ show : ({task}) => task.done(),
680
+ hide : ({task}) => task.done(),
681
+ parents: ['A'],
682
+ '@ping' : () => seen.push ( 'B' )
683
+ }
684
+ }
685
+ ];
686
+
687
+ script.setScenes ( scenes );
688
+ await script.show ({ scene: 'A' });
689
+ await script.show ({ scene: 'B' });
690
+
691
+ await script.hide (); // default endSteps = 1
692
+ expect ( script.getState() ).toEqual ({ scene: 'A', parents: [], opened: true });
693
+
694
+ script.emit ( '@ping' );
695
+ expect ( seen ).toEqual ([ 'A' ]);
696
+ }) // it hide() with the default single step climbs to the parent and restores its shortcuts context
697
+
698
+
699
+
700
+ it ( 'hide(n) climbs exactly n levels', async () => {
701
+ const calls = [];
702
+ const script = cuts();
703
+ const scenes = [
704
+ { name: 'top' , scene: { show: ({task}) => { calls.push('show top'); task.done() }, hide: ({task}) => { calls.push('hide top'); task.done() } } },
705
+ { name: 'mid' , scene: { show: ({task}) => { calls.push('show mid'); task.done() }, hide: ({task}) => { calls.push('hide mid'); task.done() }, parents: ['top'] } },
706
+ { name: 'deep', scene: { show: ({task}) => { calls.push('show deep'); task.done() }, hide: ({task}) => { calls.push('hide deep'); task.done() }, parents: ['top', 'mid'] } }
707
+ ];
708
+
709
+ script.setScenes ( scenes );
710
+ await script.show ({ scene: 'deep' });
711
+ calls.length = 0;
712
+
713
+ await script.hide ( 2 );
714
+ expect ( calls ).toEqual ([ 'hide deep', 'hide mid' ]);
715
+ expect ( script.getState() ).toEqual ({ scene: 'top', parents: [], opened: true });
716
+ }) // it hide(n) climbs exactly n levels
717
+
718
+
719
+
720
+ it ( 'SSR first load records the scene\'s parents, so leaving it hides them too', async () => {
721
+ // Regression test: the SSR branch of show() set currentScene but never
722
+ // currentParents, so navigating away from a deep SSR-loaded scene skipped
723
+ // hiding its ancestors entirely, leaving them stale in the DOM.
724
+ const calls = [];
725
+ const script = cuts();
726
+ const scenes = [
727
+ { name: 'top' , scene: { show: ({task}) => { calls.push('show top'); task.done() }, hide: ({task}) => { calls.push('hide top'); task.done() } } },
728
+ { name: 'mid' , scene: { show: ({task}) => { calls.push('show mid'); task.done() }, hide: ({task}) => { calls.push('hide mid'); task.done() }, parents: ['top'] } },
729
+ { name: 'deep', scene: { show: ({task}) => { calls.push('show deep'); task.done() }, hide: ({task}) => { calls.push('hide deep'); task.done() }, parents: ['top', 'mid'] } },
730
+ { name: 'other', scene: { show: ({task}) => { calls.push('show other'); task.done() }, hide: ({task}) => { calls.push('hide other'); task.done() } } }
731
+ ];
732
+
733
+ script.setScenes ( scenes );
734
+ await script.show ({ scene: 'deep', options: { ssr: true } });
735
+ expect ( script.getState() ).toEqual ({ scene: 'deep', parents: [ 'top', 'mid' ], opened: true });
736
+
737
+ calls.length = 0;
738
+ await script.show ({ scene: 'other' });
739
+ expect ( calls ).toEqual ([ 'hide deep', 'hide mid', 'hide top', 'show other' ]);
740
+ }) // it SSR first load records the scene's parents, so leaving it hides them too
741
+
742
+
743
+
744
+ it ( 'afterShow return value has no effect on the completed show()', async () => {
745
+ // Regression test: setScenes.js's typedef documented afterShow as
746
+ // 'returns false to cancel', but show() never read its return value, so
747
+ // the scene was always shown regardless. The typedef is now corrected to
748
+ // describe the actual (README-documented) fire-and-forget behavior.
749
+ const calls = [];
750
+ const script = cuts();
751
+ const scenes = [
752
+ {
753
+ name: 'top'
754
+ , scene: {
755
+ show : ({task}) => { calls.push('show'); task.done() },
756
+ hide : ({task}) => task.done(),
757
+ afterShow : () => { calls.push('afterShow'); return false }
758
+ }
759
+ }
760
+ ];
761
+
762
+ script.setScenes ( scenes );
763
+ await script.show ({ scene: 'top' });
764
+
765
+ expect ( calls ).toEqual ([ 'show', 'afterShow' ]);
766
+ expect ( script.getState() ).toEqual ({ scene: 'top', parents: [], opened: true });
767
+ }) // it afterShow return value has no effect on the completed show()
768
+
769
+
770
+
771
+ it ( 'hide() honors beforeHide, same as show() does', async () => {
772
+ // Regression test: hide() used to ignore beforeHide entirely, so it could
773
+ // never be blocked - even though show() already respected it when navigating
774
+ // away to a different scene.
775
+ const calls = [];
776
+ const script = cuts();
777
+ const scenes = [
778
+ {
779
+ name: 'top'
780
+ , scene: {
781
+ show : ({task}) => { calls.push('show top'); task.done() },
782
+ hide : ({task}) => { calls.push('hide top'); task.done() },
783
+ beforeHide : ({ done }) => { calls.push('beforeHide'); done ( false ) }
784
+ }
785
+ }
786
+ ];
787
+
788
+ script.setScenes ( scenes );
789
+ await script.show ({ scene: 'top' });
790
+ calls.length = 0;
791
+
792
+ await script.hide ();
793
+ expect ( calls ).toEqual ([ 'beforeHide' ]); // hide() never actually ran
794
+ expect ( script.getState() ).toEqual ({ scene: 'top', parents: [], opened: true });
795
+ }) // it hide() honors beforeHide, same as show() does
796
+
797
+
798
+
799
+ it ( 'jump() does not record a jump-stack entry when the navigation is blocked', async () => {
800
+ // Regression test: jump() pushed onto the jump stack unconditionally, even when
801
+ // the underlying show() was blocked (eg. by beforeHide) and nothing actually
802
+ // changed - leaving a phantom entry that desynced jumpBack() from real history.
803
+ const calls = [];
804
+ const script = cuts();
805
+ let block = true;
806
+ const scenes = [
807
+ {
808
+ name: 'home'
809
+ , scene: {
810
+ show : ({task}) => { calls.push('show home'); task.done() },
811
+ hide : ({task}) => { calls.push('hide home'); task.done() },
812
+ beforeHide : ({ done }) => done ( !block )
813
+ }
814
+ },
815
+ {
816
+ name: 'settings'
817
+ , scene: {
818
+ show : ({task}) => { calls.push('show settings'); task.done() },
819
+ hide : ({task}) => { calls.push('hide settings'); task.done() }
820
+ }
821
+ },
822
+ {
823
+ name: 'other'
824
+ , scene: {
825
+ show : ({task}) => { calls.push('show other'); task.done() },
826
+ hide : ({task}) => { calls.push('hide other'); task.done() }
827
+ }
828
+ }
829
+ ];
830
+
831
+ script.setScenes ( scenes );
832
+ await script.show ({ scene: 'home' });
833
+
834
+ await script.jump ({ scene: 'settings' }); // blocked by beforeHide - no stack entry
835
+ expect ( script.getState().scene ).toEqual ( 'home' );
836
+
837
+ block = false;
838
+ await script.jump ({ scene: 'other' }); // succeeds - exactly one real stack entry
839
+ expect ( script.getState().scene ).toEqual ( 'other' );
840
+
841
+ calls.length = 0;
842
+ await script.jumpBack ();
843
+ expect ( calls ).toEqual ([ 'hide other', 'show home' ]);
844
+ expect ( script.getState().scene ).toEqual ( 'home' );
845
+
846
+ calls.length = 0;
847
+ await script.jumpBack (); // stack should already be empty - true no-op
848
+ expect ( calls ).toEqual ([]);
849
+ expect ( script.getState().scene ).toEqual ( 'home' );
850
+ }) // it jump() does not record a jump-stack entry when the navigation is blocked
851
+
852
+
853
+
854
+ it ( 'Navigating from a wildcard overlay to an unrelated scene hides the scene it was covering', async () => {
855
+ // Regression test: findInstructions is a lazy generator, and show() passed it
856
+ // state.currentParents by reference. getStep's wildcard climb (state.currentParents.pop())
857
+ // mutated that same array mid-iteration, corrupting the generator's still-pending reads
858
+ // and silently dropping the 'hide' instruction for the scene the overlay was covering -
859
+ // leaking it as permanently visible.
860
+ const calls = [];
861
+ const script = cuts();
862
+ const scenes = [
863
+ { name: 'G', scene: { show: ({task}) => { calls.push('show G'); task.done() }, hide: ({task}) => { calls.push('hide G'); task.done() } } },
864
+ { name: 'OV', scene: { show: ({task}) => { calls.push('show OV'); task.done() }, hide: ({task}) => { calls.push('hide OV'); task.done() }, parents: ['*'] } },
865
+ { name: 'D', scene: { show: ({task}) => { calls.push('show D'); task.done() }, hide: ({task}) => { calls.push('hide D'); task.done() } } }
866
+ ];
867
+
868
+ script.setScenes ( scenes );
869
+ await script.show ({ scene: 'G' });
870
+ await script.show ({ scene: 'OV' });
871
+ calls.length = 0;
872
+
873
+ await script.show ({ scene: 'D' });
874
+ expect ( calls ).toEqual ([ 'hide OV', 'hide G', 'show D' ]);
875
+ expect ( script.getState() ).toEqual ({ scene: 'D', parents: [], opened: true });
876
+ }) // it Navigating from a wildcard overlay to an unrelated scene hides the scene it was covering
877
+
878
+
879
+
880
+ it ( 'Showing a wildcard overlay with no underlying scene does not crash later navigation', async () => {
881
+ // Regression test: showing an overlay when state.currentScene was null recorded a
882
+ // literal 'null' placeholder in currentParents. Navigating away from it later crashed,
883
+ // because findInstructions/getStep treated that placeholder as a real scene name to hide.
884
+ const script = cuts();
885
+ const scenes = [
886
+ { name: 'F', scene: { show: ({task}) => task.done(), hide: ({task}) => task.done() } },
887
+ { name: 'OV', scene: { show: ({task}) => task.done(), hide: ({task}) => task.done(), parents: ['*'] } },
888
+ { name: 'A', scene: { show: ({task}) => task.done(), hide: ({task}) => task.done() } }
889
+ ];
890
+
891
+ script.setScenes ( scenes );
892
+ await script.show ({ scene: 'F' });
893
+ await script.hide ( 1 ); // back to nothing - currentScene becomes null
894
+ await script.show ({ scene: 'OV' }); // overlay with no underlying scene
895
+
896
+ expect ( script.getState() ).toEqual ({ scene: 'OV', parents: [], opened: true });
897
+
898
+ await script.show ({ scene: 'A' }); // used to throw
899
+ expect ( script.getState() ).toEqual ({ scene: 'A', parents: [], opened: true });
900
+ }) // it Showing a wildcard overlay with no underlying scene does not crash later navigation
901
+
902
+
903
+
904
+ it ( 'Re-showing the same wildcard overlay does not push it onto its own ancestor chain', async () => {
905
+ // Regression test: showing an overlay while it was ALREADY the current scene pushed
906
+ // it onto its own currentParents (self-reference). A later hide() would then pop that
907
+ // self-reference and report the overlay as still current, even though it had just been
908
+ // hidden - a state/reality desync.
909
+ const script = cuts();
910
+ const visible = new Set();
911
+ const scenes = [
912
+ { name: 'C', scene: { show: ({task}) => { visible.add('C'); task.done() }, hide: ({task}) => { visible.delete('C'); task.done() } } },
913
+ { name: 'OV', scene: { show: ({task}) => { visible.add('OV'); task.done() }, hide: ({task}) => { visible.delete('OV'); task.done() }, parents: ['*'] } }
914
+ ];
915
+
916
+ script.setScenes ( scenes );
917
+ await script.show ({ scene: 'C' });
918
+ await script.show ({ scene: 'OV' });
919
+ await script.show ({ scene: 'OV' }); // show the same overlay again while already current
920
+
921
+ expect ( script.getState() ).toEqual ({ scene: 'OV', parents: [ 'C' ], opened: true });
922
+
923
+ await script.hide ( 1 );
924
+ expect ( script.getState() ).toEqual ({ scene: 'C', parents: [], opened: true });
925
+ expect ( [...visible] ).toEqual ([ 'C' ]); // OV must actually be gone, matching reported state
926
+ }) // it Re-showing the same wildcard overlay does not push it onto its own ancestor chain
927
+
928
+
929
+
661
930
  }) // describe
package/vitest.config.js CHANGED
@@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config'
3
3
  export default defineConfig({
4
4
  test: {
5
5
  environment: 'node',
6
- exclude: ['test-e2e/**', 'node_modules/**'],
6
+ exclude: ['test-e2e/**', '**/node_modules/**'],
7
7
  coverage: {
8
8
  provider: 'v8',
9
9
  reporter: ['lcov', 'text-summary'],