@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.
- package/README.md +3 -1
- package/dist/cjs/Link.cjs +3 -0
- package/dist/cjs/Link.cjs.map +1 -0
- package/dist/cjs/LinkProvider.cjs +3 -0
- package/dist/cjs/LinkProvider.cjs.map +1 -0
- package/dist/cjs/helpers.cjs +2 -0
- package/dist/cjs/helpers.cjs.map +1 -0
- package/dist/cjs/index.cjs +1 -1
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/useLinkExtra.cjs +2 -0
- package/dist/cjs/useLinkExtra.cjs.map +1 -0
- package/dist/esm/Link.mjs +3 -0
- package/dist/esm/Link.mjs.map +1 -0
- package/dist/esm/LinkProvider.mjs +3 -0
- package/dist/esm/LinkProvider.mjs.map +1 -0
- package/dist/esm/helpers.mjs +2 -0
- package/dist/esm/helpers.mjs.map +1 -0
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/useLinkExtra.mjs +2 -0
- package/dist/esm/useLinkExtra.mjs.map +1 -0
- package/dist/production.analysis.txt +26 -26
- package/dist/types/cjs/Link.d.cts.map +1 -1
- package/dist/types/cjs/LinkProvider.d.cts +2 -2
- package/dist/types/cjs/LinkProvider.d.cts.map +1 -1
- package/dist/types/cjs/helpers.d.cts.map +1 -1
- package/dist/types/cjs/types.d.cts +3 -6
- package/dist/types/cjs/types.d.cts.map +1 -1
- package/dist/types/esm/Link.d.ts.map +1 -1
- package/dist/types/esm/LinkProvider.d.ts +2 -2
- package/dist/types/esm/LinkProvider.d.ts.map +1 -1
- package/dist/types/esm/helpers.d.ts.map +1 -1
- package/dist/types/esm/types.d.ts +3 -6
- package/dist/types/esm/types.d.ts.map +1 -1
- package/dist/types/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +13 -13
- package/src/Link.tsx +2 -0
- package/src/LinkProvider.tsx +26 -7
- package/src/helpers.ts +18 -19
- package/src/main.test.tsx +160 -0
- 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 (
|
|
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
|
|
2
|
-
import { type AnchorHTMLAttributes, type ComponentType
|
|
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:
|
|
9
|
+
NextLink: (typeof NextLink)['default'];
|
|
16
10
|
OwnLink: ComponentType<AnchorHTMLAttributes<HTMLAnchorElement>>;
|
|
17
11
|
onLinkClicked: () => void;
|
|
18
12
|
};
|