@bleedingdev/modern-js-plugin-tanstack 3.9.0-ultramodern.9 → 3.9.3-ultramodern.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/dist/cjs/cli/artifacts.js +15 -1
- package/dist/cjs/cli/index.js +13 -2
- package/dist/cjs/cli/routeMetadata.js +131 -0
- package/dist/cjs/cli/rscRouteLayer.js +86 -0
- package/dist/cjs/cli/tanstackTypes/canonicalRoutes.js +7 -7
- package/dist/cjs/cli/tanstackTypes/shared.js +0 -14
- package/dist/cjs/cli/tanstackTypes/source.js +28 -63
- package/dist/cjs/runtime/lifecycle.js +0 -3
- package/dist/cjs/runtime/loaderBridge.js +10 -4
- package/dist/cjs/runtime/navigation.js +102 -0
- package/dist/cjs/runtime/plugin.js +10 -1
- package/dist/cjs/runtime/plugin.node.js +89 -64
- package/dist/cjs/runtime/routeTree/construction.js +19 -61
- package/dist/cjs/runtime/routeTree/loaders.js +1 -1
- package/dist/cjs/runtime/routeTree/paths.js +0 -6
- package/dist/cjs/runtime/ssrManagedTags.js +2 -12
- package/dist/cjs/runtime/utils.js +1 -0
- package/dist/cjs/shared/routeDescriptor.js +60 -0
- package/dist/esm/cli/artifacts.mjs +13 -2
- package/dist/esm/cli/index.mjs +13 -11
- package/dist/esm/cli/routeMetadata.mjs +71 -0
- package/dist/esm/cli/rscRouteLayer.mjs +35 -0
- package/dist/esm/cli/tanstackTypes/canonicalRoutes.mjs +7 -7
- package/dist/esm/cli/tanstackTypes/shared.mjs +1 -9
- package/dist/esm/cli/tanstackTypes/source.mjs +29 -64
- package/dist/esm/runtime/lifecycle.mjs +1 -1
- package/dist/esm/runtime/loaderBridge.mjs +8 -5
- package/dist/esm/runtime/navigation.mjs +64 -0
- package/dist/esm/runtime/plugin.mjs +10 -1
- package/dist/esm/runtime/plugin.node.mjs +91 -66
- package/dist/esm/runtime/routeTree/construction.mjs +19 -61
- package/dist/esm/runtime/routeTree/loaders.mjs +2 -2
- package/dist/esm/runtime/routeTree/paths.mjs +1 -4
- package/dist/esm/runtime/ssrManagedTags.mjs +1 -8
- package/dist/esm/runtime/utils.mjs +1 -0
- package/dist/esm/shared/routeDescriptor.mjs +22 -0
- package/dist/esm-node/cli/artifacts.mjs +13 -2
- package/dist/esm-node/cli/index.mjs +13 -11
- package/dist/esm-node/cli/routeMetadata.mjs +72 -0
- package/dist/esm-node/cli/rscRouteLayer.mjs +36 -0
- package/dist/esm-node/cli/tanstackTypes/canonicalRoutes.mjs +7 -7
- package/dist/esm-node/cli/tanstackTypes/shared.mjs +1 -9
- package/dist/esm-node/cli/tanstackTypes/source.mjs +29 -64
- package/dist/esm-node/runtime/lifecycle.mjs +1 -1
- package/dist/esm-node/runtime/loaderBridge.mjs +8 -5
- package/dist/esm-node/runtime/navigation.mjs +65 -0
- package/dist/esm-node/runtime/plugin.mjs +10 -1
- package/dist/esm-node/runtime/plugin.node.mjs +91 -66
- package/dist/esm-node/runtime/routeTree/construction.mjs +19 -61
- package/dist/esm-node/runtime/routeTree/loaders.mjs +2 -2
- package/dist/esm-node/runtime/routeTree/paths.mjs +1 -4
- package/dist/esm-node/runtime/ssrManagedTags.mjs +1 -8
- package/dist/esm-node/runtime/utils.mjs +1 -0
- package/dist/esm-node/shared/routeDescriptor.mjs +23 -0
- package/dist/types/cli/artifacts.d.ts +16 -7
- package/dist/types/cli/index.d.ts +1 -1
- package/dist/types/cli/routeMetadata.d.ts +18 -0
- package/dist/types/cli/rscRouteLayer.d.ts +11 -0
- package/dist/types/cli/tanstackTypes/shared.d.ts +0 -2
- package/dist/types/cli/tanstackTypes/source.d.ts +3 -2
- package/dist/types/runtime/lifecycle.d.ts +1 -1
- package/dist/types/runtime/loaderBridge.d.ts +1 -0
- package/dist/types/runtime/navigation.d.ts +4 -0
- package/dist/types/runtime/pluginShared.d.ts +5 -0
- package/dist/types/runtime/routeTree/paths.d.ts +0 -2
- package/dist/types/runtime/ssrManagedTags.d.ts +0 -1
- package/dist/types/runtime/ssrTypes.d.ts +0 -7
- package/dist/types/shared/routeDescriptor.d.ts +26 -0
- package/package.json +32 -26
- package/src/cli/artifacts.ts +59 -8
- package/src/cli/index.ts +17 -4
- package/src/cli/routeMetadata.ts +121 -0
- package/src/cli/rscRouteLayer.ts +93 -0
- package/src/cli/tanstackTypes/canonicalRoutes.ts +17 -12
- package/src/cli/tanstackTypes/shared.ts +0 -14
- package/src/cli/tanstackTypes/source.ts +48 -120
- package/src/runtime/lifecycle.ts +0 -1
- package/src/runtime/loaderBridge.ts +9 -9
- package/src/runtime/navigation.ts +78 -0
- package/src/runtime/plugin.node.tsx +121 -91
- package/src/runtime/plugin.tsx +10 -0
- package/src/runtime/pluginShared.ts +12 -0
- package/src/runtime/routeTree/construction.ts +32 -112
- package/src/runtime/routeTree/loaders.ts +2 -4
- package/src/runtime/routeTree/paths.ts +0 -6
- package/src/runtime/ssrManagedTags.ts +0 -17
- package/src/runtime/ssrTypes.ts +0 -7
- package/src/runtime/utils.tsx +1 -0
- package/src/shared/routeDescriptor.ts +46 -0
- package/dist/esm/rslib-runtime~1.mjs +0 -18
- package/dist/esm-node/rslib-runtime~0.mjs +0 -19
- package/rslib.config.mts +0 -4
- package/rstest.config.mts +0 -53
- package/tests/router/cli.test.ts +0 -420
- package/tests/router/clientHydration.test.tsx +0 -200
- package/tests/router/dataMutation.test.tsx +0 -428
- package/tests/router/fixtures/router-gen.oxlint.json +0 -5
- package/tests/router/flightSerialization.roundtrip.test.tsx +0 -140
- package/tests/router/generateRouteArtifacts.test.ts +0 -146
- package/tests/router/loaderBridge.test.ts +0 -207
- package/tests/router/localisedIdentity.test.tsx +0 -95
- package/tests/router/prefetchLink.test.tsx +0 -122
- package/tests/router/prefetchLinkPreload.test.tsx +0 -65
- package/tests/router/routeHooks.test.ts +0 -49
- package/tests/router/routeTree.test.ts +0 -240
- package/tests/router/rsc.test.tsx +0 -480
- package/tests/router/serverPlugin.test.ts +0 -157
- package/tests/router/slotUsageSanitizer.test.ts +0 -23
- package/tests/router/ssrPreload.test.ts +0 -30
- package/tests/router/tanstackTypes.test.ts +0 -384
- package/tsconfig.json +0 -12
- package/tsconfig.tsgo.json +0 -6
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import type { AnyRouter } from '@tanstack/react-router';
|
|
2
|
-
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
-
import React, { Suspense } from 'react';
|
|
4
|
-
import { ModernRouterClient } from '../../src/runtime/clientHydration';
|
|
5
|
-
import { createTreeGetterFromFlightStream } from '../../src/runtime/rsc/treeGetter';
|
|
6
|
-
|
|
7
|
-
let hydrateImpl: () => Promise<unknown>;
|
|
8
|
-
let createFromReadableStreamImpl: () => Promise<unknown>;
|
|
9
|
-
let registeredCallServer: unknown;
|
|
10
|
-
let decoderCallServer: unknown;
|
|
11
|
-
|
|
12
|
-
rstest.mock('@tanstack/react-router', () => ({
|
|
13
|
-
RouterProvider: ({ router }: { router: { label: string } }) => (
|
|
14
|
-
<div data-testid="router-provider">{router.label}</div>
|
|
15
|
-
),
|
|
16
|
-
}));
|
|
17
|
-
|
|
18
|
-
rstest.mock('@tanstack/react-router/ssr/client', () => ({
|
|
19
|
-
hydrate: () => hydrateImpl(),
|
|
20
|
-
}));
|
|
21
|
-
|
|
22
|
-
rstest.mock('@modern-js/runtime/rsc/client', () => {
|
|
23
|
-
const callServer = () => undefined;
|
|
24
|
-
|
|
25
|
-
return {
|
|
26
|
-
callServer,
|
|
27
|
-
createFromReadableStream: (
|
|
28
|
-
_stream: ReadableStream<Uint8Array>,
|
|
29
|
-
options: { callServer: unknown },
|
|
30
|
-
) => {
|
|
31
|
-
decoderCallServer = options.callServer;
|
|
32
|
-
return createFromReadableStreamImpl();
|
|
33
|
-
},
|
|
34
|
-
setServerCallback: (callback: unknown) => {
|
|
35
|
-
registeredCallServer = callback;
|
|
36
|
-
},
|
|
37
|
-
};
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
function createDeferred<T>() {
|
|
41
|
-
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
42
|
-
let reject!: (reason?: unknown) => void;
|
|
43
|
-
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
44
|
-
resolve = resolvePromise;
|
|
45
|
-
reject = rejectPromise;
|
|
46
|
-
});
|
|
47
|
-
return { promise, reject, resolve };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function createRouter(label: string): AnyRouter {
|
|
51
|
-
return {
|
|
52
|
-
label,
|
|
53
|
-
routesById: {},
|
|
54
|
-
stores: {
|
|
55
|
-
matches: {
|
|
56
|
-
get: () => [],
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
} as unknown as AnyRouter;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function FlightTree({ getTree }: { getTree: () => unknown }) {
|
|
63
|
-
const tree = getTree();
|
|
64
|
-
if (
|
|
65
|
-
typeof tree !== 'object' ||
|
|
66
|
-
tree === null ||
|
|
67
|
-
!('label' in tree) ||
|
|
68
|
-
typeof tree.label !== 'string'
|
|
69
|
-
) {
|
|
70
|
-
throw new Error('Expected the decoded Flight tree to contain a label.');
|
|
71
|
-
}
|
|
72
|
-
return <div data-testid="flight-tree">{tree.label}</div>;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
class FlightErrorBoundary extends React.Component<
|
|
76
|
-
{ children: React.ReactNode; onError: (error: unknown) => void },
|
|
77
|
-
{ error: unknown }
|
|
78
|
-
> {
|
|
79
|
-
state = { error: undefined };
|
|
80
|
-
|
|
81
|
-
static getDerivedStateFromError(error: unknown) {
|
|
82
|
-
return { error };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
componentDidCatch(error: unknown) {
|
|
86
|
-
this.props.onError(error);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
render() {
|
|
90
|
-
if (this.state.error !== undefined) {
|
|
91
|
-
return <div data-testid="flight-error">failed</div>;
|
|
92
|
-
}
|
|
93
|
-
return this.props.children;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
describe('TanStack SSR client hydration', () => {
|
|
98
|
-
it('keeps the router behind Suspense until late SSR hydration completes', async () => {
|
|
99
|
-
const hydration = createDeferred<void>();
|
|
100
|
-
hydrateImpl = () => hydration.promise;
|
|
101
|
-
|
|
102
|
-
render(
|
|
103
|
-
<Suspense fallback={<div data-testid="hydrating">hydrating</div>}>
|
|
104
|
-
<ModernRouterClient router={createRouter('inventory')} />
|
|
105
|
-
</Suspense>,
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
expect(screen.getByTestId('hydrating')).toBeTruthy();
|
|
109
|
-
expect(screen.queryByTestId('router-provider')).toBeNull();
|
|
110
|
-
|
|
111
|
-
hydration.resolve();
|
|
112
|
-
|
|
113
|
-
expect(
|
|
114
|
-
(await screen.findByTestId('router-provider')).textContent,
|
|
115
|
-
).toContain('inventory');
|
|
116
|
-
expect(screen.queryByTestId('hydrating')).toBeNull();
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('reuses one Flight decode while suspended and returns the resolved tree synchronously', async () => {
|
|
120
|
-
const flight = createDeferred<unknown>();
|
|
121
|
-
const decodeFlight = rstest.fn(() => flight.promise);
|
|
122
|
-
createFromReadableStreamImpl = decodeFlight;
|
|
123
|
-
registeredCallServer = undefined;
|
|
124
|
-
decoderCallServer = undefined;
|
|
125
|
-
|
|
126
|
-
const getTree = createTreeGetterFromFlightStream(
|
|
127
|
-
new ReadableStream<Uint8Array>(),
|
|
128
|
-
);
|
|
129
|
-
const view = (
|
|
130
|
-
<Suspense fallback={<div data-testid="flight-loading">loading</div>}>
|
|
131
|
-
<FlightTree getTree={getTree} />
|
|
132
|
-
</Suspense>
|
|
133
|
-
);
|
|
134
|
-
const { rerender } = render(view);
|
|
135
|
-
|
|
136
|
-
expect(screen.getByTestId('flight-loading')).toBeTruthy();
|
|
137
|
-
await waitFor(() => expect(decodeFlight).toHaveBeenCalledTimes(1));
|
|
138
|
-
expect(registeredCallServer).toBe(decoderCallServer);
|
|
139
|
-
|
|
140
|
-
await act(() => rerender(view));
|
|
141
|
-
expect(screen.getByTestId('flight-loading')).toBeTruthy();
|
|
142
|
-
expect(decodeFlight).toHaveBeenCalledTimes(1);
|
|
143
|
-
|
|
144
|
-
const tree = { label: 'decoded inventory' };
|
|
145
|
-
await act(async () => {
|
|
146
|
-
flight.resolve(tree);
|
|
147
|
-
await flight.promise;
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
expect((await screen.findByTestId('flight-tree')).textContent).toBe(
|
|
151
|
-
tree.label,
|
|
152
|
-
);
|
|
153
|
-
expect(decodeFlight).toHaveBeenCalledTimes(1);
|
|
154
|
-
expect(getTree()).toBe(tree);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it('preserves the original Flight decode rejection for the error boundary', async () => {
|
|
158
|
-
const flight = createDeferred<unknown>();
|
|
159
|
-
const failure = new Error('Flight decode failed');
|
|
160
|
-
const decodeFlight = rstest.fn(() => flight.promise);
|
|
161
|
-
createFromReadableStreamImpl = decodeFlight;
|
|
162
|
-
let caughtError: unknown;
|
|
163
|
-
const errorLog = rstest
|
|
164
|
-
.spyOn(console, 'error')
|
|
165
|
-
.mockImplementation(() => undefined);
|
|
166
|
-
|
|
167
|
-
try {
|
|
168
|
-
const getTree = createTreeGetterFromFlightStream(
|
|
169
|
-
new ReadableStream<Uint8Array>(),
|
|
170
|
-
);
|
|
171
|
-
await act(() =>
|
|
172
|
-
render(
|
|
173
|
-
<FlightErrorBoundary
|
|
174
|
-
onError={error => {
|
|
175
|
-
caughtError = error;
|
|
176
|
-
}}
|
|
177
|
-
>
|
|
178
|
-
<Suspense
|
|
179
|
-
fallback={<div data-testid="flight-loading">loading</div>}
|
|
180
|
-
>
|
|
181
|
-
<FlightTree getTree={getTree} />
|
|
182
|
-
</Suspense>
|
|
183
|
-
</FlightErrorBoundary>,
|
|
184
|
-
),
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
expect(screen.getByTestId('flight-loading')).toBeTruthy();
|
|
188
|
-
await waitFor(() => expect(decodeFlight).toHaveBeenCalledTimes(1));
|
|
189
|
-
await act(async () => {
|
|
190
|
-
flight.reject(failure);
|
|
191
|
-
await flight.promise.catch(() => undefined);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
expect(await screen.findByTestId('flight-error')).toBeTruthy();
|
|
195
|
-
expect(caughtError).toBe(failure);
|
|
196
|
-
} finally {
|
|
197
|
-
errorLog.mockRestore();
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
});
|
|
@@ -1,428 +0,0 @@
|
|
|
1
|
-
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
-
import React, { act } from 'react';
|
|
3
|
-
import type { Fetcher } from '../../src/runtime/dataMutation';
|
|
4
|
-
import { Form, useFetcher } from '../../src/runtime/dataMutation';
|
|
5
|
-
import { createFormDataFromSubmit } from '../../src/runtime/formData';
|
|
6
|
-
|
|
7
|
-
type RouteHandler = (args: {
|
|
8
|
-
request: Request;
|
|
9
|
-
params: Record<string, string>;
|
|
10
|
-
context?: unknown;
|
|
11
|
-
}) => Promise<unknown> | unknown;
|
|
12
|
-
|
|
13
|
-
let currentRouter: any;
|
|
14
|
-
|
|
15
|
-
rstest.mock('@tanstack/react-router', () => ({
|
|
16
|
-
useRouter: () => currentRouter,
|
|
17
|
-
}));
|
|
18
|
-
|
|
19
|
-
function createDeferred<T>() {
|
|
20
|
-
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
21
|
-
let reject!: (reason?: unknown) => void;
|
|
22
|
-
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
23
|
-
resolve = resolvePromise;
|
|
24
|
-
reject = rejectPromise;
|
|
25
|
-
});
|
|
26
|
-
return {
|
|
27
|
-
promise,
|
|
28
|
-
resolve,
|
|
29
|
-
reject,
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function createRouter(handler: {
|
|
34
|
-
action?: RouteHandler;
|
|
35
|
-
loader?: RouteHandler;
|
|
36
|
-
invalidate?: () => Promise<void>;
|
|
37
|
-
params?: Record<string, string>;
|
|
38
|
-
}) {
|
|
39
|
-
return {
|
|
40
|
-
buildLocation: ({ to }: { to?: string }) => {
|
|
41
|
-
const href = typeof to === 'string' ? to : '/';
|
|
42
|
-
const url = new URL(href, 'http://localhost');
|
|
43
|
-
return {
|
|
44
|
-
href: `${url.pathname}${url.search}${url.hash}`,
|
|
45
|
-
pathname: url.pathname,
|
|
46
|
-
};
|
|
47
|
-
},
|
|
48
|
-
getMatchedRoutes: () => [
|
|
49
|
-
[],
|
|
50
|
-
handler.params || {},
|
|
51
|
-
{
|
|
52
|
-
options: {
|
|
53
|
-
staticData: {
|
|
54
|
-
modernRouteAction: handler.action,
|
|
55
|
-
modernRouteLoader: handler.loader,
|
|
56
|
-
},
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
],
|
|
60
|
-
navigate: rstest.fn(async () => undefined),
|
|
61
|
-
invalidate: rstest.fn(handler.invalidate || (async () => undefined)),
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function formatFetcherError(error: unknown) {
|
|
66
|
-
if (error instanceof Error) {
|
|
67
|
-
return error.message;
|
|
68
|
-
}
|
|
69
|
-
if (typeof error === 'string') {
|
|
70
|
-
return error;
|
|
71
|
-
}
|
|
72
|
-
if (error == null) {
|
|
73
|
-
return '';
|
|
74
|
-
}
|
|
75
|
-
return JSON.stringify(error);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
describe('tanstack data mutation fetcher', () => {
|
|
79
|
-
let latestFetcher: Fetcher | undefined;
|
|
80
|
-
const states: string[] = [];
|
|
81
|
-
|
|
82
|
-
function FetcherHarness() {
|
|
83
|
-
const fetcher = useFetcher();
|
|
84
|
-
latestFetcher = fetcher;
|
|
85
|
-
|
|
86
|
-
React.useEffect(() => {
|
|
87
|
-
states.push(fetcher.state);
|
|
88
|
-
}, [fetcher.state]);
|
|
89
|
-
|
|
90
|
-
return (
|
|
91
|
-
<div>
|
|
92
|
-
<div data-testid="state">{fetcher.state}</div>
|
|
93
|
-
<div data-testid="data">
|
|
94
|
-
{fetcher.data === undefined
|
|
95
|
-
? 'undefined'
|
|
96
|
-
: JSON.stringify(fetcher.data)}
|
|
97
|
-
</div>
|
|
98
|
-
<div data-testid="error">{formatFetcherError(fetcher.error)}</div>
|
|
99
|
-
</div>
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
beforeEach(() => {
|
|
104
|
-
latestFetcher = undefined;
|
|
105
|
-
states.length = 0;
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test.each([
|
|
109
|
-
{ amount: 2, count: 2, method: 'post' as const, name: 'explicit method' },
|
|
110
|
-
{ amount: 1, count: 1, method: undefined, name: 'default method' },
|
|
111
|
-
])('tracks submitting/loading phases with the $name', async ({
|
|
112
|
-
amount,
|
|
113
|
-
count,
|
|
114
|
-
method,
|
|
115
|
-
}) => {
|
|
116
|
-
const actionResult = createDeferred<Response>();
|
|
117
|
-
const invalidateResult = createDeferred<void>();
|
|
118
|
-
const action = rstest.fn(async () => actionResult.promise);
|
|
119
|
-
const loader = rstest.fn(async () => ({ count: 0 }));
|
|
120
|
-
|
|
121
|
-
currentRouter = createRouter({
|
|
122
|
-
action,
|
|
123
|
-
loader,
|
|
124
|
-
invalidate: async () => invalidateResult.promise,
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
render(<FetcherHarness />);
|
|
128
|
-
expect(screen.getByTestId('state').textContent).toBe('idle');
|
|
129
|
-
|
|
130
|
-
let submitPromise: Promise<void> | undefined;
|
|
131
|
-
act(() => {
|
|
132
|
-
submitPromise = latestFetcher!.submit(
|
|
133
|
-
{ amount },
|
|
134
|
-
{
|
|
135
|
-
...(method === undefined ? {} : { method }),
|
|
136
|
-
action: '/mutation',
|
|
137
|
-
},
|
|
138
|
-
);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
await waitFor(() => {
|
|
142
|
-
expect(screen.getByTestId('state').textContent).toBe('submitting');
|
|
143
|
-
});
|
|
144
|
-
expect(action).toHaveBeenCalledTimes(1);
|
|
145
|
-
expect(loader).not.toHaveBeenCalled();
|
|
146
|
-
|
|
147
|
-
actionResult.resolve(
|
|
148
|
-
new Response(JSON.stringify({ count }), {
|
|
149
|
-
headers: {
|
|
150
|
-
'Content-Type': 'application/json',
|
|
151
|
-
},
|
|
152
|
-
}),
|
|
153
|
-
);
|
|
154
|
-
|
|
155
|
-
await waitFor(() => {
|
|
156
|
-
expect(screen.getByTestId('state').textContent).toBe('loading');
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
invalidateResult.resolve();
|
|
160
|
-
|
|
161
|
-
await act(async () => {
|
|
162
|
-
await submitPromise;
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
expect(screen.getByTestId('state').textContent).toBe('idle');
|
|
166
|
-
expect(screen.getByTestId('data').textContent).toBe(
|
|
167
|
-
JSON.stringify({ count }),
|
|
168
|
-
);
|
|
169
|
-
expect(states).toEqual(['idle', 'submitting', 'loading', 'idle']);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
test('passes built href and matched route params to mutation actions', async () => {
|
|
173
|
-
const action = rstest.fn(
|
|
174
|
-
async ({ request, params }: Parameters<RouteHandler>[0]) => ({
|
|
175
|
-
params,
|
|
176
|
-
requestUrl: request.url,
|
|
177
|
-
}),
|
|
178
|
-
);
|
|
179
|
-
currentRouter = createRouter({
|
|
180
|
-
action,
|
|
181
|
-
params: { userId: '42' },
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
render(<FetcherHarness />);
|
|
185
|
-
|
|
186
|
-
await act(async () => {
|
|
187
|
-
await latestFetcher!.submit(
|
|
188
|
-
{ amount: 2 },
|
|
189
|
-
{
|
|
190
|
-
method: 'post',
|
|
191
|
-
action: '/users/42?tab=edit#details',
|
|
192
|
-
},
|
|
193
|
-
);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
expect(action).toHaveBeenCalledTimes(1);
|
|
197
|
-
const actionArgs = action.mock.calls[0][0];
|
|
198
|
-
const expectedRequestUrl = `${window.location.origin}/users/42?tab=edit#details`;
|
|
199
|
-
expect(actionArgs.params).toEqual({ userId: '42' });
|
|
200
|
-
expect(actionArgs.request.url).toBe(expectedRequestUrl);
|
|
201
|
-
expect(latestFetcher?.data).toEqual({
|
|
202
|
-
params: { userId: '42' },
|
|
203
|
-
requestUrl: expectedRequestUrl,
|
|
204
|
-
});
|
|
205
|
-
expect(currentRouter.invalidate).toHaveBeenCalledTimes(1);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
test('surfaces non-2xx action responses as fetcher errors', async () => {
|
|
209
|
-
const actionResult = createDeferred<Response>();
|
|
210
|
-
currentRouter = createRouter({
|
|
211
|
-
action: async () => actionResult.promise,
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
render(<FetcherHarness />);
|
|
215
|
-
|
|
216
|
-
let thrownError: unknown;
|
|
217
|
-
let submitPromise: Promise<void> | undefined;
|
|
218
|
-
act(() => {
|
|
219
|
-
submitPromise = latestFetcher!.submit(
|
|
220
|
-
{ amount: 'not-a-number' },
|
|
221
|
-
{ method: 'post', action: '/mutation' },
|
|
222
|
-
);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
await waitFor(() => {
|
|
226
|
-
expect(screen.getByTestId('state').textContent).toBe('submitting');
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
actionResult.resolve(
|
|
230
|
-
new Response(JSON.stringify({ message: 'invalid amount' }), {
|
|
231
|
-
status: 422,
|
|
232
|
-
headers: {
|
|
233
|
-
'Content-Type': 'application/json',
|
|
234
|
-
},
|
|
235
|
-
}),
|
|
236
|
-
);
|
|
237
|
-
|
|
238
|
-
await act(async () => {
|
|
239
|
-
try {
|
|
240
|
-
await submitPromise;
|
|
241
|
-
} catch (error) {
|
|
242
|
-
thrownError = error;
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
expect(thrownError).toBeInstanceOf(Error);
|
|
247
|
-
expect(screen.getByTestId('state').textContent).toBe('idle');
|
|
248
|
-
expect(screen.getByTestId('error').textContent).toContain('422');
|
|
249
|
-
expect(currentRouter.invalidate).not.toHaveBeenCalled();
|
|
250
|
-
expect(states).toEqual(['idle', 'submitting', 'idle']);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
test('uses loading state for loader fetches', async () => {
|
|
254
|
-
const loaderResult = createDeferred<{ count: number }>();
|
|
255
|
-
|
|
256
|
-
currentRouter = createRouter({
|
|
257
|
-
loader: async () => loaderResult.promise,
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
render(<FetcherHarness />);
|
|
261
|
-
|
|
262
|
-
let submitPromise: Promise<void> | undefined;
|
|
263
|
-
act(() => {
|
|
264
|
-
submitPromise = latestFetcher!.submit(
|
|
265
|
-
{},
|
|
266
|
-
{ method: 'get', action: '/mutation' },
|
|
267
|
-
);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
await waitFor(() => {
|
|
271
|
-
expect(screen.getByTestId('state').textContent).toBe('loading');
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
loaderResult.resolve({ count: 7 });
|
|
275
|
-
|
|
276
|
-
await act(async () => {
|
|
277
|
-
await submitPromise;
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
expect(screen.getByTestId('state').textContent).toBe('idle');
|
|
281
|
-
expect(screen.getByTestId('data').textContent).toBe('{"count":7}');
|
|
282
|
-
expect(currentRouter.invalidate).not.toHaveBeenCalled();
|
|
283
|
-
expect(states).toEqual(['idle', 'loading', 'idle']);
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
test('GET Form navigation replaces action query before hash', async () => {
|
|
287
|
-
currentRouter = createRouter({});
|
|
288
|
-
|
|
289
|
-
render(
|
|
290
|
-
<Form method="get" action="/products?sort=old#results">
|
|
291
|
-
<input name="q" defaultValue="boots" />
|
|
292
|
-
<button type="submit">Search</button>
|
|
293
|
-
</Form>,
|
|
294
|
-
);
|
|
295
|
-
|
|
296
|
-
const form = document.querySelector('form');
|
|
297
|
-
expect(form).toBeTruthy();
|
|
298
|
-
|
|
299
|
-
await act(async () => {
|
|
300
|
-
fireEvent.submit(form!);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
expect(currentRouter.navigate).toHaveBeenCalledWith({
|
|
304
|
-
href: '/products?q=boots#results',
|
|
305
|
-
});
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
test('keeps non-idle state while overlapping mutation submits are still active', async () => {
|
|
309
|
-
const actionResults = [
|
|
310
|
-
createDeferred<Response>(),
|
|
311
|
-
createDeferred<Response>(),
|
|
312
|
-
];
|
|
313
|
-
const invalidateResults = [createDeferred<void>(), createDeferred<void>()];
|
|
314
|
-
let actionCallIndex = 0;
|
|
315
|
-
let invalidateCallIndex = 0;
|
|
316
|
-
|
|
317
|
-
currentRouter = createRouter({
|
|
318
|
-
action: async () => {
|
|
319
|
-
const current = actionResults[actionCallIndex];
|
|
320
|
-
actionCallIndex += 1;
|
|
321
|
-
return current.promise;
|
|
322
|
-
},
|
|
323
|
-
invalidate: async () => {
|
|
324
|
-
const current = invalidateResults[invalidateCallIndex];
|
|
325
|
-
invalidateCallIndex += 1;
|
|
326
|
-
return current.promise;
|
|
327
|
-
},
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
render(<FetcherHarness />);
|
|
331
|
-
|
|
332
|
-
let firstSubmit: Promise<void> | undefined;
|
|
333
|
-
let secondSubmit: Promise<void> | undefined;
|
|
334
|
-
|
|
335
|
-
act(() => {
|
|
336
|
-
firstSubmit = latestFetcher!.submit(
|
|
337
|
-
{ amount: 1 },
|
|
338
|
-
{ method: 'post', action: '/mutation' },
|
|
339
|
-
);
|
|
340
|
-
});
|
|
341
|
-
await waitFor(() => {
|
|
342
|
-
expect(screen.getByTestId('state').textContent).toBe('submitting');
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
act(() => {
|
|
346
|
-
secondSubmit = latestFetcher!.submit(
|
|
347
|
-
{ amount: 2 },
|
|
348
|
-
{ method: 'post', action: '/mutation' },
|
|
349
|
-
);
|
|
350
|
-
});
|
|
351
|
-
await waitFor(() => {
|
|
352
|
-
expect(screen.getByTestId('state').textContent).toBe('submitting');
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
actionResults[0].resolve(
|
|
356
|
-
new Response(JSON.stringify({ count: 1 }), {
|
|
357
|
-
headers: {
|
|
358
|
-
'Content-Type': 'application/json',
|
|
359
|
-
},
|
|
360
|
-
}),
|
|
361
|
-
);
|
|
362
|
-
await waitFor(() => {
|
|
363
|
-
expect(currentRouter.invalidate).toHaveBeenCalledTimes(1);
|
|
364
|
-
expect(screen.getByTestId('state').textContent).toBe('submitting');
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
invalidateResults[0].resolve();
|
|
368
|
-
await waitFor(() => {
|
|
369
|
-
expect(screen.getByTestId('state').textContent).toBe('submitting');
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
actionResults[1].resolve(
|
|
373
|
-
new Response(JSON.stringify({ count: 3 }), {
|
|
374
|
-
headers: {
|
|
375
|
-
'Content-Type': 'application/json',
|
|
376
|
-
},
|
|
377
|
-
}),
|
|
378
|
-
);
|
|
379
|
-
await waitFor(() => {
|
|
380
|
-
expect(currentRouter.invalidate).toHaveBeenCalledTimes(2);
|
|
381
|
-
expect(screen.getByTestId('state').textContent).toBe('loading');
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
invalidateResults[1].resolve();
|
|
385
|
-
await act(async () => {
|
|
386
|
-
await Promise.all([firstSubmit, secondSubmit]);
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
expect(screen.getByTestId('state').textContent).toBe('idle');
|
|
390
|
-
expect(screen.getByTestId('data').textContent).toBe('{"count":3}');
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
test('preserves submitter name value when FormData submitter overload is unavailable', () => {
|
|
394
|
-
const NativeFormData = globalThis.FormData;
|
|
395
|
-
|
|
396
|
-
class SingleArgumentFormData extends NativeFormData {
|
|
397
|
-
constructor(form?: HTMLFormElement, submitter?: HTMLElement) {
|
|
398
|
-
if (submitter) {
|
|
399
|
-
throw new TypeError('FormData submitter overload unavailable');
|
|
400
|
-
}
|
|
401
|
-
super(form);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
globalThis.FormData = SingleArgumentFormData as typeof FormData;
|
|
406
|
-
|
|
407
|
-
try {
|
|
408
|
-
const form = document.createElement('form');
|
|
409
|
-
form.innerHTML = `
|
|
410
|
-
<input name="amount" value="2" />
|
|
411
|
-
<button type="submit" name="intent" value="save">Save</button>
|
|
412
|
-
`;
|
|
413
|
-
|
|
414
|
-
const submitter = form.querySelector('button');
|
|
415
|
-
expect(submitter).toBeInstanceOf(HTMLButtonElement);
|
|
416
|
-
|
|
417
|
-
const formData = createFormDataFromSubmit({
|
|
418
|
-
form,
|
|
419
|
-
submitter,
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
expect(formData.get('amount')).toBe('2');
|
|
423
|
-
expect(formData.get('intent')).toBe('save');
|
|
424
|
-
} finally {
|
|
425
|
-
globalThis.FormData = NativeFormData;
|
|
426
|
-
}
|
|
427
|
-
});
|
|
428
|
-
});
|