Add Refreshable trait
Lint / lint (push) Failing after 1m45s

This commit is contained in:
Julien Valverdé
2026-07-25 02:02:17 +02:00
parent 116c1b440d
commit 462895a3c2
19 changed files with 1209 additions and 43 deletions
+22 -32
View File
@@ -13,7 +13,7 @@ The current API has two identities:
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 small development-only runtime bridge plus a Vite compiler plugin. The plugin should assign stable source IDs and hook signatures to Effect View definitions. The runtime should retain a hot cell for each definition, notify mounted instances after an update, preserve state when the signature is compatible, and remount when it is not.
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:
@@ -26,16 +26,13 @@ A production-quality Vite implementation is likely **1018 engineer-days**, in
## Scope examined
The findings apply to both implementations in this repository:
- `effect-fc` (Effect 3)
- `effect-fc-next` (Effect 4 beta)
The two packages differ in Effect runtime details, but their React component identity model is the same. The examples also use the same Vite 8 and `@vitejs/plugin-react` 6 setup.
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/src/Component.ts`](packages/effect-fc/src/Component.ts), around `make`, `makeUntraced`, and `ComponentImplPrototype`.
`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:
@@ -48,14 +45,10 @@ source definition
-> mounted React fiber
```
In `effect-fc`:
- `asFunctionComponent` creates a closure over `this` and `runtimeRef`.
- The closure invokes `this.body(props)`.
- `.use` stores a cached function in `React.useState` and keys its internal cache by reactive Effect services.
- `withRuntime` renders the function returned by `.use`.
In `effect-fc-next`, `.use` uses `componentRef` and `previousServicesRef` instead of `useState` plus `Effect.cachedFunction`, but it still retains the previously synthesized function when service identities are unchanged.
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.
@@ -93,7 +86,9 @@ Consequently, edits currently propagate through ordinary Vite HMR invalidation.
## Runtime experiment
I ran a focused jsdom/React experiment against `effect-fc`:
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`.
@@ -135,7 +130,7 @@ A complete implementation should provide the same safety contract developers exp
### 1. Add an Effect View refresh transform
Ship a Vite plugin, for example `@effect-fc/vite`, placed before `react()`:
Ship a Vite plugin, for example `@effect-view/vite`, placed before `react()`:
```ts
plugins: [
@@ -150,7 +145,7 @@ It should recognize:
- `Component.makeUntraced(...)`
- class declarations extending either factory result
- definitions followed by Effect `pipe` transformations
- aliased imports from both `effect-fc` and `effect-fc-next`
- aliased imports from `effect-fc-next`
For each definition it should inject development-only metadata containing:
@@ -163,9 +158,9 @@ Detection must be binding-aware rather than matching text. Otherwise unrelated `
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
### 2. Add a development-only hot cell protocol to Effect View
The component metadata should connect to a cell retained in `import.meta.hot.data`:
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 {
@@ -181,6 +176,8 @@ On module reevaluation, the new descriptor updates `cell.current`. If the signat
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:
@@ -258,7 +255,7 @@ Exit criterion: editing render text updates without a page reload, and incompati
### Phase 1: Vite MVP (47 additional days)
- Implement binding-aware AST detection for all public construction styles.
- Add stable shell/keyed implementation support to both Component implementations, or share the dev bridge in a small internal module.
- 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.
@@ -277,7 +274,7 @@ Exit criterion: the behavior matches React Fast Refresh closely enough that stat
### Later: other bundlers
Once the runtime metadata protocol is stable, adapters for webpack/Rspack, Rolldown, or other environments can emit the same metadata. Building those in parallel with the first Vite version would enlarge the test matrix before the semantics have settled.
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
@@ -298,18 +295,11 @@ Minimum cases:
- 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 both example applications.
- 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.
## Repository test status during this study
- `packages/effect-fc`: `bun run test` passed all 20 tests.
- `packages/effect-fc-next`: the existing suite was not green before any implementation work: 4 Component tests failed because mount/cached values were observed twice, and `Query.test.ts` could not resolve the absent `src/Result.js`. The remaining 9 tests passed. These failures are unrelated to this report, but a clean baseline or documented exclusions will be needed before implementing refresh support in the beta package.
The Vite development server also regenerated `packages/example/src/routeTree.gen.ts` with ordering-only changes while inspecting transforms; that generated file was restored, so this report is the only intended worktree change.
## Risks and open questions
### Hook signature ownership
@@ -326,7 +316,7 @@ A stable shell plus keyed implementation adds one fiber in development. This is
### 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. A standalone Effect View metadata protocol minimizes that 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