@bleedingdev/modern-js-plugin-tanstack 3.9.0-ultramodern.2 → 3.9.0-ultramodern.21

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 (137) hide show
  1. package/dist/cjs/cli/artifacts.js +15 -1
  2. package/dist/cjs/cli/index.js +12 -0
  3. package/dist/cjs/cli/routeMetadata.js +131 -0
  4. package/dist/cjs/cli/tanstackTypes/canonicalRoutes.js +7 -7
  5. package/dist/cjs/cli/tanstackTypes/shared.js +0 -14
  6. package/dist/cjs/cli/tanstackTypes/source.js +27 -60
  7. package/dist/cjs/runtime/basepathRewrite.js +13 -6
  8. package/dist/cjs/runtime/lifecycle.js +6 -9
  9. package/dist/cjs/runtime/loaderBridge.js +10 -4
  10. package/dist/cjs/runtime/navigation.js +102 -0
  11. package/dist/cjs/runtime/plugin.js +7 -2
  12. package/dist/cjs/runtime/plugin.node.js +86 -64
  13. package/dist/cjs/runtime/prefetchLink.js +2 -7
  14. package/dist/cjs/runtime/register.js +0 -2
  15. package/dist/cjs/runtime/routeTree/construction.js +19 -59
  16. package/dist/cjs/runtime/routeTree/loaders.js +1 -1
  17. package/dist/cjs/runtime/routeTree/paths.js +0 -6
  18. package/dist/cjs/runtime/router.js +25 -9
  19. package/dist/cjs/runtime/ssrManagedTags.js +2 -12
  20. package/dist/cjs/runtime/state.js +2 -2
  21. package/dist/cjs/runtime/utils.js +3 -0
  22. package/dist/cjs/shared/routeDescriptor.js +60 -0
  23. package/dist/esm/cli/artifacts.mjs +13 -2
  24. package/dist/esm/cli/index.mjs +12 -9
  25. package/dist/esm/cli/routeMetadata.mjs +71 -0
  26. package/dist/esm/cli/tanstackTypes/canonicalRoutes.mjs +7 -7
  27. package/dist/esm/cli/tanstackTypes/shared.mjs +1 -9
  28. package/dist/esm/cli/tanstackTypes/source.mjs +28 -61
  29. package/dist/esm/runtime/basepathRewrite.mjs +10 -3
  30. package/dist/esm/runtime/lifecycle.mjs +1 -1
  31. package/dist/esm/runtime/loaderBridge.mjs +8 -5
  32. package/dist/esm/runtime/navigation.mjs +64 -0
  33. package/dist/esm/runtime/plugin.mjs +7 -2
  34. package/dist/esm/runtime/plugin.node.mjs +88 -66
  35. package/dist/esm/runtime/prefetchLink.mjs +1 -6
  36. package/dist/esm/runtime/register.mjs +0 -2
  37. package/dist/esm/runtime/routeTree/construction.mjs +19 -59
  38. package/dist/esm/runtime/routeTree/loaders.mjs +2 -2
  39. package/dist/esm/runtime/routeTree/paths.mjs +1 -4
  40. package/dist/esm/runtime/router.mjs +27 -11
  41. package/dist/esm/runtime/ssrManagedTags.mjs +1 -8
  42. package/dist/esm/runtime/state.mjs +1 -1
  43. package/dist/esm/runtime/utils.mjs +3 -0
  44. package/dist/esm/shared/routeDescriptor.mjs +22 -0
  45. package/dist/esm-node/cli/artifacts.mjs +13 -2
  46. package/dist/esm-node/cli/index.mjs +12 -9
  47. package/dist/esm-node/cli/routeMetadata.mjs +72 -0
  48. package/dist/esm-node/cli/tanstackTypes/canonicalRoutes.mjs +7 -7
  49. package/dist/esm-node/cli/tanstackTypes/shared.mjs +1 -9
  50. package/dist/esm-node/cli/tanstackTypes/source.mjs +28 -61
  51. package/dist/esm-node/runtime/basepathRewrite.mjs +10 -3
  52. package/dist/esm-node/runtime/lifecycle.mjs +1 -1
  53. package/dist/esm-node/runtime/loaderBridge.mjs +8 -5
  54. package/dist/esm-node/runtime/navigation.mjs +65 -0
  55. package/dist/esm-node/runtime/plugin.mjs +7 -2
  56. package/dist/esm-node/runtime/plugin.node.mjs +88 -66
  57. package/dist/esm-node/runtime/prefetchLink.mjs +1 -6
  58. package/dist/esm-node/runtime/register.mjs +0 -2
  59. package/dist/esm-node/runtime/routeTree/construction.mjs +19 -59
  60. package/dist/esm-node/runtime/routeTree/loaders.mjs +2 -2
  61. package/dist/esm-node/runtime/routeTree/paths.mjs +1 -4
  62. package/dist/esm-node/runtime/router.mjs +27 -11
  63. package/dist/esm-node/runtime/ssrManagedTags.mjs +1 -8
  64. package/dist/esm-node/runtime/state.mjs +1 -1
  65. package/dist/esm-node/runtime/utils.mjs +3 -0
  66. package/dist/esm-node/shared/routeDescriptor.mjs +23 -0
  67. package/dist/types/cli/artifacts.d.ts +13 -5
  68. package/dist/types/cli/index.d.ts +1 -1
  69. package/dist/types/cli/routeMetadata.d.ts +18 -0
  70. package/dist/types/cli/tanstackTypes/shared.d.ts +0 -2
  71. package/dist/types/runtime/basepathRewrite.d.ts +2 -1
  72. package/dist/types/runtime/lifecycle.d.ts +9 -2
  73. package/dist/types/runtime/loaderBridge.d.ts +1 -0
  74. package/dist/types/runtime/navigation.d.ts +4 -0
  75. package/dist/types/runtime/register.d.ts +1 -9
  76. package/dist/types/runtime/routeTree/paths.d.ts +0 -2
  77. package/dist/types/runtime/router.d.ts +2 -13
  78. package/dist/types/runtime/ssrManagedTags.d.ts +0 -1
  79. package/dist/types/runtime/ssrTypes.d.ts +0 -7
  80. package/dist/types/runtime/state.d.ts +1 -1
  81. package/dist/types/runtime/types.d.ts +2 -1
  82. package/dist/types/shared/routeDescriptor.d.ts +26 -0
  83. package/package.json +22 -19
  84. package/rstest.config.mts +2 -24
  85. package/src/cli/artifacts.ts +53 -6
  86. package/src/cli/index.ts +11 -2
  87. package/src/cli/routeMetadata.ts +121 -0
  88. package/src/cli/tanstackTypes/canonicalRoutes.ts +17 -12
  89. package/src/cli/tanstackTypes/shared.ts +0 -14
  90. package/src/cli/tanstackTypes/source.ts +42 -116
  91. package/src/runtime/basepathRewrite.ts +11 -2
  92. package/src/runtime/lifecycle.ts +14 -4
  93. package/src/runtime/loaderBridge.ts +9 -9
  94. package/src/runtime/navigation.ts +78 -0
  95. package/src/runtime/plugin.node.tsx +120 -93
  96. package/src/runtime/plugin.tsx +11 -1
  97. package/src/runtime/prefetchLink.tsx +1 -23
  98. package/src/runtime/register.ts +1 -12
  99. package/src/runtime/routeTree/construction.ts +32 -110
  100. package/src/runtime/routeTree/loaders.ts +2 -4
  101. package/src/runtime/routeTree/paths.ts +0 -6
  102. package/src/runtime/router.ts +25 -40
  103. package/src/runtime/ssrManagedTags.ts +0 -17
  104. package/src/runtime/ssrTypes.ts +0 -7
  105. package/src/runtime/state.ts +1 -1
  106. package/src/runtime/types.ts +2 -2
  107. package/src/runtime/utils.tsx +4 -1
  108. package/src/shared/routeDescriptor.ts +46 -0
  109. package/tests/router/cli.test.ts +148 -824
  110. package/tests/router/clientNavigation.ts +13 -0
  111. package/tests/router/dataMutation.test.tsx +17 -85
  112. package/tests/router/flightSerialization.roundtrip.test.tsx +9 -6
  113. package/tests/router/generateRouteArtifacts.test.ts +139 -27
  114. package/tests/router/loaderBridge.test.ts +0 -25
  115. package/tests/router/localisedIdentity.test.tsx +100 -0
  116. package/tests/router/prefetchLink.test.tsx +46 -21
  117. package/tests/router/prefetchLinkPreload.test.tsx +11 -32
  118. package/tests/router/routeHooks.test.ts +42 -105
  119. package/tests/router/routeMetadata.test.ts +233 -0
  120. package/tests/router/routeTree.test.ts +144 -836
  121. package/tests/router/rsc.test.tsx +131 -166
  122. package/tests/router/serverPlugin.test.ts +193 -1
  123. package/tests/router/tanstackTypes.test.ts +218 -963
  124. package/dist/esm/rslib-runtime~1.mjs +0 -18
  125. package/dist/esm-node/rslib-runtime~0.mjs +0 -19
  126. package/tests/realm-isolation.test.ts +0 -114
  127. package/tests/router/fastDefaults.test.ts +0 -32
  128. package/tests/router/fixtures/legacyRuntimeRouter.ts +0 -10
  129. package/tests/router/fixtures/rscDecoderSecurityChild.cjs +0 -162
  130. package/tests/router/hooks.test.ts +0 -40
  131. package/tests/router/hydrationBoundary.test.tsx +0 -23
  132. package/tests/router/packageSurface.test.ts +0 -24
  133. package/tests/router/preloadRedirect.test.ts +0 -64
  134. package/tests/router/register.test.ts +0 -8
  135. package/tests/router/routerCompatibility.test.ts +0 -239
  136. package/tests/router/rscDecoderSecurity.test.ts +0 -113
  137. package/tests/router/rscPayloadRouterMatrix.test.ts +0 -197
