@bleedingdev/modern-js-plugin-tanstack 3.5.0-ultramodern.3 → 3.5.0-ultramodern.30
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 +26 -0
- package/dist/cjs/runtime/plugin.node.js +8 -7
- package/dist/cjs/runtime/prefetchLink.js +15 -2
- package/dist/esm/cli/index.mjs +24 -1
- package/dist/esm/runtime/plugin.node.mjs +8 -7
- package/dist/esm/runtime/prefetchLink.mjs +18 -5
- package/dist/esm-node/cli/index.mjs +24 -1
- package/dist/esm-node/runtime/plugin.node.mjs +8 -7
- package/dist/esm-node/runtime/prefetchLink.mjs +18 -5
- package/dist/types/cli/index.d.ts +11 -0
- package/dist/types/runtime/pluginCore.d.ts +14 -10
- package/dist/types/runtime/types.d.ts +42 -4
- package/dist/types/runtime/utils.d.ts +4 -3
- package/package.json +9 -8
- package/rstest.config.mts +2 -0
- package/src/cli/index.ts +63 -1
- package/src/runtime/plugin.node.tsx +16 -7
- package/src/runtime/pluginCore.ts +23 -15
- package/src/runtime/prefetchLink.tsx +78 -15
- package/src/runtime/types.ts +46 -16
- package/src/runtime/utils.tsx +5 -3
- package/tests/router/cli.test.ts +7 -0
- package/tests/router/generateRouteArtifacts.test.ts +106 -0
- package/tests/router/prefetchLink.test.tsx +106 -59
- package/tests/router/prefetchLinkPreload.test.tsx +85 -0
|
@@ -357,18 +357,19 @@ export const tanstackRouterPlugin = (
|
|
|
357
357
|
const _basename =
|
|
358
358
|
baseUrl === '/' ? urlJoin(baseUrl, basename || '') : baseUrl;
|
|
359
359
|
|
|
360
|
-
const
|
|
360
|
+
const rawRequest = request.raw as Request;
|
|
361
|
+
const initialHref = createGetSsrHref(rawRequest);
|
|
361
362
|
const isRSCNavigation =
|
|
362
|
-
enableRsc &&
|
|
363
|
+
enableRsc && rawRequest.headers.get('x-rsc-tree') === 'true';
|
|
363
364
|
|
|
364
365
|
const requestContext = createRequestContext(
|
|
365
366
|
context.ssrContext?.loaderContext,
|
|
366
367
|
) as RequestContext<Record<string, unknown>>;
|
|
367
368
|
|
|
368
369
|
const controller = new AbortController();
|
|
369
|
-
const ssrRequest = new Request(
|
|
370
|
+
const ssrRequest = new Request(rawRequest.url, {
|
|
370
371
|
method: 'GET',
|
|
371
|
-
headers:
|
|
372
|
+
headers: rawRequest.headers,
|
|
372
373
|
signal: controller.signal,
|
|
373
374
|
});
|
|
374
375
|
|
|
@@ -398,7 +399,7 @@ export const tanstackRouterPlugin = (
|
|
|
398
399
|
history,
|
|
399
400
|
basepath: '/',
|
|
400
401
|
rewrite,
|
|
401
|
-
origin: new URL(
|
|
402
|
+
origin: new URL(rawRequest.url).origin,
|
|
402
403
|
ssr: { nonce },
|
|
403
404
|
context: routerContext as never,
|
|
404
405
|
...(serializationAdapters ? { serializationAdapters } : {}),
|
|
@@ -439,7 +440,11 @@ export const tanstackRouterPlugin = (
|
|
|
439
440
|
|
|
440
441
|
const routerErrors = collectRouterErrors(tanstackRouter);
|
|
441
442
|
if (routerErrors && loaderFailureMode === 'clientRender') {
|
|
442
|
-
|
|
443
|
+
(
|
|
444
|
+
context.ssrContext?.response as
|
|
445
|
+
| { status: (code: number) => void }
|
|
446
|
+
| undefined
|
|
447
|
+
)?.status(200);
|
|
443
448
|
try {
|
|
444
449
|
serverRouter.serverSsr?.cleanup?.();
|
|
445
450
|
} catch {}
|
|
@@ -448,7 +453,11 @@ export const tanstackRouterPlugin = (
|
|
|
448
453
|
|
|
449
454
|
await preloadMatchedRouteComponents(serverRouter);
|
|
450
455
|
|
|
451
|
-
|
|
456
|
+
(
|
|
457
|
+
context.ssrContext?.response as
|
|
458
|
+
| { status: (code: number) => void }
|
|
459
|
+
| undefined
|
|
460
|
+
)?.status(tanstackRouter.state.statusCode);
|
|
452
461
|
|
|
453
462
|
await serverRouter.serverSsr?.dehydrate?.();
|
|
454
463
|
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { Plugin, RuntimePluginExtends } from '@modern-js/plugin';
|
|
2
|
-
import type { RuntimePluginAPI } from '@modern-js/plugin/runtime';
|
|
3
1
|
import {
|
|
4
2
|
getGlobalLayoutApp,
|
|
5
3
|
getGlobalRoutes,
|
|
@@ -15,19 +13,27 @@ export type TanstackRouterRuntimeConfig = {
|
|
|
15
13
|
[key: string]: unknown;
|
|
16
14
|
};
|
|
17
15
|
|
|
18
|
-
type
|
|
19
|
-
RuntimePluginExtends<TanstackRouterRuntimeConfig, TInternalRuntimeContext>
|
|
20
|
-
> & {
|
|
21
|
-
extendHooks: RouterExtendsHooks;
|
|
22
|
-
};
|
|
16
|
+
type RuntimeInterrupt = (value?: unknown) => unknown;
|
|
23
17
|
|
|
24
|
-
export type TanstackRouterPluginAPI =
|
|
25
|
-
|
|
18
|
+
export type TanstackRouterPluginAPI = {
|
|
19
|
+
getRuntimeConfig: () => TanstackRouterRuntimeConfig;
|
|
20
|
+
getHooks: () => RouterExtendsHooks;
|
|
21
|
+
onBeforeRender: (
|
|
22
|
+
listener: (
|
|
23
|
+
context: TInternalRuntimeContext,
|
|
24
|
+
interrupt: RuntimeInterrupt,
|
|
25
|
+
) => unknown,
|
|
26
|
+
) => void;
|
|
27
|
+
wrapRoot: (listener: (App: any) => any) => void;
|
|
28
|
+
[key: string]: any;
|
|
29
|
+
};
|
|
26
30
|
|
|
27
|
-
export type TanstackRouterRuntimePlugin =
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
export type TanstackRouterRuntimePlugin = {
|
|
32
|
+
name?: string;
|
|
33
|
+
registryHooks?: RouterExtendsHooks;
|
|
34
|
+
setup?: (api: TanstackRouterPluginAPI) => unknown;
|
|
35
|
+
[key: string]: unknown;
|
|
36
|
+
};
|
|
31
37
|
|
|
32
38
|
export function getMergedRouterConfig(
|
|
33
39
|
api: TanstackRouterPluginAPI,
|
|
@@ -39,9 +45,11 @@ export function getMergedRouterConfig(
|
|
|
39
45
|
return merge(pluginConfig.router || {}, userConfig) as RouterConfig;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
export function getFinalRouteConfig(
|
|
48
|
+
export function getFinalRouteConfig(
|
|
49
|
+
mergedConfig: RouterConfig,
|
|
50
|
+
): RouterConfig['routesConfig'] {
|
|
43
51
|
return {
|
|
44
|
-
routes: getGlobalRoutes(),
|
|
52
|
+
routes: getGlobalRoutes() as RouterConfig['routesConfig']['routes'],
|
|
45
53
|
globalApp: getGlobalLayoutApp(),
|
|
46
54
|
...mergedConfig.routesConfig,
|
|
47
55
|
};
|
|
@@ -2,9 +2,9 @@ import {
|
|
|
2
2
|
type AnyRouter,
|
|
3
3
|
type LinkComponentProps,
|
|
4
4
|
type RegisteredRouter,
|
|
5
|
-
|
|
5
|
+
useLinkProps,
|
|
6
6
|
} from '@tanstack/react-router';
|
|
7
|
-
import type { ReactElement } from 'react';
|
|
7
|
+
import type { AnchorHTMLAttributes, ReactElement, ReactNode, Ref } from 'react';
|
|
8
8
|
|
|
9
9
|
export type PrefetchBehavior = 'intent' | 'render' | 'viewport' | 'none';
|
|
10
10
|
|
|
@@ -65,21 +65,84 @@ type LinkComponentImplProps = LinkProps<
|
|
|
65
65
|
string | undefined,
|
|
66
66
|
string,
|
|
67
67
|
string
|
|
68
|
-
|
|
68
|
+
> & { ref?: Ref<HTMLAnchorElement> };
|
|
69
69
|
|
|
70
70
|
const LinkComponentImpl = (props: LinkComponentImplProps) => {
|
|
71
|
-
const {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
71
|
+
const {
|
|
72
|
+
prefetch,
|
|
73
|
+
preload,
|
|
74
|
+
'aria-current': ariaCurrentProp,
|
|
75
|
+
ref,
|
|
76
|
+
children,
|
|
77
|
+
...rest
|
|
78
|
+
} = props as LinkComponentImplProps & {
|
|
79
|
+
children?:
|
|
80
|
+
| ReactNode
|
|
81
|
+
| ((state: { isActive: boolean; isTransitioning: boolean }) => ReactNode);
|
|
82
|
+
};
|
|
83
|
+
const hasAriaCurrentOverride = 'aria-current' in props;
|
|
84
|
+
|
|
85
|
+
// Children are rendered by us, not by useLinkProps() — the hook filters
|
|
86
|
+
// unknown/function props out of its return value, so anything routed
|
|
87
|
+
// through it would be silently dropped.
|
|
88
|
+
const linkOptions = {
|
|
89
|
+
...rest,
|
|
90
|
+
preload: resolvePreloadFromPrefetch(
|
|
91
|
+
prefetch,
|
|
92
|
+
preload,
|
|
93
|
+
) as LinkComponentImplProps['preload'],
|
|
94
|
+
} as LinkComponentImplProps;
|
|
95
|
+
|
|
96
|
+
// TanStack Router's useLinkProps() unconditionally spreads
|
|
97
|
+
// STATIC_ACTIVE_PROPS = { "data-status": "active", "aria-current": "page" }
|
|
98
|
+
// as the LAST entry of the props object it returns for an active link
|
|
99
|
+
// (@tanstack/react-router dist/esm/link.js:369 `...isActive &&
|
|
100
|
+
// STATIC_ACTIVE_PROPS`, defined at line 379-382, spread after
|
|
101
|
+
// `...propsSafeToSpread`, `...resolvedActiveProps`, and
|
|
102
|
+
// `...resolvedInactiveProps`). That means any caller-supplied aria-current
|
|
103
|
+
// -- whether passed directly or via activeProps -- is always clobbered by
|
|
104
|
+
// the time <Link>/createLink render the anchor; there is no props-merge
|
|
105
|
+
// order that lets a caller win from inside <Link>. So instead of
|
|
106
|
+
// delegating rendering to <Link>, we call useLinkProps() ourselves and
|
|
107
|
+
// re-apply the caller's original aria-current (including `false`, meaning
|
|
108
|
+
// "suppress the attribute entirely") as the final word on the anchor we
|
|
109
|
+
// render.
|
|
110
|
+
const linkProps = useLinkProps(
|
|
111
|
+
linkOptions as Parameters<typeof useLinkProps>[0],
|
|
112
|
+
ref as Parameters<typeof useLinkProps>[1],
|
|
113
|
+
) as AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|
114
|
+
disabled?: boolean;
|
|
115
|
+
type?: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// TanStack's own Link strips `type` and `disabled` before rendering the
|
|
119
|
+
// anchor (link.js:449-452) — mirror that.
|
|
120
|
+
const { disabled: _disabled, type: _type, ...anchorProps } = linkProps;
|
|
121
|
+
|
|
122
|
+
if (hasAriaCurrentOverride) {
|
|
123
|
+
if (ariaCurrentProp === false || typeof ariaCurrentProp === 'undefined') {
|
|
124
|
+
delete anchorProps['aria-current'];
|
|
125
|
+
} else {
|
|
126
|
+
anchorProps['aria-current'] = ariaCurrentProp;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// TanStack's Link resolves render-prop children with the active state
|
|
131
|
+
// (link.js:450) — without this, function children reach React as an
|
|
132
|
+
// invalid child and crash the render.
|
|
133
|
+
const resolvedChildren =
|
|
134
|
+
typeof children === 'function'
|
|
135
|
+
? children({
|
|
136
|
+
isActive:
|
|
137
|
+
(anchorProps as Record<string, unknown>)['data-status'] ===
|
|
138
|
+
'active',
|
|
139
|
+
isTransitioning:
|
|
140
|
+
(anchorProps as Record<string, unknown>)['data-transitioning'] ===
|
|
141
|
+
'transitioning',
|
|
142
|
+
})
|
|
143
|
+
: children;
|
|
144
|
+
|
|
145
|
+
return <a {...anchorProps}>{resolvedChildren}</a>;
|
|
83
146
|
};
|
|
84
147
|
|
|
85
148
|
export const Link = LinkComponentImpl as LinkComponent;
|
package/src/runtime/types.ts
CHANGED
|
@@ -1,21 +1,51 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
RouterFramework,
|
|
3
|
-
// The router runtime state types are owned by @modern-js/runtime; they are
|
|
4
|
-
// imported through the `/context` seam instead of being copied here so
|
|
5
|
-
// upstream fixes propagate to this package automatically.
|
|
6
|
-
} from '@modern-js/runtime/context';
|
|
7
1
|
import type { RouteObject } from '@modern-js/runtime-utils/router';
|
|
8
|
-
import type { NestedRoute, PageRoute } from '@modern-js/types';
|
|
9
2
|
import type React from 'react';
|
|
10
3
|
|
|
11
|
-
export type
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
4
|
+
export type BuiltInRouterFramework = 'react-router' | 'tanstack';
|
|
5
|
+
export type RouterFramework = BuiltInRouterFramework | (string & {});
|
|
6
|
+
|
|
7
|
+
export type ModernRoute = {
|
|
8
|
+
type: 'nested' | 'page';
|
|
9
|
+
path?: string;
|
|
10
|
+
id?: string;
|
|
11
|
+
component?: React.ComponentType | string;
|
|
12
|
+
children?: ModernRoute[];
|
|
13
|
+
[key: string]: any;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type RouterRouteMatchSnapshot = {
|
|
17
|
+
routeId: string;
|
|
18
|
+
assetRouteId?: string;
|
|
19
|
+
pathname?: string;
|
|
20
|
+
params?: Record<string, string>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type InternalRouterServerSnapshot = {
|
|
24
|
+
framework?: RouterFramework;
|
|
25
|
+
basename?: string;
|
|
26
|
+
statusCode?: number;
|
|
27
|
+
errors?: Record<string, unknown>;
|
|
28
|
+
routerData?: {
|
|
29
|
+
loaderData?: Record<string, unknown>;
|
|
30
|
+
errors?: Record<string, unknown>;
|
|
31
|
+
};
|
|
32
|
+
hydrationScript?: string;
|
|
33
|
+
hydrationScripts?: string[];
|
|
34
|
+
matchedRouteIds?: string[];
|
|
35
|
+
matches?: RouterRouteMatchSnapshot[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type InternalRouterRuntimeState = {
|
|
39
|
+
framework: RouterFramework;
|
|
40
|
+
basename?: string;
|
|
41
|
+
instance?: unknown;
|
|
42
|
+
hydrationScript?: string;
|
|
43
|
+
hydrationScripts?: string[];
|
|
44
|
+
matchedRouteIds?: string[];
|
|
45
|
+
matches?: RouterRouteMatchSnapshot[];
|
|
46
|
+
serverSnapshot?: InternalRouterServerSnapshot;
|
|
47
|
+
cleanup?: () => void | Promise<void>;
|
|
48
|
+
};
|
|
19
49
|
|
|
20
50
|
/**
|
|
21
51
|
* TanStack-specific router config. Unlike the react-router provider config,
|
|
@@ -26,7 +56,7 @@ export type RouterConfig = {
|
|
|
26
56
|
framework?: RouterFramework;
|
|
27
57
|
routesConfig: {
|
|
28
58
|
globalApp?: React.ComponentType<any>;
|
|
29
|
-
routes?:
|
|
59
|
+
routes?: ModernRoute[];
|
|
30
60
|
};
|
|
31
61
|
serverBase?: string[];
|
|
32
62
|
supportHtml5History?: boolean;
|
package/src/runtime/utils.tsx
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// @effect-diagnostics strictBooleanExpressions:off
|
|
2
2
|
import type { RouteObject } from '@modern-js/runtime-utils/router';
|
|
3
|
-
import type { NestedRoute, PageRoute, SSRMode } from '@modern-js/types';
|
|
4
3
|
import React from 'react';
|
|
4
|
+
import type { ModernRoute } from './types';
|
|
5
|
+
|
|
6
|
+
export type SSRMode = 'string' | 'stream';
|
|
5
7
|
|
|
6
8
|
type RouterConfig = {
|
|
7
9
|
routesConfig: {
|
|
8
10
|
globalApp?: React.ComponentType<any>;
|
|
9
|
-
routes?:
|
|
11
|
+
routes?: ModernRoute[];
|
|
10
12
|
};
|
|
11
13
|
};
|
|
12
14
|
|
|
@@ -29,7 +31,7 @@ export type ModernRouteObject = RouteObject & {
|
|
|
29
31
|
};
|
|
30
32
|
|
|
31
33
|
export function getRouteObjects(
|
|
32
|
-
routes:
|
|
34
|
+
routes: ModernRoute[],
|
|
33
35
|
{
|
|
34
36
|
globalApp,
|
|
35
37
|
ssrMode,
|
package/tests/router/cli.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Entrypoint } from '@modern-js/types';
|
|
|
6
6
|
import { fs, NESTED_ROUTE_SPEC_FILE } from '@modern-js/utils';
|
|
7
7
|
import {
|
|
8
8
|
createTanstackRsbuildRouteSplittingProfile,
|
|
9
|
+
generateTanstackRouteArtifacts,
|
|
9
10
|
tanstackRouterPlugin,
|
|
10
11
|
writeTanstackRegisterFile,
|
|
11
12
|
writeTanstackRouterTypesForEntries,
|
|
@@ -977,3 +978,9 @@ describe('tanstack router cli plugin', () => {
|
|
|
977
978
|
});
|
|
978
979
|
});
|
|
979
980
|
});
|
|
981
|
+
|
|
982
|
+
describe('generateTanstackRouteArtifacts export', () => {
|
|
983
|
+
test('is exported as a function', () => {
|
|
984
|
+
expect(typeof generateTanstackRouteArtifacts).toBe('function');
|
|
985
|
+
});
|
|
986
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
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.
|
|
9
|
+
const createRunOptionsMock = rstest.fn(async (options: unknown) => ({
|
|
10
|
+
mockRunOptions: true,
|
|
11
|
+
received: options,
|
|
12
|
+
}));
|
|
13
|
+
const cliInitMock = rstest.fn(async (options: unknown) => ({
|
|
14
|
+
appContext: {},
|
|
15
|
+
received: options,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
rstest.mock('@modern-js/app-tools/cli/run', () => ({
|
|
19
|
+
createRunOptions: createRunOptionsMock,
|
|
20
|
+
}));
|
|
21
|
+
rstest.mock('@modern-js/plugin/cli', () => ({
|
|
22
|
+
cli: { init: cliInitMock },
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { INTERNAL_RUNTIME_PLUGINS } from '@modern-js/utils';
|
|
29
|
+
import { generateTanstackRouteArtifacts } from '../../src/cli';
|
|
30
|
+
|
|
31
|
+
function makeAppDir(withConfig: boolean): string {
|
|
32
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tanstack-routes-gen-'));
|
|
33
|
+
if (withConfig) {
|
|
34
|
+
fs.writeFileSync(
|
|
35
|
+
path.join(dir, 'modern.config.ts'),
|
|
36
|
+
'export default {};\n',
|
|
37
|
+
'utf-8',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('generateTanstackRouteArtifacts (headless routes-generate)', () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
createRunOptionsMock.mockClear();
|
|
46
|
+
cliInitMock.mockClear();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('drives a prepare-only cli.init with build-command semantics', async () => {
|
|
50
|
+
const previousArgv = process.env.MODERN_ARGV;
|
|
51
|
+
const appDirectory = makeAppDir(true);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await generateTanstackRouteArtifacts({
|
|
55
|
+
appDirectory,
|
|
56
|
+
version: '9.9.9-test',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(createRunOptionsMock).toHaveBeenCalledTimes(1);
|
|
60
|
+
const runOptionsArg = createRunOptionsMock.mock.calls[0][0] as Record<
|
|
61
|
+
string,
|
|
62
|
+
unknown
|
|
63
|
+
>;
|
|
64
|
+
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
|
+
expect(runOptionsArg.configFile).toBe(
|
|
73
|
+
path.resolve(appDirectory, 'modern.config'),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
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);
|
|
80
|
+
|
|
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');
|
|
84
|
+
} finally {
|
|
85
|
+
fs.rmSync(appDirectory, { force: true, recursive: true });
|
|
86
|
+
if (previousArgv === undefined) {
|
|
87
|
+
delete process.env.MODERN_ARGV;
|
|
88
|
+
} else {
|
|
89
|
+
process.env.MODERN_ARGV = previousArgv;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('throws a clear error (never a boolean path crash) when no config file exists', async () => {
|
|
95
|
+
const appDirectory = makeAppDir(false);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await expect(
|
|
99
|
+
generateTanstackRouteArtifacts({ appDirectory }),
|
|
100
|
+
).rejects.toThrow(/Unable to locate a Modern\.js config file/u);
|
|
101
|
+
expect(createRunOptionsMock).not.toHaveBeenCalled();
|
|
102
|
+
} finally {
|
|
103
|
+
fs.rmSync(appDirectory, { force: true, recursive: true });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -1,79 +1,126 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createMemoryHistory } from '@tanstack/history';
|
|
2
|
+
import {
|
|
3
|
+
createRootRoute,
|
|
4
|
+
createRoute,
|
|
5
|
+
createRouter,
|
|
6
|
+
RouterProvider,
|
|
7
|
+
} from '@tanstack/react-router';
|
|
8
|
+
import { render, waitFor } from '@testing-library/react';
|
|
2
9
|
import React from 'react';
|
|
3
10
|
import { Link, NavLink } from '../../src/runtime/prefetchLink';
|
|
4
11
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
12
|
+
// Real-router harness (no module mocking): STATIC_ACTIVE_PROPS (the
|
|
13
|
+
// TanStack behavior this adapter works around) is injected by
|
|
14
|
+
// useLinkProps() based on live route match state, which a mocked
|
|
15
|
+
// useLinkProps() cannot reproduce faithfully. We tried spying on
|
|
16
|
+
// useLinkProps via rstest.mock()+importActual while re-exporting the rest
|
|
17
|
+
// of the module untouched, but rstest's module-mock hoisting resolves the
|
|
18
|
+
// "actual" import for a module through its own in-progress mock
|
|
19
|
+
// registration, so every named export (createRootRoute included) comes
|
|
20
|
+
// back undefined -- see the sibling prefetchLinkPreload.test.tsx file for
|
|
21
|
+
// the fully-mocked preload-mapping coverage instead. This file renders
|
|
22
|
+
// through an actual createRouter()/RouterProvider tree with in-memory
|
|
23
|
+
// history so the active/inactive aria-current behavior is exercised for
|
|
24
|
+
// real.
|
|
25
|
+
function buildRouter(options: {
|
|
26
|
+
initialPath: string;
|
|
27
|
+
linkProps?: Record<string, unknown>;
|
|
28
|
+
useNavLink?: boolean;
|
|
29
|
+
children?:
|
|
30
|
+
| React.ReactNode
|
|
31
|
+
| ((state: { isActive: boolean }) => React.ReactNode);
|
|
32
|
+
}) {
|
|
33
|
+
// The Link lives on the always-rendered root component (not a route
|
|
34
|
+
// that's only matched at "/settings") so the "inactive link" case -
|
|
35
|
+
// sitting at "/" while linking to "/settings" - actually mounts it.
|
|
36
|
+
const rootRoute = createRootRoute({
|
|
37
|
+
component: () => {
|
|
38
|
+
const Component = options.useNavLink ? NavLink : Link;
|
|
39
|
+
return (
|
|
40
|
+
<Component to="/settings" {...(options.linkProps ?? {})}>
|
|
41
|
+
{options.children ?? 'Settings'}
|
|
42
|
+
</Component>
|
|
43
|
+
);
|
|
44
|
+
},
|
|
22
45
|
});
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
46
|
+
const homeRoute = createRoute({
|
|
47
|
+
getParentRoute: () => rootRoute,
|
|
48
|
+
path: '/',
|
|
49
|
+
component: () => null,
|
|
50
|
+
});
|
|
51
|
+
const settingsRoute = createRoute({
|
|
52
|
+
getParentRoute: () => rootRoute,
|
|
53
|
+
path: '/settings',
|
|
54
|
+
component: () => null,
|
|
28
55
|
});
|
|
29
56
|
|
|
30
|
-
|
|
31
|
-
render(
|
|
32
|
-
<Link to="/settings" prefetch="render" preload="intent">
|
|
33
|
-
Settings
|
|
34
|
-
</Link>,
|
|
35
|
-
);
|
|
57
|
+
const routeTree = rootRoute.addChildren([homeRoute, settingsRoute]);
|
|
36
58
|
|
|
37
|
-
|
|
59
|
+
return createRouter({
|
|
60
|
+
routeTree,
|
|
61
|
+
history: createMemoryHistory({ initialEntries: [options.initialPath] }),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function renderLink(options: Parameters<typeof buildRouter>[0]) {
|
|
66
|
+
const router = buildRouter(options);
|
|
67
|
+
const utils = render(<RouterProvider router={router} />);
|
|
68
|
+
|
|
69
|
+
const anchor = await waitFor(() => {
|
|
70
|
+
const found = utils.container.querySelector('a[href="/settings"]');
|
|
71
|
+
if (!found) {
|
|
72
|
+
throw new Error('anchor not rendered yet');
|
|
73
|
+
}
|
|
74
|
+
return found as HTMLAnchorElement;
|
|
38
75
|
});
|
|
39
76
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
<Link to="/settings" prefetch="render" preload={false}>
|
|
43
|
-
Settings
|
|
44
|
-
</Link>,
|
|
45
|
-
);
|
|
77
|
+
return { anchor, ...utils };
|
|
78
|
+
}
|
|
46
79
|
|
|
47
|
-
|
|
80
|
+
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');
|
|
48
84
|
});
|
|
49
85
|
|
|
50
|
-
it('
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
expect(capturedPreloads).toEqual([false]);
|
|
86
|
+
it('lets caller aria-current="true" win over the TanStack-forced value', async () => {
|
|
87
|
+
const { anchor } = await renderLink({
|
|
88
|
+
initialPath: '/settings',
|
|
89
|
+
linkProps: { 'aria-current': 'true' },
|
|
90
|
+
});
|
|
91
|
+
expect(anchor.getAttribute('aria-current')).toBe('true');
|
|
92
|
+
expect(anchor.outerHTML.match(/aria-current=/g)).toHaveLength(1);
|
|
58
93
|
});
|
|
59
94
|
|
|
60
|
-
it
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
Settings
|
|
68
|
-
</Link>,
|
|
69
|
-
);
|
|
95
|
+
it('suppresses aria-current entirely when caller passes false', async () => {
|
|
96
|
+
const { anchor } = await renderLink({
|
|
97
|
+
initialPath: '/settings',
|
|
98
|
+
linkProps: { 'aria-current': false },
|
|
99
|
+
});
|
|
100
|
+
expect(anchor.hasAttribute('aria-current')).toBe(false);
|
|
101
|
+
});
|
|
70
102
|
|
|
71
|
-
|
|
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');
|
|
72
107
|
});
|
|
73
108
|
|
|
74
|
-
it('
|
|
75
|
-
|
|
109
|
+
it('resolves render-prop children with the active state like TanStack Link', async () => {
|
|
110
|
+
const { anchor } = await renderLink({
|
|
111
|
+
initialPath: '/settings',
|
|
112
|
+
children: ({ isActive }: { isActive: boolean }) =>
|
|
113
|
+
isActive ? 'Active settings' : 'Settings',
|
|
114
|
+
});
|
|
115
|
+
expect(anchor.textContent).toBe('Active settings');
|
|
116
|
+
});
|
|
76
117
|
|
|
77
|
-
|
|
118
|
+
it('resolves render-prop children as inactive on a non-matching route', async () => {
|
|
119
|
+
const { anchor } = await renderLink({
|
|
120
|
+
initialPath: '/',
|
|
121
|
+
children: ({ isActive }: { isActive: boolean }) =>
|
|
122
|
+
isActive ? 'Active settings' : 'Settings',
|
|
123
|
+
});
|
|
124
|
+
expect(anchor.textContent).toBe('Settings');
|
|
78
125
|
});
|
|
79
126
|
});
|