@@ -13,7 +13,7 @@ The current API has two identities:
|
||||
|
||||
Vite's React Refresh transform sees the descriptor definition, while React reconciles the synthesized function. In addition, `.use` deliberately caches that function. A newly evaluated module therefore creates a new descriptor, but the mounted function continues to close over the old descriptor.
|
||||
|
||||
The recommended implementation is a small development-only runtime bridge plus a Vite compiler plugin. The plugin should assign stable source IDs and hook signatures to Effect View definitions. The runtime should retain a hot cell for each definition, notify mounted instances after an update, preserve state when the signature is compatible, and remount when it is not.
|
||||
The recommended implementation is a bundler-neutral development refresh protocol in Effect View plus a Vite compiler adapter. The plugin should assign stable source IDs and hook signatures to Effect View definitions. The main library's protocol should retain the current descriptor, notify mounted instances after an update, preserve state when the signature is compatible, and remount when it is not. The Vite adapter should retain those cells in `import.meta.hot.data` and manage accept/invalidate behavior.
|
||||
|
||||
My feasibility assessment is:
|
||||
|
||||
@@ -26,16 +26,13 @@ A production-quality Vite implementation is likely **10–18 engineer-days**, in
|
||||
|
||||
## Scope examined
|
||||
|
||||
The findings apply to both implementations in this repository:
|
||||
|
||||
- `effect-fc` (Effect 3)
|
||||
- `effect-fc-next` (Effect 4 beta)
|
||||
|
||||
The two packages differ in Effect runtime details, but their React component identity model is the same. The examples also use the same Vite 8 and `@vitejs/plugin-react` 6 setup.
|
||||
The implementation target is `effect-fc-next` (Effect 4 beta), which is
|
||||
expected to become `effect-view`. The legacy `effect-fc` package is not
|
||||
supported by the Vite plugin.
|
||||
|
||||
## How Effect View rendering works today
|
||||
|
||||
`Component.make` creates a function-shaped descriptor with a `body` property and `ComponentPrototype`; it does not create the React component that will be mounted. See [`packages/effect-fc/src/Component.ts`](packages/effect-fc/src/Component.ts), around `make`, `makeUntraced`, and `ComponentImplPrototype`.
|
||||
`Component.make` creates a function-shaped descriptor with a `body` property and `ComponentPrototype`; it does not create the React component that will be mounted. See [`packages/effect-fc-next/src/Component.ts`](packages/effect-fc-next/src/Component.ts), around `make`, `makeUntraced`, and `ComponentImplPrototype`.
|
||||
|
||||
The relevant path is:
|
||||
|
||||
@@ -48,14 +45,10 @@ source definition
|
||||
-> mounted React fiber
|
||||
```
|
||||
|
||||
In `effect-fc`:
|
||||
|
||||
- `asFunctionComponent` creates a closure over `this` and `runtimeRef`.
|
||||
- The closure invokes `this.body(props)`.
|
||||
- `.use` stores a cached function in `React.useState` and keys its internal cache by reactive Effect services.
|
||||
- `withRuntime` renders the function returned by `.use`.
|
||||
|
||||
In `effect-fc-next`, `.use` uses `componentRef` and `previousServicesRef` instead of `useState` plus `Effect.cachedFunction`, but it still retains the previously synthesized function when service identities are unchanged.
|
||||
In `effect-fc-next`, `asFunctionComponent` creates a closure over the descriptor
|
||||
and Effect context. `.use` retains that synthesized function in `componentRef`
|
||||
while the relevant Effect service identities remain unchanged, and
|
||||
`withContext` renders the function returned by `.use`.
|
||||
|
||||
This caching is useful during ordinary rendering: without it, React would see a new component type and remount on every parent render. It is also why a newly evaluated module cannot replace the mounted implementation by itself.
|
||||
|
||||
@@ -93,7 +86,9 @@ Consequently, edits currently propagate through ordinary Vite HMR invalidation.
|
||||
|
||||
## Runtime experiment
|
||||
|
||||
I ran a focused jsdom/React experiment against `effect-fc`:
|
||||
The initial feasibility study used the legacy implementation for a focused
|
||||
jsdom/React identity experiment. Its descriptor-caching result also applies to
|
||||
`effect-fc-next`, but the legacy package is not an implementation target:
|
||||
|
||||
1. Render an Effect View containing `React.useState(0)`.
|
||||
2. Increment it to `old:1`.
|
||||
@@ -135,7 +130,7 @@ A complete implementation should provide the same safety contract developers exp
|
||||
|
||||
### 1. Add an Effect View refresh transform
|
||||
|
||||
Ship a Vite plugin, for example `@effect-fc/vite`, placed before `react()`:
|
||||
Ship a Vite plugin, for example `@effect-view/vite`, placed before `react()`:
|
||||
|
||||
```ts
|
||||
plugins: [
|
||||
@@ -150,7 +145,7 @@ It should recognize:
|
||||
- `Component.makeUntraced(...)`
|
||||
- class declarations extending either factory result
|
||||
- definitions followed by Effect `pipe` transformations
|
||||
- aliased imports from both `effect-fc` and `effect-fc-next`
|
||||
- aliased imports from `effect-fc-next`
|
||||
|
||||
For each definition it should inject development-only metadata containing:
|
||||
|
||||
@@ -163,9 +158,9 @@ Detection must be binding-aware rather than matching text. Otherwise unrelated `
|
||||
|
||||
The plugin must instrument local Views as well as exports. React Refresh works on component families, not only refresh-boundary exports, and most nested Views in this repository are consumed through `.use`.
|
||||
|
||||
### 2. Add a development-only hot cell
|
||||
### 2. Add a development-only hot cell protocol to Effect View
|
||||
|
||||
The component metadata should connect to a cell retained in `import.meta.hot.data`:
|
||||
The main library should expose the bundler-neutral cell contract from its `Refreshable` module and the component shell should consume it directly:
|
||||
|
||||
```ts
|
||||
interface HotViewCell {
|
||||
@@ -181,6 +176,8 @@ On module reevaluation, the new descriptor updates `cell.current`. If the signat
|
||||
|
||||
Keeping the cell rather than only mutating the old descriptor is important for class inheritance and traits. Class-style Views inherit `body` and options through the generated base class, while memoized and async Views alter prototype behavior. A cell can point to the complete new descriptor without attempting to copy an unknown prototype graph onto the old one.
|
||||
|
||||
Bundler integrations should depend on this public protocol rather than define a structurally duplicated symbol and cell. The Vite adapter owns only Vite-specific storage in `import.meta.hot.data`, registration IDs, and accept/invalidate decisions.
|
||||
|
||||
### 3. Split the development component into a stable shell and keyed implementation
|
||||
|
||||
In development, the function materialized by `.use` should become a stable shell:
|
||||
@@ -258,7 +255,7 @@ Exit criterion: editing render text updates without a page reload, and incompati
|
||||
### Phase 1: Vite MVP (4–7 additional days)
|
||||
|
||||
- Implement binding-aware AST detection for all public construction styles.
|
||||
- Add stable shell/keyed implementation support to both Component implementations, or share the dev bridge in a small internal module.
|
||||
- Add stable shell/keyed implementation support to the supported `effect-fc-next` Component implementation.
|
||||
- Cover memoized and async traits.
|
||||
- Add safe self-accept/fallback behavior.
|
||||
- Add browser integration tests against the example app.
|
||||
@@ -277,7 +274,7 @@ Exit criterion: the behavior matches React Fast Refresh closely enough that stat
|
||||
|
||||
### Later: other bundlers
|
||||
|
||||
Once the runtime metadata protocol is stable, adapters for webpack/Rspack, Rolldown, or other environments can emit the same metadata. Building those in parallel with the first Vite version would enlarge the test matrix before the semantics have settled.
|
||||
Because the main Effect View library owns the runtime metadata protocol, adapters for webpack/Rspack, Rolldown, or other environments can emit the same metadata without depending on Vite. Building those in parallel with the first Vite version would enlarge the test matrix before the semantics have settled.
|
||||
|
||||
## Test matrix
|
||||
|
||||
@@ -298,18 +295,11 @@ Minimum cases:
|
||||
- Async View and Suspense/error recovery.
|
||||
- Two materializations of the same View under different Effect contexts.
|
||||
- Reactive and `nonReactiveTags` service changes.
|
||||
- TanStack Router code splitting used by both example applications.
|
||||
- TanStack Router code splitting used by the `example-next` application.
|
||||
- Successive edits before the previous refresh finishes.
|
||||
- Syntax error followed by recovery.
|
||||
- Production build contains no HMR registry/subscription code.
|
||||
|
||||
## Repository test status during this study
|
||||
|
||||
- `packages/effect-fc`: `bun run test` passed all 20 tests.
|
||||
- `packages/effect-fc-next`: the existing suite was not green before any implementation work: 4 Component tests failed because mount/cached values were observed twice, and `Query.test.ts` could not resolve the absent `src/Result.js`. The remaining 9 tests passed. These failures are unrelated to this report, but a clean baseline or documented exclusions will be needed before implementing refresh support in the beta package.
|
||||
|
||||
The Vite development server also regenerated `packages/example/src/routeTree.gen.ts` with ordering-only changes while inspecting transforms; that generated file was restored, so this report is the only intended worktree change.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
### Hook signature ownership
|
||||
@@ -326,7 +316,7 @@ A stable shell plus keyed implementation adds one fiber in development. This is
|
||||
|
||||
### React/Vite version coupling
|
||||
|
||||
The current plugin stack uses Vite's OXC refresh transform and a bundled/simplified React Refresh runtime. Depending on private runtime functions or generated transform text would make the feature sensitive to Vite upgrades. A standalone Effect View metadata protocol minimizes that coupling.
|
||||
The current plugin stack uses Vite's OXC refresh transform and a bundled/simplified React Refresh runtime. Depending on private runtime functions or generated transform text would make the feature sensitive to Vite upgrades. The main Effect View package should therefore own the standalone metadata protocol, while the Vite plugin depends on and adapts it.
|
||||
|
||||
### Multiple materializations
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"react-icons": "^5.6.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect-view/vite": "workspace:*",
|
||||
"@tanstack/react-router": "^1.170.10",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@tanstack/router-plugin": "^1.168.13",
|
||||
@@ -123,6 +124,22 @@
|
||||
"vite": "^8.0.16",
|
||||
},
|
||||
},
|
||||
"packages/vite": {
|
||||
"name": "@effect-view/vite",
|
||||
"version": "0.0.0",
|
||||
"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",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@algolia/abtesting": ["@algolia/abtesting@1.22.0", "", { "dependencies": { "@algolia/client-common": "5.56.0", "@algolia/requester-browser-xhr": "5.56.0", "@algolia/requester-fetch": "5.56.0", "@algolia/requester-node-http": "5.56.0" } }, "sha512-BFR6zNowNKcY7Ou7TaJc9QWexES4YKPbmf/OTFofpdsdhz4x6q0lbxp3duO0EHnyrN7rE4ba/TSXuY+BDGu4+g=="],
|
||||
@@ -557,6 +574,8 @@
|
||||
|
||||
"@effect-fc/example-next": ["@effect-fc/example-next@workspace:packages/example-next"],
|
||||
|
||||
"@effect-view/vite": ["@effect-view/vite@workspace:packages/vite"],
|
||||
|
||||
"@effect/language-service": ["@effect/language-service@0.86.6", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-uwXbp+lWzt60bZnm6lG6S5DWy0m6TthUzV2hcQoiS6NzieEDm07tuBakscxOXoO+AU9+yTH+0TSIa9mEDt9dFA=="],
|
||||
|
||||
"@effect/platform": ["@effect/platform@0.96.3", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.10", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.21.5" } }, "sha512-LzvIj4HYE++TcTv/cVTCI/GRvQTV0ymwRmgjZMzNB5dU4OS05pTN36RKN0npiOm5orLJgw4yjIv1dNZoYGh/vg=="],
|
||||
|
||||
@@ -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