2 Commits
Author SHA1 Message Date
ThilawynandJulien Valverdé 19ae5a366d Redesign docs and upgrade effect-lens/effect (#77)
Publish / publish (push) Successful in 2m1s
Lint / lint (push) Successful in 26s
## Summary
- Redesign the docs site (landing page, code block styling, syntax highlighting, line numbers)
- Upgrade `effect-lens` to `2.0.2-rc.115` and the associated `effect`/`@effect/platform-browser` to `4.0.0-rc.115`
- Bump package version

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #77
2026-09-16 22:07:21 +02:00
ThilawynandJulien Valverdé 0e2b4c1951 Improve effect-view docs, mutation keys, and Fast Refresh (#76)
Publish / publish (push) Successful in 2m6s
Lint / lint (push) Successful in 1m3s
## Summary

- Add comprehensive AI-oriented documentation for effect-view components, state, async rendering, queries, mutations, forms, streams, and runtime setup.
- Fix `Mutation.mutate` so each invocation consistently uses its own key instead of reusing the previous mutation key.
- Improve Vite Fast Refresh instrumentation for:
  - User-defined component wrappers.
  - Data-first and pipeline-based `withContext`/`withRuntime` entrypoints.
  - Nested function handling.
  - Stable hook signatures that preserve state for non-hook-related edits.
- Add regression tests for mutation keys and refresh behavior.
- Bump `effect-view` to `0.1.5` and `@effect-view/vite-plugin` to `0.0.2`.

## Testing

- `bun run --cwd packages/effect-view test -- src/Mutation.test.ts`
  - 4 tests passed
- `bun run --cwd packages/vite-plugin test -- src/plugin.test.ts`
  - 11 tests passed

The full test suite was not run.

---------

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #76
2026-08-26 01:06:03 +02:00
26 changed files with 1657 additions and 527 deletions
+13 -27
View File
@@ -82,21 +82,21 @@
},
"packages/effect-view": {
"name": "effect-view",
"version": "0.1.3",
"version": "0.1.5",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"effect-lens": "2.0.1-rc.109",
"effect-lens": "2.0.2-rc.115",
},
"devDependencies": {
"@effect/platform-browser": "4.0.0-rc.109",
"@effect/platform-browser": "4.0.0-rc.115",
"@testing-library/react": "^16.3.0",
"effect": "4.0.0-rc.109",
"effect": "4.0.0-rc.115",
"jsdom": "^26.1.0",
"vitest": "^3.2.4",
},
"peerDependencies": {
"@types/react": "^19.2.0",
"effect": "4.0.0-rc.109",
"effect": "4.0.0-rc.115",
"react": "^19.2.0",
},
},
@@ -104,9 +104,9 @@
"name": "@effect-view/example",
"version": "0.0.0",
"dependencies": {
"@effect/platform-browser": "4.0.0-rc.109",
"@effect/platform-browser": "4.0.0-rc.115",
"@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-rc.109",
"effect": "4.0.0-rc.115",
"effect-view": "workspace:*",
"react-icons": "^5.6.0",
},
@@ -126,7 +126,7 @@
},
"packages/vite-plugin": {
"name": "@effect-view/vite-plugin",
"version": "0.0.1",
"version": "0.0.2",
"dependencies": {
"typescript": "^6.0.3",
},
@@ -3030,8 +3030,6 @@
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
@@ -3062,9 +3060,9 @@
"@docusaurus/utils/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"@effect-view/example/@effect/platform-browser": ["@effect/platform-browser@4.0.0-rc.109", "", { "peerDependencies": { "effect": "^4.0.0-rc.109" } }, "sha512-63/hM2dCh0HQb7sRkAhAoZjO4swjRD2vT/eUrs8nXA0LzJMz52ChPsxdnL1D0yg+ZeXtMHQM0MFJxITJXjv+EA=="],
"@effect-view/example/@effect/platform-browser": ["@effect/platform-browser@4.0.0-rc.115", "", { "peerDependencies": { "effect": "^4.0.0-rc.115" } }, "sha512-RwpnxRYSBHBL1HYYlRwIrwz0+1hwmn5RvgqkKI0Tb+ChOIR9UCMpXsAhnwQcGP+mBidrC7dlloL42poGHZW4yw=="],
"@effect-view/example/effect": ["effect@4.0.0-rc.109", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.4" } }, "sha512-6ubcOCtfdbmFO5+vgcT2HsTw5s+n3aMUj4eAIbVpUxP7+VYCwXxxcBHgiWgizOrGO1eGmuOBFek3mM0dFcwaWA=="],
"@effect-view/example/effect": ["effect@4.0.0-rc.115", "", {}, "sha512-ogYulZ5ffeOzrJqQrG0XOkDO5lKn2s9KNHhoxJy/6wKUkF0i12dlVXWA9TgsgQ+zV/JzizyG0+XMPepZEQX6vw=="],
"@effect-view/vite-plugin/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
@@ -3132,11 +3130,11 @@
"dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
"effect-view/@effect/platform-browser": ["@effect/platform-browser@4.0.0-rc.109", "", { "peerDependencies": { "effect": "^4.0.0-rc.109" } }, "sha512-63/hM2dCh0HQb7sRkAhAoZjO4swjRD2vT/eUrs8nXA0LzJMz52ChPsxdnL1D0yg+ZeXtMHQM0MFJxITJXjv+EA=="],
"effect-view/@effect/platform-browser": ["@effect/platform-browser@4.0.0-rc.115", "", { "peerDependencies": { "effect": "^4.0.0-rc.115" } }, "sha512-RwpnxRYSBHBL1HYYlRwIrwz0+1hwmn5RvgqkKI0Tb+ChOIR9UCMpXsAhnwQcGP+mBidrC7dlloL42poGHZW4yw=="],
"effect-view/effect": ["effect@4.0.0-rc.109", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.4" } }, "sha512-6ubcOCtfdbmFO5+vgcT2HsTw5s+n3aMUj4eAIbVpUxP7+VYCwXxxcBHgiWgizOrGO1eGmuOBFek3mM0dFcwaWA=="],
"effect-view/effect": ["effect@4.0.0-rc.115", "", {}, "sha512-ogYulZ5ffeOzrJqQrG0XOkDO5lKn2s9KNHhoxJy/6wKUkF0i12dlVXWA9TgsgQ+zV/JzizyG0+XMPepZEQX6vw=="],
"effect-view/effect-lens": ["effect-lens@2.0.1-rc.109", "", { "peerDependencies": { "effect": "4.0.0-rc.109" } }, "sha512-rMluFV/QGvLyXWoLu7YGaEUus/E40LeUXB0bAnzR3yvF1euch0g/gVISYuGlu+mvUahzBEH//+QNi45/0IwfTQ=="],
"effect-view/effect-lens": ["effect-lens@2.0.2-rc.115", "", { "peerDependencies": { "effect": "4.0.0-rc.115" } }, "sha512-7Vmtcce7HvAuIiSkQN1mdlFLXohY2B858CS8toEXzx0fCCsRBoCi/jCM0J/D00Q0j9OLP7QBMGv15hjVkppbkg=="],
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
@@ -3404,10 +3402,6 @@
"@docusaurus/core/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"@effect-view/example/effect/fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
"@effect-view/example/effect/msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
"@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack/@jsonjoy.com/base64": ["@jsonjoy.com/base64@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw=="],
"@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack/@jsonjoy.com/codegen": ["@jsonjoy.com/codegen@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q=="],
@@ -3436,10 +3430,6 @@
"css-select/domutils/dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="],
"effect-view/effect/fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
"effect-view/effect/msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"file-loader/schema-utils/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
@@ -3498,12 +3488,8 @@
"@docusaurus/core/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"@effect-view/example/effect/fast-check/pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
"css-select/domutils/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
"effect-view/effect/fast-check/pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
"file-loader/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"null-loader/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
+6 -1
View File
@@ -2,6 +2,9 @@ import type * as Preset from "@docusaurus/preset-classic"
import type { Config } from "@docusaurus/types"
import { themes as prismThemes } from "prism-react-renderer"
import githubDark from "./src/prism/github-dark"
import remarkLineNumbers from "./src/remark/line-numbers"
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
const config: Config = {
@@ -43,6 +46,7 @@ const config: Config = {
sidebarPath: "./sidebars.ts",
editUrl:
"https://github.com/Thiladev/effect-view/tree/main/packages/docs/",
remarkPlugins: [remarkLineNumbers],
lastVersion: "current",
versions: {
current: {
@@ -65,6 +69,7 @@ const config: Config = {
themeConfig: {
// Replace with your project's social card
colorMode: {
defaultMode: "dark",
respectPrefersColorScheme: true,
},
navbar: {
@@ -139,7 +144,7 @@ const config: Config = {
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
darkTheme: githubDark,
},
} satisfies Preset.ThemeConfig,
}
+198 -27
View File
@@ -1,37 +1,208 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,400;0,14..32,500;0,14..32,600;0,14..32,700;0,14..32,800;1,14..32,400&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap");
:root {
--ifm-color-primary: #0b6f74;
--ifm-color-primary-dark: #096469;
--ifm-color-primary-darker: #085e63;
--ifm-color-primary-darkest: #074d51;
--ifm-color-primary-light: #0d7a7f;
--ifm-color-primary-lighter: #0e8085;
--ifm-color-primary-lightest: #109096;
--ifm-background-color: #fffaf1;
--ifm-code-font-size: 95%;
--ifm-color-primary: #059669;
--ifm-color-primary-dark: #04835d;
--ifm-color-primary-darker: #047857;
--ifm-color-primary-darkest: #036348;
--ifm-color-primary-light: #0aa876;
--ifm-color-primary-lighter: #10b981;
--ifm-color-primary-lightest: #34d399;
--ifm-background-color: #ffffff;
--ifm-background-surface-color: #ffffff;
--ifm-navbar-background-color: #ffffff;
--ifm-footer-background-color: #09090b;
--ifm-code-font-size: 90%;
--ifm-font-family-base:
"Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
--docusaurus-highlighted-code-line-bg: rgba(11, 111, 116, 0.1);
"Inter", "Segoe UI", "Helvetica Neue", sans-serif;
--ifm-font-family-monospace:
"JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo,
monospace;
--ifm-heading-font-weight: 700;
--ifm-heading-letter-spacing: -0.02em;
--ifm-menu-color-background-active: rgba(5, 150, 105, 0.08);
--ifm-menu-color-background-hover: rgba(5, 150, 105, 0.06);
--docusaurus-highlighted-code-line-bg: rgba(5, 150, 105, 0.1);
--ev-zinc-50: #fafafa;
--ev-zinc-100: #f4f4f5;
--ev-zinc-200: #e4e4e7;
--ev-zinc-300: #d4d4d8;
--ev-zinc-400: #a1a1aa;
--ev-zinc-500: #71717a;
--ev-zinc-600: #52525b;
--ev-zinc-700: #3f3f46;
--ev-zinc-800: #27272a;
--ev-zinc-900: #18181b;
--ev-zinc-950: #09090b;
}
.button--primary {
--ifm-button-background-color: #0b6f74;
--ifm-button-border-color: #0b6f74;
html {
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
.button--secondary {
--ifm-button-background-color: #f4a636;
--ifm-button-border-color: #f4a636;
--ifm-button-color: #1f2526;
code,
pre,
kbd,
samp {
font-family: var(--ifm-font-family-monospace);
}
.navbar {
border-bottom: 1px solid var(--ev-zinc-200);
box-shadow: none;
}
.navbar__item {
font-family: var(--ifm-font-family-monospace);
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.navbar__brand {
font-weight: 800;
letter-spacing: -0.02em;
}
[data-theme="dark"] {
--ifm-color-primary: #7ad6d4;
--ifm-color-primary-dark: #5cccca;
--ifm-color-primary-darker: #4cc6c4;
--ifm-color-primary-darkest: #34aaa8;
--ifm-color-primary-light: #98e0de;
--ifm-color-primary-lighter: #a8e6e4;
--ifm-color-primary-lightest: #d5f4f3;
--ifm-background-color: #111c1f;
--docusaurus-highlighted-code-line-bg: rgba(122, 214, 212, 0.18);
--ifm-color-primary: #34d399;
--ifm-color-primary-dark: #1fcc8c;
--ifm-color-primary-darker: #10b981;
--ifm-color-primary-darkest: #0a9c6d;
--ifm-color-primary-light: #52dbab;
--ifm-color-primary-lighter: #6ee7b7;
--ifm-color-primary-lightest: #a7f3d0;
--ifm-background-color: #09090b;
--ifm-background-surface-color: #0c0c0f;
--ifm-navbar-background-color: #09090bee;
--ifm-toc-border-color: var(--ev-zinc-800);
--ifm-table-border-color: var(--ev-zinc-800);
--ifm-menu-color-background-active: rgba(52, 211, 153, 0.1);
--ifm-menu-color-background-hover: rgba(52, 211, 153, 0.06);
--docusaurus-highlighted-code-line-bg: rgba(52, 211, 153, 0.16);
}
[data-theme="dark"] .navbar {
border-bottom: 1px solid var(--ev-zinc-800);
backdrop-filter: blur(10px);
}
[data-theme="dark"] .footer {
--ifm-footer-color: var(--ev-zinc-400);
--ifm-footer-link-color: var(--ev-zinc-300);
--ifm-footer-title-color: var(--ev-zinc-100);
border-top: 1px solid var(--ev-zinc-900);
}
.footer {
font-family: var(--ifm-font-family-monospace);
}
.footer__title {
font-size: 0.78rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.footer__link-item {
font-size: 0.85rem;
}
.button--primary {
--ifm-button-background-color: var(--ifm-color-primary);
--ifm-button-border-color: var(--ifm-color-primary);
}
.menu__link--active,
.table-of-contents__link--active {
font-weight: 600;
}
[data-theme="dark"] .DocSearch-Button {
background: var(--ev-zinc-900);
border: 1px solid var(--ev-zinc-800);
}
/* ---------- Code blocks ---------- */
/* One quiet, low-contrast surface - matches effect.website: a subtle step
off the page background, not a loud bordered card. */
.theme-code-block {
--ifm-pre-padding: 1.25rem;
background: var(--ev-zinc-50);
border: 1px solid var(--ev-zinc-200);
border-radius: 0.5rem;
box-shadow: 1.5px 1.5px 3px rgba(0, 0, 0, 0.12);
}
[data-theme="dark"] .theme-code-block {
background: var(--ev-zinc-900);
border-color: var(--ev-zinc-800);
}
/* The Prism theme sets its own (much louder) background inline - drop it
so the whole frame reads as one surface instead of a title/body seam. */
.theme-code-block pre.thin-scrollbar {
background: transparent !important;
}
/* Title renders only when a `title="..."` fence meta is present */
.theme-code-block:has(> div + div) > div:first-child {
background: transparent;
color: var(--ev-zinc-500);
font-family: var(--ifm-font-family-monospace);
font-size: 0.78rem;
font-weight: 500;
padding: 0.9rem 1.4rem 0;
}
.theme-code-block pre.thin-scrollbar {
font-size: 0.85rem;
line-height: 1.7;
}
[class*="buttonGroup_"] button {
background: transparent;
border: none;
border-radius: 0.35rem;
color: var(--ev-zinc-500);
}
[class*="buttonGroup_"] button:hover {
background: var(--ev-zinc-100);
color: var(--ev-zinc-800);
}
[data-theme="dark"] [class*="buttonGroup_"] button:hover {
background: var(--ev-zinc-900);
color: var(--ev-zinc-100);
}
[class*="codeLineNumber_"] {
border: none;
color: var(--ev-zinc-400);
padding-inline: 1.25rem 1rem !important;
text-align: right !important;
}
[data-theme="dark"] [class*="codeLineNumber_"] {
color: var(--ev-zinc-600);
}
[class*="codeLineContent_"] {
border-inline-start: 1px solid rgba(113, 113, 122, 0.2);
padding-inline-start: 1.25rem;
}
[class*="codeLineNumber_"]::before {
opacity: 1 !important;
}
+308 -354
View File
@@ -1,223 +1,265 @@
.page {
--home-ink: #102f31;
--home-muted: #506a6c;
--home-teal: #08777b;
--home-teal-bright: #16a3a1;
--home-amber: #e99526;
--home-paper: #fffdf8;
--home-card: rgba(255, 255, 255, 0.76);
--home-line: rgba(16, 47, 49, 0.12);
min-height: calc(100vh - var(--ifm-navbar-height));
--home-ink: #09090b;
--home-muted: #52525b;
--home-emerald: #059669;
--home-emerald-bright: #10b981;
--home-paper: #ffffff;
--home-card: #ffffff;
--home-line: #e4e4e7;
--home-card-hover-line: rgba(5, 150, 105, 0.4);
--home-terminal-bg: #09090b;
--home-terminal-line: #27272a;
background: var(--home-paper);
color: var(--home-ink);
overflow: hidden;
position: relative;
background:
radial-gradient(circle at 8% 5%, rgba(14, 145, 148, 0.2), transparent 28rem),
radial-gradient(circle at 92% 14%, rgba(240, 159, 48, 0.2), transparent 27rem),
linear-gradient(145deg, #f7f2e7 0%, #edf8f6 48%, #fff8ed 100%);
color: var(--home-ink);
}
:global([data-theme="dark"]) .page {
--home-ink: #f4f4f5;
--home-muted: #a1a1aa;
--home-emerald: #34d399;
--home-emerald-bright: #6ee7b7;
--home-paper: #09090b;
--home-card: #0c0c0f;
--home-line: #27272a;
--home-card-hover-line: rgba(52, 211, 153, 0.45);
--home-terminal-bg: #101012;
--home-terminal-line: #27272a;
}
/* ---------- Hero ---------- */
.hero {
overflow: hidden;
padding: 5.5rem 0 4rem;
position: relative;
}
.gridBackdrop {
background-image:
linear-gradient(rgba(16, 47, 49, 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(16, 47, 49, 0.045) 1px, transparent 1px);
background-size: 48px 48px;
linear-gradient(var(--home-line) 1px, transparent 1px),
linear-gradient(90deg, var(--home-line) 1px, transparent 1px);
background-size: 56px 56px;
inset: 0;
mask-image: linear-gradient(to bottom, black, transparent 62%);
mask-image: radial-gradient(ellipse 65% 55% at 50% 0%, black, transparent);
opacity: 0.6;
pointer-events: none;
position: absolute;
}
.hero {
.heroGlow {
background: radial-gradient(
ellipse 60% 55% at 50% -10%,
rgba(16, 185, 129, 0.22),
transparent 70%
);
height: 32rem;
inset: 0 0 auto 0;
pointer-events: none;
position: absolute;
}
.heroGrid {
align-items: center;
display: grid;
gap: clamp(3rem, 6vw, 6rem);
grid-template-columns: minmax(0, 0.88fr) minmax(560px, 1.12fr);
min-height: min(820px, calc(100vh - var(--ifm-navbar-height)));
padding-bottom: 6rem;
padding-top: 6rem;
gap: clamp(2.5rem, 5vw, 5rem);
grid-template-columns: minmax(0, 0.92fr) minmax(500px, 1.08fr);
position: relative;
z-index: 1;
}
.heroCopy {
max-width: 650px;
max-width: 600px;
}
.eyebrow,
.sectionKicker {
.eyebrow {
align-items: center;
color: var(--home-teal);
display: flex;
font-size: 0.76rem;
font-weight: 850;
gap: 0.65rem;
letter-spacing: 0.18em;
margin: 0 0 1.35rem;
border: 1px solid var(--home-line);
border-radius: 999px;
color: var(--home-muted);
display: inline-flex;
font-family: var(--ifm-font-family-monospace);
font-size: 0.78rem;
font-weight: 500;
gap: 0.55rem;
letter-spacing: 0.05em;
margin: 0 0 1.75rem;
padding: 0.45rem 1rem;
text-decoration: none !important;
text-transform: uppercase;
transition: border-color 160ms ease, color 160ms ease;
}
.pulse {
background: var(--home-teal-bright);
border-radius: 50%;
box-shadow: 0 0 0 5px rgba(22, 163, 161, 0.14);
height: 0.55rem;
width: 0.55rem;
.eyebrow:hover {
border-color: var(--home-card-hover-line);
color: var(--home-ink);
}
.eyebrowArrow {
color: var(--home-emerald);
transition: transform 160ms ease;
}
.eyebrow:hover .eyebrowArrow {
transform: translateX(2px);
}
.hero h1 {
color: var(--home-ink);
font-size: clamp(3.4rem, 6.4vw, 6.4rem);
font-weight: 780;
letter-spacing: -0.075em;
line-height: 0.92;
font-size: clamp(2.6rem, 4.6vw, 3.9rem);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.06;
margin: 0;
overflow-wrap: normal;
word-break: normal;
}
.hero h1 .unbreakable {
white-space: nowrap;
}
.hero h1 .heroAccent {
background: linear-gradient(105deg, var(--home-teal) 5%, #1a9793 48%, var(--home-amber));
background-clip: text;
color: transparent;
display: inline;
-webkit-background-clip: text;
}
.lede {
color: var(--home-muted);
font-size: clamp(1.08rem, 1.55vw, 1.32rem);
line-height: 1.65;
font-size: clamp(1.02rem, 1.4vw, 1.15rem);
line-height: 1.6;
margin: 1.5rem 0 0;
max-width: 520px;
}
.installBox {
align-items: center;
background: var(--home-terminal-bg);
border: 1px solid var(--home-terminal-line);
border-radius: 0.5rem;
display: flex;
font-family: var(--ifm-font-family-monospace);
font-size: 0.88rem;
gap: 0.7rem;
margin: 2rem 0 0;
max-width: 630px;
max-width: 420px;
padding: 0.75rem 1.1rem;
}
.installPrompt {
color: var(--home-emerald-bright);
font-weight: 700;
}
.installBox code {
background: transparent;
border: 0;
color: #e4e4e7;
padding: 0;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
margin-top: 2.3rem;
gap: 0.75rem;
margin-top: 1.75rem;
}
.primaryAction,
.secondaryAction,
.ctaAction {
align-items: center;
border-radius: 999px;
border-radius: 0.5rem;
display: inline-flex;
font-weight: 750;
gap: 0.8rem;
font-size: 0.92rem;
font-weight: 600;
gap: 0.6rem;
justify-content: center;
padding: 0.88rem 1.35rem;
padding: 0.72rem 1.25rem;
text-decoration: none !important;
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
transition:
transform 160ms ease,
box-shadow 160ms ease,
background 160ms ease,
border-color 160ms ease;
}
.primaryAction {
background: var(--home-ink);
box-shadow: 0 12px 28px rgba(16, 47, 49, 0.2);
color: #f6fffd;
background: var(--home-emerald);
color: #fff;
}
.primaryAction:hover {
box-shadow: 0 16px 36px rgba(16, 47, 49, 0.28);
color: #fff;
transform: translateY(-2px);
background: var(--home-emerald-bright);
color: #06251c;
transform: translateY(-1px);
}
.secondaryAction {
background: rgba(255, 255, 255, 0.58);
background: transparent;
border: 1px solid var(--home-line);
color: var(--home-ink);
}
.secondaryAction:hover {
background: rgba(255, 255, 255, 0.9);
color: var(--home-teal);
transform: translateY(-2px);
}
.install {
align-items: center;
color: var(--home-muted);
display: flex;
font-size: 0.84rem;
gap: 0.7rem;
margin-top: 1.4rem;
}
.install span {
color: var(--home-amber);
font-family: var(--ifm-font-family-monospace);
font-weight: 800;
}
.install code {
background: transparent;
border: 0;
color: inherit;
padding: 0;
border-color: var(--home-card-hover-line);
color: var(--home-emerald);
transform: translateY(-1px);
}
.hotReload {
align-items: center;
color: var(--home-teal);
color: var(--home-muted);
display: inline-flex;
font-size: 0.82rem;
font-weight: 750;
font-family: var(--ifm-font-family-monospace);
font-size: 0.8rem;
gap: 0.5rem;
margin-top: 0.75rem;
margin-top: 1.25rem;
text-decoration: none !important;
}
.hotReload span {
color: var(--home-amber);
font-size: 1rem;
color: var(--home-emerald);
}
.hotReload:hover {
color: var(--home-teal-bright);
color: var(--home-ink);
}
/* ---------- Code showcase ---------- */
.codeStage {
min-width: 0;
perspective: 1200px;
perspective: 1400px;
position: relative;
}
.codeGlow {
background: linear-gradient(115deg, rgba(11, 133, 136, 0.48), rgba(238, 156, 40, 0.38));
filter: blur(55px);
inset: 8% 5%;
opacity: 0.55;
background: radial-gradient(
ellipse 70% 70% at 60% 30%,
rgba(16, 185, 129, 0.28),
transparent 70%
);
filter: blur(50px);
inset: 6% 4%;
opacity: 0.75;
position: absolute;
}
.codeWindow {
background: #101b1e;
border: 1px solid rgba(166, 225, 220, 0.2);
border-radius: 1.25rem;
box-shadow:
0 34px 90px rgba(18, 49, 52, 0.3),
0 2px 0 rgba(255, 255, 255, 0.06) inset;
background: var(--home-terminal-bg);
border: 1px solid var(--home-terminal-line);
border-radius: 0.75rem;
box-shadow: 0 34px 90px rgba(0, 0, 0, 0.35);
overflow: hidden;
position: relative;
transform: rotateY(-2deg) rotateX(1deg);
transform: rotateY(-4deg) rotateX(2deg);
transition: transform 400ms ease;
}
.codeStage:hover .codeWindow {
transform: rotateY(-1deg) rotateX(0.5deg);
}
.windowBar {
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
color: #b6c9c9;
border-bottom: 1px solid var(--home-terminal-line);
color: #a1a1aa;
display: grid;
font-family: var(--ifm-font-family-monospace);
font-size: 0.73rem;
font-size: 0.72rem;
grid-template-columns: 1fr auto 1fr;
padding: 0.78rem 1rem;
padding: 0.75rem 1rem;
}
.windowDots {
@@ -226,80 +268,37 @@
}
.windowDots span {
background: #4f6d6e;
background: #3f3f46;
border-radius: 50%;
height: 0.55rem;
width: 0.55rem;
}
.windowDots span:first-child {
background: #ef8d5b;
}
.windowDots span:nth-child(2) {
background: #e9bc50;
}
.windowDots span:last-child {
background: #65bd92;
}
.windowStatus {
color: #74d2cd;
color: var(--home-emerald-bright);
justify-self: end;
}
.codeWindow :global(.theme-code-block) {
border: 0;
border-radius: 0;
box-shadow: none;
margin: 0;
}
.codeWindow :global(.theme-code-block pre) {
background: #101b1e !important;
color: #d7e5e4 !important;
font-size: 0.78rem;
line-height: 1.62;
.codeWindow :global(.theme-code-block pre.thin-scrollbar) {
background: var(--home-terminal-bg) !important;
font-size: 0.8rem !important;
line-height: 1.62 !important;
max-height: none;
padding: 1.45rem 1.6rem !important;
}
.codeWindow :global(.token-line),
.codeWindow :global(.token.plain),
.codeWindow :global(.token.imports),
.codeWindow :global(.token.maybe-class-name),
.codeWindow :global(.token.punctuation),
.codeWindow :global(.token.operator),
.codeWindow :global(.token.plain-text),
.codeWindow :global(.token.property-access) {
color: #d7e5e4 !important;
}
.codeWindow :global(.token.keyword) {
color: #c792ea !important;
}
.codeWindow :global(.token.string) {
color: #c3e88d !important;
}
.codeWindow :global(.token.function),
.codeWindow :global(.token.method) {
color: #82aaff !important;
}
.codeWindow :global(.token.tag) {
color: #f07178 !important;
}
.codeWindow :global(.token.builtin) {
color: #ffcb6b !important;
padding: 1.4rem 1.6rem !important;
}
.codeLegend {
border-top: 1px solid rgba(255, 255, 255, 0.08);
color: #a9bcbc;
border-top: 1px solid var(--home-terminal-line);
color: #a1a1aa;
display: grid;
font-family: var(--ifm-font-family-monospace);
font-size: 0.7rem;
grid-template-columns: repeat(3, 1fr);
}
@@ -310,18 +309,18 @@
}
.codeLegend span + span {
border-left: 1px solid rgba(255, 255, 255, 0.08);
border-left: 1px solid var(--home-terminal-line);
}
.codeLegend b {
color: #e8a94b;
font-family: var(--ifm-font-family-monospace);
margin-right: 0.25rem;
color: var(--home-emerald-bright);
margin-right: 0.3rem;
}
/* ---------- Features ---------- */
.featuresSection {
padding-bottom: 7rem;
padding-top: 4rem;
padding-bottom: 6rem;
position: relative;
z-index: 1;
}
@@ -330,27 +329,35 @@
display: grid;
gap: 0 3rem;
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
margin-bottom: 2.8rem;
margin-bottom: 2.5rem;
}
.sectionHeading .sectionKicker {
.sectionKicker {
color: var(--home-emerald);
font-family: var(--ifm-font-family-monospace);
font-size: 0.78rem;
font-weight: 600;
grid-column: 1 / -1;
letter-spacing: 0.04em;
margin: 0 0 1rem;
text-transform: uppercase;
}
.sectionHeading h2,
.cta h2 {
color: var(--home-ink);
font-size: clamp(2.25rem, 4vw, 4rem);
letter-spacing: -0.055em;
line-height: 1;
font-size: clamp(1.9rem, 3.2vw, 2.6rem);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.1;
margin: 0;
}
.sectionHeading > p:last-child {
align-self: end;
color: var(--home-muted);
font-size: 1.03rem;
line-height: 1.65;
font-size: 1rem;
line-height: 1.6;
margin: 0;
}
@@ -363,41 +370,22 @@
.featureCard {
background: var(--home-card);
border: 1px solid var(--home-line);
border-radius: 1.35rem;
border-radius: 0.75rem;
color: inherit;
display: flex;
flex-direction: column;
min-height: 310px;
overflow: hidden;
padding: 1.45rem;
position: relative;
min-height: 300px;
padding: 1.4rem;
text-decoration: none !important;
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
}
.featureCard::before {
background: linear-gradient(90deg, var(--home-teal-bright), var(--home-amber));
content: "";
height: 3px;
left: 1.45rem;
opacity: 0;
position: absolute;
right: 1.45rem;
top: 0;
transform: scaleX(0.5);
transition: opacity 180ms ease, transform 180ms ease;
transition:
border-color 160ms ease,
transform 160ms ease;
}
.featureCard:hover {
border-color: rgba(8, 119, 123, 0.35);
box-shadow: 0 24px 60px rgba(28, 65, 66, 0.12);
border-color: var(--home-card-hover-line);
color: inherit;
transform: translateY(-5px);
}
.featureCard:hover::before {
opacity: 1;
transform: scaleX(1);
transform: translateY(-3px);
}
.cardTopline {
@@ -408,36 +396,36 @@
.featureIcon {
align-items: center;
background: rgba(8, 119, 123, 0.1);
border: 1px solid rgba(8, 119, 123, 0.12);
border-radius: 0.8rem;
color: var(--home-teal);
background: rgba(5, 150, 105, 0.1);
border-radius: 0.4rem;
color: var(--home-emerald);
display: flex;
font-family: var(--ifm-font-family-monospace);
font-size: 1rem;
font-weight: 850;
height: 2.7rem;
font-size: 0.95rem;
font-weight: 700;
height: 2.4rem;
justify-content: center;
width: 2.7rem;
width: 2.4rem;
}
.cardIndex {
color: rgba(16, 47, 49, 0.32);
color: var(--home-muted);
font-family: var(--ifm-font-family-monospace);
font-size: 0.72rem;
}
.featureCard h3 {
color: var(--home-ink);
font-size: 1.25rem;
letter-spacing: -0.025em;
margin: 2rem 0 0.7rem;
font-size: 1.15rem;
font-weight: 650;
letter-spacing: -0.01em;
margin: 1.8rem 0 0.6rem;
}
.featureCard > p {
color: var(--home-muted);
font-size: 0.94rem;
line-height: 1.65;
font-size: 0.9rem;
line-height: 1.6;
margin: 0;
}
@@ -446,150 +434,120 @@
display: flex;
justify-content: space-between;
margin-top: auto;
padding-top: 1.5rem;
padding-top: 1.4rem;
}
.cardFooter code {
background: rgba(8, 119, 123, 0.08);
background: rgba(5, 150, 105, 0.1);
border: 0;
color: var(--home-teal);
font-size: 0.7rem;
padding: 0.35rem 0.5rem;
color: var(--home-emerald);
font-size: 0.68rem;
padding: 0.32rem 0.5rem;
}
.cardFooter > span {
color: var(--home-amber);
font-size: 1.1rem;
transition: transform 180ms ease;
color: var(--home-emerald);
font-size: 1rem;
transition: transform 160ms ease;
}
.featureCard:hover .cardFooter > span {
transform: translate(3px, -3px);
transform: translate(2px, -2px);
}
/* ---------- CTA ---------- */
.ctaWrap {
padding-bottom: 7rem;
padding-bottom: 6.5rem;
position: relative;
z-index: 1;
}
.cta {
align-items: center;
background:
radial-gradient(circle at 90% 20%, rgba(233, 149, 38, 0.22), transparent 20rem),
linear-gradient(125deg, #12383a, #10292c);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1.75rem;
box-shadow: 0 28px 80px rgba(16, 47, 49, 0.2);
background: var(--home-terminal-bg);
border-radius: 1rem;
display: flex;
gap: 3rem;
gap: 2.5rem;
justify-content: space-between;
overflow: hidden;
padding: clamp(2rem, 5vw, 4rem);
padding: clamp(2rem, 4.5vw, 3.5rem);
position: relative;
}
.ctaGridBackdrop {
background-image:
linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-size: 40px 40px;
inset: 0;
mask-image: radial-gradient(ellipse 70% 100% at 100% 0%, black, transparent);
pointer-events: none;
position: absolute;
}
.ctaCopy {
position: relative;
}
.cta .sectionKicker {
color: #79d5d1;
color: var(--home-emerald-bright);
}
.cta h2 {
color: #f7fffd;
color: #fafafa;
}
.cta p:last-child {
color: #b9cccc;
line-height: 1.65;
margin: 1rem 0 0;
max-width: 650px;
color: #a1a1aa;
line-height: 1.6;
margin: 0.85rem 0 0;
max-width: 620px;
}
.ctaAction {
background: #f0a13b;
color: #162d2e;
background: var(--home-emerald);
color: #fff;
flex: 0 0 auto;
position: relative;
}
.ctaAction:hover {
box-shadow: 0 14px 35px rgba(0, 0, 0, 0.24);
color: #102526;
transform: translateY(-2px);
}
:global([data-theme="dark"]) .page {
--home-ink: #e9f7f5;
--home-muted: #a9bfbd;
--home-teal: #79d5d1;
--home-teal-bright: #5bc8c5;
--home-amber: #f1ad4e;
--home-card: rgba(20, 42, 45, 0.76);
--home-line: rgba(174, 221, 218, 0.14);
background:
radial-gradient(circle at 8% 5%, rgba(33, 154, 154, 0.17), transparent 28rem),
radial-gradient(circle at 92% 14%, rgba(209, 130, 31, 0.13), transparent 27rem),
linear-gradient(145deg, #0f1b1e 0%, #102426 48%, #161e1e 100%);
}
:global([data-theme="dark"]) .gridBackdrop {
background-image:
linear-gradient(rgba(174, 221, 218, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(174, 221, 218, 0.04) 1px, transparent 1px);
}
:global([data-theme="dark"]) .secondaryAction {
background: rgba(19, 43, 46, 0.75);
}
:global([data-theme="dark"]) .secondaryAction:hover {
background: rgba(27, 57, 60, 0.95);
}
:global([data-theme="dark"]) .primaryAction {
background: #dff8f5;
color: #123638;
}
:global([data-theme="dark"]) .primaryAction:hover {
color: #0b282a;
}
:global([data-theme="dark"]) .cardIndex {
color: rgba(220, 242, 239, 0.35);
background: var(--home-emerald-bright);
color: #06251c;
transform: translateY(-1px);
}
@media screen and (max-width: 1180px) {
.hero {
.heroGrid {
gap: 3rem;
grid-template-columns: minmax(0, 0.85fr) minmax(500px, 1.15fr);
grid-template-columns: minmax(0, 0.9fr) minmax(460px, 1.1fr);
}
.hero h1 {
font-size: clamp(3.2rem, 6vw, 5rem);
font-size: clamp(2.4rem, 4.4vw, 3.2rem);
}
}
@media screen and (max-width: 996px) {
.hero {
.heroGrid {
grid-template-columns: minmax(0, 1fr);
padding-bottom: 4rem;
padding-top: 4.5rem;
}
.heroCopy,
.codeStage,
.codeWindow,
.codeWindow :global(.theme-code-block) {
.codeWindow {
min-width: 0;
width: 100%;
}
.heroCopy {
max-width: 760px;
max-width: 640px;
}
.codeStage {
margin: 0 auto;
max-width: 760px;
width: 100%;
max-width: 640px;
}
.codeWindow {
@@ -599,51 +557,43 @@
.featureGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media screen and (max-width: 700px) {
.hero {
box-sizing: border-box;
margin-left: 0;
margin-right: 0;
max-width: 100vw;
padding-left: 1.15rem;
padding-right: 1.15rem;
width: 100vw;
}
.heroCopy,
.codeStage {
max-width: calc(100vw - 2.3rem);
}
.hero h1 {
font-size: clamp(3rem, 15vw, 4.3rem);
}
.sectionHeading {
grid-template-columns: 1fr;
}
.sectionHeading > p:last-child {
margin-top: 1.25rem;
margin-top: 1rem;
}
}
@media screen and (max-width: 700px) {
.hero {
padding: 3.5rem 0 2.5rem;
}
.heroCopy,
.codeStage {
max-width: 100%;
}
.actions {
flex-direction: column;
}
.primaryAction,
.secondaryAction {
width: 100%;
}
.featureGrid {
grid-template-columns: 1fr;
}
.featureCard {
min-height: 275px;
}
.codeLegend {
grid-template-columns: 1fr;
}
.codeLegend span + span {
border-left: 0;
border-top: 1px solid rgba(255, 255, 255, 0.08);
.codeWindow :global(.theme-code-block pre) {
font-size: 0.72rem;
overflow-x: auto;
padding: 1.1rem !important;
}
.windowStatus {
@@ -654,25 +604,29 @@
grid-template-columns: 1fr auto;
}
.codeWindow :global(.theme-code-block pre) {
font-size: 0.69rem;
overflow-x: auto;
padding: 1.1rem !important;
}
.cta {
align-items: flex-start;
flex-direction: column;
}
.codeLegend {
grid-template-columns: 1fr;
}
.codeLegend span + span {
border-left: 0;
border-top: 1px solid var(--home-terminal-line);
}
}
@media (prefers-reduced-motion: reduce) {
.featureCard,
.featureCard::before,
.cardFooter > span,
.primaryAction,
.secondaryAction,
.ctaAction {
.ctaAction,
.eyebrow,
.eyebrowArrow,
.codeWindow {
transition: none;
}
}
+83 -61
View File
@@ -91,67 +91,88 @@ export default function Home(): ReactNode {
description="Write React function components as typed Effect programs"
>
<main className={styles.page}>
<div className={styles.gridBackdrop} aria-hidden="true" />
<section className={styles.hero}>
<div className={styles.gridBackdrop} aria-hidden="true" />
<div className={styles.heroGlow} aria-hidden="true" />
<section className={clsx("container", styles.hero)}>
<div className={styles.heroCopy}>
<div className={styles.eyebrow}>
<span className={styles.pulse} />
Effect View for React 19
</div>
<h1>
React <span className={styles.unbreakable}>components,</span>
<span className={styles.heroAccent}> powered by Effect.</span>
</h1>
<p className={styles.lede}>
Bring typed services, scoped resources, reactive state, server
queries, and schema-driven forms into React without hiding either
framework.
</p>
<div className={styles.actions}>
<Link className={styles.primaryAction} to="/docs/getting-started">
Start building
<span aria-hidden="true"></span>
<div className={clsx("container", styles.heroGrid)}>
<div className={styles.heroCopy}>
<Link className={styles.eyebrow} to="/docs/getting-started">
<span aria-hidden="true">{"//"}</span>
Effect View for React 19
<span aria-hidden="true" className={styles.eyebrowArrow}>
</span>
</Link>
<Link
className={styles.secondaryAction}
to="https://github.com/Thiladev/effect-view"
>
View on GitHub
</Link>
</div>
<div className={styles.install}>
<span aria-hidden="true">$</span>
<code>npm install effect-view effect@beta</code>
</div>
<Link
className={styles.hotReload}
to="/docs/getting-started#set-up-hot-reloading-with-vite"
>
<span aria-hidden="true"></span>
Hot reload with Vite Fast Refresh
</Link>
</div>
<h1>
React components,
<br />
powered by Effect.
</h1>
<div className={styles.codeStage}>
<div className={styles.codeGlow} aria-hidden="true" />
<div className={styles.codeWindow}>
<div className={styles.windowBar}>
<div className={styles.windowDots} aria-hidden="true">
<span />
<span />
<span />
</div>
<span>UserCard.tsx</span>
<span className={styles.windowStatus}>Effect + JSX</span>
<p className={styles.lede}>
Bring typed services, scoped resources, reactive state,
server queries, and schema-driven forms into React without
hiding either framework.
</p>
<div className={styles.installBox}>
<span className={styles.installPrompt} aria-hidden="true">
$
</span>
<code>npm install effect-view effect@rc</code>
</div>
<CodeBlock language="tsx">{componentExample}</CodeBlock>
<div className={styles.codeLegend}>
<span><b>01</b> Yield services</span>
<span><b>02</b> Own the lifecycle</span>
<span><b>03</b> Return JSX</span>
<div className={styles.actions}>
<Link
className={styles.primaryAction}
to="/docs/getting-started"
>
Start building
<span aria-hidden="true"></span>
</Link>
<Link
className={styles.secondaryAction}
to="https://github.com/Thiladev/effect-view"
>
View on GitHub
</Link>
</div>
<Link
className={styles.hotReload}
to="/docs/getting-started#set-up-hot-reloading-with-vite"
>
<span aria-hidden="true"></span>
Hot reload with Vite Fast Refresh
</Link>
</div>
<div className={styles.codeStage}>
<div className={styles.codeGlow} aria-hidden="true" />
<div className={styles.codeWindow}>
<div className={styles.windowBar}>
<div className={styles.windowDots} aria-hidden="true">
<span />
<span />
<span />
</div>
<span>UserCard.tsx</span>
<span className={styles.windowStatus}>Effect + JSX</span>
</div>
<CodeBlock language="tsx">{componentExample}</CodeBlock>
<div className={styles.codeLegend}>
<span>
<b>01</b> Yield services
</span>
<span>
<b>02</b> Own the lifecycle
</span>
<span>
<b>03</b> Return JSX
</span>
</div>
</div>
</div>
</div>
@@ -159,7 +180,7 @@ export default function Home(): ReactNode {
<section className={clsx("container", styles.featuresSection)}>
<div className={styles.sectionHeading}>
<p className={styles.sectionKicker}>Why Effect View</p>
<p className={styles.sectionKicker}>{"// Why Effect View"}</p>
<h2>One model from render to resources.</h2>
<p>
Effect View adds the pieces React deliberately leaves open while
@@ -193,12 +214,13 @@ export default function Home(): ReactNode {
<section className={clsx("container", styles.ctaWrap)}>
<div className={styles.cta}>
<div>
<p className={styles.sectionKicker}>Ready when React is</p>
<div className={styles.ctaGridBackdrop} aria-hidden="true" />
<div className={styles.ctaCopy}>
<p className={styles.sectionKicker}>{"// Ready when React is"}</p>
<h2>Start with one Effect component.</h2>
<p>
Add a runtime, cross the React boundary once, and grow into the
rest of the toolkit only when you need it.
Add a runtime, cross the React boundary once, and grow into
the rest of the toolkit only when you need it.
</p>
</div>
<Link className={styles.ctaAction} to="/docs/getting-started">
+58
View File
@@ -0,0 +1,58 @@
import type { PrismTheme } from "prism-react-renderer"
/**
* Matches the classic "GitHub Dark" token palette effect.website uses for
* its dark-mode code blocks (extracted from their rendered token styles) -
* prism-react-renderer doesn't ship this one as a preset.
*/
const githubDark: PrismTheme = {
plain: {
color: "#e1e4e8",
backgroundColor: "transparent",
},
styles: [
{
types: ["comment", "prolog", "doctype", "cdata"],
style: { color: "#6a737d" },
},
{
types: ["punctuation", "operator", "entity", "url", "variable"],
style: { color: "#e1e4e8" },
},
{
types: [
"number",
"boolean",
"constant",
"symbol",
"deleted",
"class-name",
"maybe-class-name",
"builtin",
],
style: { color: "#79b8ff" },
},
{
types: ["selector", "attr-name", "string", "char", "inserted", "attr-value"],
style: { color: "#9ecbff" },
},
{
types: ["atrule", "keyword"],
style: { color: "#f97583" },
},
{
types: ["function", "method"],
style: { color: "#b392f0" },
},
{
types: ["regex", "important"],
style: { color: "#ffab70" },
},
{
types: ["tag"],
style: { color: "#85e89d" },
},
],
}
export default githubDark
+38
View File
@@ -0,0 +1,38 @@
const numberedLanguages = new Set(["ts", "tsx", "js", "jsx"])
interface MdastNode {
type: string
lang?: string
meta?: string | null
value?: string
children?: MdastNode[]
}
function visitCodeNodes(node: MdastNode, onCode: (code: MdastNode) => void): void {
if (!node.children) return
for (const child of node.children) {
if (child.type === "code") onCode(child)
visitCodeNodes(child, onCode)
}
}
/**
* Turns on line numbers for multi-line ts/tsx/js/jsx fences that don't
* already opt in or out, matching Effect's docs code block presentation.
*/
export default function remarkLineNumbers() {
return (tree: MdastNode) => {
visitCodeNodes(tree, (code) => {
const lang = (code.lang ?? "").toLowerCase()
if (!numberedLanguages.has(lang)) return
const meta = code.meta ?? ""
if (/showLineNumbers/.test(meta)) return
const lineCount = (code.value ?? "").split("\n").length
if (lineCount <= 1) return
code.meta = `${meta} showLineNumbers`.trim()
})
}
}
+53
View File
@@ -0,0 +1,53 @@
# effect-view
`effect-view` lets a React function component be described as an Effect program: yield services, create scoped resources, subscribe to reactive state, and turn Effects into React callbacks inside a component body, then convert that description into a normal React function component at a React boundary.
Requires Effect v4 (RC) and React 19.2+. Peer dependencies: `effect`, `react`, `@types/react`. Not tied to `react-dom` — any React renderer works.
When writing effect-view code, use the actual current source and tests in `src/` as ground truth over anything remembered from training — the API is pre-1.0 and still moving. The files below are a concise reference; read the linked one(s) before writing code that touches that concern.
## Core model
1. `ReactRuntime` builds the Effect services available to the UI and exposes them through React context.
2. `Component.make` defines a component body as an Effect generator.
3. `Component.withContext` converts a `Component` into a normal React component at a React boundary (app root, router, third-party library). Apply it only there — never between two effect-view components.
4. Inside another effect-view component, compose children by yielding their `.use` Effect.
5. Every rendered component instance owns a root `Scope.Scope`, opened on mount and closed on unmount; regular component setup must complete **synchronously** during render unless the component is wrapped with `Async.async`.
## Find the right doc by what you're trying to do
**Setting up the app** — building the runtime, providing it to the tree → [ReactRuntime.md](./ai-docs/ReactRuntime.md)
**Defining a component, its lifecycle, or running Effects from event handlers** → [Component.md](./ai-docs/Component.md)
**Storing, reading, or subscribing to state** (local, shared via a service, or focused into a nested field) → [State.md](./ai-docs/State.md) — `Lens` (read/write) and `View` (read-only)
**Rendering something that needs to wait on an async Effect, or avoiding unnecessary re-renders/re-fetches** → [Async.md](./ai-docs/Async.md) — `Async` (suspend on an Effect) and `Memoized` (`React.memo` wrapper)
**Fetching/caching server data** (reactive keys, staleness, background refresh, invalidation) → [Query.md](./ai-docs/Query.md) — includes `QueryClient`, the cache service `Query` runs against
**Triggering a write** (save, delete, upload, send) with pending/error state → [Mutation.md](./ai-docs/Mutation.md)
**Building a schema-driven form**:
- shared concepts (encoded vs decoded value, focusing into fields, input/status hooks) → [Form.md](./ai-docs/Form.md) — read this first
- a form that submits a valid value via a `Mutation` → [MutationForm.md](./ai-docs/MutationForm.md)
- a form that keeps a target `Lens` continuously synchronized with a valid draft → [LensForm.md](./ai-docs/LensForm.md)
**Small utilities**:
- bridging React-tracked values into an Effect `PubSub` → [PubSub.md](./ai-docs/PubSub.md)
- consuming a raw Effect `Stream` as React state → [Stream.md](./ai-docs/Stream.md)
## Choosing between async integrations
- One-off async read before rendering → `Async.async`.
- Cached/shared/refreshable server reads → `Query`.
- User-triggered writes with observable pending/error state → `Mutation`.
- Async work in an event handler with no need for `Mutation` state → `Component.useRunPromise`/`useCallbackPromise`.
- Subscriptions or background work tied to component lifecycle → a scoped fiber forked from `Component.useReactEffect`.
## Common pitfalls
- Never yield an asynchronous Effect directly from a regular (non-`Async`) component body.
- effect-view hooks are still React hooks under the hood: call them unconditionally, at the top level, in a stable order — never in branches, loops, or after a suspend point.
- Keep `ReactRuntime` instances and `Layer` references stable; building them during render creates new resources and a new React context every time.
- `Component.withContext` requires a matching `ReactRuntime.Provider` above it in the tree.
+58
View File
@@ -0,0 +1,58 @@
# Async and Memoized
Two `Component` traits that control render behavior: `Async` lets a component's body suspend on an asynchronous Effect; `Memoized` skips re-rendering a component when its props haven't changed. They're commonly combined, since an unmemoized `Async` component restarts its async computation on every unrelated parent render.
## Async
Components run synchronously by default (the body must complete without suspending during render). `Async.async` lifts a component so its body may await an asynchronous Effect before returning JSX; React Suspense handles the wait.
```tsx
import { Effect } from "effect"
import { Async, Component } from "effect-view"
export const UserCard = Component.make("UserCard")(function* ({ userId }: { readonly userId: string }) {
const user = yield* Component.useOnChange(() => loadUser(userId), [userId])
return <article>{user.name}</article>
}).pipe(Async.async)
```
Render with a fallback (per-use or as a component default):
```tsx
const User = yield* UserCard.use
<User userId="123" fallback={<p>Loading user...</p>} />
```
```tsx
.pipe(Async.async, Async.withOptions({ defaultFallback: <p>Loading user...</p> }))
```
Rules:
- **Hook ordering**: place every React hook and effect-view hook helper *before* the first operation that may suspend. After a suspend point, the generator continuation runs outside React's synchronous render phase, so no hooks may follow it.
- The `promise` prop name is reserved on async components (used internally) — do not declare a prop with that name.
When to reach for `Async` vs alternatives:
- One-off asynchronous read before rendering → `Async.async`.
- Cached/shared/refreshable server reads → `Query` (see `Query.md`).
- User-triggered writes with pending/error state → `Mutation` (see `Mutation.md`).
- Async work in an event handler with no need for `Mutation` state → `useRunPromise`/`useCallbackPromise` (see `Component.md`).
- Subscriptions or background work tied to lifecycle → a scoped fiber forked from `useReactEffect`.
## Memoized
Wraps a component's rendered function with `React.memo`, so an unrelated parent re-render doesn't re-run the component (or restart an `Async` child's in-flight computation) when props are unchanged.
```tsx
import { Async, Component, Memoized } from "effect-view"
export const UserCard = Component.make("UserCard")(function* ({ userId }: { readonly userId: string }) {
const user = yield* Component.useOnChange(() => loadUser(userId), [userId])
return <article>{user.name}</article>
}).pipe(Async.async, Memoized.memoized)
```
- Default comparison is `Object.is` per prop (React.memo default), except on `Async` components where `fallback` is excluded from the comparison by default.
- Override with `Memoized.withOptions({ propsEquivalence })`, e.g. `Equal.asEquivalence()` for full structural equality on immutable data. Supplying `propsEquivalence` replaces the default entirely (so `fallback` is included again for `Async` components unless you exclude it yourself).
- `Memoized` is not exclusive to `Async` — it applies to any `Component` — but it matters most there, since an unmemoized async child otherwise closes/reopens its dependency scope and re-runs its async setup on every parent render.
+103
View File
@@ -0,0 +1,103 @@
# Component
Defines a React function component as an Effect program. A `Component` is a description, not yet a React component — cross into React with `Component.withContext` or `.use`.
## Define
```tsx
import { Effect } from "effect"
import { Component } from "effect-view"
export const HelloView = Component.make("HelloView")(function* (props: { readonly name: string }) {
const message = yield* Effect.succeed(`Hello, ${props.name}`)
return <h1>{message}</h1>
})
```
- `Component.make(spanName?)(generatorBody, ...pipeArgs)` — same overloads as `Effect.fn`/`Effect.gen`: a generator body, or a body plus `(_, props) => next` pipeline steps. Passing a `spanName` wraps the body in a tracing span and sets `displayName`.
- `Component.makeUntraced` is identical but skips the automatic span (still sets `displayName` from the name argument).
- The component's props type, return type, error channel, and required services (`R`) are all inferred from the generator body.
## Cross into React
```tsx
export const Hello = HelloView.pipe(Component.withContext(runtime.context))
// <Hello name="Effect" />
```
`Component.withContext(context)` reads the Effect context supplied by the matching `ReactRuntime.Provider` and turns the component into a plain `React.FC`. Apply it only at boundaries where plain React (a router, a third-party lib, an app root) needs a function component — never between two effect-view components.
## Compose inside effect-view
```tsx
const Hello = yield* HelloView.use
return <Hello name="Effect" />
```
`component.use` is an `Effect<F, never, Exclude<R, Scope.Scope>>` that binds the child to the current Effect context/scope and returns a stable function-component reference. Yield it from a parent component body; do not call `withContext` here.
## Lifecycle hooks
Hooks are plain React hooks under the hood: call them unconditionally, at the top level, in the same order every render — never in branches, loops, callbacks, or after a suspend point.
Every rendered component instance gets a root `Scope.Scope`, created on mount and closed on unmount, provided to the whole body. `Effect.addFinalizer`, `Effect.acquireRelease`, `Effect.forkScoped` used directly in the body run against this scope.
| Hook | Purpose | Scope | Closes |
|---|---|---|---|
| body | produce rendered output | component root scope | unmount |
| `useOnMount(() => effect)` | compute + cache once | component root scope | unmount |
| `useOnChange(() => effect, deps)` | recompute on deps change | new scope per dep set | deps change / unmount |
| `useReactEffect(() => effect, deps?)` | post-commit side effect (`Effect.useEffect` analog) | new scope | deps change / unmount |
| `useReactLayoutEffect(() => effect, deps?)` | pre-paint side effect | new scope | deps change / unmount |
| `useLayer(layer, options?)` | build + provide a `Layer`, returns its `Context` | new scope tied to layer identity | layer ref changes / unmount |
```tsx
const state = yield* Component.useOnMount(() =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.log("disposed"))
return Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
}),
)
```
- Setup passed to `useOnMount`/`useOnChange`/`useLayer` must complete **synchronously** in a regular component (it runs during render). Wrap the component with `Async.async` to allow suspending setup.
- `useReactEffect`/`useReactLayoutEffect` setup must also start synchronously but may `Effect.forkScoped` async work into the hook's own scope.
- `useRunSync`/`useRunPromise`/`useCallbackSync`/`useCallbackPromise` do not create a new scope; they capture the component root scope (and any extra services you request) by default.
## Run Effects from event handlers
```tsx
const runPromise = yield* Component.useRunPromise() // or useRunPromise<Scope.Scope | SomeService>()
<button onClick={() => void runPromise(saveUser(user))}>Save</button>
```
- `useRunSync<R>()` — only for Effects guaranteed to complete synchronously.
- `useRunPromise<R>()` — for Effects that may suspend/sleep/fetch.
- `useCallbackSync(f, deps)` / `useCallbackPromise(f, deps)` — memoized variants (same deps semantics as `React.useCallback`) for passing stable callbacks to children.
- Both runners provide `Scope.Scope` automatically; add extra services with an explicit type argument (`useRunPromise<Scope.Scope | UserRepository>()`).
## Provide services
Static layer, one instance per mounted component, disposed on unmount:
```tsx
const GreetingViewLive = GreetingView.pipe(Component.provide(GreetingService.layer))
```
Layer built from render-time state (props/context), provided to children explicitly:
```tsx
const layer = React.useMemo(() => Layer.succeed(GreetingService, {...}), [props.greeting])
const context = yield* Component.useLayer(layer)
const Greeting = yield* Effect.provide(GreetingView.use, context)
return <Greeting name="Effect" />
```
Keep layer references stable (module scope or `React.useMemo`) — a new layer object triggers rebuild and finalizer cleanup. Async layer construction requires `Async.async` on the owning component.
## Common pitfalls
- Never yield an asynchronous Effect from a regular component body — use `Async.async`, `Query`, a `Mutation` callback, or a scoped fiber forked from a post-commit hook instead.
- `Component.withContext` needs a matching `ReactRuntime.Provider` above it in the tree.
- Prefer `useRunPromise` over `useRunSync` for event handlers that may be async.
- Regular React hooks, refs, context, and state work normally inside a component body alongside `yield*`.
+59
View File
@@ -0,0 +1,59 @@
# Form
The shared model implemented by both root form types (`MutationForm.md`, `LensForm.md`) and every subform focused from them. A schema is the single source of truth for shape, validation, and the decoded value the application receives; `Form` supplies reactive state and lifecycle on top of it — it does not render anything.
A schema distinguishes the **encoded value** the UI edits from the **decoded value** the application uses:
```tsx
import { Schema } from "effect"
const ProfileSchema = Schema.Struct({
displayName: Schema.String.check(Schema.isMinLength(1, { message: "Enter a display name" })),
age: Schema.NumberFromString, // input edits a string, app gets a number
contact: Schema.Struct({
email: Schema.String.check(Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, { message: "Enter a valid email" })),
}),
})
```
## The Form interface
| Member | Meaning |
|---|---|
| `encodedValue` | writable input-shaped state (a `Lens`) |
| `value` | decoded value as `Option<A>`; `None` until decoding succeeds |
| `issues` | Standard Schema issues scoped to this form's path |
| `isValidating` | whether schema decoding is currently running |
| `canCommit` | whether the root has a valid value and is ready to commit |
| `isCommitting` | whether a mutation or target write is in progress |
There is no separate "field" type — a field is just a `Form` focused on part of its parent.
## Focus into subforms
```tsx
const displayNameField = Form.focusObjectOn(form, "displayName")
const contactForm = Form.focusObjectOn(form, "contact")
const emailField = form.pipe(Form.focusObjectOn("contact"), Form.focusObjectOn("email"))
```
- `Form.focusObjectOn` (struct key), `Form.focusArrayAt` (array index), `Form.focusTupleAt` (tuple index), `Form.focusChunkAt` (Chunk index). All are dual API: data-first, or curried for chaining through nested paths with `pipe`.
- A focused form exposes `encodedValue`/`value`/`issues` scoped to that path; `isValidating`/`canCommit`/`isCommitting` stay connected to the root form.
- Focus once (e.g. at component setup), not on every render.
## Bind a subform to an input
```tsx
const input = yield* Form.useInput(emailField, { debounce: "250 millis" })
<input value={input.value} onChange={e => input.setValue(e.currentTarget.value)} />
```
- `Form.useInput(form, { debounce? })` returns `{ value, setValue }` from the subform's encoded value. `setValue` writes the form and re-runs the schema pipeline. `debounce` delays propagation to the form (the displayed value updates immediately) — useful for text inputs to avoid validating every keystroke.
- `Form.useOptionalInput(form, { defaultValue, debounce? })` is for a field whose encoded value is `Option<I>`: returns `{ value, setValue, enabled, setEnabled }` for a togglable optional input. `value`/`setValue` operate on the unwrapped `I`; `defaultValue` is used as `value` while `enabled` is `false` (i.e. while the encoded field is `None`), and `defaultValue` is required.
- `Form.useStatus(form, { debounce? })` returns `{ isValidating, isCommitting, canCommit }`, debounced (default 250ms) to avoid flicker in pending indicators.
These hooks are building blocks for your own reusable input components — wrap them once to handle labels, issues, disabled state, and styling consistently; both hooks accept any `Form.Form`, so the same input component works with subforms from `MutationForm` or `LensForm`.
## Schema-owned conversions
Because the schema defines both directions, it can own an entire domain conversion — e.g. a local `datetime-local` input string decoding to a UTC `DateTime.Utc` and back — with no manual parsing in components. See `MutationForm.md` for a complete example (`DateTimeUtcFromZonedInput`).
+25
View File
@@ -0,0 +1,25 @@
# LensForm
A root form (implements `Form.Form`, see `Form.md`) that keeps an encoded draft synchronized in both directions with a target `Lens` of decoded application data. Use for settings panels, inspectors, and edit screens where a valid change should update existing state without a final submit.
```tsx
import { Effect, SubscriptionRef } from "effect"
import { Component, Lens, LensForm, View } from "effect-view"
const [form, profile] = yield* Component.useOnMount(() =>
Effect.gen(function* () {
const profile = Lens.fromSubscriptionRef(
yield* SubscriptionRef.make({ displayName: "Ada", age: 37, contact: { email: "ada@example.com" } }),
)
const form = yield* LensForm.make({ schema: ProfileSchema, target: profile }).pipe(LensForm.thenRun)
return [form, profile] as const
}),
)
const [savedProfile, isCommitting] = yield* View.useAll([profile, form.isCommitting])
```
- `LensForm.make({ schema, target, initialEncodedValue? })``target` holds decoded data. `LensForm.thenRun` starts synchronization.
- A valid edit is decoded and written to `target` automatically; an invalid edit stays in the form (so the user can correct it) and never reaches `target`. If something else updates `target`, `LensForm` encodes that value back into the draft.
- Pass `initialEncodedValue` only when the first draft should differ from the encoded target; otherwise `LensForm.make` derives the initial draft by encoding the current target through the schema.
- No `submit` method — commits happen continuously as valid edits arrive. Focus into subforms/fields with `Form.focusObjectOn`/etc. and bind with `Form.useInput` exactly as with `MutationForm` (see `Form.md`).
+89
View File
@@ -0,0 +1,89 @@
# Mutation
effect-view's counterpart to TanStack Query mutations: user-triggered asynchronous work (save, delete, upload, send) as an Effect. No cache, no reactive key, no automatic execution — it runs only when called.
| TanStack Mutation | effect-view |
|---|---|
| mutation variables | the input key `K` |
| `mutationFn` | `f: (key: K) => Effect<A, E, R>` |
| mutation result | `mutation.state`, a `View<{ key: Option<K>; result: AsyncResult<A, E> }>` |
| `isPending` | `state.result.waiting` |
| `mutateAsync` | `mutation.mutate(key)` |
| start without awaiting | `mutation.mutateView(key)` |
## Create
```tsx
import { Mutation } from "effect-view"
const mutation = yield* Component.useOnMount(() =>
Mutation.make({ f: (input: InviteInput) => sendInvite(input) }),
)
```
`Mutation.make({ f })` is an Effect constructor, not a hook — create each instance once (`Component.useOnMount` for component-owned, an Effect service for shared) and keep it stable. `f` keeps its full `Effect<A, E, R>` type; required services are captured from the creation context, so callbacks don't reconstruct dependencies. Fibers belong to the creation scope and are interrupted if that scope closes while running.
## AsyncResult state
`mutation.state` is a `View` of `{ key: Option<K>, result: AsyncResult<A, E> }`. `result` starts `Initial` (`waiting: false`); calling `mutate`/`mutateView` sets `waiting: true`, then publishes `Success` or `Failure`. Match on `state.result`, not `state` itself:
```tsx
import { AsyncResult } from "effect/unstable/reactivity"
const [state] = yield* View.useAll([mutation.state])
AsyncResult.match(state.result, {
onInitial: ({ waiting }) => (...),
onFailure: ({ cause, previousSuccess, waiting }) => (...), // cause: Cause<E>
onSuccess: ({ value, waiting }) => (...),
})
```
`waiting` is independent of the result tag: after one success, starting another call keeps the value visible while `waiting: true`; if that call fails, the failure can retain `previousSuccess`. Failures carry a full `Cause<E>`.
## mutate vs mutateView
| Method | Returns | Use for |
|---|---|---|
| `mutate(key)` | the final `FinalMutationState` (`{ key: Option.Some<K>, result: Success \| Failure }`) | an Effect workflow that needs the outcome |
| `mutateView(key)` | a live per-call `View<{ key: Option.Some<K>, result: AsyncResult<A, E> }>` | a UI callback that just starts the work |
```tsx
const runPromise = yield* Component.useRunPromise()
void runPromise(Effect.gen(function* () {
const final = yield* mutation.mutate(input)
if (AsyncResult.isSuccess(final.result)) yield* Effect.log(`Saved ${final.result.value.id}`)
}))
```
```tsx
const runSync = yield* Component.useRunSync()
const state = runSync(mutation.mutateView(input)) // a View for this specific call
```
The mutation Effect never fails with `E` itself — it captures the operation's `Exit` and always resolves to a final state wrapping an `AsyncResult.Success`/`Failure`.
## Reactive metadata
| Member | Meaning |
|---|---|
| `state` | latest mutation state, shared `View` |
| `latestKey` | most recent input, `Option<K>` |
| `latestFinalState` | latest completed final state, `Option<FinalMutationState<K, A, E>>` |
| `fiber` | most recently started mutation fiber, `Option` |
## Concurrency
Starting a mutation does not interrupt an earlier one — calls can overlap, each with its own `mutateView` state; `mutation.state` reflects whichever update arrived last. For a single submit button, disabling while `result.waiting` is usually enough. Use per-call `mutateView` Views (e.g. per uploaded file) when concurrent operations each need their own progress indicator.
## Updating queries after a mutation
Mutations never auto-invalidate `Query` caches — compose it explicitly:
```tsx
const final = yield* updatePost.mutate(input)
if (AsyncResult.isSuccess(final.result)) {
yield* posts.invalidateCacheEntry(["post", final.result.value.id] as const)
yield* posts.refreshView // invalidation alone does not refetch
}
```
@@ -0,0 +1,51 @@
# MutationForm
A root form (implements `Form.Form`, see `Form.md`) that owns a local encoded draft and passes the valid decoded value to a `Mutation` when `submit` runs. Use for registration, checkout, search — any workflow with an explicit submit action.
```tsx
import { Effect } from "effect"
import { Component, MutationForm, View } from "effect-view"
const form = yield* Component.useOnMount(() =>
MutationForm.make({
schema: ProfileSchema,
initialEncodedValue: { displayName: "", age: "", contact: { email: "" } },
f: ([profile, form]) => Effect.log(`Creating ${profile.displayName}, age ${profile.age}`),
}).pipe(MutationForm.thenRun),
)
const [canCommit, isCommitting] = yield* View.useAll([form.canCommit, form.isCommitting])
const runPromise = yield* Component.useRunPromise()
<button disabled={!canCommit || isCommitting} onClick={() => void runPromise(form.submit)}>
{isCommitting ? "Creating..." : "Create profile"}
</button>
```
- `MutationForm.make({ schema, initialEncodedValue, f })` constructs the form; the underlying `Mutation`'s input key is the tuple `[decodedValue, form]`, not just the decoded value. Most `f` implementations only destructure the first element (`profile.age` is a number even though the input edited a string); `form` is included so `f` can, if needed, read other form state (e.g. `form.issues`, `form.encodedValue`) while handling the submission. `MutationForm.thenRun` starts initial validation in the current scope.
- Create once and keep stable — the usual home is `Component.useOnMount`.
- `form.submit` runs the mutation only when the form can currently commit; schema issues block submission before the mutation ever runs.
- Focus into subforms/fields with `Form.focusObjectOn`/`focusArrayAt`/etc. (see `Form.md`) and bind them to inputs with `Form.useInput`.
- If `f` fails with a `Schema.SchemaError`, `form.submit` formats that error into `form.issues` automatically — the same formatting path used for client-side decoding errors — instead of only surfacing it as a mutation failure. Any other failure from `f` is left as the mutation's `Failure` and does not touch `form.issues`.
## Example: schema-owned date conversion
```tsx
class DateTimeUtcFromZoned extends Schema.transformOrFail(Schema.DateTimeZonedFromSelf, Schema.DateTimeUtcFromSelf, {
strict: true,
decode: input => ParseResult.succeed(DateTime.toUtc(input)),
encode: DateTime.setZoneCurrent,
}) {}
export class DateTimeUtcFromZonedInput extends Schema.transformOrFail(Schema.String, DateTimeUtcFromZoned, {
strict: true,
decode: (input, _options, ast) => Effect.flatMap(DateTime.CurrentTimeZone, timeZone =>
Option.match(DateTime.makeZoned(input, { timeZone, adjustForTimeZone: true }), {
onSome: ParseResult.succeed,
onNone: () => ParseResult.fail(new ParseResult.Type(ast, input, "Enter a valid date and time")),
})),
encode: value => ParseResult.succeed(DateTime.formatIsoZoned(value).slice(0, 16)),
}) {}
```
An `<input type="datetime-local">` edits `"2026-07-22T14:30"`; the schema decodes it to a UTC `DateTime.Utc` (`decode(...).pipe requires DateTime.CurrentTimeZone` — provide `DateTime.layerCurrentZoneLocal` in the runtime) and the mutation receives the UTC instant directly. No manual date parsing in the component.
+9
View File
@@ -0,0 +1,9 @@
# PubSub
Re-exports Effect's `PubSub` module in full (`export * from "effect/PubSub"`) and adds one component hook.
```tsx
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
```
`PubSub.useFromReactiveValues(values: DependencyList)` creates a scoped, unbounded `PubSub` on mount and publishes `values` to it every time the dependency array changes (skipping publish once the PubSub has been shut down). Useful for bridging a set of React-tracked reactive values into an Effect `Stream`-based consumer inside the component's scope.
+118
View File
@@ -0,0 +1,118 @@
# Query
effect-view's take on TanStack Query: reactive query keys, cached results, stale times, background refresh, window-focus refetching, cache invalidation — but the query function is an `Effect` and the observable state is a `View` (see `State.md`).
| TanStack Query | effect-view |
|---|---|
| `QueryClient` | the `QueryClient` Effect service (below) |
| `queryKey` | a reactive key supplied as a `View<K>` |
| `queryFn` | `f: (key: K) => Effect<A, E, R>` |
| `useQuery` result | `query.state`, a `View<QueryState<K, A, E>>` |
| `isFetching` | `result.waiting` |
| `refetch` | `query.refresh` / `query.refreshView` |
| `invalidateQueries` | `query.invalidateCache` / `invalidateCacheEntry` |
## QueryClient: the shared cache
Every `Query` reads and writes through a `QueryClient`, the Effect service that owns the cache and its garbage collection. Add it once to the application runtime:
```tsx
import { Layer } from "effect"
import { QueryClient, ReactRuntime } from "effect-view"
const AppLive = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layer({
defaultStaleTime: "30 seconds", // default: "0 minutes"
defaultRefreshOnWindowFocus: true, // default: true
cacheGcTime: "5 minutes", // default: "5 minutes"
})),
)
export const runtime = ReactRuntime.make(AppLive)
```
`QueryClient.layer(options?)` builds the service and forks its background garbage-collection loop into the layer's scope. Individual queries may override `staleTime`/`refreshOnWindowFocus`; unset options fall back to these client defaults. `cacheGcTime` controls how long a stale, unaccessed cache entry is kept before eviction. You interact with the client only indirectly through `Query` instances — no need to call `QueryClientService` methods directly.
## Create and run a query
```tsx
import { Effect, Schema, SubscriptionRef } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Component, Lens, Query, View } from "effect-view"
const [postId, query] = yield* Component.useOnMount(() =>
Effect.gen(function* () {
const key = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const))
const query = yield* Query.make({
key,
staleTime: "1 minute",
f: ([, id]) =>
HttpClient.HttpClient.pipe(
Effect.andThen(client => client.get(`https://example.com/posts/${id}`)),
Effect.andThen(res => res.json),
Effect.andThen(Schema.decodeUnknownEffect(Post)),
),
}).pipe(Query.thenRun)
return [Lens.focusTupleAt(key, 1), query] as const
}),
)
```
- `Query.make({ key, f, staleTime?, refreshOnWindowFocus?, keyEquivalence? })` constructs the query; `Query.thenRun` starts watching `key` in the current scope.
- Create each query once and keep it stable — the usual home is `Component.useOnMount` (component-owned) or an Effect service (shared across components). Change the key, don't recreate the query, when input changes.
- Keys use Effect equality by default (`keyEquivalence` overrides it). A tuple key plays the same role as `['post', id]` in TanStack Query.
- `f` keeps its full `Effect<A, E, R>` type: required services, schema decoding, retries, tracing all compose normally. The context is captured at creation time.
- Changing the key interrupts the previous in-flight request; a fresh cached success or a new run for the new key follows.
## Render AsyncResult
`query.state: View<QueryState<K, A, E>>` where `QueryState = { key: K; result: AsyncResult<A, E> }`.
```tsx
import { AsyncResult } from "effect/unstable/reactivity"
const [state] = yield* View.useAll([query.state])
AsyncResult.match(state.result, {
onInitial: ({ waiting }) => waiting ? <p>Loading...</p> : <p>Not loaded.</p>,
onFailure: ({ cause, previousSuccess, waiting }) => (/* cause: Cause<E>, previousSuccess: Option<Success> */),
onSuccess: ({ value, waiting }) => (/* value: A, waiting: refreshing in background */),
})
```
`waiting` is independent of the result tag: a background refresh keeps a `Success` successful (with its value) while `waiting: true`; a failed refresh can retain `previousSuccess`. Failures carry a full `Cause<E>` (typed errors, defects, interruption), not just `E`.
## Refresh, fetch, invalidate
| Method | Behavior |
|---|---|
| `fetch(key)` | fetch a specific key, wait for its final state |
| `fetchView(key)` | start fetching a key, return immediately as a live state `View` |
| `refresh` | resolve the current key again, wait for its final state |
| `refreshView` | resolve the current key again, return immediately as a live `View` |
| `invalidateCacheEntry(key)` | remove the cached success for one key |
| `invalidateCache` | remove every cached success for this query |
The `*View` variants suit synchronous UI callbacks (`runSync(query.refreshView)`); the non-`View` variants suit Effect workflows waiting on the outcome. **Invalidating does not refetch by itself** — follow with `refreshView`, a key change, or a later natural fetch.
```ts
import { Schedule } from "effect"
const query = yield* Query.make(options).pipe(
Query.thenRun,
Query.withScheduledRefresh(Schedule.spaced("5 minutes").pipe(Schedule.upTo({ times: 3 }))),
)
```
`Query.withScheduledRefresh(schedule)` forks a refresh fiber tied to the surrounding scope; cache/`staleTime` rules still apply.
## Staleness and lifetime
- `staleTime` (per query, falls back to `QueryClient` default): how long a successful result satisfies a fetch without re-running `f`. A stale entry stays available as previous data while it refreshes.
- `cacheGcTime` (on `QueryClient`): entries unused for `staleTime + cacheGcTime` are evicted.
- `refreshOnWindowFocus` (per query, falls back to `QueryClient` default): re-resolves the current key on window focus. Requires the optional `@effect/platform-browser` package; without it, the option is silently ignored (rest of the API works normally). Also a no-op outside browser environments.
## The Effect touch
Request fibers belong to the creation scope and are interrupted on unmount, key replacement, or scope closure. Results are `View`s usable outside React too. Mutations do not auto-invalidate queries — compose it explicitly (see `Mutation.md`).
@@ -0,0 +1,39 @@
# ReactRuntime
Owns a managed Effect runtime and exposes it to a React subtree through context. Every effect-view application has exactly one root runtime per independent Effect context tree.
## Create and provide
```tsx
import { Layer } from "effect"
import { ReactRuntime } from "effect-view"
const AppLive = Layer.empty // add application layers here
export const runtime = ReactRuntime.make(AppLive)
```
```tsx
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { ReactRuntime } from "effect-view"
import { runtime } from "./runtime"
import { App } from "./App"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ReactRuntime.Provider runtime={runtime} fallback={<p>Starting...</p>}>
<App />
</ReactRuntime.Provider>
</StrictMode>,
)
```
- `ReactRuntime.make(layer, memoMap?)` builds a `ManagedRuntime` and a `React.Context` in one value. Define it at module scope — never inside a render.
- `ReactRuntime.Provider` builds the runtime layer (which can suspend, hence `fallback`), makes the resulting context available via React context, and disposes the managed runtime on unmount.
- With a router, keep the provider above the router provider. Put a React error boundary above it if runtime construction can fail.
## Rules
- One runtime instance per app (or per independent subtree that needs its own root context).
- `ReactRuntime.Provider` only supplies context; it never builds automatically at other boundaries — `Component.withContext` reads it explicitly.
+87
View File
@@ -0,0 +1,87 @@
# State: Lens and View
`Lens` and `View` are effect-view's state primitives, re-exported in full from [`effect-lens`](https://www.npmjs.com/package/effect-lens/v/beta) with two React hooks added on top (`Lens.useState`, `View.useAll`). The core data model, constructors, and focus/derive API belong to `effect-lens` — consult its docs for anything not covered here.
- **`View<A>`** is the read-only half: a current value plus a stream of changes. Use it for anything a component should only observe (derived/computed state, `Query`/`Mutation` state, a read-only field exposed by a service).
- **`Lens<A>`** is a `View<A>` that can also be written to. Every `Lens` is a `View`, so anywhere a `View` is expected, a `Lens` works too.
## Create state
```tsx
import { SubscriptionRef } from "effect"
import { Lens } from "effect-view"
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
```
The usual pattern: build state with an Effect primitive (`SubscriptionRef`), then wrap it as a `Lens` with a matching constructor.
## Where to store it
| Owner | When |
|---|---|
| Effect service (`Context.Service` + `Layer`) | shared by multiple components / application-level state |
| `Component.useOnMount` | owned by one component instance (or a shallow subtree receiving it via props) |
| plain `React.useState` | simple local UI state with no need for Effect integration, subscriptions, or sharing |
```tsx
class CounterState extends Context.Service<CounterState, {
readonly count: Lens.Lens<number>
readonly doubled: View.View<number>
}>()("CounterState") {
static readonly layer = Layer.effect(CounterState, Effect.gen(function* () {
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
const doubled = View.map(count, n => n * 2) // derived, read-only
return { count, doubled } as const
}))
}
```
## Read: View.useAll
```tsx
const [count, doubled] = yield* View.useAll([state.count, state.doubled])
```
- Reads current values during render, then subscribes via a scoped stream and updates React state on change.
- This is the default way to read a `Lens` too, since `Lens` is a `View`.
- `View.useAll(views, { equivalence? })``equivalence` controls when a combined change across the supplied views counts as meaningful (defaults to comparing element-wise with `Equal.strictEqual()`).
## Write
```tsx
yield* Lens.update(state.count, n => n + 1)
yield* Lens.set(state.count, 0)
```
## Lens.useState — read/write tuple for controlled inputs
Use when a JSX API wants React's `[value, setValue]` shape (controlled `<input>`, checkbox, select, third-party `value`/`onChange` props). If a component only needs to *display* the value, prefer `View.useAll` — reach for `Lens.useState` only where reading and writing need to be wired together in React's local-state shape.
```tsx
const [name, setName] = yield* Lens.useState(state.name)
<input value={name} onChange={e => setName(e.currentTarget.value)} />
```
Calling the setter writes through the `Lens`, so every other subscriber (another `Lens.useState`, or a `View.useAll` elsewhere) sees the update. `Lens.useState(lens, { equivalence? })` controls when a change triggers a re-render. The setter accepts a plain value or a `prev => next` updater, same as `React.useState`'s.
## Focused Lenses
A focused Lens is still a `Lens` — read it with `View.useAll` or `Lens.useState` like any other.
```tsx
const nameLens = Lens.focusObjectOn(state.profile, "name")
const cityLens = state.profile.pipe(
Lens.focusObjectOn("contact"),
Lens.focusObjectOn("address"),
Lens.focusObjectOn("city"),
)
```
- Focus helpers (`focusObjectOn`, `focusArrayAt`, `focusTupleAt`, `focusChunkAt`, ...) are dual API: data-first (`Lens.focusObjectOn(lens, key)`) or curried for `pipe` chaining through nested paths.
- Create focused lenses once (e.g. in `Component.useOnMount`), not on every render. Writes through a focused lens propagate to the parent lens.
- Full focus/derive/custom-write API: see the `effect-lens` docs.
## Bridging plain React state into a Lens
`Lens.useFromReactState([value, setValue])` wraps an existing React state tuple as a `Lens`, keeping both directions in sync — useful when integrating a third-party hook that already owns `[value, setValue]` state.
+12
View File
@@ -0,0 +1,12 @@
# Stream
Re-exports Effect's `Stream` module in full (`export * from "effect/Stream"`) and adds one component hook for consuming a stream as React state.
```tsx
const latest = yield* Stream.use(someStream) // Effect<Option<A>, never, R>
const latest = yield* Stream.use(someStream, initialValue) // Effect<Some<A>, never, R>
```
`Stream.use(stream, initialValue?)` subscribes to `stream` for the component's lifetime (via a scoped fiber forked in a post-commit effect) and returns the latest emitted value as React state, deduped with strict equality. Without `initialValue` the result starts as `Option.none()` until the first emission; with one, it starts as `Option.some(initialValue)`.
Prefer `View.useAll` (see `State.md`) when the source is already a `View`/`Lens` — reach for `Stream.use` when you have a raw Effect `Stream` to observe directly.
+8 -6
View File
@@ -1,14 +1,16 @@
{
"name": "effect-view",
"description": "Write React function components with Effect",
"version": "0.1.4",
"version": "0.1.6",
"type": "module",
"files": [
"./README.md",
"./AGENTS.md",
"./src/**/*.ts",
"./dist/**/*.js",
"./dist/**/*.js.map",
"./dist/**/*.d.ts"
"./dist/**/*.d.ts",
"./ai-docs/**/*.md"
],
"license": "MIT",
"repository": {
@@ -100,19 +102,19 @@
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect/platform-browser": "4.0.0-rc.109",
"@effect/platform-browser": "4.0.0-rc.115",
"@testing-library/react": "^16.3.0",
"effect": "4.0.0-rc.109",
"effect": "4.0.0-rc.115",
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
"@types/react": "^19.2.0",
"effect": "4.0.0-rc.109",
"effect": "4.0.0-rc.115",
"react": "^19.2.0"
},
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"effect-lens": "2.0.1-rc.109"
"effect-lens": "2.0.2-rc.115"
}
}
+23
View File
@@ -77,6 +77,29 @@ describe("Mutation", () => {
expect(result.result.previousSuccess.value.value).toBe("saved")
})
it("runs a second mutation with its own key, not the previous one", async () => {
const result = await runMutationTest(Effect.gen(function*() {
const calls: Array<string> = []
const mutation = yield* Mutation.make({
f: (key: string) => Effect.sync(() => {
calls.push(key)
return key
}),
})
const first = yield* mutation.mutate("a")
const second = yield* mutation.mutate("b")
return { calls, first, second }
}))
expect(result.calls).toEqual(["a", "b"])
expect(result.first.key.value).toBe("a")
expect(expectSuccessValue(result.first)).toBe("a")
expect(result.second.key.value).toBe("b")
expect(expectSuccessValue(result.second)).toBe("b")
})
it("mutateView returns a waiting state without waiting for completion", async () => {
const result = await runMutationTest(Effect.gen(function*() {
const deferred = yield* Deferred.make<string>()
+11 -9
View File
@@ -78,8 +78,10 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
Scope.Scope | R
> {
return Effect.gen({ self: this }, function*() {
const currentKey = Option.some(key) as Option.Some<K>
const previous: MutationState<K, A, E> = Option.getOrElse(yield* Lens.get(this.latestFinalState), () => ({
key: Option.some(key) as Option.Some<K>,
key: currentKey,
result: AsyncResult.initial(),
}))
const state = yield* makeMutationStateLens(previous)
@@ -89,17 +91,17 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
state,
previous => AsyncResult.match(previous.result, {
onInitial: () => ({
key: previous.key,
key: currentKey,
result: AsyncResult.initial(true),
}),
onSuccess: result => ({
key: previous.key,
key: currentKey,
result: AsyncResult.success(result.value, {
waiting: true,
}),
}),
onFailure: result => ({
key: previous.key,
key: currentKey,
result: AsyncResult.failure(result.cause, {
waiting: true,
previousSuccess: result.previousSuccess,
@@ -108,7 +110,7 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
}
)),
Effect.onExit(this.f(previous.key.value), exit => Effect.gen({ self: this }, function*() {
Effect.onExit(this.f(key), exit => Effect.gen({ self: this }, function*() {
const fiberId = yield* Effect.fiberId
const fiber = yield* Lens.get(this.fiber)
@@ -119,24 +121,24 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
state,
previous => Exit.match(exit, {
onSuccess: v => ({
key: previous.key,
key: currentKey,
result: AsyncResult.success(v),
}),
onFailure: c => Cause.hasInterruptsOnly(c)
? previous
: AsyncResult.match(previous.result, {
onInitial: () => ({
key: previous.key,
key: currentKey,
result: AsyncResult.failure(c),
}),
onSuccess: v => ({
key: previous.key,
key: currentKey,
result: AsyncResult.failure(c, {
previousSuccess: Option.some(v),
}),
}),
onFailure: v => ({
key: previous.key,
key: currentKey,
result: AsyncResult.failure(c, {
previousSuccess: v.previousSuccess,
}),
+3 -3
View File
@@ -27,15 +27,15 @@
"vite": "^8.0.16"
},
"dependencies": {
"@effect/platform-browser": "4.0.0-rc.109",
"@effect/platform-browser": "4.0.0-rc.115",
"@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-rc.109",
"effect": "4.0.0-rc.115",
"effect-view": "workspace:*",
"react-icons": "^5.6.0"
},
"overrides": {
"@types/react": "^19.2.15",
"effect": "4.0.0-rc.109",
"effect": "4.0.0-rc.115",
"react": "^19.2.6"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@effect-view/vite-plugin",
"description": "Vite Fast Refresh support for Effect View components",
"version": "0.0.1",
"version": "0.0.2",
"type": "module",
"files": [
"./README.md",
+131 -38
View File
@@ -33,11 +33,10 @@ interface ComponentImports {
interface Definition {
readonly expression: ts.Expression
readonly factoryCall: ts.CallExpression
readonly wrapTarget: ts.Expression
readonly body: ts.FunctionLikeDeclaration | undefined
readonly id: string
readonly pipeline: ts.CallExpression | undefined
readonly entrypointIndex: number
readonly site: EntrypointSite.Pipe | undefined
}
const defaultInclude = /\.[cm]?[jt]sx?$/
@@ -140,6 +139,13 @@ const findFactoryCall = (
if (result)
return
// Never look inside a nested function body: a factory call there
// belongs to a different, unrelated component (e.g. one composed
// dynamically inside this component's own render), not to the
// composition shape of the definition being analyzed.
if (node !== root && ts.isFunctionLike(node))
return
if (ts.isCallExpression(node)) {
if (isFactoryCallee(node.expression, imports)) {
result = {
@@ -165,18 +171,83 @@ const findFactoryCall = (
return result
}
const isPipeline = (expression: ts.Expression): expression is ts.CallExpression =>
ts.isCallExpression(expression)
&& ts.isPropertyAccessExpression(expression.expression)
&& expression.expression.name.text === "pipe"
const isPipeline = (node: ts.Node): node is ts.CallExpression =>
ts.isCallExpression(node)
&& ts.isPropertyAccessExpression(node.expression)
&& node.expression.name.text === "pipe"
const isEntrypoint = (expression: ts.Expression): boolean => {
if (!ts.isCallExpression(expression))
return false
const entrypointName = (callee: ts.Expression): boolean =>
ts.isPropertyAccessExpression(callee)
&& (callee.name.text === "withRuntime" || callee.name.text === "withContext")
const callee = expression.expression
return ts.isPropertyAccessExpression(callee)
&& (callee.name.text === "withRuntime" || callee.name.text === "withContext")
/**
* A curried entrypoint call (`withContext(context)`) used as one step of a
* `.pipe(...)` chain: the descriptor produced by the preceding steps must be
* registered *before* this step runs, since it is what converts the
* descriptor into a plain React function component.
*/
const isEntrypoint = (expression: ts.Expression): boolean =>
ts.isCallExpression(expression) && entrypointName(expression.expression)
/**
* The data-first form of an entrypoint call (`withContext(descriptor,
* context)`), which can appear anywhere - not only inside a `.pipe(...)`
* chain - and always takes the descriptor to convert as its first argument.
*/
const isEntrypointDataFirst = (node: ts.Node): node is ts.CallExpression =>
ts.isCallExpression(node)
&& entrypointName(node.expression)
&& node.arguments.length >= 2
declare namespace EntrypointSite {
export interface Pipe {
readonly kind: "pipe"
readonly pipeCall: ts.CallExpression
readonly argIndex: number
}
export interface DataFirst {
readonly kind: "dataFirst"
readonly call: ts.CallExpression
}
}
type EntrypointSite = EntrypointSite.Pipe | EntrypointSite.DataFirst
/**
* Finds where, anywhere within a definition's composition expression, a
* descriptor is converted into a plain React function component - whether
* through a `.pipe(..., withContext(context))` chain or a direct
* `withContext(descriptor, context)` call. Does not look inside nested
* function bodies, for the same reason as `findFactoryCall`.
*/
const findEntrypointSite = (root: ts.Node): EntrypointSite | undefined => {
let result: EntrypointSite | undefined
const visit = (node: ts.Node): void => {
if (result)
return
if (node !== root && ts.isFunctionLike(node))
return
if (isPipeline(node)) {
const argIndex = node.arguments.findIndex(isEntrypoint)
if (argIndex >= 0) {
result = { kind: "pipe", pipeCall: node, argIndex }
return
}
}
else if (isEntrypointDataFirst(node)) {
result = { kind: "dataFirst", call: node }
return
}
ts.forEachChild(node, visit)
}
visit(root)
return result
}
const makeDefinition = (
@@ -185,25 +256,33 @@ const makeDefinition = (
imports: ComponentImports,
): Definition | undefined => {
const factory = findFactoryCall(expression, imports)
const site = findEntrypointSite(expression)
// A `.pipe(..., withContext(context))` chain always needs the splice
// treatment, whether or not a factory call could be found in this same
// statement (its base may be a component defined elsewhere).
if (site?.kind === "pipe")
return { expression, wrapTarget: expression, body: factory?.body, id, site }
// `withContext(descriptor, context)` always needs its descriptor
// argument wrapped directly, since by the time the call returns the
// result is a plain function component, not a Component descriptor.
if (site?.kind === "dataFirst") {
const target = site.call.arguments[0]
if (!target)
return undefined
return { expression, wrapTarget: target, body: factory?.body, id, site: undefined }
}
// No entrypoint conversion happens within this statement: the
// descriptor remains a Component.Any throughout, however it got here
// (a bare factory call, a `.pipe()` of traits with no withContext, or a
// user-defined composition helper wrapping either) - safe to register
// the whole expression.
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,
}
return { expression, wrapTarget: expression, body: factory.body, id, site: undefined }
}
const collectDefinitions = (
@@ -260,9 +339,19 @@ const collectDefinitions = (
return definitions
}
/**
* Computes a signature for the *shape* of a component's hook calls: the
* ordered sequence of hook names it calls at its top level, ignoring
* everything else (argument contents, dependency array entries, unrelated
* logic). Two bodies calling the same hooks in the same order hash to the
* same signature even if most of their code differs, so edits that don't
* touch which hooks run - the overwhelming majority of edits - refresh in
* place instead of forcing a remount. This mirrors how React's own Fast
* Refresh only forces a remount when the hook call sequence itself changes,
* not when a hook's arguments or the surrounding logic change.
*/
const hookSignature = (
body: ts.FunctionLikeDeclaration | undefined,
sourceFile: ts.SourceFile,
): string => {
if (!body?.body)
return "unknown"
@@ -276,14 +365,18 @@ const hookSignature = (
if (ts.isCallExpression(node)) {
const callee = node.expression
const name = ts.isIdentifier(callee)
const localName = 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))
if (localName && /^use[A-Z0-9]/.test(localName)) {
const qualifier = ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)
? `${callee.expression.text}.`
: ""
hooks.push(`${qualifier}${localName}`)
}
}
ts.forEachChild(node, visit)
@@ -384,10 +477,10 @@ export function effectView(
const edits: Edit[] = []
for (const definition of definitions) {
const signature = hookSignature(definition.body, sourceFile)
const signature = hookSignature(definition.body)
if (definition.pipeline && definition.entrypointIndex >= 0) {
const entrypoint = definition.pipeline.arguments[definition.entrypointIndex]
if (definition.site?.kind === "pipe") {
const entrypoint = definition.site.pipeCall.arguments[definition.site.argIndex]
if (!entrypoint)
continue
edits.push({
@@ -403,8 +496,8 @@ export function effectView(
continue
}
const start = definition.expression.getStart(sourceFile)
const end = definition.expression.end
const start = definition.wrapTarget.getStart(sourceFile)
const end = definition.wrapTarget.end
edits.push({
start,
end,
+73
View File
@@ -135,4 +135,77 @@ export const Legacy = Component.makeUntraced(function*() {
})
`)).toBeUndefined()
})
it("registers a component wrapped by a user-defined helper function", async () => {
const result = await transform(`
import { Component } from "effect-view"
const withLogging = (view) => view
export const LoggedView = withLogging(Component.make("LoggedView")(function*() {
return <div />
}))
`)
expect(result).toContain("__effectViewRefresh(withLogging(Component.make(\"LoggedView\")")
expect(result).toContain("\"src/View.tsx:LoggedView\"")
})
it("wraps the descriptor argument of a data-first withContext call", async () => {
const result = await transform(`
import { Component } from "effect-view"
const HomeBase = Component.make("Home")(function*() {
return <div />
})
export const Home = Component.withContext(HomeBase, runtime.context)
`)
expect(result).toContain("__effectViewRefresh(Component.make(\"Home\")")
expect(result).toContain("Component.withContext(__effectViewRefresh(HomeBase, import.meta.hot, \"src/View.tsx:Home\", \"unknown\", false), runtime.context)")
})
it("keeps the same hook signature when only literal content inside a hook call changes", async () => {
const before = await transform(`
import { Component } from "effect-view"
export const CounterView = Component.make("CounterView")(function*() {
const value = yield* Component.useOnMount(() => loadInitial(1))
return <div>{value}</div>
})
`)
const after = await transform(`
import { Component } from "effect-view"
export const CounterView = Component.make("CounterView")(function*() {
const value = yield* Component.useOnMount(() => loadInitial(2))
return <div>{value}</div>
})
`)
const signatureOf = (code: string | undefined) => code?.match(/"src\/View\.tsx:CounterView", "([a-z0-9]+)"/)?.[1]
expect(signatureOf(before)).toBeDefined()
expect(signatureOf(before)).toBe(signatureOf(after))
})
it("changes the hook signature when a hook call is added", async () => {
const before = await transform(`
import { Component } from "effect-view"
export const CounterView = Component.make("CounterView")(function*() {
const value = yield* Component.useOnMount(() => loadInitial())
return <div>{value}</div>
})
`)
const after = await transform(`
import { Component } from "effect-view"
export const CounterView = Component.make("CounterView")(function*() {
const value = yield* Component.useOnMount(() => loadInitial())
yield* Component.useReactEffect(() => trackView(), [])
return <div>{value}</div>
})
`)
const signatureOf = (code: string | undefined) => code?.match(/"src\/View\.tsx:CounterView", "([a-z0-9]+)"/)?.[1]
expect(signatureOf(before)).toBeDefined()
expect(signatureOf(before)).not.toBe(signatureOf(after))
})
})