Add Effect v4 support, Fast Refresh tooling, and revamped docs #56

Merged
Thilawyn merged 133 commits from next into master 2026-07-26 02:32:59 +02:00
3 changed files with 146 additions and 3 deletions
Showing only changes of commit a47ae73618 - Show all commits
+30 -3
View File
@@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as AsyncRouteImport } from './routes/async' import { Route as AsyncRouteImport } from './routes/async'
import { Route as BlankRouteImport } from './routes/blank' import { Route as BlankRouteImport } from './routes/blank'
import { Route as FormRouteImport } from './routes/form' import { Route as FormRouteImport } from './routes/form'
import { Route as LensformRouteImport } from './routes/lensform'
import { Route as QueryRouteImport } from './routes/query' import { Route as QueryRouteImport } from './routes/query'
import { Route as ResultRouteImport } from './routes/result' import { Route as ResultRouteImport } from './routes/result'
@@ -36,6 +37,11 @@ const FormRoute = FormRouteImport.update({
path: '/form', path: '/form',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const LensformRoute = LensformRouteImport.update({
id: '/lensform',
path: '/lensform',
getParentRoute: () => rootRouteImport,
} as any)
const QueryRoute = QueryRouteImport.update({ const QueryRoute = QueryRouteImport.update({
id: '/query', id: '/query',
path: '/query', path: '/query',
@@ -52,6 +58,7 @@ export interface FileRoutesByFullPath {
'/async': typeof AsyncRoute '/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/lensform': typeof LensformRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute '/result': typeof ResultRoute
} }
@@ -60,6 +67,7 @@ export interface FileRoutesByTo {
'/async': typeof AsyncRoute '/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/lensform': typeof LensformRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute '/result': typeof ResultRoute
} }
@@ -69,15 +77,25 @@ export interface FileRoutesById {
'/async': typeof AsyncRoute '/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/lensform': typeof LensformRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute '/result': typeof ResultRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/async' | '/blank' | '/form' | '/query' | '/result' fullPaths:
'/' | '/async' | '/blank' | '/form' | '/lensform' | '/query' | '/result'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/async' | '/blank' | '/form' | '/query' | '/result' to: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query' | '/result'
id: '__root__' | '/' | '/async' | '/blank' | '/form' | '/query' | '/result' id:
| '__root__'
| '/'
| '/async'
| '/blank'
| '/form'
| '/lensform'
| '/query'
| '/result'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -85,6 +103,7 @@ export interface RootRouteChildren {
AsyncRoute: typeof AsyncRoute AsyncRoute: typeof AsyncRoute
BlankRoute: typeof BlankRoute BlankRoute: typeof BlankRoute
FormRoute: typeof FormRoute FormRoute: typeof FormRoute
LensformRoute: typeof LensformRoute
QueryRoute: typeof QueryRoute QueryRoute: typeof QueryRoute
ResultRoute: typeof ResultRoute ResultRoute: typeof ResultRoute
} }
@@ -119,6 +138,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof FormRouteImport preLoaderRoute: typeof FormRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/lensform': {
id: '/lensform'
path: '/lensform'
fullPath: '/lensform'
preLoaderRoute: typeof LensformRouteImport
parentRoute: typeof rootRouteImport
}
'/query': { '/query': {
id: '/query' id: '/query'
path: '/query' path: '/query'
@@ -141,6 +167,7 @@ const rootRouteChildren: RootRouteChildren = {
AsyncRoute: AsyncRoute, AsyncRoute: AsyncRoute,
BlankRoute: BlankRoute, BlankRoute: BlankRoute,
FormRoute: FormRoute, FormRoute: FormRoute,
LensformRoute: LensformRoute,
QueryRoute: QueryRoute, QueryRoute: QueryRoute,
ResultRoute: ResultRoute, ResultRoute: ResultRoute,
} }
@@ -21,6 +21,7 @@ function Root() {
<Link to="/query">Query</Link> <Link to="/query">Query</Link>
<Link to="/result">Result</Link> <Link to="/result">Result</Link>
<Link to="/form">Form</Link> <Link to="/form">Form</Link>
<Link to="/lensform">LensForm</Link>
</Flex> </Flex>
</Container> </Container>
@@ -0,0 +1,115 @@
import { Button, Container, Flex, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Context, Effect, Layer, Schema, Stream, SubscriptionRef } from "effect"
import { Component, Form, Lens, LensForm, MutationForm, View } from "effect-fc-next"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { runtime } from "@/runtime"
const UserProfileSchema = Schema.Struct({
email: Schema.String.check(
Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {
message: "Enter a valid email address",
}),
),
password: Schema.String.check(
Schema.isMinLength(5, {
message: "Password must be at least 5 characters long",
}),
),
})
const AppStateSchema = Schema.Struct({
currentUser: UserProfileSchema,
})
class AppState extends Context.Service<AppState, {
readonly lens: Lens.Lens<typeof AppStateSchema.Type>
}>()("AppState") {
static readonly layer = Layer.effect(
AppState,
Effect.gen(function*() {
return {
lens: Lens.fromSubscriptionRef(
yield* SubscriptionRef.make<typeof AppStateSchema.Type>({
currentUser: {
email: "",
password: "",
},
})
),
}
}),
)
}
const LensFormPageView = Component.make("LensFormPageView")(function*() {
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("LensForm route unmounted"))
yield* Console.log("LensForm route mounted")
}))
const context = yield* Component.useContextFromLayer(AppState.layer)
const UserProfileEditor = yield* Effect.provide(UserProfileEditorView.use, context)
return <UserProfileEditor />
})
const UserProfileEditorView = Component.make("UserProfileEditorView")(function*() {
const appState = yield* AppState
const [
emailField,
passwordField,
] = yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.forkScoped(
Stream.runForEach(Lens.changes(appState.lens), Console.log)
)
const form = yield* LensForm.service({
schema: UserProfileSchema,
target: Lens.focusObjectOn(appState.lens, "currentUser"),
initialEncodedValue: {
email: "",
password: "",
},
})
const emailField = Form.focusObjectOn(form, "email")
const passwordField = Form.focusObjectOn(form, "password")
return [emailField, passwordField] as const
}))
const TextFieldFormInput = yield* TextFieldFormInputView.use
return (
<Container width="300">
<form onSubmit={event => {
event.preventDefault()
}}>
<Flex direction="column" gap="2">
<TextFieldFormInput
form={emailField}
placeholder="Email"
debounce="250 millis"
/>
<TextFieldFormInput
form={passwordField}
placeholder="Password"
type="password"
debounce="250 millis"
/>
</Flex>
</form>
</Container>
)
})
export const Route = createFileRoute("/lensform")({
component: Component.withContext(LensFormPageView, runtime.context),
})