## Summary - Add comprehensive AI-oriented documentation for effect-view components, state, async rendering, queries, mutations, forms, streams, and runtime setup. - Fix `Mutation.mutate` so each invocation consistently uses its own key instead of reusing the previous mutation key. - Improve Vite Fast Refresh instrumentation for: - User-defined component wrappers. - Data-first and pipeline-based `withContext`/`withRuntime` entrypoints. - Nested function handling. - Stable hook signatures that preserve state for non-hook-related edits. - Add regression tests for mutation keys and refresh behavior. - Bump `effect-view` to `0.1.5` and `@effect-view/vite-plugin` to `0.0.2`. ## Testing - `bun run --cwd packages/effect-view test -- src/Mutation.test.ts` - 4 tests passed - `bun run --cwd packages/vite-plugin test -- src/plugin.test.ts` - 11 tests passed The full test suite was not run. --------- Co-authored-by: Julien Valverdé <julien.valverde@mailo.com> Reviewed-on: #76
212 lines
7.0 KiB
TypeScript
212 lines
7.0 KiB
TypeScript
import path from "node:path"
|
|
import type { Plugin } from "vite"
|
|
import { describe, expect, it } from "vitest"
|
|
import { effectView } from "./index.js"
|
|
|
|
|
|
type TransformHook = Extract<
|
|
NonNullable<Plugin["transform"]>,
|
|
(...args: never[]) => unknown
|
|
>
|
|
type TransformPluginContext = ThisParameterType<TransformHook>
|
|
|
|
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?.toString()
|
|
}
|
|
|
|
const transform = (
|
|
code: string,
|
|
id = path.join(process.cwd(), "src/View.tsx"),
|
|
): Promise<string | undefined> => runTransform(effectView(), code, id)
|
|
|
|
describe("effectView", () => {
|
|
it("wraps a const Effect View descriptor", async () => {
|
|
const result = await transform(`
|
|
import { Component } from "effect-view"
|
|
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-plugin/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-view"
|
|
|
|
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-view"
|
|
|
|
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-view"
|
|
|
|
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 = effectView() as Plugin
|
|
const id = path.join(process.cwd(), "src/Removed.tsx")
|
|
|
|
await runTransform(plugin, `
|
|
import { Component } from "effect-view"
|
|
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()
|
|
})
|
|
|
|
it("registers a component wrapped by a user-defined helper function", async () => {
|
|
const result = await transform(`
|
|
import { Component } from "effect-view"
|
|
|
|
const withLogging = (view) => view
|
|
|
|
export const LoggedView = withLogging(Component.make("LoggedView")(function*() {
|
|
return <div />
|
|
}))
|
|
`)
|
|
|
|
expect(result).toContain("__effectViewRefresh(withLogging(Component.make(\"LoggedView\")")
|
|
expect(result).toContain("\"src/View.tsx:LoggedView\"")
|
|
})
|
|
|
|
it("wraps the descriptor argument of a data-first withContext call", async () => {
|
|
const result = await transform(`
|
|
import { Component } from "effect-view"
|
|
|
|
const HomeBase = Component.make("Home")(function*() {
|
|
return <div />
|
|
})
|
|
|
|
export const Home = Component.withContext(HomeBase, runtime.context)
|
|
`)
|
|
|
|
expect(result).toContain("__effectViewRefresh(Component.make(\"Home\")")
|
|
expect(result).toContain("Component.withContext(__effectViewRefresh(HomeBase, import.meta.hot, \"src/View.tsx:Home\", \"unknown\", false), runtime.context)")
|
|
})
|
|
|
|
it("keeps the same hook signature when only literal content inside a hook call changes", async () => {
|
|
const before = await transform(`
|
|
import { Component } from "effect-view"
|
|
export const CounterView = Component.make("CounterView")(function*() {
|
|
const value = yield* Component.useOnMount(() => loadInitial(1))
|
|
return <div>{value}</div>
|
|
})
|
|
`)
|
|
const after = await transform(`
|
|
import { Component } from "effect-view"
|
|
export const CounterView = Component.make("CounterView")(function*() {
|
|
const value = yield* Component.useOnMount(() => loadInitial(2))
|
|
return <div>{value}</div>
|
|
})
|
|
`)
|
|
|
|
const signatureOf = (code: string | undefined) => code?.match(/"src\/View\.tsx:CounterView", "([a-z0-9]+)"/)?.[1]
|
|
expect(signatureOf(before)).toBeDefined()
|
|
expect(signatureOf(before)).toBe(signatureOf(after))
|
|
})
|
|
|
|
it("changes the hook signature when a hook call is added", async () => {
|
|
const before = await transform(`
|
|
import { Component } from "effect-view"
|
|
export const CounterView = Component.make("CounterView")(function*() {
|
|
const value = yield* Component.useOnMount(() => loadInitial())
|
|
return <div>{value}</div>
|
|
})
|
|
`)
|
|
const after = await transform(`
|
|
import { Component } from "effect-view"
|
|
export const CounterView = Component.make("CounterView")(function*() {
|
|
const value = yield* Component.useOnMount(() => loadInitial())
|
|
yield* Component.useReactEffect(() => trackView(), [])
|
|
return <div>{value}</div>
|
|
})
|
|
`)
|
|
|
|
const signatureOf = (code: string | undefined) => code?.match(/"src\/View\.tsx:CounterView", "([a-z0-9]+)"/)?.[1]
|
|
expect(signatureOf(before)).toBeDefined()
|
|
expect(signatureOf(before)).not.toBe(signatureOf(after))
|
|
})
|
|
})
|