@@ -0,0 +1,13 @@
1
+ import type { AnyRouter } from '@tanstack/react-router';
2
+
3
+ // Router-core navigates only on the client. Node tests perform the same
4
+ // transition a mounted RouterProvider runs: push the rewritten public URL,
5
+ // then load it.
6
+ export async function navigateOnClient(
7
+ router: AnyRouter,
8
+ options: Parameters<AnyRouter['buildLocation']>[0],
9
+ ) {
10
+ router.history.push(router.buildLocation(options).publicHref);
11
+ router.updateLatestLocation();
12
+ await router.load();
13
+ }
@@ -105,54 +105,14 @@ describe('tanstack data mutation fetcher', () => {
105
105
  states.length = 0;
106
106
  });
107
107
 
108
- test('tracks submitting and loading phases for mutation submit', async () => {
109
- const actionResult = createDeferred<Response>();
110
- const invalidateResult = createDeferred<void>();
111
-
112
- currentRouter = createRouter({
113
- action: async () => actionResult.promise,
114
- invalidate: async () => invalidateResult.promise,
115
- });
116
-
117
- render(<FetcherHarness />);
118
- expect(screen.getByTestId('state').textContent).toBe('idle');
119
-
120
- let submitPromise: Promise<void> | undefined;
121
- act(() => {
122
- submitPromise = latestFetcher!.submit(
123
- { amount: 2 },
124
- { method: 'post', action: '/mutation' },
125
- );
126
- });
127
-
128
- await waitFor(() => {
129
- expect(screen.getByTestId('state').textContent).toBe('submitting');
130
- });
131
-
132
- actionResult.resolve(
133
- new Response(JSON.stringify({ count: 2 }), {
134
- headers: {
135
- 'Content-Type': 'application/json',
136
- },
137
- }),
138
- );
139
-
140
- await waitFor(() => {
141
- expect(screen.getByTestId('state').textContent).toBe('loading');
142
- });
143
-
144
- invalidateResult.resolve();
145
-
146
- await act(async () => {
147
- await submitPromise;
148
- });
149
-
150
- expect(screen.getByTestId('state').textContent).toBe('idle');
151
- expect(screen.getByTestId('data').textContent).toBe('{"count":2}');
152
- expect(states).toEqual(['idle', 'submitting', 'loading', 'idle']);
153
- });
154
-
155
- test('defaults fetcher submit without method to mutation state', async () => {
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
+ }) => {
156
116
  const actionResult = createDeferred<Response>();
157
117
  const invalidateResult = createDeferred<void>();
158
118
  const action = rstest.fn(async () => actionResult.promise);
@@ -170,8 +130,11 @@ describe('tanstack data mutation fetcher', () => {
170
130
  let submitPromise: Promise<void> | undefined;
171
131
  act(() => {
172
132
  submitPromise = latestFetcher!.submit(
173
- { amount: 1 },
174
- { action: '/mutation' },
133
+ { amount },
134
+ {
135
+ ...(method === undefined ? {} : { method }),
136
+ action: '/mutation',
137
+ },
175
138
  );
176
139
  });
177
140
 
@@ -182,7 +145,7 @@ describe('tanstack data mutation fetcher', () => {
182
145
  expect(loader).not.toHaveBeenCalled();
183
146
 
184
147
  actionResult.resolve(
185
- new Response(JSON.stringify({ count: 1 }), {
148
+ new Response(JSON.stringify({ count }), {
186
149
  headers: {
187
150
  'Content-Type': 'application/json',
188
151
  },
@@ -200,7 +163,9 @@ describe('tanstack data mutation fetcher', () => {
200
163
  });
201
164
 
202
165
  expect(screen.getByTestId('state').textContent).toBe('idle');
203
- expect(screen.getByTestId('data').textContent).toBe('{"count":1}');
166
+ expect(screen.getByTestId('data').textContent).toBe(
167
+ JSON.stringify({ count }),
168
+ );
204
169
  expect(states).toEqual(['idle', 'submitting', 'loading', 'idle']);
205
170
  });
206
171
 
@@ -460,37 +425,4 @@ describe('tanstack data mutation fetcher', () => {
460
425
  globalThis.FormData = NativeFormData;
461
426
  }
462
427
  });
463
-
464
- test('does not throw for Form submit with non-2xx response and still invalidates', async () => {
465
- const action = rstest.fn(
466
- async () =>
467
- new Response(JSON.stringify({ message: 'invalid amount' }), {
468
- status: 422,
469
- headers: {
470
- 'Content-Type': 'application/json',
471
- },
472
- }),
473
- );
474
- currentRouter = createRouter({
475
- action,
476
- });
477
-
478
- render(
479
- <Form method="post" action="/mutation">
480
- <button type="submit">submit</button>
481
- </Form>,
482
- );
483
-
484
- const form = document.querySelector('form');
485
- expect(form).toBeTruthy();
486
-
487
- await act(async () => {
488
- fireEvent.submit(form!);
489
- });
490
-
491
- await waitFor(() => {
492
- expect(action).toHaveBeenCalledTimes(1);
493
- expect(currentRouter.invalidate).toHaveBeenCalledTimes(1);
494
- });
495
- });
496
428
  });
@@ -45,21 +45,22 @@ test('round-trips TanStack RSC flight values across supported value types', asyn
45
45
  const circular: Record<string, unknown> = { marker: 'circular' };
46
46
  circular.self = circular;
47
47
 
48
+ const rscProxy = createRscProxy(() => ({ slot: 'server' }), {
49
+ renderable: true,
50
+ stream: createStream(),
51
+ }) as React.ReactElement & Record<PropertyKey, unknown>;
52
+
48
53
  const mapValue = new Map<unknown, unknown>([
49
54
  ['primitive', 1],
50
55
  ['shared', shared],
51
56
  [shared, circular],
57
+ ['rscProxy', rscProxy],
52
58
  ]);
53
59
  mapValue.set('self', mapValue);
54
60
 
55
- const setValue = new Set<unknown>(['set-item', shared, circular]);
61
+ const setValue = new Set<unknown>(['set-item', shared, circular, rscProxy]);
56
62
  setValue.add(setValue);
57
63
 
58
- const rscProxy = createRscProxy(() => ({ slot: 'server' }), {
59
- renderable: true,
60
- stream: createStream(),
61
- }) as React.ReactElement & Record<PropertyKey, unknown>;
62
-
63
64
  const fixture: RoundTripFixture = {
64
65
  bigintValue: 9007199254740993n,
65
66
  booleanValue: true,
@@ -115,12 +116,14 @@ test('round-trips TanStack RSC flight values across supported value types', asyn
115
116
  expect(revived.mapValue.get('shared')).toBe(revived.sharedA);
116
117
  expect(revived.mapValue.get(revived.sharedA)).toBe(revived.circular);
117
118
  expect(revived.mapValue.get('self')).toBe(revived.mapValue);
119
+ expect(isValidElement(revived.mapValue.get('rscProxy'))).toBe(true);
118
120
 
119
121
  expect(revived.setValue).toBeInstanceOf(Set);
120
122
  expect(revived.setValue).not.toBe(setValue);
121
123
  expect(revived.setValue.has('set-item')).toBe(true);
122
124
  expect(revived.setValue.has(revived.sharedA)).toBe(true);
123
125
  expect(revived.setValue.has(revived.circular)).toBe(true);
126
+ expect([...revived.setValue].some(value => isValidElement(value))).toBe(true);
124
127
  expect(revived.setValue.has(revived.setValue)).toBe(true);
125
128
 
126
129
  expect(serialized.rscProxy).not.toBe(rscProxy);
@@ -1,18 +1,12 @@
1
- // Behavioral coverage for the headless routes-generate entry point: the
2
- // export-shape test in cli.test.ts would pass even if the body were
3
- // reverted, so this file pins the actual prepare-only drive contract —
4
- // createRunOptions against the app dir, cli.init with build-command
5
- // semantics, the MODERN_ARGV gate that makes analyze.onPrepare fire
6
- // generateEntryCode, and the absolute config-file resolution that keeps
7
- // headless runs (cwd != appDirectory) from feeding `false` into node's
8
- // path APIs.
1
+ // The headless entry must drive the real TanStack artifact writer. Checking
2
+ // only the createRunOptions/cli.init choreography would allow a no-op CLI to
3
+ // pass while leaving router.gen.ts stale or missing.
9
4
  const createRunOptionsMock = rstest.fn(async (options: unknown) => ({
10
5
  mockRunOptions: true,
11
6
  received: options,
12
7
  }));
13
- const cliInitMock = rstest.fn(async (options: unknown) => ({
14
- appContext: {},
15
- received: options,
8
+ const cliInitMock = rstest.fn(async () => ({
9
+ appContext: { hooks: { onBeforeExit: { call: async () => {} } } },
16
10
  }));
17
11
 
18
12
  rstest.mock('@modern-js/app-tools/cli/run', () => ({
@@ -25,8 +19,12 @@ rstest.mock('@modern-js/plugin/cli', () => ({
25
19
  import fs from 'node:fs';
26
20
  import os from 'node:os';
27
21
  import path from 'node:path';
22
+ import { createAsyncHook } from '@modern-js/plugin';
28
23
  import { INTERNAL_RUNTIME_PLUGINS } from '@modern-js/utils';
29
- import { generateTanstackRouteArtifacts } from '../../src/cli';
24
+ import {
25
+ generateTanstackRouteArtifacts,
26
+ writeTanstackRouterTypesForEntries,
27
+ } from '../../src/cli';
30
28
 
31
29
  function makeAppDir(withConfig: boolean): string {
32
30
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tanstack-routes-gen-'));
@@ -46,9 +44,61 @@ describe('generateTanstackRouteArtifacts (headless routes-generate)', () => {
46
44
  cliInitMock.mockClear();
47
45
  });
48
46
 
49
- it('drives a prepare-only cli.init with build-command semantics', async () => {
47
+ it('writes a real route artifact through the prepare-only CLI drive', async () => {
50
48
  const previousArgv = process.env.MODERN_ARGV;
51
49
  const appDirectory = makeAppDir(true);
50
+ const srcDirectory = path.join(appDirectory, 'src');
51
+ const componentPath = path.join(srcDirectory, 'routes', 'page.tsx');
52
+ fs.mkdirSync(path.dirname(componentPath), { recursive: true });
53
+ fs.writeFileSync(
54
+ componentPath,
55
+ 'export default function Page() { return null; }\n',
56
+ 'utf-8',
57
+ );
58
+
59
+ const runOptions = {
60
+ cwd: appDirectory,
61
+ version: '9.9.9-test',
62
+ internalPlugins: INTERNAL_RUNTIME_PLUGINS,
63
+ configFile: path.resolve(appDirectory, 'modern.config'),
64
+ };
65
+ createRunOptionsMock.mockResolvedValue({
66
+ mockRunOptions: true,
67
+ received: runOptions,
68
+ });
69
+ const onBeforeExit = createAsyncHook<() => Promise<void>>();
70
+ let disposed = false;
71
+ onBeforeExit.tap(async () => {
72
+ await Promise.resolve();
73
+ disposed = true;
74
+ });
75
+ cliInitMock.mockImplementation(async () => {
76
+ await writeTanstackRouterTypesForEntries({
77
+ appContext: {
78
+ srcDirectory,
79
+ internalSrcAlias: '@/_',
80
+ entrypoints: [{ entryName: 'main', isMainEntry: true }],
81
+ } as any,
82
+ routesByEntry: {
83
+ main: [
84
+ {
85
+ type: 'nested',
86
+ id: 'layout',
87
+ isRoot: true,
88
+ children: [
89
+ {
90
+ type: 'nested',
91
+ id: 'page',
92
+ index: true,
93
+ _component: '@/_/routes/page',
94
+ },
95
+ ],
96
+ },
97
+ ] as any,
98
+ },
99
+ });
100
+ return { appContext: { hooks: { onBeforeExit } } };
101
+ });
52
102
 
53
103
  try {
54
104
  await generateTanstackRouteArtifacts({
@@ -61,26 +111,26 @@ describe('generateTanstackRouteArtifacts (headless routes-generate)', () => {
61
111
  string,
62
112
  unknown
63
113
  >;
114
+ // The headless entry must resolve cwd/configFile from the target app
115
+ // directory, not the process's own cwd — that was the actual bug.
64
116
  expect(runOptionsArg.cwd).toBe(appDirectory);
65
- expect(runOptionsArg.version).toBe('9.9.9-test');
66
- expect(runOptionsArg.internalPlugins).toBe(INTERNAL_RUNTIME_PLUGINS);
67
-
68
- // The config file is resolved to an absolute base path against the app
69
- // directory (not process.cwd()). Without this, a headless run from a
70
- // different cwd finds no config, `findExists` returns `false`, and that
71
- // boolean reaches `path.isAbsolute` deep inside plugin/cli.
72
117
  expect(runOptionsArg.configFile).toBe(
73
118
  path.resolve(appDirectory, 'modern.config'),
74
119
  );
75
120
 
76
121
  expect(cliInitMock).toHaveBeenCalledTimes(1);
77
- const initArg = cliInitMock.mock.calls[0][0] as Record<string, unknown>;
78
- expect(initArg.command).toBe('build');
79
- expect(initArg.mockRunOptions).toBe(true);
122
+ expect(disposed).toBe(true);
80
123
 
81
- // checkIsBuildCommands() resolves the command from MODERN_ARGV — this
82
- // is what lets analyze.onPrepare fire generateEntryCode headlessly.
83
- expect(process.env.MODERN_ARGV).toBe('node modern build');
124
+ expect(
125
+ fs.existsSync(
126
+ path.join(srcDirectory, 'modern-tanstack', 'main', 'router.gen.ts'),
127
+ ),
128
+ ).toBe(true);
129
+ const registerArtifact = fs.readFileSync(
130
+ path.join(srcDirectory, 'modern-tanstack', 'register.gen.d.ts'),
131
+ 'utf-8',
132
+ );
133
+ expect(registerArtifact).toContain('./main/router.gen');
84
134
  } finally {
85
135
  fs.rmSync(appDirectory, { force: true, recursive: true });
86
136
  if (previousArgv === undefined) {
@@ -91,7 +141,69 @@ describe('generateTanstackRouteArtifacts (headless routes-generate)', () => {
91
141
  }
92
142
  });
93
143
 
94
- it('throws a clear error (never a boolean path crash) when no config file exists', async () => {
144
+ it('preserves initialization and cleanup failures', async () => {
145
+ const appDirectory = makeAppDir(true);
146
+ const initializationFailure = new Error('prepare failed');
147
+ const cleanupFailure = new Error('dispose failed');
148
+ const onBeforeExit = createAsyncHook<() => Promise<void>>();
149
+ onBeforeExit.tap(async () => {
150
+ throw cleanupFailure;
151
+ });
152
+ try {
153
+ cliInitMock.mockRejectedValueOnce(initializationFailure);
154
+ await expect(
155
+ generateTanstackRouteArtifacts({ appDirectory }),
156
+ ).rejects.toBe(initializationFailure);
157
+ cliInitMock.mockResolvedValueOnce({
158
+ appContext: { hooks: { onBeforeExit } },
159
+ });
160
+ await expect(
161
+ generateTanstackRouteArtifacts({ appDirectory }),
162
+ ).rejects.toBe(cleanupFailure);
163
+ } finally {
164
+ fs.rmSync(appDirectory, { force: true, recursive: true });
165
+ }
166
+ });
167
+
168
+ it('regenerates the route metadata manifest before loading the config', async () => {
169
+ const appDirectory = makeAppDir(true);
170
+ const routesDirectory = path.join(appDirectory, 'src', 'routes');
171
+ const manifestPath = path.join(
172
+ routesDirectory,
173
+ 'ultramodern-route-metadata.ts',
174
+ );
175
+ fs.mkdirSync(path.join(routesDirectory, '[lang]'), { recursive: true });
176
+ fs.writeFileSync(
177
+ path.join(routesDirectory, '[lang]', 'route.meta.ts'),
178
+ "export const routeMeta = { namespace: 'app' } as const;\n",
179
+ );
180
+ // A manifest that still imports a deleted route.meta.ts makes the config
181
+ // (which imports the manifest) fail to load.
182
+ fs.writeFileSync(
183
+ manifestPath,
184
+ "import { routeMeta as route0 } from './[lang]/deleted/route.meta';\n",
185
+ );
186
+ const configLoadFailure = new Error(
187
+ "Cannot find module './[lang]/deleted/route.meta'",
188
+ );
189
+ cliInitMock.mockImplementationOnce(async () => {
190
+ throw configLoadFailure;
191
+ });
192
+ try {
193
+ await expect(
194
+ generateTanstackRouteArtifacts({ appDirectory }),
195
+ ).rejects.toBe(configLoadFailure);
196
+ const manifest = fs.readFileSync(manifestPath, 'utf8');
197
+ expect(manifest).toContain(
198
+ "import { routeMeta as route0 } from './[lang]/route.meta';",
199
+ );
200
+ expect(manifest).not.toContain('deleted');
201
+ } finally {
202
+ fs.rmSync(appDirectory, { force: true, recursive: true });
203
+ }
204
+ });
205
+
206
+ it('throws a clear error when no config file exists', async () => {
95
207
  const appDirectory = makeAppDir(false);
96
208
 
97
209
  try {
@@ -1,7 +1,5 @@
1
1
  import { isNotFound, isRedirect } from '@tanstack/react-router';
2
2
  import {
3
- createRouteStaticData,
4
- isAbsoluteUrl,
5
3
  mapSplatParamsForModernLoader,
6
4
  modernLoaderToTanstack,
7
5
  throwTanstackRedirect,
@@ -53,15 +51,6 @@ describe('throwTanstackRedirect', () => {
53
51
  });
54
52
  });
55
53
 
56
- describe('isAbsoluteUrl', () => {
57
- test('detects absolute and relative URLs', () => {
58
- expect(isAbsoluteUrl('https://example.com/a')).toBe(true);
59
- expect(isAbsoluteUrl('mailto:x@example.com')).toBe(true);
60
- expect(isAbsoluteUrl('/internal/path')).toBe(false);
61
- expect(isAbsoluteUrl('relative')).toBe(false);
62
- });
63
- });
64
-
65
54
  describe('mapSplatParamsForModernLoader', () => {
66
55
  test('maps TanStack _splat to React Router * only for splat routes', () => {
67
56
  expect(
@@ -76,20 +65,6 @@ describe('mapSplatParamsForModernLoader', () => {
76
65
  });
77
66
  });
78
67
 
79
- describe('createRouteStaticData', () => {
80
- test('drops empty fields', () => {
81
- const loader = () => null;
82
- expect(createRouteStaticData({})).toEqual({});
83
- expect(createRouteStaticData({ modernRouteId: '' })).toEqual({});
84
- expect(
85
- createRouteStaticData({
86
- modernRouteId: 'page',
87
- modernRouteLoader: loader,
88
- }),
89
- ).toEqual({ modernRouteId: 'page', modernRouteLoader: loader });
90
- });
91
- });
92
-
93
68
  describe('modernLoaderToTanstack', () => {
94
69
  const baseCtx = {
95
70
  location: { href: 'http://localhost/products/1' },
@@ -0,0 +1,100 @@
1
+ import { applyLocalisedUrlsToRoutes } from '@modern-js/i18n-runtime-extensions';
2
+ import {
3
+ createMemoryHistory,
4
+ createRouter,
5
+ Outlet,
6
+ RouterProvider,
7
+ useLoaderData,
8
+ useParams,
9
+ } from '@tanstack/react-router';
10
+ import { createElement } from 'react';
11
+ import { renderToString } from 'react-dom/server';
12
+ import { createModernBasepathRewrite } from '../../src/runtime/basepathRewrite';
13
+ import { createRouteTreeFromRouteObjects } from '../../src/runtime/routeTree';
14
+ import { createTanstackRouteObjectsFromConfig } from '../../src/runtime/utils';
15
+ import { navigateOnClient } from './clientNavigation';
16
+
17
+ function SearchPage() {
18
+ const data = useLoaderData({ from: '/$lang/search' }) as { language: string };
19
+ return createElement('p', null, `search:${data.language}`);
20
+ }
21
+
22
+ function ResourcePage() {
23
+ const params = useParams({ from: '/$lang/resources/$id' }) as { id: string };
24
+ return createElement('p', null, `resource:${params.id}`);
25
+ }
26
+
27
+ test('localized URLs preserve native loader and params route identity in SSR and navigation', async () => {
28
+ const routes = applyLocalisedUrlsToRoutes(
29
+ [
30
+ {
31
+ type: 'nested',
32
+ id: 'lang',
33
+ path: ':lang',
34
+ component: Outlet,
35
+ children: [
36
+ {
37
+ type: 'nested',
38
+ id: 'search',
39
+ path: 'search',
40
+ component: SearchPage,
41
+ loader: ({ params }: { params: { lang: string } }) => ({
42
+ language: params.lang,
43
+ }),
44
+ },
45
+ {
46
+ type: 'nested',
47
+ id: 'resource',
48
+ path: 'resources/:id',
49
+ component: ResourcePage,
50
+ },
51
+ ],
52
+ },
53
+ ],
54
+ ['en', 'cs'],
55
+ {
56
+ '/search': { en: '/find', cs: '/hledat' },
57
+ '/resources/:id': { en: '/resources/:id', cs: '/zdroje/:id' },
58
+ },
59
+ 'canonical',
60
+ );
61
+ const routeObjects = createTanstackRouteObjectsFromConfig({
62
+ routesConfig: { routes },
63
+ })!;
64
+ const history = createMemoryHistory({
65
+ initialEntries: ['/base/cs/hledat?q=tractor#results'],
66
+ });
67
+ const router = createRouter({
68
+ context: {
69
+ request: new Request(
70
+ 'https://example.test/base/cs/hledat?q=tractor#results',
71
+ ),
72
+ },
73
+ routeTree: createRouteTreeFromRouteObjects(routeObjects),
74
+ history,
75
+ rewrite: createModernBasepathRewrite('/base', false, routeObjects),
76
+ });
77
+ await router.load();
78
+ expect(router.state.matches.at(-1)?.routeId).toBe('/$lang/search');
79
+ expect(renderToString(createElement(RouterProvider, { router }))).toContain(
80
+ 'search:cs',
81
+ );
82
+ expect(history.location.href).toBe('/base/cs/hledat?q=tractor#results');
83
+
84
+ await navigateOnClient(router, {
85
+ to: '/en/search',
86
+ search: { q: 'tractor' },
87
+ });
88
+ expect(history.location.href).toBe('/base/en/find?q=tractor');
89
+ expect(router.state.matches.at(-1)?.routeId).toBe('/$lang/search');
90
+ expect(renderToString(createElement(RouterProvider, { router }))).toContain(
91
+ 'search:en',
92
+ );
93
+
94
+ await navigateOnClient(router, { to: '/cs/zdroje/a%2Fb' });
95
+ expect(history.location.href).toBe('/base/cs/zdroje/a%2Fb');
96
+ expect(router.state.matches.at(-1)?.routeId).toBe('/$lang/resources/$id');
97
+ expect(renderToString(createElement(RouterProvider, { router }))).toContain(
98
+ 'resource:a/b',
99
+ );
100
+ });
@@ -5,8 +5,9 @@ import {
5
5
  createRouter,
6
6
  RouterProvider,
7
7
  } from '@tanstack/react-router';
8
- import { render, waitFor } from '@testing-library/react';
8
+ import { act, render, waitFor } from '@testing-library/react';
9
9
  import React from 'react';
10
+ import { createTanstackNavigation } from '../../src/runtime/navigation';
10
11
  import { Link, NavLink } from '../../src/runtime/prefetchLink';
11
12
 
12
13
  // Real-router harness (no module mocking): STATIC_ACTIVE_PROPS (the
@@ -74,15 +75,10 @@ async function renderLink(options: Parameters<typeof buildRouter>[0]) {
74
75
  return found as HTMLAnchorElement;
75
76
  });
76
77
 
77
- return { anchor, ...utils };
78
+ return { anchor, router, ...utils };
78
79
  }
79
80
 
80
81
  describe('tanstack prefetch link adapter - aria-current override', () => {
81
- it('defaults active link to aria-current="page" when caller passes nothing', async () => {
82
- const { anchor } = await renderLink({ initialPath: '/settings' });
83
- expect(anchor.getAttribute('aria-current')).toBe('page');
84
- });
85
-
86
82
  it('lets caller aria-current="true" win over the TanStack-forced value', async () => {
87
83
  const { anchor } = await renderLink({
88
84
  initialPath: '/settings',
@@ -100,27 +96,56 @@ describe('tanstack prefetch link adapter - aria-current override', () => {
100
96
  expect(anchor.hasAttribute('aria-current')).toBe(false);
101
97
  });
102
98
 
103
- it('leaves an inactive link untouched when caller passes nothing', async () => {
104
- const { anchor } = await renderLink({ initialPath: '/' });
105
- expect(anchor.hasAttribute('aria-current')).toBe(false);
106
- expect(anchor.getAttribute('data-status')).not.toBe('active');
107
- });
108
-
109
- it('resolves render-prop children with the active state like TanStack Link', async () => {
110
- const { anchor } = await renderLink({
111
- initialPath: '/settings',
99
+ it('updates render-prop children across an inactive-to-active transition', async () => {
100
+ const { anchor, router } = await renderLink({
101
+ initialPath: '/',
112
102
  children: ({ isActive }: { isActive: boolean }) =>
113
103
  isActive ? 'Active settings' : 'Settings',
114
104
  });
115
- expect(anchor.textContent).toBe('Active settings');
105
+
106
+ expect(anchor.textContent).toBe('Settings');
107
+
108
+ await act(async () => {
109
+ await router.navigate({ to: '/settings' });
110
+ });
111
+
112
+ await waitFor(() => expect(anchor.textContent).toBe('Active settings'));
116
113
  });
117
114
 
118
- it('resolves render-prop children as inactive on a non-matching route', async () => {
115
+ it('passes the native anchor ref through the router hook', async () => {
116
+ const ref = React.createRef<HTMLAnchorElement>();
119
117
  const { anchor } = await renderLink({
120
118
  initialPath: '/',
121
- children: ({ isActive }: { isActive: boolean }) =>
122
- isActive ? 'Active settings' : 'Settings',
119
+ linkProps: { ref, prefetch: 'none' },
123
120
  });
124
- expect(anchor.textContent).toBe('Settings');
121
+ expect(ref.current).toBe(anchor);
122
+ });
123
+ });
124
+
125
+ test('provider navigation observes native navigation and preserves history state', async () => {
126
+ const { router, unmount } = await renderLink({ initialPath: '/' });
127
+ const capability = createTanstackNavigation(router);
128
+ const initial = capability.getSnapshot();
129
+ expect(capability.getSnapshot()).toBe(initial);
130
+ const listener = rstest.fn();
131
+ const stop = capability.subscribe(listener);
132
+ await act(() =>
133
+ capability.navigate('/settings?q=two#detail', {
134
+ replace: true,
135
+ state: { custom: true },
136
+ }),
137
+ );
138
+ expect(listener).toHaveBeenCalled();
139
+ expect(capability.getSnapshot().location).toEqual({
140
+ pathname: '/settings',
141
+ search: '?q=two',
142
+ hash: '#detail',
125
143
  });
144
+ expect(router.state.location.state).toMatchObject({ custom: true });
145
+ expect(router.history.length).toBe(1);
146
+ stop();
147
+ listener.mockClear();
148
+ await act(() => capability.navigate('/'));
149
+ expect(listener).not.toHaveBeenCalled();
150
+ unmount();
126
151
  });