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
+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"