@wavelengthusaf/client-router 0.0.1 → 0.0.3

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.
Files changed (168) hide show
  1. package/dist/elements/RouteElement.d.ts +11 -0
  2. package/dist/elements/RouteElement.d.ts.map +1 -0
  3. package/dist/elements/RouteElement.js +44 -0
  4. package/dist/elements/RouteElement.js.map +1 -0
  5. package/dist/elements/RouterElement.d.ts +12 -0
  6. package/dist/elements/RouterElement.d.ts.map +1 -0
  7. package/dist/elements/RouterElement.js +70 -0
  8. package/dist/elements/RouterElement.js.map +1 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +5 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/models/CompiledRoute.d.ts +7 -0
  14. package/dist/models/CompiledRoute.d.ts.map +1 -0
  15. package/dist/models/CompiledRoute.js +2 -0
  16. package/dist/models/CompiledRoute.js.map +1 -0
  17. package/dist/models/MatchedRoute.d.ts +7 -0
  18. package/dist/models/MatchedRoute.d.ts.map +1 -0
  19. package/dist/models/MatchedRoute.js +2 -0
  20. package/dist/models/MatchedRoute.js.map +1 -0
  21. package/dist/models/RouteComponent.d.ts +6 -0
  22. package/dist/models/RouteComponent.d.ts.map +1 -0
  23. package/dist/models/RouteComponent.js +2 -0
  24. package/dist/models/RouteComponent.js.map +1 -0
  25. package/dist/models/RouteContext.d.ts +7 -0
  26. package/dist/models/RouteContext.d.ts.map +1 -0
  27. package/dist/models/RouteContext.js +2 -0
  28. package/dist/models/RouteContext.js.map +1 -0
  29. package/dist/models/RouteDefinition.d.ts +6 -0
  30. package/dist/models/RouteDefinition.d.ts.map +1 -0
  31. package/dist/models/RouteDefinition.js +2 -0
  32. package/dist/models/RouteDefinition.js.map +1 -0
  33. package/dist/models/RouteGuard.d.ts +3 -0
  34. package/dist/models/RouteGuard.d.ts.map +1 -0
  35. package/dist/models/RouteGuard.js +2 -0
  36. package/dist/models/RouteGuard.js.map +1 -0
  37. package/dist/models/RouteParams.d.ts +4 -0
  38. package/dist/models/RouteParams.d.ts.map +1 -0
  39. package/dist/models/RouteParams.js +2 -0
  40. package/dist/models/RouteParams.js.map +1 -0
  41. package/dist/models/index.d.ts +9 -0
  42. package/dist/models/index.d.ts.map +1 -0
  43. package/dist/models/index.js +2 -0
  44. package/dist/models/index.js.map +1 -0
  45. package/dist/router/Router.d.ts +20 -0
  46. package/dist/router/Router.d.ts.map +1 -0
  47. package/dist/router/Router.js +158 -0
  48. package/dist/router/Router.js.map +1 -0
  49. package/dist/utils/compileRoute.d.ts +3 -0
  50. package/dist/utils/compileRoute.d.ts.map +1 -0
  51. package/dist/utils/compileRoute.js +30 -0
  52. package/dist/utils/compileRoute.js.map +1 -0
  53. package/dist/utils/index.d.ts +4 -0
  54. package/dist/utils/index.d.ts.map +1 -0
  55. package/dist/utils/index.js +4 -0
  56. package/dist/utils/index.js.map +1 -0
  57. package/dist/utils/matchRoute.d.ts +3 -0
  58. package/dist/utils/matchRoute.d.ts.map +1 -0
  59. package/dist/utils/matchRoute.js +25 -0
  60. package/dist/utils/matchRoute.js.map +1 -0
  61. package/package.json +7 -1
  62. package/.gitlab-ci.yml +0 -84
  63. package/.husky/pre-commit +0 -38
  64. package/.storybook/main.ts +0 -16
  65. package/.storybook/manager.ts +0 -7
  66. package/.storybook/preview.ts +0 -23
  67. package/.storybook/theme.ts +0 -30
  68. package/.storybook/vitest.setup.ts +0 -7
  69. package/dist/elements/__tests__/RouteElement.test.d.ts +0 -0
  70. package/dist/elements/__tests__/RouteElement.test.d.ts.map +0 -0
  71. package/dist/elements/__tests__/RouteElement.test.js +0 -77
  72. package/dist/elements/__tests__/RouteElement.test.js.map +0 -0
  73. package/dist/elements/__tests__/RouterElement.test.d.ts +0 -0
  74. package/dist/elements/__tests__/RouterElement.test.d.ts.map +0 -0
  75. package/dist/elements/__tests__/RouterElement.test.js +0 -0
  76. package/dist/elements/__tests__/RouterElement.test.js.map +0 -0
  77. package/dist/prettier.config.d.ts +0 -0
  78. package/dist/prettier.config.d.ts.map +0 -0
  79. package/dist/router/__tests__/Router.test.d.ts +0 -0
  80. package/dist/router/__tests__/Router.test.d.ts.map +0 -0
  81. package/dist/router/__tests__/Router.test.js +0 -0
  82. package/dist/router/__tests__/Router.test.js.map +0 -0
  83. package/dist/src/elements/RouteElement.d.ts +0 -0
  84. package/dist/src/elements/RouteElement.d.ts.map +0 -0
  85. package/dist/src/elements/RouterElement.d.ts +0 -0
  86. package/dist/src/elements/RouterElement.d.ts.map +0 -0
  87. package/dist/src/elements/__tests__/RouteElement.test.d.ts +0 -0
  88. package/dist/src/elements/__tests__/RouteElement.test.d.ts.map +0 -0
  89. package/dist/src/elements/__tests__/RouterElement.test.d.ts +0 -0
  90. package/dist/src/elements/__tests__/RouterElement.test.d.ts.map +0 -0
  91. package/dist/src/index.d.ts +0 -0
  92. package/dist/src/index.d.ts.map +0 -0
  93. package/dist/src/models/CompiledRoute.d.ts +0 -0
  94. package/dist/src/models/CompiledRoute.d.ts.map +0 -0
  95. package/dist/src/models/MatchedRoute.d.ts +0 -0
  96. package/dist/src/models/MatchedRoute.d.ts.map +0 -0
  97. package/dist/src/models/RouteComponent.d.ts +0 -0
  98. package/dist/src/models/RouteComponent.d.ts.map +0 -0
  99. package/dist/src/models/RouteContext.d.ts +0 -0
  100. package/dist/src/models/RouteContext.d.ts.map +0 -0
  101. package/dist/src/models/RouteDefinition.d.ts +0 -0
  102. package/dist/src/models/RouteDefinition.d.ts.map +0 -0
  103. package/dist/src/models/RouteGuard.d.ts +0 -0
  104. package/dist/src/models/RouteGuard.d.ts.map +0 -0
  105. package/dist/src/models/RouteParams.d.ts +0 -0
  106. package/dist/src/models/RouteParams.d.ts.map +0 -0
  107. package/dist/src/models/index.d.ts +0 -0
  108. package/dist/src/models/index.d.ts.map +0 -0
  109. package/dist/src/router/Router.d.ts +0 -0
  110. package/dist/src/router/Router.d.ts.map +0 -0
  111. package/dist/src/router/__tests__/Router.test.d.ts +0 -0
  112. package/dist/src/router/__tests__/Router.test.d.ts.map +0 -0
  113. package/dist/src/stories/RouterElement.stories.d.ts +0 -0
  114. package/dist/src/stories/RouterElement.stories.d.ts.map +0 -0
  115. package/dist/src/stories/RouterElement.stories.test.d.ts +0 -0
  116. package/dist/src/stories/RouterElement.stories.test.d.ts.map +0 -0
  117. package/dist/src/utils/__tests__/compileRoute.test.d.ts +0 -0
  118. package/dist/src/utils/__tests__/compileRoute.test.d.ts.map +0 -0
  119. package/dist/src/utils/__tests__/matchRoute.test.d.ts +0 -0
  120. package/dist/src/utils/__tests__/matchRoute.test.d.ts.map +0 -0
  121. package/dist/src/utils/compileRoute.d.ts +0 -0
  122. package/dist/src/utils/compileRoute.d.ts.map +0 -0
  123. package/dist/src/utils/index.d.ts +0 -0
  124. package/dist/src/utils/index.d.ts.map +0 -0
  125. package/dist/src/utils/matchRoute.d.ts +0 -0
  126. package/dist/src/utils/matchRoute.d.ts.map +0 -0
  127. package/dist/stories/RouterElement.stories.d.ts +0 -0
  128. package/dist/stories/RouterElement.stories.d.ts.map +0 -0
  129. package/dist/stories/RouterElement.stories.js +0 -0
  130. package/dist/stories/RouterElement.stories.js.map +0 -0
  131. package/dist/utils/__tests__/compileRoute.test.d.ts +0 -0
  132. package/dist/utils/__tests__/compileRoute.test.d.ts.map +0 -0
  133. package/dist/utils/__tests__/compileRoute.test.js +0 -0
  134. package/dist/utils/__tests__/compileRoute.test.js.map +0 -0
  135. package/dist/utils/__tests__/matchRoute.test.d.ts +0 -0
  136. package/dist/utils/__tests__/matchRoute.test.d.ts.map +0 -0
  137. package/dist/utils/__tests__/matchRoute.test.js +0 -0
  138. package/dist/utils/__tests__/matchRoute.test.js.map +0 -0
  139. package/eslint.config.mjs +0 -89
  140. package/index.ts +0 -15
  141. package/prettier.config.ts +0 -30
  142. package/src/elements/RouteElement.ts +0 -67
  143. package/src/elements/RouterElement.ts +0 -97
  144. package/src/elements/__tests__/RouteElement.test.ts +0 -89
  145. package/src/elements/__tests__/RouterElement.test.ts +0 -253
  146. package/src/index.ts +0 -15
  147. package/src/models/CompiledRoute.ts +0 -7
  148. package/src/models/MatchedRoute.ts +0 -7
  149. package/src/models/RouteComponent.ts +0 -6
  150. package/src/models/RouteContext.ts +0 -7
  151. package/src/models/RouteDefinition.ts +0 -6
  152. package/src/models/RouteGuard.ts +0 -6
  153. package/src/models/RouteParams.ts +0 -3
  154. package/src/models/index.ts +0 -17
  155. package/src/router/Router.ts +0 -225
  156. package/src/router/__tests__/Router.test.ts +0 -464
  157. package/src/stories/RouterElement.stories.test.ts +0 -310
  158. package/src/stories/RouterElement.stories.ts +0 -208
  159. package/src/utils/__tests__/compileRoute.test.ts +0 -97
  160. package/src/utils/__tests__/matchRoute.test.ts +0 -180
  161. package/src/utils/compileRoute.ts +0 -37
  162. package/src/utils/index.ts +0 -4
  163. package/src/utils/matchRoute.ts +0 -37
  164. package/tsconfig.build.json +0 -20
  165. package/tsconfig.json +0 -55
  166. package/vitest.config.ts +0 -34
  167. package/vitest.setup.ts +0 -3
  168. package/vitest.shims.d.ts +0 -1
