@@ -0,0 +1,175 @@
|
||||
# Effect FC Next error-handling review
|
||||
|
||||
## Verdict
|
||||
|
||||
`ErrorObserver` is a reasonable *opt-in failure notification bus*, but it is
|
||||
not an application error handler. It neither recovers from errors nor, in
|
||||
`effect-fc-next`, observes the application's effects by default. Keeping it
|
||||
as the central error-handling mechanism would make error delivery implicit,
|
||||
incomplete, and easy to duplicate.
|
||||
|
||||
The better model is:
|
||||
|
||||
1. Handle expected, typed failures where the operation is rendered or invoked.
|
||||
2. Report unhandled/background failures through one explicit runtime boundary.
|
||||
3. Let React error boundaries render defects and render-time failures.
|
||||
|
||||
In particular, queries, mutations, and forms should keep their current
|
||||
`AsyncResult`/validation state as the user-facing error path; a global service
|
||||
should not replace that state.
|
||||
|
||||
## What exists today
|
||||
|
||||
`packages/effect-fc-next/src/ErrorObserver.ts` provides a context service
|
||||
backed by an unbounded `PubSub<Cause.Cause<E>>`:
|
||||
|
||||
- `handle(effect)` uses `Effect.tapCause` to publish every non-successful
|
||||
cause, then preserves the original result;
|
||||
- `subscribe` exposes a scoped subscription;
|
||||
- module-level `handle(effect)` is a no-op when the service is absent.
|
||||
|
||||
This is narrower than the similarly named service in the legacy
|
||||
`packages/effect-fc` package. The legacy default runtime layer installs an
|
||||
Effect supervisor that attempts to publish every failed fiber. The Next
|
||||
runtime's `preludeLayer` contains only `Component.ScopeMap`; it does not
|
||||
install `ErrorObserver.layer`. There are also no `ErrorObserver.handle(...)`
|
||||
call sites in `effect-fc-next`.
|
||||
|
||||
Therefore, as checked at this revision, a consumer can create and subscribe to
|
||||
the service manually, but no failure is automatically delivered to it. It is
|
||||
not currently an app-wide error mechanism.
|
||||
|
||||
## What is good about the pattern
|
||||
|
||||
- It keeps the original failure intact. Observability does not accidentally
|
||||
turn a failing effect into a successful one.
|
||||
- It publishes `Cause`, not only `E`, so defects, interruption, parallel
|
||||
failures, and traces are not discarded.
|
||||
- The service is optional, which is useful for a library: applications that do
|
||||
not want reporting do not need to install a logger/telemetry dependency.
|
||||
- A scoped subscription has the right basic lifetime shape for a React
|
||||
component or a root-level reporting worker.
|
||||
|
||||
## Main issues
|
||||
|
||||
### It is opt-in at every execution site
|
||||
|
||||
Wrapping effects with `ErrorObserver.handle` is both easy to forget and
|
||||
ambiguous. It does not cover failures from `Component.useCallbackPromise`,
|
||||
`Async.async`, `useReactEffect`, or a fork unless each path explicitly wraps
|
||||
the relevant effect. Conversely, adding wrappers at multiple levels reports
|
||||
the same failure more than once.
|
||||
|
||||
### Its type promise is unsound
|
||||
|
||||
`handle<A, E1, R>` accepts any `E1` then casts its `Cause<E1>` to
|
||||
`Cause<E>`. A value retrieved as `ErrorObserver<NetworkError>` can receive a
|
||||
`ValidationError` or a defect. `Cause` is correctly broader than a single
|
||||
app-error type, but the generic parameter suggests filtering that the service
|
||||
does not perform.
|
||||
|
||||
### "Error" and "handle" blur distinct responsibilities
|
||||
|
||||
The code only observes/reports failures; it does not decide a fallback, show a
|
||||
toast, retry, recover, log, or rethrow. Calling it a handler encourages using
|
||||
a global side channel for errors that should remain part of local UI state.
|
||||
|
||||
### An unbounded, raw pub/sub is a risky public policy
|
||||
|
||||
A slow or absent subscriber can retain an unbounded backlog. Consumers must
|
||||
also inspect every `Cause`, decide whether interruption is meaningful, dedupe
|
||||
retries, redact data, and ensure their own reporting failure does not affect
|
||||
the application. Those are application policy choices, not library defaults.
|
||||
|
||||
### It is not a React error boundary
|
||||
|
||||
React error boundaries cover render/lifecycle errors in the React tree. They
|
||||
do not replace reporting of failures from detached/background Effect fibers,
|
||||
and an Effect failure should not be blindly thrown into render merely to reach
|
||||
one. These are complementary boundaries.
|
||||
|
||||
## Recommended replacement
|
||||
|
||||
Replace the public observer with a small **`FailureReporter`** service. Make
|
||||
reporting explicit at the handful of Effect-to-JavaScript runtime boundaries,
|
||||
not at every business effect.
|
||||
|
||||
```ts
|
||||
interface FailureReporter {
|
||||
readonly report: (event: FailureEvent) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
interface FailureEvent {
|
||||
readonly cause: Cause.Cause<unknown>
|
||||
readonly source: "event" | "effect" | "async-component" | "background"
|
||||
readonly componentName?: string
|
||||
readonly operation?: string
|
||||
}
|
||||
```
|
||||
|
||||
The default implementation should be a no-op. Application layers can add
|
||||
logging, Sentry/OpenTelemetry, a toast dispatcher, or a bounded in-memory
|
||||
development sink. `report` should be made best-effort at the boundary
|
||||
(`Effect.ignore`/equivalent after recording its own diagnostics) so reporting
|
||||
outages never replace the original failure.
|
||||
|
||||
Provide one internal combinator, for example `reportFailure(effect, metadata)`,
|
||||
which reports only a non-interruption failure and then re-fails with exactly
|
||||
the original cause. Use it in these adapters:
|
||||
|
||||
- `Component.useCallbackPromise` and any promise-returning event callback;
|
||||
- `Component.useReactEffect` / layout-effect execution;
|
||||
- `Async.async` before the promise is handed to React;
|
||||
- library-created detached fibers whose failures are not represented in an
|
||||
`AsyncResult`.
|
||||
|
||||
Do **not** use it for query/mutation/form operations whose failure is already
|
||||
captured and displayed in `AsyncResult`; report only when the caller abandons
|
||||
or explicitly escalates that result. This avoids a failed request producing a
|
||||
toast/log and an inline error merely because it was expected.
|
||||
|
||||
At the application root, pair this with a normal React `ErrorBoundary` that
|
||||
reports render defects and presents the recovery UI. Keep its reporting
|
||||
separate from the Effect service; optionally normalize both into the same
|
||||
application telemetry event schema.
|
||||
|
||||
## Lower-risk incremental path
|
||||
|
||||
If replacing the API now is too disruptive:
|
||||
|
||||
1. Do not add `ErrorObserver.layer` to the Next prelude merely to make it look
|
||||
global. A hidden global bus still has unclear coverage and duplicate-event
|
||||
semantics.
|
||||
2. Rename `handle` to `observeFailure` and document that it is an explicit
|
||||
notification wrapper, not recovery or global supervision.
|
||||
3. Remove the generic `E`, or expose `Cause.Cause<unknown>`; alternatively
|
||||
accept a predicate/decoder that actually filters before publication.
|
||||
4. Make the queue policy explicit: preferably no public queue, or a bounded,
|
||||
dropping development event sink with a dropped-event counter.
|
||||
5. Add tests for: preservation of the original exit, defects, interruption
|
||||
filtering, a failing reporter, duplicate wrapping, and teardown of a scoped
|
||||
subscriber.
|
||||
|
||||
## Names
|
||||
|
||||
`ErrorHandler` is inaccurate, and `ErrorObserver` is acceptable only for the
|
||||
current low-level notification primitive. Recommended names by role:
|
||||
|
||||
| Role | Recommended name | Why |
|
||||
| --- | --- | --- |
|
||||
| Application service that sends telemetry/logs | `FailureReporter` | Says it reports an Effect failure without claiming recovery. |
|
||||
| Wrapper combinator | `reportFailure` | Describes the side effect and retains the original failure. |
|
||||
| Optional stream-like dev/testing primitive | `FailureEvents` or `FailureEventBus` | Makes the pub/sub nature explicit. |
|
||||
| React UI component | `AppErrorBoundary` | Uses React's established boundary terminology. |
|
||||
|
||||
I would use **`FailureReporter`** for the replacement. "Failure" is more
|
||||
accurate than "error" in Effect because it can intentionally retain the full
|
||||
`Cause`, while "reporter" makes the non-recovery responsibility clear.
|
||||
|
||||
## Decision
|
||||
|
||||
Treat `ErrorObserver` as an experimental diagnostic primitive, not the app
|
||||
error architecture. Design expected errors into local state, add a
|
||||
best-effort `FailureReporter` at the few execution boundaries, and use a React
|
||||
error boundary for render failures. This gives complete, testable coverage
|
||||
without turning every domain failure into a global event.
|
||||
@@ -0,0 +1,484 @@
|
||||
# Effect–React Render Lifecycle Report
|
||||
|
||||
## Purpose
|
||||
|
||||
This report describes the lifecycle problem in `effect-fc-next`, why abandoned React renders leak Effect scopes, what the experimental implementations attempted, why those implementations are not satisfactory, and how a robust system should be designed.
|
||||
|
||||
The experimental implementation discussed here has been removed from the source tree. This document is intended as design material for a clean reimplementation.
|
||||
|
||||
## Executive summary
|
||||
|
||||
The fundamental problem is not scope disposal by itself. It is that `effect-fc-next` performs Effect work and creates resource scopes while React is rendering.
|
||||
|
||||
React distinguishes between two broad phases:
|
||||
|
||||
1. **Render:** React evaluates components and builds a prospective tree. React may retry, pause, duplicate, or permanently abandon this work.
|
||||
2. **Commit:** React accepts a rendered tree and runs layout and passive effects. Cleanup callbacks only exist for work that reached this phase.
|
||||
|
||||
The existing `Component.useScope` creates and registers an Effect scope during render, but installs its cleanup from `React.useEffect`. If React abandons the render, the effect is never mounted, its cleanup is never installed, and the render-created scope remains registered indefinitely.
|
||||
|
||||
There is no public React callback for “this render attempt was abandoned.” Consequently, an object created only for a render attempt cannot receive deterministic abandonment notification.
|
||||
|
||||
A correct architecture must therefore choose one of these models:
|
||||
|
||||
- Do not acquire resources until commit.
|
||||
- Store render-time resources in an external registry under an explicit, stable identity.
|
||||
- Treat render-time resources as speculative cached entries and garbage-collect idle entries, while ensuring an expired entry can always be recreated safely.
|
||||
|
||||
The first model is simplest. The second is the model used by atom/resource systems. The third is necessarily a cache/TTL design rather than exact component-lifecycle tracking.
|
||||
|
||||
## The original `useScope` lifecycle
|
||||
|
||||
The implementation in `Component.ts` performs approximately the following work:
|
||||
|
||||
```ts
|
||||
function useScope(dependencies) {
|
||||
const { key, scope } = React.useMemo(() => {
|
||||
const key = makeIdentity()
|
||||
const scope = makeEffectScope()
|
||||
scopeMap.set(key, { scope, closeFiber: none() })
|
||||
|
||||
return { key, scope }
|
||||
}, dependencies)
|
||||
|
||||
React.useEffect(() => {
|
||||
cancelPreviouslyScheduledClose(key)
|
||||
|
||||
return () => {
|
||||
const closeFiber = sleep(debounce).andThen(close(scope)).fork()
|
||||
scopeMap.set(key, { scope, closeFiber: some(closeFiber) })
|
||||
}
|
||||
}, [key])
|
||||
|
||||
return scope
|
||||
}
|
||||
```
|
||||
|
||||
The intended committed lifecycle is:
|
||||
|
||||
```text
|
||||
render
|
||||
create scope
|
||||
insert scope into ScopeMap
|
||||
|
||||
commit
|
||||
run useEffect setup
|
||||
cancel any pending close left by a rapid remount
|
||||
|
||||
unmount or dependency change
|
||||
run useEffect cleanup
|
||||
wait for the debounce
|
||||
close the scope
|
||||
remove it from ScopeMap
|
||||
```
|
||||
|
||||
For an ordinary committed component, this works. The debounce also bridges React Strict Mode’s development-only effect cycle:
|
||||
|
||||
```text
|
||||
effect setup
|
||||
effect cleanup schedules close
|
||||
effect setup again cancels close
|
||||
```
|
||||
|
||||
The failing lifecycle is:
|
||||
|
||||
```text
|
||||
render
|
||||
create scope
|
||||
insert scope into ScopeMap
|
||||
|
||||
React abandons render
|
||||
useEffect setup never runs
|
||||
useEffect cleanup therefore never exists
|
||||
no close is scheduled
|
||||
ScopeMap retains the scope forever
|
||||
```
|
||||
|
||||
Suspense can trigger this by throwing a promise before the component commits. Concurrent rendering and failed/erroring renders can produce the same category of problem.
|
||||
|
||||
## Why React cannot directly report abandonment
|
||||
|
||||
Hooks such as `useEffect` and `useLayoutEffect` describe the lifetime of a committed component. They are deliberately not executed for render work that never commits.
|
||||
|
||||
Render-time hook state does not solve this:
|
||||
|
||||
- `useMemo` may be discarded with the render attempt.
|
||||
- `useRef` belongs to the same provisional fiber state.
|
||||
- `useState` belongs to the same provisional fiber state.
|
||||
- Cleanup returned from `useEffect` only exists after effect setup has run.
|
||||
- A component cannot read its React `key`; React consumes it before props reach the component.
|
||||
|
||||
JavaScript garbage collection is also insufficient. `FinalizationRegistry` is nondeterministic and cannot be used to guarantee timely execution of Effect finalizers.
|
||||
|
||||
This means there is no reliable per-render-attempt destructor available to application code.
|
||||
|
||||
## Why Effect Atom can use an external lifecycle
|
||||
|
||||
Effect Atom has something a component scope does not: an atom is an explicit object with stable identity outside React.
|
||||
|
||||
The conceptual flow is:
|
||||
|
||||
```text
|
||||
React receives an Atom object
|
||||
│
|
||||
▼
|
||||
AtomRegistry.getOrCreate(atom)
|
||||
│
|
||||
├── returns an existing external node
|
||||
└── or creates a new external node and schedules idle removal
|
||||
|
||||
React commit subscribes to the node
|
||||
│
|
||||
└── subscription prevents idle removal
|
||||
|
||||
React unmount unsubscribes
|
||||
│
|
||||
└── node becomes eligible for removal
|
||||
```
|
||||
|
||||
If a render is abandoned, no subscription is established. The node is still owned by the external registry and can remove itself when idle. If React retries later, it asks the registry for the atom again. The registry either returns the still-live node or creates a replacement.
|
||||
|
||||
`useSyncExternalStore` belongs naturally in this design because an atom node has a changing snapshot. React must both subscribe to changes and read a consistent value.
|
||||
|
||||
A component scope does not naturally have either property:
|
||||
|
||||
- It has no explicit identity external to React.
|
||||
- Its scope object is not a changing UI snapshot.
|
||||
|
||||
Therefore, copying the Atom mechanism without first solving identity produces abstractions that look like an external store but are actually render-attempt bookkeeping.
|
||||
|
||||
## The experimental external-store implementation
|
||||
|
||||
The first experiment introduced three concepts:
|
||||
|
||||
- A runtime-owned registry.
|
||||
- A render-owned store/handle created with `useMemo`.
|
||||
- A registry entry containing the Effect scope and its cleanup state.
|
||||
|
||||
It used `useSyncExternalStore` as follows:
|
||||
|
||||
```text
|
||||
getSnapshot during render
|
||||
create a scope entry
|
||||
register it externally
|
||||
immediately schedule idle cleanup
|
||||
|
||||
subscribe during commit
|
||||
retain the entry
|
||||
cancel idle cleanup
|
||||
|
||||
unsubscribe during unmount
|
||||
release the entry
|
||||
schedule cleanup
|
||||
```
|
||||
|
||||
This fixed the abandoned-render leak because an uncommitted entry had no subscriber and its initial cleanup remained scheduled.
|
||||
|
||||
It also allowed a closed snapshot to be replaced: if cleanup won a race before subscription, a later `getSnapshot` could create another entry.
|
||||
|
||||
However, it had several design problems:
|
||||
|
||||
1. The “store” was created by `useMemo`, so it was not truly an externally identified store.
|
||||
2. `useSyncExternalStore` was being used primarily as a commit/unmount detector rather than for observable state.
|
||||
3. The names “store,” “entry,” and “snapshot” obscured the resource lifecycle.
|
||||
4. It introduced listener notification machinery even though a scope has no meaningful changing value for the UI.
|
||||
5. It gave the appearance of Atom’s semantics without possessing Atom’s stable external identity.
|
||||
|
||||
It was functional as a self-healing speculative cache, but it was not a clean model for a component instance.
|
||||
|
||||
## The experimental retain/release implementation
|
||||
|
||||
The second experiment removed `useSyncExternalStore` and represented the lifecycle directly:
|
||||
|
||||
```ts
|
||||
const registration = React.useMemo(
|
||||
() => scopeMap.register(context, options),
|
||||
dependencies,
|
||||
)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
registration.retain()
|
||||
return registration.release
|
||||
}, [registration])
|
||||
|
||||
return registration.scope
|
||||
```
|
||||
|
||||
Registration immediately scheduled its own close. A committed layout effect cancelled that close through `retain()`. Unmount called `release()` and scheduled it again.
|
||||
|
||||
This was much easier to understand:
|
||||
|
||||
```text
|
||||
registration created → provisional
|
||||
layout effect setup → retained
|
||||
layout effect cleanup → released
|
||||
idle timeout → closed
|
||||
```
|
||||
|
||||
It still had a serious race:
|
||||
|
||||
```text
|
||||
render creates provisional registration
|
||||
React delays commit longer than the cleanup debounce
|
||||
registration closes
|
||||
React eventually commits the same render
|
||||
layout effect calls retain on an already-closed registration
|
||||
component now holds a closed Effect scope
|
||||
```
|
||||
|
||||
The external-store version could recover by returning a new snapshot. The direct retain/release version could not trigger a render-time replacement after the scope had closed.
|
||||
|
||||
Increasing the timeout reduces the likelihood but does not make the system correct. A timeout cannot prove that React has abandoned work.
|
||||
|
||||
## What the bookkeeping in the experiment was doing
|
||||
|
||||
The experimental implementation contained more machinery than the conceptual model suggested. Each part existed for a specific race:
|
||||
|
||||
### Runtime registry
|
||||
|
||||
The registry retained every scope that might still need disposal. It also allowed `ReactRuntime` disposal to close every remaining scope.
|
||||
|
||||
A `HashMap` and `Ref` were used, but neither is essential for a synchronous JavaScript registry. A `Set<Registration>` would express the ownership more clearly unless atomic Effect composition is specifically required.
|
||||
|
||||
### Unique registration key
|
||||
|
||||
Each speculative render needed a separate map entry. Otherwise two simultaneous attempts could overwrite or close one another.
|
||||
|
||||
### Cleanup fiber
|
||||
|
||||
The fiber represented the debounce period followed by scope closure. Retaining the registration interrupted this fiber.
|
||||
|
||||
### Cleanup generation
|
||||
|
||||
Interrupting an Effect fiber is not equivalent to synchronously erasing every continuation it may execute. Consider:
|
||||
|
||||
```text
|
||||
cleanup A is running
|
||||
retain interrupts cleanup A
|
||||
release schedules cleanup B
|
||||
cleanup A's ensuring handler finishes afterward
|
||||
```
|
||||
|
||||
If cleanup A blindly clears the `closeFiber` field, it erases the reference to cleanup B. A monotonically increasing generation lets a finalizer clear the field only if it still represents the current scheduled close.
|
||||
|
||||
### Retain count
|
||||
|
||||
A count allows repeated retain/release pairs, including Strict Mode effect simulation, without closing while at least one committed lease remains. A boolean is sufficient only if the code can prove that setup and cleanup are perfectly paired and never nested.
|
||||
|
||||
These details are legitimate in a speculative cache, but they should be encapsulated behind a much smaller domain model.
|
||||
|
||||
## The identity problem
|
||||
|
||||
An external registry needs a key that means “this logical resource.” Possible candidates all have limitations:
|
||||
|
||||
### Component definition
|
||||
|
||||
Using the `Component` object would make every mounted instance of the same component share one scope. That breaks per-instance mount state and cleanup.
|
||||
|
||||
### Props or dependency values
|
||||
|
||||
Two component instances can have equal props while requiring independent scopes. Object props may also be recreated on every render.
|
||||
|
||||
### `useId`
|
||||
|
||||
`useId` is intended for accessibility and hydration identifiers, not general cache identity. Concurrent versions of the same logical component can receive the same identifier, and uniqueness across multiple roots depends on root configuration. It should not silently become a resource-ownership contract.
|
||||
|
||||
### React `key`
|
||||
|
||||
This would be conceptually useful, but React does not expose it to the component. Requiring a separate explicit resource key would be an API change.
|
||||
|
||||
### A value made by `useMemo`, `useRef`, or `useState`
|
||||
|
||||
These values identify a React fiber lifetime only after React preserves that hook state. They do not survive an abandoned initial render and therefore cannot provide Atom-style external identity.
|
||||
|
||||
The clean external design consequently requires an explicit identity supplied by the application or created outside the speculative render.
|
||||
|
||||
## Recommended architecture
|
||||
|
||||
The API should distinguish committed lifecycle work from render-time resources instead of trying to give them identical semantics.
|
||||
|
||||
### 1. Commit-only component scopes
|
||||
|
||||
Use this model for resources that do not need to affect the current render synchronously.
|
||||
|
||||
```ts
|
||||
function useCommittedScope(options): ScopeState {
|
||||
const [scope, setScope] = React.useState<Scope.Closeable | undefined>()
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const scope = makeScope(options)
|
||||
setScope(scope)
|
||||
|
||||
return () => runClose(scope)
|
||||
}, dependencies)
|
||||
|
||||
return scope
|
||||
}
|
||||
```
|
||||
|
||||
The exact API need not use state, but the invariant should be:
|
||||
|
||||
> No resource is acquired until React commits the component.
|
||||
|
||||
An abandoned render then has nothing to clean up.
|
||||
|
||||
This may require changing APIs that currently expect acquisition results during the same render. Such APIs cannot be made commit-only without representing an uninitialized/loading state or causing a subsequent render.
|
||||
|
||||
### 2. Explicit external resources for Suspense
|
||||
|
||||
Use this model when rendering depends on asynchronous acquisition or a promise.
|
||||
|
||||
```ts
|
||||
interface ResourceKey {
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
interface ResourceNode<A> {
|
||||
readonly read: () => A
|
||||
readonly subscribe: (listener: () => void) => () => void
|
||||
}
|
||||
|
||||
const node = resourceRegistry.getOrCreate(key, createResource)
|
||||
const value = React.useSyncExternalStore(node.subscribe, node.read, node.read)
|
||||
```
|
||||
|
||||
Here `useSyncExternalStore` is justified because the node is truly external, has a stable explicit key, and has a changing observable result.
|
||||
|
||||
The node lifecycle should be:
|
||||
|
||||
```text
|
||||
absent
|
||||
│ getOrCreate
|
||||
▼
|
||||
idle/pending ── subscribe ──► retained
|
||||
▲ │
|
||||
└──── unsubscribe ──────────┘
|
||||
│
|
||||
└── idle TTL expires ──► disposed and removed
|
||||
```
|
||||
|
||||
If a node expires before a retry, `getOrCreate` constructs a new node. No React render can remain permanently bound to an expired object without revalidating it.
|
||||
|
||||
### 3. Make `useOnMount` semantics explicit
|
||||
|
||||
The name `useOnMount` suggests commit-time execution, but if its Effect is evaluated from `useMemo` during render, it is actually speculative render-time execution.
|
||||
|
||||
The library should choose and document one of these contracts:
|
||||
|
||||
- `useOnMount` runs only after commit and cannot synchronously return an acquisition result during the initial render.
|
||||
- A separate render-safe primitive accepts only pure/synchronous computation.
|
||||
- A resource primitive uses an explicit external resource key and supports Suspense.
|
||||
|
||||
Allowing arbitrary scoped Effects to execute during render while promising exact component cleanup is the combination that creates the unsolvable abandonment problem.
|
||||
|
||||
## Required invariants for an external resource registry
|
||||
|
||||
If an Atom-style registry is implemented, it should satisfy all of the following:
|
||||
|
||||
1. **Stable identity:** Every node is addressed by an explicit resource key independent of React hook state.
|
||||
2. **Idempotent lookup:** Repeated reads of the same key return the same live node.
|
||||
3. **Recoverable expiry:** A lookup after disposal returns a new live node, never the disposed node.
|
||||
4. **Commit retention:** A committed subscriber prevents idle disposal.
|
||||
5. **Abandoned-render collection:** A node read by an abandoned render has no subscriber and becomes eligible for disposal.
|
||||
6. **Reference-counted ownership:** One unmount cannot dispose a node still used by another subscriber.
|
||||
7. **Idempotent disposal:** Closing a node multiple times has the same result as closing it once.
|
||||
8. **Registry disposal:** Disposing the runtime closes every node regardless of subscriber state.
|
||||
9. **Generation safety:** Completion of an older cleanup task cannot overwrite or cancel newer lifecycle state.
|
||||
10. **Consistent snapshot:** React must never receive a closed node as the current live snapshot.
|
||||
|
||||
## Required test matrix
|
||||
|
||||
A replacement should be tested against lifecycle sequences, not only final rendered output.
|
||||
|
||||
### Normal lifecycle
|
||||
|
||||
- Initial commit acquires exactly the intended resources.
|
||||
- Rerender with equivalent dependencies does not reacquire.
|
||||
- Dependency change releases the old resource and acquires the new one.
|
||||
- Unmount runs every finalizer once.
|
||||
|
||||
### Strict Mode
|
||||
|
||||
- Development render duplication does not leak the discarded attempt.
|
||||
- Effect setup/cleanup/setup simulation does not prematurely close the committed resource.
|
||||
- Final unmount closes all remaining resources.
|
||||
|
||||
### Suspense
|
||||
|
||||
- A render that suspends forever does not retain resources indefinitely.
|
||||
- A suspended render that retries can obtain a live resource after any idle cleanup.
|
||||
- Resolving an obsolete promise cannot mutate or resurrect a newer node.
|
||||
- Changing props while pending disposes or detaches the obsolete computation.
|
||||
|
||||
### Concurrent replacement
|
||||
|
||||
- The current committed tree remains usable while a replacement tree renders.
|
||||
- Abandoning the replacement does not affect the current tree.
|
||||
- Committing the replacement releases the previous tree only after the replacement commits.
|
||||
|
||||
### Runtime disposal
|
||||
|
||||
- Disposing `ReactRuntime` closes committed resources.
|
||||
- It also closes idle, pending, and speculative registry nodes.
|
||||
- Pending async fibers are interrupted without unhandled promise rejections.
|
||||
|
||||
### Timing races
|
||||
|
||||
- Retain racing with scheduled disposal has a deterministic winner.
|
||||
- Disposal racing with retry either preserves the live node or forces recreation.
|
||||
- An old cleanup task cannot clear the handle for a newer cleanup task.
|
||||
|
||||
## Suggested minimal domain model
|
||||
|
||||
For a real external resource cache, the implementation can remain small if the names match the responsibilities:
|
||||
|
||||
```ts
|
||||
interface ResourceRegistry {
|
||||
getOrCreate<A>(key: ResourceKey, create: () => ResourceNode<A>): ResourceNode<A>
|
||||
dispose: Effect.Effect<void>
|
||||
}
|
||||
|
||||
interface ResourceNode<A> {
|
||||
readonly key: ResourceKey
|
||||
readonly read: () => A
|
||||
readonly subscribe: (listener: () => void) => () => void
|
||||
readonly dispose: Effect.Effect<void>
|
||||
}
|
||||
```
|
||||
|
||||
Internally, a node can own:
|
||||
|
||||
- Its Effect scope.
|
||||
- Its current result or promise.
|
||||
- Its subscribers.
|
||||
- Its idle cleanup task.
|
||||
- Its lifecycle generation.
|
||||
|
||||
There should not be a separate render-owned object called a “store.” React should interact directly with the externally owned node.
|
||||
|
||||
For commit-only scopes, the model is even smaller:
|
||||
|
||||
```ts
|
||||
interface ComponentScope {
|
||||
readonly scope: Scope.Closeable
|
||||
readonly close: Effect.Effect<void>
|
||||
}
|
||||
```
|
||||
|
||||
Create it at commit and close it at effect cleanup. No registry, snapshot, subscription, speculative timer, or abandonment recovery is necessary unless runtime-wide disposal must independently own it.
|
||||
|
||||
## Final recommendation for `effect-fc-next`
|
||||
|
||||
Do not attempt to hide both committed component lifecycles and render-time Suspense resources behind one implicit `Scope` mechanism.
|
||||
|
||||
The most defensible direction is:
|
||||
|
||||
1. Make normal component acquisition commit-driven.
|
||||
2. Prevent or clearly label arbitrary Effect acquisition during render.
|
||||
3. Introduce a separate external resource abstraction for async/Suspense work.
|
||||
4. Require that abstraction to have an explicit stable key.
|
||||
5. Use `useSyncExternalStore` only for those externally owned nodes whose snapshots can change.
|
||||
6. Treat idle timeout as cache eviction, not as proof that React abandoned a render.
|
||||
7. Make every read capable of replacing an expired node.
|
||||
|
||||
If backward compatibility requires speculative per-render scopes, document that the debounce is heuristic and retain the recovery behavior that can replace a scope which expires before commit. That model can prevent leaks, but it should not be presented as exact React component-instance lifecycle tracking.
|
||||
@@ -0,0 +1,335 @@
|
||||
# React Fast Refresh for Effect View components
|
||||
|
||||
Date: 2026-07-22
|
||||
|
||||
## Executive summary
|
||||
|
||||
Implementing Fast Refresh for Effect View components is feasible, but it is not a one-line change to React Refresh's component detection.
|
||||
|
||||
The current API has two identities:
|
||||
|
||||
1. `Component.make` / `Component.makeUntraced` creates an Effect View descriptor.
|
||||
2. `.use` later synthesizes the React function component that is actually mounted.
|
||||
|
||||
Vite's React Refresh transform sees the descriptor definition, while React reconciles the synthesized function. In addition, `.use` deliberately caches that function. A newly evaluated module therefore creates a new descriptor, but the mounted function continues to close over the old descriptor.
|
||||
|
||||
The recommended implementation is a bundler-neutral development refresh protocol in Effect View plus a Vite compiler adapter. The plugin should assign stable source IDs and hook signatures to Effect View definitions. The main library's protocol should retain the current descriptor, notify mounted instances after an update, preserve state when the signature is compatible, and remount when it is not. The Vite adapter should retain those cells in `import.meta.hot.data` and manage accept/invalidate behavior.
|
||||
|
||||
My feasibility assessment is:
|
||||
|
||||
- **Safe refresh with remount on every edit:** high confidence, moderate effort.
|
||||
- **State-preserving refresh with React-like hook signature handling:** high confidence, moderate-to-high effort.
|
||||
- **A runtime-only or export-detection-only fix:** not sufficient.
|
||||
- **Bundler-independent support in the first release:** not recommended; establish the protocol with Vite first.
|
||||
|
||||
A production-quality Vite implementation is likely **10–18 engineer-days**, including the runtime bridge, transform, lifecycle tests, examples, and documentation. A remounting proof of concept should take **2–4 days**.
|
||||
|
||||
## Scope examined
|
||||
|
||||
The implementation target is `effect-fc-next` (Effect 4 beta), which is
|
||||
expected to become `effect-view`. The legacy `effect-fc` package is not
|
||||
supported by the Vite plugin.
|
||||
|
||||
## How Effect View rendering works today
|
||||
|
||||
`Component.make` creates a function-shaped descriptor with a `body` property and `ComponentPrototype`; it does not create the React component that will be mounted. See [`packages/effect-fc-next/src/Component.ts`](packages/effect-fc-next/src/Component.ts), around `make`, `makeUntraced`, and `ComponentImplPrototype`.
|
||||
|
||||
The relevant path is:
|
||||
|
||||
```text
|
||||
source definition
|
||||
-> Effect View descriptor (`body`, options, prototype traits)
|
||||
-> `.use`
|
||||
-> synthesized React function bound to an Effect runtime/context
|
||||
-> optional `React.memo` / async transformation
|
||||
-> mounted React fiber
|
||||
```
|
||||
|
||||
In `effect-fc-next`, `asFunctionComponent` creates a closure over the descriptor
|
||||
and Effect context. `.use` retains that synthesized function in `componentRef`
|
||||
while the relevant Effect service identities remain unchanged, and
|
||||
`withContext` renders the function returned by `.use`.
|
||||
|
||||
This caching is useful during ordinary rendering: without it, React would see a new component type and remount on every parent render. It is also why a newly evaluated module cannot replace the mounted implementation by itself.
|
||||
|
||||
## What the current Vite transform does
|
||||
|
||||
I inspected Vite's development transform for representative files with the repository's actual configuration and dependency versions.
|
||||
|
||||
For a class definition such as `TodosView`, the transform produced the equivalent of:
|
||||
|
||||
```js
|
||||
var _s = $RefreshSig$()
|
||||
export class TodosView extends _s(
|
||||
Component.make("TodosView")(
|
||||
_s(function* () {
|
||||
_s()
|
||||
// component body
|
||||
}, "<signature>", false, () => [
|
||||
Subscribable.useAll,
|
||||
Component.useOnMount,
|
||||
])
|
||||
),
|
||||
"<signature>",
|
||||
false,
|
||||
() => [Subscribable.useAll, Component.useOnMount],
|
||||
) {}
|
||||
```
|
||||
|
||||
The transform recognizes hook-shaped calls and calculates a signature, but it emits no `$RefreshReg$` call and no refresh boundary for the Effect View. The same was true for `const` definitions created with `Component.make` and for route components ending in `.pipe(Component.withRuntime(...))`.
|
||||
|
||||
This happens because `Component.make(...)` and Effect's `pipe(...)` are not React component patterns known to the transform. Class-style Effect Views also inherit from a non-React base, so React Refresh's runtime heuristic deliberately does not classify them as React classes.
|
||||
|
||||
Even if export registration were forced, registering the descriptor would not be enough. The descriptor is not the type stored in the mounted React fiber; the lazily synthesized function is.
|
||||
|
||||
Consequently, edits currently propagate through ordinary Vite HMR invalidation. Whether they eventually update through an importer or cause a page reload depends on the surrounding module graph and router integration, but React Refresh cannot directly reconcile the edited Effect View family.
|
||||
|
||||
## Runtime experiment
|
||||
|
||||
The initial feasibility study used the legacy implementation for a focused
|
||||
jsdom/React identity experiment. Its descriptor-caching result also applies to
|
||||
`effect-fc-next`, but the legacy package is not an implementation target:
|
||||
|
||||
1. Render an Effect View containing `React.useState(0)`.
|
||||
2. Increment it to `old:1`.
|
||||
3. Create a replacement descriptor with a body rendering `new:<count>`.
|
||||
4. Rerender the existing entry component.
|
||||
5. Copy the replacement `body` onto the descriptor retained by the mounted component and rerender again.
|
||||
|
||||
Observed output:
|
||||
|
||||
```text
|
||||
after local state update: old:1
|
||||
new descriptor, old mounted entry: old:1
|
||||
mutated descriptor, stable entry: new:1
|
||||
```
|
||||
|
||||
This proves two things:
|
||||
|
||||
- Module reevaluation alone cannot update a mounted Effect View because its synthesized function retained the old descriptor.
|
||||
- Updating the retained implementation and scheduling a render can apply new code while preserving React state.
|
||||
|
||||
The experiment was temporary and left no probe files in the repository.
|
||||
|
||||
## Required semantics
|
||||
|
||||
A complete implementation should provide the same safety contract developers expect from Fast Refresh:
|
||||
|
||||
- Apply edits without reloading the page.
|
||||
- Preserve React state when the hook signature is compatible.
|
||||
- Remount when hooks are added, removed, reordered, or otherwise become incompatible.
|
||||
- Recover after a syntax or render error.
|
||||
- Support both `const` and class definition styles.
|
||||
- Support local/non-exported child Views, not only module exports.
|
||||
- Support `make`, `makeUntraced`, `withOptions`, `Memoized.memoized`, async traits, and `withRuntime` / `withContext` entrypoints.
|
||||
- Work when one View is materialized under multiple Effect service contexts.
|
||||
- Preserve Effect scopes on compatible edits and close them exactly once on a reset/remount.
|
||||
- Offer an explicit reset escape hatch equivalent to a refresh-reset directive.
|
||||
|
||||
## Recommended design
|
||||
|
||||
### 1. Add an Effect View refresh transform
|
||||
|
||||
Ship a Vite plugin, `@effect-view/vite-plugin`, placed before `react()`:
|
||||
|
||||
```ts
|
||||
plugins: [
|
||||
effectViewPlugin(),
|
||||
react(),
|
||||
]
|
||||
```
|
||||
|
||||
It should recognize:
|
||||
|
||||
- `Component.make(...)`
|
||||
- `Component.makeUntraced(...)`
|
||||
- class declarations extending either factory result
|
||||
- definitions followed by Effect `pipe` transformations
|
||||
- aliased imports from `effect-fc-next`
|
||||
|
||||
For each definition it should inject development-only metadata containing:
|
||||
|
||||
- A stable ID based on normalized module path plus local binding.
|
||||
- A hook signature derived from the generator/body.
|
||||
- The HMR context or a small generated accept handler.
|
||||
- Optional reset metadata.
|
||||
|
||||
Detection must be binding-aware rather than matching text. Otherwise unrelated `Component.make` functions, renamed imports, and namespace aliases will cause false positives or missed Views.
|
||||
|
||||
The plugin must instrument local Views as well as exports. React Refresh works on component families, not only refresh-boundary exports, and most nested Views in this repository are consumed through `.use`.
|
||||
|
||||
### 2. Add a development-only hot cell protocol to Effect View
|
||||
|
||||
The main library should expose the bundler-neutral cell contract from its `Refreshable` module and the component shell should consume it directly:
|
||||
|
||||
```ts
|
||||
interface HotViewCell {
|
||||
current: Component.Any
|
||||
revision: number
|
||||
resetRevision: number
|
||||
signature: string
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
```
|
||||
|
||||
On module reevaluation, the new descriptor updates `cell.current`. If the signature is unchanged, `revision` changes. If the signature changes, `resetRevision` changes as well.
|
||||
|
||||
Keeping the cell rather than only mutating the old descriptor is important for class inheritance and traits. Class-style Views inherit `body` and options through the generated base class, while memoized and async Views alter prototype behavior. A cell can point to the complete new descriptor without attempting to copy an unknown prototype graph onto the old one.
|
||||
|
||||
Bundler integrations should depend on this public protocol rather than define a structurally duplicated symbol and cell. The Vite adapter owns only Vite-specific storage in `import.meta.hot.data`, registration IDs, and accept/invalidate decisions.
|
||||
|
||||
### 3. Split the development component into a stable shell and keyed implementation
|
||||
|
||||
In development, the function materialized by `.use` should become a stable shell:
|
||||
|
||||
```text
|
||||
stable shell
|
||||
-> subscribes to HotViewCell
|
||||
-> reads current descriptor and resetRevision
|
||||
-> renders keyed implementation
|
||||
-> runs useScope
|
||||
-> runs the current Effect View body
|
||||
-> owns the View's React hooks and Effect resources
|
||||
```
|
||||
|
||||
On a compatible edit, the cell subscription rerenders the shell and the implementation keeps the same type/key, preserving state. On an incompatible edit, the key changes and React remounts the implementation safely.
|
||||
|
||||
This extra fiber and subscription should exist only in development builds. The production path can remain unchanged.
|
||||
|
||||
This design avoids depending on React Refresh's private family maps. It can coexist with the normal Vite React plugin, which remains responsible for standard React components and the error overlay. The Effect View plugin self-accepts only the modules it successfully instruments; unsupported patterns should fall back to normal HMR invalidation rather than accepting an update it cannot apply.
|
||||
|
||||
### 4. Reuse or reproduce hook signatures carefully
|
||||
|
||||
This is the hardest part of state-preserving support.
|
||||
|
||||
The current Vite/OXC transform already sees calls such as `React.useState`, `Component.useOnMount`, and `Subscribable.useAll`, but its computed signature is attached to the descriptor expression rather than the lazily created React function. There is no public runtime API for reading that signature back.
|
||||
|
||||
The robust choices are:
|
||||
|
||||
1. Run a small binding-aware AST pass that computes Effect View hook signatures and feeds them to the hot cell.
|
||||
2. Integrate with a public transform hook if Vite/React exposes one in a future version.
|
||||
|
||||
Parsing generated OXC text to steal `_s(...)` arguments would be brittle and is not recommended.
|
||||
|
||||
An MVP may conservatively remount on every edit. The next step can preserve state only when the plugin can prove that the ordered hook signature is unchanged. False negatives cost state; false positives can violate the Rules of Hooks, so the algorithm must favor remounting.
|
||||
|
||||
The signature pass should cover:
|
||||
|
||||
- Direct React hooks.
|
||||
- Effect View hooks named `useX` even when used through `yield*`.
|
||||
- Imported custom hooks and their identities where possible.
|
||||
- Hooks introduced by traits such as async rendering.
|
||||
- An explicit force-reset directive.
|
||||
|
||||
### 5. Keep Effect lifecycle behavior explicit
|
||||
|
||||
Compatible refresh should retain existing `useOnMount` values and open scopes, just as React Fast Refresh retains hook state. Editing the acquisition code inside `useOnMount` should not silently rerun it unless the View resets.
|
||||
|
||||
On a reset, the existing `useScope` effect cleanup should close the old scope and its finalizers. Tests need to account for `finalizerExecutionDebounce` (100 ms by default), Strict Mode, and rapid consecutive edits. The implementation must not create a second close path in the HMR layer.
|
||||
|
||||
## Why smaller fixes are insufficient
|
||||
|
||||
| Option | Result | Assessment |
|
||||
| --- | --- | --- |
|
||||
| Teach Vite that `Component.make` is a component factory | Registers the descriptor, not the mounted synthesized function | Insufficient alone |
|
||||
| Export every View or rename it to PascalCase | May improve boundary heuristics, but does not bridge the two identities | Insufficient |
|
||||
| Wrap only route entrypoints with a normal React component | Can refresh the entry wrapper, but nested `.use` components still retain old implementations and state may remount | Partial workaround |
|
||||
| Remove caching from `.use` | New type on every ordinary render; all child Views remount continuously | Incorrect |
|
||||
| Mutate the old descriptor in a manual HMR handler | Demonstrably updates code and can preserve state, but lacks scheduling, hook safety, class/trait handling, and ergonomics | Useful prototype only |
|
||||
| Register every synthesized function directly with React Refresh | Possible, but needs stable IDs per materialization, replacement creation for every Effect context, signature propagation, and private/runtime coupling | Viable but more coupled |
|
||||
| Stable hot cell plus development shell | Explicitly bridges identities and controls preserve-versus-reset behavior | Recommended |
|
||||
| Redesign Effect Views to be ordinary React component types | Makes React Refresh natural, but conflicts with context binding through `.use` and is a large API/architecture change | Not justified for this feature |
|
||||
|
||||
## Proposed delivery plan
|
||||
|
||||
### Phase 0: executable spike (2–4 days)
|
||||
|
||||
- Instrument one `const` View and one class View in the example app.
|
||||
- Retain a hot cell in `import.meta.hot.data`.
|
||||
- Refresh with a guaranteed remount.
|
||||
- Verify edits through a nested `.use` View and a `withRuntime` / `withContext` route.
|
||||
- Verify a finalizer runs exactly once on reset.
|
||||
|
||||
Exit criterion: editing render text updates without a page reload, and incompatible edits remount safely.
|
||||
|
||||
### Phase 1: Vite MVP (4–7 additional days)
|
||||
|
||||
- Implement binding-aware AST detection for all public construction styles.
|
||||
- Add stable shell/keyed implementation support to the supported `effect-fc-next` Component implementation.
|
||||
- Cover memoized and async traits.
|
||||
- Add safe self-accept/fallback behavior.
|
||||
- Add browser integration tests against the example app.
|
||||
|
||||
Exit criterion: all supported Effect View forms refresh reliably, initially with conservative remounting where signature information is unavailable.
|
||||
|
||||
### Phase 2: state preservation (4–7 additional days)
|
||||
|
||||
- Compute ordered hook signatures.
|
||||
- Preserve local state for compatible edits.
|
||||
- Reset for hook-order changes and the force-reset directive.
|
||||
- Test custom hooks, multiple service contexts, syntax-error recovery, and rapid edits.
|
||||
- Document expected `useOnMount` and scope behavior.
|
||||
|
||||
Exit criterion: the behavior matches React Fast Refresh closely enough that state preservation is predictable and hook-safe.
|
||||
|
||||
### Later: other bundlers
|
||||
|
||||
Because the main Effect View library owns the runtime metadata protocol, adapters for webpack/Rspack, Rolldown, or other environments can emit the same metadata without depending on Vite. Building those in parallel with the first Vite version would enlarge the test matrix before the semantics have settled.
|
||||
|
||||
## Test matrix
|
||||
|
||||
The feature should have real browser HMR tests; unit tests cannot fully validate React Refresh scheduling and module replacement.
|
||||
|
||||
Minimum cases:
|
||||
|
||||
- `const` and class View definitions.
|
||||
- `make` and `makeUntraced`.
|
||||
- Root entrypoint and nested `.use` child.
|
||||
- A View imported through another module.
|
||||
- Local, exported, default-exported, and renamed Views.
|
||||
- Direct React state preserved after a render-only edit.
|
||||
- State reset after adding/removing/reordering a hook.
|
||||
- `useOnMount`, `useOnChange`, `useReactEffect`, and `useReactLayoutEffect` cleanup.
|
||||
- Default and zero finalizer debounce.
|
||||
- Memoized View with unchanged props.
|
||||
- Async View and Suspense/error recovery.
|
||||
- Two materializations of the same View under different Effect contexts.
|
||||
- Reactive and `nonReactiveTags` service changes.
|
||||
- TanStack Router code splitting used by the `example-next` application.
|
||||
- Successive edits before the previous refresh finishes.
|
||||
- Syntax error followed by recovery.
|
||||
- Production build contains no HMR registry/subscription code.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
### Hook signature ownership
|
||||
|
||||
The plugin must decide which `useX` calls represent React hook state. Effect View intentionally makes React hooks appear inside generator bodies, so relying only on standard React source patterns will remain incomplete.
|
||||
|
||||
### Trait composition
|
||||
|
||||
`Memoized.memoized`, async behavior, `withOptions`, and future traits can replace `transformFunctionComponent` or alter prototypes. The hot cell should point to the newest complete descriptor, and the development shell must define whether a trait change is compatible or forces reset.
|
||||
|
||||
### Development tree shape
|
||||
|
||||
A stable shell plus keyed implementation adds one fiber in development. This is a reasonable tradeoff, but React DevTools naming should make the extra layer understandable.
|
||||
|
||||
### React/Vite version coupling
|
||||
|
||||
The current plugin stack uses Vite's OXC refresh transform and a bundled/simplified React Refresh runtime. Depending on private runtime functions or generated transform text would make the feature sensitive to Vite upgrades. The main Effect View package should therefore own the standalone metadata protocol, while the Vite plugin depends on and adapts it.
|
||||
|
||||
### Multiple materializations
|
||||
|
||||
One View definition can produce more than one function identity because `.use` is evaluated under different Effect service sets and parent instances. A solution based on direct React Refresh registration must track every materialization. The hot-cell subscription model naturally fans one update out to all mounted materializations.
|
||||
|
||||
### Resource expectations
|
||||
|
||||
Preserving state also preserves acquired resources. Developers will need a reset directive or a documented way to force a remount when they edit acquisition logic and want it rerun immediately.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Proceed with a Vite-first proof of concept using a stable hot cell and a development-only stable shell/keyed implementation.
|
||||
|
||||
Do not begin by patching component-name heuristics or by registering the Effect View descriptor with React Refresh; those changes target the wrong identity. Also avoid coupling the initial implementation to private React Refresh family maps.
|
||||
|
||||
The first milestone should always remount safely. Once browser tests prove update delivery and Effect scope cleanup, add signature-aware state preservation. This order isolates the HMR transport/identity problem from the hook-compatibility problem and gives the project a useful, releasable intermediate result.
|
||||
Reference in New Issue
Block a user