@effectify/react-router 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -8
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/lib/http-api-handler.d.ts +13 -0
- package/dist/src/lib/http-api-handler.js +17 -0
- package/dist/src/lib/http-response.d.ts +22 -10
- package/dist/src/lib/http-response.js +9 -2
- package/dist/src/lib/runtime.d.ts +8 -5
- package/dist/src/lib/runtime.js +80 -26
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -41,15 +41,20 @@ Use the Effect-based loaders and actions in your React Router routes:
|
|
|
41
41
|
```typescript
|
|
42
42
|
// routes/home.tsx
|
|
43
43
|
import type * as Route from "./+types.home";
|
|
44
|
-
import {
|
|
44
|
+
import { httpSuccess, httpFailure, LoaderArgsContext } from "@effectify/react-router";
|
|
45
45
|
import { withLoaderEffect } from "~/lib/server-runtime";
|
|
46
|
-
import * as
|
|
46
|
+
import * as Effect from "effect/Effect"
|
|
47
47
|
|
|
48
48
|
export const loader = withLoaderEffect(
|
|
49
|
-
|
|
49
|
+
Effect.gen(function* () {
|
|
50
50
|
const { request } = yield* LoaderArgsContext
|
|
51
|
-
yield*
|
|
52
|
-
|
|
51
|
+
yield* Effect.log("request", request)
|
|
52
|
+
|
|
53
|
+
// Improved DX: Simple syntax for success responses
|
|
54
|
+
return yield* httpSuccess({ hello: 'world' })
|
|
55
|
+
|
|
56
|
+
// For error responses, use:
|
|
57
|
+
// return yield* httpFailure("Something went wrong")
|
|
53
58
|
})
|
|
54
59
|
)
|
|
55
60
|
|
|
@@ -63,6 +68,40 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|
|
63
68
|
}
|
|
64
69
|
```
|
|
65
70
|
|
|
71
|
+
## Improved Developer Experience
|
|
72
|
+
|
|
73
|
+
The library provides helper functions for better DX when returning HTTP responses:
|
|
74
|
+
|
|
75
|
+
### Success Responses
|
|
76
|
+
|
|
77
|
+
Instead of the verbose:
|
|
78
|
+
```typescript
|
|
79
|
+
return yield* Effect.succeed(new HttpResponseSuccess({ data: { hello: 'world' }}))
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Use the simplified syntax:
|
|
83
|
+
```typescript
|
|
84
|
+
return yield* httpSuccess({ hello: 'world' })
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Error Responses
|
|
88
|
+
|
|
89
|
+
For error handling, use the `httpFailure` helper:
|
|
90
|
+
```typescript
|
|
91
|
+
return yield* httpFailure("Something went wrong")
|
|
92
|
+
// or with more complex error objects
|
|
93
|
+
return yield* httpFailure({ code: 'VALIDATION_ERROR', message: 'Invalid input' })
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Redirects
|
|
97
|
+
|
|
98
|
+
For redirects, use the `httpRedirect` helper:
|
|
99
|
+
```typescript
|
|
100
|
+
return yield* httpRedirect('/login')
|
|
101
|
+
// or with custom status/headers
|
|
102
|
+
return yield* httpRedirect('/dashboard', { status: 301 })
|
|
103
|
+
```
|
|
104
|
+
|
|
66
105
|
## API
|
|
67
106
|
|
|
68
107
|
### `make(layers)`
|
|
@@ -88,9 +127,49 @@ Effect context providing access to React Router loader arguments including:
|
|
|
88
127
|
|
|
89
128
|
### Response Types
|
|
90
129
|
|
|
91
|
-
- `
|
|
92
|
-
- `
|
|
93
|
-
- `
|
|
130
|
+
- `HttpResponseSuccess<T>(data)`: Successful HTTP response with data
|
|
131
|
+
- `HttpResponseFailure<T>(cause)`: Failed HTTP response with cause
|
|
132
|
+
- `HttpResponseRedirect(to, init?)`: HTTP redirect response
|
|
133
|
+
|
|
134
|
+
### Helper Functions
|
|
135
|
+
|
|
136
|
+
- `httpSuccess<T>(data: T)`: Creates a successful Effect with HttpResponseSuccess
|
|
137
|
+
- `httpFailure<T>(cause: T)`: Creates a successful Effect with HttpResponseFailure
|
|
138
|
+
- `httpRedirect(to: string, init?: ResponseInit)`: Creates a successful Effect with HttpResponseRedirect
|
|
139
|
+
|
|
140
|
+
### Error Handling & Logging
|
|
141
|
+
|
|
142
|
+
The library provides comprehensive error handling with full ErrorBoundary support:
|
|
143
|
+
|
|
144
|
+
- **Automatic Error Logging**: Errors are automatically logged using `Effect.logError`
|
|
145
|
+
- **ErrorBoundary Compatible**: Loader errors are properly thrown as `Response` objects for React Router ErrorBoundary
|
|
146
|
+
- **Configurable Logging**: Users can configure their own logging system through Effect layers
|
|
147
|
+
- **Non-blocking**: Logging doesn't block the main thread
|
|
148
|
+
- **Structured Logging**: Errors are logged with context and structured data
|
|
149
|
+
- **Error Preservation**: Original error context is preserved for better debugging
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
// The library automatically logs errors like this:
|
|
153
|
+
Effect.tapError((cause) => Effect.logError('Loader effect failed', cause))
|
|
154
|
+
|
|
155
|
+
// Loader errors are automatically converted to Response objects for ErrorBoundary:
|
|
156
|
+
// - Effect errors → Response with { ok: false, errors: [...] } and status 500
|
|
157
|
+
// - HttpResponseFailure → Response with { ok: false, errors: [...] } and status 500
|
|
158
|
+
// - Original Response/Error objects are preserved
|
|
159
|
+
|
|
160
|
+
// Users can configure custom logging through Effect layers
|
|
161
|
+
const customLogger = Logger.make(({ message, cause }) => {
|
|
162
|
+
// Custom logging implementation
|
|
163
|
+
console.log(`[${new Date().toISOString()}] ${message}`, cause)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const runtime = make(pipe(
|
|
167
|
+
// Your app layers
|
|
168
|
+
MyAppLayer,
|
|
169
|
+
// Custom logger layer
|
|
170
|
+
Logger.replace(Logger.defaultLogger, customLogger)
|
|
171
|
+
))
|
|
172
|
+
```
|
|
94
173
|
|
|
95
174
|
## Requirements
|
|
96
175
|
|
package/dist/src/index.d.ts
CHANGED
package/dist/src/index.js
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** biome-ignore-all lint/suspicious/noConsole: <debug> */
|
|
2
|
+
import type * as HttpApi from '@effect/platform/HttpApi';
|
|
3
|
+
import * as HttpApiScalar from '@effect/platform/HttpApiScalar';
|
|
4
|
+
import * as Layer from 'effect/Layer';
|
|
5
|
+
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
|
6
|
+
export type HttpApiOptions = {
|
|
7
|
+
apiLive: Layer.Layer<HttpApi.Api, never, never>;
|
|
8
|
+
scalar?: HttpApiScalar.ScalarConfig;
|
|
9
|
+
};
|
|
10
|
+
export type RoutePath = '/' | `/${string}/`;
|
|
11
|
+
export declare const make: (options: HttpApiOptions & {
|
|
12
|
+
pathPrefix?: RoutePath;
|
|
13
|
+
}) => ({ request }: ActionFunctionArgs | LoaderFunctionArgs) => Promise<Response>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as HttpApiBuilder from '@effect/platform/HttpApiBuilder';
|
|
2
|
+
import * as HttpApiScalar from '@effect/platform/HttpApiScalar';
|
|
3
|
+
import * as HttpServer from '@effect/platform/HttpServer';
|
|
4
|
+
import { Option } from 'effect';
|
|
5
|
+
import { pipe } from 'effect/Function';
|
|
6
|
+
import * as Layer from 'effect/Layer';
|
|
7
|
+
export const make = (options) => ({ request }) => pipe(Option.fromNullable(options.scalar), Option.map((scalar) => HttpApiScalar.layer({
|
|
8
|
+
path: `${options.pathPrefix || '/api/'}docs`,
|
|
9
|
+
scalar: {
|
|
10
|
+
...scalar,
|
|
11
|
+
baseServerURL: new URL(request.url).origin,
|
|
12
|
+
},
|
|
13
|
+
}).pipe(Layer.provide(options.apiLive))), Option.getOrElse(() => Layer.empty), (ApiDocsLive) => {
|
|
14
|
+
const EnvLive = Layer.mergeAll(options.apiLive, ApiDocsLive, HttpServer.layerContext);
|
|
15
|
+
const { handler } = HttpApiBuilder.toWebHandler(EnvLive);
|
|
16
|
+
return handler(request);
|
|
17
|
+
});
|
|
@@ -1,21 +1,33 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import * as Effect from 'effect/Effect';
|
|
2
|
+
declare const HttpResponseSuccess_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => Readonly<A> & {
|
|
3
|
+
readonly _tag: "HttpResponseSuccess";
|
|
3
4
|
};
|
|
4
|
-
export declare class
|
|
5
|
+
export declare class HttpResponseSuccess<T> extends HttpResponseSuccess_base<{
|
|
5
6
|
readonly data: T;
|
|
6
7
|
}> {
|
|
7
8
|
}
|
|
8
|
-
declare const
|
|
9
|
-
readonly _tag: "
|
|
9
|
+
declare const HttpResponseFailure_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => Readonly<A> & {
|
|
10
|
+
readonly _tag: "HttpResponseFailure";
|
|
10
11
|
};
|
|
11
|
-
export declare class
|
|
12
|
+
export declare class HttpResponseFailure<T = unknown> extends HttpResponseFailure_base<{
|
|
13
|
+
readonly cause: T;
|
|
14
|
+
}> {
|
|
15
|
+
}
|
|
16
|
+
declare const HttpResponseRedirect_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => Readonly<A> & {
|
|
17
|
+
readonly _tag: "HttpResponseRedirect";
|
|
18
|
+
};
|
|
19
|
+
export declare class HttpResponseRedirect extends HttpResponseRedirect_base<{
|
|
12
20
|
readonly to: string;
|
|
13
21
|
readonly init?: number | ResponseInit | undefined;
|
|
14
22
|
}> {
|
|
15
23
|
}
|
|
16
|
-
export type HttpResponse<T> =
|
|
24
|
+
export type HttpResponse<T> = HttpResponseRedirect | HttpResponseSuccess<T> | HttpResponseFailure<unknown>;
|
|
17
25
|
export declare const matchHttpResponse: <T>() => <P extends {
|
|
18
|
-
readonly
|
|
19
|
-
readonly
|
|
20
|
-
|
|
26
|
+
readonly HttpResponseSuccess: (_: HttpResponseSuccess<T>) => any;
|
|
27
|
+
readonly HttpResponseFailure: (_: HttpResponseFailure<unknown>) => any;
|
|
28
|
+
readonly HttpResponseRedirect: (_: HttpResponseRedirect) => any;
|
|
29
|
+
} & { readonly [Tag in Exclude<keyof P, "HttpResponseSuccess" | "HttpResponseFailure" | "HttpResponseRedirect">]: never; }>(fields: P) => (input: HttpResponse<T>) => import("effect/Unify").Unify<ReturnType<P[keyof P]>>;
|
|
30
|
+
export declare const httpSuccess: <T>(data: T) => Effect.Effect<HttpResponseSuccess<T>, never, never>;
|
|
31
|
+
export declare const httpFailure: <T = unknown>(cause: T) => Effect.Effect<HttpResponseFailure<T>, never, never>;
|
|
32
|
+
export declare const httpRedirect: (to: string, init?: number | ResponseInit) => Effect.Effect<HttpResponseRedirect, never, never>;
|
|
21
33
|
export {};
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import * as Data from 'effect/Data';
|
|
2
|
+
import * as Effect from 'effect/Effect';
|
|
2
3
|
import * as Match from 'effect/Match';
|
|
3
|
-
export class
|
|
4
|
+
export class HttpResponseSuccess extends Data.TaggedClass('HttpResponseSuccess') {
|
|
4
5
|
}
|
|
5
|
-
export class
|
|
6
|
+
export class HttpResponseFailure extends Data.TaggedClass('HttpResponseFailure') {
|
|
7
|
+
}
|
|
8
|
+
export class HttpResponseRedirect extends Data.TaggedClass('HttpResponseRedirect') {
|
|
6
9
|
}
|
|
7
10
|
export const matchHttpResponse = () => Match.typeTags();
|
|
11
|
+
// Helper functions for better DX
|
|
12
|
+
export const httpSuccess = (data) => Effect.succeed(new HttpResponseSuccess({ data }));
|
|
13
|
+
export const httpFailure = (cause) => Effect.succeed(new HttpResponseFailure({ cause }));
|
|
14
|
+
export const httpRedirect = (to, init) => Effect.succeed(new HttpResponseRedirect({ to, init }));
|
|
@@ -1,21 +1,24 @@
|
|
|
1
|
-
import * as
|
|
1
|
+
import * as Effect from 'effect/Effect';
|
|
2
2
|
import type * as Layer from 'effect/Layer';
|
|
3
3
|
import { type ActionFunctionArgs, type LoaderFunctionArgs } from 'react-router';
|
|
4
4
|
import { ActionArgsContext, LoaderArgsContext } from '../lib/context.js';
|
|
5
5
|
import { type HttpResponse } from '../lib/http-response.js';
|
|
6
6
|
export declare const make: <R, E>(layer: Layer.Layer<R, E, never>) => {
|
|
7
|
-
withLoaderEffect: <A, B>(self:
|
|
7
|
+
withLoaderEffect: <A, B>(self: Effect.Effect<HttpResponse<A> | Response, B, R | LoaderArgsContext>) => (args: LoaderFunctionArgs) => Promise<{
|
|
8
8
|
ok: true;
|
|
9
9
|
data: A;
|
|
10
10
|
} | {
|
|
11
11
|
ok: false;
|
|
12
12
|
errors: string[];
|
|
13
13
|
}>;
|
|
14
|
-
withActionEffect: <A, B_1>(self:
|
|
14
|
+
withActionEffect: <A, B_1>(self: Effect.Effect<HttpResponse<A> | Response, B_1, R | ActionArgsContext>) => (args: ActionFunctionArgs) => Promise<Response | import("react-router").UNSAFE_DataWithResponseInit<{
|
|
15
15
|
ok: false;
|
|
16
|
-
errors:
|
|
16
|
+
errors: string[];
|
|
17
17
|
}> | {
|
|
18
18
|
ok: true;
|
|
19
19
|
response: A;
|
|
20
|
-
} |
|
|
20
|
+
} | import("react-router").UNSAFE_DataWithResponseInit<{
|
|
21
|
+
ok: false;
|
|
22
|
+
errors: string[];
|
|
23
|
+
}>>;
|
|
21
24
|
};
|
package/dist/src/lib/runtime.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as
|
|
1
|
+
import * as Effect from 'effect/Effect';
|
|
2
2
|
import * as Exit from 'effect/Exit';
|
|
3
3
|
import { pipe } from 'effect/Function';
|
|
4
4
|
import * as Logger from 'effect/Logger';
|
|
@@ -9,41 +9,95 @@ import { matchHttpResponse } from '../lib/http-response.js';
|
|
|
9
9
|
export const make = (layer) => {
|
|
10
10
|
const runtime = ManagedRuntime.make(layer);
|
|
11
11
|
const withLoaderEffect = (self) => (args) => {
|
|
12
|
-
const runnable = pipe(self,
|
|
12
|
+
const runnable = pipe(self, Effect.provide(Logger.pretty), Effect.provideService(LoaderArgsContext, args), Effect.tapError((cause) => Effect.logError('Loader effect failed', cause)));
|
|
13
13
|
return runtime.runPromiseExit(runnable).then(Exit.match({
|
|
14
14
|
onFailure: (cause) => {
|
|
15
|
-
console.error(cause);
|
|
16
15
|
if (cause._tag === 'Fail') {
|
|
17
|
-
|
|
16
|
+
// Preserve the original error for ErrorBoundary
|
|
17
|
+
const error = cause.error;
|
|
18
|
+
if (error instanceof Response) {
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
if (error instanceof Error) {
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
// Convert other errors to Response for ErrorBoundary with ok: false
|
|
25
|
+
const errorData = { ok: false, errors: [String(error)] };
|
|
26
|
+
throw new Response(JSON.stringify(errorData), {
|
|
27
|
+
status: 500,
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
});
|
|
18
30
|
}
|
|
19
|
-
//
|
|
20
|
-
|
|
31
|
+
// Handle other types of failures (interrupts, defects, etc.)
|
|
32
|
+
console.error('Runtime execution failed with defect:', JSON.stringify(cause, null, 2));
|
|
33
|
+
const errorData = { ok: false, errors: ['Internal server error'] };
|
|
34
|
+
throw new Response(JSON.stringify(errorData), {
|
|
35
|
+
status: 500,
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
onSuccess: (result) => {
|
|
40
|
+
// If the result is a Response, throw it directly to preserve headers (including Set-Cookie)
|
|
41
|
+
// This is how React Router expects to handle Responses from loaders/actions
|
|
42
|
+
if (result instanceof Response) {
|
|
43
|
+
throw result;
|
|
44
|
+
}
|
|
45
|
+
// Otherwise, match the HttpResponse types
|
|
46
|
+
return matchHttpResponse()({
|
|
47
|
+
HttpResponseSuccess: ({ data: response }) => ({ ok: true, data: response }),
|
|
48
|
+
HttpResponseFailure: ({ cause }) => {
|
|
49
|
+
// Convert HttpResponseFailure to Response for ErrorBoundary with ok: false
|
|
50
|
+
const errorMessage = typeof cause === 'string' ? cause : String(cause);
|
|
51
|
+
const errorData = { ok: false, errors: [errorMessage] };
|
|
52
|
+
throw new Response(JSON.stringify(errorData), {
|
|
53
|
+
status: 500,
|
|
54
|
+
headers: { 'Content-Type': 'application/json' },
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
HttpResponseRedirect: ({ to, init = {} }) => {
|
|
58
|
+
redirect(to, init);
|
|
59
|
+
return { ok: false, errors: ['Redirecting...'] };
|
|
60
|
+
},
|
|
61
|
+
})(result);
|
|
21
62
|
},
|
|
22
|
-
onSuccess: matchHttpResponse()({
|
|
23
|
-
Ok: ({ data: response }) => {
|
|
24
|
-
return { ok: true, data: response };
|
|
25
|
-
},
|
|
26
|
-
Redirect: ({ to, init = {} }) => {
|
|
27
|
-
redirect(to, init);
|
|
28
|
-
return { ok: false, errors: ['Redirecting...'] };
|
|
29
|
-
},
|
|
30
|
-
}),
|
|
31
63
|
}));
|
|
32
64
|
};
|
|
33
65
|
// Don't throw the Error requests, handle them in the normal UI. No ErrorBoundary
|
|
34
66
|
const withActionEffect = (self) => (args) => {
|
|
35
|
-
const runnable = pipe(self,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
67
|
+
const runnable = pipe(self, Effect.provide(Logger.pretty), Effect.provideService(ActionArgsContext, args), Effect.tapError((cause) => {
|
|
68
|
+
// Don't log if it's a Response - that's expected behavior
|
|
69
|
+
if (!(cause instanceof Response)) {
|
|
70
|
+
return Effect.logError('Action effect failed', cause);
|
|
71
|
+
}
|
|
72
|
+
return Effect.void;
|
|
73
|
+
}));
|
|
74
|
+
return runtime.runPromiseExit(runnable).then(Exit.match({
|
|
75
|
+
onFailure: (cause) => {
|
|
76
|
+
if (cause._tag === 'Fail') {
|
|
77
|
+
const error = cause.error;
|
|
78
|
+
// If the error is a Response, throw it directly to preserve headers
|
|
79
|
+
if (error instanceof Response) {
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
return data({ ok: false, errors: [String(error)] }, { status: 400 });
|
|
83
|
+
}
|
|
84
|
+
// Handle other types of failures
|
|
85
|
+
return data({ ok: false, errors: ['Internal server error'] }, { status: 400 });
|
|
86
|
+
},
|
|
87
|
+
onSuccess: (result) => {
|
|
88
|
+
// If the result is a Response, throw it directly to preserve headers (including Set-Cookie)
|
|
89
|
+
// This is how React Router expects to handle Responses from actions
|
|
90
|
+
if (result instanceof Response) {
|
|
91
|
+
throw result;
|
|
92
|
+
}
|
|
93
|
+
// Otherwise, match the HttpResponse types
|
|
94
|
+
return matchHttpResponse()({
|
|
95
|
+
HttpResponseSuccess: ({ data: response }) => ({ ok: true, response }),
|
|
96
|
+
HttpResponseFailure: ({ cause }) => data({ ok: false, errors: [String(cause)] }, { status: 400 }),
|
|
97
|
+
HttpResponseRedirect: ({ to, init = {} }) => redirect(to, init),
|
|
98
|
+
})(result);
|
|
99
|
+
},
|
|
45
100
|
}));
|
|
46
|
-
return runtime.runPromise(runnable);
|
|
47
101
|
};
|
|
48
102
|
return { withLoaderEffect, withActionEffect };
|
|
49
103
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectify/react-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Integration of React Router with Effect for React applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
"!**/*.tsbuildinfo"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@effect/platform": "0.
|
|
26
|
-
"@effect/platform-node": "0.
|
|
27
|
-
"effect": "3.
|
|
28
|
-
"react-router": "7.9.
|
|
25
|
+
"@effect/platform": "0.93.3",
|
|
26
|
+
"@effect/platform-node": "0.101.1",
|
|
27
|
+
"effect": "3.19.6",
|
|
28
|
+
"react-router": "7.9.6"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {},
|
|
31
31
|
"peerDependencies": {},
|