From 23547afe9126c598c4a82b1d5ffdced1341acd2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Sat, 25 Jul 2026 22:27:11 +0200 Subject: [PATCH] Fix refresh plugin --- packages/effect-fc-next/src/Async.ts | 2 +- packages/effect-fc-next/src/Component.ts | 45 ++-------- packages/effect-fc-next/src/Refreshable.ts | 84 +++++++++++++++---- .../effect-fc-next/test/Refreshable.test.ts | 30 +++++-- packages/vite/src/runtime.ts | 7 +- packages/vite/test/runtime.test.ts | 20 +++-- 6 files changed, 119 insertions(+), 69 deletions(-) diff --git a/packages/effect-fc-next/src/Async.ts b/packages/effect-fc-next/src/Async.ts index 58b7f5a..bf4661f 100644 --- a/packages/effect-fc-next/src/Async.ts +++ b/packages/effect-fc-next/src/Async.ts @@ -37,7 +37,7 @@ export type AsyncProps = Omit export const AsyncPrototype: AsyncPrototype = Object.freeze({ [AsyncTypeId]: AsyncTypeId, - asFunctionComponent

( + makeFunctionComponent

( this: Component.Component & Async, contextRef: React.RefObject>>, ) { diff --git a/packages/effect-fc-next/src/Component.ts b/packages/effect-fc-next/src/Component.ts index 1e9c32f..6325f16 100644 --- a/packages/effect-fc-next/src/Component.ts +++ b/packages/effect-fc-next/src/Component.ts @@ -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

extends Component, ComponentImplPrototype {} +export declare namespace ComponentImpl { + export type Any = ComponentImpl +} + export interface ComponentImplPrototype { readonly use: Effect.Effect> asFunctionComponent(contextRef: React.Ref>>): F - asRefreshableFunctionComponent(contextRef: React.Ref>>): F + makeFunctionComponent(contextRef: React.Ref>>): F setFunctionComponentName(f: F): void transformFunctionComponent(f: F): F } @@ -57,7 +60,7 @@ export interface ComponentImplPrototype { export const ComponentImplPrototype: ComponentImplPrototype = Object.freeze({ get use() { return use(this) }, - asFunctionComponent

( + makeFunctionComponent

( this: ComponentImpl, contextRef: React.RefObject>>, ) { @@ -69,41 +72,11 @@ export const ComponentImplPrototype: ComponentImplPrototype = Object.f ) }, - asRefreshableFunctionComponent

( + asFunctionComponent

( this: ComponentImpl, contextRef: React.RefObject>>, ) { - 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

( @@ -137,7 +110,7 @@ const use = Effect.fnUntraced(function*

{ - 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

( + this: Component.ComponentImpl & Refreshable, + contextRef: React.RefObject>>, + ) { + 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 { - readonly [RefreshableTypeId]: Cell +export interface Refreshable extends RefreshablePrototype { + readonly [RefreshableTypeId]: Cell } /** @@ -52,23 +94,23 @@ export interface Refreshable { */ export const isRefreshable = ( value: A, -): value is A & Refreshable => 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 = ( - component: A, +export const makeCell = ( + component: Component.Component.Any, signature: string, forceReset: boolean, -): Cell => { +): Cell => { const listeners = new Set<() => void>() let notificationPending = false - const cell: Cell = { - current: component, + const cell: Cell = { + current: component as Component.ComponentImpl.Any, signature, forceReset, snapshot: { @@ -89,7 +131,7 @@ export const makeCell = ( || 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 = ( * * This low-level API is intended for development-server integrations. */ -export const attach = ( +export const attach = ( component: A, - cell: Cell, -): A & Refreshable => { + 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 + return component as A & Refreshable } diff --git a/packages/effect-fc-next/test/Refreshable.test.ts b/packages/effect-fc-next/test/Refreshable.test.ts index b094d3d..e756d7c 100644 --- a/packages/effect-fc-next/test/Refreshable.test.ts +++ b/packages/effect-fc-next/test/Refreshable.test.ts @@ -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) + }) }) diff --git a/packages/vite/src/runtime.ts b/packages/vite/src/runtime.ts index be62b49..a7db583 100644 --- a/packages/vite/src/runtime.ts +++ b/packages/vite/src/runtime.ts @@ -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> + readonly cells: Map ids?: readonly string[] } -const hotDataKey = "__effectFcRefresh" +const hotDataKey = "__effectViewRefresh" const getRefreshData = (hot: HotContext): RefreshData => { const data = hot.data as Record @@ -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 = ( +export const register = ( component: A, hot: HotContext | undefined, id: string, diff --git a/packages/vite/test/runtime.test.ts b/packages/vite/test/runtime.test.ts index 8acf1a5..ff200ef 100644 --- a/packages/vite/test/runtime.test.ts +++ b/packages/vite/test/runtime.test.ts @@ -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 = (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)[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)[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)[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)[Refreshable.RefreshableTypeId]).toBeUndefined() + const descriptor = component({}) + expect(register(descriptor, undefined, "module:View", "hooks")).toBe(descriptor) + expect((descriptor as Record)[Refreshable.RefreshableTypeId]).toBeUndefined() }) it("invalidates when the module's View IDs change", () => {