Add Effect v4 support, Fast Refresh tooling, and revamped docs (#56)
Lint / lint (push) Successful in 57s
Publish / publish (push) Failing after 57s

## Summary

- Add `effect-fc-next`, a React 19 integration targeting Effect v4 beta.
- Introduce component lifecycles, scoped resources, queries, mutations, forms, lenses, views, and refreshable components.
- Add `@effect-view/vite-plugin` for Vite Fast Refresh support.
- Add an Effect v4 example application covering the new APIs.
- Expand test coverage for both the existing and next-generation packages.
- Replace the starter Docusaurus content with complete Effect View documentation while preserving the Effect v3 docs as a versioned snapshot.
- Update the landing page, navigation, package scripts, and build output.
- Extend CI with linting, tests, package builds, Docker builds, and container publishing.

## Validation

- `bun lint:tsc`
- `bun lint:biome`
- `bun test`
- `bun run build`
- `bun pack`
- Docker image build

---------

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: Thilawyn/effect-fc#56
This commit was merged in pull request #56.
This commit is contained in:
2026-07-26 02:32:59 +02:00
parent 721ab7d736
commit 4478598f15
138 changed files with 13360 additions and 1970 deletions
+41
View File
@@ -0,0 +1,41 @@
# `@effect-view/vite-plugin`
Experimental Vite Fast Refresh support for Effect View components.
```ts
import { effectViewPlugin } from "@effect-view/vite-plugin"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [
effectViewPlugin(),
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
- 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/**"]
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@effect-view/vite-plugin",
"description": "Vite Fast Refresh support for Effect View components",
"version": "0.0.1",
"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 -b --noEmit",
"lint:biome": "biome lint",
"test": "vitest run",
"build": "tsc -b tsconfig.build.json",
"pack": "npm pack",
"clean:cache": "rm -rf .turbo *.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-plugin/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 function effectViewPlugin(
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,
}
},
}
}
+138
View File
@@ -0,0 +1,138 @@
import path from "node:path"
import type { Plugin } from "vite"
import { describe, expect, it } from "vitest"
import { effectViewPlugin } from "./index.js"
type TransformHook = Extract<
NonNullable<Plugin["transform"]>,
(...args: never[]) => unknown
>
type TransformPluginContext = ThisParameterType<TransformHook>
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?.toString()
}
const transform = (
code: string,
id = path.join(process.cwd(), "src/View.tsx"),
): Promise<string | undefined> => runTransform(effectViewPlugin(), code, id)
describe("effectViewPlugin", () => {
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-plugin/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 = effectViewPlugin() 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()
})
})
+78
View File
@@ -0,0 +1,78 @@
import type * as Component from "effect-fc-next/Component"
import * as Refreshable from "effect-fc-next/Refreshable"
import { describe, expect, it, vi } from "vitest"
import { accept, register } from "./runtime.js"
describe("refresh runtime", () => {
const component = <A extends object>(value: A): A & Component.Component.Any =>
value as A & Component.Component.Any
const refreshCell = (value: Component.Component.Any): Refreshable.Cell => {
if (!Refreshable.isRefreshable(value))
throw new Error("Expected a refreshable component")
return value[Refreshable.RefreshableTypeId]
}
it("retains a cell and notifies subscribers for compatible updates", async () => {
const hot = {
data: {},
accept: vi.fn(),
invalidate: vi.fn(),
}
const first = register(component({ body: "first" }), hot, "module:View", "hooks")
const cell = refreshCell(first)
const listener = vi.fn()
cell.subscribe(listener)
const second = register(component({ body: "second" }), hot, "module:View", "hooks")
const secondCell = refreshCell(second)
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(component({}), hot, "module:View", "one")
const cell = refreshCell(first)
register(component({}), hot, "module:View", "two")
expect(cell.snapshot.resetRevision).toBe(1)
register(component({}), hot, "module:View", "two", true)
expect(cell.snapshot.resetRevision).toBe(2)
})
it("is inert outside a Vite hot context", () => {
const descriptor = component({})
expect(register(descriptor, undefined, "module:View", "hooks")).toBe(descriptor)
expect(Refreshable.isRefreshable(descriptor)).toBe(false)
})
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")
})
})
+86
View File
@@ -0,0 +1,86 @@
import type * as Component from "effect-fc-next/Component"
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>
ids?: readonly string[]
}
const hotDataKey = "__effectViewRefresh"
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-plugin`; applications should not need to
* call it directly.
*/
export const register = <A extends Component.Component.Any>(
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()
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["**/setup-tests.ts", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"]
}
+39
View File
@@ -0,0 +1,39 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "NodeNext",
"moduleDetection": "force",
"jsx": "react-jsx",
// "allowJs": true,
// Bundler mode
"moduleResolution": "NodeNext",
// "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
// "noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
// Build
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"sourceMap": true,
"plugins": [
{ "name": "@effect/language-service" }
]
},
"include": ["./src"],
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
include: ["./src/**/*.test.ts?(x)"],
},
})