@@ -1,225 +0,0 @@
1
- import type {
2
- CompiledRoute,
3
- MatchedRoute,
4
- RouteComponent,
5
- RouteContext,
6
- RouteDefinition,
7
- RouteGuard,
8
- } from '../models/index.ts';
9
- import { compileRoute, matchRoute } from '../utils/index.ts';
10
-
11
- export interface RouterEnv {
12
- window?: Window;
13
- document?: Document;
14
- }
15
-
16
- export interface RouterOptions {
17
- outlet?: HTMLElement;
18
- hashMode?: boolean;
19
- beforeEach?: RouteGuard;
20
- }
21
-
22
- export class Router {
23
- readonly #compiledRoutes: CompiledRoute[];
24
- readonly #hashMode: boolean;
25
- readonly #outlet: HTMLElement | undefined;
26
- readonly #beforeEach?: RouteGuard;
27
- #currentComponent?: RouteComponent;
28
- readonly #window?: Window;
29
- readonly #document?: Document;
30
-
31
- #isStarted: boolean = false;
32
-
33
- constructor(
34
- routeDefinitions: RouteDefinition[],
35
- routerOptions: RouterOptions = {},
36
- env: RouterEnv = {},
37
- ) {
38
- this.#compiledRoutes = routeDefinitions.map(compileRoute);
39
- this.#hashMode = routerOptions.hashMode ?? false;
40
-
41
- if (routerOptions.beforeEach !== undefined) {
42
- this.#beforeEach = routerOptions.beforeEach;
43
- }
44
-
45
- if (env.window !== undefined) {
46
- this.#window = env.window;
47
- }
48
-
49
- if (env.document !== undefined) {
50
- this.#document = env.document;
51
- }
52
-
53
- this.#outlet = routerOptions.outlet ?? this.#document?.body ?? undefined;
54
- }
55
-
56
- public start(): void {
57
- if (!this.#window?.addEventListener || !this.#document?.addEventListener) {
58
- throw new Error('[Router] Invalid environment: missing DOM APIs');
59
- }
60
-
61
- if (this.#isStarted) return;
62
-
63
- this.#bindEvents();
64
- this.#resolve();
65
-
66
- this.#isStarted = true;
67
- }
68
-
69
- public destroy(): void {
70
- if (!this.#window || !this.#document) return;
71
-
72
- this.#window.removeEventListener('popstate', this.#handlePopState);
73
- this.#document.removeEventListener('click', this.#handleClick);
74
-
75
- this.#currentComponent?.unmount?.();
76
- this.#isStarted = false;
77
- }
78
-
79
- public navigate(path: string): void {
80
- if (!this.#window) return;
81
-
82
- if (this.#hashMode) {
83
- this.#window.location.hash = path;
84
- } else {
85
- this.#window.history.pushState(null, '', path);
86
- }
87
-
88
- this.#resolve();
89
- }
90
-
91
- public replace(path: string): void {
92
- if (!this.#window) return;
93
-
94
- if (this.#hashMode) {
95
- this.#window.location.replace('#' + path);
96
- } else {
97
- this.#window.history.replaceState(null, '', path);
98
- }
99
-
100
- this.#resolve();
101
- }
102
-
103
- public back(): void {
104
- this.#window?.history.back();
105
- }
106
-
107
- #bindEvents(): void {
108
- this.#window!.addEventListener('popstate', this.#handlePopState);
109
- this.#document!.addEventListener('click', this.#handleClick);
110
- }
111
-
112
- #handlePopState = (): void => {
113
- this.#resolve();
114
- };
115
-
116
- #handleClick = (event: MouseEvent): void => {
117
- const target = event.target as Element | null;
118
- const anchor = target?.closest?.('a') as HTMLAnchorElement | null;
119
-
120
- if (!anchor) return;
121
-
122
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
123
- return;
124
-
125
- if (anchor.target === '_blank') return;
126
-
127
- const href: string | null = anchor.getAttribute('href');
128
-
129
- if (!href) return;
130
-
131
- if (
132
- href.startsWith('http') ||
133
- href.startsWith('//') ||
134
- href.startsWith('mailto:') ||
135
- href.startsWith('tel:')
136
- ) {
137
- return;
138
- }
139
-
140
- event.preventDefault();
141
- this.navigate(href);
142
- };
143
-
144
- #currentPath(): string {
145
- if (!this.#window) return '/';
146
-
147
- if (this.#hashMode) {
148
- const hash: string = this.#window.location.hash.slice(1);
149
- return hash || '/';
150
- }
151
-
152
- return this.#window.location.pathname || '/';
153
- }
154
-
155
- #currentQuery(): URLSearchParams {
156
- if (!this.#window) return new URLSearchParams();
157
-
158
- if (this.#hashMode) {
159
- const hash = this.#window.location.hash;
160
- const queryIndex = hash.indexOf('?');
161
-
162
- if (queryIndex >= 0) {
163
- return new URLSearchParams(hash.slice(queryIndex));
164
- }
165
-
166
- return new URLSearchParams();
167
- }
168
-
169
- return new URLSearchParams(this.#window.location.search);
170
- }
171
-
172
- #mount(routeComponent: RouteComponent, routeContext: RouteContext): void {
173
- if (!this.#outlet) return;
174
-
175
- this.#currentComponent?.unmount?.();
176
-
177
- this.#outlet.replaceChildren();
178
-
179
- routeComponent.mount(this.#outlet, routeContext);
180
-
181
- this.#currentComponent = routeComponent;
182
- }
183
-
184
- #resolve(): void {
185
- const pathname: string = this.#currentPath();
186
- const query: URLSearchParams = this.#currentQuery();
187
-
188
- const match: MatchedRoute | null = matchRoute(
189
- this.#compiledRoutes,
190
- pathname,
191
- );
192
-
193
- if (!match) {
194
- console.warn(`[Router] No route matched: ${pathname}`);
195
- return;
196
- }
197
-
198
- const context: RouteContext = {
199
- path: pathname,
200
- routeParams: match.routeParams,
201
- query,
202
- };
203
-
204
- if (!this.#beforeEach) {
205
- this.#mount(match.routeDefinition.routeComponent, context);
206
- return;
207
- }
208
-
209
- let redirected: boolean = false;
210
-
211
- const next = (redirect?: string): void => {
212
- if (redirected) return;
213
-
214
- redirected = true;
215
-
216
- if (redirect && redirect !== context.path) {
217
- this.navigate(redirect);
218
- } else {
219
- this.#mount(match.routeDefinition.routeComponent, context);
220
- }
221
- };
222
-
223
- this.#beforeEach(context, next);
224
- }
225
- }
@@ -1,464 +0,0 @@
1
- import { describe, expect, it, vi, beforeEach } from 'vitest';
2
- import { Router } from '../Router.ts';
3
- import { matchRoute } from '../../utils/index.ts';
4
-
5
- /* eslint-disable @typescript-eslint/no-explicit-any */
6
- vi.mock('../../utils/index.ts', () => ({
7
- compileRoute: vi.fn((route) => ({ ...route, compiled: true })),
8
- matchRoute: vi.fn(),
9
- }));
10
-
11
- const mockedMatchRoute = vi.mocked(matchRoute);
12
-
13
- const createMockWindow = (overrides: any = {}) => {
14
- const state = {
15
- pathname: overrides.pathname ?? '/',
16
- search: overrides.search ?? '',
17
- hash: overrides.hash ?? '',
18
- };
19
-
20
- return {
21
- location: {
22
- get pathname() { return state.pathname; },
23
- get search() { return state.search; },
24
- get hash() { return state.hash; },
25
- set hash(v: string) { state.hash = v; },
26
- replace: vi.fn((path: string) => {
27
- state.hash = path.startsWith('#') ? path.slice(1) : path;
28
- }),
29
- },
30
- history: {
31
- pushState: vi.fn((data: any, title: string, url: string) => {
32
- state.pathname = url;
33
- }),
34
- replaceState: vi.fn((data: any, title: string, url: string) => {
35
- state.pathname = url;
36
- }),
37
- back: vi.fn(),
38
- },
39
- addEventListener: vi.fn(),
40
- removeEventListener: vi.fn(),
41
- } as unknown as Window;
42
- };
43
-
44
- const createMockDocument = (overrides: any = {}) => {
45
- return {
46
- body: { replaceChildren: vi.fn(), appendChild: vi.fn() },
47
- addEventListener: vi.fn(),
48
- removeEventListener: vi.fn(),
49
- ...overrides,
50
- } as unknown as Document;
51
- };
52
-
53
- const createEnv = (url = 'http://localhost/') => {
54
- const currentUrl = new URL(url);
55
-
56
- return {
57
- window: {
58
- location: {
59
- get pathname() { return currentUrl.pathname; },
60
- get search() { return currentUrl.search; },
61
- get hash() { return currentUrl.hash; },
62
- set hash(v: string) { currentUrl.hash = v; },
63
- replace: vi.fn((v: string) => { currentUrl.hash = v; }),
64
- },
65
- history: {
66
- pushState: vi.fn((_, __, path) => {
67
- const next = new URL(path, currentUrl.origin);
68
- currentUrl.pathname = next.pathname;
69
- currentUrl.search = next.search;
70
- }),
71
- back: vi.fn(),
72
- },
73
- addEventListener: vi.fn(),
74
- removeEventListener: vi.fn(),
75
- } as unknown as Window,
76
- document: createMockDocument()
77
- };
78
- };
79
-
80
- describe('Router', () => {
81
- beforeEach(() => {
82
- vi.clearAllMocks();
83
- });
84
-
85
- describe('Initialization & Environment Checks', () => {
86
- it('throws when DOM APIs are missing in start()', () => {
87
- // @ts-expect-error (passing wrong parameters)
88
- const router = new Router([], {}, { window: undefined, document: undefined });
89
- expect(() => router.start()).toThrow('[Router] Invalid environment: missing DOM APIs');
90
- });
91
-
92
- it('throws when objects exist but lack addEventListener', () => {
93
- const router = new Router([], {}, { window: {} as Window, document: createMockDocument() });
94
- expect(() => router.start()).toThrow('[Router] Invalid environment: missing DOM APIs');
95
- });
96
-
97
- it('gracefully exits early if methods are called in an SSR environment', () => {
98
- // @ts-expect-error (passing wrong parameters)
99
- const router = new Router([], {}, { window: undefined, document: undefined });
100
- expect(() => router.navigate('/home')).not.toThrow();
101
- expect(() => router.replace('/home')).not.toThrow();
102
- expect(() => router.back()).not.toThrow();
103
- expect(() => router.destroy()).not.toThrow();
104
- });
105
-
106
- it('falls back to document.body if outlet is completely omitted', () => {
107
- const mockDoc = createMockDocument();
108
- const router = new Router([], {}, { window: createMockWindow(), document: mockDoc });
109
-
110
- const mountSpy = vi.fn();
111
- mockedMatchRoute.mockReturnValue({
112
- // @ts-expect-error (passing wrong parameters)
113
- routeDefinition: { routeComponent: { mount: mountSpy, unmount: vi.fn() } },
114
- routeParams: {},
115
- });
116
-
117
- router.start();
118
- expect(mockDoc.body.replaceChildren).toHaveBeenCalled();
119
- expect(mountSpy).toHaveBeenCalledWith(mockDoc.body, expect.any(Object));
120
- });
121
- });
122
-
123
- describe('Lifecycle: start & destroy', () => {
124
- it('prevents multiple redundant starts', () => {
125
- const win = createMockWindow();
126
- const router = new Router([], {}, { window: win, document: createMockDocument() });
127
-
128
- router.start();
129
- router.start();
130
-
131
- expect(win.addEventListener).toHaveBeenCalledTimes(1);
132
- });
133
-
134
- it('cleans up listeners and unmounts current component on destroy', () => {
135
- const win = createMockWindow();
136
- const doc = createMockDocument();
137
- const unmountSpy = vi.fn();
138
-
139
- mockedMatchRoute.mockReturnValue({
140
- // @ts-expect-error (passing wrong parameters)
141
- routeDefinition: { routeComponent: { mount: vi.fn(), unmount: unmountSpy } },
142
- routeParams: {},
143
- });
144
-
145
- const router = new Router([], {}, { window: win, document: doc });
146
- router.start();
147
- router.destroy();
148
-
149
- expect(win.removeEventListener).toHaveBeenCalledWith('popstate', expect.any(Function));
150
- expect(doc.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function));
151
- expect(unmountSpy).toHaveBeenCalledTimes(1);
152
- });
153
-
154
- it('allows restart after destruction', () => {
155
- const win = createMockWindow();
156
- const router = new Router([], {}, { window: win, document: createMockDocument() });
157
-
158
- router.start();
159
- router.destroy();
160
- router.start();
161
-
162
- expect(win.addEventListener).toHaveBeenCalledTimes(2);
163
- });
164
-
165
- it('throws an error if window or document are missing during start', () => {
166
- const router = new Router([], {}, {});
167
-
168
- expect(() => {
169
- router.start();
170
- }).toThrow('[Router] Invalid environment: missing DOM APIs');
171
-
172
- expect(mockedMatchRoute).not.toHaveBeenCalled();
173
- });
174
-
175
- it('handles destruction when component lacks an unmount method', () => {
176
- const env = createEnv();
177
- mockedMatchRoute.mockReturnValue({
178
- routeDefinition: { routeComponent: { mount: vi.fn() } } as any,
179
- routeParams: {},
180
- });
181
-
182
- const router = new Router([], {}, env);
183
- router.start();
184
- expect(() => router.destroy()).not.toThrow();
185
- });
186
-
187
- it('does not throw if the current component lacks an unmount method', () => {
188
- const mountSpy1 = vi.fn();
189
- const mountSpy2 = vi.fn();
190
-
191
- mockedMatchRoute
192
- .mockReturnValueOnce({
193
- // @ts-expect-error (passing wrong parameters)
194
- routeDefinition: { routeComponent: { mount: mountSpy1 } }, // unmount is missing
195
- routeParams: {},
196
- })
197
- .mockReturnValueOnce({
198
- // @ts-expect-error (passing wrong parameters)
199
- routeDefinition: { routeComponent: { mount: mountSpy2, unmount: vi.fn() } },
200
- routeParams: {},
201
- });
202
-
203
- const router = new Router([], {}, { window: createMockWindow(), document: createMockDocument() });
204
-
205
- router.start();
206
-
207
- expect(() => router.navigate('/next')).not.toThrow();
208
- expect(mountSpy2).toHaveBeenCalled();
209
- });
210
- });
211
-
212
- describe('Navigation Methods', () => {
213
- describe.each([
214
- { hashMode: true },
215
- { hashMode: false },
216
- ])('Mode: hashMode=$hashMode', ({ hashMode }) => {
217
- it('navigates via pushState or hash assignment', () => {
218
- const win = createMockWindow();
219
- const router = new Router([], { hashMode }, { window: win, document: createMockDocument() });
220
-
221
- router.navigate('/path');
222
-
223
- if (hashMode) {
224
- expect(win.location.hash).toBe('/path');
225
- } else {
226
- expect(win.history.pushState).toHaveBeenCalledWith(null, '', '/path');
227
- }
228
- });
229
-
230
- it('replaces via replaceState or location replace', () => {
231
- const win = createMockWindow();
232
- const router = new Router([], { hashMode }, { window: win, document: createMockDocument() });
233
-
234
- router.replace('/path');
235
-
236
- if (hashMode) {
237
- expect(win.location.replace).toHaveBeenCalledWith('#/path');
238
- } else {
239
- expect(win.history.replaceState).toHaveBeenCalledWith(null, '', '/path');
240
- }
241
- });
242
- });
243
-
244
- it('triggers history.back()', () => {
245
- const win = createMockWindow();
246
- const router = new Router([], {}, { window: win, document: createMockDocument() });
247
- router.back();
248
- expect(win.history.back).toHaveBeenCalled();
249
- });
250
- });
251
-
252
- describe('Click Handling', () => {
253
- const createClickEvent = (config: any) => ({
254
- target: {
255
- closest: vi.fn((selector) => {
256
- if (selector === 'a' && config.isAnchor !== false) {
257
- return {
258
- target: config.target || '',
259
- getAttribute: vi.fn().mockReturnValue(config.href),
260
- };
261
- }
262
- return null;
263
- }),
264
- },
265
- preventDefault: vi.fn(),
266
- metaKey: !!config.metaKey,
267
- ctrlKey: !!config.ctrlKey,
268
- shiftKey: !!config.shiftKey,
269
- altKey: !!config.altKey,
270
- } as unknown as MouseEvent);
271
-
272
- it.each([
273
- ['Ignored: Not an anchor', { isAnchor: false }, false],
274
- ['Ignored: Modifier Meta', { href: '/foo', metaKey: true }, false],
275
- ['Ignored: Modifier Ctrl', { href: '/foo', ctrlKey: true }, false],
276
- ['Ignored: Modifier Shift', { href: '/foo', shiftKey: true }, false],
277
- ['Ignored: Modifier Alt', { href: '/foo', altKey: true }, false],
278
- ['Ignored: Target _blank', { href: '/foo', target: '_blank' }, false],
279
- ['Ignored: No href', { href: null }, false],
280
- // eslint-disable-next-line sonarjs/no-clear-text-protocols
281
- ['Ignored: External HTTP', { href: 'http://foo.com' }, false],
282
- ['Ignored: External HTTPS', { href: 'https://foo.com' }, false],
283
- ['Ignored: External Protocol-Relative', { href: '//foo.com' }, false],
284
- ['Ignored: Mailto', { href: 'mailto:a@b.com' }, false],
285
- ['Ignored: Tel', { href: 'tel:123' }, false],
286
- ['Handled: Valid internal path', { href: '/internal' }, true],
287
- ])('%s', (_, config, shouldNavigate) => {
288
- const win = createMockWindow();
289
- const doc = createMockDocument();
290
- const router = new Router([], {}, { window: win, document: doc });
291
-
292
- router.start();
293
-
294
- const clickHandler = vi.mocked(doc.addEventListener).mock.calls.find(c => c[0] === 'click')?.[1] as EventListener;
295
- const event = createClickEvent(config);
296
-
297
- clickHandler(event);
298
-
299
- if (shouldNavigate) {
300
- expect(event.preventDefault).toHaveBeenCalled();
301
- // @ts-expect-error (passing wrong parameters)
302
- expect(win.history.pushState).toHaveBeenCalledWith(null, '', config.href);
303
- } else {
304
- expect(win.history.pushState).not.toHaveBeenCalled();
305
- }
306
- });
307
- });
308
-
309
- describe('Path & Query Parsing', () => {
310
- it.each([
311
- ['History Mode, empty fallback', false, '', '', '', '/', new URLSearchParams()],
312
- ['History Mode, with search', false, '/foo', '?a=1', '', '/foo', new URLSearchParams('?a=1')],
313
- ['Hash Mode, empty fallback', true, '/', '', '', '/', new URLSearchParams()],
314
- ['Hash Mode, just pound', true, '/', '', '#', '/', new URLSearchParams()],
315
- ['Hash Mode, no hash string', true, '/', '', '', '/', new URLSearchParams()],
316
- ['Hash Mode, path with no query', true, '/', '', '#/dashboard', '/dashboard', new URLSearchParams()],
317
- ['Hash Mode, path only', true, '/', '', '#/bar', '/bar', new URLSearchParams()],
318
- ['Hash Mode, path & query', true, '/', '', '#/bar?c=2', '/bar?c=2', new URLSearchParams('?c=2')],
319
- ])('%s', (_, hashMode, pathname, search, hash, expectedPath, expectedQuery) => {
320
- const win = createMockWindow({ pathname, search, hash });
321
- const router = new Router([], { hashMode }, { window: win, document: createMockDocument() });
322
-
323
- mockedMatchRoute.mockReturnValue({
324
- // @ts-expect-error (passing wrong parameters)
325
- routeDefinition: { routeComponent: { mount: vi.fn(), unmount: vi.fn() } },
326
- routeParams: {},
327
- });
328
-
329
- router.start();
330
-
331
- // @ts-expect-error (passing wrong parameters)
332
- const routeComponent = mockedMatchRoute.mock.results[0].value.routeDefinition.routeComponent;
333
- expect(mockedMatchRoute).toHaveBeenCalledWith(expect.any(Array), expectedPath);
334
- expect(routeComponent.mount).toHaveBeenCalledWith(
335
- expect.any(Object),
336
- expect.objectContaining({ path: expectedPath, query: expectedQuery })
337
- );
338
- });
339
- });
340
-
341
- describe('Resolution & Guards (beforeEach)', () => {
342
- it('resolves on popstate', () => {
343
- const win = createMockWindow();
344
- const router = new Router([], {}, { window: win, document: createMockDocument() });
345
- router.start();
346
-
347
- const popStateHandler = vi.mocked(win.addEventListener).mock.calls.find(c => c[0] === 'popstate')?.[1] as EventListener;
348
- mockedMatchRoute.mockClear();
349
-
350
- popStateHandler(new Event('popstate'));
351
- expect(mockedMatchRoute).toHaveBeenCalled();
352
- });
353
-
354
- it('logs a warning when no route matches', () => {
355
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
356
- mockedMatchRoute.mockReturnValue(null);
357
-
358
- const router = new Router([], {}, { window: createMockWindow(), document: createMockDocument() });
359
- router.start();
360
-
361
- expect(warnSpy).toHaveBeenCalledWith('[Router] No route matched: /');
362
- warnSpy.mockRestore();
363
- });
364
-
365
- it('allows direct mount when guard calls next() without arguments', () => {
366
- const mountSpy = vi.fn();
367
- mockedMatchRoute.mockReturnValue({
368
- // @ts-expect-error (passing wrong parameters)
369
- routeDefinition: { routeComponent: { mount: mountSpy, unmount: vi.fn() } },
370
- routeParams: {},
371
- });
372
-
373
- const beforeEachGuard = vi.fn((ctx, next) => next());
374
- const router = new Router([], { beforeEach: beforeEachGuard }, { window: createMockWindow(), document: createMockDocument() });
375
-
376
- router.start();
377
-
378
- expect(beforeEachGuard).toHaveBeenCalled();
379
- expect(mountSpy).toHaveBeenCalled();
380
- });
381
-
382
- it('triggers a redirect when guard provides a new path', () => {
383
- const win = createMockWindow();
384
- mockedMatchRoute.mockReturnValue({
385
- // @ts-expect-error (passing wrong parameters)
386
- routeDefinition: { routeComponent: { mount: vi.fn(), unmount: vi.fn() } },
387
- routeParams: {},
388
- });
389
-
390
- const beforeEachGuard = vi.fn((ctx, next) => next('/login'));
391
- const router = new Router([], { beforeEach: beforeEachGuard }, { window: win, document: createMockDocument() });
392
-
393
- router.start();
394
- expect(win.history.pushState).toHaveBeenCalledWith(null, '', '/login');
395
- });
396
-
397
- it('skips redirect and mounts if redirected path matches current context path', () => {
398
- const win = createMockWindow({ pathname: '/login' });
399
- const mountSpy = vi.fn();
400
- mockedMatchRoute.mockReturnValue({
401
- // @ts-expect-error (passing wrong parameters)
402
- routeDefinition: { routeComponent: { mount: mountSpy, unmount: vi.fn() } },
403
- routeParams: {},
404
- });
405
-
406
- const beforeEachGuard = vi.fn((ctx, next) => next('/login'));
407
- const router = new Router([], { beforeEach: beforeEachGuard }, { window: win, document: createMockDocument() });
408
-
409
- router.start();
410
- expect(win.history.pushState).not.toHaveBeenCalled();
411
- expect(mountSpy).toHaveBeenCalled();
412
- });
413
-
414
- it('ignores subsequent next(path) calls to prevent redirect loops', () => {
415
- const win = createMockWindow({ pathname: '/home' });
416
- mockedMatchRoute.mockReturnValue({
417
- // @ts-expect-error (passing wrong parameters)
418
- routeDefinition: { routeComponent: { mount: vi.fn(), unmount: vi.fn() } },
419
- routeParams: {},
420
- });
421
-
422
- const beforeEachGuard = vi.fn((ctx, next) => {
423
- next('/login');
424
- next('/dashboard'); // Secondary attempts should be ignored
425
- });
426
-
427
- const router = new Router([], { beforeEach: beforeEachGuard }, { window: win, document: createMockDocument() });
428
- router.start();
429
-
430
- expect(win.history.pushState).toHaveBeenCalledTimes(1);
431
- expect(win.history.pushState).toHaveBeenCalledWith(null, '', '/login');
432
- });
433
- });
434
-
435
- describe('Component ComponentMount Lifecycle', () => {
436
- it('unmounts the previous component before mounting the new one', () => {
437
- const unmountSpy1 = vi.fn();
438
- const mountSpy1 = vi.fn();
439
- const unmountSpy2 = vi.fn();
440
- const mountSpy2 = vi.fn();
441
-
442
- mockedMatchRoute
443
- .mockReturnValueOnce({
444
- // @ts-expect-error (passing wrong parameters)
445
- routeDefinition: { routeComponent: { mount: mountSpy1, unmount: unmountSpy1 } },
446
- routeParams: {},
447
- })
448
- .mockReturnValueOnce({
449
- // @ts-expect-error (passing wrong parameters)
450
- routeDefinition: { routeComponent: { mount: mountSpy2, unmount: unmountSpy2 } },
451
- routeParams: {},
452
- });
453
-
454
- const router = new Router([], {}, { window: createMockWindow(), document: createMockDocument() });
455
-
456
- router.start();
457
- expect(mountSpy1).toHaveBeenCalled();
458
-
459
- router.navigate('/next');
460
- expect(unmountSpy1).toHaveBeenCalled();
461
- expect(mountSpy2).toHaveBeenCalled();
462
- });
463
- });
464
- });