zero-query 0.9.0 → 0.9.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.
- package/README.md +2 -3
- package/cli/commands/bundle.js +15 -2
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +184 -44
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +6 -2
- package/package.json +1 -1
- package/src/component.js +28 -7
- package/src/core.js +62 -12
- package/src/diff.js +11 -5
- package/src/expression.js +1 -0
- package/src/http.js +17 -1
- package/src/reactive.js +8 -2
- package/src/router.js +37 -8
- package/src/ssr.js +1 -1
- package/src/store.js +5 -0
- package/src/utils.js +12 -6
- package/tests/cli.test.js +456 -0
- package/tests/component.test.js +1387 -0
- package/tests/core.test.js +893 -1
- package/tests/diff.test.js +891 -0
- package/tests/errors.test.js +179 -0
- package/tests/expression.test.js +569 -0
- package/tests/http.test.js +160 -1
- package/tests/reactive.test.js +320 -0
- package/tests/router.test.js +1187 -0
- package/tests/ssr.test.js +261 -0
- package/tests/store.test.js +210 -0
- package/tests/utils.test.js +186 -0
- package/types/store.d.ts +3 -0
package/tests/router.test.js
CHANGED
|
@@ -647,3 +647,1190 @@ describe('Router — edge cases', () => {
|
|
|
647
647
|
expect(r).toBeDefined();
|
|
648
648
|
});
|
|
649
649
|
});
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
// Route matching priority (first match wins)
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
|
|
656
|
+
describe('Router — route matching priority', () => {
|
|
657
|
+
it('first matching route wins', () => {
|
|
658
|
+
const router = createRouter({
|
|
659
|
+
mode: 'hash',
|
|
660
|
+
routes: [
|
|
661
|
+
{ path: '/test', component: 'home-page' },
|
|
662
|
+
{ path: '/test', component: 'about-page' },
|
|
663
|
+
],
|
|
664
|
+
});
|
|
665
|
+
// The first route with path '/test' should be matched
|
|
666
|
+
const matched = router._routes[0];
|
|
667
|
+
expect(matched._regex.test('/test')).toBe(true);
|
|
668
|
+
expect(matched.component).toBe('home-page');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('specific route takes priority over wildcard', () => {
|
|
672
|
+
const router = createRouter({
|
|
673
|
+
mode: 'hash',
|
|
674
|
+
routes: [
|
|
675
|
+
{ path: '/about', component: 'about-page' },
|
|
676
|
+
{ path: '*', component: 'home-page' },
|
|
677
|
+
],
|
|
678
|
+
});
|
|
679
|
+
// /about should match the first route, not wildcard
|
|
680
|
+
expect(router._routes[0]._regex.test('/about')).toBe(true);
|
|
681
|
+
expect(router._routes[0].component).toBe('about-page');
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('parameterized routes match correctly', () => {
|
|
685
|
+
const router = createRouter({
|
|
686
|
+
mode: 'hash',
|
|
687
|
+
routes: [
|
|
688
|
+
{ path: '/user/:id', component: 'user-page' },
|
|
689
|
+
{ path: '/user/settings', component: 'about-page' },
|
|
690
|
+
],
|
|
691
|
+
});
|
|
692
|
+
// /user/42 should match parameterized route
|
|
693
|
+
expect(router._routes[0]._regex.test('/user/42')).toBe(true);
|
|
694
|
+
// /user/settings matches first (since :id catches "settings")
|
|
695
|
+
expect(router._routes[0]._regex.test('/user/settings')).toBe(true);
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
// ---------------------------------------------------------------------------
|
|
701
|
+
// Route removal
|
|
702
|
+
// ---------------------------------------------------------------------------
|
|
703
|
+
|
|
704
|
+
describe('Router — route removal', () => {
|
|
705
|
+
it('remove() deletes the matching route', () => {
|
|
706
|
+
const router = createRouter({
|
|
707
|
+
mode: 'hash',
|
|
708
|
+
routes: [
|
|
709
|
+
{ path: '/', component: 'home-page' },
|
|
710
|
+
{ path: '/about', component: 'about-page' },
|
|
711
|
+
],
|
|
712
|
+
});
|
|
713
|
+
expect(router._routes.length).toBe(2);
|
|
714
|
+
router.remove('/about');
|
|
715
|
+
expect(router._routes.length).toBe(1);
|
|
716
|
+
expect(router._routes.find(r => r.path === '/about')).toBeUndefined();
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it('remove() on non-existent path is a no-op', () => {
|
|
720
|
+
const router = createRouter({
|
|
721
|
+
mode: 'hash',
|
|
722
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
723
|
+
});
|
|
724
|
+
router.remove('/nonexistent');
|
|
725
|
+
expect(router._routes.length).toBe(1);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it('remove() returns the router for chaining', () => {
|
|
729
|
+
const router = createRouter({
|
|
730
|
+
mode: 'hash',
|
|
731
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
732
|
+
});
|
|
733
|
+
const result = router.remove('/');
|
|
734
|
+
expect(result).toBe(router);
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
// Dynamic route addition
|
|
741
|
+
// ---------------------------------------------------------------------------
|
|
742
|
+
|
|
743
|
+
describe('Router — dynamic route addition', () => {
|
|
744
|
+
it('add() returns the router for chaining', () => {
|
|
745
|
+
const router = createRouter({ mode: 'hash', routes: [] });
|
|
746
|
+
const result = router.add({ path: '/new', component: 'home-page' });
|
|
747
|
+
expect(result).toBe(router);
|
|
748
|
+
expect(router._routes.length).toBe(1);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it('add() compiles regex for parameterized routes', () => {
|
|
752
|
+
const router = createRouter({ mode: 'hash', routes: [] });
|
|
753
|
+
router.add({ path: '/item/:id/detail/:section', component: 'home-page' });
|
|
754
|
+
const route = router._routes[0];
|
|
755
|
+
expect(route._regex.test('/item/42/detail/overview')).toBe(true);
|
|
756
|
+
expect(route._keys).toEqual(['id', 'section']);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('add() with fallback creates two routes', () => {
|
|
760
|
+
const router = createRouter({ mode: 'hash', routes: [] });
|
|
761
|
+
router.add({ path: '/docs/:page', fallback: '/docs', component: 'docs-page' });
|
|
762
|
+
expect(router._routes.length).toBe(2);
|
|
763
|
+
expect(router._routes[0]._regex.test('/docs/intro')).toBe(true);
|
|
764
|
+
expect(router._routes[1]._regex.test('/docs')).toBe(true);
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
// ---------------------------------------------------------------------------
|
|
770
|
+
// Navigation chaining
|
|
771
|
+
// ---------------------------------------------------------------------------
|
|
772
|
+
|
|
773
|
+
describe('Router — navigation chaining', () => {
|
|
774
|
+
let router;
|
|
775
|
+
beforeEach(() => {
|
|
776
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
777
|
+
window.location.hash = '#/';
|
|
778
|
+
router = createRouter({
|
|
779
|
+
el: '#app',
|
|
780
|
+
mode: 'hash',
|
|
781
|
+
routes: [
|
|
782
|
+
{ path: '/', component: 'home-page' },
|
|
783
|
+
{ path: '/about', component: 'about-page' },
|
|
784
|
+
],
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('navigate returns the router for chaining', () => {
|
|
789
|
+
const result = router.navigate('/about');
|
|
790
|
+
expect(result).toBe(router);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it('replace returns the router for chaining', () => {
|
|
794
|
+
const result = router.replace('/about');
|
|
795
|
+
expect(result).toBe(router);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
it('back() returns the router', () => {
|
|
799
|
+
expect(router.back()).toBe(router);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it('forward() returns the router', () => {
|
|
803
|
+
expect(router.forward()).toBe(router);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it('go() returns the router', () => {
|
|
807
|
+
expect(router.go(0)).toBe(router);
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
// Hash mode path parsing
|
|
814
|
+
// ---------------------------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
describe('Router — hash mode path parsing', () => {
|
|
817
|
+
let router;
|
|
818
|
+
beforeEach(() => {
|
|
819
|
+
router = createRouter({
|
|
820
|
+
mode: 'hash',
|
|
821
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it('path returns / when hash is empty', () => {
|
|
826
|
+
window.location.hash = '';
|
|
827
|
+
expect(router.path).toBe('/');
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('path returns correct value from hash', () => {
|
|
831
|
+
window.location.hash = '#/about';
|
|
832
|
+
expect(router.path).toBe('/about');
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it('path returns / when hash is just #/', () => {
|
|
836
|
+
window.location.hash = '#/';
|
|
837
|
+
expect(router.path).toBe('/');
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
// ---------------------------------------------------------------------------
|
|
843
|
+
// History mode path handling with base
|
|
844
|
+
// ---------------------------------------------------------------------------
|
|
845
|
+
|
|
846
|
+
describe('Router — history mode with base path', () => {
|
|
847
|
+
it('resolve includes base prefix', () => {
|
|
848
|
+
const router = createRouter({
|
|
849
|
+
mode: 'history',
|
|
850
|
+
base: '/myapp',
|
|
851
|
+
routes: [],
|
|
852
|
+
});
|
|
853
|
+
expect(router.resolve('/page')).toBe('/myapp/page');
|
|
854
|
+
expect(router.resolve('/')).toBe('/myapp/');
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it('_normalizePath strips double base prefix', () => {
|
|
858
|
+
const router = createRouter({
|
|
859
|
+
mode: 'history',
|
|
860
|
+
base: '/myapp',
|
|
861
|
+
routes: [],
|
|
862
|
+
});
|
|
863
|
+
// If someone accidentally includes the base
|
|
864
|
+
expect(router._normalizePath('/myapp/page')).toBe('/page');
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it('base without leading slash gets normalized', () => {
|
|
868
|
+
const router = createRouter({
|
|
869
|
+
mode: 'history',
|
|
870
|
+
base: 'app',
|
|
871
|
+
routes: [],
|
|
872
|
+
});
|
|
873
|
+
expect(router.base).toBe('/app');
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
// ---------------------------------------------------------------------------
|
|
879
|
+
// Navigate with query strings in hash mode
|
|
880
|
+
// ---------------------------------------------------------------------------
|
|
881
|
+
|
|
882
|
+
describe('Router — query string in hash mode', () => {
|
|
883
|
+
let router;
|
|
884
|
+
beforeEach(() => {
|
|
885
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
886
|
+
window.location.hash = '#/';
|
|
887
|
+
router = createRouter({
|
|
888
|
+
el: '#app',
|
|
889
|
+
mode: 'hash',
|
|
890
|
+
routes: [
|
|
891
|
+
{ path: '/', component: 'home-page' },
|
|
892
|
+
{ path: '/search', component: 'about-page' },
|
|
893
|
+
],
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it('navigate preserves path for query routing', () => {
|
|
898
|
+
router.navigate('/search');
|
|
899
|
+
expect(window.location.hash).toBe('#/search');
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
// ---------------------------------------------------------------------------
|
|
905
|
+
// Guard edge cases
|
|
906
|
+
// ---------------------------------------------------------------------------
|
|
907
|
+
|
|
908
|
+
describe('Router — guard edge cases', () => {
|
|
909
|
+
it('beforeEach returns the router for chaining', () => {
|
|
910
|
+
const router = createRouter({
|
|
911
|
+
mode: 'hash',
|
|
912
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
913
|
+
});
|
|
914
|
+
const result = router.beforeEach(() => {});
|
|
915
|
+
expect(result).toBe(router);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it('afterEach returns the router for chaining', () => {
|
|
919
|
+
const router = createRouter({
|
|
920
|
+
mode: 'hash',
|
|
921
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
922
|
+
});
|
|
923
|
+
const result = router.afterEach(() => {});
|
|
924
|
+
expect(result).toBe(router);
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
it('guard cancels navigation when returning false', async () => {
|
|
928
|
+
const router = createRouter({
|
|
929
|
+
el: '#app',
|
|
930
|
+
mode: 'hash',
|
|
931
|
+
routes: [
|
|
932
|
+
{ path: '/', component: 'home-page' },
|
|
933
|
+
{ path: '/blocked', component: 'about-page' },
|
|
934
|
+
],
|
|
935
|
+
});
|
|
936
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
937
|
+
router.beforeEach(() => false);
|
|
938
|
+
// Manually trigger resolve for /blocked
|
|
939
|
+
window.location.hash = '#/blocked';
|
|
940
|
+
await router._resolve();
|
|
941
|
+
// current should not be updated to /blocked
|
|
942
|
+
expect(router._current === null || router._current.path !== '/blocked').toBe(true);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
it('guard redirects to a different route', async () => {
|
|
946
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
947
|
+
const router = createRouter({
|
|
948
|
+
el: '#app',
|
|
949
|
+
mode: 'hash',
|
|
950
|
+
routes: [
|
|
951
|
+
{ path: '/', component: 'home-page' },
|
|
952
|
+
{ path: '/login', component: 'about-page' },
|
|
953
|
+
{ path: '/dashboard', component: 'docs-page' },
|
|
954
|
+
],
|
|
955
|
+
});
|
|
956
|
+
router.beforeEach((to) => {
|
|
957
|
+
if (to.path === '/dashboard') return '/login';
|
|
958
|
+
});
|
|
959
|
+
window.location.hash = '#/dashboard';
|
|
960
|
+
await router._resolve();
|
|
961
|
+
expect(window.location.hash).toBe('#/login');
|
|
962
|
+
});
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
// ---------------------------------------------------------------------------
|
|
967
|
+
// onChange with navigation
|
|
968
|
+
// ---------------------------------------------------------------------------
|
|
969
|
+
|
|
970
|
+
describe('Router — onChange fires on resolve', () => {
|
|
971
|
+
it('fires onChange listener after route resolution', async () => {
|
|
972
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
973
|
+
const listener = vi.fn();
|
|
974
|
+
const router = createRouter({
|
|
975
|
+
el: '#app',
|
|
976
|
+
mode: 'hash',
|
|
977
|
+
routes: [
|
|
978
|
+
{ path: '/', component: 'home-page' },
|
|
979
|
+
{ path: '/about', component: 'about-page' },
|
|
980
|
+
],
|
|
981
|
+
});
|
|
982
|
+
router.onChange(listener);
|
|
983
|
+
// Wait for initial resolve
|
|
984
|
+
await new Promise(r => setTimeout(r, 10));
|
|
985
|
+
listener.mockClear();
|
|
986
|
+
|
|
987
|
+
window.location.hash = '#/about';
|
|
988
|
+
await router._resolve();
|
|
989
|
+
expect(listener).toHaveBeenCalled();
|
|
990
|
+
const [to, from] = listener.mock.calls[0];
|
|
991
|
+
expect(to.path).toBe('/about');
|
|
992
|
+
});
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
// ---------------------------------------------------------------------------
|
|
997
|
+
// Multi-param extraction
|
|
998
|
+
// ---------------------------------------------------------------------------
|
|
999
|
+
|
|
1000
|
+
describe('Router — multi-param extraction', () => {
|
|
1001
|
+
it('extracts multiple params from URL', () => {
|
|
1002
|
+
const router = createRouter({
|
|
1003
|
+
mode: 'hash',
|
|
1004
|
+
routes: [
|
|
1005
|
+
{ path: '/org/:orgId/team/:teamId/member/:memberId', component: 'user-page' },
|
|
1006
|
+
],
|
|
1007
|
+
});
|
|
1008
|
+
const route = router._routes[0];
|
|
1009
|
+
const match = '/org/acme/team/dev/member/42'.match(route._regex);
|
|
1010
|
+
expect(match).not.toBeNull();
|
|
1011
|
+
const params = {};
|
|
1012
|
+
route._keys.forEach((key, i) => { params[key] = match[i + 1]; });
|
|
1013
|
+
expect(params).toEqual({ orgId: 'acme', teamId: 'dev', memberId: '42' });
|
|
1014
|
+
});
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
// ---------------------------------------------------------------------------
|
|
1019
|
+
// Substate in hash mode
|
|
1020
|
+
// ---------------------------------------------------------------------------
|
|
1021
|
+
|
|
1022
|
+
describe('Router — substates hash mode', () => {
|
|
1023
|
+
let router, pushSpy;
|
|
1024
|
+
|
|
1025
|
+
beforeEach(() => {
|
|
1026
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1027
|
+
window.location.hash = '#/';
|
|
1028
|
+
pushSpy = vi.spyOn(window.history, 'pushState');
|
|
1029
|
+
router = createRouter({
|
|
1030
|
+
el: '#app',
|
|
1031
|
+
mode: 'hash',
|
|
1032
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
1033
|
+
});
|
|
1034
|
+
pushSpy.mockClear();
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
afterEach(() => {
|
|
1038
|
+
pushSpy.mockRestore();
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
it('pushSubstate works in hash mode', () => {
|
|
1042
|
+
router.pushSubstate('drawer', { side: 'left' });
|
|
1043
|
+
expect(pushSpy).toHaveBeenCalledTimes(1);
|
|
1044
|
+
const state = pushSpy.mock.calls[0][0];
|
|
1045
|
+
expect(state.__zq).toBe('substate');
|
|
1046
|
+
expect(state.key).toBe('drawer');
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it('multiple substates can be pushed', () => {
|
|
1050
|
+
router.pushSubstate('modal', { id: 'a' });
|
|
1051
|
+
router.pushSubstate('modal', { id: 'b' });
|
|
1052
|
+
expect(pushSpy).toHaveBeenCalledTimes(2);
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
// ---------------------------------------------------------------------------
|
|
1058
|
+
// _interpolateParams edge cases
|
|
1059
|
+
// ---------------------------------------------------------------------------
|
|
1060
|
+
|
|
1061
|
+
describe('Router — _interpolateParams edge cases', () => {
|
|
1062
|
+
let router;
|
|
1063
|
+
beforeEach(() => {
|
|
1064
|
+
router = createRouter({ mode: 'hash', routes: [] });
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
it('handles special characters in param values', () => {
|
|
1068
|
+
expect(router._interpolateParams('/tag/:name', { name: 'c++' })).toBe('/tag/c%2B%2B');
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it('handles empty string param value', () => {
|
|
1072
|
+
expect(router._interpolateParams('/user/:id', { id: '' })).toBe('/user/');
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
it('handles zero as param value', () => {
|
|
1076
|
+
expect(router._interpolateParams('/page/:num', { num: 0 })).toBe('/page/0');
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it('handles boolean param values', () => {
|
|
1080
|
+
expect(router._interpolateParams('/flag/:val', { val: true })).toBe('/flag/true');
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
it('returns path unchanged when params is non-object', () => {
|
|
1084
|
+
expect(router._interpolateParams('/about', 'string')).toBe('/about');
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
it('handles path with no placeholders', () => {
|
|
1088
|
+
expect(router._interpolateParams('/about', { id: 42 })).toBe('/about');
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it('handles adjacent params', () => {
|
|
1092
|
+
// This is a weird URL but should still work
|
|
1093
|
+
expect(router._interpolateParams('/:a/:b', { a: 'x', b: 'y' })).toBe('/x/y');
|
|
1094
|
+
});
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
// ---------------------------------------------------------------------------
|
|
1099
|
+
// Router.destroy cleans up everything
|
|
1100
|
+
// ---------------------------------------------------------------------------
|
|
1101
|
+
|
|
1102
|
+
describe('Router — destroy completeness', () => {
|
|
1103
|
+
it('clears instance, routes, guards, listeners, and substates', () => {
|
|
1104
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1105
|
+
const router = createRouter({
|
|
1106
|
+
el: '#app',
|
|
1107
|
+
mode: 'hash',
|
|
1108
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
1109
|
+
});
|
|
1110
|
+
router.beforeEach(() => {});
|
|
1111
|
+
router.afterEach(() => {});
|
|
1112
|
+
router.onChange(() => {});
|
|
1113
|
+
router.onSubstate(() => {});
|
|
1114
|
+
|
|
1115
|
+
router.destroy();
|
|
1116
|
+
|
|
1117
|
+
expect(router._routes.length).toBe(0);
|
|
1118
|
+
expect(router._guards.before.length).toBe(0);
|
|
1119
|
+
expect(router._guards.after.length).toBe(0);
|
|
1120
|
+
expect(router._listeners.size).toBe(0);
|
|
1121
|
+
expect(router._substateListeners.length).toBe(0);
|
|
1122
|
+
expect(router._inSubstate).toBe(false);
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it('removes window event listeners on destroy (no leak)', () => {
|
|
1126
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1127
|
+
const removeSpy = vi.spyOn(window, 'removeEventListener');
|
|
1128
|
+
const router = createRouter({
|
|
1129
|
+
el: '#app',
|
|
1130
|
+
mode: 'hash',
|
|
1131
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
1132
|
+
});
|
|
1133
|
+
// Store the handler reference before destroy
|
|
1134
|
+
const navHandler = router._onNavEvent;
|
|
1135
|
+
const clickHandler = router._onLinkClick;
|
|
1136
|
+
expect(navHandler).toBeDefined();
|
|
1137
|
+
expect(clickHandler).toBeDefined();
|
|
1138
|
+
|
|
1139
|
+
router.destroy();
|
|
1140
|
+
|
|
1141
|
+
expect(removeSpy).toHaveBeenCalledWith('hashchange', navHandler);
|
|
1142
|
+
expect(router._onNavEvent).toBeNull();
|
|
1143
|
+
expect(router._onLinkClick).toBeNull();
|
|
1144
|
+
removeSpy.mockRestore();
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
it('removes popstate listener in history mode on destroy', () => {
|
|
1148
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1149
|
+
const removeSpy = vi.spyOn(window, 'removeEventListener');
|
|
1150
|
+
const router = createRouter({
|
|
1151
|
+
el: '#app',
|
|
1152
|
+
mode: 'history',
|
|
1153
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
1154
|
+
});
|
|
1155
|
+
const navHandler = router._onNavEvent;
|
|
1156
|
+
router.destroy();
|
|
1157
|
+
expect(removeSpy).toHaveBeenCalledWith('popstate', navHandler);
|
|
1158
|
+
removeSpy.mockRestore();
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
it('removes document click listener on destroy', () => {
|
|
1162
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1163
|
+
const removeSpy = vi.spyOn(document, 'removeEventListener');
|
|
1164
|
+
const router = createRouter({
|
|
1165
|
+
el: '#app',
|
|
1166
|
+
mode: 'hash',
|
|
1167
|
+
routes: [],
|
|
1168
|
+
});
|
|
1169
|
+
const clickHandler = router._onLinkClick;
|
|
1170
|
+
router.destroy();
|
|
1171
|
+
expect(removeSpy).toHaveBeenCalledWith('click', clickHandler);
|
|
1172
|
+
removeSpy.mockRestore();
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
// ---------------------------------------------------------------------------
|
|
1178
|
+
// PERF: same-route comparison uses shallow equality (no JSON.stringify)
|
|
1179
|
+
// ---------------------------------------------------------------------------
|
|
1180
|
+
|
|
1181
|
+
describe('Router — same-route shallow equality', () => {
|
|
1182
|
+
it('skips re-render when navigating to same route with same params', async () => {
|
|
1183
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1184
|
+
let renderCount = 0;
|
|
1185
|
+
const router = createRouter({
|
|
1186
|
+
el: '#app',
|
|
1187
|
+
mode: 'hash',
|
|
1188
|
+
routes: [
|
|
1189
|
+
{ path: '/user/:id', render: () => '<div>user</div>' },
|
|
1190
|
+
],
|
|
1191
|
+
});
|
|
1192
|
+
// Mock component mount counting
|
|
1193
|
+
router.afterEach(() => { renderCount++; });
|
|
1194
|
+
|
|
1195
|
+
router.navigate('/user/42');
|
|
1196
|
+
await new Promise(r => setTimeout(r, 50));
|
|
1197
|
+
const firstCount = renderCount;
|
|
1198
|
+
|
|
1199
|
+
// Navigate to the same route — should skip
|
|
1200
|
+
router.navigate('/user/42');
|
|
1201
|
+
await new Promise(r => setTimeout(r, 50));
|
|
1202
|
+
// Hash mode prevents same-hash navigation at URL level,
|
|
1203
|
+
// so renderCount should not increase
|
|
1204
|
+
expect(renderCount).toBe(firstCount);
|
|
1205
|
+
router.destroy();
|
|
1206
|
+
});
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
// ===========================================================================
|
|
1211
|
+
// Guard — cancel navigation
|
|
1212
|
+
// ===========================================================================
|
|
1213
|
+
|
|
1214
|
+
describe('Router — guard returning false cancels navigation', () => {
|
|
1215
|
+
it('does not resolve route when guard returns false', async () => {
|
|
1216
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1217
|
+
const router = createRouter({
|
|
1218
|
+
el: '#app',
|
|
1219
|
+
mode: 'hash',
|
|
1220
|
+
routes: [
|
|
1221
|
+
{ path: '/', component: 'home-page' },
|
|
1222
|
+
{ path: '/blocked', component: 'about-page' },
|
|
1223
|
+
],
|
|
1224
|
+
});
|
|
1225
|
+
router.beforeEach(() => false);
|
|
1226
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1227
|
+
router.navigate('/blocked');
|
|
1228
|
+
await router._resolve();
|
|
1229
|
+
// Should NOT have navigated to /blocked because guard cancelled
|
|
1230
|
+
expect(router.current?.path).not.toBe('/blocked');
|
|
1231
|
+
router.destroy();
|
|
1232
|
+
});
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
// ===========================================================================
|
|
1237
|
+
// Guard — redirect loop detection
|
|
1238
|
+
// ===========================================================================
|
|
1239
|
+
|
|
1240
|
+
describe('Router — guard redirect loop protection', () => {
|
|
1241
|
+
it('stops after more than 10 redirects', async () => {
|
|
1242
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1243
|
+
const router = createRouter({
|
|
1244
|
+
el: '#app',
|
|
1245
|
+
mode: 'hash',
|
|
1246
|
+
routes: [
|
|
1247
|
+
{ path: '/', component: 'home-page' },
|
|
1248
|
+
{ path: '/a', component: 'about-page' },
|
|
1249
|
+
{ path: '/b', component: 'docs-page' },
|
|
1250
|
+
],
|
|
1251
|
+
});
|
|
1252
|
+
// Guard that keeps bouncing between /a and /b
|
|
1253
|
+
router.beforeEach((to) => {
|
|
1254
|
+
if (to.path === '/a') return '/b';
|
|
1255
|
+
if (to.path === '/b') return '/a';
|
|
1256
|
+
});
|
|
1257
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1258
|
+
// Navigate to /a — should not infinite loop
|
|
1259
|
+
window.location.hash = '#/a';
|
|
1260
|
+
await router._resolve();
|
|
1261
|
+
// Just verify it doesn't hang — the guard count > 10 stops it
|
|
1262
|
+
router.destroy();
|
|
1263
|
+
});
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
// ===========================================================================
|
|
1268
|
+
// Guard — afterEach fires after resolve
|
|
1269
|
+
// ===========================================================================
|
|
1270
|
+
|
|
1271
|
+
describe('Router — afterEach hook', () => {
|
|
1272
|
+
it('fires afterEach with to and from after route resolves', async () => {
|
|
1273
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1274
|
+
const afterFn = vi.fn();
|
|
1275
|
+
const router = createRouter({
|
|
1276
|
+
el: '#app',
|
|
1277
|
+
mode: 'hash',
|
|
1278
|
+
routes: [
|
|
1279
|
+
{ path: '/', component: 'home-page' },
|
|
1280
|
+
{ path: '/about', component: 'about-page' },
|
|
1281
|
+
],
|
|
1282
|
+
});
|
|
1283
|
+
router.afterEach(afterFn);
|
|
1284
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1285
|
+
afterFn.mockClear();
|
|
1286
|
+
|
|
1287
|
+
window.location.hash = '#/about';
|
|
1288
|
+
await router._resolve();
|
|
1289
|
+
expect(afterFn).toHaveBeenCalledTimes(1);
|
|
1290
|
+
expect(afterFn.mock.calls[0][0].path).toBe('/about');
|
|
1291
|
+
router.destroy();
|
|
1292
|
+
});
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
// ===========================================================================
|
|
1297
|
+
// Guard — before guard that throws
|
|
1298
|
+
// ===========================================================================
|
|
1299
|
+
|
|
1300
|
+
describe('Router — before guard that throws', () => {
|
|
1301
|
+
it('catches the error and does not crash', async () => {
|
|
1302
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1303
|
+
const router = createRouter({
|
|
1304
|
+
el: '#app',
|
|
1305
|
+
mode: 'hash',
|
|
1306
|
+
routes: [
|
|
1307
|
+
{ path: '/', component: 'home-page' },
|
|
1308
|
+
{ path: '/err', component: 'about-page' },
|
|
1309
|
+
],
|
|
1310
|
+
});
|
|
1311
|
+
router.beforeEach(() => { throw new Error('guard boom'); });
|
|
1312
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1313
|
+
window.location.hash = '#/err';
|
|
1314
|
+
await expect(router._resolve()).resolves.not.toThrow();
|
|
1315
|
+
router.destroy();
|
|
1316
|
+
});
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
// ===========================================================================
|
|
1321
|
+
// Lazy loading via route.load
|
|
1322
|
+
// ===========================================================================
|
|
1323
|
+
|
|
1324
|
+
describe('Router — lazy loading with route.load', () => {
|
|
1325
|
+
it('calls load() before mounting component', async () => {
|
|
1326
|
+
const loadFn = vi.fn().mockResolvedValue(undefined);
|
|
1327
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1328
|
+
const router = createRouter({
|
|
1329
|
+
el: '#app',
|
|
1330
|
+
mode: 'hash',
|
|
1331
|
+
routes: [
|
|
1332
|
+
{ path: '/', component: 'home-page' },
|
|
1333
|
+
{ path: '/lazy', load: loadFn, component: 'about-page' },
|
|
1334
|
+
],
|
|
1335
|
+
});
|
|
1336
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1337
|
+
window.location.hash = '#/lazy';
|
|
1338
|
+
await router._resolve();
|
|
1339
|
+
expect(loadFn).toHaveBeenCalledTimes(1);
|
|
1340
|
+
router.destroy();
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
it('does not mount if load() rejects', async () => {
|
|
1344
|
+
const loadFn = vi.fn().mockRejectedValue(new Error('load fail'));
|
|
1345
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1346
|
+
const router = createRouter({
|
|
1347
|
+
el: '#app',
|
|
1348
|
+
mode: 'hash',
|
|
1349
|
+
routes: [
|
|
1350
|
+
{ path: '/', component: 'home-page' },
|
|
1351
|
+
{ path: '/fail', load: loadFn, component: 'about-page' },
|
|
1352
|
+
],
|
|
1353
|
+
});
|
|
1354
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1355
|
+
window.location.hash = '#/fail';
|
|
1356
|
+
await router._resolve();
|
|
1357
|
+
// Route should not have resolved to /fail since load() threw
|
|
1358
|
+
expect(router.current?.path).not.toBe('/fail');
|
|
1359
|
+
router.destroy();
|
|
1360
|
+
});
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
// ===========================================================================
|
|
1365
|
+
// Fallback / 404 route
|
|
1366
|
+
// ===========================================================================
|
|
1367
|
+
|
|
1368
|
+
describe('Router — fallback 404 route', () => {
|
|
1369
|
+
it('resolves to fallback component for unknown paths', async () => {
|
|
1370
|
+
component('notfound-page', { render: () => '<p>404</p>' });
|
|
1371
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1372
|
+
window.location.hash = '#/';
|
|
1373
|
+
const router = createRouter({
|
|
1374
|
+
el: '#app',
|
|
1375
|
+
mode: 'hash',
|
|
1376
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
1377
|
+
fallback: 'notfound-page',
|
|
1378
|
+
});
|
|
1379
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1380
|
+
window.location.hash = '#/nonexistent';
|
|
1381
|
+
await router._resolve();
|
|
1382
|
+
expect(router.current.path).toBe('/nonexistent');
|
|
1383
|
+
expect(router.current.route.component).toBe('notfound-page');
|
|
1384
|
+
router.destroy();
|
|
1385
|
+
});
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
// ===========================================================================
|
|
1390
|
+
// replace()
|
|
1391
|
+
// ===========================================================================
|
|
1392
|
+
|
|
1393
|
+
describe('Router — replace()', () => {
|
|
1394
|
+
it('returns router for chaining in hash mode', async () => {
|
|
1395
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1396
|
+
window.location.hash = '#/';
|
|
1397
|
+
const router = createRouter({
|
|
1398
|
+
el: '#app',
|
|
1399
|
+
mode: 'hash',
|
|
1400
|
+
routes: [
|
|
1401
|
+
{ path: '/', component: 'home-page' },
|
|
1402
|
+
{ path: '/replaced', component: 'about-page' },
|
|
1403
|
+
],
|
|
1404
|
+
});
|
|
1405
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1406
|
+
const result = router.replace('/replaced');
|
|
1407
|
+
expect(result).toBe(router);
|
|
1408
|
+
router.destroy();
|
|
1409
|
+
});
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
// ===========================================================================
|
|
1414
|
+
// query getter
|
|
1415
|
+
// ===========================================================================
|
|
1416
|
+
|
|
1417
|
+
describe('Router — query getter', () => {
|
|
1418
|
+
it('returns parsed query params from hash', () => {
|
|
1419
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1420
|
+
const router = createRouter({
|
|
1421
|
+
el: '#app',
|
|
1422
|
+
mode: 'hash',
|
|
1423
|
+
routes: [{ path: '/search', component: 'home-page' }],
|
|
1424
|
+
});
|
|
1425
|
+
window.location.hash = '#/search?q=hello&page=2';
|
|
1426
|
+
expect(router.query).toEqual({ q: 'hello', page: '2' });
|
|
1427
|
+
router.destroy();
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
it('returns empty object for no query params', () => {
|
|
1431
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1432
|
+
const router = createRouter({
|
|
1433
|
+
el: '#app',
|
|
1434
|
+
mode: 'hash',
|
|
1435
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
1436
|
+
});
|
|
1437
|
+
window.location.hash = '#/';
|
|
1438
|
+
expect(router.query).toEqual({});
|
|
1439
|
+
router.destroy();
|
|
1440
|
+
});
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
// ===========================================================================
|
|
1445
|
+
// resolve() — programmatic link generation
|
|
1446
|
+
// ===========================================================================
|
|
1447
|
+
|
|
1448
|
+
describe('Router — resolve()', () => {
|
|
1449
|
+
it('returns full URL path with base prefix', () => {
|
|
1450
|
+
const router = createRouter({
|
|
1451
|
+
mode: 'hash',
|
|
1452
|
+
base: '/app',
|
|
1453
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
1454
|
+
});
|
|
1455
|
+
expect(router.resolve('/about')).toBe('/app/about');
|
|
1456
|
+
router.destroy();
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
it('returns path as-is when no base', () => {
|
|
1460
|
+
const router = createRouter({
|
|
1461
|
+
mode: 'hash',
|
|
1462
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
1463
|
+
});
|
|
1464
|
+
expect(router.resolve('/about')).toBe('/about');
|
|
1465
|
+
router.destroy();
|
|
1466
|
+
});
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
// ===========================================================================
|
|
1471
|
+
// back/forward/go wrappers
|
|
1472
|
+
// ===========================================================================
|
|
1473
|
+
|
|
1474
|
+
describe('Router — back/forward/go wrappers', () => {
|
|
1475
|
+
it('calls window.history.back', () => {
|
|
1476
|
+
const spy = vi.spyOn(window.history, 'back').mockImplementation(() => {});
|
|
1477
|
+
const router = createRouter({
|
|
1478
|
+
mode: 'hash',
|
|
1479
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
1480
|
+
});
|
|
1481
|
+
router.back();
|
|
1482
|
+
expect(spy).toHaveBeenCalled();
|
|
1483
|
+
spy.mockRestore();
|
|
1484
|
+
router.destroy();
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
it('calls window.history.forward', () => {
|
|
1488
|
+
const spy = vi.spyOn(window.history, 'forward').mockImplementation(() => {});
|
|
1489
|
+
const router = createRouter({
|
|
1490
|
+
mode: 'hash',
|
|
1491
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
1492
|
+
});
|
|
1493
|
+
router.forward();
|
|
1494
|
+
expect(spy).toHaveBeenCalled();
|
|
1495
|
+
spy.mockRestore();
|
|
1496
|
+
router.destroy();
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
it('calls window.history.go with argument', () => {
|
|
1500
|
+
const spy = vi.spyOn(window.history, 'go').mockImplementation(() => {});
|
|
1501
|
+
const router = createRouter({
|
|
1502
|
+
mode: 'hash',
|
|
1503
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
1504
|
+
});
|
|
1505
|
+
router.go(-2);
|
|
1506
|
+
expect(spy).toHaveBeenCalledWith(-2);
|
|
1507
|
+
spy.mockRestore();
|
|
1508
|
+
router.destroy();
|
|
1509
|
+
});
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
// ===========================================================================
|
|
1514
|
+
// Link click interception — modified clicks bypass
|
|
1515
|
+
// ===========================================================================
|
|
1516
|
+
|
|
1517
|
+
describe('Router — link click interception', () => {
|
|
1518
|
+
it('intercepts normal clicks on z-link elements', async () => {
|
|
1519
|
+
document.body.innerHTML = '<div id="app"></div><a z-link="/about">About</a>';
|
|
1520
|
+
const router = createRouter({
|
|
1521
|
+
el: '#app',
|
|
1522
|
+
mode: 'hash',
|
|
1523
|
+
routes: [
|
|
1524
|
+
{ path: '/', component: 'home-page' },
|
|
1525
|
+
{ path: '/about', component: 'about-page' },
|
|
1526
|
+
],
|
|
1527
|
+
});
|
|
1528
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1529
|
+
|
|
1530
|
+
const link = document.querySelector('[z-link]');
|
|
1531
|
+
const e = new Event('click', { bubbles: true, cancelable: true });
|
|
1532
|
+
link.dispatchEvent(e);
|
|
1533
|
+
// Should have navigated
|
|
1534
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1535
|
+
expect(window.location.hash).toBe('#/about');
|
|
1536
|
+
router.destroy();
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
it('ignores clicks with meta key (does not navigate)', async () => {
|
|
1540
|
+
document.body.innerHTML = '<div id="app"></div><a z-link="/about">About</a>';
|
|
1541
|
+
window.location.hash = '#/';
|
|
1542
|
+
const router = createRouter({
|
|
1543
|
+
el: '#app',
|
|
1544
|
+
mode: 'hash',
|
|
1545
|
+
routes: [
|
|
1546
|
+
{ path: '/', component: 'home-page' },
|
|
1547
|
+
{ path: '/about', component: 'about-page' },
|
|
1548
|
+
],
|
|
1549
|
+
});
|
|
1550
|
+
await new Promise(r => setTimeout(r, 50));
|
|
1551
|
+
// Record current route before meta click
|
|
1552
|
+
const currentBefore = router.current?.path;
|
|
1553
|
+
|
|
1554
|
+
const link = document.querySelector('[z-link]');
|
|
1555
|
+
const e = new MouseEvent('click', { bubbles: true, metaKey: true });
|
|
1556
|
+
link.dispatchEvent(e);
|
|
1557
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1558
|
+
// Route should remain unchanged — meta key bypasses SPA navigation
|
|
1559
|
+
expect(router.current?.path).toBe(currentBefore);
|
|
1560
|
+
router.destroy();
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
it('ignores clicks with ctrl key', async () => {
|
|
1564
|
+
document.body.innerHTML = '<div id="app"></div><a z-link="/about2">About</a>';
|
|
1565
|
+
window.location.hash = '#/';
|
|
1566
|
+
const router = createRouter({
|
|
1567
|
+
el: '#app',
|
|
1568
|
+
mode: 'hash',
|
|
1569
|
+
routes: [
|
|
1570
|
+
{ path: '/', component: 'home-page' },
|
|
1571
|
+
{ path: '/about2', component: 'about-page' },
|
|
1572
|
+
],
|
|
1573
|
+
});
|
|
1574
|
+
await new Promise(r => setTimeout(r, 50));
|
|
1575
|
+
const link = document.querySelector('[z-link]');
|
|
1576
|
+
const e = new MouseEvent('click', { bubbles: true, ctrlKey: true });
|
|
1577
|
+
link.dispatchEvent(e);
|
|
1578
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1579
|
+
expect(router.current?.path).not.toBe('/about2');
|
|
1580
|
+
router.destroy();
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
it('ignores links with target=_blank', async () => {
|
|
1584
|
+
document.body.innerHTML = '<div id="app"></div><a z-link="/about" target="_blank">About</a>';
|
|
1585
|
+
window.location.hash = '#/';
|
|
1586
|
+
const router = createRouter({
|
|
1587
|
+
el: '#app',
|
|
1588
|
+
mode: 'hash',
|
|
1589
|
+
routes: [
|
|
1590
|
+
{ path: '/', component: 'home-page' },
|
|
1591
|
+
{ path: '/about', component: 'about-page' },
|
|
1592
|
+
],
|
|
1593
|
+
});
|
|
1594
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1595
|
+
const link = document.querySelector('[z-link]');
|
|
1596
|
+
link.click();
|
|
1597
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1598
|
+
expect(window.location.hash).toBe('#/');
|
|
1599
|
+
router.destroy();
|
|
1600
|
+
});
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
// ===========================================================================
|
|
1605
|
+
// Router — remove() route
|
|
1606
|
+
// ===========================================================================
|
|
1607
|
+
|
|
1608
|
+
describe('Router — remove()', () => {
|
|
1609
|
+
it('removes route by path', () => {
|
|
1610
|
+
const router = createRouter({
|
|
1611
|
+
mode: 'hash',
|
|
1612
|
+
routes: [
|
|
1613
|
+
{ path: '/', component: 'home-page' },
|
|
1614
|
+
{ path: '/temp', component: 'about-page' },
|
|
1615
|
+
],
|
|
1616
|
+
});
|
|
1617
|
+
expect(router._routes.length).toBe(2);
|
|
1618
|
+
router.remove('/temp');
|
|
1619
|
+
expect(router._routes.length).toBe(1);
|
|
1620
|
+
expect(router._routes[0].path).toBe('/');
|
|
1621
|
+
router.destroy();
|
|
1622
|
+
});
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
// ===========================================================================
|
|
1627
|
+
// Router — add() chaining
|
|
1628
|
+
// ===========================================================================
|
|
1629
|
+
|
|
1630
|
+
describe('Router — add() chaining', () => {
|
|
1631
|
+
it('supports fluent chaining of add calls', () => {
|
|
1632
|
+
const router = createRouter({
|
|
1633
|
+
mode: 'hash',
|
|
1634
|
+
routes: [],
|
|
1635
|
+
});
|
|
1636
|
+
const result = router.add({ path: '/', component: 'home-page' })
|
|
1637
|
+
.add({ path: '/about', component: 'about-page' });
|
|
1638
|
+
expect(result).toBe(router);
|
|
1639
|
+
expect(router._routes.length).toBe(2);
|
|
1640
|
+
router.destroy();
|
|
1641
|
+
});
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
// ===========================================================================
|
|
1646
|
+
// Router — onChange unsubscribe
|
|
1647
|
+
// ===========================================================================
|
|
1648
|
+
|
|
1649
|
+
describe('Router — onChange unsubscribe', () => {
|
|
1650
|
+
it('stops calling listener after unsubscribe', async () => {
|
|
1651
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1652
|
+
const listener = vi.fn();
|
|
1653
|
+
const router = createRouter({
|
|
1654
|
+
el: '#app',
|
|
1655
|
+
mode: 'hash',
|
|
1656
|
+
routes: [
|
|
1657
|
+
{ path: '/', component: 'home-page' },
|
|
1658
|
+
{ path: '/about', component: 'about-page' },
|
|
1659
|
+
],
|
|
1660
|
+
});
|
|
1661
|
+
const unsub = router.onChange(listener);
|
|
1662
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1663
|
+
listener.mockClear();
|
|
1664
|
+
|
|
1665
|
+
unsub();
|
|
1666
|
+
window.location.hash = '#/about';
|
|
1667
|
+
await router._resolve();
|
|
1668
|
+
expect(listener).not.toHaveBeenCalled();
|
|
1669
|
+
router.destroy();
|
|
1670
|
+
});
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
// ===========================================================================
|
|
1675
|
+
// Router — destroy cleans up
|
|
1676
|
+
// ===========================================================================
|
|
1677
|
+
|
|
1678
|
+
describe('Router — destroy cleans up', () => {
|
|
1679
|
+
it('clears listeners, guards, and routes on destroy', () => {
|
|
1680
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1681
|
+
const router = createRouter({
|
|
1682
|
+
el: '#app',
|
|
1683
|
+
mode: 'hash',
|
|
1684
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
1685
|
+
});
|
|
1686
|
+
router.beforeEach(() => {});
|
|
1687
|
+
router.afterEach(() => {});
|
|
1688
|
+
router.onChange(() => {});
|
|
1689
|
+
router.onSubstate(() => {});
|
|
1690
|
+
router.destroy();
|
|
1691
|
+
expect(router._routes.length).toBe(0);
|
|
1692
|
+
expect(router._guards.before.length).toBe(0);
|
|
1693
|
+
expect(router._guards.after.length).toBe(0);
|
|
1694
|
+
expect(router._listeners.size).toBe(0);
|
|
1695
|
+
expect(router._substateListeners.length).toBe(0);
|
|
1696
|
+
});
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
// ===========================================================================
|
|
1701
|
+
// Router — _interpolateParams
|
|
1702
|
+
// ===========================================================================
|
|
1703
|
+
|
|
1704
|
+
describe('Router — _interpolateParams', () => {
|
|
1705
|
+
it('replaces :param with provided values', () => {
|
|
1706
|
+
const router = createRouter({
|
|
1707
|
+
mode: 'hash',
|
|
1708
|
+
routes: [{ path: '/user/:id', component: 'user-page' }],
|
|
1709
|
+
});
|
|
1710
|
+
const result = router._interpolateParams('/user/:id/post/:pid', { id: 42, pid: 7 });
|
|
1711
|
+
expect(result).toBe('/user/42/post/7');
|
|
1712
|
+
router.destroy();
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
it('keeps :param when value not provided', () => {
|
|
1716
|
+
const router = createRouter({
|
|
1717
|
+
mode: 'hash',
|
|
1718
|
+
routes: [],
|
|
1719
|
+
});
|
|
1720
|
+
const result = router._interpolateParams('/user/:id', {});
|
|
1721
|
+
expect(result).toBe('/user/:id');
|
|
1722
|
+
router.destroy();
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
it('returns path unchanged when params is null', () => {
|
|
1726
|
+
const router = createRouter({ mode: 'hash', routes: [] });
|
|
1727
|
+
expect(router._interpolateParams('/test', null)).toBe('/test');
|
|
1728
|
+
router.destroy();
|
|
1729
|
+
});
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
|
|
1733
|
+
// ===========================================================================
|
|
1734
|
+
// Router — _normalizePath with base stripping
|
|
1735
|
+
// ===========================================================================
|
|
1736
|
+
|
|
1737
|
+
describe('Router — _normalizePath', () => {
|
|
1738
|
+
it('strips base prefix if accidentally included', () => {
|
|
1739
|
+
const router = createRouter({
|
|
1740
|
+
mode: 'hash',
|
|
1741
|
+
base: '/app',
|
|
1742
|
+
routes: [],
|
|
1743
|
+
});
|
|
1744
|
+
expect(router._normalizePath('/app/about')).toBe('/about');
|
|
1745
|
+
router.destroy();
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
it('returns / when path matches base exactly', () => {
|
|
1749
|
+
const router = createRouter({
|
|
1750
|
+
mode: 'hash',
|
|
1751
|
+
base: '/app',
|
|
1752
|
+
routes: [],
|
|
1753
|
+
});
|
|
1754
|
+
expect(router._normalizePath('/app')).toBe('/');
|
|
1755
|
+
router.destroy();
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
it('adds leading slash to bare paths', () => {
|
|
1759
|
+
const router = createRouter({ mode: 'hash', routes: [] });
|
|
1760
|
+
expect(router._normalizePath('about')).toBe('/about');
|
|
1761
|
+
router.destroy();
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
it('returns / for empty/null path', () => {
|
|
1765
|
+
const router = createRouter({ mode: 'hash', routes: [] });
|
|
1766
|
+
expect(router._normalizePath('')).toBe('/');
|
|
1767
|
+
expect(router._normalizePath(null)).toBe('/');
|
|
1768
|
+
router.destroy();
|
|
1769
|
+
});
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
// ===========================================================================
|
|
1774
|
+
// Router — navigate with options.params
|
|
1775
|
+
// ===========================================================================
|
|
1776
|
+
|
|
1777
|
+
describe('Router — navigate with options.params', () => {
|
|
1778
|
+
it('interpolates params in path', async () => {
|
|
1779
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1780
|
+
const router = createRouter({
|
|
1781
|
+
el: '#app',
|
|
1782
|
+
mode: 'hash',
|
|
1783
|
+
routes: [
|
|
1784
|
+
{ path: '/', component: 'home-page' },
|
|
1785
|
+
{ path: '/user/:id', component: 'user-page' },
|
|
1786
|
+
],
|
|
1787
|
+
});
|
|
1788
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1789
|
+
router.navigate('/user/:id', { params: { id: '99' } });
|
|
1790
|
+
await router._resolve();
|
|
1791
|
+
expect(window.location.hash).toBe('#/user/99');
|
|
1792
|
+
router.destroy();
|
|
1793
|
+
});
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1796
|
+
|
|
1797
|
+
// ===========================================================================
|
|
1798
|
+
// Router — render function components
|
|
1799
|
+
// ===========================================================================
|
|
1800
|
+
|
|
1801
|
+
describe('Router — render function component', () => {
|
|
1802
|
+
it('renders HTML from a function component', async () => {
|
|
1803
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
1804
|
+
window.location.hash = '#/';
|
|
1805
|
+
const router = createRouter({
|
|
1806
|
+
el: '#app',
|
|
1807
|
+
mode: 'hash',
|
|
1808
|
+
routes: [
|
|
1809
|
+
{ path: '/', component: (route) => `<p>fn: ${route.path}</p>` },
|
|
1810
|
+
],
|
|
1811
|
+
});
|
|
1812
|
+
// Wait for initial resolve (queueMicrotask + rendering)
|
|
1813
|
+
await new Promise(r => setTimeout(r, 100));
|
|
1814
|
+
const p = document.querySelector('#app p');
|
|
1815
|
+
expect(p).not.toBeNull();
|
|
1816
|
+
expect(p.textContent).toBe('fn: /');
|
|
1817
|
+
router.destroy();
|
|
1818
|
+
});
|
|
1819
|
+
});
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
|
+
// ===========================================================================
|
|
1823
|
+
// Router — substate onSubstate unsubscribe
|
|
1824
|
+
// ===========================================================================
|
|
1825
|
+
|
|
1826
|
+
describe('Router — onSubstate unsubscribe', () => {
|
|
1827
|
+
it('removes listener after unsubscribe', () => {
|
|
1828
|
+
const router = createRouter({ mode: 'hash', routes: [] });
|
|
1829
|
+
const fn = vi.fn();
|
|
1830
|
+
const unsub = router.onSubstate(fn);
|
|
1831
|
+
expect(router._substateListeners.length).toBe(1);
|
|
1832
|
+
unsub();
|
|
1833
|
+
expect(router._substateListeners.length).toBe(0);
|
|
1834
|
+
router.destroy();
|
|
1835
|
+
});
|
|
1836
|
+
});
|