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
+19
View File
@@ -111,6 +111,7 @@
"react-icons": "^5.6.0",
},
"devDependencies": {
"@effect-view/vite": "workspace:*",
"@tanstack/react-router": "^1.170.10",
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.168.13",
@@ -123,6 +124,22 @@
"vite": "^8.0.16",
},
},
"packages/vite": {
"name": "@effect-view/vite",
"version": "0.0.0",
"dependencies": {
"typescript": "^6.0.3",
},
"devDependencies": {
"effect-fc-next": "workspace:*",
"vite": "^8.0.16",
"vitest": "^3.2.4",
},
"peerDependencies": {
"effect-fc-next": "0.1.0-beta.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
},
},
},
"packages": {
"@algolia/abtesting": ["@algolia/abtesting@1.22.0", "", { "dependencies": { "@algolia/client-common": "5.56.0", "@algolia/requester-browser-xhr": "5.56.0", "@algolia/requester-fetch": "5.56.0", "@algolia/requester-node-http": "5.56.0" } }, "sha512-BFR6zNowNKcY7Ou7TaJc9QWexES4YKPbmf/OTFofpdsdhz4x6q0lbxp3duO0EHnyrN7rE4ba/TSXuY+BDGu4+g=="],
@@ -557,6 +574,8 @@
"@effect-fc/example-next": ["@effect-fc/example-next@workspace:packages/example-next"],
"@effect-view/vite": ["@effect-view/vite@workspace:packages/vite"],
"@effect/language-service": ["@effect/language-service@0.86.6", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-uwXbp+lWzt60bZnm6lG6S5DWy0m6TthUzV2hcQoiS6NzieEDm07tuBakscxOXoO+AU9+yTH+0TSIa9mEDt9dFA=="],
"@effect/platform": ["@effect/platform@0.96.3", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.10", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.21.5" } }, "sha512-LzvIj4HYE++TcTv/cVTCI/GRvQTV0ymwRmgjZMzNB5dU4OS05pTN36RKN0npiOm5orLJgw4yjIv1dNZoYGh/vg=="],
+70 -8
View File
@@ -17,16 +17,78 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./*": [
{
"types": "./dist/*/index.d.ts",
"default": "./dist/*/index.js"
"./Async": {
"types": "./dist/Async.d.ts",
"default": "./dist/Async.js"
},
{
"types": "./dist/*.d.ts",
"default": "./dist/*.js"
"./Component": {
"types": "./dist/Component.d.ts",
"default": "./dist/Component.js"
},
"./ErrorObserver": {
"types": "./dist/ErrorObserver.d.ts",
"default": "./dist/ErrorObserver.js"
},
"./Form": {
"types": "./dist/Form.d.ts",
"default": "./dist/Form.js"
},
"./Lens": {
"types": "./dist/Lens.d.ts",
"default": "./dist/Lens.js"
},
"./LensForm": {
"types": "./dist/LensForm.d.ts",
"default": "./dist/LensForm.js"
},
"./Memoized": {
"types": "./dist/Memoized.d.ts",
"default": "./dist/Memoized.js"
},
"./Mutation": {
"types": "./dist/Mutation.d.ts",
"default": "./dist/Mutation.js"
},
"./MutationForm": {
"types": "./dist/MutationForm.d.ts",
"default": "./dist/MutationForm.js"
},
"./PubSub": {
"types": "./dist/PubSub.d.ts",
"default": "./dist/PubSub.js"
},
"./Query": {
"types": "./dist/Query.d.ts",
"default": "./dist/Query.js"
},
"./QueryClient": {
"types": "./dist/QueryClient.d.ts",
"default": "./dist/QueryClient.js"
},
"./ReactRuntime": {
"types": "./dist/ReactRuntime.d.ts",
"default": "./dist/ReactRuntime.js"
},
"./Refreshable": {
"types": "./dist/Refreshable.d.ts",
"default": "./dist/Refreshable.js"
},
"./ScopeRegistry": {
"types": "./dist/ScopeRegistry.d.ts",
"default": "./dist/ScopeRegistry.js"
},
"./SetStateAction": {
"types": "./dist/SetStateAction.d.ts",
"default": "./dist/SetStateAction.js"
},
"./Stream": {
"types": "./dist/Stream.d.ts",
"default": "./dist/Stream.js"
},
"./View": {
"types": "./dist/View.d.ts",
"default": "./dist/View.js"
}
]
},
"scripts": {
"lint:tsc": "tsc --noEmit",
+40 -1
View File
@@ -2,6 +2,7 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { Context, type Duration, Effect, Equivalence, Exit, Function, identity, Layer, Pipeable, Predicate, References, Scheduler, Scope, Tracer } from "effect"
import * as React from "react"
import * as Refreshable from "./Refreshable.js"
import * as ScopeRegistry from "./ScopeRegistry.js"
@@ -48,6 +49,7 @@ export interface ComponentImplPrototype<R, F extends Component.Signature> {
readonly use: Effect.Effect<F, never, Exclude<R, Scope.Scope>>
asFunctionComponent(contextRef: React.Ref<Context.Context<Exclude<R, Scope.Scope>>>): F
asRefreshableFunctionComponent(contextRef: React.Ref<Context.Context<Exclude<R, Scope.Scope>>>): F
setFunctionComponentName(f: F): void
transformFunctionComponent(f: F): F
}
@@ -67,6 +69,43 @@ export const ComponentImplPrototype: ComponentImplPrototype<any, any> = Object.f
)
},
asRefreshableFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
this: ComponentImpl<P, A, E, R, F>,
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>,
) {
if (!Refreshable.isRefreshable(this))
return this.asFunctionComponent(contextRef)
const cell = this[Refreshable.RefreshableTypeId]
let current = cell.current
let functionComponent = current.asFunctionComponent(contextRef)
// Calling the current renderer inside this stable component deliberately
// keeps its hooks on the same fiber until resetRevision changes.
const Implementation = (props: P) => {
if (current !== cell.current) {
current = cell.current
functionComponent = current.asFunctionComponent(contextRef)
}
return functionComponent(props)
}
const RefreshableComponent = (props: P) => {
const snapshot = React.useSyncExternalStore(
cell.subscribe,
cell.getSnapshot,
cell.getSnapshot,
)
return React.createElement(Implementation, {
...props,
key: snapshot.resetRevision,
})
}
return RefreshableComponent as F
},
setFunctionComponentName<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
this: ComponentImpl<P, A, E, R, F>,
f: React.FC<P>,
@@ -98,7 +137,7 @@ const use = Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode
!Equivalence.Array(Equivalence.strictEqual())(services, previousServicesRef.current)
) {
previousServicesRef.current = services
const f = self.asFunctionComponent(contextRef)
const f = self.asRefreshableFunctionComponent(contextRef)
self.setFunctionComponentName(f)
componentRef.current = self.transformFunctionComponent(f)
}
+129
View File
@@ -0,0 +1,129 @@
/**
* A stable identifier used to associate an Effect View descriptor with its
* development refresh cell.
*
* This low-level API is intended for development-server integrations such as
* `@effect-view/vite`.
*/
export const RefreshableTypeId: unique symbol = Symbol.for("@effect-view/Refreshable/Refreshable")
export type RefreshableTypeId = typeof RefreshableTypeId
/**
* The version observed by a mounted Effect View refresh shell.
*
* `revision` changes for every update. `resetRevision` changes only when the
* adapter determines that preserving React state is unsafe.
*/
export interface Snapshot {
readonly revision: number
readonly resetRevision: number
}
/**
* A mutable development cell holding the latest version of an Effect View
* descriptor.
*
* This low-level API is intended for development-server integrations.
*/
export interface Cell<A extends object = object> {
current: A
signature: string
forceReset: boolean
snapshot: Snapshot
readonly subscribe: (listener: () => void) => () => void
readonly getSnapshot: () => Snapshot
readonly update: (
component: A,
signature: string,
forceReset: boolean,
) => void
}
/**
* A descriptor that can be connected to a development refresh cell.
*/
export interface Refreshable<A extends object = object> {
readonly [RefreshableTypeId]: Cell<A>
}
/**
* Checks whether a descriptor has been connected to a development refresh
* cell.
*/
export const isRefreshable = <A extends object>(
value: A,
): value is A & Refreshable<A> => Object.hasOwn(value, RefreshableTypeId)
/**
* Creates a refresh cell for an Effect View descriptor.
*
* This low-level API is intended for development-server integrations.
*/
export const makeCell = <A extends object>(
component: A,
signature: string,
forceReset: boolean,
): Cell<A> => {
const listeners = new Set<() => void>()
let notificationPending = false
const cell: Cell<A> = {
current: component,
signature,
forceReset,
snapshot: {
revision: 0,
resetRevision: 0,
},
subscribe(listener) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
getSnapshot() {
return cell.snapshot
},
update(nextComponent, nextSignature, nextForceReset) {
const shouldReset = cell.forceReset
|| nextForceReset
|| cell.signature !== nextSignature
cell.current = nextComponent
cell.signature = nextSignature
cell.forceReset = nextForceReset
cell.snapshot = {
revision: cell.snapshot.revision + 1,
resetRevision: cell.snapshot.resetRevision + (shouldReset ? 1 : 0),
}
if (!notificationPending) {
notificationPending = true
queueMicrotask(() => {
notificationPending = false
for (const listener of listeners)
listener()
})
}
},
}
return cell
}
/**
* Associates a descriptor with a refresh cell and returns the descriptor.
*
* This low-level API is intended for development-server integrations.
*/
export const attach = <A extends object>(
component: A,
cell: Cell<A>,
): A & Refreshable<A> => {
Object.defineProperty(component, RefreshableTypeId, {
configurable: true,
enumerable: true,
value: cell,
})
return component as A & Refreshable<A>
}
+1
View File
@@ -11,6 +11,7 @@ export * as PubSub from "./PubSub.js"
export * as Query from "./Query.js"
export * as QueryClient from "./QueryClient.js"
export * as ReactRuntime from "./ReactRuntime.js"
export * as Refreshable from "./Refreshable.js"
export * as ScopeRegistry from "./ScopeRegistry.js"
export * as SetStateAction from "./SetStateAction.js"
export * as Stream from "./Stream.js"
@@ -4,6 +4,7 @@ import * as React from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
import * as Component from "../src/Component.js"
import * as ReactRuntime from "../src/ReactRuntime.js"
import * as Refreshable from "../src/Refreshable.js"
import * as ScopeRegistry from "../src/ScopeRegistry.js"
@@ -529,4 +530,42 @@ describe("Component", () => {
await runtime.runtime.dispose()
}
})
it("refreshes a registered body while preserving or resetting local state", async () => {
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context()
const makeProbe = (label: string) => Component.makeUntraced("RefreshProbe")(function*() {
const [count, setCount] = React.useState(0)
return <button type="button" onClick={() => setCount(value => value + 1)}>{label}:{count}</button>
})
const original = makeProbe("old")
const cell = Refreshable.makeCell(original, "hooks", false)
Refreshable.attach(original, cell)
const Probe = Component.withContext(original, runtime.context)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
fireEvent.click(await screen.findByRole("button"))
expect(screen.getByRole("button").textContent).toBe("old:1")
await act(async () => {
cell.update(makeProbe("compatible"), "hooks", false)
await Promise.resolve()
})
expect(screen.getByRole("button").textContent).toBe("compatible:1")
await act(async () => {
cell.update(makeProbe("reset"), "changed-hooks", false)
await Promise.resolve()
})
expect(screen.getByRole("button").textContent).toBe("reset:0")
view.unmount()
await runtime.runtime.dispose()
})
})
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from "vitest"
import * as Refreshable from "../src/Refreshable.js"
describe("Refreshable", () => {
it("attaches a cell and notifies subscribers after a compatible update", async () => {
const first = {}
const cell = Refreshable.makeCell(first, "hooks", false)
const listener = vi.fn()
expect(Refreshable.isRefreshable(first)).toBe(false)
const attached = Refreshable.attach(first, cell)
expect(attached).toBe(first)
expect(Refreshable.isRefreshable(first)).toBe(true)
expect(attached[Refreshable.RefreshableTypeId]).toBe(cell)
cell.subscribe(listener)
const second = {}
cell.update(second, "hooks", false)
expect(cell.current).toBe(second)
expect(cell.snapshot).toEqual({
revision: 1,
resetRevision: 0,
})
await Promise.resolve()
expect(listener).toHaveBeenCalledTimes(1)
})
it("requests a remount when a signature changes or reset is forced", () => {
const cell = Refreshable.makeCell({}, "one", false)
cell.update({}, "two", false)
expect(cell.snapshot.resetRevision).toBe(1)
cell.update({}, "two", true)
expect(cell.snapshot.resetRevision).toBe(2)
})
})
+1
View File
@@ -14,6 +14,7 @@
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect-view/vite": "workspace:*",
"@tanstack/react-router": "^1.170.10",
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.168.13",
+2
View File
@@ -1,5 +1,6 @@
import { tanstackRouter } from "@tanstack/router-plugin/vite"
import react from "@vitejs/plugin-react"
import effectViewRefresh from "@effect-view/vite"
import path from "node:path"
import { defineConfig } from "vite"
@@ -7,6 +8,7 @@ import { defineConfig } from "vite"
// https://vite.dev/config/
export default defineConfig({
plugins: [
effectViewRefresh(),
tanstackRouter({
target: "react",
autoCodeSplitting: true,
+42
View File
@@ -0,0 +1,42 @@
# `@effect-view/vite`
Experimental Vite Fast Refresh support for Effect View components.
```ts
import effectViewRefresh from "@effect-view/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [
effectViewRefresh(),
react(),
],
})
```
The plugin recognizes `Component.make` and `Component.makeUntraced`
definitions imported from `effect-fc-next`. The legacy `effect-fc` package is
not supported. `effect-fc-next` owns the bundler-neutral refresh cell protocol;
this package is the Vite adapter that retains those cells in HMR data. The
plugin assigns stable development IDs and self-accepts successfully
instrumented modules.
The adapter-facing protocol is exported from `effect-fc-next/Refreshable`.
Renaming or removing an instrumented View invalidates the module upward instead
of leaving a stale mounted descriptor.
React state is retained when the ordered hook signature is unchanged. Adding,
removing, or changing a hook call resets the Effect View implementation. Add
`// @refresh reset` to a module to reset its Views on every update.
## Current limitations
- Vite is the only supported bundler.
- Definitions must be top-level variable, class, or default-export
declarations.
- Hook signature analysis is conservative and based on `useX` call syntax.
- Changing a descriptor's trait pipeline during a refresh is not guaranteed to
preserve state.
- Source maps for the injected transform are not generated yet.
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"root": false,
"extends": "//",
"files": {
"includes": ["./src/**", "./test/**"]
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@effect-view/vite",
"description": "Vite Fast Refresh support for Effect View components",
"version": "0.0.0",
"type": "module",
"files": [
"./README.md",
"./dist"
],
"license": "MIT",
"repository": {
"url": "git+https://github.com/Thiladev/effect-fc.git"
},
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./runtime": {
"types": "./dist/runtime.d.ts",
"default": "./dist/runtime.js"
}
},
"scripts": {
"lint:tsc": "tsc --noEmit",
"lint:biome": "biome lint",
"test": "vitest run",
"build": "tsc",
"pack": "npm pack",
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
"clean:dist": "rm -rf dist",
"clean:modules": "rm -rf node_modules"
},
"dependencies": {
"typescript": "^6.0.3"
},
"devDependencies": {
"effect-fc-next": "workspace:*",
"vite": "^8.0.16",
"vitest": "^3.2.4"
},
"peerDependencies": {
"effect-fc-next": "0.1.0-beta.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
}
+435
View File
@@ -0,0 +1,435 @@
import path from "node:path"
import ts from "typescript"
import type { Plugin } from "vite"
export interface EffectViewRefreshOptions {
/**
* Include filter for source files.
*
* @default /\.[cm]?[jt]sx?$/
*/
readonly include?: RegExp
/**
* Exclude filter for source files.
*
* @default /\/node_modules\//
*/
readonly exclude?: RegExp
}
interface Edit {
readonly start: number
readonly end: number
readonly content: string
}
interface ComponentImports {
readonly componentNamespaces: Set<string>
readonly packageNamespaces: Set<string>
readonly directFactories: Set<string>
}
interface Definition {
readonly expression: ts.Expression
readonly factoryCall: ts.CallExpression
readonly body: ts.FunctionLikeDeclaration | undefined
readonly id: string
readonly pipeline: ts.CallExpression | undefined
readonly entrypointIndex: number
}
const defaultInclude = /\.[cm]?[jt]sx?$/
const defaultExclude = /\/node_modules\//
const runtimeImport = "@effect-view/vite/runtime"
const refreshIdentifier = "__effectViewRefresh"
const acceptIdentifier = "__effectViewAccept"
const isSupportedPackage = (source: string): boolean =>
source === "effect-fc-next"
|| source === "effect-fc-next/Component"
const collectImports = (
sourceFile: ts.SourceFile,
): ComponentImports => {
const componentNamespaces = new Set<string>()
const packageNamespaces = new Set<string>()
const directFactories = new Set<string>()
for (const statement of sourceFile.statements) {
if (!ts.isImportDeclaration(statement)
|| !ts.isStringLiteral(statement.moduleSpecifier)
|| !isSupportedPackage(statement.moduleSpecifier.text))
continue
const source = statement.moduleSpecifier.text
const clause = statement.importClause
if (!clause)
continue
if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
if (source.endsWith("/Component"))
componentNamespaces.add(clause.namedBindings.name.text)
else
packageNamespaces.add(clause.namedBindings.name.text)
continue
}
if (!clause.namedBindings || !ts.isNamedImports(clause.namedBindings))
continue
for (const element of clause.namedBindings.elements) {
const importedName = element.propertyName?.text ?? element.name.text
if (source.endsWith("/Component") && (importedName === "make" || importedName === "makeUntraced"))
directFactories.add(element.name.text)
else if (!source.endsWith("/Component") && importedName === "Component")
componentNamespaces.add(element.name.text)
}
}
return {
componentNamespaces,
packageNamespaces,
directFactories,
}
}
const isFactoryCallee = (
expression: ts.Expression,
imports: ComponentImports,
): boolean => {
if (ts.isIdentifier(expression))
return imports.directFactories.has(expression.text)
if (!ts.isPropertyAccessExpression(expression))
return false
if (expression.name.text !== "make" && expression.name.text !== "makeUntraced")
return false
if (ts.isIdentifier(expression.expression))
return imports.componentNamespaces.has(expression.expression.text)
return ts.isPropertyAccessExpression(expression.expression)
&& expression.expression.name.text === "Component"
&& ts.isIdentifier(expression.expression.expression)
&& imports.packageNamespaces.has(expression.expression.expression.text)
}
const functionArgument = (
call: ts.CallExpression,
): ts.FunctionLikeDeclaration | undefined => call.arguments.find(argument =>
ts.isArrowFunction(argument)
|| ts.isFunctionExpression(argument)
) as ts.FunctionLikeDeclaration | undefined
const findFactoryCall = (
root: ts.Node,
imports: ComponentImports,
): {
readonly factoryCall: ts.CallExpression
readonly body: ts.FunctionLikeDeclaration | undefined
} | undefined => {
let result: {
readonly factoryCall: ts.CallExpression
readonly body: ts.FunctionLikeDeclaration | undefined
} | undefined
const visit = (node: ts.Node): void => {
if (result)
return
if (ts.isCallExpression(node)) {
if (isFactoryCallee(node.expression, imports)) {
result = {
factoryCall: node,
body: functionArgument(node),
}
return
}
if (ts.isCallExpression(node.expression) && isFactoryCallee(node.expression.expression, imports)) {
result = {
factoryCall: node,
body: functionArgument(node),
}
return
}
}
ts.forEachChild(node, visit)
}
visit(root)
return result
}
const isPipeline = (expression: ts.Expression): expression is ts.CallExpression =>
ts.isCallExpression(expression)
&& ts.isPropertyAccessExpression(expression.expression)
&& expression.expression.name.text === "pipe"
const isEntrypoint = (expression: ts.Expression): boolean => {
if (!ts.isCallExpression(expression))
return false
const callee = expression.expression
return ts.isPropertyAccessExpression(callee)
&& (callee.name.text === "withRuntime" || callee.name.text === "withContext")
}
const makeDefinition = (
expression: ts.Expression,
id: string,
imports: ComponentImports,
): Definition | undefined => {
const factory = findFactoryCall(expression, imports)
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,
}
}
const collectDefinitions = (
sourceFile: ts.SourceFile,
imports: ComponentImports,
moduleId: string,
): readonly Definition[] => {
const definitions: Definition[] = []
for (const statement of sourceFile.statements) {
if (ts.isVariableStatement(statement)) {
for (const declaration of statement.declarationList.declarations) {
if (!declaration.initializer || !ts.isIdentifier(declaration.name))
continue
const definition = makeDefinition(
declaration.initializer,
`${moduleId}:${declaration.name.text}`,
imports,
)
if (definition)
definitions.push(definition)
}
continue
}
if (ts.isClassDeclaration(statement) && statement.name) {
const heritage = statement.heritageClauses
?.find(clause => clause.token === ts.SyntaxKind.ExtendsKeyword)
?.types[0]
?.expression
if (!heritage)
continue
const definition = makeDefinition(
heritage,
`${moduleId}:${statement.name.text}`,
imports,
)
if (definition)
definitions.push(definition)
continue
}
if (ts.isExportAssignment(statement)) {
const definition = makeDefinition(
statement.expression,
`${moduleId}:default`,
imports,
)
if (definition)
definitions.push(definition)
}
}
return definitions
}
const hookSignature = (
body: ts.FunctionLikeDeclaration | undefined,
sourceFile: ts.SourceFile,
): string => {
if (!body?.body)
return "unknown"
const hooks: string[] = []
const root = body.body
const visit = (node: ts.Node): void => {
if (node !== root && ts.isFunctionLike(node))
return
if (ts.isCallExpression(node)) {
const callee = node.expression
const name = 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))
}
ts.forEachChild(node, visit)
}
visit(root)
return hash(hooks.join("\n"))
}
const hash = (value: string): string => {
let result = 0x811c9dc5
for (let index = 0; index < value.length; index++) {
result ^= value.charCodeAt(index)
result = Math.imul(result, 0x01000193)
}
return (result >>> 0).toString(36)
}
const quote = (value: string): string => JSON.stringify(value)
const registration = (
expression: string,
definition: Definition,
signature: string,
forceReset: boolean,
): string => `${refreshIdentifier}(${expression}, import.meta.hot, ${quote(definition.id)}, ${quote(signature)}, ${forceReset})`
const applyEdits = (code: string, edits: readonly Edit[]): string => {
let output = code
for (const edit of [...edits].sort((self, that) => that.start - self.start))
output = output.slice(0, edit.start) + edit.content + output.slice(edit.end)
return output
}
const importPosition = (sourceFile: ts.SourceFile): number => {
let position = 0
for (const statement of sourceFile.statements) {
if (ts.isImportDeclaration(statement))
position = statement.end
else if (position > 0)
break
}
return position
}
const scriptKind = (id: string): ts.ScriptKind => id.endsWith(".tsx")
? ts.ScriptKind.TSX
: id.endsWith(".jsx")
? ts.ScriptKind.JSX
: id.endsWith(".js") || id.endsWith(".mjs") || id.endsWith(".cjs")
? ts.ScriptKind.JS
: ts.ScriptKind.TS
/**
* Adds Fast Refresh support for Effect View components.
*
* Place this plugin before `@vitejs/plugin-react`.
*/
export default function effectViewRefresh(
options: EffectViewRefreshOptions = {},
): Plugin {
const include = options.include ?? defaultInclude
const exclude = options.exclude ?? defaultExclude
const instrumentedModules = new Set<string>()
return {
name: "effect-view:refresh",
enforce: "pre",
apply: "serve",
transform(code, rawId) {
const [id] = rawId.split("?", 1)
if (!id)
return null
if (!include.test(id) || exclude.test(id))
return null
const sourceFile = ts.createSourceFile(
id,
code,
ts.ScriptTarget.Latest,
true,
scriptKind(id),
)
const imports = collectImports(sourceFile)
if (imports.componentNamespaces.size === 0
&& imports.packageNamespaces.size === 0
&& imports.directFactories.size === 0
&& !instrumentedModules.has(id))
return null
const moduleId = path.relative(process.cwd(), id).split(path.sep).join("/")
const definitions = collectDefinitions(sourceFile, imports, moduleId)
if (definitions.length === 0 && !instrumentedModules.has(id))
return null
instrumentedModules.add(id)
const forceReset = /@refresh\s+reset\b/.test(code)
const edits: Edit[] = []
for (const definition of definitions) {
const signature = hookSignature(definition.body, sourceFile)
if (definition.pipeline && definition.entrypointIndex >= 0) {
const entrypoint = definition.pipeline.arguments[definition.entrypointIndex]
if (!entrypoint)
continue
edits.push({
start: entrypoint.getStart(sourceFile),
end: entrypoint.getStart(sourceFile),
content: `(${refreshIdentifier}View) => ${registration(
`${refreshIdentifier}View`,
definition,
signature,
forceReset,
)}, `,
})
continue
}
const start = definition.expression.getStart(sourceFile)
const end = definition.expression.end
edits.push({
start,
end,
content: registration(
code.slice(start, end),
definition,
signature,
forceReset,
),
})
}
const position = importPosition(sourceFile)
edits.push({
start: position,
end: position,
content: `${position === 0 ? "" : "\n"}import { accept as ${acceptIdentifier}, register as ${refreshIdentifier} } from ${quote(runtimeImport)};\n`,
})
const transformed = applyEdits(code, edits)
const definitionIds = definitions.map(definition => quote(definition.id)).join(", ")
return {
code: `${transformed}\n${acceptIdentifier}(import.meta.hot, [${definitionIds}])\n`,
map: null,
}
},
}
}
+85
View File
@@ -0,0 +1,85 @@
import * as Refreshable from "effect-fc-next/Refreshable"
export interface HotContext {
readonly data: Record<string, unknown>
accept(): void
invalidate(message?: string): void
}
interface RefreshData {
readonly cells: Map<string, Refreshable.Cell<any>>
ids?: readonly string[]
}
const hotDataKey = "__effectFcRefresh"
const getRefreshData = (hot: HotContext): RefreshData => {
const data = hot.data as Record<string, unknown>
const current = data[hotDataKey] as RefreshData | undefined
if (current)
return current
const refreshData: RefreshData = {
cells: new Map(),
}
data[hotDataKey] = refreshData
return refreshData
}
/**
* Registers an Effect View descriptor in a Vite HMR data cell.
*
* This API is injected by `@effect-view/vite`; applications should not need to
* call it directly.
*/
export const register = <A extends object>(
component: A,
hot: HotContext | undefined,
id: string,
signature: string,
forceReset = false,
): A => {
if (!hot)
return component
const refreshData = getRefreshData(hot)
const previous = refreshData.cells.get(id)
if (!previous) {
const cell = Refreshable.makeCell(component, signature, forceReset)
refreshData.cells.set(id, cell)
return Refreshable.attach(component, cell)
}
previous.update(component, signature, forceReset)
return Refreshable.attach(component, previous)
}
const sameIds = (
self: readonly string[],
that: readonly string[],
): boolean => self.length === that.length
&& self.every((id, index) => id === that[index])
/**
* Accepts an instrumented module when its Effect View definition set is
* unchanged. Renames and removals invalidate upward so a stale cell cannot
* remain mounted indefinitely.
*/
export const accept = (
hot: HotContext | undefined,
ids: readonly string[],
): void => {
if (!hot)
return
const refreshData = getRefreshData(hot)
const previousIds = refreshData.ids
refreshData.ids = ids
if (previousIds && !sameIds(previousIds, ids)) {
hot.invalidate("[effect-view] Effect View exports changed")
return
}
hot.accept()
}
+132
View File
@@ -0,0 +1,132 @@
import path from "node:path"
import type { Plugin, TransformPluginContext } from "vite"
import { describe, expect, it } from "vitest"
import effectViewRefresh from "../src/index.js"
const runTransform = async (
plugin: Plugin,
code: string,
id: string,
): Promise<string | undefined> => {
const hook = plugin.transform
if (!hook)
return undefined
const result = typeof hook === "function"
? await hook.call({} as TransformPluginContext, code, id)
: await hook.handler.call({} as TransformPluginContext, code, id)
if (!result)
return undefined
return typeof result === "string" ? result : result.code
}
const transform = (
code: string,
id = path.join(process.cwd(), "src/View.tsx"),
): Promise<string | undefined> => runTransform(effectViewRefresh(), code, id)
describe("effectViewRefresh", () => {
it("wraps a const Effect View descriptor", async () => {
const result = await transform(`
import { Component } from "effect-fc-next"
import * as React from "react"
export const CounterView = Component.make("CounterView")(function*() {
const [count] = React.useState(0)
return <div>{count}</div>
})
`)
expect(result).toContain("import { accept as __effectViewAccept, register as __effectViewRefresh } from \"@effect-view/vite/runtime\"")
expect(result).toContain("__effectViewRefresh(Component.make(\"CounterView\")")
expect(result).toContain("\"src/View.tsx:CounterView\"")
expect(result).toContain("__effectViewAccept(import.meta.hot, [\"src/View.tsx:CounterView\"])")
})
it("registers a pipeline before withContext", async () => {
const result = await transform(`
import { Component } from "effect-fc-next"
const Page = Component.make("Page")(function*() {
return <main />
}).pipe(
Component.withContext(runtime.context),
)
`)
expect(result).toContain("(__effectViewRefreshView) => __effectViewRefresh(__effectViewRefreshView")
expect(result).toContain("Component.withContext(runtime.context)")
expect(result).not.toContain("__effectViewRefresh(Component.make(\"Page\")")
})
it("wraps a class's complete trait pipeline", async () => {
const result = await transform(`
import { Async, Component, Memoized } from "effect-fc-next"
class PostView extends Component.make("PostView")(function*() {
const value = yield* Component.useOnMount(load)
return <div>{value}</div>
}).pipe(
Async.async,
Memoized.memoized,
) {}
`)
expect(result).toContain("class PostView extends __effectViewRefresh(Component.make(\"PostView\")")
expect(result).toContain("Memoized.memoized,")
expect(result).toContain("\"src/View.tsx:PostView\"")
})
it("supports aliased Component imports and refresh reset", async () => {
const result = await transform(`
// @refresh reset
import { Component as View } from "effect-fc-next"
const Probe = View.makeUntraced(function*() {
return null
})
`)
expect(result).toContain("__effectViewRefresh(View.makeUntraced")
expect(result).toMatch(/"src\/View\.tsx:Probe", "[a-z0-9]+", true/)
})
it("ignores modules without Effect View definitions", async () => {
expect(await transform(`
import * as React from "react"
export function View() {
return <div />
}
`)).toBeUndefined()
})
it("keeps instrumenting a module when all Effect Views are removed", async () => {
const plugin = effectViewRefresh() as Plugin
const id = path.join(process.cwd(), "src/Removed.tsx")
await runTransform(plugin, `
import { Component } from "effect-fc-next"
export const Probe = Component.makeUntraced(function*() {
return null
})
`, id)
const result = await runTransform(plugin, `
export const value = 1
`, id)
expect(result).toContain("__effectViewAccept(import.meta.hot, [])")
expect(result).not.toContain("__effectViewRefresh(")
})
it("ignores the legacy effect-fc package", async () => {
expect(await transform(`
import { Component } from "effect-fc"
export const Legacy = Component.makeUntraced(function*() {
return null
})
`)).toBeUndefined()
})
})
+68
View File
@@ -0,0 +1,68 @@
import * as Refreshable from "effect-fc-next/Refreshable"
import { describe, expect, it, vi } from "vitest"
import { accept, register } from "../src/runtime.js"
describe("refresh runtime", () => {
it("retains a cell and notifies subscribers for compatible updates", async () => {
const hot = {
data: {},
accept: vi.fn(),
invalidate: vi.fn(),
}
const first = register({ body: "first" }, hot, "module:View", "hooks")
const cell = (first as Record<PropertyKey, unknown>)[Refreshable.RefreshableTypeId] as Refreshable.Cell
const listener = vi.fn()
cell.subscribe(listener)
const second = register({ body: "second" }, hot, "module:View", "hooks")
const secondCell = (second as Record<PropertyKey, unknown>)[Refreshable.RefreshableTypeId]
expect(secondCell).toBe(cell)
expect(cell.current).toBe(second)
expect(cell.snapshot).toEqual({
revision: 1,
resetRevision: 0,
})
await Promise.resolve()
expect(listener).toHaveBeenCalledTimes(1)
})
it("increments resetRevision for incompatible and forced updates", () => {
const hot = {
data: {},
accept: vi.fn(),
invalidate: vi.fn(),
}
const first = register({}, hot, "module:View", "one")
const cell = (first as Record<PropertyKey, unknown>)[Refreshable.RefreshableTypeId] as Refreshable.Cell
register({}, hot, "module:View", "two")
expect(cell.snapshot.resetRevision).toBe(1)
register({}, hot, "module:View", "two", true)
expect(cell.snapshot.resetRevision).toBe(2)
})
it("is inert outside a Vite hot context", () => {
const component = {}
expect(register(component, undefined, "module:View", "hooks")).toBe(component)
expect((component as Record<PropertyKey, unknown>)[Refreshable.RefreshableTypeId]).toBeUndefined()
})
it("invalidates when the module's View IDs change", () => {
const hot = {
data: {},
accept: vi.fn(),
invalidate: vi.fn(),
}
accept(hot, ["module:First"])
expect(hot.accept).toHaveBeenCalledTimes(1)
expect(hot.invalidate).not.toHaveBeenCalled()
accept(hot, ["module:Second"])
expect(hot.invalidate).toHaveBeenCalledWith("[effect-view] Effect View exports changed")
})
})
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "NodeNext",
"moduleDetection": "force",
"moduleResolution": "NodeNext",
"verbatimModuleSyntax": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"sourceMap": true
},
"include": ["./src"],
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
},
})