@next-box/link-extra 0.1.1 → 0.1.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 (41) hide show
  1. package/README.md +3 -1
  2. package/dist/cjs/Link.cjs +3 -0
  3. package/dist/cjs/Link.cjs.map +1 -0
  4. package/dist/cjs/LinkProvider.cjs +3 -0
  5. package/dist/cjs/LinkProvider.cjs.map +1 -0
  6. package/dist/cjs/helpers.cjs +2 -0
  7. package/dist/cjs/helpers.cjs.map +1 -0
  8. package/dist/cjs/index.cjs +1 -1
  9. package/dist/cjs/index.cjs.map +1 -1
  10. package/dist/cjs/useLinkExtra.cjs +2 -0
  11. package/dist/cjs/useLinkExtra.cjs.map +1 -0
  12. package/dist/esm/Link.mjs +3 -0
  13. package/dist/esm/Link.mjs.map +1 -0
  14. package/dist/esm/LinkProvider.mjs +3 -0
  15. package/dist/esm/LinkProvider.mjs.map +1 -0
  16. package/dist/esm/helpers.mjs +2 -0
  17. package/dist/esm/helpers.mjs.map +1 -0
  18. package/dist/esm/index.mjs +1 -1
  19. package/dist/esm/index.mjs.map +1 -1
  20. package/dist/esm/useLinkExtra.mjs +2 -0
  21. package/dist/esm/useLinkExtra.mjs.map +1 -0
  22. package/dist/production.analysis.txt +26 -26
  23. package/dist/types/cjs/Link.d.cts.map +1 -1
  24. package/dist/types/cjs/LinkProvider.d.cts +2 -2
  25. package/dist/types/cjs/LinkProvider.d.cts.map +1 -1
  26. package/dist/types/cjs/helpers.d.cts.map +1 -1
  27. package/dist/types/cjs/types.d.cts +3 -6
  28. package/dist/types/cjs/types.d.cts.map +1 -1
  29. package/dist/types/esm/Link.d.ts.map +1 -1
  30. package/dist/types/esm/LinkProvider.d.ts +2 -2
  31. package/dist/types/esm/LinkProvider.d.ts.map +1 -1
  32. package/dist/types/esm/helpers.d.ts.map +1 -1
  33. package/dist/types/esm/types.d.ts +3 -6
  34. package/dist/types/esm/types.d.ts.map +1 -1
  35. package/dist/types/tsconfig.build.tsbuildinfo +1 -1
  36. package/package.json +13 -13
  37. package/src/Link.tsx +2 -0
  38. package/src/LinkProvider.tsx +26 -7
  39. package/src/helpers.ts +18 -19
  40. package/src/main.test.tsx +160 -0
  41. package/src/types.ts +3 -9
package/src/helpers.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  import { nanoid } from 'nanoid';
2
2
 
3
+ const normalizeSearchParams = (search: string, toOmitCacheBuster = false): string => {
4
+ const entries = new URLSearchParams(search)
5
+ .entries()
6
+ .filter(([key]) => !toOmitCacheBuster || key !== 'y')
7
+ .toArray()
8
+ .toSorted(([keyA, valueA], [keyB, valueB]) => keyA.localeCompare(keyB) || valueA.localeCompare(valueB));
9
+
10
+ return new URLSearchParams(entries).toString();
11
+ };
12
+
3
13
  export const shouldUrlAndLocationMatch = (url: string): boolean => {
4
14
  // It is possible for this to be undefined
5
15
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -13,20 +23,10 @@ export const shouldUrlAndLocationMatch = (url: string): boolean => {
13
23
  return false;
14
24
  }
15
25
 
16
- const { pathname, search } = parsedUrl;
17
- const urlSearchParams = new URLSearchParams(search);
18
-
19
- if (urlSearchParams.has('y')) {
20
- urlSearchParams.delete('y');
21
- }
22
-
23
- const locationSearchParams = new URLSearchParams(location.search);
24
-
25
- if (locationSearchParams.has('y')) {
26
- locationSearchParams.delete('y');
27
- }
28
-
29
- return `${pathname}?${urlSearchParams.toString()}` === `${location.pathname}?${locationSearchParams.toString()}`;
26
+ return (
27
+ parsedUrl.pathname === location.pathname &&
28
+ normalizeSearchParams(parsedUrl.search, true) === normalizeSearchParams(location.search, true)
29
+ );
30
30
  };
