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
+132
View File
@@ -0,0 +1,132 @@
import path from "node:path"
import type { Plugin, TransformPluginContext } from "vite"
import { describe, expect, it } from "vitest"
import effectViewRefresh from "../src/index.js"
const runTransform = async (
plugin: Plugin,
code: string,
id: string,
): Promise<string | undefined> => {
const hook = plugin.transform
if (!hook)
return undefined
const result = typeof hook === "function"
? await hook.call({} as TransformPluginContext, code, id)
: await hook.handler.call({} as TransformPluginContext, code, id)
if (!result)
return undefined
return typeof result === "string" ? result : result.code
}
const transform = (
code: string,
id = path.join(process.cwd(), "src/View.tsx"),
): Promise<string | undefined> => runTransform(effectViewRefresh(), code, id)
describe("effectViewRefresh", () => {
it("wraps a const Effect View descriptor", async () => {
const result = await transform(`
import { Component } from "effect-fc-next"
import * as React from "react"
export const CounterView = Component.make("CounterView")(function*() {
const [count] = React.useState(0)
return <div>{count}</div>
})
`)
expect(result).toContain("import { accept as __effectViewAccept, register as __effectViewRefresh } from \"@effect-view/vite/runtime\"")
expect(result).toContain("__effectViewRefresh(Component.make(\"CounterView\")")
expect(result).toContain("\"src/View.tsx:CounterView\"")
expect(result).toContain("__effectViewAccept(import.meta.hot, [\"src/View.tsx:CounterView\"])")
})
it("registers a pipeline before withContext", async () => {
const result = await transform(`
import { Component } from "effect-fc-next"
const Page = Component.make("Page")(function*() {
return <main />
}).pipe(
Component.withContext(runtime.context),
)
`)
expect(result).toContain("(__effectViewRefreshView) => __effectViewRefresh(__effectViewRefreshView")
expect(result).toContain("Component.withContext(runtime.context)")
expect(result).not.toContain("__effectViewRefresh(Component.make(\"Page\")")
})
it("wraps a class's complete trait pipeline", async () => {
const result = await transform(`
import { Async, Component, Memoized } from "effect-fc-next"
class PostView extends Component.make("PostView")(function*() {
const value = yield* Component.useOnMount(load)
return <div>{value}</div>
}).pipe(
Async.async,
Memoized.memoized,
) {}
`)
expect(result).toContain("class PostView extends __effectViewRefresh(Component.make(\"PostView\")")
expect(result).toContain("Memoized.memoized,")
expect(result).toContain("\"src/View.tsx:PostView\"")
})
it("supports aliased Component imports and refresh reset", async () => {
const result = await transform(`
// @refresh reset
import { Component as View } from "effect-fc-next"
const Probe = View.makeUntraced(function*() {
return null
})
`)
expect(result).toContain("__effectViewRefresh(View.makeUntraced")
expect(result).toMatch(/"src\/View\.tsx:Probe", "[a-z0-9]+", true/)
})
it("ignores modules without Effect View definitions", async () => {
expect(await transform(`
import * as React from "react"
export function View() {
return <div />
}
`)).toBeUndefined()
})
it("keeps instrumenting a module when all Effect Views are removed", async () => {
const plugin = effectViewRefresh() as Plugin
const id = path.join(process.cwd(), "src/Removed.tsx")
await runTransform(plugin, `
import { Component } from "effect-fc-next"
export const Probe = Component.makeUntraced(function*() {
return null
})
`, id)
const result = await runTransform(plugin, `
export const value = 1
`, id)
expect(result).toContain("__effectViewAccept(import.meta.hot, [])")
expect(result).not.toContain("__effectViewRefresh(")
})
it("ignores the legacy effect-fc package", async () => {
expect(await transform(`
import { Component } from "effect-fc"
export const Legacy = Component.makeUntraced(function*() {
return null
})
`)).toBeUndefined()
})
})
+68
View File
@@ -0,0 +1,68 @@
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", () => {
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 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 secondCell = (second as Record<PropertyKey, unknown>)[Refreshable.RefreshableTypeId]
expect(secondCell).toBe(cell)
expect(cell.current).toBe(second)
expect(cell.snapshot).toEqual({
revision: 1,
resetRevision: 0,
})
await Promise.resolve()
expect(listener).toHaveBeenCalledTimes(1)
})
it("increments resetRevision for incompatible and forced updates", () => {
const hot = {
data: {},
accept: vi.fn(),
invalidate: vi.fn(),
}
const first = register({}, hot, "module:View", "one")
const cell = (first as Record<PropertyKey, unknown>)[Refreshable.RefreshableTypeId] as Refreshable.Cell
register({}, hot, "module:View", "two")
expect(cell.snapshot.resetRevision).toBe(1)
register({}, 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()
})
it("invalidates when the module's View IDs change", () => {
const hot = {
data: {},
accept: vi.fn(),
invalidate: vi.fn(),
}
accept(hot, ["module:First"])
expect(hot.accept).toHaveBeenCalledTimes(1)
expect(hot.invalidate).not.toHaveBeenCalled()
accept(hot, ["module:Second"])
expect(hot.invalidate).toHaveBeenCalledWith("[effect-view] Effect View exports changed")
})
})