Initial commit
This commit is contained in:
11
packages/effect-fc/README.md
Normal file
11
packages/effect-fc/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Reffuse
|
||||
|
||||
[Effect-TS](https://effect.website/) integration for React 19+ with the aim of integrating the Effect context system within React's component hierarchy, while avoiding touching React's internals.
|
||||
|
||||
This library is in early development. While it is (almost) feature complete and mostly usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are more than welcome.
|
||||
|
||||
Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory.
|
||||
|
||||
## Peer dependencies
|
||||
- `effect` 3.13+
|
||||
- `react` & `@types/react` 19+
|
||||
44
packages/effect-fc/package.json
Normal file
44
packages/effect-fc/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "effect-fc",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"./README.md",
|
||||
"./dist"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "git+https://github.com/Thiladev/effect-fc.git"
|
||||
},
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/types/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/*.d.ts",
|
||||
"default": "./dist/*.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint:tsc": "tsc --noEmit",
|
||||
"pack": "npm pack",
|
||||
"clean:cache": "rm -f tsconfig.tsbuildinfo",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:node": "rm -rf node_modules"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"effect": "^3.15.0",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/language-service": "^0.23.3"
|
||||
}
|
||||
}
|
||||
72
packages/effect-fc/src/ReactComponent.ts
Normal file
72
packages/effect-fc/src/ReactComponent.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Context, Effect, Function, Runtime, Scope, Tracer } from "effect"
|
||||
import type { Mutable } from "effect/Types"
|
||||
import * as React from "react"
|
||||
import * as ReactHook from "./ReactHook.js"
|
||||
|
||||
|
||||
export interface ReactComponent<E, R, P> {
|
||||
(props: P): Effect.Effect<React.ReactNode, E, R>
|
||||
readonly displayName?: string
|
||||
}
|
||||
|
||||
export const nonReactiveTags = [Tracer.ParentSpan] as const
|
||||
|
||||
export const withDisplayName: {
|
||||
<C extends ReactComponent<any, any, any>>(displayName: string): (self: C) => C
|
||||
<C extends ReactComponent<any, any, any>>(self: C, displayName: string): C
|
||||
} = Function.dual(2, <C extends ReactComponent<any, any, any>>(
|
||||
self: C,
|
||||
displayName: string,
|
||||
): C => {
|
||||
(self as Mutable<C>).displayName = displayName
|
||||
return self
|
||||
})
|
||||
|
||||
export const useFC: {
|
||||
<E, R, P extends {} = {}>(
|
||||
self: ReactComponent<E, R, P>,
|
||||
options?: ReactHook.ScopeOptions,
|
||||
): Effect.Effect<React.FC<P>, never, Exclude<R, Scope.Scope>>
|
||||
} = Effect.fnUntraced(function* <E, R, P extends {}>(
|
||||
self: ReactComponent<E, R, P>,
|
||||
options?: ReactHook.ScopeOptions,
|
||||
) {
|
||||
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
|
||||
|
||||
return React.useMemo(() => function ScopeProvider(props: P) {
|
||||
const scope = Runtime.runSync(runtime)(ReactHook.useScope(options))
|
||||
|
||||
const FC = React.useMemo(() => {
|
||||
const f = (props: P) => Runtime.runSync(runtime)(
|
||||
Effect.provideService(self(props), Scope.Scope, scope)
|
||||
)
|
||||
if (self.displayName) f.displayName = self.displayName
|
||||
return f
|
||||
}, [scope])
|
||||
|
||||
return React.createElement(FC, props)
|
||||
}, Array.from(
|
||||
Context.omit(...nonReactiveTags)(runtime.context).unsafeMap.values()
|
||||
))
|
||||
})
|
||||
|
||||
export const use: {
|
||||
<E, R, P extends {} = {}>(
|
||||
self: ReactComponent<E, R, P>,
|
||||
fn: (Component: React.FC<P>) => React.ReactNode,
|
||||
options?: ReactHook.ScopeOptions,
|
||||
): Effect.Effect<React.ReactNode, never, Exclude<R, Scope.Scope>>
|
||||
} = Effect.fnUntraced(function*(self, fn, options) {
|
||||
return fn(yield* useFC(self, options))
|
||||
})
|
||||
|
||||
export const withRuntime: {
|
||||
<E, R, P extends {} = {}>(context: React.Context<Runtime.Runtime<R>>): (self: ReactComponent<E, R, P>) => React.FC<P>
|
||||
<E, R, P extends {} = {}>(self: ReactComponent<E, R, P>, context: React.Context<Runtime.Runtime<R>>): React.FC<P>
|
||||
} = Function.dual(2, <E, R, P extends {}>(
|
||||
self: ReactComponent<E, R, P>,
|
||||
context: React.Context<Runtime.Runtime<R>>,
|
||||
): React.FC<P> => function WithRuntime(props) {
|
||||
const runtime = React.useContext(context)
|
||||
return React.createElement(Runtime.runSync(runtime)(useFC(self)), props)
|
||||
})
|
||||
317
packages/effect-fc/src/ReactHook.ts
Normal file
317
packages/effect-fc/src/ReactHook.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { type Context, Effect, ExecutionStrategy, Exit, type Layer, Option, pipe, PubSub, Ref, Runtime, Scope, Stream, SubscriptionRef } from "effect"
|
||||
import * as React from "react"
|
||||
import { SetStateAction } from "./types/index.js"
|
||||
|
||||
|
||||
export interface ScopeOptions {
|
||||
readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy
|
||||
readonly finalizerExecutionMode?: "sync" | "fork"
|
||||
}
|
||||
|
||||
|
||||
export const useScope: {
|
||||
(options?: ScopeOptions): Effect.Effect<Scope.Scope>
|
||||
} = Effect.fnUntraced(function* (options?: ScopeOptions) {
|
||||
const runtime = yield* Effect.runtime()
|
||||
|
||||
const [isInitialRun, initialScope] = React.useMemo(() => Runtime.runSync(runtime)(
|
||||
Effect.all([Ref.make(true), makeScope(options)])
|
||||
), [])
|
||||
const [scope, setScope] = React.useState(initialScope)
|
||||
|
||||
React.useEffect(() => Runtime.runSync(runtime)(
|
||||
Effect.if(isInitialRun, {
|
||||
onTrue: () => Effect.as(
|
||||
Ref.set(isInitialRun, false),
|
||||
() => closeScope(scope, runtime, options),
|
||||
),
|
||||
|
||||
onFalse: () => makeScope(options).pipe(
|
||||
Effect.tap(scope => Effect.sync(() => setScope(scope))),
|
||||
Effect.map(scope => () => closeScope(scope, runtime, options)),
|
||||
),
|
||||
})
|
||||
), [])
|
||||
|
||||
return scope
|
||||
})
|
||||
|
||||
const makeScope = (options?: ScopeOptions) => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
|
||||
const closeScope = (
|
||||
scope: Scope.CloseableScope,
|
||||
runtime: Runtime.Runtime<never>,
|
||||
options?: ScopeOptions,
|
||||
) => {
|
||||
switch (options?.finalizerExecutionMode ?? "sync") {
|
||||
case "sync":
|
||||
Runtime.runSync(runtime)(Scope.close(scope, Exit.void))
|
||||
break
|
||||
case "fork":
|
||||
Runtime.runFork(runtime)(Scope.close(scope, Exit.void))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const useMemo: {
|
||||
<A, E, R>(
|
||||
factory: () => Effect.Effect<A, E, R>,
|
||||
deps: React.DependencyList,
|
||||
): Effect.Effect<A, E, R>
|
||||
} = Effect.fnUntraced(function* <A, E, R>(
|
||||
factory: () => Effect.Effect<A, E, R>,
|
||||
deps: React.DependencyList,
|
||||
) {
|
||||
const runtime = yield* Effect.runtime()
|
||||
return yield* React.useMemo(() => Runtime.runSync(runtime)(Effect.cached(factory())), deps)
|
||||
})
|
||||
|
||||
export const useOnce: {
|
||||
<A, E, R>(factory: () => Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
|
||||
} = Effect.fnUntraced(function* <A, E, R>(
|
||||
factory: () => Effect.Effect<A, E, R>
|
||||
) {
|
||||
return yield* useMemo(factory, [])
|
||||
})
|
||||
|
||||
export const useMemoLayer: {
|
||||
<ROut, E, RIn>(
|
||||
layer: Layer.Layer<ROut, E, RIn>
|
||||
): Effect.Effect<Context.Context<ROut>, E, RIn>
|
||||
} = Effect.fnUntraced(function* <ROut, E, RIn>(
|
||||
layer: Layer.Layer<ROut, E, RIn>
|
||||
) {
|
||||
return yield* useMemo(() => Effect.provide(Effect.context<ROut>(), layer), [layer])
|
||||
})
|
||||
|
||||
|
||||
export const useCallbackSync: {
|
||||
<Args extends unknown[], A, E, R>(
|
||||
callback: (...args: Args) => Effect.Effect<A, E, R>,
|
||||
deps: React.DependencyList,
|
||||
): Effect.Effect<(...args: Args) => A, never, R>
|
||||
} = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
|
||||
callback: (...args: Args) => Effect.Effect<A, E, R>,
|
||||
deps: React.DependencyList,
|
||||
) {
|
||||
const runtime = yield* Effect.runtime<R>()
|
||||
return React.useCallback((...args: Args) => Runtime.runSync(runtime)(callback(...args)), deps)
|
||||
})
|
||||
|
||||
export const useCallbackPromise: {
|
||||
<Args extends unknown[], A, E, R>(
|
||||
callback: (...args: Args) => Effect.Effect<A, E, R>,
|
||||
deps: React.DependencyList,
|
||||
): Effect.Effect<(...args: Args) => Promise<A>, never, R>
|
||||
} = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
|
||||
callback: (...args: Args) => Effect.Effect<A, E, R>,
|
||||
deps: React.DependencyList,
|
||||
) {
|
||||
const runtime = yield* Effect.runtime<R>()
|
||||
return React.useCallback((...args: Args) => Runtime.runPromise(runtime)(callback(...args)), deps)
|
||||
})
|
||||
|
||||
|
||||
export const useEffect: {
|
||||
<E, R>(
|
||||
effect: () => Effect.Effect<void, E, R>,
|
||||
deps?: React.DependencyList,
|
||||
options?: ScopeOptions,
|
||||
): Effect.Effect<void, never, Exclude<R, Scope.Scope>>
|
||||
} = Effect.fnUntraced(function* <E, R>(
|
||||
effect: () => Effect.Effect<void, E, R>,
|
||||
deps?: React.DependencyList,
|
||||
options?: ScopeOptions,
|
||||
) {
|
||||
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
|
||||
|
||||
React.useEffect(() => {
|
||||
const { scope, exit } = Effect.Do.pipe(
|
||||
Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)),
|
||||
Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(effect(), Scope.Scope, scope))),
|
||||
Runtime.runSync(runtime),
|
||||
)
|
||||
|
||||
return () => {
|
||||
switch (options?.finalizerExecutionMode ?? "sync") {
|
||||
case "sync":
|
||||
Runtime.runSync(runtime)(Scope.close(scope, exit))
|
||||
break
|
||||
case "fork":
|
||||
Runtime.runFork(runtime)(Scope.close(scope, exit))
|
||||
break
|
||||
}
|
||||
}
|
||||
}, deps)
|
||||
})
|
||||
|
||||
export const useLayoutEffect: {
|
||||
<E, R>(
|
||||
effect: () => Effect.Effect<void, E, R>,
|
||||
deps?: React.DependencyList,
|
||||
options?: ScopeOptions,
|
||||
): Effect.Effect<void, never, Exclude<R, Scope.Scope>>
|
||||
} = Effect.fnUntraced(function* <E, R>(
|
||||
effect: () => Effect.Effect<void, E, R>,
|
||||
deps?: React.DependencyList,
|
||||
options?: ScopeOptions,
|
||||
) {
|
||||
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const { scope, exit } = Effect.Do.pipe(
|
||||
Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)),
|
||||
Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(effect(), Scope.Scope, scope))),
|
||||
Runtime.runSync(runtime),
|
||||
)
|
||||
|
||||
return () => {
|
||||
switch (options?.finalizerExecutionMode ?? "sync") {
|
||||
case "sync":
|
||||
Runtime.runSync(runtime)(Scope.close(scope, exit))
|
||||
break
|
||||
case "fork":
|
||||
Runtime.runFork(runtime)(Scope.close(scope, exit))
|
||||
break
|
||||
}
|
||||
}
|
||||
}, deps)
|
||||
})
|
||||
|
||||
export const useFork: {
|
||||
<E, R>(
|
||||
effect: () => Effect.Effect<void, E, R>,
|
||||
deps?: React.DependencyList,
|
||||
options?: Runtime.RunForkOptions & ScopeOptions,
|
||||
): Effect.Effect<void, never, Exclude<R, Scope.Scope>>
|
||||
} = Effect.fnUntraced(function* <E, R>(
|
||||
effect: () => Effect.Effect<void, E, R>,
|
||||
deps?: React.DependencyList,
|
||||
options?: Runtime.RunForkOptions & ScopeOptions,
|
||||
) {
|
||||
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
|
||||
|
||||
React.useEffect(() => {
|
||||
const scope = Runtime.runSync(runtime)(options?.scope
|
||||
? Scope.fork(options.scope, options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
|
||||
: Scope.make(options?.finalizerExecutionStrategy)
|
||||
)
|
||||
Runtime.runFork(runtime)(Effect.provideService(effect(), Scope.Scope, scope), { ...options, scope })
|
||||
|
||||
return () => {
|
||||
switch (options?.finalizerExecutionMode ?? "fork") {
|
||||
case "sync":
|
||||
Runtime.runSync(runtime)(Scope.close(scope, Exit.void))
|
||||
break
|
||||
case "fork":
|
||||
Runtime.runFork(runtime)(Scope.close(scope, Exit.void))
|
||||
break
|
||||
}
|
||||
}
|
||||
}, deps)
|
||||
})
|
||||
|
||||
|
||||
export const useRefFromReactiveValue: {
|
||||
<A>(value: A): Effect.Effect<SubscriptionRef.SubscriptionRef<A>>
|
||||
} = Effect.fnUntraced(function*(value) {
|
||||
const ref = yield* useOnce(() => SubscriptionRef.make(value))
|
||||
yield* useEffect(() => Ref.set(ref, value), [value])
|
||||
return ref
|
||||
})
|
||||
|
||||
export const useSubscribeRefs: {
|
||||
<const Refs extends readonly SubscriptionRef.SubscriptionRef<any>[]>(
|
||||
...refs: Refs
|
||||
): Effect.Effect<{ [K in keyof Refs]: Effect.Effect.Success<Refs[K]> }>
|
||||
} = Effect.fnUntraced(function* <const Refs extends readonly SubscriptionRef.SubscriptionRef<any>[]>(
|
||||
...refs: Refs
|
||||
) {
|
||||
const [reactStateValue, setReactStateValue] = React.useState(yield* useOnce(() =>
|
||||
Effect.all(refs as readonly SubscriptionRef.SubscriptionRef<any>[])
|
||||
))
|
||||
|
||||
yield* useFork(() => pipe(
|
||||
refs.map(ref => Stream.changesWith(ref.changes, (x, y) => x === y)),
|
||||
streams => Stream.zipLatestAll(...streams),
|
||||
Stream.runForEach(v =>
|
||||
Effect.sync(() => setReactStateValue(v))
|
||||
),
|
||||
), refs)
|
||||
|
||||
return reactStateValue as any
|
||||
})
|
||||
|
||||
export const useRefState: {
|
||||
<A>(
|
||||
ref: SubscriptionRef.SubscriptionRef<A>
|
||||
): Effect.Effect<readonly [A, React.Dispatch<React.SetStateAction<A>>]>
|
||||
} = Effect.fnUntraced(function* <A>(ref: SubscriptionRef.SubscriptionRef<A>) {
|
||||
const [reactStateValue, setReactStateValue] = React.useState(yield* useOnce(() => ref))
|
||||
|
||||
yield* useFork(() => Stream.runForEach(
|
||||
Stream.changesWith(ref.changes, (x, y) => x === y),
|
||||
v => Effect.sync(() => setReactStateValue(v)),
|
||||
), [ref])
|
||||
|
||||
const setValue = yield* useCallbackSync((setStateAction: React.SetStateAction<A>) =>
|
||||
Ref.update(ref, prevState =>
|
||||
SetStateAction.value(setStateAction, prevState)
|
||||
),
|
||||
[ref])
|
||||
|
||||
return [reactStateValue, setValue]
|
||||
})
|
||||
|
||||
|
||||
export const useStreamFromReactiveValues: {
|
||||
<const A extends React.DependencyList>(
|
||||
values: A
|
||||
): Effect.Effect<Stream.Stream<A>, never, Scope.Scope>
|
||||
} = Effect.fnUntraced(function* <const A extends React.DependencyList>(values: A) {
|
||||
const { latest, pubsub, stream } = yield* useOnce(() => Effect.Do.pipe(
|
||||
Effect.bind("latest", () => Ref.make(values)),
|
||||
Effect.bind("pubsub", () => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown)),
|
||||
Effect.let("stream", ({ latest, pubsub }) => latest.pipe(
|
||||
Effect.flatMap(a => Effect.map(
|
||||
Stream.fromPubSub(pubsub, { scoped: true }),
|
||||
s => Stream.concat(Stream.make(a), s),
|
||||
)),
|
||||
Stream.unwrapScoped,
|
||||
)),
|
||||
))
|
||||
|
||||
yield* useEffect(() => Ref.set(latest, values).pipe(
|
||||
Effect.andThen(PubSub.publish(pubsub, values)),
|
||||
Effect.unlessEffect(PubSub.isShutdown(pubsub)),
|
||||
), values)
|
||||
|
||||
return stream
|
||||
})
|
||||
|
||||
export const useSubscribeStream: {
|
||||
<A, E, R>(
|
||||
stream: Stream.Stream<A, E, R>
|
||||
): Effect.Effect<Option.Option<A>, never, R>
|
||||
<A extends NonNullable<unknown>, E, R>(
|
||||
stream: Stream.Stream<A, E, R>,
|
||||
initialValue: A,
|
||||
): Effect.Effect<Option.Some<A>, never, R>
|
||||
} = Effect.fnUntraced(function* <A extends NonNullable<unknown>, E, R>(
|
||||
stream: Stream.Stream<A, E, R>,
|
||||
initialValue?: A,
|
||||
) {
|
||||
const [reactStateValue, setReactStateValue] = React.useState(
|
||||
React.useMemo(() => initialValue
|
||||
? Option.some(initialValue)
|
||||
: Option.none(),
|
||||
[])
|
||||
)
|
||||
|
||||
yield* useFork(() => Stream.runForEach(
|
||||
Stream.changesWith(stream, (x, y) => x === y),
|
||||
v => Effect.sync(() => setReactStateValue(Option.some(v))),
|
||||
), [stream])
|
||||
|
||||
return reactStateValue as Option.Some<A>
|
||||
})
|
||||
47
packages/effect-fc/src/ReactManagedRuntime.ts
Normal file
47
packages/effect-fc/src/ReactManagedRuntime.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Effect, type Layer, ManagedRuntime, type Runtime } from "effect"
|
||||
import * as React from "react"
|
||||
|
||||
|
||||
export interface ReactManagedRuntime<R, ER> {
|
||||
readonly runtime: ManagedRuntime.ManagedRuntime<R, ER>
|
||||
readonly context: React.Context<Runtime.Runtime<R>>
|
||||
}
|
||||
|
||||
export const make = <R, ER>(
|
||||
layer: Layer.Layer<R, ER>,
|
||||
memoMap?: Layer.MemoMap,
|
||||
): ReactManagedRuntime<R, ER> => ({
|
||||
runtime: ManagedRuntime.make(layer, memoMap),
|
||||
context: React.createContext<Runtime.Runtime<R>>(null!),
|
||||
})
|
||||
|
||||
|
||||
export interface AsyncProviderProps<R, ER> extends React.SuspenseProps {
|
||||
readonly runtime: ReactManagedRuntime<R, ER>
|
||||
readonly children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function AsyncProvider<R, ER>(
|
||||
{ runtime, children, ...suspenseProps }: AsyncProviderProps<R, ER>
|
||||
): React.ReactNode {
|
||||
const promise = React.useMemo(() => Effect.runPromise(runtime.runtime.runtimeEffect), [runtime])
|
||||
|
||||
return React.createElement(
|
||||
React.Suspense,
|
||||
suspenseProps,
|
||||
React.createElement(AsyncProviderInner<R, ER>, { runtime, promise, children }),
|
||||
)
|
||||
}
|
||||
|
||||
interface AsyncProviderInnerProps<R, ER> {
|
||||
readonly runtime: ReactManagedRuntime<R, ER>
|
||||
readonly promise: Promise<Runtime.Runtime<R>>
|
||||
readonly children?: React.ReactNode
|
||||
}
|
||||
|
||||
function AsyncProviderInner<R, ER>(
|
||||
{ runtime, promise, children }: AsyncProviderInnerProps<R, ER>
|
||||
): React.ReactNode {
|
||||
const value = React.use(promise)
|
||||
return React.createElement(runtime.context, { value }, children)
|
||||
}
|
||||
3
packages/effect-fc/src/index.ts
Normal file
3
packages/effect-fc/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * as ReactComponent from "./ReactComponent.js"
|
||||
export * as ReactHook from "./ReactHook.js"
|
||||
export * as ReactManagedRuntime from "./ReactManagedRuntime.js"
|
||||
99
packages/effect-fc/src/types/PropertyPath.ts
Normal file
99
packages/effect-fc/src/types/PropertyPath.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Array, Function, Option, Predicate } from "effect"
|
||||
|
||||
|
||||
type Prev = readonly [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
export type Paths<T, D extends number = 5, Seen = never> = [] | (
|
||||
D extends never ? [] :
|
||||
T extends Seen ? [] :
|
||||
T extends readonly any[] ? ArrayPaths<T, D, Seen | T> :
|
||||
T extends object ? ObjectPaths<T, D, Seen | T> :
|
||||
never
|
||||
)
|
||||
|
||||
export type ArrayPaths<T extends readonly any[], D extends number, Seen> = {
|
||||
[K in keyof T as K extends number ? K : never]:
|
||||
| [K]
|
||||
| [K, ...Paths<T[K], Prev[D], Seen>]
|
||||
} extends infer O
|
||||
? O[keyof O]
|
||||
: never
|
||||
|
||||
export type ObjectPaths<T extends object, D extends number, Seen> = {
|
||||
[K in keyof T as K extends string | number | symbol ? K : never]-?:
|
||||
NonNullable<T[K]> extends infer V
|
||||
? [K] | [K, ...Paths<V, Prev[D], Seen>]
|
||||
: never
|
||||
} extends infer O
|
||||
? O[keyof O]
|
||||
: never
|
||||
|
||||
export type ValueFromPath<T, P extends any[]> = P extends [infer Head, ...infer Tail]
|
||||
? Head extends keyof T
|
||||
? ValueFromPath<T[Head], Tail>
|
||||
: T extends readonly any[]
|
||||
? Head extends number
|
||||
? ValueFromPath<T[number], Tail>
|
||||
: never
|
||||
: never
|
||||
: T
|
||||
|
||||
export type AnyKey = string | number | symbol
|
||||
export type AnyPath = readonly AnyKey[]
|
||||
|
||||
|
||||
export const unsafeGet: {
|
||||
<T, const P extends Paths<T>>(path: P): (self: T) => ValueFromPath<T, P>
|
||||
<T, const P extends Paths<T>>(self: T, path: P): ValueFromPath<T, P>
|
||||
} = Function.dual(2, <T, const P extends Paths<T>>(self: T, path: P): ValueFromPath<T, P> =>
|
||||
path.reduce((acc: any, key: any) => acc?.[key], self)
|
||||
)
|
||||
|
||||
export const get: {
|
||||
<T, const P extends Paths<T>>(path: P): (self: T) => Option.Option<ValueFromPath<T, P>>
|
||||
<T, const P extends Paths<T>>(self: T, path: P): Option.Option<ValueFromPath<T, P>>
|
||||
} = Function.dual(2, <T, const P extends Paths<T>>(self: T, path: P): Option.Option<ValueFromPath<T, P>> =>
|
||||
path.reduce(
|
||||
(acc: Option.Option<any>, key: any): Option.Option<any> => Option.isSome(acc)
|
||||
? Predicate.hasProperty(acc.value, key)
|
||||
? Option.some(acc.value[key])
|
||||
: Option.none()
|
||||
: acc,
|
||||
|
||||
Option.some(self),
|
||||
)
|
||||
)
|
||||
|
||||
export const immutableSet: {
|
||||
<T, const P extends Paths<T>>(path: P, value: ValueFromPath<T, P>): (self: T) => ValueFromPath<T, P>
|
||||
<T, const P extends Paths<T>>(self: T, path: P, value: ValueFromPath<T, P>): Option.Option<T>
|
||||
} = Function.dual(3, <T, const P extends Paths<T>>(self: T, path: P, value: ValueFromPath<T, P>): Option.Option<T> => {
|
||||
const key = Array.head(path as AnyPath)
|
||||
if (Option.isNone(key))
|
||||
return Option.some(value as T)
|
||||
if (!Predicate.hasProperty(self, key.value))
|
||||
return Option.none()
|
||||
|
||||
const child = immutableSet<any, any>(self[key.value], Option.getOrThrow(Array.tail(path as AnyPath)), value)
|
||||
if (Option.isNone(child))
|
||||
return child
|
||||
|
||||
if (Array.isArray(self))
|
||||
return typeof key.value === "number"
|
||||
? Option.some([
|
||||
...self.slice(0, key.value),
|
||||
child.value,
|
||||
...self.slice(key.value + 1),
|
||||
] as T)
|
||||
: Option.none()
|
||||
|
||||
if (typeof self === "object")
|
||||
return Option.some(
|
||||
Object.assign(
|
||||
Object.create(Object.getPrototypeOf(self)),
|
||||
{ ...self, [key.value]: child.value },
|
||||
)
|
||||
)
|
||||
|
||||
return Option.none()
|
||||
})
|
||||
12
packages/effect-fc/src/types/SetStateAction.ts
Normal file
12
packages/effect-fc/src/types/SetStateAction.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Function } from "effect"
|
||||
import type * as React from "react"
|
||||
|
||||
|
||||
export const value: {
|
||||
<S>(prevState: S): (self: React.SetStateAction<S>) => S
|
||||
<S>(self: React.SetStateAction<S>, prevState: S): S
|
||||
} = Function.dual(2, <S>(self: React.SetStateAction<S>, prevState: S): S =>
|
||||
typeof self === "function"
|
||||
? (self as (prevState: S) => S)(prevState)
|
||||
: self
|
||||
)
|
||||
100
packages/effect-fc/src/types/SubscriptionSubRef.ts
Normal file
100
packages/effect-fc/src/types/SubscriptionSubRef.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Effect, Effectable, Option, Readable, Ref, Stream, Subscribable, SubscriptionRef, SynchronizedRef, type Types, type Unify } from "effect"
|
||||
import * as PropertyPath from "./PropertyPath.js"
|
||||
|
||||
|
||||
export const SubscriptionSubRefTypeId: unique symbol = Symbol.for("reffuse/types/SubscriptionSubRef")
|
||||
export type SubscriptionSubRefTypeId = typeof SubscriptionSubRefTypeId
|
||||
|
||||
export interface SubscriptionSubRef<in out A, in out B> extends SubscriptionSubRef.Variance<A, B>, SubscriptionRef.SubscriptionRef<A> {
|
||||
readonly parent: SubscriptionRef.SubscriptionRef<B>
|
||||
|
||||
readonly [Unify.typeSymbol]?: unknown
|
||||
readonly [Unify.unifySymbol]?: SubscriptionSubRefUnify<this>
|
||||
readonly [Unify.ignoreSymbol]?: SubscriptionSubRefUnifyIgnore
|
||||
}
|
||||
|
||||
export declare namespace SubscriptionSubRef {
|
||||
export interface Variance<in out A, in out B> {
|
||||
readonly [SubscriptionSubRefTypeId]: {
|
||||
readonly _A: Types.Invariant<A>
|
||||
readonly _B: Types.Invariant<B>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubscriptionSubRefUnify<A extends { [Unify.typeSymbol]?: any }> extends SubscriptionRef.SubscriptionRefUnify<A> {
|
||||
SubscriptionSubRef?: () => Extract<A[Unify.typeSymbol], SubscriptionSubRef<any, any>>
|
||||
}
|
||||
|
||||
export interface SubscriptionSubRefUnifyIgnore extends SubscriptionRef.SubscriptionRefUnifyIgnore {
|
||||
SubscriptionRef?: true
|
||||
}
|
||||
|
||||
|
||||
const refVariance = { _A: (_: any) => _ }
|
||||
const synchronizedRefVariance = { _A: (_: any) => _ }
|
||||
const subscriptionRefVariance = { _A: (_: any) => _ }
|
||||
const subscriptionSubRefVariance = { _A: (_: any) => _, _B: (_: any) => _ }
|
||||
|
||||
class SubscriptionSubRefImpl<in out A, in out B> extends Effectable.Class<A> implements SubscriptionSubRef<A, B> {
|
||||
readonly [Readable.TypeId]: Readable.TypeId = Readable.TypeId
|
||||
readonly [Subscribable.TypeId]: Subscribable.TypeId = Subscribable.TypeId
|
||||
readonly [Ref.RefTypeId] = refVariance
|
||||
readonly [SynchronizedRef.SynchronizedRefTypeId] = synchronizedRefVariance
|
||||
readonly [SubscriptionRef.SubscriptionRefTypeId] = subscriptionRefVariance
|
||||
readonly [SubscriptionSubRefTypeId] = subscriptionSubRefVariance
|
||||
|
||||
readonly get: Effect.Effect<A>
|
||||
|
||||
constructor(
|
||||
readonly parent: SubscriptionRef.SubscriptionRef<B>,
|
||||
readonly getter: (parentValue: B) => A,
|
||||
readonly setter: (parentValue: B, value: A) => B,
|
||||
) {
|
||||
super()
|
||||
this.get = Effect.map(Ref.get(this.parent), this.getter)
|
||||
}
|
||||
|
||||
commit() {
|
||||
return this.get
|
||||
}
|
||||
|
||||
get changes(): Stream.Stream<A> {
|
||||
return this.get.pipe(
|
||||
Effect.map(a => this.parent.changes.pipe(
|
||||
Stream.map(this.getter),
|
||||
s => Stream.concat(Stream.make(a), s),
|
||||
)),
|
||||
Stream.unwrap,
|
||||
)
|
||||
}
|
||||
|
||||
modify<C>(f: (a: A) => readonly [C, A]): Effect.Effect<C> {
|
||||
return this.modifyEffect(a => Effect.succeed(f(a)))
|
||||
}
|
||||
|
||||
modifyEffect<C, E, R>(f: (a: A) => Effect.Effect<readonly [C, A], E, R>): Effect.Effect<C, E, R> {
|
||||
return Effect.Do.pipe(
|
||||
Effect.bind("b", () => Ref.get(this.parent)),
|
||||
Effect.bind("ca", ({ b }) => f(this.getter(b))),
|
||||
Effect.tap(({ b, ca: [, a] }) => Ref.set(this.parent, this.setter(b, a))),
|
||||
Effect.map(({ ca: [c] }) => c),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const makeFromGetSet = <A, B>(
|
||||
parent: SubscriptionRef.SubscriptionRef<B>,
|
||||
getter: (parentValue: B) => A,
|
||||
setter: (parentValue: B, value: A) => B,
|
||||
): SubscriptionSubRef<A, B> => new SubscriptionSubRefImpl(parent, getter, setter)
|
||||
|
||||
export const makeFromPath = <B, const P extends PropertyPath.Paths<B>>(
|
||||
parent: SubscriptionRef.SubscriptionRef<B>,
|
||||
path: P,
|
||||
): SubscriptionSubRef<PropertyPath.ValueFromPath<B, P>, B> => new SubscriptionSubRefImpl(
|
||||
parent,
|
||||
parentValue => Option.getOrThrow(PropertyPath.get(parentValue, path)),
|
||||
(parentValue, value) => Option.getOrThrow(PropertyPath.immutableSet(parentValue, path, value)),
|
||||
)
|
||||
3
packages/effect-fc/src/types/index.ts
Normal file
3
packages/effect-fc/src/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * as PropertyPath from "./PropertyPath.js"
|
||||
export * as SetStateAction from "./SetStateAction.js"
|
||||
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"
|
||||
33
packages/effect-fc/tsconfig.json
Normal file
33
packages/effect-fc/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Enable latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
// "allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "NodeNext",
|
||||
// "allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
// "noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
// Build
|
||||
"outDir": "./dist",
|
||||
"declaration": true
|
||||
},
|
||||
|
||||
"include": ["./src"]
|
||||
}
|
||||
24
packages/example/.gitignore
vendored
Normal file
24
packages/example/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
50
packages/example/README.md
Normal file
50
packages/example/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
||||
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import react from 'eslint-plugin-react'
|
||||
|
||||
export default tseslint.config({
|
||||
// Set the react version
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
// Add the react plugin
|
||||
react,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended rules
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
28
packages/example/eslint.config.js
Normal file
28
packages/example/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
13
packages/example/index.html
Normal file
13
packages/example/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
packages/example/package.json
Normal file
50
packages/example/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@effect-fc/example",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint:tsc": "tsc --noEmit",
|
||||
"lint:eslint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.26.0",
|
||||
"@tanstack/react-router": "^1.120.3",
|
||||
"@tanstack/react-router-devtools": "^1.120.3",
|
||||
"@tanstack/router-plugin": "^1.120.3",
|
||||
"@thilawyn/thilaschema": "^0.1.4",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.1.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"typescript-eslint": "^8.32.1",
|
||||
"vite": "^6.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform": "^0.82.1",
|
||||
"@effect/platform-browser": "^0.62.1",
|
||||
"@radix-ui/themes": "^3.2.1",
|
||||
"@typed/async-data": "^0.13.1",
|
||||
"@typed/id": "^0.17.2",
|
||||
"@typed/lazy-ref": "^0.3.3",
|
||||
"effect": "^3.15.1",
|
||||
"lucide-react": "^0.510.0",
|
||||
"mobx": "^6.13.7",
|
||||
"effect-fc": "workspace:*"
|
||||
},
|
||||
"overrides": {
|
||||
"effect": "^3.15.1",
|
||||
"@effect/platform": "^0.82.1",
|
||||
"@effect/platform-browser": "^0.62.1",
|
||||
"@typed/lazy-ref": "^0.3.3",
|
||||
"@typed/async-data": "^0.13.1"
|
||||
}
|
||||
}
|
||||
1
packages/example/public/vite.svg
Normal file
1
packages/example/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
57
packages/example/src/VQueryErrorHandler.tsx
Normal file
57
packages/example/src/VQueryErrorHandler.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { AlertDialog, Button, Flex, Text } from "@radix-ui/themes"
|
||||
import { Cause, Console, Effect, Either, flow, Match, Option, Stream } from "effect"
|
||||
import { useState } from "react"
|
||||
import { R } from "./reffuse"
|
||||
import { AppQueryErrorHandler } from "./services"
|
||||
|
||||
|
||||
export function VQueryErrorHandler() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const error = R.useSubscribeStream(
|
||||
R.useMemo(() => AppQueryErrorHandler.AppQueryErrorHandler.pipe(
|
||||
Effect.map(handler => handler.errors.pipe(
|
||||
Stream.changes,
|
||||
Stream.tap(Console.error),
|
||||
Stream.tap(() => Effect.sync(() => setOpen(true))),
|
||||
))
|
||||
), [])
|
||||
)
|
||||
|
||||
if (Option.isNone(error))
|
||||
return <></>
|
||||
|
||||
return (
|
||||
<AlertDialog.Root open={open}>
|
||||
<AlertDialog.Content maxWidth="450px">
|
||||
<AlertDialog.Title>Error</AlertDialog.Title>
|
||||
<AlertDialog.Description size="2">
|
||||
{Either.match(Cause.failureOrCause(error.value), {
|
||||
onLeft: flow(
|
||||
Match.value,
|
||||
Match.tag("RequestError", () => <Text>HTTP request error</Text>),
|
||||
Match.tag("ResponseError", () => <Text>HTTP response error</Text>),
|
||||
Match.exhaustive,
|
||||
),
|
||||
|
||||
onRight: flow(
|
||||
Cause.dieOption,
|
||||
Option.match({
|
||||
onSome: () => <Text>Unrecoverable defect</Text>,
|
||||
onNone: () => <Text>Unknown error</Text>,
|
||||
}),
|
||||
),
|
||||
})}
|
||||
</AlertDialog.Description>
|
||||
|
||||
<Flex gap="3" mt="4" justify="end">
|
||||
<AlertDialog.Action>
|
||||
<Button variant="solid" color="red" onClick={() => setOpen(false)}>
|
||||
Ok
|
||||
</Button>
|
||||
</AlertDialog.Action>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
)
|
||||
}
|
||||
20
packages/example/src/domain/Todo.ts
Normal file
20
packages/example/src/domain/Todo.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ThSchema } from "@thilawyn/thilaschema"
|
||||
import { Schema } from "effect"
|
||||
|
||||
|
||||
export class Todo extends Schema.Class<Todo>("Todo")({
|
||||
_tag: Schema.tag("Todo"),
|
||||
id: Schema.String,
|
||||
content: Schema.String,
|
||||
completedAt: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
|
||||
}) {}
|
||||
|
||||
|
||||
export const TodoFromJsonStruct = Schema.Struct({
|
||||
...Todo.fields,
|
||||
completedAt: Schema.Option(Schema.DateTimeUtc),
|
||||
}).pipe(
|
||||
ThSchema.assertEncodedJsonifiable
|
||||
)
|
||||
|
||||
export const TodoFromJson = Schema.compose(TodoFromJsonStruct, Todo)
|
||||
1
packages/example/src/domain/index.ts
Normal file
1
packages/example/src/domain/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * as Todo from "./Todo"
|
||||
0
packages/example/src/index.css
Normal file
0
packages/example/src/index.css
Normal file
39
packages/example/src/main.tsx
Normal file
39
packages/example/src/main.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||
import { Layer } from "effect"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { ReffuseRuntime } from "reffuse"
|
||||
import { RootContext } from "./reffuse"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
import { AppQueryClient, AppQueryErrorHandler } from "./services"
|
||||
|
||||
|
||||
const layer = Layer.empty.pipe(
|
||||
Layer.provideMerge(AppQueryClient.AppQueryClient.Default),
|
||||
Layer.provideMerge(AppQueryErrorHandler.AppQueryErrorHandler.Default),
|
||||
Layer.provideMerge(Clipboard.layer),
|
||||
Layer.provideMerge(Geolocation.layer),
|
||||
Layer.provideMerge(Permissions.layer),
|
||||
Layer.provideMerge(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
const router = createRouter({ routeTree })
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ReffuseRuntime.Provider>
|
||||
<RootContext.Provider layer={layer}>
|
||||
<RouterProvider router={router} />
|
||||
</RootContext.Provider>
|
||||
</ReffuseRuntime.Provider>
|
||||
</StrictMode>
|
||||
)
|
||||
10
packages/example/src/query/reffuse.ts
Normal file
10
packages/example/src/query/reffuse.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { RootReffuse } from "@/reffuse"
|
||||
import { Reffuse, ReffuseContext } from "reffuse"
|
||||
import { Uuid4Query } from "./services"
|
||||
|
||||
|
||||
export const QueryContext = ReffuseContext.make<Uuid4Query.Uuid4Query>()
|
||||
|
||||
export const R = new class QueryReffuse extends RootReffuse.pipe(
|
||||
Reffuse.withContexts(QueryContext)
|
||||
) {}
|
||||
11
packages/example/src/query/services/Uuid4Query.ts
Normal file
11
packages/example/src/query/services/Uuid4Query.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { QueryRunner } from "@reffuse/extension-query"
|
||||
import { ParseResult, Schema } from "effect"
|
||||
|
||||
|
||||
export const Result = Schema.Array(Schema.String)
|
||||
|
||||
export class Uuid4Query extends QueryRunner.Tag("Uuid4Query")<Uuid4Query,
|
||||
readonly ["uuid4", number],
|
||||
typeof Result.Type,
|
||||
ParseResult.ParseError
|
||||
>() {}
|
||||
1
packages/example/src/query/services/index.ts
Normal file
1
packages/example/src/query/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * as Uuid4Query from "./Uuid4Query"
|
||||
32
packages/example/src/query/views/Uuid4QueryService.tsx
Normal file
32
packages/example/src/query/views/Uuid4QueryService.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Button, Container, Flex, Text } from "@radix-ui/themes"
|
||||
import * as AsyncData from "@typed/async-data"
|
||||
import { R } from "../reffuse"
|
||||
import { Uuid4Query } from "../services"
|
||||
|
||||
|
||||
export function Uuid4QueryService() {
|
||||
const runFork = R.useRunFork()
|
||||
|
||||
const query = R.useMemo(() => Uuid4Query.Uuid4Query, [])
|
||||
const [state] = R.useSubscribeRefs(query.stateRef)
|
||||
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Flex direction="column" align="center" gap="2">
|
||||
<Text>
|
||||
{AsyncData.match(state, {
|
||||
NoData: () => "No data yet",
|
||||
Loading: () => "Loading...",
|
||||
Success: (value, { isRefreshing, isOptimistic }) =>
|
||||
`Value: ${value} ${isRefreshing ? "(refreshing)" : ""} ${isOptimistic ? "(optimistic)" : ""}`,
|
||||
Failure: (cause, { isRefreshing }) =>
|
||||
`Error: ${cause} ${isRefreshing ? "(refreshing)" : ""}`,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Button onClick={() => runFork(query.forkRefresh)}>Refresh</Button>
|
||||
</Flex>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
24
packages/example/src/reffuse.ts
Normal file
24
packages/example/src/reffuse.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { HttpClient } from "@effect/platform"
|
||||
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
|
||||
import { LazyRefExtension } from "@reffuse/extension-lazyref"
|
||||
import { QueryExtension } from "@reffuse/extension-query"
|
||||
import { Reffuse, ReffuseContext } from "reffuse"
|
||||
import { AppQueryClient, AppQueryErrorHandler } from "./services"
|
||||
|
||||
|
||||
export const RootContext = ReffuseContext.make<
|
||||
| AppQueryClient.AppQueryClient
|
||||
| AppQueryErrorHandler.AppQueryErrorHandler
|
||||
| Clipboard.Clipboard
|
||||
| Geolocation.Geolocation
|
||||
| Permissions.Permissions
|
||||
| HttpClient.HttpClient
|
||||
>()
|
||||
|
||||
export class RootReffuse extends Reffuse.Reffuse.pipe(
|
||||
Reffuse.withExtension(LazyRefExtension),
|
||||
Reffuse.withExtension(QueryExtension),
|
||||
Reffuse.withContexts(RootContext),
|
||||
) {}
|
||||
|
||||
export const R = new RootReffuse()
|
||||
404
packages/example/src/routeTree.gen.ts
Normal file
404
packages/example/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
// Import Routes
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as TodosImport } from './routes/todos'
|
||||
import { Route as TimeImport } from './routes/time'
|
||||
import { Route as TestsImport } from './routes/tests'
|
||||
import { Route as PromiseImport } from './routes/promise'
|
||||
import { Route as LazyrefImport } from './routes/lazyref'
|
||||
import { Route as EffectComponentTestsImport } from './routes/effect-component-tests'
|
||||
import { Route as CountImport } from './routes/count'
|
||||
import { Route as BlankImport } from './routes/blank'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
import { Route as StreamsPullImport } from './routes/streams/pull'
|
||||
import { Route as QueryUsequeryImport } from './routes/query/usequery'
|
||||
import { Route as QueryUsemutationImport } from './routes/query/usemutation'
|
||||
import { Route as QueryServiceImport } from './routes/query/service'
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const TodosRoute = TodosImport.update({
|
||||
id: '/todos',
|
||||
path: '/todos',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const TimeRoute = TimeImport.update({
|
||||
id: '/time',
|
||||
path: '/time',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const TestsRoute = TestsImport.update({
|
||||
id: '/tests',
|
||||
path: '/tests',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const PromiseRoute = PromiseImport.update({
|
||||
id: '/promise',
|
||||
path: '/promise',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const LazyrefRoute = LazyrefImport.update({
|
||||
id: '/lazyref',
|
||||
path: '/lazyref',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const EffectComponentTestsRoute = EffectComponentTestsImport.update({
|
||||
id: '/effect-component-tests',
|
||||
path: '/effect-component-tests',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const CountRoute = CountImport.update({
|
||||
id: '/count',
|
||||
path: '/count',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const BlankRoute = BlankImport.update({
|
||||
id: '/blank',
|
||||
path: '/blank',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const IndexRoute = IndexImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const StreamsPullRoute = StreamsPullImport.update({
|
||||
id: '/streams/pull',
|
||||
path: '/streams/pull',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const QueryUsequeryRoute = QueryUsequeryImport.update({
|
||||
id: '/query/usequery',
|
||||
path: '/query/usequery',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const QueryUsemutationRoute = QueryUsemutationImport.update({
|
||||
id: '/query/usemutation',
|
||||
path: '/query/usemutation',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const QueryServiceRoute = QueryServiceImport.update({
|
||||
id: '/query/service',
|
||||
path: '/query/service',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/blank': {
|
||||
id: '/blank'
|
||||
path: '/blank'
|
||||
fullPath: '/blank'
|
||||
preLoaderRoute: typeof BlankImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/count': {
|
||||
id: '/count'
|
||||
path: '/count'
|
||||
fullPath: '/count'
|
||||
preLoaderRoute: typeof CountImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/effect-component-tests': {
|
||||
id: '/effect-component-tests'
|
||||
path: '/effect-component-tests'
|
||||
fullPath: '/effect-component-tests'
|
||||
preLoaderRoute: typeof EffectComponentTestsImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/lazyref': {
|
||||
id: '/lazyref'
|
||||
path: '/lazyref'
|
||||
fullPath: '/lazyref'
|
||||
preLoaderRoute: typeof LazyrefImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/promise': {
|
||||
id: '/promise'
|
||||
path: '/promise'
|
||||
fullPath: '/promise'
|
||||
preLoaderRoute: typeof PromiseImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/tests': {
|
||||
id: '/tests'
|
||||
path: '/tests'
|
||||
fullPath: '/tests'
|
||||
preLoaderRoute: typeof TestsImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/time': {
|
||||
id: '/time'
|
||||
path: '/time'
|
||||
fullPath: '/time'
|
||||
preLoaderRoute: typeof TimeImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/todos': {
|
||||
id: '/todos'
|
||||
path: '/todos'
|
||||
fullPath: '/todos'
|
||||
preLoaderRoute: typeof TodosImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/query/service': {
|
||||
id: '/query/service'
|
||||
path: '/query/service'
|
||||
fullPath: '/query/service'
|
||||
preLoaderRoute: typeof QueryServiceImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/query/usemutation': {
|
||||
id: '/query/usemutation'
|
||||
path: '/query/usemutation'
|
||||
fullPath: '/query/usemutation'
|
||||
preLoaderRoute: typeof QueryUsemutationImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/query/usequery': {
|
||||
id: '/query/usequery'
|
||||
path: '/query/usequery'
|
||||
fullPath: '/query/usequery'
|
||||
preLoaderRoute: typeof QueryUsequeryImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/streams/pull': {
|
||||
id: '/streams/pull'
|
||||
path: '/streams/pull'
|
||||
fullPath: '/streams/pull'
|
||||
preLoaderRoute: typeof StreamsPullImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/count': typeof CountRoute
|
||||
'/effect-component-tests': typeof EffectComponentTestsRoute
|
||||
'/lazyref': typeof LazyrefRoute
|
||||
'/promise': typeof PromiseRoute
|
||||
'/tests': typeof TestsRoute
|
||||
'/time': typeof TimeRoute
|
||||
'/todos': typeof TodosRoute
|
||||
'/query/service': typeof QueryServiceRoute
|
||||
'/query/usemutation': typeof QueryUsemutationRoute
|
||||
'/query/usequery': typeof QueryUsequeryRoute
|
||||
'/streams/pull': typeof StreamsPullRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/count': typeof CountRoute
|
||||
'/effect-component-tests': typeof EffectComponentTestsRoute
|
||||
'/lazyref': typeof LazyrefRoute
|
||||
'/promise': typeof PromiseRoute
|
||||
'/tests': typeof TestsRoute
|
||||
'/time': typeof TimeRoute
|
||||
'/todos': typeof TodosRoute
|
||||
'/query/service': typeof QueryServiceRoute
|
||||
'/query/usemutation': typeof QueryUsemutationRoute
|
||||
'/query/usequery': typeof QueryUsequeryRoute
|
||||
'/streams/pull': typeof StreamsPullRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
'/': typeof IndexRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/count': typeof CountRoute
|
||||
'/effect-component-tests': typeof EffectComponentTestsRoute
|
||||
'/lazyref': typeof LazyrefRoute
|
||||
'/promise': typeof PromiseRoute
|
||||
'/tests': typeof TestsRoute
|
||||
'/time': typeof TimeRoute
|
||||
'/todos': typeof TodosRoute
|
||||
'/query/service': typeof QueryServiceRoute
|
||||
'/query/usemutation': typeof QueryUsemutationRoute
|
||||
'/query/usequery': typeof QueryUsequeryRoute
|
||||
'/streams/pull': typeof StreamsPullRoute
|
||||
}
|
||||
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/blank'
|
||||
| '/count'
|
||||
| '/effect-component-tests'
|
||||
| '/lazyref'
|
||||
| '/promise'
|
||||
| '/tests'
|
||||
| '/time'
|
||||
| '/todos'
|
||||
| '/query/service'
|
||||
| '/query/usemutation'
|
||||
| '/query/usequery'
|
||||
| '/streams/pull'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/blank'
|
||||
| '/count'
|
||||
| '/effect-component-tests'
|
||||
| '/lazyref'
|
||||
| '/promise'
|
||||
| '/tests'
|
||||
| '/time'
|
||||
| '/todos'
|
||||
| '/query/service'
|
||||
| '/query/usemutation'
|
||||
| '/query/usequery'
|
||||
| '/streams/pull'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/blank'
|
||||
| '/count'
|
||||
| '/effect-component-tests'
|
||||
| '/lazyref'
|
||||
| '/promise'
|
||||
| '/tests'
|
||||
| '/time'
|
||||
| '/todos'
|
||||
| '/query/service'
|
||||
| '/query/usemutation'
|
||||
| '/query/usequery'
|
||||
| '/streams/pull'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
BlankRoute: typeof BlankRoute
|
||||
CountRoute: typeof CountRoute
|
||||
EffectComponentTestsRoute: typeof EffectComponentTestsRoute
|
||||
LazyrefRoute: typeof LazyrefRoute
|
||||
PromiseRoute: typeof PromiseRoute
|
||||
TestsRoute: typeof TestsRoute
|
||||
TimeRoute: typeof TimeRoute
|
||||
TodosRoute: typeof TodosRoute
|
||||
QueryServiceRoute: typeof QueryServiceRoute
|
||||
QueryUsemutationRoute: typeof QueryUsemutationRoute
|
||||
QueryUsequeryRoute: typeof QueryUsequeryRoute
|
||||
StreamsPullRoute: typeof StreamsPullRoute
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
BlankRoute: BlankRoute,
|
||||
CountRoute: CountRoute,
|
||||
EffectComponentTestsRoute: EffectComponentTestsRoute,
|
||||
LazyrefRoute: LazyrefRoute,
|
||||
PromiseRoute: PromiseRoute,
|
||||
TestsRoute: TestsRoute,
|
||||
TimeRoute: TimeRoute,
|
||||
TodosRoute: TodosRoute,
|
||||
QueryServiceRoute: QueryServiceRoute,
|
||||
QueryUsemutationRoute: QueryUsemutationRoute,
|
||||
QueryUsequeryRoute: QueryUsequeryRoute,
|
||||
StreamsPullRoute: StreamsPullRoute,
|
||||
}
|
||||
|
||||
export const routeTree = rootRoute
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
/* ROUTE_MANIFEST_START
|
||||
{
|
||||
"routes": {
|
||||
"__root__": {
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/blank",
|
||||
"/count",
|
||||
"/effect-component-tests",
|
||||
"/lazyref",
|
||||
"/promise",
|
||||
"/tests",
|
||||
"/time",
|
||||
"/todos",
|
||||
"/query/service",
|
||||
"/query/usemutation",
|
||||
"/query/usequery",
|
||||
"/streams/pull"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/blank": {
|
||||
"filePath": "blank.tsx"
|
||||
},
|
||||
"/count": {
|
||||
"filePath": "count.tsx"
|
||||
},
|
||||
"/effect-component-tests": {
|
||||
"filePath": "effect-component-tests.tsx"
|
||||
},
|
||||
"/lazyref": {
|
||||
"filePath": "lazyref.tsx"
|
||||
},
|
||||
"/promise": {
|
||||
"filePath": "promise.tsx"
|
||||
},
|
||||
"/tests": {
|
||||
"filePath": "tests.tsx"
|
||||
},
|
||||
"/time": {
|
||||
"filePath": "time.tsx"
|
||||
},
|
||||
"/todos": {
|
||||
"filePath": "todos.tsx"
|
||||
},
|
||||
"/query/service": {
|
||||
"filePath": "query/service.tsx"
|
||||
},
|
||||
"/query/usemutation": {
|
||||
"filePath": "query/usemutation.tsx"
|
||||
},
|
||||
"/query/usequery": {
|
||||
"filePath": "query/usequery.tsx"
|
||||
},
|
||||
"/streams/pull": {
|
||||
"filePath": "streams/pull.tsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
ROUTE_MANIFEST_END */
|
||||
35
packages/example/src/routes/__root.tsx
Normal file
35
packages/example/src/routes/__root.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { VQueryErrorHandler } from "@/VQueryErrorHandler"
|
||||
import { Container, Flex, Theme } from "@radix-ui/themes"
|
||||
import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
||||
|
||||
import "@radix-ui/themes/styles.css"
|
||||
import "../index.css"
|
||||
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: Root
|
||||
})
|
||||
|
||||
function Root() {
|
||||
return (
|
||||
<Theme>
|
||||
<Container>
|
||||
<Flex direction="row" justify="center" align="center" gap="2">
|
||||
<Link to="/">Index</Link>
|
||||
<Link to="/time">Time</Link>
|
||||
<Link to="/count">Count</Link>
|
||||
<Link to="/tests">Tests</Link>
|
||||
<Link to="/promise">Promise</Link>
|
||||
<Link to="/query/usequery">Query</Link>
|
||||
<Link to="/blank">Blank</Link>
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
<Outlet />
|
||||
|
||||
<VQueryErrorHandler />
|
||||
<TanStackRouterDevtools />
|
||||
</Theme>
|
||||
)
|
||||
}
|
||||
9
packages/example/src/routes/blank.tsx
Normal file
9
packages/example/src/routes/blank.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/blank')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/blank"!</div>
|
||||
}
|
||||
26
packages/example/src/routes/count.tsx
Normal file
26
packages/example/src/routes/count.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { R } from "@/reffuse"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Effect, Ref } from "effect"
|
||||
|
||||
|
||||
export const Route = createFileRoute("/count")({
|
||||
component: Count
|
||||
})
|
||||
|
||||
function Count() {
|
||||
|
||||
const runSync = R.useRunSync()
|
||||
|
||||
const countRef = R.useRef(() => Effect.succeed(0))
|
||||
const [count] = R.useSubscribeRefs(countRef)
|
||||
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
<button onClick={() => runSync(Ref.update(countRef, count => count + 1))}>
|
||||
count is {count}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
105
packages/example/src/routes/effect-component-tests.tsx
Normal file
105
packages/example/src/routes/effect-component-tests.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Box, TextField } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Array, Console, Effect, Layer, pipe, Ref, Runtime, SubscriptionRef } from "effect"
|
||||
import { ReactComponent, ReactHook, ReactManagedRuntime } from "effect-components"
|
||||
|
||||
|
||||
const LogLive = Layer.scopedDiscard(Effect.acquireRelease(
|
||||
Console.log("Runtime built."),
|
||||
() => Console.log("Runtime destroyed."),
|
||||
))
|
||||
|
||||
class TestService extends Effect.Service<TestService>()("TestService", {
|
||||
effect: Effect.bind(Effect.Do, "ref", () => SubscriptionRef.make("value")),
|
||||
}) {}
|
||||
|
||||
class SubService extends Effect.Service<SubService>()("SubService", {
|
||||
effect: Effect.bind(Effect.Do, "ref", () => SubscriptionRef.make("subvalue")),
|
||||
}) {}
|
||||
|
||||
const runtime = ReactManagedRuntime.make(Layer.empty.pipe(
|
||||
Layer.provideMerge(LogLive),
|
||||
Layer.provideMerge(TestService.Default),
|
||||
))
|
||||
|
||||
|
||||
export const Route = createFileRoute("/effect-component-tests")({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<ReactManagedRuntime.AsyncProvider runtime={runtime}>
|
||||
<MyRoute />
|
||||
</ReactManagedRuntime.AsyncProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const MyRoute = pipe(
|
||||
Effect.fn(function*() {
|
||||
const runtime = yield* Effect.runtime()
|
||||
|
||||
const service = yield* TestService
|
||||
const [value] = yield* ReactHook.useSubscribeRefs(service.ref)
|
||||
|
||||
// const MyTestComponentFC = yield* Effect.provide(
|
||||
// ReactComponent.useFC(MyTestComponent),
|
||||
// yield* ReactHook.useMemoLayer(SubService.Default),
|
||||
// )
|
||||
|
||||
return <>
|
||||
<Box>
|
||||
<TextField.Root
|
||||
value={value}
|
||||
onChange={e => Runtime.runSync(runtime)(Ref.set(service.ref, e.target.value))}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* {yield* ReactComponent.use(MyTestComponent, C => <C />).pipe(
|
||||
Effect.provide(yield* ReactHook.useMemoLayer(SubService.Default))
|
||||
)} */}
|
||||
|
||||
{/* {Array.range(0, 3).map(k =>
|
||||
<MyTestComponentFC key={k} />
|
||||
)} */}
|
||||
|
||||
{yield* pipe(
|
||||
Array.range(0, 3),
|
||||
Array.map(k => ReactComponent.use(MyTestComponent, FC =>
|
||||
<FC key={k} />
|
||||
)),
|
||||
Effect.all,
|
||||
Effect.provide(yield* ReactHook.useMemoLayer(SubService.Default)),
|
||||
)}
|
||||
</>
|
||||
}),
|
||||
|
||||
ReactComponent.withDisplayName("MyRoute"),
|
||||
ReactComponent.withRuntime(runtime.context),
|
||||
)
|
||||
|
||||
|
||||
const MyTestComponent = pipe(
|
||||
Effect.fn(function*() {
|
||||
const runtime = yield* Effect.runtime()
|
||||
|
||||
const service = yield* SubService
|
||||
const [value] = yield* ReactHook.useSubscribeRefs(service.ref)
|
||||
|
||||
// yield* ReactHook.useMemo(() => Effect.andThen(
|
||||
// Effect.addFinalizer(() => Console.log("MyTestComponent umounted")),
|
||||
// Console.log("MyTestComponent mounted"),
|
||||
// ), [])
|
||||
|
||||
return <>
|
||||
<Box>
|
||||
<TextField.Root
|
||||
value={value}
|
||||
onChange={e => Runtime.runSync(runtime)(Ref.set(service.ref, e.target.value))}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
}),
|
||||
|
||||
ReactComponent.withDisplayName("MyTestComponent"),
|
||||
)
|
||||
10
packages/example/src/routes/index.tsx
Normal file
10
packages/example/src/routes/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: RouteComponent
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/"!</div>
|
||||
}
|
||||
31
packages/example/src/routes/lazyref.tsx
Normal file
31
packages/example/src/routes/lazyref.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { R } from "@/reffuse"
|
||||
import { Button, Text } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import * as LazyRef from "@typed/lazy-ref"
|
||||
import { Suspense, use } from "react"
|
||||
|
||||
|
||||
export const Route = createFileRoute("/lazyref")({
|
||||
component: RouteComponent
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const promise = R.usePromise(() => LazyRef.of(0), [])
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Text>Loading...</Text>}>
|
||||
<LazyRefComponent promise={promise} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function LazyRefComponent({ promise }: { readonly promise: Promise<LazyRef.LazyRef<number>> }) {
|
||||
const ref = use(promise)
|
||||
const [value, setValue] = R.useLazyRefState(ref)
|
||||
|
||||
return (
|
||||
<Button onClick={() => setValue(prev => prev + 1)}>
|
||||
{value}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
35
packages/example/src/routes/promise.tsx
Normal file
35
packages/example/src/routes/promise.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { R } from "@/reffuse"
|
||||
import { HttpClient } from "@effect/platform"
|
||||
import { Text } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Console, Effect, Schema } from "effect"
|
||||
import { Suspense, use } from "react"
|
||||
|
||||
|
||||
export const Route = createFileRoute("/promise")({
|
||||
component: RouteComponent
|
||||
})
|
||||
|
||||
|
||||
const Result = Schema.Tuple(Schema.String)
|
||||
type Result = typeof Result.Type
|
||||
|
||||
function RouteComponent() {
|
||||
const promise = R.usePromise(() => Effect.addFinalizer(() => Console.log("Cleanup")).pipe(
|
||||
Effect.andThen(HttpClient.get("https://www.uuidtools.com/api/generate/v4")),
|
||||
HttpClient.withTracerPropagation(false),
|
||||
Effect.flatMap(res => res.json),
|
||||
Effect.flatMap(Schema.decodeUnknown(Result)),
|
||||
), [])
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Text>Loading...</Text>}>
|
||||
<AsyncComponent promise={promise} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function AsyncComponent({ promise }: { readonly promise: Promise<Result> }) {
|
||||
const [uuid] = use(promise)
|
||||
return <Text>{uuid}</Text>
|
||||
}
|
||||
38
packages/example/src/routes/query/service.tsx
Normal file
38
packages/example/src/routes/query/service.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { QueryContext } from "@/query/reffuse"
|
||||
import { Uuid4Query } from "@/query/services"
|
||||
import { Uuid4QueryService } from "@/query/views/Uuid4QueryService"
|
||||
import { R } from "@/reffuse"
|
||||
import { HttpClient } from "@effect/platform"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Console, Effect, Layer, Schema } from "effect"
|
||||
import { useMemo } from "react"
|
||||
|
||||
|
||||
export const Route = createFileRoute("/query/service")({
|
||||
component: RouteComponent
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const query = R.useQuery({
|
||||
key: R.useStreamFromReactiveValues(["uuid4", 10 as number]),
|
||||
query: ([, count]) => Console.log(`Querying ${ count } IDs...`).pipe(
|
||||
Effect.andThen(Effect.sleep("500 millis")),
|
||||
Effect.andThen(Effect.map(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.withTracerPropagation(false),
|
||||
)),
|
||||
Effect.flatMap(client => client.get(`https://www.uuidtools.com/api/generate/v4/count/${ count }`)),
|
||||
Effect.flatMap(res => res.json),
|
||||
Effect.flatMap(Schema.decodeUnknown(Uuid4Query.Result)),
|
||||
Effect.scoped,
|
||||
),
|
||||
})
|
||||
|
||||
const layer = useMemo(() => Layer.succeed(Uuid4Query.Uuid4Query, query), [query])
|
||||
|
||||
return (
|
||||
<QueryContext.Provider layer={layer}>
|
||||
<Uuid4QueryService />
|
||||
</QueryContext.Provider>
|
||||
)
|
||||
}
|
||||
84
packages/example/src/routes/query/usemutation.tsx
Normal file
84
packages/example/src/routes/query/usemutation.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { R } from "@/reffuse"
|
||||
import { HttpClient } from "@effect/platform"
|
||||
import { Button, Container, Flex, Slider, Text } from "@radix-ui/themes"
|
||||
import { QueryProgress } from "@reffuse/extension-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import * as AsyncData from "@typed/async-data"
|
||||
import { Array, Console, Effect, flow, Option, Schema, Stream } from "effect"
|
||||
import { useState } from "react"
|
||||
|
||||
|
||||
export const Route = createFileRoute("/query/usemutation")({
|
||||
component: RouteComponent
|
||||
})
|
||||
|
||||
|
||||
const Result = Schema.Array(Schema.String)
|
||||
|
||||
function RouteComponent() {
|
||||
const runFork = R.useRunFork()
|
||||
|
||||
const [count, setCount] = useState(1)
|
||||
|
||||
const mutation = R.useMutation({
|
||||
mutation: ([count]: readonly [count: number]) => Console.log(`Querying ${ count } IDs...`).pipe(
|
||||
Effect.andThen(QueryProgress.QueryProgress.update(() =>
|
||||
AsyncData.Progress.make({ loaded: 0, total: Option.some(100) })
|
||||
)),
|
||||
Effect.andThen(Effect.sleep("500 millis")),
|
||||
Effect.tap(() => QueryProgress.QueryProgress.update(() =>
|
||||
AsyncData.Progress.make({ loaded: 50, total: Option.some(100) })
|
||||
)),
|
||||
Effect.andThen(Effect.map(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.withTracerPropagation(false),
|
||||
)),
|
||||
Effect.flatMap(client => client.get(`https://www.uuidtools.com/api/generate/v4/count/${ count }`)),
|
||||
Effect.flatMap(res => res.json),
|
||||
Effect.flatMap(Schema.decodeUnknown(Result)),
|
||||
Effect.scoped,
|
||||
)
|
||||
})
|
||||
|
||||
const [state] = R.useSubscribeRefs(mutation.stateRef)
|
||||
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Flex direction="column" align="center" gap="2">
|
||||
<Slider
|
||||
min={1}
|
||||
max={100}
|
||||
value={[count]}
|
||||
onValueChange={flow(
|
||||
Array.head,
|
||||
Option.getOrThrow,
|
||||
setCount,
|
||||
)}
|
||||
/>
|
||||
|
||||
<Text>
|
||||
{AsyncData.match(state, {
|
||||
NoData: () => "No data yet",
|
||||
Loading: progress =>
|
||||
`Loading...
|
||||
${ Option.match(progress, {
|
||||
onSome: ({ loaded, total }) => ` (${ loaded }/${ Option.getOrElse(total, () => "unknown") })`,
|
||||
onNone: () => "",
|
||||
}) }`,
|
||||
Success: value => `Value: ${ value }`,
|
||||
Failure: cause => `Error: ${ cause }`,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Button onClick={() => mutation.forkMutate(count).pipe(
|
||||
Effect.flatMap(([, state]) => Stream.runForEach(state, Console.log)),
|
||||
Effect.andThen(Console.log("Mutation done.")),
|
||||
runFork,
|
||||
)}>
|
||||
Get
|
||||
</Button>
|
||||
</Flex>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
77
packages/example/src/routes/query/usequery.tsx
Normal file
77
packages/example/src/routes/query/usequery.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { R } from "@/reffuse"
|
||||
import { HttpClient } from "@effect/platform"
|
||||
import { Button, Container, Flex, Slider, Text } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import * as AsyncData from "@typed/async-data"
|
||||
import { Array, Console, Effect, flow, Option, Schema, Stream } from "effect"
|
||||
import { useState } from "react"
|
||||
|
||||
|
||||
export const Route = createFileRoute("/query/usequery")({
|
||||
component: RouteComponent
|
||||
})
|
||||
|
||||
|
||||
const Result = Schema.Array(Schema.String)
|
||||
|
||||
function RouteComponent() {
|
||||
const runFork = R.useRunFork()
|
||||
|
||||
const [count, setCount] = useState(1)
|
||||
|
||||
const query = R.useQuery({
|
||||
key: R.useStreamFromReactiveValues(["uuid4", count]),
|
||||
query: ([, count]) => Console.log(`Querying ${ count } IDs...`).pipe(
|
||||
Effect.andThen(Effect.sleep("500 millis")),
|
||||
Effect.andThen(Effect.map(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.withTracerPropagation(false),
|
||||
)),
|
||||
Effect.flatMap(client => client.get(`https://www.uuidtools.com/api/generate/v4/count/${ count }`)),
|
||||
Effect.flatMap(res => res.json),
|
||||
Effect.flatMap(Schema.decodeUnknown(Result)),
|
||||
Effect.scoped,
|
||||
),
|
||||
})
|
||||
|
||||
const [state] = R.useSubscribeRefs(query.stateRef)
|
||||
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Flex direction="column" align="center" gap="2">
|
||||
<Slider
|
||||
min={1}
|
||||
max={100}
|
||||
value={[count]}
|
||||
onValueChange={flow(
|
||||
Array.head,
|
||||
Option.getOrThrow,
|
||||
setCount,
|
||||
)}
|
||||
/>
|
||||
|
||||
<Text>
|
||||
{AsyncData.match(state, {
|
||||
NoData: () => "No data yet",
|
||||
Loading: () => "Loading...",
|
||||
Success: (value, { isRefreshing, isOptimistic }) =>
|
||||
`Value: ${value} ${isRefreshing ? "(refreshing)" : ""} ${isOptimistic ? "(optimistic)" : ""}`,
|
||||
Failure: (cause, { isRefreshing }) =>
|
||||
`Error: ${cause} ${isRefreshing ? "(refreshing)" : ""}`,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
onClick={() => query.forkRefresh.pipe(
|
||||
Effect.flatMap(([, state]) => Stream.runForEach(state, Console.log)),
|
||||
Effect.andThen(Console.log("Refresh finished or stopped")),
|
||||
runFork,
|
||||
)}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Flex>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
34
packages/example/src/routes/streams/pull.tsx
Normal file
34
packages/example/src/routes/streams/pull.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { R } from "@/reffuse"
|
||||
import { Button, Flex, Text } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Chunk, Effect, Exit, Option, Queue, Random, Scope, Stream } from "effect"
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
|
||||
export const Route = createFileRoute("/streams/pull")({
|
||||
component: RouteComponent
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const stream = useMemo(() => Stream.repeatEffect(Random.nextInt), [])
|
||||
const streamScope = R.useScope([stream], { finalizerExecutionMode: "fork" })
|
||||
|
||||
const queue = R.useMemo(() => Effect.provideService(Stream.toQueueOfElements(stream), Scope.Scope, streamScope), [streamScope])
|
||||
|
||||
const [value, setValue] = useState(Option.none<number>())
|
||||
const pullLatest = R.useCallbackSync(() => Queue.takeAll(queue).pipe(
|
||||
Effect.flatMap(Chunk.last),
|
||||
Effect.flatMap(Exit.matchEffect({
|
||||
onSuccess: Effect.succeed,
|
||||
onFailure: Effect.fail,
|
||||
})),
|
||||
Effect.tap(v => Effect.sync(() => setValue(Option.some(v)))),
|
||||
), [queue])
|
||||
|
||||
return (
|
||||
<Flex direction="column" align="center" gap="2">
|
||||
{Option.isSome(value) && <Text>{value.value}</Text>}
|
||||
<Button onClick={pullLatest}>Pull latest</Button>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
62
packages/example/src/routes/tests.tsx
Normal file
62
packages/example/src/routes/tests.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { R } from "@/reffuse"
|
||||
import { Button, Flex, Text } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
||||
import { Console, Effect, Option } from "effect"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
|
||||
interface Node {
|
||||
value: string
|
||||
left?: Leaf
|
||||
right?: Leaf
|
||||
}
|
||||
interface Leaf {
|
||||
node: Node
|
||||
}
|
||||
|
||||
|
||||
const makeUuid = Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)
|
||||
|
||||
|
||||
export const Route = createFileRoute("/tests")({
|
||||
component: RouteComponent
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const runSync = R.useRunSync()
|
||||
|
||||
const [uuid, setUuid] = useState(R.useMemo(() => makeUuid, []))
|
||||
const generateUuid = R.useCallbackSync(() => makeUuid.pipe(
|
||||
Effect.tap(v => Effect.sync(() => setUuid(v)))
|
||||
), [])
|
||||
|
||||
const uuidStream = R.useStreamFromReactiveValues([uuid])
|
||||
const uuidStreamLatestValue = R.useSubscribeStream(uuidStream)
|
||||
|
||||
const [, scopeLayer] = R.useScope([uuid])
|
||||
|
||||
useEffect(() => Effect.addFinalizer(() => Console.log("Scope cleanup!")).pipe(
|
||||
Effect.andThen(Console.log("Scope changed")),
|
||||
Effect.provide(scopeLayer),
|
||||
runSync,
|
||||
), [scopeLayer, runSync])
|
||||
|
||||
|
||||
const nodeRef = R.useRef(() => Effect.succeed<Node>({ value: "prout" }))
|
||||
const nodeValueRef = R.useSubRefFromPath(nodeRef, ["value"])
|
||||
|
||||
|
||||
return (
|
||||
<Flex direction="column" justify="center" align="center" gap="2">
|
||||
<Text>{uuid}</Text>
|
||||
<Button onClick={generateUuid}>Generate UUID</Button>
|
||||
<Text>
|
||||
{Option.match(uuidStreamLatestValue, {
|
||||
onSome: ([v]) => v,
|
||||
onNone: () => <></>,
|
||||
})}
|
||||
</Text>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
39
packages/example/src/routes/time.tsx
Normal file
39
packages/example/src/routes/time.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { R } from "@/reffuse"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Console, DateTime, Effect, Ref, Schedule, Stream, SubscriptionRef } from "effect"
|
||||
|
||||
|
||||
const timeEverySecond = Stream.repeatEffectWithSchedule(
|
||||
DateTime.now,
|
||||
Schedule.intersect(Schedule.forever, Schedule.spaced("1 second")),
|
||||
)
|
||||
|
||||
|
||||
export const Route = createFileRoute("/time")({
|
||||
component: Time
|
||||
})
|
||||
|
||||
function Time() {
|
||||
|
||||
const timeRef = R.useMemo(() => DateTime.now.pipe(Effect.flatMap(SubscriptionRef.make)), [])
|
||||
|
||||
R.useFork(() => Effect.addFinalizer(() => Console.log("Cleanup")).pipe(
|
||||
Effect.andThen(Stream.runForEach(timeEverySecond, v => Ref.set(timeRef, v)))
|
||||
), [timeRef])
|
||||
|
||||
const [time] = R.useRefState(timeRef)
|
||||
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
<p className="text-center">
|
||||
{DateTime.format(time, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
35
packages/example/src/routes/todos.tsx
Normal file
35
packages/example/src/routes/todos.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TodosContext } from "@/todos/reffuse"
|
||||
import { TodosState } from "@/todos/services"
|
||||
import { VTodos } from "@/todos/views/VTodos"
|
||||
import { Container } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Console, Effect, Layer } from "effect"
|
||||
import { useMemo } from "react"
|
||||
|
||||
|
||||
export const Route = createFileRoute("/todos")({
|
||||
component: Todos
|
||||
})
|
||||
|
||||
function Todos() {
|
||||
|
||||
const todosLayer = useMemo(() => Layer.empty.pipe(
|
||||
Layer.provideMerge(TodosState.make("todos")),
|
||||
|
||||
Layer.merge(Layer.effectDiscard(
|
||||
Effect.addFinalizer(() => Console.log("TodosContext cleaned up")).pipe(
|
||||
Effect.andThen(Console.log("TodosContext constructed"))
|
||||
)
|
||||
)),
|
||||
), [])
|
||||
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TodosContext.Provider layer={todosLayer} finalizerExecutionMode="fork">
|
||||
<VTodos />
|
||||
</TodosContext.Provider>
|
||||
</Container>
|
||||
)
|
||||
|
||||
}
|
||||
7
packages/example/src/services/AppQueryClient.ts
Normal file
7
packages/example/src/services/AppQueryClient.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { QueryClient } from "@reffuse/extension-query"
|
||||
import * as AppQueryErrorHandler from "./AppQueryErrorHandler"
|
||||
|
||||
|
||||
export class AppQueryClient extends QueryClient.Service<AppQueryClient>()({
|
||||
errorHandler: AppQueryErrorHandler.AppQueryErrorHandler
|
||||
}) {}
|
||||
13
packages/example/src/services/AppQueryErrorHandler.ts
Normal file
13
packages/example/src/services/AppQueryErrorHandler.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { HttpClientError } from "@effect/platform"
|
||||
import { QueryErrorHandler } from "@reffuse/extension-query"
|
||||
import { Effect } from "effect"
|
||||
|
||||
|
||||
export class AppQueryErrorHandler extends Effect.Service<AppQueryErrorHandler>()("AppQueryErrorHandler", {
|
||||
effect: QueryErrorHandler.make<HttpClientError.HttpClientError>()(
|
||||
(self, failure, defect) => self.pipe(
|
||||
Effect.catchTag("RequestError", "ResponseError", failure),
|
||||
Effect.catchAllDefect(defect),
|
||||
)
|
||||
)
|
||||
}) {}
|
||||
2
packages/example/src/services/index.ts
Normal file
2
packages/example/src/services/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * as AppQueryClient from "./AppQueryClient"
|
||||
export * as AppQueryErrorHandler from "./AppQueryErrorHandler"
|
||||
10
packages/example/src/todos/reffuse.ts
Normal file
10
packages/example/src/todos/reffuse.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { RootReffuse } from "@/reffuse"
|
||||
import { Reffuse, ReffuseContext } from "reffuse"
|
||||
import { TodosState } from "./services"
|
||||
|
||||
|
||||
export const TodosContext = ReffuseContext.make<TodosState.TodosState>()
|
||||
|
||||
export const R = new class TodosReffuse extends RootReffuse.pipe(
|
||||
Reffuse.withContexts(TodosContext)
|
||||
) {}
|
||||
44
packages/example/src/todos/services/TodosState.ts
Normal file
44
packages/example/src/todos/services/TodosState.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Todo } from "@/domain"
|
||||
import { KeyValueStore } from "@effect/platform"
|
||||
import { BrowserKeyValueStore } from "@effect/platform-browser"
|
||||
import { PlatformError } from "@effect/platform/Error"
|
||||
import { Chunk, Context, Effect, identity, Layer, ParseResult, Ref, Schema, Stream, SubscriptionRef } from "effect"
|
||||
|
||||
|
||||
export class TodosState extends Context.Tag("TodosState")<TodosState, {
|
||||
readonly todos: SubscriptionRef.SubscriptionRef<Chunk.Chunk<Todo.Todo>>
|
||||
readonly load: Effect.Effect<void, PlatformError | ParseResult.ParseError>
|
||||
readonly save: Effect.Effect<void, PlatformError | ParseResult.ParseError>
|
||||
}>() {}
|
||||
|
||||
|
||||
export const make = (key: string) => Layer.effect(TodosState, Effect.gen(function*() {
|
||||
const readFromLocalStorage = KeyValueStore.KeyValueStore.pipe(
|
||||
Effect.flatMap(kv => kv.get(key)),
|
||||
Effect.flatMap(identity),
|
||||
Effect.flatMap(Schema.decode(
|
||||
Schema.compose(Schema.parseJson(), Schema.Chunk(Todo.TodoFromJson))
|
||||
)),
|
||||
Effect.catchTag("NoSuchElementException", () => Effect.succeed(Chunk.empty<Todo.Todo>())),
|
||||
Effect.provide(BrowserKeyValueStore.layerLocalStorage),
|
||||
)
|
||||
|
||||
const writeToLocalStorage = (values: Chunk.Chunk<Todo.Todo>) => KeyValueStore.KeyValueStore.pipe(
|
||||
Effect.flatMap(kv => values.pipe(
|
||||
Schema.encode(
|
||||
Schema.compose(Schema.parseJson(), Schema.Chunk(Todo.TodoFromJson))
|
||||
),
|
||||
Effect.flatMap(v => kv.set(key, v)),
|
||||
)),
|
||||
Effect.provide(BrowserKeyValueStore.layerLocalStorage),
|
||||
)
|
||||
|
||||
const todos = yield* SubscriptionRef.make(yield* readFromLocalStorage)
|
||||
const load = Effect.flatMap(readFromLocalStorage, v => Ref.set(todos, v))
|
||||
const save = Effect.flatMap(todos, writeToLocalStorage)
|
||||
|
||||
// Sync changes with local storage
|
||||
yield* Effect.forkScoped(Stream.runForEach(todos.changes, writeToLocalStorage))
|
||||
|
||||
return { todos, load, save }
|
||||
}))
|
||||
1
packages/example/src/todos/services/index.ts
Normal file
1
packages/example/src/todos/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * as TodosState from "./TodosState"
|
||||
44
packages/example/src/todos/views/VNewTodo.tsx
Normal file
44
packages/example/src/todos/views/VNewTodo.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Todo } from "@/domain"
|
||||
import { Box, Button, Card, Flex, TextArea } from "@radix-ui/themes"
|
||||
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
||||
import { Chunk, Effect, Option, Ref } from "effect"
|
||||
import { R } from "../reffuse"
|
||||
import { TodosState } from "../services"
|
||||
|
||||
|
||||
const createEmptyTodo = makeUuid4.pipe(
|
||||
Effect.map(id => Todo.Todo.make({ id, content: "", completedAt: Option.none()}, true)),
|
||||
Effect.provide(GetRandomValues.CryptoRandom),
|
||||
)
|
||||
|
||||
|
||||
export function VNewTodo() {
|
||||
|
||||
const todoRef = R.useRef(() => createEmptyTodo)
|
||||
const [content, setContent] = R.useRefState(R.useSubRefFromPath(todoRef, ["content"]))
|
||||
|
||||
const add = R.useCallbackSync(() => Effect.all([TodosState.TodosState, todoRef]).pipe(
|
||||
Effect.flatMap(([state, todo]) => Ref.update(state.todos, Chunk.prepend(todo))),
|
||||
Effect.andThen(createEmptyTodo),
|
||||
Effect.flatMap(v => Ref.set(todoRef, v)),
|
||||
), [todoRef])
|
||||
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Card>
|
||||
<Flex direction="column" align="stretch" gap="2">
|
||||
<TextArea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
/>
|
||||
|
||||
<Flex direction="row" justify="center" align="center">
|
||||
<Button onClick={add}>Add</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Box>
|
||||
)
|
||||
|
||||
}
|
||||
53
packages/example/src/todos/views/VTodo.tsx
Normal file
53
packages/example/src/todos/views/VTodo.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Todo } from "@/domain"
|
||||
import { Box, Card, Flex, IconButton, TextArea } from "@radix-ui/themes"
|
||||
import { Effect, Ref, Stream, SubscriptionRef } from "effect"
|
||||
import { Delete } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { R } from "../reffuse"
|
||||
|
||||
|
||||
export interface VTodoProps {
|
||||
readonly todoRef: SubscriptionRef.SubscriptionRef<Todo.Todo>
|
||||
readonly remove: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export function VTodo({ todoRef, remove }: VTodoProps) {
|
||||
|
||||
const runSync = R.useRunSync()
|
||||
|
||||
const localTodoRef = R.useRef(() => todoRef)
|
||||
const [content, setContent] = R.useRefState(R.useSubRefFromPath(localTodoRef, ["content"]))
|
||||
|
||||
R.useFork(() => localTodoRef.changes.pipe(
|
||||
Stream.debounce("250 millis"),
|
||||
Stream.runForEach(v => Ref.set(todoRef, v)),
|
||||
), [localTodoRef])
|
||||
|
||||
const editorMode = useState(false)
|
||||
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Card>
|
||||
<Flex direction="column" align="stretch" gap="1">
|
||||
<TextArea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
disabled={!editorMode}
|
||||
/>
|
||||
|
||||
<Flex direction="row" justify="between" align="center">
|
||||
<Box></Box>
|
||||
|
||||
<Flex direction="row" align="center" gap="1">
|
||||
<IconButton onClick={() => runSync(remove)}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Box>
|
||||
)
|
||||
|
||||
}
|
||||
38
packages/example/src/todos/views/VTodos.tsx
Normal file
38
packages/example/src/todos/views/VTodos.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Box, Flex } from "@radix-ui/themes"
|
||||
import { Chunk, Effect, Ref } from "effect"
|
||||
import { R } from "../reffuse"
|
||||
import { TodosState } from "../services"
|
||||
import { VNewTodo } from "./VNewTodo"
|
||||
import { VTodo } from "./VTodo"
|
||||
|
||||
|
||||
export function VTodos() {
|
||||
|
||||
const todosRef = R.useMemo(() => Effect.map(TodosState.TodosState, state => state.todos), [])
|
||||
const [todos] = R.useSubscribeRefs(todosRef)
|
||||
|
||||
|
||||
return (
|
||||
<Flex direction="column" align="center" gap="3">
|
||||
<Box width="500px">
|
||||
<VNewTodo />
|
||||
</Box>
|
||||
|
||||
{Chunk.map(todos, (todo, index) => (
|
||||
<Box key={todo.id} width="500px">
|
||||
<R.SubRefFromGetSet
|
||||
parent={todosRef}
|
||||
getter={parentValue => Chunk.unsafeGet(parentValue, index)}
|
||||
setter={(parentValue, value) => Chunk.replace(parentValue, index, value)}
|
||||
>
|
||||
{ref => <VTodo
|
||||
todoRef={ref}
|
||||
remove={Ref.update(todosRef, Chunk.remove(index))}
|
||||
/>}
|
||||
</R.SubRefFromGetSet>
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
)
|
||||
|
||||
}
|
||||
1
packages/example/src/vite-env.d.ts
vendored
Normal file
1
packages/example/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
30
packages/example/tsconfig.app.json
Normal file
30
packages/example/tsconfig.app.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
packages/example/tsconfig.json
Normal file
7
packages/example/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
packages/example/tsconfig.node.json
Normal file
24
packages/example/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
19
packages/example/vite.config.ts
Normal file
19
packages/example/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import path from "node:path"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterVite(),
|
||||
react(),
|
||||
],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user