31
31
 
32
32
  export const addCacheBusterQueryParam = (url: string): string => {
@@ -45,14 +45,13 @@ export const addCacheBusterQueryParam = (url: string): string => {
45
45
  const locationSearchParams = new URLSearchParams(location.search);
46
46
  const locationCacheBusterSearchParam = locationSearchParams.get('y');
47
47
 
48
- if (locationCacheBusterSearchParam) {
49
- locationSearchParams.delete('y');
50
- }
51
-
52
48
  const { pathname, search } = parsedUrl;
53
49
  const searchParams = new URLSearchParams(search);
54
50
 
55
- if (`${pathname}?${search}` === `${location.pathname}?${locationSearchParams.toString()}`) {
51
+ if (
52
+ pathname === location.pathname &&
53
+ normalizeSearchParams(search, true) === normalizeSearchParams(location.search, true)
54
+ ) {
56
55
  searchParams.set('y', locationCacheBusterSearchParam ?? nanoid());
57
56
  } else {
58
57
  searchParams.set('y', nanoid());
@@ -0,0 +1,160 @@
1
+ /** @jest-environment @repodog/jest-config/FailOnJsdomConsoleEnvironment.mjs */
2
+
3
+ import { afterEach, describe, expect, it, jest } from '@jest/globals';
4
+ import type NextLink from 'next/link.js';
5
+ import { type ComponentProps, type ComponentType, type ReactElement, act } from 'react';
6
+ import { createRoot } from 'react-dom/client';
7
+ import { Link } from './Link.tsx';
8
+ import { LinkProvider } from './LinkProvider.tsx';
9
+ import { shouldUrlAndLocationMatch } from './helpers.ts';
10
+ import { type OwnLinkProps } from './types.ts';
11
+
12
+ // @ts-expect-error Okay for test file
13
+ const NextLinkMock: (typeof NextLink)['default'] = ({ children, href }) => {
14
+ const nextHref = typeof href === 'string' ? href : (href.pathname ?? '');
15
+ return <div data-next-href={nextHref}>{children}</div>;
16
+ };
17
+
18
+ const OwnLink: ComponentType<OwnLinkProps> = ({ children, ...props }) => <a {...props}>{children}</a>;
19
+
20
+ const Loading = () => <span data-testid="loading">Loading</span>;
21
+
22
+ Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
23
+
24
+ const mount = (element: ReactElement) => {
25
+ const container = document.createElement('div');
26
+ const root = createRoot(container);
27
+
28
+ act(() => {
29
+ root.render(element);
30
+ });
31
+
32
+ return {
33
+ container,
34
+ unmount: () => {
35
+ act(() => {
36
+ root.unmount();
37
+ });
38
+ },
39
+ };
40
+ };
41
+
42
+ const createLink = (props: ComponentProps<typeof Link>) => (
43
+ <LinkProvider
44
+ LoadingComponent={Loading}
45
+ NextLink={NextLinkMock}
46
+ OwnLink={OwnLink}
47
+ loadingTimeout={100}
48
+ pathname="/current"
49
+ >
50
+ <Link {...props} />
51
+ </LinkProvider>
52
+ );
53
+
54
+ afterEach(() => {
55
+ jest.useRealTimers();
56
+ history.replaceState(undefined, '', '/');
57
+ });
58
+
59
+ describe('Link integration', () => {
60
+ it('When a link is clicked and navigation exceeds the timeout, then it displays the loading component', () => {
61
+ // Arrange
62
+ history.replaceState(undefined, '', '/current');
63
+ Object.defineProperty(globalThis, 'scroll', { configurable: true, value: jest.fn() });
64
+ jest.useFakeTimers();
65
+
66
+ const { container, unmount } = mount(createLink({ children: 'Next page', href: '/next' }));
67
+ const anchor = container.querySelector('a');
68
+
69
+ // Act
70
+ act(() => {
71
+ anchor?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
72
+ jest.advanceTimersByTime(100);
73
+ });
74
+
75
+ // Assert
76
+ expect(container.querySelector('[data-testid="loading"]')?.textContent).toBe('Loading');
77
+ expect(container.querySelector<HTMLElement>('[data-next-href]')?.dataset.nextHref).toMatch(/^\/next\?y=/);
78
+
79
+ unmount();
80
+ });
81
+
82
+ it('When a click is prevented, then it does not display the loading component', () => {
83
+ // Arrange
84
+ history.replaceState(undefined, '', '/current');
85
+ Object.defineProperty(globalThis, 'scroll', { configurable: true, value: jest.fn() });
86
+ jest.useFakeTimers();
87
+
88
+ const { container, unmount } = mount(
89
+ createLink({
90
+ children: 'Prevented navigation',
91
+ href: '/next',
92
+ onClick: event => {
93
+ event.preventDefault();
94
+ },
95
+ }),
96
+ );
97
+
98
+ const anchor = container.querySelector('a');
99
+
100
+ // Act
101
+ act(() => {
102
+ anchor?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
103
+ jest.advanceTimersByTime(100);
104
+ });
105
+
106
+ // Assert
107
+ expect(container.querySelector('[data-testid="loading"]')).toBeNull();
108
+
109
+ unmount();
110
+ });
111
+
112
+ it('When router cache control is disabled, then it passes the original href to NextLink', () => {
113
+ // Arrange
114
+ history.replaceState(undefined, '', '/current');
115
+ Object.defineProperty(globalThis, 'scroll', { configurable: true, value: jest.fn() });
116
+
117
+ const { container, unmount } = mount(
118
+ createLink({ children: 'Current page', disableRouterCache: false, href: '/current' }),
119
+ );
120
+
121
+ // Act
122
+ const href = container.querySelector<HTMLElement>('[data-next-href]')?.dataset.nextHref;
123
+
124
+ // Assert
125
+ expect(href).toBe('/current');
126
+
127
+ unmount();
128
+ });
129
+
130
+ it('When a pending navigation timer reaches an unmounted provider, then it has been cleared', () => {
131
+ // Arrange
132
+ history.replaceState(undefined, '', '/current');
133
+ Object.defineProperty(globalThis, 'scroll', { configurable: true, value: jest.fn() });
134
+ jest.useFakeTimers();
135
+
136
+ const { container, unmount } = mount(createLink({ children: 'Next page', href: '/next' }));
137
+ const anchor = container.querySelector('a');
138
+
139
+ act(() => {
140
+ anchor?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
141
+ });
142
+
143
+ // Act
144
+ unmount();
145
+
146
+ // Assert
147
+ expect(jest.getTimerCount()).toBe(0);
148
+ });
149
+
150
+ it('When query parameters are reordered, then the URL matches the current location', () => {
151
+ // Arrange
152
+ history.replaceState(undefined, '', '/current?alpha=one&bravo=two&y=cache');
153
+
154
+ // Act
155
+ const isMatch = shouldUrlAndLocationMatch('/current?bravo=two&alpha=one&y=other');
156
+
157
+ // Assert
158
+ expect(isMatch).toBe(true);
159
+ });
160
+ });
package/src/types.ts CHANGED
@@ -1,18 +1,12 @@
1
- import { type LinkProps } from 'next/link.js';
2
- import { type AnchorHTMLAttributes, type ComponentType, type ReactNode } from 'react';
1
+ import type NextLink from 'next/link.js';
2
+ import { type AnchorHTMLAttributes, type ComponentType } from 'react';
3
3
 
4
4
  // This needs to be as generic as possible
5
5
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
6
  export type OwnLinkProps = Record<string, any> & AnchorHTMLAttributes<HTMLAnchorElement>;
7
7
 
8
- export type NextLinkComponent = ComponentType<
9
- LinkProps & {
10
- children: ReactNode;
11
- }
12
- >;
13
-
14
8
  export type LinkContextData = {
15
- NextLink: NextLinkComponent;
9
+ NextLink: (typeof NextLink)['default'];
16
10
  OwnLink: ComponentType<AnchorHTMLAttributes<HTMLAnchorElement>>;
17
11
  onLinkClicked: () => void;
18
12
  };