Add Effect v4 support, Fast Refresh tooling, and revamped docs (#56)
## 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:
@@ -0,0 +1,181 @@
|
||||
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.
|
||||
*
|
||||
* This low-level API is intended for development-server integrations such as
|
||||
* `@effect-view/vite-plugin`.
|
||||
*/
|
||||
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 {
|
||||
current: Component.ComponentImpl.Any
|
||||
signature: string
|
||||
forceReset: boolean
|
||||
snapshot: Snapshot
|
||||
readonly subscribe: (listener: () => void) => () => void
|
||||
readonly getSnapshot: () => Snapshot
|
||||
readonly update: (
|
||||
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 extends RefreshablePrototype {
|
||||
readonly [RefreshableTypeId]: Cell
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a descriptor has been connected to a development refresh
|
||||
* cell.
|
||||
*/
|
||||
export const isRefreshable = <A extends object>(
|
||||
value: A,
|
||||
): 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 = (
|
||||
component: Component.Component.Any,
|
||||
signature: string,
|
||||
forceReset: boolean,
|
||||
): Cell => {
|
||||
const listeners = new Set<() => void>()
|
||||
let notificationPending = false
|
||||
|
||||
const cell: Cell = {
|
||||
current: component as Component.ComponentImpl.Any,
|
||||
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 as Component.ComponentImpl.Any
|
||||
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 Component.Component.Any>(
|
||||
component: 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
|
||||
}
|
||||
Reference in New Issue
Block a user