Improve effect-view docs, mutation keys, and Fast Refresh (#76)
## Summary - Add comprehensive AI-oriented documentation for effect-view components, state, async rendering, queries, mutations, forms, streams, and runtime setup. - Fix `Mutation.mutate` so each invocation consistently uses its own key instead of reusing the previous mutation key. - Improve Vite Fast Refresh instrumentation for: - User-defined component wrappers. - Data-first and pipeline-based `withContext`/`withRuntime` entrypoints. - Nested function handling. - Stable hook signatures that preserve state for non-hook-related edits. - Add regression tests for mutation keys and refresh behavior. - Bump `effect-view` to `0.1.5` and `@effect-view/vite-plugin` to `0.0.2`. ## Testing - `bun run --cwd packages/effect-view test -- src/Mutation.test.ts` - 4 tests passed - `bun run --cwd packages/vite-plugin test -- src/plugin.test.ts` - 11 tests passed The full test suite was not run. --------- Co-authored-by: Julien Valverdé <julien.valverde@mailo.com> Reviewed-on: #76
This commit was merged in pull request #76.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
# effect-view
|
||||
|
||||
`effect-view` lets a React function component be described as an Effect program: yield services, create scoped resources, subscribe to reactive state, and turn Effects into React callbacks inside a component body, then convert that description into a normal React function component at a React boundary.
|
||||
|
||||
Requires Effect v4 (RC) and React 19.2+. Peer dependencies: `effect`, `react`, `@types/react`. Not tied to `react-dom` — any React renderer works.
|
||||
|
||||
When writing effect-view code, use the actual current source and tests in `src/` as ground truth over anything remembered from training — the API is pre-1.0 and still moving. The files below are a concise reference; read the linked one(s) before writing code that touches that concern.
|
||||
|
||||
## Core model
|
||||
|
||||
1. `ReactRuntime` builds the Effect services available to the UI and exposes them through React context.
|
||||
2. `Component.make` defines a component body as an Effect generator.
|
||||
3. `Component.withContext` converts a `Component` into a normal React component at a React boundary (app root, router, third-party library). Apply it only there — never between two effect-view components.
|
||||
4. Inside another effect-view component, compose children by yielding their `.use` Effect.
|
||||
5. Every rendered component instance owns a root `Scope.Scope`, opened on mount and closed on unmount; regular component setup must complete **synchronously** during render unless the component is wrapped with `Async.async`.
|
||||
|
||||
## Find the right doc by what you're trying to do
|
||||
|
||||
**Setting up the app** — building the runtime, providing it to the tree → [ReactRuntime.md](./ai-docs/ReactRuntime.md)
|
||||
|
||||
**Defining a component, its lifecycle, or running Effects from event handlers** → [Component.md](./ai-docs/Component.md)
|
||||
|
||||
**Storing, reading, or subscribing to state** (local, shared via a service, or focused into a nested field) → [State.md](./ai-docs/State.md) — `Lens` (read/write) and `View` (read-only)
|
||||
|
||||
**Rendering something that needs to wait on an async Effect, or avoiding unnecessary re-renders/re-fetches** → [Async.md](./ai-docs/Async.md) — `Async` (suspend on an Effect) and `Memoized` (`React.memo` wrapper)
|
||||
|
||||
**Fetching/caching server data** (reactive keys, staleness, background refresh, invalidation) → [Query.md](./ai-docs/Query.md) — includes `QueryClient`, the cache service `Query` runs against
|
||||
|
||||
**Triggering a write** (save, delete, upload, send) with pending/error state → [Mutation.md](./ai-docs/Mutation.md)
|
||||
|
||||
**Building a schema-driven form**:
|
||||
- shared concepts (encoded vs decoded value, focusing into fields, input/status hooks) → [Form.md](./ai-docs/Form.md) — read this first
|
||||
- a form that submits a valid value via a `Mutation` → [MutationForm.md](./ai-docs/MutationForm.md)
|
||||
- a form that keeps a target `Lens` continuously synchronized with a valid draft → [LensForm.md](./ai-docs/LensForm.md)
|
||||
|
||||
**Small utilities**:
|
||||
- bridging React-tracked values into an Effect `PubSub` → [PubSub.md](./ai-docs/PubSub.md)
|
||||
- consuming a raw Effect `Stream` as React state → [Stream.md](./ai-docs/Stream.md)
|
||||
|
||||
## Choosing between async integrations
|
||||
|
||||
- One-off async read before rendering → `Async.async`.
|
||||
- Cached/shared/refreshable server reads → `Query`.
|
||||
- User-triggered writes with observable pending/error state → `Mutation`.
|
||||
- Async work in an event handler with no need for `Mutation` state → `Component.useRunPromise`/`useCallbackPromise`.
|
||||
- Subscriptions or background work tied to component lifecycle → a scoped fiber forked from `Component.useReactEffect`.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- Never yield an asynchronous Effect directly from a regular (non-`Async`) component body.
|
||||
- effect-view hooks are still React hooks under the hood: call them unconditionally, at the top level, in a stable order — never in branches, loops, or after a suspend point.
|
||||
- Keep `ReactRuntime` instances and `Layer` references stable; building them during render creates new resources and a new React context every time.
|
||||
- `Component.withContext` requires a matching `ReactRuntime.Provider` above it in the tree.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Async and Memoized
|
||||
|
||||
Two `Component` traits that control render behavior: `Async` lets a component's body suspend on an asynchronous Effect; `Memoized` skips re-rendering a component when its props haven't changed. They're commonly combined, since an unmemoized `Async` component restarts its async computation on every unrelated parent render.
|
||||
|
||||
## Async
|
||||
|
||||
Components run synchronously by default (the body must complete without suspending during render). `Async.async` lifts a component so its body may await an asynchronous Effect before returning JSX; React Suspense handles the wait.
|
||||
|
||||
```tsx
|
||||
import { Effect } from "effect"
|
||||
import { Async, Component } from "effect-view"
|
||||
|
||||
export const UserCard = Component.make("UserCard")(function* ({ userId }: { readonly userId: string }) {
|
||||
const user = yield* Component.useOnChange(() => loadUser(userId), [userId])
|
||||
return <article>{user.name}</article>
|
||||
}).pipe(Async.async)
|
||||
```
|
||||
|
||||
Render with a fallback (per-use or as a component default):
|
||||
|
||||
```tsx
|
||||
const User = yield* UserCard.use
|
||||
<User userId="123" fallback={<p>Loading user...</p>} />
|
||||
```
|
||||
|
||||
```tsx
|
||||
.pipe(Async.async, Async.withOptions({ defaultFallback: <p>Loading user...</p> }))
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Hook ordering**: place every React hook and effect-view hook helper *before* the first operation that may suspend. After a suspend point, the generator continuation runs outside React's synchronous render phase, so no hooks may follow it.
|
||||
- The `promise` prop name is reserved on async components (used internally) — do not declare a prop with that name.
|
||||
|
||||
When to reach for `Async` vs alternatives:
|
||||
|
||||
- One-off asynchronous read before rendering → `Async.async`.
|
||||
- Cached/shared/refreshable server reads → `Query` (see `Query.md`).
|
||||
- User-triggered writes with pending/error state → `Mutation` (see `Mutation.md`).
|
||||
- Async work in an event handler with no need for `Mutation` state → `useRunPromise`/`useCallbackPromise` (see `Component.md`).
|
||||
- Subscriptions or background work tied to lifecycle → a scoped fiber forked from `useReactEffect`.
|
||||
|
||||
## Memoized
|
||||
|
||||
Wraps a component's rendered function with `React.memo`, so an unrelated parent re-render doesn't re-run the component (or restart an `Async` child's in-flight computation) when props are unchanged.
|
||||
|
||||
```tsx
|
||||
import { Async, Component, Memoized } from "effect-view"
|
||||
|
||||
export const UserCard = Component.make("UserCard")(function* ({ userId }: { readonly userId: string }) {
|
||||
const user = yield* Component.useOnChange(() => loadUser(userId), [userId])
|
||||
return <article>{user.name}</article>
|
||||
}).pipe(Async.async, Memoized.memoized)
|
||||
```
|
||||
|
||||
- Default comparison is `Object.is` per prop (React.memo default), except on `Async` components where `fallback` is excluded from the comparison by default.
|
||||
- Override with `Memoized.withOptions({ propsEquivalence })`, e.g. `Equal.asEquivalence()` for full structural equality on immutable data. Supplying `propsEquivalence` replaces the default entirely (so `fallback` is included again for `Async` components unless you exclude it yourself).
|
||||
- `Memoized` is not exclusive to `Async` — it applies to any `Component` — but it matters most there, since an unmemoized async child otherwise closes/reopens its dependency scope and re-runs its async setup on every parent render.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Component
|
||||
|
||||
Defines a React function component as an Effect program. A `Component` is a description, not yet a React component — cross into React with `Component.withContext` or `.use`.
|
||||
|
||||
## Define
|
||||
|
||||
```tsx
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-view"
|
||||
|
||||
export const HelloView = Component.make("HelloView")(function* (props: { readonly name: string }) {
|
||||
const message = yield* Effect.succeed(`Hello, ${props.name}`)
|
||||
return <h1>{message}</h1>
|
||||
})
|
||||
```
|
||||
|
||||
- `Component.make(spanName?)(generatorBody, ...pipeArgs)` — same overloads as `Effect.fn`/`Effect.gen`: a generator body, or a body plus `(_, props) => next` pipeline steps. Passing a `spanName` wraps the body in a tracing span and sets `displayName`.
|
||||
- `Component.makeUntraced` is identical but skips the automatic span (still sets `displayName` from the name argument).
|
||||
- The component's props type, return type, error channel, and required services (`R`) are all inferred from the generator body.
|
||||
|
||||
## Cross into React
|
||||
|
||||
```tsx
|
||||
export const Hello = HelloView.pipe(Component.withContext(runtime.context))
|
||||
// <Hello name="Effect" />
|
||||
```
|
||||
|
||||
`Component.withContext(context)` reads the Effect context supplied by the matching `ReactRuntime.Provider` and turns the component into a plain `React.FC`. Apply it only at boundaries where plain React (a router, a third-party lib, an app root) needs a function component — never between two effect-view components.
|
||||
|
||||
## Compose inside effect-view
|
||||
|
||||
```tsx
|
||||
const Hello = yield* HelloView.use
|
||||
return <Hello name="Effect" />
|
||||
```
|
||||
|
||||
`component.use` is an `Effect<F, never, Exclude<R, Scope.Scope>>` that binds the child to the current Effect context/scope and returns a stable function-component reference. Yield it from a parent component body; do not call `withContext` here.
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
Hooks are plain React hooks under the hood: call them unconditionally, at the top level, in the same order every render — never in branches, loops, callbacks, or after a suspend point.
|
||||
|
||||
Every rendered component instance gets a root `Scope.Scope`, created on mount and closed on unmount, provided to the whole body. `Effect.addFinalizer`, `Effect.acquireRelease`, `Effect.forkScoped` used directly in the body run against this scope.
|
||||
|
||||
| Hook | Purpose | Scope | Closes |
|
||||
|---|---|---|---|
|
||||
| body | produce rendered output | component root scope | unmount |
|
||||
| `useOnMount(() => effect)` | compute + cache once | component root scope | unmount |
|
||||
| `useOnChange(() => effect, deps)` | recompute on deps change | new scope per dep set | deps change / unmount |
|
||||
| `useReactEffect(() => effect, deps?)` | post-commit side effect (`Effect.useEffect` analog) | new scope | deps change / unmount |
|
||||
| `useReactLayoutEffect(() => effect, deps?)` | pre-paint side effect | new scope | deps change / unmount |
|
||||
| `useLayer(layer, options?)` | build + provide a `Layer`, returns its `Context` | new scope tied to layer identity | layer ref changes / unmount |
|
||||
|
||||
```tsx
|
||||
const state = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.log("disposed"))
|
||||
return Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
- Setup passed to `useOnMount`/`useOnChange`/`useLayer` must complete **synchronously** in a regular component (it runs during render). Wrap the component with `Async.async` to allow suspending setup.
|
||||
- `useReactEffect`/`useReactLayoutEffect` setup must also start synchronously but may `Effect.forkScoped` async work into the hook's own scope.
|
||||
- `useRunSync`/`useRunPromise`/`useCallbackSync`/`useCallbackPromise` do not create a new scope; they capture the component root scope (and any extra services you request) by default.
|
||||
|
||||
## Run Effects from event handlers
|
||||
|
||||
```tsx
|
||||
const runPromise = yield* Component.useRunPromise() // or useRunPromise<Scope.Scope | SomeService>()
|
||||
<button onClick={() => void runPromise(saveUser(user))}>Save</button>
|
||||
```
|
||||
|
||||
- `useRunSync<R>()` — only for Effects guaranteed to complete synchronously.
|
||||
- `useRunPromise<R>()` — for Effects that may suspend/sleep/fetch.
|
||||
- `useCallbackSync(f, deps)` / `useCallbackPromise(f, deps)` — memoized variants (same deps semantics as `React.useCallback`) for passing stable callbacks to children.
|
||||
- Both runners provide `Scope.Scope` automatically; add extra services with an explicit type argument (`useRunPromise<Scope.Scope | UserRepository>()`).
|
||||
|
||||
## Provide services
|
||||
|
||||
Static layer, one instance per mounted component, disposed on unmount:
|
||||
|
||||
```tsx
|
||||
const GreetingViewLive = GreetingView.pipe(Component.provide(GreetingService.layer))
|
||||
```
|
||||
|
||||
Layer built from render-time state (props/context), provided to children explicitly:
|
||||
|
||||
```tsx
|
||||
const layer = React.useMemo(() => Layer.succeed(GreetingService, {...}), [props.greeting])
|
||||
const context = yield* Component.useLayer(layer)
|
||||
const Greeting = yield* Effect.provide(GreetingView.use, context)
|
||||
return <Greeting name="Effect" />
|
||||
```
|
||||
|
||||
Keep layer references stable (module scope or `React.useMemo`) — a new layer object triggers rebuild and finalizer cleanup. Async layer construction requires `Async.async` on the owning component.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- Never yield an asynchronous Effect from a regular component body — use `Async.async`, `Query`, a `Mutation` callback, or a scoped fiber forked from a post-commit hook instead.
|
||||
- `Component.withContext` needs a matching `ReactRuntime.Provider` above it in the tree.
|
||||
- Prefer `useRunPromise` over `useRunSync` for event handlers that may be async.
|
||||
- Regular React hooks, refs, context, and state work normally inside a component body alongside `yield*`.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Form
|
||||
|
||||
The shared model implemented by both root form types (`MutationForm.md`, `LensForm.md`) and every subform focused from them. A schema is the single source of truth for shape, validation, and the decoded value the application receives; `Form` supplies reactive state and lifecycle on top of it — it does not render anything.
|
||||
|
||||
A schema distinguishes the **encoded value** the UI edits from the **decoded value** the application uses:
|
||||
|
||||
```tsx
|
||||
import { Schema } from "effect"
|
||||
|
||||
const ProfileSchema = Schema.Struct({
|
||||
displayName: Schema.String.check(Schema.isMinLength(1, { message: "Enter a display name" })),
|
||||
age: Schema.NumberFromString, // input edits a string, app gets a number
|
||||
contact: Schema.Struct({
|
||||
email: Schema.String.check(Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, { message: "Enter a valid email" })),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
## The Form interface
|
||||
|
||||
| Member | Meaning |
|
||||
|---|---|
|
||||
| `encodedValue` | writable input-shaped state (a `Lens`) |
|
||||
| `value` | decoded value as `Option<A>`; `None` until decoding succeeds |
|
||||
| `issues` | Standard Schema issues scoped to this form's path |
|
||||
| `isValidating` | whether schema decoding is currently running |
|
||||
| `canCommit` | whether the root has a valid value and is ready to commit |
|
||||
| `isCommitting` | whether a mutation or target write is in progress |
|
||||
|
||||
There is no separate "field" type — a field is just a `Form` focused on part of its parent.
|
||||
|
||||
## Focus into subforms
|
||||
|
||||
```tsx
|
||||
const displayNameField = Form.focusObjectOn(form, "displayName")
|
||||
const contactForm = Form.focusObjectOn(form, "contact")
|
||||
const emailField = form.pipe(Form.focusObjectOn("contact"), Form.focusObjectOn("email"))
|
||||
```
|
||||
|
||||
- `Form.focusObjectOn` (struct key), `Form.focusArrayAt` (array index), `Form.focusTupleAt` (tuple index), `Form.focusChunkAt` (Chunk index). All are dual API: data-first, or curried for chaining through nested paths with `pipe`.
|
||||
- A focused form exposes `encodedValue`/`value`/`issues` scoped to that path; `isValidating`/`canCommit`/`isCommitting` stay connected to the root form.
|
||||
- Focus once (e.g. at component setup), not on every render.
|
||||
|
||||
## Bind a subform to an input
|
||||
|
||||
```tsx
|
||||
const input = yield* Form.useInput(emailField, { debounce: "250 millis" })
|
||||
<input value={input.value} onChange={e => input.setValue(e.currentTarget.value)} />
|
||||
```
|
||||
|
||||
- `Form.useInput(form, { debounce? })` returns `{ value, setValue }` from the subform's encoded value. `setValue` writes the form and re-runs the schema pipeline. `debounce` delays propagation to the form (the displayed value updates immediately) — useful for text inputs to avoid validating every keystroke.
|
||||
- `Form.useOptionalInput(form, { defaultValue, debounce? })` is for a field whose encoded value is `Option<I>`: returns `{ value, setValue, enabled, setEnabled }` for a togglable optional input. `value`/`setValue` operate on the unwrapped `I`; `defaultValue` is used as `value` while `enabled` is `false` (i.e. while the encoded field is `None`), and `defaultValue` is required.
|
||||
- `Form.useStatus(form, { debounce? })` returns `{ isValidating, isCommitting, canCommit }`, debounced (default 250ms) to avoid flicker in pending indicators.
|
||||
|
||||
These hooks are building blocks for your own reusable input components — wrap them once to handle labels, issues, disabled state, and styling consistently; both hooks accept any `Form.Form`, so the same input component works with subforms from `MutationForm` or `LensForm`.
|
||||
|
||||
## Schema-owned conversions
|
||||
|
||||
Because the schema defines both directions, it can own an entire domain conversion — e.g. a local `datetime-local` input string decoding to a UTC `DateTime.Utc` and back — with no manual parsing in components. See `MutationForm.md` for a complete example (`DateTimeUtcFromZonedInput`).
|
||||
@@ -0,0 +1,25 @@
|
||||
# LensForm
|
||||
|
||||
A root form (implements `Form.Form`, see `Form.md`) that keeps an encoded draft synchronized in both directions with a target `Lens` of decoded application data. Use for settings panels, inspectors, and edit screens where a valid change should update existing state without a final submit.
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, LensForm, View } from "effect-view"
|
||||
|
||||
const [form, profile] = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
const profile = Lens.fromSubscriptionRef(
|
||||
yield* SubscriptionRef.make({ displayName: "Ada", age: 37, contact: { email: "ada@example.com" } }),
|
||||
)
|
||||
const form = yield* LensForm.make({ schema: ProfileSchema, target: profile }).pipe(LensForm.thenRun)
|
||||
return [form, profile] as const
|
||||
}),
|
||||
)
|
||||
|
||||
const [savedProfile, isCommitting] = yield* View.useAll([profile, form.isCommitting])
|
||||
```
|
||||
|
||||
- `LensForm.make({ schema, target, initialEncodedValue? })` — `target` holds decoded data. `LensForm.thenRun` starts synchronization.
|
||||
- A valid edit is decoded and written to `target` automatically; an invalid edit stays in the form (so the user can correct it) and never reaches `target`. If something else updates `target`, `LensForm` encodes that value back into the draft.
|
||||
- Pass `initialEncodedValue` only when the first draft should differ from the encoded target; otherwise `LensForm.make` derives the initial draft by encoding the current target through the schema.
|
||||
- No `submit` method — commits happen continuously as valid edits arrive. Focus into subforms/fields with `Form.focusObjectOn`/etc. and bind with `Form.useInput` exactly as with `MutationForm` (see `Form.md`).
|
||||
@@ -0,0 +1,89 @@
|
||||
# Mutation
|
||||
|
||||
effect-view's counterpart to TanStack Query mutations: user-triggered asynchronous work (save, delete, upload, send) as an Effect. No cache, no reactive key, no automatic execution — it runs only when called.
|
||||
|
||||
| TanStack Mutation | effect-view |
|
||||
|---|---|
|
||||
| mutation variables | the input key `K` |
|
||||
| `mutationFn` | `f: (key: K) => Effect<A, E, R>` |
|
||||
| mutation result | `mutation.state`, a `View<{ key: Option<K>; result: AsyncResult<A, E> }>` |
|
||||
| `isPending` | `state.result.waiting` |
|
||||
| `mutateAsync` | `mutation.mutate(key)` |
|
||||
| start without awaiting | `mutation.mutateView(key)` |
|
||||
|
||||
## Create
|
||||
|
||||
```tsx
|
||||
import { Mutation } from "effect-view"
|
||||
|
||||
const mutation = yield* Component.useOnMount(() =>
|
||||
Mutation.make({ f: (input: InviteInput) => sendInvite(input) }),
|
||||
)
|
||||
```
|
||||
|
||||
`Mutation.make({ f })` is an Effect constructor, not a hook — create each instance once (`Component.useOnMount` for component-owned, an Effect service for shared) and keep it stable. `f` keeps its full `Effect<A, E, R>` type; required services are captured from the creation context, so callbacks don't reconstruct dependencies. Fibers belong to the creation scope and are interrupted if that scope closes while running.
|
||||
|
||||
## AsyncResult state
|
||||
|
||||
`mutation.state` is a `View` of `{ key: Option<K>, result: AsyncResult<A, E> }`. `result` starts `Initial` (`waiting: false`); calling `mutate`/`mutateView` sets `waiting: true`, then publishes `Success` or `Failure`. Match on `state.result`, not `state` itself:
|
||||
|
||||
```tsx
|
||||
import { AsyncResult } from "effect/unstable/reactivity"
|
||||
|
||||
const [state] = yield* View.useAll([mutation.state])
|
||||
|
||||
AsyncResult.match(state.result, {
|
||||
onInitial: ({ waiting }) => (...),
|
||||
onFailure: ({ cause, previousSuccess, waiting }) => (...), // cause: Cause<E>
|
||||
onSuccess: ({ value, waiting }) => (...),
|
||||
})
|
||||
```
|
||||
|
||||
`waiting` is independent of the result tag: after one success, starting another call keeps the value visible while `waiting: true`; if that call fails, the failure can retain `previousSuccess`. Failures carry a full `Cause<E>`.
|
||||
|
||||
## mutate vs mutateView
|
||||
|
||||
| Method | Returns | Use for |
|
||||
|---|---|---|
|
||||
| `mutate(key)` | the final `FinalMutationState` (`{ key: Option.Some<K>, result: Success \| Failure }`) | an Effect workflow that needs the outcome |
|
||||
| `mutateView(key)` | a live per-call `View<{ key: Option.Some<K>, result: AsyncResult<A, E> }>` | a UI callback that just starts the work |
|
||||
|
||||
```tsx
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
void runPromise(Effect.gen(function* () {
|
||||
const final = yield* mutation.mutate(input)
|
||||
if (AsyncResult.isSuccess(final.result)) yield* Effect.log(`Saved ${final.result.value.id}`)
|
||||
}))
|
||||
```
|
||||
|
||||
```tsx
|
||||
const runSync = yield* Component.useRunSync()
|
||||
const state = runSync(mutation.mutateView(input)) // a View for this specific call
|
||||
```
|
||||
|
||||
The mutation Effect never fails with `E` itself — it captures the operation's `Exit` and always resolves to a final state wrapping an `AsyncResult.Success`/`Failure`.
|
||||
|
||||
## Reactive metadata
|
||||
|
||||
| Member | Meaning |
|
||||
|---|---|
|
||||
| `state` | latest mutation state, shared `View` |
|
||||
| `latestKey` | most recent input, `Option<K>` |
|
||||
| `latestFinalState` | latest completed final state, `Option<FinalMutationState<K, A, E>>` |
|
||||
| `fiber` | most recently started mutation fiber, `Option` |
|
||||
|
||||
## Concurrency
|
||||
|
||||
Starting a mutation does not interrupt an earlier one — calls can overlap, each with its own `mutateView` state; `mutation.state` reflects whichever update arrived last. For a single submit button, disabling while `result.waiting` is usually enough. Use per-call `mutateView` Views (e.g. per uploaded file) when concurrent operations each need their own progress indicator.
|
||||
|
||||
## Updating queries after a mutation
|
||||
|
||||
Mutations never auto-invalidate `Query` caches — compose it explicitly:
|
||||
|
||||
```tsx
|
||||
const final = yield* updatePost.mutate(input)
|
||||
if (AsyncResult.isSuccess(final.result)) {
|
||||
yield* posts.invalidateCacheEntry(["post", final.result.value.id] as const)
|
||||
yield* posts.refreshView // invalidation alone does not refetch
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
# MutationForm
|
||||
|
||||
A root form (implements `Form.Form`, see `Form.md`) that owns a local encoded draft and passes the valid decoded value to a `Mutation` when `submit` runs. Use for registration, checkout, search — any workflow with an explicit submit action.
|
||||
|
||||
```tsx
|
||||
import { Effect } from "effect"
|
||||
import { Component, MutationForm, View } from "effect-view"
|
||||
|
||||
const form = yield* Component.useOnMount(() =>
|
||||
MutationForm.make({
|
||||
schema: ProfileSchema,
|
||||
initialEncodedValue: { displayName: "", age: "", contact: { email: "" } },
|
||||
f: ([profile, form]) => Effect.log(`Creating ${profile.displayName}, age ${profile.age}`),
|
||||
}).pipe(MutationForm.thenRun),
|
||||
)
|
||||
|
||||
const [canCommit, isCommitting] = yield* View.useAll([form.canCommit, form.isCommitting])
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
<button disabled={!canCommit || isCommitting} onClick={() => void runPromise(form.submit)}>
|
||||
{isCommitting ? "Creating..." : "Create profile"}
|
||||
</button>
|
||||
```
|
||||
|
||||
- `MutationForm.make({ schema, initialEncodedValue, f })` constructs the form; the underlying `Mutation`'s input key is the tuple `[decodedValue, form]`, not just the decoded value. Most `f` implementations only destructure the first element (`profile.age` is a number even though the input edited a string); `form` is included so `f` can, if needed, read other form state (e.g. `form.issues`, `form.encodedValue`) while handling the submission. `MutationForm.thenRun` starts initial validation in the current scope.
|
||||
- Create once and keep stable — the usual home is `Component.useOnMount`.
|
||||
- `form.submit` runs the mutation only when the form can currently commit; schema issues block submission before the mutation ever runs.
|
||||
- Focus into subforms/fields with `Form.focusObjectOn`/`focusArrayAt`/etc. (see `Form.md`) and bind them to inputs with `Form.useInput`.
|
||||
- If `f` fails with a `Schema.SchemaError`, `form.submit` formats that error into `form.issues` automatically — the same formatting path used for client-side decoding errors — instead of only surfacing it as a mutation failure. Any other failure from `f` is left as the mutation's `Failure` and does not touch `form.issues`.
|
||||
|
||||
## Example: schema-owned date conversion
|
||||
|
||||
```tsx
|
||||
class DateTimeUtcFromZoned extends Schema.transformOrFail(Schema.DateTimeZonedFromSelf, Schema.DateTimeUtcFromSelf, {
|
||||
strict: true,
|
||||
decode: input => ParseResult.succeed(DateTime.toUtc(input)),
|
||||
encode: DateTime.setZoneCurrent,
|
||||
}) {}
|
||||
|
||||
export class DateTimeUtcFromZonedInput extends Schema.transformOrFail(Schema.String, DateTimeUtcFromZoned, {
|
||||
strict: true,
|
||||
decode: (input, _options, ast) => Effect.flatMap(DateTime.CurrentTimeZone, timeZone =>
|
||||
Option.match(DateTime.makeZoned(input, { timeZone, adjustForTimeZone: true }), {
|
||||
onSome: ParseResult.succeed,
|
||||
onNone: () => ParseResult.fail(new ParseResult.Type(ast, input, "Enter a valid date and time")),
|
||||
})),
|
||||
encode: value => ParseResult.succeed(DateTime.formatIsoZoned(value).slice(0, 16)),
|
||||
}) {}
|
||||
```
|
||||
|
||||
An `<input type="datetime-local">` edits `"2026-07-22T14:30"`; the schema decodes it to a UTC `DateTime.Utc` (`decode(...).pipe requires DateTime.CurrentTimeZone` — provide `DateTime.layerCurrentZoneLocal` in the runtime) and the mutation receives the UTC instant directly. No manual date parsing in the component.
|
||||
@@ -0,0 +1,9 @@
|
||||
# PubSub
|
||||
|
||||
Re-exports Effect's `PubSub` module in full (`export * from "effect/PubSub"`) and adds one component hook.
|
||||
|
||||
```tsx
|
||||
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
|
||||
```
|
||||
|
||||
`PubSub.useFromReactiveValues(values: DependencyList)` creates a scoped, unbounded `PubSub` on mount and publishes `values` to it every time the dependency array changes (skipping publish once the PubSub has been shut down). Useful for bridging a set of React-tracked reactive values into an Effect `Stream`-based consumer inside the component's scope.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Query
|
||||
|
||||
effect-view's take on TanStack Query: reactive query keys, cached results, stale times, background refresh, window-focus refetching, cache invalidation — but the query function is an `Effect` and the observable state is a `View` (see `State.md`).
|
||||
|
||||
| TanStack Query | effect-view |
|
||||
|---|---|
|
||||
| `QueryClient` | the `QueryClient` Effect service (below) |
|
||||
| `queryKey` | a reactive key supplied as a `View<K>` |
|
||||
| `queryFn` | `f: (key: K) => Effect<A, E, R>` |
|
||||
| `useQuery` result | `query.state`, a `View<QueryState<K, A, E>>` |
|
||||
| `isFetching` | `result.waiting` |
|
||||
| `refetch` | `query.refresh` / `query.refreshView` |
|
||||
| `invalidateQueries` | `query.invalidateCache` / `invalidateCacheEntry` |
|
||||
|
||||
## QueryClient: the shared cache
|
||||
|
||||
Every `Query` reads and writes through a `QueryClient`, the Effect service that owns the cache and its garbage collection. Add it once to the application runtime:
|
||||
|
||||
```tsx
|
||||
import { Layer } from "effect"
|
||||
import { QueryClient, ReactRuntime } from "effect-view"
|
||||
|
||||
const AppLive = Layer.empty.pipe(
|
||||
Layer.provideMerge(QueryClient.layer({
|
||||
defaultStaleTime: "30 seconds", // default: "0 minutes"
|
||||
defaultRefreshOnWindowFocus: true, // default: true
|
||||
cacheGcTime: "5 minutes", // default: "5 minutes"
|
||||
})),
|
||||
)
|
||||
export const runtime = ReactRuntime.make(AppLive)
|
||||
```
|
||||
|
||||
`QueryClient.layer(options?)` builds the service and forks its background garbage-collection loop into the layer's scope. Individual queries may override `staleTime`/`refreshOnWindowFocus`; unset options fall back to these client defaults. `cacheGcTime` controls how long a stale, unaccessed cache entry is kept before eviction. You interact with the client only indirectly through `Query` instances — no need to call `QueryClientService` methods directly.
|
||||
|
||||
## Create and run a query
|
||||
|
||||
```tsx
|
||||
import { Effect, Schema, SubscriptionRef } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Component, Lens, Query, View } from "effect-view"
|
||||
|
||||
const [postId, query] = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
const key = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const))
|
||||
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
staleTime: "1 minute",
|
||||
f: ([, id]) =>
|
||||
HttpClient.HttpClient.pipe(
|
||||
Effect.andThen(client => client.get(`https://example.com/posts/${id}`)),
|
||||
Effect.andThen(res => res.json),
|
||||
Effect.andThen(Schema.decodeUnknownEffect(Post)),
|
||||
),
|
||||
}).pipe(Query.thenRun)
|
||||
|
||||
return [Lens.focusTupleAt(key, 1), query] as const
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
- `Query.make({ key, f, staleTime?, refreshOnWindowFocus?, keyEquivalence? })` constructs the query; `Query.thenRun` starts watching `key` in the current scope.
|
||||
- Create each query once and keep it stable — the usual home is `Component.useOnMount` (component-owned) or an Effect service (shared across components). Change the key, don't recreate the query, when input changes.
|
||||
- Keys use Effect equality by default (`keyEquivalence` overrides it). A tuple key plays the same role as `['post', id]` in TanStack Query.
|
||||
- `f` keeps its full `Effect<A, E, R>` type: required services, schema decoding, retries, tracing all compose normally. The context is captured at creation time.
|
||||
- Changing the key interrupts the previous in-flight request; a fresh cached success or a new run for the new key follows.
|
||||
|
||||
## Render AsyncResult
|
||||
|
||||
`query.state: View<QueryState<K, A, E>>` where `QueryState = { key: K; result: AsyncResult<A, E> }`.
|
||||
|
||||
```tsx
|
||||
import { AsyncResult } from "effect/unstable/reactivity"
|
||||
|
||||
const [state] = yield* View.useAll([query.state])
|
||||
|
||||
AsyncResult.match(state.result, {
|
||||
onInitial: ({ waiting }) => waiting ? <p>Loading...</p> : <p>Not loaded.</p>,
|
||||
onFailure: ({ cause, previousSuccess, waiting }) => (/* cause: Cause<E>, previousSuccess: Option<Success> */),
|
||||
onSuccess: ({ value, waiting }) => (/* value: A, waiting: refreshing in background */),
|
||||
})
|
||||
```
|
||||
|
||||
`waiting` is independent of the result tag: a background refresh keeps a `Success` successful (with its value) while `waiting: true`; a failed refresh can retain `previousSuccess`. Failures carry a full `Cause<E>` (typed errors, defects, interruption), not just `E`.
|
||||
|
||||
## Refresh, fetch, invalidate
|
||||
|
||||
| Method | Behavior |
|
||||
|---|---|
|
||||
| `fetch(key)` | fetch a specific key, wait for its final state |
|
||||
| `fetchView(key)` | start fetching a key, return immediately as a live state `View` |
|
||||
| `refresh` | resolve the current key again, wait for its final state |
|
||||
| `refreshView` | resolve the current key again, return immediately as a live `View` |
|
||||
| `invalidateCacheEntry(key)` | remove the cached success for one key |
|
||||
| `invalidateCache` | remove every cached success for this query |
|
||||
|
||||
The `*View` variants suit synchronous UI callbacks (`runSync(query.refreshView)`); the non-`View` variants suit Effect workflows waiting on the outcome. **Invalidating does not refetch by itself** — follow with `refreshView`, a key change, or a later natural fetch.
|
||||
|
||||
```ts
|
||||
import { Schedule } from "effect"
|
||||
|
||||
const query = yield* Query.make(options).pipe(
|
||||
Query.thenRun,
|
||||
Query.withScheduledRefresh(Schedule.spaced("5 minutes").pipe(Schedule.upTo({ times: 3 }))),
|
||||
)
|
||||
```
|
||||
|
||||
`Query.withScheduledRefresh(schedule)` forks a refresh fiber tied to the surrounding scope; cache/`staleTime` rules still apply.
|
||||
|
||||
## Staleness and lifetime
|
||||
|
||||
- `staleTime` (per query, falls back to `QueryClient` default): how long a successful result satisfies a fetch without re-running `f`. A stale entry stays available as previous data while it refreshes.
|
||||
- `cacheGcTime` (on `QueryClient`): entries unused for `staleTime + cacheGcTime` are evicted.
|
||||
- `refreshOnWindowFocus` (per query, falls back to `QueryClient` default): re-resolves the current key on window focus. Requires the optional `@effect/platform-browser` package; without it, the option is silently ignored (rest of the API works normally). Also a no-op outside browser environments.
|
||||
|
||||
## The Effect touch
|
||||
|
||||
Request fibers belong to the creation scope and are interrupted on unmount, key replacement, or scope closure. Results are `View`s usable outside React too. Mutations do not auto-invalidate queries — compose it explicitly (see `Mutation.md`).
|
||||
@@ -0,0 +1,39 @@
|
||||
# ReactRuntime
|
||||
|
||||
Owns a managed Effect runtime and exposes it to a React subtree through context. Every effect-view application has exactly one root runtime per independent Effect context tree.
|
||||
|
||||
## Create and provide
|
||||
|
||||
```tsx
|
||||
import { Layer } from "effect"
|
||||
import { ReactRuntime } from "effect-view"
|
||||
|
||||
const AppLive = Layer.empty // add application layers here
|
||||
|
||||
export const runtime = ReactRuntime.make(AppLive)
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { ReactRuntime } from "effect-view"
|
||||
import { runtime } from "./runtime"
|
||||
import { App } from "./App"
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ReactRuntime.Provider runtime={runtime} fallback={<p>Starting...</p>}>
|
||||
<App />
|
||||
</ReactRuntime.Provider>
|
||||
</StrictMode>,
|
||||
)
|
||||
```
|
||||
|
||||
- `ReactRuntime.make(layer, memoMap?)` builds a `ManagedRuntime` and a `React.Context` in one value. Define it at module scope — never inside a render.
|
||||
- `ReactRuntime.Provider` builds the runtime layer (which can suspend, hence `fallback`), makes the resulting context available via React context, and disposes the managed runtime on unmount.
|
||||
- With a router, keep the provider above the router provider. Put a React error boundary above it if runtime construction can fail.
|
||||
|
||||
## Rules
|
||||
|
||||
- One runtime instance per app (or per independent subtree that needs its own root context).
|
||||
- `ReactRuntime.Provider` only supplies context; it never builds automatically at other boundaries — `Component.withContext` reads it explicitly.
|
||||
@@ -0,0 +1,87 @@
|
||||
# State: Lens and View
|
||||
|
||||
`Lens` and `View` are effect-view's state primitives, re-exported in full from [`effect-lens`](https://www.npmjs.com/package/effect-lens/v/beta) with two React hooks added on top (`Lens.useState`, `View.useAll`). The core data model, constructors, and focus/derive API belong to `effect-lens` — consult its docs for anything not covered here.
|
||||
|
||||
- **`View<A>`** is the read-only half: a current value plus a stream of changes. Use it for anything a component should only observe (derived/computed state, `Query`/`Mutation` state, a read-only field exposed by a service).
|
||||
- **`Lens<A>`** is a `View<A>` that can also be written to. Every `Lens` is a `View`, so anywhere a `View` is expected, a `Lens` works too.
|
||||
|
||||
## Create state
|
||||
|
||||
```tsx
|
||||
import { SubscriptionRef } from "effect"
|
||||
import { Lens } from "effect-view"
|
||||
|
||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
```
|
||||
|
||||
The usual pattern: build state with an Effect primitive (`SubscriptionRef`), then wrap it as a `Lens` with a matching constructor.
|
||||
|
||||
## Where to store it
|
||||
|
||||
| Owner | When |
|
||||
|---|---|
|
||||
| Effect service (`Context.Service` + `Layer`) | shared by multiple components / application-level state |
|
||||
| `Component.useOnMount` | owned by one component instance (or a shallow subtree receiving it via props) |
|
||||
| plain `React.useState` | simple local UI state with no need for Effect integration, subscriptions, or sharing |
|
||||
|
||||
```tsx
|
||||
class CounterState extends Context.Service<CounterState, {
|
||||
readonly count: Lens.Lens<number>
|
||||
readonly doubled: View.View<number>
|
||||
}>()("CounterState") {
|
||||
static readonly layer = Layer.effect(CounterState, Effect.gen(function* () {
|
||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
const doubled = View.map(count, n => n * 2) // derived, read-only
|
||||
return { count, doubled } as const
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
## Read: View.useAll
|
||||
|
||||
```tsx
|
||||
const [count, doubled] = yield* View.useAll([state.count, state.doubled])
|
||||
```
|
||||
|
||||
- Reads current values during render, then subscribes via a scoped stream and updates React state on change.
|
||||
- This is the default way to read a `Lens` too, since `Lens` is a `View`.
|
||||
- `View.useAll(views, { equivalence? })` — `equivalence` controls when a combined change across the supplied views counts as meaningful (defaults to comparing element-wise with `Equal.strictEqual()`).
|
||||
|
||||
## Write
|
||||
|
||||
```tsx
|
||||
yield* Lens.update(state.count, n => n + 1)
|
||||
yield* Lens.set(state.count, 0)
|
||||
```
|
||||
|
||||
## Lens.useState — read/write tuple for controlled inputs
|
||||
|
||||
Use when a JSX API wants React's `[value, setValue]` shape (controlled `<input>`, checkbox, select, third-party `value`/`onChange` props). If a component only needs to *display* the value, prefer `View.useAll` — reach for `Lens.useState` only where reading and writing need to be wired together in React's local-state shape.
|
||||
|
||||
```tsx
|
||||
const [name, setName] = yield* Lens.useState(state.name)
|
||||
<input value={name} onChange={e => setName(e.currentTarget.value)} />
|
||||
```
|
||||
|
||||
Calling the setter writes through the `Lens`, so every other subscriber (another `Lens.useState`, or a `View.useAll` elsewhere) sees the update. `Lens.useState(lens, { equivalence? })` controls when a change triggers a re-render. The setter accepts a plain value or a `prev => next` updater, same as `React.useState`'s.
|
||||
|
||||
## Focused Lenses
|
||||
|
||||
A focused Lens is still a `Lens` — read it with `View.useAll` or `Lens.useState` like any other.
|
||||
|
||||
```tsx
|
||||
const nameLens = Lens.focusObjectOn(state.profile, "name")
|
||||
const cityLens = state.profile.pipe(
|
||||
Lens.focusObjectOn("contact"),
|
||||
Lens.focusObjectOn("address"),
|
||||
Lens.focusObjectOn("city"),
|
||||
)
|
||||
```
|
||||
|
||||
- Focus helpers (`focusObjectOn`, `focusArrayAt`, `focusTupleAt`, `focusChunkAt`, ...) are dual API: data-first (`Lens.focusObjectOn(lens, key)`) or curried for `pipe` chaining through nested paths.
|
||||
- Create focused lenses once (e.g. in `Component.useOnMount`), not on every render. Writes through a focused lens propagate to the parent lens.
|
||||
- Full focus/derive/custom-write API: see the `effect-lens` docs.
|
||||
|
||||
## Bridging plain React state into a Lens
|
||||
|
||||
`Lens.useFromReactState([value, setValue])` wraps an existing React state tuple as a `Lens`, keeping both directions in sync — useful when integrating a third-party hook that already owns `[value, setValue]` state.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Stream
|
||||
|
||||
Re-exports Effect's `Stream` module in full (`export * from "effect/Stream"`) and adds one component hook for consuming a stream as React state.
|
||||
|
||||
```tsx
|
||||
const latest = yield* Stream.use(someStream) // Effect<Option<A>, never, R>
|
||||
const latest = yield* Stream.use(someStream, initialValue) // Effect<Some<A>, never, R>
|
||||
```
|
||||
|
||||
`Stream.use(stream, initialValue?)` subscribes to `stream` for the component's lifetime (via a scoped fiber forked in a post-commit effect) and returns the latest emitted value as React state, deduped with strict equality. Without `initialValue` the result starts as `Option.none()` until the first emission; with one, it starts as `Option.some(initialValue)`.
|
||||
|
||||
Prefer `View.useAll` (see `State.md`) when the source is already a `View`/`Lens` — reach for `Stream.use` when you have a raw Effect `Stream` to observe directly.
|
||||
@@ -1,14 +1,16 @@
|
||||
{
|
||||
"name": "effect-view",
|
||||
"description": "Write React function components with Effect",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"./README.md",
|
||||
"./AGENTS.md",
|
||||
"./src/**/*.ts",
|
||||
"./dist/**/*.js",
|
||||
"./dist/**/*.js.map",
|
||||
"./dist/**/*.d.ts"
|
||||
"./dist/**/*.d.ts",
|
||||
"./ai-docs/**/*.md"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -77,6 +77,29 @@ describe("Mutation", () => {
|
||||
expect(result.result.previousSuccess.value.value).toBe("saved")
|
||||
})
|
||||
|
||||
it("runs a second mutation with its own key, not the previous one", async () => {
|
||||
const result = await runMutationTest(Effect.gen(function*() {
|
||||
const calls: Array<string> = []
|
||||
const mutation = yield* Mutation.make({
|
||||
f: (key: string) => Effect.sync(() => {
|
||||
calls.push(key)
|
||||
return key
|
||||
}),
|
||||
})
|
||||
|
||||
const first = yield* mutation.mutate("a")
|
||||
const second = yield* mutation.mutate("b")
|
||||
|
||||
return { calls, first, second }
|
||||
}))
|
||||
|
||||
expect(result.calls).toEqual(["a", "b"])
|
||||
expect(result.first.key.value).toBe("a")
|
||||
expect(expectSuccessValue(result.first)).toBe("a")
|
||||
expect(result.second.key.value).toBe("b")
|
||||
expect(expectSuccessValue(result.second)).toBe("b")
|
||||
})
|
||||
|
||||
it("mutateView returns a waiting state without waiting for completion", async () => {
|
||||
const result = await runMutationTest(Effect.gen(function*() {
|
||||
const deferred = yield* Deferred.make<string>()
|
||||
|
||||
@@ -78,8 +78,10 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
|
||||
Scope.Scope | R
|
||||
> {
|
||||
return Effect.gen({ self: this }, function*() {
|
||||
const currentKey = Option.some(key) as Option.Some<K>
|
||||
|
||||
const previous: MutationState<K, A, E> = Option.getOrElse(yield* Lens.get(this.latestFinalState), () => ({
|
||||
key: Option.some(key) as Option.Some<K>,
|
||||
key: currentKey,
|
||||
result: AsyncResult.initial(),
|
||||
}))
|
||||
const state = yield* makeMutationStateLens(previous)
|
||||
@@ -89,17 +91,17 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
|
||||
state,
|
||||
previous => AsyncResult.match(previous.result, {
|
||||
onInitial: () => ({
|
||||
key: previous.key,
|
||||
key: currentKey,
|
||||
result: AsyncResult.initial(true),
|
||||
}),
|
||||
onSuccess: result => ({
|
||||
key: previous.key,
|
||||
key: currentKey,
|
||||
result: AsyncResult.success(result.value, {
|
||||
waiting: true,
|
||||
}),
|
||||
}),
|
||||
onFailure: result => ({
|
||||
key: previous.key,
|
||||
key: currentKey,
|
||||
result: AsyncResult.failure(result.cause, {
|
||||
waiting: true,
|
||||
previousSuccess: result.previousSuccess,
|
||||
@@ -108,7 +110,7 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
|
||||
}
|
||||
)),
|
||||
|
||||
Effect.onExit(this.f(previous.key.value), exit => Effect.gen({ self: this }, function*() {
|
||||
Effect.onExit(this.f(key), exit => Effect.gen({ self: this }, function*() {
|
||||
const fiberId = yield* Effect.fiberId
|
||||
const fiber = yield* Lens.get(this.fiber)
|
||||
|
||||
@@ -119,24 +121,24 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
|
||||
state,
|
||||
previous => Exit.match(exit, {
|
||||
onSuccess: v => ({
|
||||
key: previous.key,
|
||||
key: currentKey,
|
||||
result: AsyncResult.success(v),
|
||||
}),
|
||||
onFailure: c => Cause.hasInterruptsOnly(c)
|
||||
? previous
|
||||
: AsyncResult.match(previous.result, {
|
||||
onInitial: () => ({
|
||||
key: previous.key,
|
||||
key: currentKey,
|
||||
result: AsyncResult.failure(c),
|
||||
}),
|
||||
onSuccess: v => ({
|
||||
key: previous.key,
|
||||
key: currentKey,
|
||||
result: AsyncResult.failure(c, {
|
||||
previousSuccess: Option.some(v),
|
||||
}),
|
||||
}),
|
||||
onFailure: v => ({
|
||||
key: previous.key,
|
||||
key: currentKey,
|
||||
result: AsyncResult.failure(c, {
|
||||
previousSuccess: v.previousSuccess,
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@effect-view/vite-plugin",
|
||||
"description": "Vite Fast Refresh support for Effect View components",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"./README.md",
|
||||
|
||||
@@ -33,11 +33,10 @@ interface ComponentImports {
|
||||
|
||||
interface Definition {
|
||||
readonly expression: ts.Expression
|
||||
readonly factoryCall: ts.CallExpression
|
||||
readonly wrapTarget: ts.Expression
|
||||
readonly body: ts.FunctionLikeDeclaration | undefined
|
||||
readonly id: string
|
||||
readonly pipeline: ts.CallExpression | undefined
|
||||
readonly entrypointIndex: number
|
||||
readonly site: EntrypointSite.Pipe | undefined
|
||||
}
|
||||
|
||||
const defaultInclude = /\.[cm]?[jt]sx?$/
|
||||
@@ -140,6 +139,13 @@ const findFactoryCall = (
|
||||
if (result)
|
||||
return
|
||||
|
||||
// Never look inside a nested function body: a factory call there
|
||||
// belongs to a different, unrelated component (e.g. one composed
|
||||
// dynamically inside this component's own render), not to the
|
||||
// composition shape of the definition being analyzed.
|
||||
if (node !== root && ts.isFunctionLike(node))
|
||||
return
|
||||
|
||||
if (ts.isCallExpression(node)) {
|
||||
if (isFactoryCallee(node.expression, imports)) {
|
||||
result = {
|
||||
@@ -165,18 +171,83 @@ const findFactoryCall = (
|
||||
return result
|
||||
}
|
||||
|
||||
const isPipeline = (expression: ts.Expression): expression is ts.CallExpression =>
|
||||
ts.isCallExpression(expression)
|
||||
&& ts.isPropertyAccessExpression(expression.expression)
|
||||
&& expression.expression.name.text === "pipe"
|
||||
const isPipeline = (node: ts.Node): node is ts.CallExpression =>
|
||||
ts.isCallExpression(node)
|
||||
&& ts.isPropertyAccessExpression(node.expression)
|
||||
&& node.expression.name.text === "pipe"
|
||||
|
||||
const isEntrypoint = (expression: ts.Expression): boolean => {
|
||||
if (!ts.isCallExpression(expression))
|
||||
return false
|
||||
const entrypointName = (callee: ts.Expression): boolean =>
|
||||
ts.isPropertyAccessExpression(callee)
|
||||
&& (callee.name.text === "withRuntime" || callee.name.text === "withContext")
|
||||
|
||||
const callee = expression.expression
|
||||
return ts.isPropertyAccessExpression(callee)
|
||||
&& (callee.name.text === "withRuntime" || callee.name.text === "withContext")
|
||||
/**
|
||||
* A curried entrypoint call (`withContext(context)`) used as one step of a
|
||||
* `.pipe(...)` chain: the descriptor produced by the preceding steps must be
|
||||
* registered *before* this step runs, since it is what converts the
|
||||
* descriptor into a plain React function component.
|
||||
*/
|
||||
const isEntrypoint = (expression: ts.Expression): boolean =>
|
||||
ts.isCallExpression(expression) && entrypointName(expression.expression)
|
||||
|
||||
/**
|
||||
* The data-first form of an entrypoint call (`withContext(descriptor,
|
||||
* context)`), which can appear anywhere - not only inside a `.pipe(...)`
|
||||
* chain - and always takes the descriptor to convert as its first argument.
|
||||
*/
|
||||
const isEntrypointDataFirst = (node: ts.Node): node is ts.CallExpression =>
|
||||
ts.isCallExpression(node)
|
||||
&& entrypointName(node.expression)
|
||||
&& node.arguments.length >= 2
|
||||
|
||||
declare namespace EntrypointSite {
|
||||
export interface Pipe {
|
||||
readonly kind: "pipe"
|
||||
readonly pipeCall: ts.CallExpression
|
||||
readonly argIndex: number
|
||||
}
|
||||
|
||||
export interface DataFirst {
|
||||
readonly kind: "dataFirst"
|
||||
readonly call: ts.CallExpression
|
||||
}
|
||||
}
|
||||
|
||||
type EntrypointSite = EntrypointSite.Pipe | EntrypointSite.DataFirst
|
||||
|
||||
/**
|
||||
* Finds where, anywhere within a definition's composition expression, a
|
||||
* descriptor is converted into a plain React function component - whether
|
||||
* through a `.pipe(..., withContext(context))` chain or a direct
|
||||
* `withContext(descriptor, context)` call. Does not look inside nested
|
||||
* function bodies, for the same reason as `findFactoryCall`.
|
||||
*/
|
||||
const findEntrypointSite = (root: ts.Node): EntrypointSite | undefined => {
|
||||
let result: EntrypointSite | undefined
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (result)
|
||||
return
|
||||
|
||||
if (node !== root && ts.isFunctionLike(node))
|
||||
return
|
||||
|
||||
if (isPipeline(node)) {
|
||||
const argIndex = node.arguments.findIndex(isEntrypoint)
|
||||
if (argIndex >= 0) {
|
||||
result = { kind: "pipe", pipeCall: node, argIndex }
|
||||
return
|
||||
}
|
||||
}
|
||||
else if (isEntrypointDataFirst(node)) {
|
||||
result = { kind: "dataFirst", call: node }
|
||||
return
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(root)
|
||||
return result
|
||||
}
|
||||
|
||||
const makeDefinition = (
|
||||
@@ -185,25 +256,33 @@ const makeDefinition = (
|
||||
imports: ComponentImports,
|
||||
): Definition | undefined => {
|
||||
const factory = findFactoryCall(expression, imports)
|
||||
const site = findEntrypointSite(expression)
|
||||
|
||||
// A `.pipe(..., withContext(context))` chain always needs the splice
|
||||
// treatment, whether or not a factory call could be found in this same
|
||||
// statement (its base may be a component defined elsewhere).
|
||||
if (site?.kind === "pipe")
|
||||
return { expression, wrapTarget: expression, body: factory?.body, id, site }
|
||||
|
||||
// `withContext(descriptor, context)` always needs its descriptor
|
||||
// argument wrapped directly, since by the time the call returns the
|
||||
// result is a plain function component, not a Component descriptor.
|
||||
if (site?.kind === "dataFirst") {
|
||||
const target = site.call.arguments[0]
|
||||
if (!target)
|
||||
return undefined
|
||||
return { expression, wrapTarget: target, body: factory?.body, id, site: undefined }
|
||||
}
|
||||
|
||||
// No entrypoint conversion happens within this statement: the
|
||||
// descriptor remains a Component.Any throughout, however it got here
|
||||
// (a bare factory call, a `.pipe()` of traits with no withContext, or a
|
||||
// user-defined composition helper wrapping either) - safe to register
|
||||
// the whole expression.
|
||||
if (!factory)
|
||||
return undefined
|
||||
|
||||
const pipeline = isPipeline(expression) ? expression : undefined
|
||||
if (expression !== factory.factoryCall && !pipeline)
|
||||
return undefined
|
||||
|
||||
const entrypointIndex = pipeline
|
||||
? pipeline.arguments.findIndex(isEntrypoint)
|
||||
: -1
|
||||
|
||||
return {
|
||||
expression,
|
||||
factoryCall: factory.factoryCall,
|
||||
body: factory.body,
|
||||
id,
|
||||
pipeline,
|
||||
entrypointIndex,
|
||||
}
|
||||
return { expression, wrapTarget: expression, body: factory.body, id, site: undefined }
|
||||
}
|
||||
|
||||
const collectDefinitions = (
|
||||
@@ -260,9 +339,19 @@ const collectDefinitions = (
|
||||
return definitions
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a signature for the *shape* of a component's hook calls: the
|
||||
* ordered sequence of hook names it calls at its top level, ignoring
|
||||
* everything else (argument contents, dependency array entries, unrelated
|
||||
* logic). Two bodies calling the same hooks in the same order hash to the
|
||||
* same signature even if most of their code differs, so edits that don't
|
||||
* touch which hooks run - the overwhelming majority of edits - refresh in
|
||||
* place instead of forcing a remount. This mirrors how React's own Fast
|
||||
* Refresh only forces a remount when the hook call sequence itself changes,
|
||||
* not when a hook's arguments or the surrounding logic change.
|
||||
*/
|
||||
const hookSignature = (
|
||||
body: ts.FunctionLikeDeclaration | undefined,
|
||||
sourceFile: ts.SourceFile,
|
||||
): string => {
|
||||
if (!body?.body)
|
||||
return "unknown"
|
||||
@@ -276,14 +365,18 @@ const hookSignature = (
|
||||
|
||||
if (ts.isCallExpression(node)) {
|
||||
const callee = node.expression
|
||||
const name = ts.isIdentifier(callee)
|
||||
const localName = ts.isIdentifier(callee)
|
||||
? callee.text
|
||||
: ts.isPropertyAccessExpression(callee)
|
||||
? callee.name.text
|
||||
: undefined
|
||||
|
||||
if (name && /^use[A-Z0-9]/.test(name))
|
||||
hooks.push(node.getText(sourceFile))
|
||||
if (localName && /^use[A-Z0-9]/.test(localName)) {
|
||||
const qualifier = ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)
|
||||
? `${callee.expression.text}.`
|
||||
: ""
|
||||
hooks.push(`${qualifier}${localName}`)
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
@@ -384,10 +477,10 @@ export function effectView(
|
||||
const edits: Edit[] = []
|
||||
|
||||
for (const definition of definitions) {
|
||||
const signature = hookSignature(definition.body, sourceFile)
|
||||
const signature = hookSignature(definition.body)
|
||||
|
||||
if (definition.pipeline && definition.entrypointIndex >= 0) {
|
||||
const entrypoint = definition.pipeline.arguments[definition.entrypointIndex]
|
||||
if (definition.site?.kind === "pipe") {
|
||||
const entrypoint = definition.site.pipeCall.arguments[definition.site.argIndex]
|
||||
if (!entrypoint)
|
||||
continue
|
||||
edits.push({
|
||||
@@ -403,8 +496,8 @@ export function effectView(
|
||||
continue
|
||||
}
|
||||
|
||||
const start = definition.expression.getStart(sourceFile)
|
||||
const end = definition.expression.end
|
||||
const start = definition.wrapTarget.getStart(sourceFile)
|
||||
const end = definition.wrapTarget.end
|
||||
edits.push({
|
||||
start,
|
||||
end,
|
||||
|
||||
@@ -135,4 +135,77 @@ export const Legacy = Component.makeUntraced(function*() {
|
||||
})
|
||||
`)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("registers a component wrapped by a user-defined helper function", async () => {
|
||||
const result = await transform(`
|
||||
import { Component } from "effect-view"
|
||||
|
||||
const withLogging = (view) => view
|
||||
|
||||
export const LoggedView = withLogging(Component.make("LoggedView")(function*() {
|
||||
return <div />
|
||||
}))
|
||||
`)
|
||||
|
||||
expect(result).toContain("__effectViewRefresh(withLogging(Component.make(\"LoggedView\")")
|
||||
expect(result).toContain("\"src/View.tsx:LoggedView\"")
|
||||
})
|
||||
|
||||
it("wraps the descriptor argument of a data-first withContext call", async () => {
|
||||
const result = await transform(`
|
||||
import { Component } from "effect-view"
|
||||
|
||||
const HomeBase = Component.make("Home")(function*() {
|
||||
return <div />
|
||||
})
|
||||
|
||||
export const Home = Component.withContext(HomeBase, runtime.context)
|
||||
`)
|
||||
|
||||
expect(result).toContain("__effectViewRefresh(Component.make(\"Home\")")
|
||||
expect(result).toContain("Component.withContext(__effectViewRefresh(HomeBase, import.meta.hot, \"src/View.tsx:Home\", \"unknown\", false), runtime.context)")
|
||||
})
|
||||
|
||||
it("keeps the same hook signature when only literal content inside a hook call changes", async () => {
|
||||
const before = await transform(`
|
||||
import { Component } from "effect-view"
|
||||
export const CounterView = Component.make("CounterView")(function*() {
|
||||
const value = yield* Component.useOnMount(() => loadInitial(1))
|
||||
return <div>{value}</div>
|
||||
})
|
||||
`)
|
||||
const after = await transform(`
|
||||
import { Component } from "effect-view"
|
||||
export const CounterView = Component.make("CounterView")(function*() {
|
||||
const value = yield* Component.useOnMount(() => loadInitial(2))
|
||||
return <div>{value}</div>
|
||||
})
|
||||
`)
|
||||
|
||||
const signatureOf = (code: string | undefined) => code?.match(/"src\/View\.tsx:CounterView", "([a-z0-9]+)"/)?.[1]
|
||||
expect(signatureOf(before)).toBeDefined()
|
||||
expect(signatureOf(before)).toBe(signatureOf(after))
|
||||
})
|
||||
|
||||
it("changes the hook signature when a hook call is added", async () => {
|
||||
const before = await transform(`
|
||||
import { Component } from "effect-view"
|
||||
export const CounterView = Component.make("CounterView")(function*() {
|
||||
const value = yield* Component.useOnMount(() => loadInitial())
|
||||
return <div>{value}</div>
|
||||
})
|
||||
`)
|
||||
const after = await transform(`
|
||||
import { Component } from "effect-view"
|
||||
export const CounterView = Component.make("CounterView")(function*() {
|
||||
const value = yield* Component.useOnMount(() => loadInitial())
|
||||
yield* Component.useReactEffect(() => trackView(), [])
|
||||
return <div>{value}</div>
|
||||
})
|
||||
`)
|
||||
|
||||
const signatureOf = (code: string | undefined) => code?.match(/"src\/View\.tsx:CounterView", "([a-z0-9]+)"/)?.[1]
|
||||
expect(signatureOf(before)).toBeDefined()
|
||||
expect(signatureOf(before)).not.toBe(signatureOf(after))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user