Fix refresh plugin
Lint / lint (push) Failing after 39s

This commit is contained in:
Julien Valverdé
2026-07-25 22:27:11 +02:00
parent 462895a3c2
commit 23547afe91
6 changed files with 119 additions and 69 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ export type AsyncProps = Omit<React.SuspenseProps, "children">
export const AsyncPrototype: AsyncPrototype = Object.freeze({ export const AsyncPrototype: AsyncPrototype = Object.freeze({
[AsyncTypeId]: AsyncTypeId, [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, this: Component.Component<P, A, E, R, F> & Async,
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>, 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 */ /** 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 { Context, type Duration, Effect, Equivalence, Exit, Function, identity, Layer, Pipeable, Predicate, References, Scheduler, Scope, Tracer } from "effect"
import * as React from "react" import * as React from "react"
import * as Refreshable from "./Refreshable.js"
import * as ScopeRegistry from "./ScopeRegistry.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> 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> {} 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> { export interface ComponentImplPrototype<R, F extends Component.Signature> {
readonly use: Effect.Effect<F, never, Exclude<R, Scope.Scope>> readonly use: Effect.Effect<F, never, Exclude<R, Scope.Scope>>
asFunctionComponent(contextRef: React.Ref<Context.Context<Exclude<R, Scope.Scope>>>): F 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 setFunctionComponentName(f: F): void
transformFunctionComponent(f: F): F transformFunctionComponent(f: F): F
} }
@@ -57,7 +60,7 @@ export interface ComponentImplPrototype<R, F extends Component.Signature> {
export const ComponentImplPrototype: ComponentImplPrototype<any, any> = Object.freeze({ export const ComponentImplPrototype: ComponentImplPrototype<any, any> = Object.freeze({
get use() { return use(this) }, 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>, this: ComponentImpl<P, A, E, R, F>,
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>, 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>, this: ComponentImpl<P, A, E, R, F>,
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>, contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>,
) { ) {
if (!Refreshable.isRefreshable(this)) return this.makeFunctionComponent(contextRef)
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>( 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) !Equivalence.Array(Equivalence.strictEqual())(services, previousServicesRef.current)
) { ) {
previousServicesRef.current = services previousServicesRef.current = services
const f = self.asRefreshableFunctionComponent(contextRef) const f = self.asFunctionComponent(contextRef)
self.setFunctionComponentName(f) self.setFunctionComponentName(f)
componentRef.current = self.transformFunctionComponent(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 * A stable identifier used to associate an Effect View descriptor with its
* development refresh cell. * development refresh cell.
@@ -25,25 +30,62 @@ export interface Snapshot {
* *
* This low-level API is intended for development-server integrations. * This low-level API is intended for development-server integrations.
*/ */
export interface Cell<A extends object = object> { export interface Cell {
current: A current: Component.ComponentImpl.Any
signature: string signature: string
forceReset: boolean forceReset: boolean
snapshot: Snapshot snapshot: Snapshot
readonly subscribe: (listener: () => void) => () => void readonly subscribe: (listener: () => void) => () => void
readonly getSnapshot: () => Snapshot readonly getSnapshot: () => Snapshot
readonly update: ( readonly update: (
component: A, component: Component.Component.Any,
signature: string, signature: string,
forceReset: boolean, forceReset: boolean,
) => void ) => 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. * A descriptor that can be connected to a development refresh cell.
*/ */
export interface Refreshable<A extends object = object> { export interface Refreshable extends RefreshablePrototype {
readonly [RefreshableTypeId]: Cell<A> readonly [RefreshableTypeId]: Cell
} }
/** /**
@@ -52,23 +94,23 @@ export interface Refreshable<A extends object = object> {
*/ */
export const isRefreshable = <A extends object>( export const isRefreshable = <A extends object>(
value: A, 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. * Creates a refresh cell for an Effect View descriptor.
* *
* This low-level API is intended for development-server integrations. * This low-level API is intended for development-server integrations.
*/ */
export const makeCell = <A extends object>( export const makeCell = (
component: A, component: Component.Component.Any,
signature: string, signature: string,
forceReset: boolean, forceReset: boolean,
): Cell<A> => { ): Cell => {
const listeners = new Set<() => void>() const listeners = new Set<() => void>()
let notificationPending = false let notificationPending = false
const cell: Cell<A> = { const cell: Cell = {
current: component, current: component as Component.ComponentImpl.Any,
signature, signature,
forceReset, forceReset,
snapshot: { snapshot: {
@@ -89,7 +131,7 @@ export const makeCell = <A extends object>(
|| nextForceReset || nextForceReset
|| cell.signature !== nextSignature || cell.signature !== nextSignature
cell.current = nextComponent cell.current = nextComponent as Component.ComponentImpl.Any
cell.signature = nextSignature cell.signature = nextSignature
cell.forceReset = nextForceReset cell.forceReset = nextForceReset
cell.snapshot = { cell.snapshot = {
@@ -116,14 +158,24 @@ export const makeCell = <A extends object>(
* *
* This low-level API is intended for development-server integrations. * 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, component: A,
cell: Cell<A>, cell: Cell,
): A & Refreshable<A> => { ): A & Refreshable => {
if (!isRefreshable(component)) {
Object.setPrototypeOf(
component,
Object.freeze(Object.setPrototypeOf(
Object.assign({}, RefreshablePrototype),
Object.getPrototypeOf(component),
)),
)
}
Object.defineProperty(component, RefreshableTypeId, { Object.defineProperty(component, RefreshableTypeId, {
configurable: true, configurable: true,
enumerable: true, enumerable: true,
value: cell, 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 { describe, expect, it, vi } from "vitest"
import type * as Component from "../src/Component.js"
import * as Refreshable from "../src/Refreshable.js" import * as Refreshable from "../src/Refreshable.js"
describe("Refreshable", () => { describe("Refreshable", () => {
it("attaches a cell and notifies subscribers after a compatible update", async () => { 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 cell = Refreshable.makeCell(first, "hooks", false)
const listener = vi.fn() const listener = vi.fn()
@@ -13,9 +14,11 @@ describe("Refreshable", () => {
expect(attached).toBe(first) expect(attached).toBe(first)
expect(Refreshable.isRefreshable(first)).toBe(true) expect(Refreshable.isRefreshable(first)).toBe(true)
expect(attached[Refreshable.RefreshableTypeId]).toBe(cell) expect(attached[Refreshable.RefreshableTypeId]).toBe(cell)
expect(Object.getPrototypeOf(attached).asFunctionComponent)
.toBe(Refreshable.RefreshablePrototype.asFunctionComponent)
cell.subscribe(listener) cell.subscribe(listener)
const second = {} const second = {} as Component.Component.Any
cell.update(second, "hooks", false) cell.update(second, "hooks", false)
expect(cell.current).toBe(second) expect(cell.current).toBe(second)
@@ -29,12 +32,29 @@ describe("Refreshable", () => {
}) })
it("requests a remount when a signature changes or reset is forced", () => { 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) expect(cell.snapshot.resetRevision).toBe(1)
cell.update({}, "two", true) cell.update({} as Component.Component.Any, "two", true)
expect(cell.snapshot.resetRevision).toBe(2) 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" import * as Refreshable from "effect-fc-next/Refreshable"
export interface HotContext { export interface HotContext {
@@ -7,11 +8,11 @@ export interface HotContext {
} }
interface RefreshData { interface RefreshData {
readonly cells: Map<string, Refreshable.Cell<any>> readonly cells: Map<string, Refreshable.Cell>
ids?: readonly string[] ids?: readonly string[]
} }
const hotDataKey = "__effectFcRefresh" const hotDataKey = "__effectViewRefresh"
const getRefreshData = (hot: HotContext): RefreshData => { const getRefreshData = (hot: HotContext): RefreshData => {
const data = hot.data as Record<string, unknown> 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 * This API is injected by `@effect-view/vite`; applications should not need to
* call it directly. * call it directly.
*/ */
export const register = <A extends object>( export const register = <A extends Component.Component.Any>(
component: A, component: A,
hot: HotContext | undefined, hot: HotContext | undefined,
id: string, 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 * as Refreshable from "effect-fc-next/Refreshable"
import { describe, expect, it, vi } from "vitest" import { describe, expect, it, vi } from "vitest"
import { accept, register } from "../src/runtime.js" import { accept, register } from "../src/runtime.js"
describe("refresh runtime", () => { 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 () => { it("retains a cell and notifies subscribers for compatible updates", async () => {
const hot = { const hot = {
data: {}, data: {},
accept: vi.fn(), accept: vi.fn(),
invalidate: 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 cell = (first as Record<PropertyKey, unknown>)[Refreshable.RefreshableTypeId] as Refreshable.Cell
const listener = vi.fn() const listener = vi.fn()
cell.subscribe(listener) 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] const secondCell = (second as Record<PropertyKey, unknown>)[Refreshable.RefreshableTypeId]
expect(secondCell).toBe(cell) expect(secondCell).toBe(cell)
@@ -35,20 +39,20 @@ describe("refresh runtime", () => {
accept: vi.fn(), accept: vi.fn(),
invalidate: 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 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) 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) expect(cell.snapshot.resetRevision).toBe(2)
}) })
it("is inert outside a Vite hot context", () => { it("is inert outside a Vite hot context", () => {
const component = {} const descriptor = component({})
expect(register(component, undefined, "module:View", "hooks")).toBe(component) expect(register(descriptor, undefined, "module:View", "hooks")).toBe(descriptor)
expect((component as Record<PropertyKey, unknown>)[Refreshable.RefreshableTypeId]).toBeUndefined() expect((descriptor as Record<PropertyKey, unknown>)[Refreshable.RefreshableTypeId]).toBeUndefined()
}) })
it("invalidates when the module's View IDs change", () => { it("invalidates when the module's View IDs change", () => {