@@ -17,16 +17,78 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./*": [
|
||||
{
|
||||
"types": "./dist/*/index.d.ts",
|
||||
"default": "./dist/*/index.js"
|
||||
},
|
||||
{
|
||||
"types": "./dist/*.d.ts",
|
||||
"default": "./dist/*.js"
|
||||
}
|
||||
]
|
||||
"./Async": {
|
||||
"types": "./dist/Async.d.ts",
|
||||
"default": "./dist/Async.js"
|
||||
},
|
||||
"./Component": {
|
||||
"types": "./dist/Component.d.ts",
|
||||
"default": "./dist/Component.js"
|
||||
},
|
||||
"./ErrorObserver": {
|
||||
"types": "./dist/ErrorObserver.d.ts",
|
||||
"default": "./dist/ErrorObserver.js"
|
||||
},
|
||||
"./Form": {
|
||||
"types": "./dist/Form.d.ts",
|
||||
"default": "./dist/Form.js"
|
||||
},
|
||||
"./Lens": {
|
||||
"types": "./dist/Lens.d.ts",
|
||||
"default": "./dist/Lens.js"
|
||||
},
|
||||
"./LensForm": {
|
||||
"types": "./dist/LensForm.d.ts",
|
||||
"default": "./dist/LensForm.js"
|
||||
},
|
||||
"./Memoized": {
|
||||
"types": "./dist/Memoized.d.ts",
|
||||
"default": "./dist/Memoized.js"
|
||||
},
|
||||
"./Mutation": {
|
||||
"types": "./dist/Mutation.d.ts",
|
||||
"default": "./dist/Mutation.js"
|
||||
},
|
||||
"./MutationForm": {
|
||||
"types": "./dist/MutationForm.d.ts",
|
||||
"default": "./dist/MutationForm.js"
|
||||
},
|
||||
"./PubSub": {
|
||||
"types": "./dist/PubSub.d.ts",
|
||||
"default": "./dist/PubSub.js"
|
||||
},
|
||||
"./Query": {
|
||||
"types": "./dist/Query.d.ts",
|
||||
"default": "./dist/Query.js"
|
||||
},
|
||||
"./QueryClient": {
|
||||
"types": "./dist/QueryClient.d.ts",
|
||||
"default": "./dist/QueryClient.js"
|
||||
},
|
||||
"./ReactRuntime": {
|
||||
"types": "./dist/ReactRuntime.d.ts",
|
||||
"default": "./dist/ReactRuntime.js"
|
||||
},
|
||||
"./Refreshable": {
|
||||
"types": "./dist/Refreshable.d.ts",
|
||||
"default": "./dist/Refreshable.js"
|
||||
},
|
||||
"./ScopeRegistry": {
|
||||
"types": "./dist/ScopeRegistry.d.ts",
|
||||
"default": "./dist/ScopeRegistry.js"
|
||||
},
|
||||
"./SetStateAction": {
|
||||
"types": "./dist/SetStateAction.d.ts",
|
||||
"default": "./dist/SetStateAction.js"
|
||||
},
|
||||
"./Stream": {
|
||||
"types": "./dist/Stream.d.ts",
|
||||
"default": "./dist/Stream.js"
|
||||
},
|
||||
"./View": {
|
||||
"types": "./dist/View.d.ts",
|
||||
"default": "./dist/View.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint:tsc": "tsc --noEmit",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
/** 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"
|
||||
|
||||
|
||||
@@ -48,6 +49,7 @@ 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
|
||||
setFunctionComponentName(f: F): void
|
||||
transformFunctionComponent(f: F): F
|
||||
}
|
||||
@@ -67,6 +69,43 @@ export const ComponentImplPrototype: ComponentImplPrototype<any, any> = Object.f
|
||||
)
|
||||
},
|
||||
|
||||
asRefreshableFunctionComponent<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
|
||||
},
|
||||
|
||||
setFunctionComponentName<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
|
||||
this: ComponentImpl<P, A, E, R, F>,
|
||||
f: React.FC<P>,
|
||||
@@ -98,7 +137,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.asFunctionComponent(contextRef)
|
||||
const f = self.asRefreshableFunctionComponent(contextRef)
|
||||
self.setFunctionComponentName(f)
|
||||
componentRef.current = self.transformFunctionComponent(f)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* A stable identifier used to associate an Effect View descriptor with its
|
||||
* development refresh cell.
|
||||
*
|
||||
* This low-level API is intended for development-server integrations such as
|
||||
* `@effect-view/vite`.
|
||||
*/
|
||||
export const RefreshableTypeId: unique symbol = Symbol.for("@effect-view/Refreshable/Refreshable")
|
||||
export type RefreshableTypeId = typeof RefreshableTypeId
|
||||
|
||||
/**
|
||||
* The version observed by a mounted Effect View refresh shell.
|
||||
*
|
||||
* `revision` changes for every update. `resetRevision` changes only when the
|
||||
* adapter determines that preserving React state is unsafe.
|
||||
*/
|
||||
export interface Snapshot {
|
||||
readonly revision: number
|
||||
readonly resetRevision: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A mutable development cell holding the latest version of an Effect View
|
||||
* descriptor.
|
||||
*
|
||||
* This low-level API is intended for development-server integrations.
|
||||
*/
|
||||
export interface Cell<A extends object = object> {
|
||||
current: A
|
||||
signature: string
|
||||
forceReset: boolean
|
||||
snapshot: Snapshot
|
||||
readonly subscribe: (listener: () => void) => () => void
|
||||
readonly getSnapshot: () => Snapshot
|
||||
readonly update: (
|
||||
component: A,
|
||||
signature: string,
|
||||
forceReset: boolean,
|
||||
) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A descriptor that can be connected to a development refresh cell.
|
||||
*/
|
||||
export interface Refreshable<A extends object = object> {
|
||||
readonly [RefreshableTypeId]: Cell<A>
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a descriptor has been connected to a development refresh
|
||||
* cell.
|
||||
*/
|
||||
export const isRefreshable = <A extends object>(
|
||||
value: A,
|
||||
): value is A & Refreshable<A> => 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,
|
||||
signature: string,
|
||||
forceReset: boolean,
|
||||
): Cell<A> => {
|
||||
const listeners = new Set<() => void>()
|
||||
let notificationPending = false
|
||||
|
||||
const cell: Cell<A> = {
|
||||
current: component,
|
||||
signature,
|
||||
forceReset,
|
||||
snapshot: {
|
||||
revision: 0,
|
||||
resetRevision: 0,
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
},
|
||||
getSnapshot() {
|
||||
return cell.snapshot
|
||||
},
|
||||
update(nextComponent, nextSignature, nextForceReset) {
|
||||
const shouldReset = cell.forceReset
|
||||
|| nextForceReset
|
||||
|| cell.signature !== nextSignature
|
||||
|
||||
cell.current = nextComponent
|
||||
cell.signature = nextSignature
|
||||
cell.forceReset = nextForceReset
|
||||
cell.snapshot = {
|
||||
revision: cell.snapshot.revision + 1,
|
||||
resetRevision: cell.snapshot.resetRevision + (shouldReset ? 1 : 0),
|
||||
}
|
||||
|
||||
if (!notificationPending) {
|
||||
notificationPending = true
|
||||
queueMicrotask(() => {
|
||||
notificationPending = false
|
||||
for (const listener of listeners)
|
||||
listener()
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates a descriptor with a refresh cell and returns the descriptor.
|
||||
*
|
||||
* This low-level API is intended for development-server integrations.
|
||||
*/
|
||||
export const attach = <A extends object>(
|
||||
component: A,
|
||||
cell: Cell<A>,
|
||||
): A & Refreshable<A> => {
|
||||
Object.defineProperty(component, RefreshableTypeId, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: cell,
|
||||
})
|
||||
return component as A & Refreshable<A>
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export * as PubSub from "./PubSub.js"
|
||||
export * as Query from "./Query.js"
|
||||
export * as QueryClient from "./QueryClient.js"
|
||||
export * as ReactRuntime from "./ReactRuntime.js"
|
||||
export * as Refreshable from "./Refreshable.js"
|
||||
export * as ScopeRegistry from "./ScopeRegistry.js"
|
||||
export * as SetStateAction from "./SetStateAction.js"
|
||||
export * as Stream from "./Stream.js"
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import * as Component from "../src/Component.js"
|
||||
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||
import * as Refreshable from "../src/Refreshable.js"
|
||||
import * as ScopeRegistry from "../src/ScopeRegistry.js"
|
||||
|
||||
|
||||
@@ -529,4 +530,42 @@ describe("Component", () => {
|
||||
await runtime.runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it("refreshes a registered body while preserving or resetting local state", async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
const makeProbe = (label: string) => Component.makeUntraced("RefreshProbe")(function*() {
|
||||
const [count, setCount] = React.useState(0)
|
||||
return <button type="button" onClick={() => setCount(value => value + 1)}>{label}:{count}</button>
|
||||
})
|
||||
const original = makeProbe("old")
|
||||
const cell = Refreshable.makeCell(original, "hooks", false)
|
||||
Refreshable.attach(original, cell)
|
||||
|
||||
const Probe = Component.withContext(original, runtime.context)
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
fireEvent.click(await screen.findByRole("button"))
|
||||
expect(screen.getByRole("button").textContent).toBe("old:1")
|
||||
|
||||
await act(async () => {
|
||||
cell.update(makeProbe("compatible"), "hooks", false)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole("button").textContent).toBe("compatible:1")
|
||||
|
||||
await act(async () => {
|
||||
cell.update(makeProbe("reset"), "changed-hooks", false)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole("button").textContent).toBe("reset:0")
|
||||
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import * as Refreshable from "../src/Refreshable.js"
|
||||
|
||||
|
||||
describe("Refreshable", () => {
|
||||
it("attaches a cell and notifies subscribers after a compatible update", async () => {
|
||||
const first = {}
|
||||
const cell = Refreshable.makeCell(first, "hooks", false)
|
||||
const listener = vi.fn()
|
||||
|
||||
expect(Refreshable.isRefreshable(first)).toBe(false)
|
||||
const attached = Refreshable.attach(first, cell)
|
||||
expect(attached).toBe(first)
|
||||
expect(Refreshable.isRefreshable(first)).toBe(true)
|
||||
expect(attached[Refreshable.RefreshableTypeId]).toBe(cell)
|
||||
|
||||
cell.subscribe(listener)
|
||||
const second = {}
|
||||
cell.update(second, "hooks", false)
|
||||
|
||||
expect(cell.current).toBe(second)
|
||||
expect(cell.snapshot).toEqual({
|
||||
revision: 1,
|
||||
resetRevision: 0,
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("requests a remount when a signature changes or reset is forced", () => {
|
||||
const cell = Refreshable.makeCell({}, "one", false)
|
||||
|
||||
cell.update({}, "two", false)
|
||||
expect(cell.snapshot.resetRevision).toBe(1)
|
||||
|
||||
cell.update({}, "two", true)
|
||||
expect(cell.snapshot.resetRevision).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect-view/vite": "workspace:*",
|
||||
"@tanstack/react-router": "^1.170.10",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@tanstack/router-plugin": "^1.168.13",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import effectViewRefresh from "@effect-view/vite"
|
||||
import path from "node:path"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
@@ -7,6 +8,7 @@ import { defineConfig } from "vite"
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
effectViewRefresh(),
|
||||
tanstackRouter({
|
||||
target: "react",
|
||||
autoCodeSplitting: true,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# `@effect-view/vite`
|
||||
|
||||
Experimental Vite Fast Refresh support for Effect View components.
|
||||
|
||||
```ts
|
||||
import effectViewRefresh from "@effect-view/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
effectViewRefresh(),
|
||||
react(),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
The plugin recognizes `Component.make` and `Component.makeUntraced`
|
||||
definitions imported from `effect-fc-next`. The legacy `effect-fc` package is
|
||||
not supported. `effect-fc-next` owns the bundler-neutral refresh cell protocol;
|
||||
this package is the Vite adapter that retains those cells in HMR data. The
|
||||
plugin assigns stable development IDs and self-accepts successfully
|
||||
instrumented modules.
|
||||
|
||||
The adapter-facing protocol is exported from `effect-fc-next/Refreshable`.
|
||||
|
||||
Renaming or removing an instrumented View invalidates the module upward instead
|
||||
of leaving a stale mounted descriptor.
|
||||
|
||||
React state is retained when the ordered hook signature is unchanged. Adding,
|
||||
removing, or changing a hook call resets the Effect View implementation. Add
|
||||
`// @refresh reset` to a module to reset its Views on every update.
|
||||
|
||||
## Current limitations
|
||||
|
||||
- Vite is the only supported bundler.
|
||||
- Definitions must be top-level variable, class, or default-export
|
||||
declarations.
|
||||
- Hook signature analysis is conservative and based on `useX` call syntax.
|
||||
- Changing a descriptor's trait pipeline during a refresh is not guaranteed to
|
||||
preserve state.
|
||||
- Source maps for the injected transform are not generated yet.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"files": {
|
||||
"includes": ["./src/**", "./test/**"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@effect-view/vite",
|
||||
"description": "Vite Fast Refresh support for Effect View components",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"./README.md",
|
||||
"./dist"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "git+https://github.com/Thiladev/effect-fc.git"
|
||||
},
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./runtime": {
|
||||
"types": "./dist/runtime.d.ts",
|
||||
"default": "./dist/runtime.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint:tsc": "tsc --noEmit",
|
||||
"lint:biome": "biome lint",
|
||||
"test": "vitest run",
|
||||
"build": "tsc",
|
||||
"pack": "npm pack",
|
||||
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"effect-fc-next": "workspace:*",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect-fc-next": "0.1.0-beta.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import path from "node:path"
|
||||
import ts from "typescript"
|
||||
import type { Plugin } from "vite"
|
||||
|
||||
|
||||
export interface EffectViewRefreshOptions {
|
||||
/**
|
||||
* Include filter for source files.
|
||||
*
|
||||
* @default /\.[cm]?[jt]sx?$/
|
||||
*/
|
||||
readonly include?: RegExp
|
||||
|
||||
/**
|
||||
* Exclude filter for source files.
|
||||
*
|
||||
* @default /\/node_modules\//
|
||||
*/
|
||||
readonly exclude?: RegExp
|
||||
}
|
||||
|
||||
interface Edit {
|
||||
readonly start: number
|
||||
readonly end: number
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
interface ComponentImports {
|
||||
readonly componentNamespaces: Set<string>
|
||||
readonly packageNamespaces: Set<string>
|
||||
readonly directFactories: Set<string>
|
||||
}
|
||||
|
||||
interface Definition {
|
||||
readonly expression: ts.Expression
|
||||
readonly factoryCall: ts.CallExpression
|
||||
readonly body: ts.FunctionLikeDeclaration | undefined
|
||||
readonly id: string
|
||||
readonly pipeline: ts.CallExpression | undefined
|
||||
readonly entrypointIndex: number
|
||||
}
|
||||
|
||||
const defaultInclude = /\.[cm]?[jt]sx?$/
|
||||
const defaultExclude = /\/node_modules\//
|
||||
const runtimeImport = "@effect-view/vite/runtime"
|
||||
const refreshIdentifier = "__effectViewRefresh"
|
||||
const acceptIdentifier = "__effectViewAccept"
|
||||
|
||||
const isSupportedPackage = (source: string): boolean =>
|
||||
source === "effect-fc-next"
|
||||
|| source === "effect-fc-next/Component"
|
||||
|
||||
const collectImports = (
|
||||
sourceFile: ts.SourceFile,
|
||||
): ComponentImports => {
|
||||
const componentNamespaces = new Set<string>()
|
||||
const packageNamespaces = new Set<string>()
|
||||
const directFactories = new Set<string>()
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!ts.isImportDeclaration(statement)
|
||||
|| !ts.isStringLiteral(statement.moduleSpecifier)
|
||||
|| !isSupportedPackage(statement.moduleSpecifier.text))
|
||||
continue
|
||||
|
||||
const source = statement.moduleSpecifier.text
|
||||
const clause = statement.importClause
|
||||
if (!clause)
|
||||
continue
|
||||
|
||||
if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
|
||||
if (source.endsWith("/Component"))
|
||||
componentNamespaces.add(clause.namedBindings.name.text)
|
||||
else
|
||||
packageNamespaces.add(clause.namedBindings.name.text)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!clause.namedBindings || !ts.isNamedImports(clause.namedBindings))
|
||||
continue
|
||||
|
||||
for (const element of clause.namedBindings.elements) {
|
||||
const importedName = element.propertyName?.text ?? element.name.text
|
||||
if (source.endsWith("/Component") && (importedName === "make" || importedName === "makeUntraced"))
|
||||
directFactories.add(element.name.text)
|
||||
else if (!source.endsWith("/Component") && importedName === "Component")
|
||||
componentNamespaces.add(element.name.text)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
componentNamespaces,
|
||||
packageNamespaces,
|
||||
directFactories,
|
||||
}
|
||||
}
|
||||
|
||||
const isFactoryCallee = (
|
||||
expression: ts.Expression,
|
||||
imports: ComponentImports,
|
||||
): boolean => {
|
||||
if (ts.isIdentifier(expression))
|
||||
return imports.directFactories.has(expression.text)
|
||||
|
||||
if (!ts.isPropertyAccessExpression(expression))
|
||||
return false
|
||||
|
||||
if (expression.name.text !== "make" && expression.name.text !== "makeUntraced")
|
||||
return false
|
||||
|
||||
if (ts.isIdentifier(expression.expression))
|
||||
return imports.componentNamespaces.has(expression.expression.text)
|
||||
|
||||
return ts.isPropertyAccessExpression(expression.expression)
|
||||
&& expression.expression.name.text === "Component"
|
||||
&& ts.isIdentifier(expression.expression.expression)
|
||||
&& imports.packageNamespaces.has(expression.expression.expression.text)
|
||||
}
|
||||
|
||||
const functionArgument = (
|
||||
call: ts.CallExpression,
|
||||
): ts.FunctionLikeDeclaration | undefined => call.arguments.find(argument =>
|
||||
ts.isArrowFunction(argument)
|
||||
|| ts.isFunctionExpression(argument)
|
||||
) as ts.FunctionLikeDeclaration | undefined
|
||||
|
||||
const findFactoryCall = (
|
||||
root: ts.Node,
|
||||
imports: ComponentImports,
|
||||
): {
|
||||
readonly factoryCall: ts.CallExpression
|
||||
readonly body: ts.FunctionLikeDeclaration | undefined
|
||||
} | undefined => {
|
||||
let result: {
|
||||
readonly factoryCall: ts.CallExpression
|
||||
readonly body: ts.FunctionLikeDeclaration | undefined
|
||||
} | undefined
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (result)
|
||||
return
|
||||
|
||||
if (ts.isCallExpression(node)) {
|
||||
if (isFactoryCallee(node.expression, imports)) {
|
||||
result = {
|
||||
factoryCall: node,
|
||||
body: functionArgument(node),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (ts.isCallExpression(node.expression) && isFactoryCallee(node.expression.expression, imports)) {
|
||||
result = {
|
||||
factoryCall: node,
|
||||
body: functionArgument(node),
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(root)
|
||||
return result
|
||||
}
|
||||
|
||||
const isPipeline = (expression: ts.Expression): expression is ts.CallExpression =>
|
||||
ts.isCallExpression(expression)
|
||||
&& ts.isPropertyAccessExpression(expression.expression)
|
||||
&& expression.expression.name.text === "pipe"
|
||||
|
||||
const isEntrypoint = (expression: ts.Expression): boolean => {
|
||||
if (!ts.isCallExpression(expression))
|
||||
return false
|
||||
|
||||
const callee = expression.expression
|
||||
return ts.isPropertyAccessExpression(callee)
|
||||
&& (callee.name.text === "withRuntime" || callee.name.text === "withContext")
|
||||
}
|
||||
|
||||
const makeDefinition = (
|
||||
expression: ts.Expression,
|
||||
id: string,
|
||||
imports: ComponentImports,
|
||||
): Definition | undefined => {
|
||||
const factory = findFactoryCall(expression, imports)
|
||||
if (!factory)
|
||||
return undefined
|
||||
|
||||
const pipeline = isPipeline(expression) ? expression : undefined
|
||||
if (expression !== factory.factoryCall && !pipeline)
|
||||
return undefined
|
||||
|
||||
const entrypointIndex = pipeline
|
||||
? pipeline.arguments.findIndex(isEntrypoint)
|
||||
: -1
|
||||
|
||||
return {
|
||||
expression,
|
||||
factoryCall: factory.factoryCall,
|
||||
body: factory.body,
|
||||
id,
|
||||
pipeline,
|
||||
entrypointIndex,
|
||||
}
|
||||
}
|
||||
|
||||
const collectDefinitions = (
|
||||
sourceFile: ts.SourceFile,
|
||||
imports: ComponentImports,
|
||||
moduleId: string,
|
||||
): readonly Definition[] => {
|
||||
const definitions: Definition[] = []
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (ts.isVariableStatement(statement)) {
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (!declaration.initializer || !ts.isIdentifier(declaration.name))
|
||||
continue
|
||||
const definition = makeDefinition(
|
||||
declaration.initializer,
|
||||
`${moduleId}:${declaration.name.text}`,
|
||||
imports,
|
||||
)
|
||||
if (definition)
|
||||
definitions.push(definition)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (ts.isClassDeclaration(statement) && statement.name) {
|
||||
const heritage = statement.heritageClauses
|
||||
?.find(clause => clause.token === ts.SyntaxKind.ExtendsKeyword)
|
||||
?.types[0]
|
||||
?.expression
|
||||
if (!heritage)
|
||||
continue
|
||||
const definition = makeDefinition(
|
||||
heritage,
|
||||
`${moduleId}:${statement.name.text}`,
|
||||
imports,
|
||||
)
|
||||
if (definition)
|
||||
definitions.push(definition)
|
||||
continue
|
||||
}
|
||||
|
||||
if (ts.isExportAssignment(statement)) {
|
||||
const definition = makeDefinition(
|
||||
statement.expression,
|
||||
`${moduleId}:default`,
|
||||
imports,
|
||||
)
|
||||
if (definition)
|
||||
definitions.push(definition)
|
||||
}
|
||||
}
|
||||
|
||||
return definitions
|
||||
}
|
||||
|
||||
const hookSignature = (
|
||||
body: ts.FunctionLikeDeclaration | undefined,
|
||||
sourceFile: ts.SourceFile,
|
||||
): string => {
|
||||
if (!body?.body)
|
||||
return "unknown"
|
||||
|
||||
const hooks: string[] = []
|
||||
const root = body.body
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (node !== root && ts.isFunctionLike(node))
|
||||
return
|
||||
|
||||
if (ts.isCallExpression(node)) {
|
||||
const callee = node.expression
|
||||
const name = ts.isIdentifier(callee)
|
||||
? callee.text
|
||||
: ts.isPropertyAccessExpression(callee)
|
||||
? callee.name.text
|
||||
: undefined
|
||||
|
||||
if (name && /^use[A-Z0-9]/.test(name))
|
||||
hooks.push(node.getText(sourceFile))
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(root)
|
||||
return hash(hooks.join("\n"))
|
||||
}
|
||||
|
||||
const hash = (value: string): string => {
|
||||
let result = 0x811c9dc5
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
result ^= value.charCodeAt(index)
|
||||
result = Math.imul(result, 0x01000193)
|
||||
}
|
||||
return (result >>> 0).toString(36)
|
||||
}
|
||||
|
||||
const quote = (value: string): string => JSON.stringify(value)
|
||||
|
||||
const registration = (
|
||||
expression: string,
|
||||
definition: Definition,
|
||||
signature: string,
|
||||
forceReset: boolean,
|
||||
): string => `${refreshIdentifier}(${expression}, import.meta.hot, ${quote(definition.id)}, ${quote(signature)}, ${forceReset})`
|
||||
|
||||
const applyEdits = (code: string, edits: readonly Edit[]): string => {
|
||||
let output = code
|
||||
for (const edit of [...edits].sort((self, that) => that.start - self.start))
|
||||
output = output.slice(0, edit.start) + edit.content + output.slice(edit.end)
|
||||
return output
|
||||
}
|
||||
|
||||
const importPosition = (sourceFile: ts.SourceFile): number => {
|
||||
let position = 0
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (ts.isImportDeclaration(statement))
|
||||
position = statement.end
|
||||
else if (position > 0)
|
||||
break
|
||||
}
|
||||
return position
|
||||
}
|
||||
|
||||
const scriptKind = (id: string): ts.ScriptKind => id.endsWith(".tsx")
|
||||
? ts.ScriptKind.TSX
|
||||
: id.endsWith(".jsx")
|
||||
? ts.ScriptKind.JSX
|
||||
: id.endsWith(".js") || id.endsWith(".mjs") || id.endsWith(".cjs")
|
||||
? ts.ScriptKind.JS
|
||||
: ts.ScriptKind.TS
|
||||
|
||||
/**
|
||||
* Adds Fast Refresh support for Effect View components.
|
||||
*
|
||||
* Place this plugin before `@vitejs/plugin-react`.
|
||||
*/
|
||||
export default function effectViewRefresh(
|
||||
options: EffectViewRefreshOptions = {},
|
||||
): Plugin {
|
||||
const include = options.include ?? defaultInclude
|
||||
const exclude = options.exclude ?? defaultExclude
|
||||
const instrumentedModules = new Set<string>()
|
||||
|
||||
return {
|
||||
name: "effect-view:refresh",
|
||||
enforce: "pre",
|
||||
apply: "serve",
|
||||
transform(code, rawId) {
|
||||
const [id] = rawId.split("?", 1)
|
||||
if (!id)
|
||||
return null
|
||||
if (!include.test(id) || exclude.test(id))
|
||||
return null
|
||||
|
||||
const sourceFile = ts.createSourceFile(
|
||||
id,
|
||||
code,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
scriptKind(id),
|
||||
)
|
||||
const imports = collectImports(sourceFile)
|
||||
if (imports.componentNamespaces.size === 0
|
||||
&& imports.packageNamespaces.size === 0
|
||||
&& imports.directFactories.size === 0
|
||||
&& !instrumentedModules.has(id))
|
||||
return null
|
||||
|
||||
const moduleId = path.relative(process.cwd(), id).split(path.sep).join("/")
|
||||
const definitions = collectDefinitions(sourceFile, imports, moduleId)
|
||||
if (definitions.length === 0 && !instrumentedModules.has(id))
|
||||
return null
|
||||
instrumentedModules.add(id)
|
||||
|
||||
const forceReset = /@refresh\s+reset\b/.test(code)
|
||||
const edits: Edit[] = []
|
||||
|
||||
for (const definition of definitions) {
|
||||
const signature = hookSignature(definition.body, sourceFile)
|
||||
|
||||
if (definition.pipeline && definition.entrypointIndex >= 0) {
|
||||
const entrypoint = definition.pipeline.arguments[definition.entrypointIndex]
|
||||
if (!entrypoint)
|
||||
continue
|
||||
edits.push({
|
||||
start: entrypoint.getStart(sourceFile),
|
||||
end: entrypoint.getStart(sourceFile),
|
||||
content: `(${refreshIdentifier}View) => ${registration(
|
||||
`${refreshIdentifier}View`,
|
||||
definition,
|
||||
signature,
|
||||
forceReset,
|
||||
)}, `,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const start = definition.expression.getStart(sourceFile)
|
||||
const end = definition.expression.end
|
||||
edits.push({
|
||||
start,
|
||||
end,
|
||||
content: registration(
|
||||
code.slice(start, end),
|
||||
definition,
|
||||
signature,
|
||||
forceReset,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const position = importPosition(sourceFile)
|
||||
edits.push({
|
||||
start: position,
|
||||
end: position,
|
||||
content: `${position === 0 ? "" : "\n"}import { accept as ${acceptIdentifier}, register as ${refreshIdentifier} } from ${quote(runtimeImport)};\n`,
|
||||
})
|
||||
|
||||
const transformed = applyEdits(code, edits)
|
||||
const definitionIds = definitions.map(definition => quote(definition.id)).join(", ")
|
||||
return {
|
||||
code: `${transformed}\n${acceptIdentifier}(import.meta.hot, [${definitionIds}])\n`,
|
||||
map: null,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as Refreshable from "effect-fc-next/Refreshable"
|
||||
|
||||
export interface HotContext {
|
||||
readonly data: Record<string, unknown>
|
||||
accept(): void
|
||||
invalidate(message?: string): void
|
||||
}
|
||||
|
||||
interface RefreshData {
|
||||
readonly cells: Map<string, Refreshable.Cell<any>>
|
||||
ids?: readonly string[]
|
||||
}
|
||||
|
||||
const hotDataKey = "__effectFcRefresh"
|
||||
|
||||
const getRefreshData = (hot: HotContext): RefreshData => {
|
||||
const data = hot.data as Record<string, unknown>
|
||||
const current = data[hotDataKey] as RefreshData | undefined
|
||||
if (current)
|
||||
return current
|
||||
|
||||
const refreshData: RefreshData = {
|
||||
cells: new Map(),
|
||||
}
|
||||
data[hotDataKey] = refreshData
|
||||
return refreshData
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an Effect View descriptor in a Vite HMR data cell.
|
||||
*
|
||||
* This API is injected by `@effect-view/vite`; applications should not need to
|
||||
* call it directly.
|
||||
*/
|
||||
export const register = <A extends object>(
|
||||
component: A,
|
||||
hot: HotContext | undefined,
|
||||
id: string,
|
||||
signature: string,
|
||||
forceReset = false,
|
||||
): A => {
|
||||
if (!hot)
|
||||
return component
|
||||
|
||||
const refreshData = getRefreshData(hot)
|
||||
const previous = refreshData.cells.get(id)
|
||||
if (!previous) {
|
||||
const cell = Refreshable.makeCell(component, signature, forceReset)
|
||||
refreshData.cells.set(id, cell)
|
||||
return Refreshable.attach(component, cell)
|
||||
}
|
||||
|
||||
previous.update(component, signature, forceReset)
|
||||
return Refreshable.attach(component, previous)
|
||||
}
|
||||
|
||||
const sameIds = (
|
||||
self: readonly string[],
|
||||
that: readonly string[],
|
||||
): boolean => self.length === that.length
|
||||
&& self.every((id, index) => id === that[index])
|
||||
|
||||
/**
|
||||
* Accepts an instrumented module when its Effect View definition set is
|
||||
* unchanged. Renames and removals invalidate upward so a stale cell cannot
|
||||
* remain mounted indefinitely.
|
||||
*/
|
||||
export const accept = (
|
||||
hot: HotContext | undefined,
|
||||
ids: readonly string[],
|
||||
): void => {
|
||||
if (!hot)
|
||||
return
|
||||
|
||||
const refreshData = getRefreshData(hot)
|
||||
const previousIds = refreshData.ids
|
||||
refreshData.ids = ids
|
||||
|
||||
if (previousIds && !sameIds(previousIds, ids)) {
|
||||
hot.invalidate("[effect-view] Effect View exports changed")
|
||||
return
|
||||
}
|
||||
|
||||
hot.accept()
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"moduleResolution": "NodeNext",
|
||||
"verbatimModuleSyntax": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["./src"],
|
||||
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user