@bleedingdev/modern-js-plugin-tanstack 3.5.0-ultramodern.99 → 3.8.1-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/index.js +10 -2
- package/dist/cjs/cli/tanstackTypes/source.js +9 -20
- package/dist/cjs/runtime/plugin.node.js +12 -5
- package/dist/cjs/runtime/pluginShared.js +3 -3
- package/dist/cjs/runtime/rsc/payloadErrors.js +2 -2
- package/dist/cjs/runtime/rsc/shared.js +1 -1
- package/dist/cjs/runtime/rsc/treeGetter.js +5 -9
- package/dist/cjs/runtime/submitAction.js +4 -5
- package/dist/esm/cli/index.mjs +10 -2
- package/dist/esm/cli/tanstackTypes/source.mjs +9 -10
- package/dist/esm/runtime/plugin.node.mjs +12 -5
- package/dist/esm/runtime/pluginShared.mjs +3 -3
- package/dist/esm/runtime/rsc/payloadErrors.mjs +1 -1
- package/dist/esm/runtime/rsc/shared.mjs +1 -1
- package/dist/esm/runtime/rsc/treeGetter.mjs +5 -9
- package/dist/esm/runtime/submitAction.mjs +4 -5
- package/dist/esm-node/cli/index.mjs +10 -2
- package/dist/esm-node/cli/tanstackTypes/source.mjs +9 -10
- package/dist/esm-node/runtime/plugin.node.mjs +12 -5
- package/dist/esm-node/runtime/pluginShared.mjs +3 -3
- package/dist/esm-node/runtime/rsc/payloadErrors.mjs +1 -1
- package/dist/esm-node/runtime/rsc/shared.mjs +1 -1
- package/dist/esm-node/runtime/rsc/treeGetter.mjs +5 -9
- package/dist/esm-node/runtime/submitAction.mjs +4 -5
- package/dist/types/runtime/clientHydration.d.ts +1 -1
- package/dist/types/runtime/ssrTypes.d.ts +0 -2
- package/package.json +27 -20
- package/rstest.config.mts +2 -0
- package/src/cli/index.ts +12 -0
- package/src/cli/tanstackTypes/canonicalRoutes.ts +0 -1
- package/src/cli/tanstackTypes/shared.ts +0 -1
- package/src/cli/tanstackTypes/source.ts +3 -4
- package/src/runtime/plugin.node.tsx +17 -11
- package/src/runtime/plugin.tsx +0 -2
- package/src/runtime/pluginShared.ts +8 -7
- package/src/runtime/rsc/payloadErrors.ts +1 -1
- package/src/runtime/rsc/shared.ts +1 -1
- package/src/runtime/rsc/treeGetter.tsx +9 -8
- package/src/runtime/ssrManagedTags.ts +0 -2
- package/src/runtime/ssrTypes.ts +0 -4
- package/src/runtime/submitAction.ts +6 -11
- package/src/types/react-server-dom-rspack.d.ts +4 -52
- package/tests/router/cli.test.ts +204 -96
- package/tests/router/clientHydration.test.tsx +140 -1
- package/tests/router/dataMutation.test.tsx +50 -9
- package/tests/router/fixtures/rscDecoderSecurityChild.cjs +162 -0
- package/tests/router/preloadRedirect.test.ts +3 -8
- package/tests/router/routeTree.test.ts +73 -19
- package/tests/router/routerCompatibility.test.ts +0 -6
- package/tests/router/rscDecoderSecurity.test.ts +113 -0
- package/tests/router/serverPlugin.test.ts +115 -0
- package/tests/router/tanstackTypes.test.ts +668 -356
- package/src/runtime/ssr-shim.d.ts +0 -10
- package/tests/castRatchet.test.ts +0 -85
- package/tests/router/fixtures/router-gen.golden.txt +0 -71
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
|
|
3
|
+
const scenario = process.argv[2];
|
|
4
|
+
const serverPath = process.argv[3];
|
|
5
|
+
const clientPath = process.argv[4];
|
|
6
|
+
let moduleLoads = 0;
|
|
7
|
+
|
|
8
|
+
global.__webpack_chunk_load__ = async () => {};
|
|
9
|
+
global.__webpack_require__ = () => {
|
|
10
|
+
moduleLoads += 1;
|
|
11
|
+
return {
|
|
12
|
+
action(formData) {
|
|
13
|
+
return formData;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
global.__rspack_rsc_hot_reloader__ = { on: () => () => {} };
|
|
18
|
+
global.__rspack_rsc_manifest__ = {
|
|
19
|
+
clientManifest: {},
|
|
20
|
+
entryCssFiles: {},
|
|
21
|
+
entryJsFiles: [],
|
|
22
|
+
moduleLoading: { prefix: '', crossOrigin: '' },
|
|
23
|
+
serverConsumerModuleMap: {},
|
|
24
|
+
serverManifest: Object.fromEntries(
|
|
25
|
+
Array.from({ length: 8 }, (_, index) => [
|
|
26
|
+
`action-${index}`,
|
|
27
|
+
{ id: 'server-module', chunks: [], name: 'action' },
|
|
28
|
+
]),
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let mapArrayConstructions = 0;
|
|
33
|
+
if (scenario === 'cycle') {
|
|
34
|
+
const NativeMap = Map;
|
|
35
|
+
global.Map = class CountedMap extends NativeMap {
|
|
36
|
+
constructor(iterable) {
|
|
37
|
+
if (Array.isArray(iterable)) {
|
|
38
|
+
mapArrayConstructions += 1;
|
|
39
|
+
}
|
|
40
|
+
super(iterable);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const server = require(path.resolve(serverPath));
|
|
46
|
+
|
|
47
|
+
function output(value) {
|
|
48
|
+
process.stdout.write(JSON.stringify(value), () => process.exit(0));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function main() {
|
|
52
|
+
switch (scenario) {
|
|
53
|
+
case 'cycle': {
|
|
54
|
+
const body = new FormData();
|
|
55
|
+
body.set(
|
|
56
|
+
'0',
|
|
57
|
+
JSON.stringify([
|
|
58
|
+
...Array.from({ length: 8 }, (_, index) => [`key-${index}`, index]),
|
|
59
|
+
...Array.from({ length: 8 }, () => '$Q0'),
|
|
60
|
+
]),
|
|
61
|
+
);
|
|
62
|
+
let error;
|
|
63
|
+
try {
|
|
64
|
+
await server.decodeReply(body);
|
|
65
|
+
} catch (caught) {
|
|
66
|
+
error = caught;
|
|
67
|
+
}
|
|
68
|
+
output({ mapArrayConstructions, rejected: error instanceof Error });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
case 'referenced-form-data': {
|
|
72
|
+
const body = new FormData();
|
|
73
|
+
body.append('_1_item', 'first');
|
|
74
|
+
body.append('_2_item', 'second');
|
|
75
|
+
body.append('0', JSON.stringify(['$K1', '$K2']));
|
|
76
|
+
let keyIterations = 0;
|
|
77
|
+
const originalKeys = body.keys.bind(body);
|
|
78
|
+
body.keys = () => {
|
|
79
|
+
keyIterations += 1;
|
|
80
|
+
return originalKeys();
|
|
81
|
+
};
|
|
82
|
+
const decoded = await server.decodeReply(body);
|
|
83
|
+
output({
|
|
84
|
+
keyIterations,
|
|
85
|
+
values: decoded.map(value => value.get('item')),
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
case 'blob-type': {
|
|
90
|
+
const body = new FormData();
|
|
91
|
+
body.set('1', 'not-a-blob');
|
|
92
|
+
body.set('0', JSON.stringify('$B1'));
|
|
93
|
+
let error;
|
|
94
|
+
try {
|
|
95
|
+
await server.decodeReply(body);
|
|
96
|
+
} catch (caught) {
|
|
97
|
+
error = caught;
|
|
98
|
+
}
|
|
99
|
+
output({
|
|
100
|
+
rejected: error instanceof Error,
|
|
101
|
+
returnedString: error === undefined,
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
case 'async-iterator': {
|
|
106
|
+
let throwCalls = 0;
|
|
107
|
+
const reason = new Error('iterator failed');
|
|
108
|
+
const iterator = {
|
|
109
|
+
next: () => Promise.reject(reason),
|
|
110
|
+
throw: () => {
|
|
111
|
+
throwCalls += 1;
|
|
112
|
+
return throwCalls < 6
|
|
113
|
+
? Promise.reject(reason)
|
|
114
|
+
: {
|
|
115
|
+
// biome-ignore lint/suspicious/noThenProperty: The regression requires a deliberately pending thenable to bound the vulnerable recursion.
|
|
116
|
+
then: () => {},
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
const iterable = { [Symbol.asyncIterator]: () => iterator };
|
|
121
|
+
try {
|
|
122
|
+
await server.decodeReplyFromAsyncIterable(iterable);
|
|
123
|
+
} catch {}
|
|
124
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
125
|
+
output({ throwCalls });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
case 'action-load': {
|
|
129
|
+
const body = new FormData();
|
|
130
|
+
for (let index = 0; index < 8; index += 1) {
|
|
131
|
+
body.append(`$ACTION_ID_action-${index}`, '');
|
|
132
|
+
}
|
|
133
|
+
const action = await server.decodeAction(body);
|
|
134
|
+
if (action === null) {
|
|
135
|
+
throw new Error('Expected an action');
|
|
136
|
+
}
|
|
137
|
+
const submitted = await action();
|
|
138
|
+
output({
|
|
139
|
+
moduleLoads,
|
|
140
|
+
submittedEntries: Array.from(submitted.entries()),
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
case 'form-data-roundtrip': {
|
|
145
|
+
const client = require(path.resolve(clientPath));
|
|
146
|
+
const nested = new FormData();
|
|
147
|
+
nested.append('item', 'first');
|
|
148
|
+
nested.append('item', 'second');
|
|
149
|
+
const encoded = await client.encodeReply({ nested });
|
|
150
|
+
const decoded = await server.decodeReply(encoded);
|
|
151
|
+
output({ values: decoded.nested.getAll('item') });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
default:
|
|
155
|
+
throw new Error(`Unknown scenario: ${scenario}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
main().catch(error => {
|
|
160
|
+
console.error(error);
|
|
161
|
+
process.exitCode = 1;
|
|
162
|
+
});
|
|
@@ -11,7 +11,7 @@ describe('tanstack router-core preload redirects', () => {
|
|
|
11
11
|
rstest.restoreAllMocks();
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
test('
|
|
14
|
+
test('resolves concurrent preload redirects without stale loader work', async () => {
|
|
15
15
|
const consoleErrorSpy = rstest
|
|
16
16
|
.spyOn(console, 'error')
|
|
17
17
|
.mockImplementation(() => {});
|
|
@@ -58,12 +58,7 @@ describe('tanstack router-core preload redirects', () => {
|
|
|
58
58
|
|
|
59
59
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
60
60
|
expect(router.options.defaultStructuralSharing).toBe(true);
|
|
61
|
-
expect(redirectLoaderCalls).
|
|
62
|
-
expect(targetLoader).
|
|
63
|
-
expect(
|
|
64
|
-
router.stores.cachedMatches
|
|
65
|
-
.get()
|
|
66
|
-
.some(match => match.status === 'redirected'),
|
|
67
|
-
).toBe(false);
|
|
61
|
+
expect(redirectLoaderCalls).toBe(1);
|
|
62
|
+
expect(targetLoader).toHaveBeenCalledTimes(1);
|
|
68
63
|
});
|
|
69
64
|
});
|
|
@@ -3,7 +3,12 @@ import path from 'node:path';
|
|
|
3
3
|
import type { RouteObject } from '@modern-js/runtime-utils/router';
|
|
4
4
|
import type { NestedRoute } from '@modern-js/types';
|
|
5
5
|
import { createMemoryHistory } from '@tanstack/history';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
createRouter,
|
|
8
|
+
Outlet,
|
|
9
|
+
RouterProvider,
|
|
10
|
+
redirect,
|
|
11
|
+
} from '@tanstack/react-router';
|
|
7
12
|
import type { ComponentType } from 'react';
|
|
8
13
|
import { createElement, lazy } from 'react';
|
|
9
14
|
import { renderToStaticMarkup, renderToString } from 'react-dom/server';
|
|
@@ -77,15 +82,23 @@ type PreloadableTestComponent = {
|
|
|
77
82
|
};
|
|
78
83
|
|
|
79
84
|
type TestRouter = {
|
|
85
|
+
_serverResult?:
|
|
86
|
+
| {
|
|
87
|
+
type: 'redirect';
|
|
88
|
+
redirect: Response;
|
|
89
|
+
}
|
|
90
|
+
| {
|
|
91
|
+
type: 'render';
|
|
92
|
+
status: 200 | 404 | 500;
|
|
93
|
+
};
|
|
80
94
|
load: () => Promise<void>;
|
|
81
|
-
|
|
95
|
+
routesById: Partial<Record<string, TestRoute>>;
|
|
82
96
|
state: {
|
|
83
97
|
matches: Array<{
|
|
84
98
|
error?: unknown;
|
|
85
99
|
loaderData?: unknown;
|
|
86
100
|
routeId: string;
|
|
87
101
|
}>;
|
|
88
|
-
statusCode?: number;
|
|
89
102
|
};
|
|
90
103
|
};
|
|
91
104
|
|
|
@@ -110,19 +123,19 @@ async function loadRouteTree(
|
|
|
110
123
|
return router as unknown as TestRouter;
|
|
111
124
|
}
|
|
112
125
|
|
|
113
|
-
function
|
|
114
|
-
const route = router.
|
|
126
|
+
function getRoute(router: TestRouter, id: string): TestRoute {
|
|
127
|
+
const route = router.routesById[id];
|
|
115
128
|
if (!route) {
|
|
116
129
|
throw new Error(`Expected TanStack route ${id} to exist`);
|
|
117
130
|
}
|
|
118
131
|
return route;
|
|
119
132
|
}
|
|
120
133
|
|
|
121
|
-
function
|
|
134
|
+
function getRouteByModernRouteId(
|
|
122
135
|
router: TestRouter,
|
|
123
136
|
modernRouteId: string,
|
|
124
137
|
): TestRoute {
|
|
125
|
-
const route = Object.values(router.
|
|
138
|
+
const route = Object.values(router.routesById).find(
|
|
126
139
|
route => route?.options.staticData?.modernRouteId === modernRouteId,
|
|
127
140
|
);
|
|
128
141
|
if (!route) {
|
|
@@ -255,7 +268,47 @@ describe('tanstack route tree from RouteObject[]', () => {
|
|
|
255
268
|
const routeTree = createRouteTreeFromRouteObjects(routes);
|
|
256
269
|
const router = await loadRouteTree(routeTree, '/missing');
|
|
257
270
|
|
|
258
|
-
expect(router.
|
|
271
|
+
expect(router._serverResult).toMatchObject({
|
|
272
|
+
type: 'render',
|
|
273
|
+
status: 404,
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('reports native TanStack loader redirects as server responses', async () => {
|
|
278
|
+
const routes: RouteObject[] = [
|
|
279
|
+
{
|
|
280
|
+
id: 'root',
|
|
281
|
+
path: '/',
|
|
282
|
+
Component: () => null,
|
|
283
|
+
children: [
|
|
284
|
+
{
|
|
285
|
+
id: 'redirect',
|
|
286
|
+
path: 'redirect',
|
|
287
|
+
loader: () => {
|
|
288
|
+
throw redirect({ to: '/target' });
|
|
289
|
+
},
|
|
290
|
+
Component: () => null,
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: 'target',
|
|
294
|
+
path: 'target',
|
|
295
|
+
Component: () => null,
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
const routeTree = createRouteTreeFromRouteObjects(routes);
|
|
302
|
+
const router = await loadRouteTree(routeTree, '/redirect');
|
|
303
|
+
|
|
304
|
+
expect(router._serverResult?.type).toBe('redirect');
|
|
305
|
+
if (router._serverResult?.type !== 'redirect') {
|
|
306
|
+
throw new Error('Expected a TanStack redirect server result');
|
|
307
|
+
}
|
|
308
|
+
expect(router._serverResult.redirect.status).toBe(307);
|
|
309
|
+
expect(router._serverResult.redirect.headers.get('Location')).toBe(
|
|
310
|
+
'/target',
|
|
311
|
+
);
|
|
259
312
|
});
|
|
260
313
|
|
|
261
314
|
test('does not force Suspense wrappers for ordinary generated routes', async () => {
|
|
@@ -278,9 +331,7 @@ describe('tanstack route tree from RouteObject[]', () => {
|
|
|
278
331
|
const router = await loadRouteTree(routeTree, '/plain');
|
|
279
332
|
|
|
280
333
|
expect(routeTree.options.wrapInSuspense).toBeUndefined();
|
|
281
|
-
expect(
|
|
282
|
-
getLooseRoute(router, '/plain').options.wrapInSuspense,
|
|
283
|
-
).toBeUndefined();
|
|
334
|
+
expect(getRoute(router, '/plain').options.wrapInSuspense).toBeUndefined();
|
|
284
335
|
});
|
|
285
336
|
|
|
286
337
|
test('renders Modern Outlet through TanStack native outlet', async () => {
|
|
@@ -395,7 +446,7 @@ describe('tanstack route tree from RouteObject[]', () => {
|
|
|
395
446
|
}),
|
|
396
447
|
context: {},
|
|
397
448
|
}) as unknown as TestRouter;
|
|
398
|
-
const searchRoute =
|
|
449
|
+
const searchRoute = getRoute(router, '/search');
|
|
399
450
|
|
|
400
451
|
expect(routeTree.options.validateSearch).toBe(rootValidateSearch);
|
|
401
452
|
expect(routeTree.options.loaderDeps).toBe(rootLoaderDeps);
|
|
@@ -516,7 +567,7 @@ describe('tanstack route tree from RouteObject[]', () => {
|
|
|
516
567
|
|
|
517
568
|
const routeTree = createRouteTreeFromRouteObjects(routes);
|
|
518
569
|
const router = await loadRouteTree(routeTree, '/lazy');
|
|
519
|
-
const lazyRoute =
|
|
570
|
+
const lazyRoute = getRoute(router, '/lazy');
|
|
520
571
|
const component = lazyRoute.options.component as ComponentType & {
|
|
521
572
|
preload?: () => Promise<unknown>;
|
|
522
573
|
};
|
|
@@ -550,7 +601,7 @@ describe('tanstack route tree from RouteObject[]', () => {
|
|
|
550
601
|
|
|
551
602
|
const routeTree = createRouteTreeFromRouteObjects(routes);
|
|
552
603
|
const router = await loadRouteTree(routeTree, '/lazy');
|
|
553
|
-
const lazyRoute =
|
|
604
|
+
const lazyRoute = getRoute(router, '/lazy');
|
|
554
605
|
const lazyComponent = lazyRoute.options
|
|
555
606
|
.component as PreloadableTestComponent;
|
|
556
607
|
|
|
@@ -586,7 +637,7 @@ describe('tanstack route tree from RouteObject[]', () => {
|
|
|
586
637
|
|
|
587
638
|
const routeTree = createRouteTreeFromRouteObjects(routes);
|
|
588
639
|
const router = await loadRouteTree(routeTree, '/load-only');
|
|
589
|
-
const loadOnlyRoute =
|
|
640
|
+
const loadOnlyRoute = getRoute(router, '/load-only');
|
|
590
641
|
const loadOnlyComponent = loadOnlyRoute.options
|
|
591
642
|
.component as PreloadableTestComponent;
|
|
592
643
|
|
|
@@ -665,7 +716,7 @@ describe('tanstack route tree from RouteObject[]', () => {
|
|
|
665
716
|
|
|
666
717
|
const routeTree = createRouteTreeFromRouteObjects(routes);
|
|
667
718
|
const router = await loadRouteTree(routeTree, '/user/123');
|
|
668
|
-
const userRoute =
|
|
719
|
+
const userRoute = getRoute(router, '/user/$id');
|
|
669
720
|
|
|
670
721
|
expect(routeTree.options.staticData.modernRouteHandle).toEqual({
|
|
671
722
|
shell: true,
|
|
@@ -718,7 +769,7 @@ describe('tanstack route tree from RouteObject[]', () => {
|
|
|
718
769
|
|
|
719
770
|
const routeTree = createRouteTreeFromRouteObjects(routes);
|
|
720
771
|
const router = await loadRouteTree(routeTree, '/client');
|
|
721
|
-
const clientRoute =
|
|
772
|
+
const clientRoute = getRoute(router, '/client');
|
|
722
773
|
|
|
723
774
|
expect(clientRoute.options.ssr).toBe(false);
|
|
724
775
|
expect(clientRoute.options.staticData).toMatchObject({
|
|
@@ -789,7 +840,10 @@ describe('tanstack route tree from RouteObject[]', () => {
|
|
|
789
840
|
match => match.routeId === '/broken',
|
|
790
841
|
);
|
|
791
842
|
|
|
792
|
-
expect(router.
|
|
843
|
+
expect(router._serverResult).toMatchObject({
|
|
844
|
+
type: 'render',
|
|
845
|
+
status: 200,
|
|
846
|
+
});
|
|
793
847
|
expect(brokenMatch?.loaderData).toBe(response);
|
|
794
848
|
expect(brokenMatch?.error).toBeUndefined();
|
|
795
849
|
});
|
|
@@ -829,7 +883,7 @@ describe('tanstack route tree from RouteObject[]', () => {
|
|
|
829
883
|
}),
|
|
830
884
|
context: {},
|
|
831
885
|
}) as unknown as TestRouter;
|
|
832
|
-
const clientRoute =
|
|
886
|
+
const clientRoute = getRouteByModernRouteId(router, 'client');
|
|
833
887
|
|
|
834
888
|
expect(clientRoute.options.ssr).toBe(false);
|
|
835
889
|
expect(clientRoute.options.staticData).toMatchObject({
|
|
@@ -199,12 +199,6 @@ describe('runtime router compatibility', () => {
|
|
|
199
199
|
runTsgo(tsgoBin, path.join(tempRoot, 'emit.json'));
|
|
200
200
|
|
|
201
201
|
const declarationPath = path.join(emittedRoot, 'router.d.ts');
|
|
202
|
-
const declaration = readFileSync(declarationPath, 'utf8');
|
|
203
|
-
expect(declaration).not.toMatch(
|
|
204
|
-
/\b(?:createRouterPlugin|RouterPluginFactory)\b/u,
|
|
205
|
-
);
|
|
206
|
-
expect(declaration).toContain("['routerPlugin']");
|
|
207
|
-
|
|
208
202
|
writeFileSync(
|
|
209
203
|
path.join(tempRoot, 'legacyRuntimeRouter.ts'),
|
|
210
204
|
readFileSync(legacyFixturePath, 'utf8'),
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
type DecoderScenario =
|
|
6
|
+
| 'action-load'
|
|
7
|
+
| 'async-iterator'
|
|
8
|
+
| 'blob-type'
|
|
9
|
+
| 'cycle'
|
|
10
|
+
| 'form-data-roundtrip'
|
|
11
|
+
| 'referenced-form-data';
|
|
12
|
+
|
|
13
|
+
const resolvePackage = createRequire(
|
|
14
|
+
path.join(__dirname, 'rsc-decoder-security-resolver.cjs'),
|
|
15
|
+
).resolve;
|
|
16
|
+
const childPath = path.join(
|
|
17
|
+
__dirname,
|
|
18
|
+
'fixtures',
|
|
19
|
+
'rscDecoderSecurityChild.cjs',
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
function runDecoderScenario(options: {
|
|
23
|
+
entry: 'edge' | 'node';
|
|
24
|
+
nodeEnv: 'development' | 'production';
|
|
25
|
+
scenario: DecoderScenario;
|
|
26
|
+
}): Record<string, unknown> {
|
|
27
|
+
const result = spawnSync(
|
|
28
|
+
process.execPath,
|
|
29
|
+
[
|
|
30
|
+
'--conditions=react-server',
|
|
31
|
+
childPath,
|
|
32
|
+
options.scenario,
|
|
33
|
+
resolvePackage(`react-server-dom-rspack/server.${options.entry}`),
|
|
34
|
+
resolvePackage(`react-server-dom-rspack/client.${options.entry}`),
|
|
35
|
+
],
|
|
36
|
+
{
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
env: { ...process.env, NODE_ENV: options.nodeEnv },
|
|
39
|
+
timeout: 5_000,
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (result.error || result.status !== 0) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
[
|
|
46
|
+
`RSC decoder child failed for ${options.entry}/${options.nodeEnv}/${options.scenario}`,
|
|
47
|
+
result.stdout,
|
|
48
|
+
result.stderr,
|
|
49
|
+
]
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.join('\n'),
|
|
52
|
+
{ cause: result.error },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return JSON.parse(result.stdout) as Record<string, unknown>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const entry of ['node', 'edge'] as const) {
|
|
60
|
+
for (const nodeEnv of ['development', 'production'] as const) {
|
|
61
|
+
describe(`${entry} ${nodeEnv} Flight decoder security`, () => {
|
|
62
|
+
test('bounds cyclic Map reconstruction before invoking constructors', () => {
|
|
63
|
+
expect(
|
|
64
|
+
runDecoderScenario({ entry, nodeEnv, scenario: 'cycle' }),
|
|
65
|
+
).toEqual({
|
|
66
|
+
mapArrayConstructions: 2,
|
|
67
|
+
rejected: true,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('decodes referenced FormData with one backing-store enumeration', () => {
|
|
72
|
+
expect(
|
|
73
|
+
runDecoderScenario({
|
|
74
|
+
entry,
|
|
75
|
+
nodeEnv,
|
|
76
|
+
scenario: 'referenced-form-data',
|
|
77
|
+
}),
|
|
78
|
+
).toEqual({ keyIterations: 1, values: ['first', 'second'] });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('round-trips referenced FormData through the public client and server APIs', () => {
|
|
82
|
+
expect(
|
|
83
|
+
runDecoderScenario({
|
|
84
|
+
entry,
|
|
85
|
+
nodeEnv,
|
|
86
|
+
scenario: 'form-data-roundtrip',
|
|
87
|
+
}),
|
|
88
|
+
).toEqual({ values: ['first', 'second'] });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('rejects a Blob reference backed by a string', () => {
|
|
92
|
+
expect(
|
|
93
|
+
runDecoderScenario({ entry, nodeEnv, scenario: 'blob-type' }),
|
|
94
|
+
).toEqual({
|
|
95
|
+
rejected: true,
|
|
96
|
+
returnedString: false,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('does not recursively throw into a rejecting async iterator', () => {
|
|
101
|
+
expect(
|
|
102
|
+
runDecoderScenario({ entry, nodeEnv, scenario: 'async-iterator' }),
|
|
103
|
+
).toEqual({ throwCalls: 1 });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('loads one server module for eight distinct action identifiers', () => {
|
|
107
|
+
expect(
|
|
108
|
+
runDecoderScenario({ entry, nodeEnv, scenario: 'action-load' }),
|
|
109
|
+
).toEqual({ moduleLoads: 1, submittedEntries: [] });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { TInternalRuntimeContext } from '@modern-js/runtime/context';
|
|
2
|
+
import { routerProviderRegistryHooks } from '@modern-js/runtime/context';
|
|
3
|
+
import { getRouterServerSnapshot } from '../../src/runtime/lifecycle';
|
|
4
|
+
import { tanstackRouterPlugin } from '../../src/runtime/plugin.node';
|
|
5
|
+
import type { TanstackRouterPluginAPI } from '../../src/runtime/pluginShared';
|
|
6
|
+
import type { RouterConfig } from '../../src/runtime/types';
|
|
7
|
+
|
|
8
|
+
type BeforeRenderListener = Parameters<
|
|
9
|
+
TanstackRouterPluginAPI['onBeforeRender']
|
|
10
|
+
>[0];
|
|
11
|
+
|
|
12
|
+
function collectBeforeRender(
|
|
13
|
+
createRoutes: NonNullable<RouterConfig['createRoutes']>,
|
|
14
|
+
) {
|
|
15
|
+
let listener: BeforeRenderListener | undefined;
|
|
16
|
+
tanstackRouterPlugin({ createRoutes }).setup?.({
|
|
17
|
+
getRuntimeConfig: () => ({}),
|
|
18
|
+
getHooks: () => routerProviderRegistryHooks,
|
|
19
|
+
onBeforeRender: nextListener => {
|
|
20
|
+
listener = nextListener;
|
|
21
|
+
},
|
|
22
|
+
wrapRoot: () => {},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (!listener) {
|
|
26
|
+
throw new Error('Expected the TanStack server plugin to register a hook');
|
|
27
|
+
}
|
|
28
|
+
return listener;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createServerContext(pathname: string) {
|
|
32
|
+
const status = rstest.fn();
|
|
33
|
+
const context = {
|
|
34
|
+
ssrContext: {
|
|
35
|
+
baseUrl: '/',
|
|
36
|
+
loaderContext: {},
|
|
37
|
+
mode: 'string',
|
|
38
|
+
request: {
|
|
39
|
+
raw: new Request(`http://localhost${pathname}`),
|
|
40
|
+
},
|
|
41
|
+
response: { status },
|
|
42
|
+
},
|
|
43
|
+
} as unknown as TInternalRuntimeContext;
|
|
44
|
+
|
|
45
|
+
return { context, status };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('tanstack server plugin router results', () => {
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
rstest.restoreAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('uses the router render result as the HTTP and hydration status', async () => {
|
|
54
|
+
const beforeRender = collectBeforeRender(() => [
|
|
55
|
+
{
|
|
56
|
+
id: 'root',
|
|
57
|
+
path: '/',
|
|
58
|
+
Component: () => null,
|
|
59
|
+
children: [
|
|
60
|
+
{
|
|
61
|
+
id: 'target',
|
|
62
|
+
path: 'target',
|
|
63
|
+
Component: () => null,
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
68
|
+
const { context, status } = createServerContext('/target');
|
|
69
|
+
|
|
70
|
+
await beforeRender(context, value => value);
|
|
71
|
+
|
|
72
|
+
expect(status).toHaveBeenCalledWith(200);
|
|
73
|
+
expect(getRouterServerSnapshot(context)).toMatchObject({
|
|
74
|
+
framework: 'tanstack',
|
|
75
|
+
statusCode: 200,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('interrupts SSR with the router redirect response', async () => {
|
|
80
|
+
const beforeRender = collectBeforeRender(() => [
|
|
81
|
+
{
|
|
82
|
+
id: 'root',
|
|
83
|
+
path: '/',
|
|
84
|
+
Component: () => null,
|
|
85
|
+
children: [
|
|
86
|
+
{
|
|
87
|
+
id: 'redirect',
|
|
88
|
+
path: 'redirect',
|
|
89
|
+
loader: () =>
|
|
90
|
+
new Response(null, {
|
|
91
|
+
status: 307,
|
|
92
|
+
headers: { Location: '/target' },
|
|
93
|
+
}),
|
|
94
|
+
Component: () => null,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'target',
|
|
98
|
+
path: 'target',
|
|
99
|
+
Component: () => null,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
]);
|
|
104
|
+
const { context, status } = createServerContext('/redirect');
|
|
105
|
+
const interrupt = rstest.fn((value: unknown) => value);
|
|
106
|
+
|
|
107
|
+
const response = await beforeRender(context, interrupt);
|
|
108
|
+
|
|
109
|
+
expect(response).toBeInstanceOf(Response);
|
|
110
|
+
expect((response as Response).status).toBe(307);
|
|
111
|
+
expect((response as Response).headers.get('Location')).toBe('/target');
|
|
112
|
+
expect(interrupt).toHaveBeenCalledWith(response);
|
|
113
|
+
expect(status).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
});
|