Add Effect v4 support, Fast Refresh tooling, and revamped docs #56
@@ -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>>>,
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user