Add Effect v4 support, Fast Refresh tooling, and revamped docs #56

Merged
Thilawyn merged 133 commits from next into master 2026-07-26 02:32:59 +02:00
6 changed files with 119 additions and 69 deletions
Showing only changes of commit 23547afe91 - Show all commits
+1 -1
View File
@@ -37,7 +37,7 @@ export type AsyncProps = Omit<React.SuspenseProps, "children">
export const AsyncPrototype: AsyncPrototype = Object.freeze({
[AsyncTypeId]: AsyncTypeId,
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Component.Signature>(
makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Component.Signature>(
this: Component.Component<P, A, E, R, F> & Async,
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>,
) {
+9 -36
View File
@@ -2,7 +2,6 @@
/** 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"
@@ -45,11 +44,15 @@ export declare namespace Component {
export interface ComponentImpl<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>
extends Component<P, A, E, R, F>, ComponentImplPrototype<R, F> {}
export declare namespace ComponentImpl {
export type Any = ComponentImpl<any, any, any, any, any>
}
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
makeFunctionComponent(contextRef: React.Ref<Context.Context<Exclude<R, Scope.Scope>>>): F
setFunctionComponentName(f: F): void
transformFunctionComponent(f: F): F
}
@@ -57,7 +60,7 @@ export interface ComponentImplPrototype<R, F extends Component.Signature> {
export const ComponentImplPrototype: ComponentImplPrototype<any, any> = Object.freeze({
get use() { return use(this) },
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
makeFunctionComponent<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>>>,
) {
@@ -69,41 +72,11 @@ export const ComponentImplPrototype: ComponentImplPrototype<any, any> = Object.f
)
},
asRefreshableFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
asFunctionComponent<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
return this.makeFunctionComponent(contextRef)
},
setFunctionComponentName<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
@@ -137,7 +110,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.asRefreshableFunctionComponent(contextRef)
const f = self.asFunctionComponent(contextRef)
self.setFunctionComponentName(f)
componentRef.current = self.transformFunctionComponent(f)
}
+68 -16
View File
@@ -1,3 +1,8 @@
import type { Context, Scope } from "effect"
import * as React from "react"
import type * as Component from "./Component.js"
/**
* A stable identifier used to associate an Effect View descriptor with its
* development refresh cell.
@@ -25,25 +30,62 @@ export interface Snapshot {
*
* This low-level API is intended for development-server integrations.
*/
export interface Cell<A extends object = object> {
current: A
export interface Cell {
current: Component.ComponentImpl.Any
signature: string
forceReset: boolean
snapshot: Snapshot
readonly subscribe: (listener: () => void) => () => void
readonly getSnapshot: () => Snapshot
readonly update: (
component: A,
component: Component.Component.Any,
signature: string,
forceReset: boolean,
) => void
}
export const RefreshablePrototype = Object.freeze({
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Component.Signature>(
this: Component.ComponentImpl<P, A, E, R, F> & Refreshable,
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>,
) {
const cell = this[RefreshableTypeId]
let current = cell.current
let functionComponent = current.makeFunctionComponent(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.makeFunctionComponent(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
},
} as const)
export type RefreshablePrototype = typeof RefreshablePrototype
/**
* A descriptor that can be connected to a development refresh cell.
*/
export interface Refreshable<A extends object = object> {
readonly [RefreshableTypeId]: Cell<A>
export interface Refreshable extends RefreshablePrototype {
readonly [RefreshableTypeId]: Cell
}
/**
@@ -52,23 +94,23 @@ export interface Refreshable<A extends object = object> {
*/
export const isRefreshable = <A extends object>(
value: A,
): value is A & Refreshable<A> => Object.hasOwn(value, RefreshableTypeId)
): value is A & Refreshable => 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,
export const makeCell = (
component: Component.Component.Any,
signature: string,
forceReset: boolean,
): Cell<A> => {
): Cell => {
const listeners = new Set<() => void>()
let notificationPending = false
const cell: Cell<A> = {
current: component,
const cell: Cell = {
current: component as Component.ComponentImpl.Any,
signature,
forceReset,
snapshot: {
@@ -89,7 +131,7 @@ export const makeCell = <A extends object>(
|| nextForceReset
|| cell.signature !== nextSignature
cell.current = nextComponent
cell.current = nextComponent as Component.ComponentImpl.Any
cell.signature = nextSignature
cell.forceReset = nextForceReset
cell.snapshot = {
@@ -116,14 +158,24 @@ export const makeCell = <A extends object>(
*
* This low-level API is intended for development-server integrations.
*/
export const attach = <A extends object>(
export const attach = <A extends Component.Component.Any>(
component: A,
cell: Cell<A>,
): A & Refreshable<A> => {
cell: Cell,
): A & Refreshable => {
if (!isRefreshable(component)) {
Object.setPrototypeOf(
component,
Object.freeze(Object.setPrototypeOf(
Object.assign({}, RefreshablePrototype),
Object.getPrototypeOf(component),
)),
)
}
Object.defineProperty(component, RefreshableTypeId, {
configurable: true,
enumerable: true,
value: cell,
})
return component as A & Refreshable<A>
return component as A & Refreshable
}
@@ -1,10 +1,11 @@
import { describe, expect, it, vi } from "vitest"
import type * as Component from "../src/Component.js"
import * as Refreshable from "../src/Refreshable.js"
describe("Refreshable", () => {
it("attaches a cell and notifies subscribers after a compatible update", async () => {
const first = {}
const first = {} as Component.Component.Any
const cell = Refreshable.makeCell(first, "hooks", false)
const listener = vi.fn()
@@ -13,9 +14,11 @@ describe("Refreshable", () => {
expect(attached).toBe(first)
expect(Refreshable.isRefreshable(first)).toBe(true)
expect(attached[Refreshable.RefreshableTypeId]).toBe(cell)
expect(Object.getPrototypeOf(attached).asFunctionComponent)
.toBe(Refreshable.RefreshablePrototype.asFunctionComponent)
cell.subscribe(listener)
const second = {}
const second = {} as Component.Component.Any
cell.update(second, "hooks", false)
expect(cell.current).toBe(second)
@@ -29,12 +32,29 @@ describe("Refreshable", () => {
})
it("requests a remount when a signature changes or reset is forced", () => {
const cell = Refreshable.makeCell({}, "one", false)
const component = {} as Component.Component.Any
const cell = Refreshable.makeCell(component, "one", false)
cell.update({}, "two", false)
cell.update({} as Component.Component.Any, "two", false)
expect(cell.snapshot.resetRevision).toBe(1)
cell.update({}, "two", true)
cell.update({} as Component.Component.Any, "two", true)
expect(cell.snapshot.resetRevision).toBe(2)
})
it("builds the refresh shell from the current descriptor implementation", () => {
const implementation = () => null
const makeFunctionComponent = vi.fn(() => implementation)
const component = {
makeFunctionComponent,
} as unknown as Component.ComponentImpl.Any
const contextRef = {
current: {},
} as never
const cell = Refreshable.makeCell(component, "hooks", false)
const attached = Refreshable.attach(component, cell)
expect(attached.asFunctionComponent(contextRef)).not.toBe(implementation)
expect(makeFunctionComponent).toHaveBeenCalledWith(contextRef)
})
})
+4 -3
View File
@@ -1,3 +1,4 @@
import type * as Component from "effect-fc-next/Component"
import * as Refreshable from "effect-fc-next/Refreshable"
export interface HotContext {
@@ -7,11 +8,11 @@ export interface HotContext {
}
interface RefreshData {
readonly cells: Map<string, Refreshable.Cell<any>>
readonly cells: Map<string, Refreshable.Cell>
ids?: readonly string[]
}
const hotDataKey = "__effectFcRefresh"
const hotDataKey = "__effectViewRefresh"
const getRefreshData = (hot: HotContext): RefreshData => {
const data = hot.data as Record<string, unknown>
@@ -32,7 +33,7 @@ const getRefreshData = (hot: HotContext): RefreshData => {
* This API is injected by `@effect-view/vite`; applications should not need to
* call it directly.
*/
export const register = <A extends object>(
export const register = <A extends Component.Component.Any>(
component: A,
hot: HotContext | undefined,
id: string,
+12 -8
View File
@@ -1,21 +1,25 @@
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 "../src/runtime.js"
describe("refresh runtime", () => {
const component = <A extends object>(value: A): A & Component.Component.Any =>
value as A & Component.Component.Any
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 first = register(component({ 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 second = register(component({ body: "second" }), hot, "module:View", "hooks")
const secondCell = (second as Record<PropertyKey, unknown>)[Refreshable.RefreshableTypeId]
expect(secondCell).toBe(cell)
@@ -35,20 +39,20 @@ describe("refresh runtime", () => {
accept: vi.fn(),
invalidate: vi.fn(),
}
const first = register({}, hot, "module:View", "one")
const first = register(component({}), hot, "module:View", "one")
const cell = (first as Record<PropertyKey, unknown>)[Refreshable.RefreshableTypeId] as Refreshable.Cell
register({}, hot, "module:View", "two")
register(component({}), hot, "module:View", "two")
expect(cell.snapshot.resetRevision).toBe(1)
register({}, hot, "module:View", "two", true)
register(component({}), 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()
const descriptor = component({})
expect(register(descriptor, undefined, "module:View", "hooks")).toBe(descriptor)
expect((descriptor as Record<PropertyKey, unknown>)[Refreshable.RefreshableTypeId]).toBeUndefined()
})
it("invalidates when the module's View IDs change", () => {