diff --git a/bun.lock b/bun.lock index a30a084..cf8ffc5 100644 --- a/bun.lock +++ b/bun.lock @@ -54,6 +54,23 @@ "react": "^19.2.0", }, }, + "packages/effect-fc-next": { + "name": "effect-fc-next", + "version": "0.1.0-beta.0", + "dependencies": { + "effect-lens": "2.0.0-beta.0", + }, + "devDependencies": { + "@testing-library/react": "^16.3.0", + "jsdom": "^26.1.0", + "vitest": "^3.2.4", + }, + "peerDependencies": { + "@types/react": "^19.2.0", + "effect": "4.0.0-beta.85", + "react": "^19.2.0", + }, + }, "packages/example": { "name": "@effect-fc/example", "version": "0.0.0", @@ -80,6 +97,23 @@ "vite": "^8.0.16", }, }, + "packages/example-next": { + "name": "@effect-fc/example-next", + "version": "0.0.0", + "dependencies": { + "effect": "4.0.0-beta.85", + "effect-fc-next": "workspace:*", + }, + "devDependencies": { + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "globals": "^17.6.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "vite": "^8.0.16", + }, + }, }, "packages": { "@algolia/abtesting": ["@algolia/abtesting@1.19.0", "", { "dependencies": { "@algolia/client-common": "5.53.0", "@algolia/requester-browser-xhr": "5.53.0", "@algolia/requester-fetch": "5.53.0", "@algolia/requester-node-http": "5.53.0" } }, "sha512-Lhnez3hhXHk25lfxLAMxvkP4fmN3+1RgADhD2ssMDBYuAsDVReeyP+3SGRx+ntq8ijMrLqUyfvO72TB6jsTteQ=="], @@ -512,6 +546,8 @@ "@effect-fc/example": ["@effect-fc/example@workspace:packages/example"], + "@effect-fc/example-next": ["@effect-fc/example-next@workspace:packages/example-next"], + "@effect/language-service": ["@effect/language-service@0.86.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-SaPln+8srOqDJDUwNTDmP5e+IYpEDr9+1epGznnsLqu8xvo6VnxyWARdeLpqvZJlb0Pgy9ca7ppqvvdWbHPXAg=="], "@effect/platform": ["@effect/platform@0.96.1", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.10", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.21.2" } }, "sha512-cjB1QZZYEP8JXCFNGvBLVi0T6YUBQTmOVEUA3SDbiQ6RUO+p6CE3eyD2vMWmrz5nE8yY5QSAuOV9v0boEcUv+A=="], @@ -1574,6 +1610,8 @@ "effect-fc": ["effect-fc@workspace:packages/effect-fc"], + "effect-fc-next": ["effect-fc-next@workspace:packages/effect-fc-next"], + "effect-lens": ["effect-lens@0.2.0", "", { "peerDependencies": { "effect": "^3.21.0" } }, "sha512-Cfyps811WQVSxnbxm6xqJldL08YXUU1jAzWtdKXSKWZfF6LAEqm1l8swnw4ltVky1TSfi2vvS+I/tFPqwObrMg=="], "electron-to-chromium": ["electron-to-chromium@1.5.368", "", {}, "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw=="], @@ -1844,7 +1882,7 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@2.0.0", "", {}, "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA=="], + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -1948,6 +1986,8 @@ "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "latest-version": ["latest-version@7.0.0", "", { "dependencies": { "package-json": "^8.1.0" } }, "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg=="], "launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="], @@ -2758,6 +2798,8 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], @@ -2840,7 +2882,7 @@ "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], "value-equal": ["value-equal@1.0.1", "", {}, "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw=="], @@ -2920,6 +2962,8 @@ "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=="], @@ -2950,6 +2994,8 @@ "@docusaurus/utils/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + "@effect-fc/example-next/effect": ["effect@4.0.0-beta.85", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Cjv9YQyv4CiIccmRAQIWAoeESCpCpiuHYY8zb5vqiYs3Ac2yE5RQAnKq7z4Ir/3VJYZb8kxh6rS8czDDnpTdkQ=="], + "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack": ["@jsonjoy.com/json-pack@17.67.0", "", { "dependencies": { "@jsonjoy.com/base64": "17.67.0", "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0", "@jsonjoy.com/json-pointer": "17.67.0", "@jsonjoy.com/util": "17.67.0", "hyperdyperid": "^1.2.0", "thingies": "^2.5.0", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w=="], "@jsonjoy.com/fs-snapshot/@jsonjoy.com/util": ["@jsonjoy.com/util@17.67.0", "", { "dependencies": { "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew=="], @@ -3016,6 +3062,10 @@ "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], + "effect-fc-next/effect": ["effect@4.0.0-beta.85", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Cjv9YQyv4CiIccmRAQIWAoeESCpCpiuHYY8zb5vqiYs3Ac2yE5RQAnKq7z4Ir/3VJYZb8kxh6rS8czDDnpTdkQ=="], + + "effect-fc-next/effect-lens": ["effect-lens@2.0.0-beta.0", "", { "peerDependencies": { "effect": "4.0.0-beta.85" } }, "sha512-ntsRQSrzoX+GsNpkNVEGqebl/g1YOEDPIFRAHigf+4P6AtGnnOFcklqDHLrbm4zqnaxuWEgQvvn1J6S3eyC4yw=="], + "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], "express/content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], @@ -3030,6 +3080,8 @@ "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "global-dirs/ini": ["ini@2.0.0", "", {}, "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA=="], + "got/@sindresorhus/is": ["@sindresorhus/is@5.6.0", "", {}, "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g=="], "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], @@ -3226,6 +3278,8 @@ "sitemap/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="], + "sockjs/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -3282,6 +3336,10 @@ "@docusaurus/core/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "@effect-fc/example-next/effect/fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="], + + "@effect-fc/example-next/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=="], @@ -3312,6 +3370,10 @@ "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + "effect-fc-next/effect/fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="], + + "effect-fc-next/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=="], @@ -3370,8 +3432,12 @@ "@docusaurus/core/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "@effect-fc/example-next/effect/fast-check/pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + "css-select/domutils/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "effect-fc-next/effect/fast-check/pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + "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=="], diff --git a/packages/effect-fc-next/README.md b/packages/effect-fc-next/README.md new file mode 100644 index 0000000..5c164f8 --- /dev/null +++ b/packages/effect-fc-next/README.md @@ -0,0 +1,56 @@ +# Effect FC Next + +[Effect-TS](https://effect.website/) integration for React 19.2+ that allows you to write function components using Effect generators. + +This library is in early development. While it is (almost) feature complete and mostly usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are more than welcome. + +Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory. + +## Peer dependencies +- `effect` 3.19+ +- `react` & `@types/react` 19.2+ + +## Known issues +- React Refresh doesn't work for Effect FC's yet. Page reload is required to view changes. Regular React components are unaffected. + +## What writing components looks like +```typescript +export class TodosView extends Component.make("TodosView")(function*() { + const state = yield* TodosState + const [todos] = yield* Component.useSubscribables([state.subscriptionRef]) + + yield* Component.useOnMount(() => Effect.andThen( + Console.log("Todos mounted"), + Effect.addFinalizer(() => Console.log("Todos unmounted")), + )) + + const Todo = yield* TodoView.use + + return ( + + Todos + + + + + {Chunk.map(todos, todo => + + )} + + + ) +}) {} + +const Index = Component.make("IndexView")(function*() { + const context = yield* Component.useContextFromLayer(TodosState.Default) + const Todos = yield* Effect.provide(TodosView.use, context) + + return +}).pipe( + Component.withRuntime(runtime.context) +) + +export const Route = createFileRoute("/")({ + component: Index +}) +``` diff --git a/packages/effect-fc-next/biome.json b/packages/effect-fc-next/biome.json new file mode 100644 index 0000000..41d707b --- /dev/null +++ b/packages/effect-fc-next/biome.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://biomejs.dev/schemas/latest/schema.json", + "root": false, + "extends": "//", + "files": { + "includes": ["./src/**"] + } +} diff --git a/packages/effect-fc-next/package.json b/packages/effect-fc-next/package.json new file mode 100644 index 0000000..f29a70b --- /dev/null +++ b/packages/effect-fc-next/package.json @@ -0,0 +1,54 @@ +{ + "name": "effect-fc-next", + "description": "Write React function components with Effect", + "version": "0.1.0-beta.0", + "type": "module", + "files": [ + "./README.md", + "./dist" + ], + "license": "MIT", + "repository": { + "url": "git+https://github.com/Thiladev/effect-fc.git" + }, + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./*": [ + { + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" + }, + { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + } + ] + }, + "scripts": { + "build": "tsc", + "lint:tsc": "tsc --noEmit", + "lint:biome": "biome lint", + "test": "vitest run", + "pack": "npm pack", + "clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo", + "clean:dist": "rm -rf dist", + "clean:modules": "rm -rf node_modules" + }, + "devDependencies": { + "@testing-library/react": "^16.3.0", + "jsdom": "^26.1.0", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "@types/react": "^19.2.0", + "effect": "4.0.0-beta.85", + "react": "^19.2.0" + }, + "dependencies": { + "effect-lens": "2.0.0-beta.0" + } +} diff --git a/packages/effect-fc-next/src/Async.ts b/packages/effect-fc-next/src/Async.ts new file mode 100644 index 0000000..6495308 --- /dev/null +++ b/packages/effect-fc-next/src/Async.ts @@ -0,0 +1,171 @@ +/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ +import { Context, Effect, type Equivalence, Function, Predicate, Scope } from "effect" +import * as React from "react" +import * as Component from "./Component.js" + + +export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async") +export type AsyncTypeId = typeof AsyncTypeId + + +/** + * A trait for `Component`'s that allows them running asynchronous effects. + */ +export interface Async extends AsyncPrototype, AsyncOptions {} + +export interface AsyncPrototype { + readonly [AsyncTypeId]: AsyncTypeId +} + +/** + * Configuration options for `Async` components. + */ +export interface AsyncOptions { + /** + * The default fallback React node to display while the async operation is pending. + * Used if no fallback is provided to the component when rendering. + */ + readonly defaultFallback?: React.ReactNode +} + +/** + * Props for `Async` components. + */ +export type AsyncProps = Omit + + +export const AsyncPrototype: AsyncPrototype = Object.freeze({ + [AsyncTypeId]: AsyncTypeId, + + asFunctionComponent

( + this: Component.Component & Async, + contextRef: React.RefObject>>, + ) { + const Inner = (props: { readonly promise: Promise }) => React.use(props.promise) + + return ({ fallback, name, ...props }: AsyncProps) => { + const promise = Effect.runPromiseWith(contextRef.current)( + Effect.andThen( + Component.useScope([], this), + scope => Effect.provideService(this.body(props as P), Scope.Scope, scope), + ) + ) + + return React.createElement( + React.Suspense, + { fallback: fallback ?? this.defaultFallback, name }, + React.createElement(Inner, { promise }), + ) + } + }, +} as const) + +/** + * An equivalence function for comparing `AsyncProps` that ignores the `fallback` property. + * Used by default by async components with `Memoized.memoized` applied. + */ +export const defaultPropsEquivalence: Equivalence.Equivalence = ( + self: Record, + that: Record, +) => { + if (self === that) + return true + + for (const key in self) { + if (key === "fallback") + continue + if (!(key in that) || !Object.is(self[key], that[key])) + return false + } + + for (const key in that) { + if (key === "fallback") + continue + if (!(key in self)) + return false + } + + return true +} + + +export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, AsyncTypeId) + +/** + * Converts a Component into an `Async` component that supports running asynchronous effects. + * + * Note: The component cannot have a prop named "promise" as it's reserved for internal use. + * + * @param self - The component to convert to an Async component + * @returns A new `Async` component with the same body, error, and context types as the input + * + * @example + * ```ts + * const MyAsyncComponent = MyComponent.pipe( + * Async.async, + * ) + * ``` + */ +export const async = ( + self: T & ( + "promise" extends keyof Component.Component.Props + ? "The 'promise' prop name is restricted for Async components. Please rename the 'promise' prop to something else." + : T + ) +): ( + & Omit> + & Component.Component< + Component.Component.Props & AsyncProps, + Component.Component.Success, + Component.Component.Error, + Component.Component.Context, + Component.Component.DefaultSignature & AsyncProps, Component.Component.Success> + > + & Async +) => Object.setPrototypeOf( + Object.assign(function() {}, self, { propsEquivalence: defaultPropsEquivalence }), + Object.freeze(Object.setPrototypeOf( + Object.assign({}, AsyncPrototype), + Object.getPrototypeOf(self), + )), +) + +/** + * Applies options to an Async component, returning a new Async component with the updated configuration. + * + * Supports both curried and uncurried application styles. + * + * @param self - The Async component to apply options to (in uncurried form) + * @param options - The options to apply to the component + * @returns An Async component with the applied options + * + * @example + * ```ts + * // Curried + * const MyAsyncComponent = MyComponent.pipe( + * Async.async, + * Async.withOptions({ defaultFallback:

Loading...

}), + * ) + * + * // Uncurried + * const MyAsyncComponent = Async.withOptions( + * Async.async(MyComponent), + * { defaultFallback:

Loading...

}, + * ) + * ``` + */ +export const withOptions: { + ( + options: Partial + ): (self: T) => T + ( + self: T, + options: Partial, + ): T +} = Function.dual(2, ( + self: T, + options: Partial, +): T => Object.setPrototypeOf( + Object.assign(function() {}, self, options), + Object.getPrototypeOf(self), +)) diff --git a/packages/effect-fc-next/src/Component.ts b/packages/effect-fc-next/src/Component.ts new file mode 100644 index 0000000..6bf9d93 --- /dev/null +++ b/packages/effect-fc-next/src/Component.ts @@ -0,0 +1,385 @@ +/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */ +import { + Context, + type Duration, + Effect, + Exit, + Function, + identity, + Layer, + Pipeable, + Predicate, + Scope, + Tracer, +} from "effect" +import * as React from "react" + + +export const ComponentTypeId: unique symbol = Symbol.for("@effect-fc/Component/Component") +export type ComponentTypeId = typeof ComponentTypeId + +export interface Component

+extends ComponentPrototype, ComponentOptions { + new(_: never): Record + readonly [ComponentTypeId]: ComponentTypeId + readonly "~Props": P + readonly "~Success": A + readonly "~Error": E + readonly "~Context": R + readonly "~Function": F + readonly body: (props: P) => Effect.Effect +} + +export declare namespace Component { + export type Default

= Component> + export type Any = Component + export type Signature = (props: any) => React.ReactNode + export type DefaultSignature

= (props: P) => A + export type Props = T["~Props"] + export type Success = T["~Success"] + export type Error = T["~Error"] + export type Context = T["~Context"] + export type Function = T["~Function"] + export type AsComponent = Component, Success, Error, Context, Function> +} + +export interface ComponentOptions { + readonly displayName?: string + readonly nonReactiveTags: readonly Context.Key[] + readonly finalizerExecutionStrategy: "sequential" | "parallel" + readonly finalizerExecutionDebounce: Duration.Input +} + +export const defaultOptions: ComponentOptions = { + nonReactiveTags: [Tracer.ParentSpan], + finalizerExecutionStrategy: "sequential", + finalizerExecutionDebounce: "100 millis", +} + +export interface ComponentPrototype extends Pipeable.Pipeable { + readonly [ComponentTypeId]: ComponentTypeId + readonly use: Effect.Effect> +} + +type ComponentImpl = Component + +const makeFunctionComponent = ( + self: ComponentImpl, + contextRef: React.RefObject>, +): Component.Signature => { + if ("asFunctionComponent" in self && typeof self.asFunctionComponent === "function") { + return self.asFunctionComponent(contextRef) + } + const FunctionComponent = (props: {}) => Effect.runSyncWith(contextRef.current)( + Effect.flatMap( + useScope([], self), + scope => Effect.provideService(self.body(props), Scope.Scope, scope), + ), + ) + FunctionComponent.displayName = self.displayName ?? "Anonymous" + return "transformFunctionComponent" in self && typeof self.transformFunctionComponent === "function" + ? self.transformFunctionComponent(FunctionComponent) + : FunctionComponent +} + +const use = Effect.fnUntraced(function* (self: ComponentImpl) { + const context = yield* Effect.context() + const cached = componentCache.get(self) + if (cached !== undefined) { + cached.contextRef.current = context + return cached.component + } + const contextRef = { current: context } + const component = makeFunctionComponent(self, contextRef) + componentCache.set(self, { contextRef, component }) + return component +}) + +const componentCache = new WeakMap } + readonly component: Component.Signature +}>() + +export const ComponentPrototype = Object.freeze({ + [ComponentTypeId]: ComponentTypeId, + ...Pipeable.Prototype, + get use() { + return use(this as ComponentImpl) + }, +}) as unknown as ComponentPrototype + +export const isComponent = (u: unknown): u is Component.Any => Predicate.hasProperty(u, ComponentTypeId) + +type GeneratorBody

= ( + props: P, +) => Effect.fn.Return + +type EffectBody

= ( + props: P, +) => Effect.Effect + +export interface Make { +

( + body: GeneratorBody | EffectBody, + ...pipeables: readonly Function[] + ): Component.Default + (name: string, options?: Tracer.SpanOptionsNoTrace):

( + body: GeneratorBody | EffectBody, + ...pipeables: readonly Function[] + ) => Component.Default +} + +const component = ( + body: Function, + displayName: string | undefined, + traced: boolean, + pipeables: readonly Function[], +): Component.Any => Object.setPrototypeOf( + Object.assign(function() {}, defaultOptions, { + body: traced && displayName + ? Effect.fn(displayName)(body as never, ...pipeables as []) + : Effect.fnUntraced(body as never, ...pipeables as []), + displayName, + }), + ComponentPrototype, +) + +export const make: Make = ((nameOrBody: string | Function, ...args: readonly unknown[]) => { + if (typeof nameOrBody === "string") { + return (body: Function, ...pipeables: readonly Function[]) => component(body, nameOrBody, true, pipeables) + } + return component(nameOrBody, undefined, true, args as readonly Function[]) +}) as Make + +export const makeUntraced: Make = ((nameOrBody: string | Function, ...args: readonly unknown[]) => { + if (typeof nameOrBody === "string") { + return (body: Function, ...pipeables: readonly Function[]) => component(body, nameOrBody, false, pipeables) + } + return component(nameOrBody, undefined, false, args as readonly Function[]) +}) as Make + +export declare namespace withSignature { + export type Result = ( + & Omit> + & Component, Component.Success, Component.Error, Component.Context, F> + ) +} + +export const withSignature: { + (): (self: T) => withSignature.Result + (self: T): withSignature.Result +} = (self?: Component.Any): any => self === undefined ? identity : self + +export const withOptions: { + (options: Partial): (self: T) => T + (self: T, options: Partial): T +} = Function.dual(2, (self: T, options: Partial): T => Object.setPrototypeOf( + Object.assign(function() {}, self, options), + Object.getPrototypeOf(self), +)) + +export const withRuntime: { +

( + context: React.Context>, + ): (self: Component, F>) => F +

( + self: Component, F>, + context: React.Context>, + ): F +} = Function.dual(2,

( + self: Component, + context: React.Context>, +) => function WithRuntime(props: P) { + return React.createElement( + Effect.runSyncWith(React.useContext(context))(self.use) as React.FC

, + props, + ) +}) + +export declare namespace useScope { + export interface Options { + readonly finalizerExecutionStrategy?: "sequential" | "parallel" + readonly finalizerExecutionDebounce?: Duration.Input + } +} + +export const useScope = Effect.fnUntraced(function* ( + deps: React.DependencyList, + options?: useScope.Options, +): Effect.fn.Return { + const context = yield* Effect.context() + const contextRef = React.useRef(context) + contextRef.current = context + const scope = React.useMemo( + () => Scope.makeUnsafe(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy), + // biome-ignore lint/correctness/useExhaustiveDependencies: caller controls scope lifetime + deps, + ) + + React.useEffect(() => { + const pending = scopeCleanupTimers.get(scope) + if (pending !== undefined) clearTimeout(pending) + return () => { + const timer = setTimeout(() => { + Effect.runSyncWith(contextRef.current)(Scope.close(scope, Exit.succeed(undefined))) + scopeCleanupTimers.delete(scope) + }, durationMillis(options?.finalizerExecutionDebounce ?? defaultOptions.finalizerExecutionDebounce)) + scopeCleanupTimers.set(scope, timer) + } + }, [scope, options?.finalizerExecutionDebounce]) + + return scope +}) + +const scopeCleanupTimers = new WeakMap>() + +const durationMillis = (input: Duration.Input): number => { + if (typeof input === "number") return input + return Number(Effect.runSync(Effect.map(Effect.succeed(input), value => { + const match = typeof value === "string" ? /([\d.]+)\s*(millis|seconds?)/.exec(value) : undefined + if (!match) return 0 + return Number(match[1]) * (match[2].startsWith("second") ? 1_000 : 1) + }))) +} + +export const useOnMount = Effect.fnUntraced(function* ( + f: () => Effect.Effect, +): Effect.fn.Return { + const context = yield* Effect.context() + const id = React.useId() + let cached = mountCache.get(id) + if (cached === undefined) { + cached = { + effect: Effect.runSyncWith(context)(Effect.cached(f())), + } + mountCache.set(id, cached) + } + React.useEffect(() => { + if (cached?.cleanup !== undefined) clearTimeout(cached.cleanup) + const entry = cached + return () => { + entry.cleanup = setTimeout(() => mountCache.delete(id), 0) + } + }, [id, cached]) + return yield* cached.effect as Effect.Effect +}) + +const mountCache = new Map + cleanup?: ReturnType +}>() + +export declare namespace useOnChange { + export interface Options extends useScope.Options {} +} + +export const useOnChange = Effect.fnUntraced(function* ( + f: () => Effect.Effect, + deps: React.DependencyList, + options?: useOnChange.Options, +): Effect.fn.Return> { + const context = yield* Effect.context>() + const scope = yield* useScope(deps, options) + const cached = + // biome-ignore lint/correctness/useExhaustiveDependencies: scope tracks the caller-provided dependency list + React.useMemo( + () => Effect.runSyncWith(context)(Effect.cached(Effect.provideService(f(), Scope.Scope, scope))), + [scope], + ) + return yield* cached +}) + +export declare namespace useReactEffect { + export interface Options { + readonly finalizerExecutionMode?: "sync" | "fork" + readonly finalizerExecutionStrategy?: "sequential" | "parallel" + } +} + +const runReactEffect = ( + context: Context.Context>, + f: () => Effect.Effect, + options?: useReactEffect.Options, +): (() => void) => { + const scope = Scope.makeUnsafe(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy) + Effect.runSyncWith(context)(Effect.exit(Effect.provideService(f(), Scope.Scope, scope))) + return () => { + const close = Scope.close(scope, Exit.succeed(undefined)) + if ((options?.finalizerExecutionMode ?? "fork") === "sync") Effect.runSyncWith(context)(close) + else Effect.runForkWith(context)(close) + } +} + +export const useReactEffect = Effect.fnUntraced(function* ( + f: () => Effect.Effect, + deps?: React.DependencyList, + options?: useReactEffect.Options, +): Effect.fn.Return> { + const context = yield* Effect.context>() + // biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies + React.useEffect(() => runReactEffect(context, f, options), deps) +}) + +export declare namespace useReactLayoutEffect { + export interface Options extends useReactEffect.Options {} +} + +export const useReactLayoutEffect = Effect.fnUntraced(function* ( + f: () => Effect.Effect, + deps?: React.DependencyList, + options?: useReactLayoutEffect.Options, +): Effect.fn.Return> { + const context = yield* Effect.context>() + // biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies + React.useLayoutEffect(() => runReactEffect(context, f, options), deps) +}) + +export const useRunSync = (): Effect.Effect< + (effect: Effect.Effect) => A, + never, + R +> => Effect.map(Effect.context(), Effect.runSyncWith) + +export const useRunPromise = (): Effect.Effect< + (effect: Effect.Effect) => Promise, + never, + R +> => Effect.map(Effect.context(), Effect.runPromiseWith) + +export const useCallbackSync = Effect.fnUntraced(function* ( + f: (...args: Args) => Effect.Effect, + deps: React.DependencyList, +): Effect.fn.Return<(...args: Args) => A, never, R> { + const context = yield* Effect.context() + const contextRef = React.useRef(context) + contextRef.current = context + // biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies + return React.useCallback((...args: Args) => Effect.runSyncWith(contextRef.current)(f(...args)), deps) +}) + +export const useCallbackPromise = Effect.fnUntraced(function* ( + f: (...args: Args) => Effect.Effect, + deps: React.DependencyList, +): Effect.fn.Return<(...args: Args) => Promise, never, R> { + const context = yield* Effect.context() + const contextRef = React.useRef(context) + contextRef.current = context + // biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies + return React.useCallback((...args: Args) => Effect.runPromiseWith(contextRef.current)(f(...args)), deps) +}) + +export declare namespace useContext { + export interface Options extends useOnChange.Options {} +} + +export const useContextFromLayer = ( + layer: Layer.Layer, + options?: useContext.Options, +): Effect.Effect, E, RIn | Scope.Scope> => useOnChange( + () => Effect.flatMap( + Effect.context(), + context => Layer.build(Layer.provide(layer, Layer.succeedContext(context))), + ), + [layer], + options, +) diff --git a/packages/effect-fc-next/src/ErrorObserver.ts b/packages/effect-fc-next/src/ErrorObserver.ts new file mode 100644 index 0000000..0fd19aa --- /dev/null +++ b/packages/effect-fc-next/src/ErrorObserver.ts @@ -0,0 +1,47 @@ +import { type Cause, Context, Effect, Layer, Option, Pipeable, Predicate, PubSub, type Scope } from "effect" + + +export const ErrorObserverTypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver") +export type ErrorObserverTypeId = typeof ErrorObserverTypeId + +export interface ErrorObserver extends Pipeable.Pipeable { + readonly [ErrorObserverTypeId]: ErrorObserverTypeId + handle(effect: Effect.Effect): Effect.Effect + readonly subscribe: Effect.Effect>, never, Scope.Scope> +} + +export const ErrorObserver = () => Context.Service>( + "@effect-fc/ErrorObserver/ErrorObserver", +) + +export class ErrorObserverImpl extends Pipeable.Class implements ErrorObserver { + readonly [ErrorObserverTypeId]: ErrorObserverTypeId = ErrorObserverTypeId + + constructor(readonly pubsub: PubSub.PubSub>) { + super() + } + + get subscribe(): Effect.Effect>, never, Scope.Scope> { + return PubSub.subscribe(this.pubsub) + } + + handle(effect: Effect.Effect): Effect.Effect { + return Effect.tapCause(effect, cause => Effect.asVoid( + PubSub.publish(this.pubsub, cause as unknown as Cause.Cause), + )) + } +} + +export const isErrorObserver = (u: unknown): u is ErrorObserver => Predicate.hasProperty(u, ErrorObserverTypeId) + +export const layer: Layer.Layer = Layer.effect(ErrorObserver())( + Effect.map(PubSub.unbounded>(), pubsub => new ErrorObserverImpl(pubsub)), +) + +export const handle = (effect: Effect.Effect): Effect.Effect => Effect.flatMap( + Effect.serviceOption(ErrorObserver()), + Option.match({ + onSome: observer => observer.handle(effect), + onNone: () => effect, + }), +) diff --git a/packages/effect-fc-next/src/Form.ts b/packages/effect-fc-next/src/Form.ts new file mode 100644 index 0000000..30039aa --- /dev/null +++ b/packages/effect-fc-next/src/Form.ts @@ -0,0 +1,279 @@ +import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect" +import type * as React from "react" +import * as Component from "./Component.js" +import * as Lens from "./Lens.js" +import * as Subscribable from "./Subscribable.js" + + +export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form") +export type FormTypeId = typeof FormTypeId + +export interface FormIssue { + readonly path: readonly PropertyKey[] + readonly message: string +} + +export interface Form +extends Pipeable.Pipeable { + readonly [FormTypeId]: FormTypeId + + readonly path: P + readonly value: Subscribable.Subscribable, ER, never> + readonly encodedValue: Lens.Lens + readonly issues: Subscribable.Subscribable + readonly isValidating: Subscribable.Subscribable + readonly canCommit: Subscribable.Subscribable + readonly isCommitting: Subscribable.Subscribable +} + +export class FormImpl +extends Pipeable.Class implements Form { + readonly [FormTypeId]: FormTypeId = FormTypeId + + constructor( + readonly path: P, + readonly value: Subscribable.Subscribable, ER, never>, + readonly encodedValue: Lens.Lens, + readonly issues: Subscribable.Subscribable, + readonly isValidating: Subscribable.Subscribable, + readonly canCommit: Subscribable.Subscribable, + readonly isCommitting: Subscribable.Subscribable, + ) { + super() + } +} + + +export const isForm = (u: unknown): u is Form => Predicate.hasProperty(u, FormTypeId) + + +const filterIssuesByPath = ( + issues: readonly FormIssue[], + path: readonly PropertyKey[], +): readonly FormIssue[] => Array.filter(issues, issue => + issue.path.length >= path.length && Array.every(path, (p, i) => p === issue.path[i]) +) + +export const focusObjectOn: { +

( + self: Form, + key: K, + ): Form +

( + key: K, + ): (self: Form) => Form +} = Function.dual(2,

( + self: Form, + key: K, +): Form => { + const form = self as unknown as FormImpl + const path = [...form.path, key] as const + + return new FormImpl( + path, + Subscribable.mapOption(form.value, a => a[key]), + Lens.focusObjectOn(form.encodedValue, key), + Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)), + form.isValidating, + form.canCommit, + form.isCommitting, + ) +}) + +export const focusArrayAt: { +

( + self: Form, + index: number, + ): Form +

( + index: number, + ): (self: Form) => Form +} = Function.dual(2,

( + self: Form, + index: number, +): Form => { + const form = self as unknown as FormImpl + const path = [...form.path, index] as const + + return new FormImpl( + path, + Subscribable.mapOptionEffect(form.value, values => Effect.fromOption(Array.get(values, index))), + Lens.focusArrayAt(form.encodedValue, index), + Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)), + form.isValidating, + form.canCommit, + form.isCommitting, + ) +}) + +export const focusTupleAt: { +

( + self: Form, + index: K, + ): Form +

( + index: K, + ): (self: Form) => Form +} = Function.dual(2,

( + self: Form, + index: K, +): Form => { + const form = self as unknown as FormImpl + const path = [...form.path, index] as const + + return new FormImpl( + path, + Subscribable.mapOption(form.value, values => values[index]), + Lens.focusTupleAt(form.encodedValue, index), + Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)), + form.isValidating, + form.canCommit, + form.isCommitting, + ) +}) + +export const focusChunkAt: { +

( + self: Form, Chunk.Chunk, ER, EW>, + index: number, + ): Form +

( + index: number, + ): (self: Form, Chunk.Chunk, ER, EW>) => Form +} = Function.dual(2,

( + self: Form, Chunk.Chunk, ER, EW>, + index: number, +): Form => { + const form = self as unknown as FormImpl, Chunk.Chunk, ER, EW> + const path = [...form.path, index] as const + + return new FormImpl( + path, + Subscribable.mapOptionEffect(form.value, values => Effect.fromOption(Chunk.get(values, index))), + Lens.focusChunkAt(form.encodedValue, index), + Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)), + form.isValidating, + form.canCommit, + form.isCommitting, + ) +}) + + +export namespace useInput { + export interface Options { + readonly debounce?: Duration.Input + } + + export interface Success { + readonly value: T + readonly setValue: React.Dispatch> + } +} + +export const useInput = Effect.fnUntraced(function*

( + form: Form, + options?: useInput.Options, +): Effect.fn.Return, ER, Scope.Scope> { + const internalValueLens = yield* Component.useOnChange(() => Effect.gen(function*() { + const internalValueLens = yield* Lens.get(form.encodedValue).pipe( + Effect.flatMap(SubscriptionRef.make), + Effect.map(Lens.fromSubscriptionRef), + ) + + yield* Effect.forkScoped(Effect.all([ + Stream.runForEach( + Stream.drop(form.encodedValue.changes, 1), + upstreamEncodedValue => Effect.flatMap( + Lens.get(internalValueLens), + internalValue => !Equal.equals(upstreamEncodedValue, internalValue) + ? Lens.set(internalValueLens, upstreamEncodedValue) + : Effect.succeed(undefined), + ), + ), + + Stream.runForEach( + internalValueLens.changes.pipe( + Stream.drop(1), + Stream.changesWith(Equal.asEquivalence()), + options?.debounce ? Stream.debounce(options.debounce) : identity, + ), + internalValue => Lens.set(form.encodedValue, internalValue), + ), + ], { concurrency: "unbounded", discard: true })) + + return internalValueLens + }), [form, options?.debounce]) + + const [value, setValue] = yield* Lens.useState(internalValueLens) + return { value, setValue } +}) + +export namespace useOptionalInput { + export interface Options extends useInput.Options { + readonly defaultValue: T + } + + export interface Success extends useInput.Success { + readonly enabled: boolean + readonly setEnabled: React.Dispatch> + } +} + +export const useOptionalInput = Effect.fnUntraced(function*

( + field: Form, ER, EW>, + options: useOptionalInput.Options, +): Effect.fn.Return, ER, Scope.Scope> { + const [enabledLens, internalValueLens] = yield* Component.useOnChange(() => Effect.gen(function*() { + const [enabledLens, internalValueLens] = yield* Effect.flatMap( + Lens.get(field.encodedValue), + Option.match({ + onSome: v => Effect.all([ + Effect.map(SubscriptionRef.make(true), Lens.fromSubscriptionRef), + Effect.map(SubscriptionRef.make(v), Lens.fromSubscriptionRef), + ]), + onNone: () => Effect.all([ + Effect.map(SubscriptionRef.make(false), Lens.fromSubscriptionRef), + Effect.map(SubscriptionRef.make(options.defaultValue), Lens.fromSubscriptionRef), + ]), + }), + ) + + yield* Effect.forkScoped(Effect.all([ + Stream.runForEach( + Stream.drop(field.encodedValue.changes, 1), + + upstreamEncodedValue => Effect.flatMap( + Effect.all([Lens.get(enabledLens), Lens.get(internalValueLens)]), + ([enabled, internalValue]) => Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()) + ? Effect.succeed(undefined) + : Option.match(upstreamEncodedValue, { + onSome: v => Effect.andThen( + Lens.set(enabledLens, true), + Lens.set(internalValueLens, v), + ), + onNone: () => Effect.andThen( + Lens.set(enabledLens, false), + Lens.set(internalValueLens, options.defaultValue), + ), + }), + ), + ), + + Stream.runForEach( + enabledLens.changes.pipe( + Stream.zipLatest(internalValueLens.changes), + Stream.drop(1), + Stream.changesWith(Equal.asEquivalence()), + options?.debounce ? Stream.debounce(options.debounce) : identity, + ), + ([enabled, internalValue]) => Lens.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()), + ), + ], { concurrency: "unbounded" })) + + return [enabledLens, internalValueLens] as const + }), [field, options.debounce]) + + const [enabled, setEnabled] = yield* Lens.useState(enabledLens) + const [value, setValue] = yield* Lens.useState(internalValueLens) + return { enabled, setEnabled, value, setValue } +}) diff --git a/packages/effect-fc-next/src/Lens.ts b/packages/effect-fc-next/src/Lens.ts new file mode 100644 index 0000000..4941dea --- /dev/null +++ b/packages/effect-fc-next/src/Lens.ts @@ -0,0 +1,62 @@ +import { Effect, Equivalence, Stream, SubscriptionRef } from "effect" +import { Lens } from "effect-lens" +import * as React from "react" +import * as Component from "./Component.js" +import * as SetStateAction from "./SetStateAction.js" + + +export * from "effect-lens/Lens" + +export declare namespace useState { + export interface Options { + readonly equivalence?: Equivalence.Equivalence + } +} + +export const useState = Effect.fnUntraced(function* ( + lens: Lens.Lens, + options?: useState.Options>, +): Effect.fn.Return>], ER | EW, RR | RW> { + const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => Lens.get(lens))) + + yield* Component.useReactEffect(() => Effect.forkScoped( + Stream.runForEach( + Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strictEqual()), + v => Effect.sync(() => setReactStateValue(v)), + ) + ), [lens]) + + const setValue = yield* Component.useCallbackSync( + (setStateAction: React.SetStateAction) => Effect.tap( + Lens.updateAndGet(lens, prevState => SetStateAction.value(setStateAction, prevState)), + v => Effect.sync(() => setReactStateValue(v)), + ), + [lens], + ) + + return [reactStateValue, setValue] +}) + +export declare namespace useFromReactState { + export interface Options { + readonly equivalence?: Equivalence.Equivalence + } +} + +export const useFromReactState = Effect.fnUntraced(function* ( + [value, setValue]: readonly [A, React.Dispatch>], + options?: useFromReactState.Options>, +): Effect.fn.Return> { + const lens = yield* Component.useOnMount(() => Effect.map( + SubscriptionRef.make(value), + Lens.fromSubscriptionRef, + )) + + yield* Component.useReactEffect(() => Effect.forkScoped(Stream.runForEach( + Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strictEqual()), + v => Effect.sync(() => setValue(v)), + )), [setValue]) + yield* Component.useReactEffect(() => Lens.set(lens, value), [value]) + + return lens +}) diff --git a/packages/effect-fc-next/src/Memoized.ts b/packages/effect-fc-next/src/Memoized.ts new file mode 100644 index 0000000..0c981c8 --- /dev/null +++ b/packages/effect-fc-next/src/Memoized.ts @@ -0,0 +1,112 @@ +/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ +import { type Equivalence, Function, Predicate } from "effect" +import * as React from "react" +import type * as Component from "./Component.js" + + +export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized") +export type MemoizedTypeId = typeof MemoizedTypeId + + +/** + * A trait for `Component`'s that uses `React.memo` to optimize re-renders based on prop equality. + * + * @template P The props type of the component + */ +export interface Memoized

extends MemoizedPrototype, MemoizedOptions

{} + +export interface MemoizedPrototype { + readonly [MemoizedTypeId]: MemoizedTypeId +} + +/** + * Configuration options for Memoized components. + * + * @template P The props type of the component + */ +export interface MemoizedOptions

{ + /** + * An optional equivalence function for comparing component props. + * If provided, this function is used by React.memo to determine if props have changed. + * Returns `true` if props are equivalent (no re-render), `false` if they differ (re-render). + */ + readonly propsEquivalence?: Equivalence.Equivalence

+} + + +export const MemoizedPrototype: MemoizedPrototype = Object.freeze({ + [MemoizedTypeId]: MemoizedTypeId, + + transformFunctionComponent

( + this: Memoized

, + f: React.FC

, + ) { + return React.memo(f, this.propsEquivalence) + }, +} as const) + + +export const isMemoized = (u: unknown): u is Memoized => Predicate.hasProperty(u, MemoizedTypeId) + +/** + * Converts a Component into a `Memoized` component that optimizes re-renders using `React.memo`. + * + * @param self - The component to convert to a Memoized component + * @returns A new `Memoized` component with the same body, error, and context types as the input + * + * @example + * ```ts + * const MyMemoizedComponent = MyComponent.pipe( + * Memoized.memoized, + * ) + * ``` + */ +export const memoized = ( + self: T +): T & Memoized> => Object.setPrototypeOf( + Object.assign(function() {}, self), + Object.freeze(Object.setPrototypeOf( + Object.assign({}, MemoizedPrototype), + Object.getPrototypeOf(self), + )), +) + +/** + * Applies options to a Memoized component, returning a new Memoized component with the updated configuration. + * + * Supports both curried and uncurried application styles. + * + * @param self - The Memoized component to apply options to (in uncurried form) + * @param options - The options to apply to the component + * @returns A Memoized component with the applied options + * + * @example + * ```ts + * // Curried + * const MyMemoizedComponent = MyComponent.pipe( + * Memoized.memoized, + * Memoized.withOptions({ propsEquivalence: (a, b) => a.id === b.id }), + * ) + * + * // Uncurried + * const MyMemoizedComponent = Memoized.withOptions( + * Memoized.memoized(MyComponent), + * { propsEquivalence: (a, b) => a.id === b.id }, + * ) + * ``` + */ +export const withOptions: { + >( + options: Partial>> + ): (self: T) => T + >( + self: T, + options: Partial>>, + ): T +} = Function.dual(2, >( + self: T, + options: Partial>>, +): T => Object.setPrototypeOf( + Object.assign(function() {}, self, options), + Object.getPrototypeOf(self), +)) diff --git a/packages/effect-fc-next/src/Mutation.ts b/packages/effect-fc-next/src/Mutation.ts new file mode 100644 index 0000000..0d873eb --- /dev/null +++ b/packages/effect-fc-next/src/Mutation.ts @@ -0,0 +1,146 @@ +import { type Context, Effect, Equal, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect" +import { Subscribable } from "effect-lens" +import * as Result from "./Result.js" + + +export const MutationTypeId: unique symbol = Symbol.for("@effect-fc/Mutation/Mutation") +export type MutationTypeId = typeof MutationTypeId + +export interface Mutation +extends Pipeable.Pipeable { + readonly [MutationTypeId]: MutationTypeId + + readonly context: Context.Context + readonly f: (key: K) => Effect.Effect + readonly initialProgress: P + + readonly latestKey: Subscribable.Subscribable> + readonly fiber: Subscribable.Subscribable>> + readonly result: Subscribable.Subscribable> + readonly latestFinalResult: Subscribable.Subscribable>> + + mutate(key: K): Effect.Effect> + mutateSubscribable(key: K): Effect.Effect>> +} + +export declare namespace Mutation { + export type AnyKey = readonly any[] +} + +export class MutationImpl +extends Pipeable.Class implements Mutation { + readonly [MutationTypeId]: MutationTypeId = MutationTypeId + readonly latestKey: Subscribable.Subscribable> + readonly fiber: Subscribable.Subscribable>> + readonly result: Subscribable.Subscribable> + readonly latestFinalResult: Subscribable.Subscribable>> + + constructor( + readonly context: Context.Context, + readonly f: (key: K) => Effect.Effect, + readonly initialProgress: P, + + readonly latestKeyRef: SubscriptionRef.SubscriptionRef>, + readonly fiberRef: SubscriptionRef.SubscriptionRef>>, + readonly resultRef: SubscriptionRef.SubscriptionRef>, + readonly latestFinalResultRef: SubscriptionRef.SubscriptionRef>>, + ) { + super() + this.latestKey = fromSubscriptionRef(latestKeyRef) + this.fiber = fromSubscriptionRef(fiberRef) + this.result = fromSubscriptionRef(resultRef) + this.latestFinalResult = fromSubscriptionRef(latestFinalResultRef) + } + + mutate(key: K): Effect.Effect> { + return SubscriptionRef.set(this.latestKeyRef, Option.some(key)).pipe( + Effect.andThen(this.start(key)), + Effect.andThen(sub => this.watch(sub)), + Effect.provide(this.context), + ) + } + mutateSubscribable(key: K): Effect.Effect>> { + return SubscriptionRef.set(this.latestKeyRef, Option.some(key)).pipe( + Effect.andThen(this.start(key)), + Effect.tap(sub => Effect.forkScoped(this.watch(sub))), + Effect.provide(this.context), + ) + } + + start(key: K): Effect.Effect< + Subscribable.Subscribable>, + never, + Scope.Scope | R + > { + const self = this + return Effect.gen(function*() { + const initial = yield* SubscriptionRef.get(self.latestFinalResultRef) + const [sub, fiber] = yield* Result.unsafeForkEffect( + Effect.onExit(self.f(key), () => Effect.andThen( + Effect.all([Effect.fiberId, SubscriptionRef.get(self.fiberRef)]), + ([currentFiberId, fiber]) => Option.match(fiber, { + onSome: v => Equal.equals(currentFiberId, v.id) + ? SubscriptionRef.set(self.fiberRef, Option.none()) + : Effect.succeed(undefined), + onNone: () => Effect.succeed(undefined), + }), + )), + + { + initial: Option.isSome(initial) ? Result.willFetch(initial.value) : Result.initial(), + initialProgress: self.initialProgress, + } as Result.unsafeForkEffect.Options, + ) + yield* SubscriptionRef.set(self.fiberRef, Option.some(fiber)) + return sub + }) + } + + watch( + sub: Subscribable.Subscribable> + ): Effect.Effect> { + return sub.get.pipe( + Effect.andThen(initial => Stream.runFoldEffect( + Stream.takeUntil(sub.changes, result => Result.isFinal(result) && !Result.hasFlag(result)), + () => initial, + (_, result) => Effect.as(SubscriptionRef.set(this.resultRef, result), result), + ) as Effect.Effect>), + Effect.tap(result => SubscriptionRef.set(this.latestFinalResultRef, Option.some(result))), + ) + } +} + + +export const isMutation = (u: unknown): u is Mutation => Predicate.hasProperty(u, MutationTypeId) + + +export declare namespace make { + export interface Options { + readonly f: (key: K) => Effect.Effect>> + readonly initialProgress?: P + } +} + +export const make = Effect.fnUntraced(function* ( + options: make.Options +): Effect.fn.Return< + Mutation, P>, + never, + Scope.Scope | Result.forkEffect.OutputContext +> { + return new MutationImpl( + yield* Effect.context>(), + options.f as any, + options.initialProgress as P, + + yield* SubscriptionRef.make(Option.none()), + yield* SubscriptionRef.make(Option.none>()), + yield* SubscriptionRef.make(Result.initial()), + yield* SubscriptionRef.make(Option.none>()), + ) +}) + +const fromSubscriptionRef = (ref: SubscriptionRef.SubscriptionRef): Subscribable.Subscribable => Subscribable.make({ + get: SubscriptionRef.get(ref), + changes: SubscriptionRef.changes(ref), +}) diff --git a/packages/effect-fc-next/src/PubSub.ts b/packages/effect-fc-next/src/PubSub.ts new file mode 100644 index 0000000..27401bc --- /dev/null +++ b/packages/effect-fc-next/src/PubSub.ts @@ -0,0 +1,17 @@ +import { Effect, PubSub, type Scope } from "effect" +import type * as React from "react" +import * as Component from "./Component.js" + + +export const useFromReactiveValues = Effect.fnUntraced(function* ( + values: A +): Effect.fn.Return, never, Scope.Scope> { + const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded(), PubSub.shutdown)) + yield* Component.useReactEffect(() => Effect.flatMap( + PubSub.isShutdown(pubsub), + shutdown => shutdown ? Effect.succeed(undefined) : Effect.asVoid(PubSub.publish(pubsub, values)), + ), values) + return pubsub +}) + +export * from "effect/PubSub" diff --git a/packages/effect-fc-next/src/Query.ts b/packages/effect-fc-next/src/Query.ts new file mode 100644 index 0000000..e9e317b --- /dev/null +++ b/packages/effect-fc-next/src/Query.ts @@ -0,0 +1,283 @@ +import { + type Cause, + type Context, + type Duration, + Effect, + Equal, + Fiber, + Option, + Pipeable, + Predicate, + type Scope, + Semaphore, + Stream, + SubscriptionRef, +} from "effect" +import { Subscribable } from "effect-lens" +import * as QueryClient from "./QueryClient.js" +import * as Result from "./Result.js" + + +export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query") +export type QueryTypeId = typeof QueryTypeId + +export interface Query +extends Pipeable.Pipeable { + readonly [QueryTypeId]: QueryTypeId + readonly context: Context.Context + readonly key: Stream.Stream + readonly f: (key: K) => Effect.Effect + readonly initialProgress: P + readonly staleTime: Duration.Input + readonly refreshOnWindowFocus: boolean + readonly latestKey: Subscribable.Subscribable> + readonly fiber: Subscribable.Subscribable>> + readonly result: Subscribable.Subscribable> + readonly latestFinalResult: Subscribable.Subscribable>> + readonly run: Effect.Effect + fetch(key: K): Effect.Effect> + fetchSubscribable(key: K): Effect.Effect>> + readonly refresh: Effect.Effect, Cause.NoSuchElementError> + readonly refreshSubscribable: Effect.Effect>, Cause.NoSuchElementError> + readonly invalidateCache: Effect.Effect + invalidateCacheEntry(key: K): Effect.Effect +} + +export declare namespace Query { + export type AnyKey = readonly any[] +} + +export class QueryImpl +extends Pipeable.Class implements Query { + readonly [QueryTypeId]: QueryTypeId = QueryTypeId + readonly latestKey: Subscribable.Subscribable> + readonly fiber: Subscribable.Subscribable>> + readonly result: Subscribable.Subscribable> + readonly latestFinalResult: Subscribable.Subscribable>> + + constructor( + readonly context: Context.Context, + readonly key: Stream.Stream, + readonly f: (key: K) => Effect.Effect, + readonly initialProgress: P, + readonly staleTime: Duration.Input, + readonly refreshOnWindowFocus: boolean, + readonly latestKeyRef: SubscriptionRef.SubscriptionRef>, + readonly fiberRef: SubscriptionRef.SubscriptionRef>>, + readonly resultRef: SubscriptionRef.SubscriptionRef>, + readonly latestFinalResultRef: SubscriptionRef.SubscriptionRef>>, + readonly runSemaphore: Semaphore.Semaphore, + ) { + super() + this.latestKey = fromSubscriptionRef(latestKeyRef) + this.fiber = fromSubscriptionRef(fiberRef) + this.result = fromSubscriptionRef(resultRef) + this.latestFinalResult = fromSubscriptionRef(latestFinalResultRef) + } + + get run(): Effect.Effect { + const focus = this.refreshOnWindowFocus && typeof window !== "undefined" + ? Stream.runForEach(Stream.fromEventListener(window, "focus"), () => this.refreshSubscribable) + : Effect.succeed(undefined) + return Effect.all([ + Stream.runForEach(this.key, key => this.fetchSubscribable(key)), + focus, + ], { concurrency: "unbounded", discard: true }).pipe( + Effect.ignore, + this.runSemaphore.withPermits(1), + Effect.provide(this.context), + ) + } + + get interrupt(): Effect.Effect { + return Effect.flatMap(SubscriptionRef.get(this.fiberRef), Option.match({ + onSome: Fiber.interrupt, + onNone: () => Effect.succeed(undefined), + })) + } + + fetch(key: K): Effect.Effect> { + const self = this + return Effect.gen(function*() { + yield* self.interrupt + yield* SubscriptionRef.set(self.latestKeyRef, Option.some(key)) + const previous = yield* SubscriptionRef.get(self.latestFinalResultRef) + const sub = yield* self.startCached(key, Option.isSome(previous) + ? Result.willFetch(previous.value) as Result.Final + : Result.initial()) + return yield* self.watch(key, sub) + }).pipe(Effect.provide(this.context)) + } + + fetchSubscribable(key: K): Effect.Effect>> { + const self = this + return Effect.gen(function*() { + yield* self.interrupt + yield* SubscriptionRef.set(self.latestKeyRef, Option.some(key)) + const previous = yield* SubscriptionRef.get(self.latestFinalResultRef) + const sub = yield* self.startCached(key, Option.isSome(previous) + ? Result.willFetch(previous.value) as Result.Final + : Result.initial()) + yield* Effect.forkScoped(self.watch(key, sub)) + return sub + }).pipe(Effect.provide(this.context)) + } + + get refresh(): Effect.Effect, Cause.NoSuchElementError> { + const self = this + return Effect.gen(function*() { + yield* self.interrupt + const key = yield* Effect.fromOption(yield* SubscriptionRef.get(self.latestKeyRef)) + const previous = yield* SubscriptionRef.get(self.latestFinalResultRef) + const sub = yield* self.startCached(key, Option.isSome(previous) + ? Result.willRefresh(previous.value) as Result.Final + : Result.initial()) + return yield* self.watch(key, sub) + }).pipe(Effect.provide(this.context)) + } + + get refreshSubscribable(): Effect.Effect>, Cause.NoSuchElementError> { + const self = this + return Effect.gen(function*() { + yield* self.interrupt + const key = yield* Effect.fromOption(yield* SubscriptionRef.get(self.latestKeyRef)) + const previous = yield* SubscriptionRef.get(self.latestFinalResultRef) + const sub = yield* self.startCached(key, Option.isSome(previous) + ? Result.willRefresh(previous.value) as Result.Final + : Result.initial()) + yield* Effect.forkScoped(self.watch(key, sub)) + return sub + }).pipe(Effect.provide(this.context)) + } + + startCached( + key: K, + initial: Result.Initial | Result.Final, + ): Effect.Effect>, never, Scope.Scope | QueryClient.QueryClient | R> { + return Effect.flatMap(this.getCacheEntry(key), Option.match({ + onSome: entry => Effect.flatMap( + QueryClient.isQueryClientCacheEntryStale(entry), + isStale => isStale + ? this.start(key, Result.willRefresh(entry.result) as Result.Final) + : Effect.succeed(Subscribable.make({ + get: Effect.succeed(entry.result as Result.Result), + changes: Stream.make(entry.result as Result.Result), + })), + ), + onNone: () => this.start(key, initial), + })) + } + + start( + key: K, + initial: Result.Initial | Result.Final, + ): Effect.Effect>, never, Scope.Scope | R> { + const self = this + return Effect.gen(function*() { + const [sub, fiber] = yield* Result.unsafeForkEffect( + Effect.onExit(self.f(key), () => Effect.flatMap( + Effect.all([Effect.fiberId, SubscriptionRef.get(self.fiberRef)]), + ([currentFiberId, current]) => Option.match(current, { + onSome: value => Equal.equals(currentFiberId, value.id) + ? SubscriptionRef.set(self.fiberRef, Option.none()) + : Effect.succeed(undefined), + onNone: () => Effect.succeed(undefined), + }), + )), + { initial, initialProgress: self.initialProgress }, + ) + yield* SubscriptionRef.set(self.fiberRef, Option.some(fiber)) + return sub + }) + } + + watch( + key: K, + sub: Subscribable.Subscribable>, + ): Effect.Effect, never, QueryClient.QueryClient> { + return Effect.flatMap(sub.get, initial => Stream.runFoldEffect( + Stream.takeUntil(sub.changes, result => Result.isFinal(result) && !Result.hasFlag(result)), + () => initial, + (_, result) => Effect.as(SubscriptionRef.set(this.resultRef, result), result), + ) as Effect.Effect>).pipe( + Effect.tap(result => SubscriptionRef.set(this.latestFinalResultRef, Option.some(result))), + Effect.tap(result => Result.isSuccess(result) + ? Effect.asVoid(this.setCacheEntry(key, result)) + : Effect.succeed(undefined)), + ) + } + + makeCacheKey(key: K): QueryClient.QueryClientCacheKey { + return new QueryClient.QueryClientCacheKey(key, this.f as (key: Query.AnyKey) => Effect.Effect) + } + + getCacheEntry(key: K): Effect.Effect, never, QueryClient.QueryClient> { + return Effect.flatMap(QueryClient.QueryClient, client => client.getCacheEntry(this.makeCacheKey(key))) + } + + setCacheEntry(key: K, result: Result.Success): Effect.Effect { + return Effect.flatMap(QueryClient.QueryClient, client => client.setCacheEntry(this.makeCacheKey(key), result, this.staleTime)) + } + + get invalidateCache(): Effect.Effect { + return Effect.flatMap( + QueryClient.QueryClient, + client => client.invalidateCacheEntries(this.f as (key: Query.AnyKey) => Effect.Effect), + ).pipe(Effect.provide(this.context)) + } + + invalidateCacheEntry(key: K): Effect.Effect { + return Effect.flatMap( + QueryClient.QueryClient, + client => client.invalidateCacheEntry(this.makeCacheKey(key)), + ).pipe(Effect.provide(this.context)) + } +} + +export const isQuery = (u: unknown): u is Query => Predicate.hasProperty(u, QueryTypeId) + +export declare namespace make { + export interface Options { + readonly key: Stream.Stream + readonly f: (key: NoInfer) => Effect.Effect>> + readonly initialProgress?: P + readonly staleTime?: Duration.Input + readonly refreshOnWindowFocus?: boolean + } +} + +export const make = Effect.fnUntraced(function* ( + options: make.Options, +): Effect.fn.Return< + Query, P>, + never, + Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext +> { + const client = yield* QueryClient.QueryClient + return new QueryImpl, P>( + yield* Effect.context>(), + options.key, + options.f as any, + options.initialProgress as P, + options.staleTime ?? client.defaultStaleTime, + options.refreshOnWindowFocus ?? client.defaultRefreshOnWindowFocus, + yield* SubscriptionRef.make(Option.none()), + yield* SubscriptionRef.make(Option.none>()), + yield* SubscriptionRef.make(Result.initial()), + yield* SubscriptionRef.make(Option.none>()), + yield* Semaphore.make(1), + ) +}) + +export const service = ( + options: make.Options, +): Effect.Effect< + Query, P>, + never, + Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext +> => Effect.tap(make(options), query => Effect.asVoid(Effect.forkScoped(query.run))) + +const fromSubscriptionRef = (ref: SubscriptionRef.SubscriptionRef): Subscribable.Subscribable => Subscribable.make({ + get: SubscriptionRef.get(ref), + changes: SubscriptionRef.changes(ref), +}) diff --git a/packages/effect-fc-next/src/QueryClient.ts b/packages/effect-fc-next/src/QueryClient.ts new file mode 100644 index 0000000..b811138 --- /dev/null +++ b/packages/effect-fc-next/src/QueryClient.ts @@ -0,0 +1,191 @@ +import { + Context, + DateTime, + Duration, + Effect, + Equal, + Equivalence, + Hash, + HashMap, + Layer, + Option, + Pipeable, + Predicate, + Schedule, + Scope, + Semaphore, + SubscriptionRef, +} from "effect" +import { Subscribable } from "effect-lens" +import type * as Query from "./Query.js" +import type * as Result from "./Result.js" + + +export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService") +export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId + +export interface QueryClientService extends Pipeable.Pipeable { + readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId + readonly cache: Subscribable.Subscribable> + readonly cacheGcTime: Duration.Input + readonly defaultStaleTime: Duration.Input + readonly defaultRefreshOnWindowFocus: boolean + readonly run: Effect.Effect + getCacheEntry(key: QueryClientCacheKey): Effect.Effect> + setCacheEntry(key: QueryClientCacheKey, result: Result.Success, staleTime: Duration.Input): Effect.Effect + invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect): Effect.Effect + invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect +} + +export class QueryClient extends Context.Service()( + "@effect-fc/QueryClient/QueryClient", +) { + static get Default(): Layer.Layer { + return Layer.effect(QueryClient)(service()) + } +} + +export class QueryClientServiceImpl extends Pipeable.Class implements QueryClientService { + readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId + readonly cache: Subscribable.Subscribable> + + constructor( + readonly cacheRef: SubscriptionRef.SubscriptionRef>, + readonly cacheGcTime: Duration.Input, + readonly defaultStaleTime: Duration.Input, + readonly defaultRefreshOnWindowFocus: boolean, + readonly runSemaphore: Semaphore.Semaphore, + ) { + super() + this.cache = Subscribable.make({ + get: SubscriptionRef.get(cacheRef), + changes: SubscriptionRef.changes(cacheRef), + }) + } + + get run(): Effect.Effect { + return this.runSemaphore.withPermits(1)(Effect.repeat( + Effect.flatMap(DateTime.now, now => SubscriptionRef.update(this.cacheRef, HashMap.filter(entry => + Duration.isLessThan( + DateTime.distance(entry.lastAccessedAt, now), + Duration.sum(Duration.fromInputUnsafe(entry.staleTime), Duration.fromInputUnsafe(this.cacheGcTime)), + ) + ))), + Schedule.spaced("30 seconds"), + )) + } + + getCacheEntry(key: QueryClientCacheKey): Effect.Effect> { + const self = this + return Effect.gen(function*() { + const entry = HashMap.get(yield* SubscriptionRef.get(self.cacheRef), key) + if (Option.isNone(entry)) return Option.none() + const now = yield* DateTime.now + const accessed = new QueryClientCacheEntry( + entry.value.result, + entry.value.staleTime, + entry.value.createdAt, + now, + ) + yield* SubscriptionRef.update(self.cacheRef, HashMap.set(key, accessed)) + return Option.some(accessed) + }) + } + + setCacheEntry( + key: QueryClientCacheKey, + result: Result.Success, + staleTime: Duration.Input, + ): Effect.Effect { + return Effect.flatMap(DateTime.now, now => { + const entry = new QueryClientCacheEntry(result, staleTime, now, now) + return Effect.as(SubscriptionRef.update(this.cacheRef, HashMap.set(key, entry)), entry) + }) + } + + invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect): Effect.Effect { + return SubscriptionRef.update(this.cacheRef, HashMap.filter((_, key) => !Equivalence.strictEqual()(key.f, f))) + } + + invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect { + return SubscriptionRef.update(this.cacheRef, HashMap.remove(key)) + } +} + +export const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId) + +export declare namespace make { + export interface Options { + readonly cacheGcTime?: Duration.Input + readonly defaultStaleTime?: Duration.Input + readonly defaultRefreshOnWindowFocus?: boolean + } +} + +export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return { + return new QueryClientServiceImpl( + yield* SubscriptionRef.make(HashMap.empty()), + options.cacheGcTime ?? "5 minutes", + options.defaultStaleTime ?? "0 minutes", + options.defaultRefreshOnWindowFocus ?? true, + yield* Semaphore.make(1), + ) +}) + +export declare namespace service { + export interface Options extends make.Options {} +} + +export const service = (options?: service.Options): Effect.Effect => Effect.tap( + make(options), + client => Effect.asVoid(Effect.forkScoped(client.run)), +) + +export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheKey") +export type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId + +export class QueryClientCacheKey extends Pipeable.Class implements Equal.Equal { + readonly [QueryClientCacheKeyTypeId]: QueryClientCacheKeyTypeId = QueryClientCacheKeyTypeId + + constructor( + readonly key: Query.Query.AnyKey, + readonly f: (key: Query.Query.AnyKey) => Effect.Effect, + ) { + super() + } + + [Equal.symbol](that: Equal.Equal): boolean { + return isQueryClientCacheKey(that) + && Equivalence.Array(Equal.asEquivalence())(this.key, that.key) + && Equivalence.strictEqual()(this.f, that.f) + } + + [Hash.symbol](): number { + return Hash.combine(Hash.hash(this.f))(Hash.array(this.key)) + } +} + +export const isQueryClientCacheKey = (u: unknown): u is QueryClientCacheKey => Predicate.hasProperty(u, QueryClientCacheKeyTypeId) + +export const QueryClientCacheEntryTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheEntry") +export type QueryClientCacheEntryTypeId = typeof QueryClientCacheEntryTypeId + +export class QueryClientCacheEntry extends Pipeable.Class { + readonly [QueryClientCacheEntryTypeId]: QueryClientCacheEntryTypeId = QueryClientCacheEntryTypeId + + constructor( + readonly result: Result.Success, + readonly staleTime: Duration.Input, + readonly createdAt: DateTime.DateTime, + readonly lastAccessedAt: DateTime.DateTime, + ) { + super() + } +} + +export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId) + +export const isQueryClientCacheEntryStale = (self: QueryClientCacheEntry): Effect.Effect => Effect.map( + DateTime.now, + now => Duration.isGreaterThanOrEqualTo(DateTime.distance(self.createdAt, now), Duration.fromInputUnsafe(self.staleTime)), +) diff --git a/packages/effect-fc-next/src/ReactRuntime.ts b/packages/effect-fc-next/src/ReactRuntime.ts new file mode 100644 index 0000000..92f9108 --- /dev/null +++ b/packages/effect-fc-next/src/ReactRuntime.ts @@ -0,0 +1,71 @@ +/** biome-ignore-all lint/complexity/useArrowFunction: React component names are intentional */ +import { Context, Layer, ManagedRuntime, Predicate } from "effect" +import * as React from "react" +import * as ErrorObserver from "./ErrorObserver.js" +import * as QueryClient from "./QueryClient.js" + + +export const TypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime") +export type TypeId = typeof TypeId + +export interface ReactRuntime { + new(_: never): Record + readonly [TypeId]: TypeId + readonly runtime: ManagedRuntime.ManagedRuntime + readonly context: React.Context> +} + +const ReactRuntimeProto = Object.freeze({ [TypeId]: TypeId } as const) + +export const Prelude: Layer.Layer = Layer.merge( + ErrorObserver.layer, + QueryClient.QueryClient.Default, +) + +export const isReactRuntime = (u: unknown): u is ReactRuntime => Predicate.hasProperty(u, TypeId) + +export const make = ( + layer: Layer.Layer, + memoMap?: Layer.MemoMap, +): ReactRuntime => Object.setPrototypeOf( + Object.assign(function() {}, { + runtime: ManagedRuntime.make(Layer.merge(layer, Prelude), { memoMap }), + // biome-ignore lint/style/noNonNullAssertion: initialized by Provider before consumers render + context: React.createContext>(null!), + }), + ReactRuntimeProto, +) + +export namespace Provider { + export interface Props extends React.SuspenseProps { + readonly runtime: ReactRuntime + readonly children?: React.ReactNode + } +} + +export const Provider = ( + { runtime, children, ...suspenseProps }: Provider.Props, +): React.ReactNode => { + const promise = React.useMemo(() => runtime.runtime.context(), [runtime]) + + return React.createElement( + React.Suspense, + suspenseProps, + React.createElement(ProviderInner, { runtime, promise, children }), + ) +} + +const ProviderInner = ( + { runtime, promise, children }: { + readonly runtime: ReactRuntime + readonly promise: Promise> + readonly children?: React.ReactNode + }, +): React.ReactNode => { + const context = React.use(promise) + React.useEffect(() => () => { + void runtime.runtime.dispose() + }, [runtime]) + + return React.createElement(runtime.context, { value: context }, children) +} diff --git a/packages/effect-fc-next/src/Result.ts b/packages/effect-fc-next/src/Result.ts new file mode 100644 index 0000000..672ae18 --- /dev/null +++ b/packages/effect-fc-next/src/Result.ts @@ -0,0 +1,249 @@ +import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, pipe, type Scope, SubscriptionRef } from "effect" +import { Lens, Subscribable } from "effect-lens" + + +export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result") +export type ResultTypeId = typeof ResultTypeId + +export type Result = ( + | Initial + | Running

+ | Final +) + +// biome-ignore lint/complexity/noBannedTypes: "{}" is relevant here +export type Final = (Success | Failure) & ({} | Flags

) +export type Flags

= WillFetch | WillRefresh | Refreshing

+ +export declare namespace Result { + export type Success> = [R] extends [Result] ? A : never + export type Failure> = [R] extends [Result] ? E : never + export type Progress> = [R] extends [Result] ? P : never +} + +export declare namespace Flags { + export type Keys = keyof WillFetch & WillRefresh & Refreshing +} + +export interface Initial extends ResultPrototype { + readonly _tag: "Initial" +} + +export interface Running

extends ResultPrototype { + readonly _tag: "Running" + readonly progress: P +} + +export interface Success extends ResultPrototype { + readonly _tag: "Success" + readonly value: A +} + +export interface Failure extends ResultPrototype { + readonly _tag: "Failure" + readonly cause: Cause.Cause +} + +export interface WillFetch { + readonly _flag: "WillFetch" +} + +export interface WillRefresh { + readonly _flag: "WillRefresh" +} + +export interface Refreshing

{ + readonly _flag: "Refreshing" + readonly progress: P +} + + +export interface ResultPrototype extends Pipeable.Pipeable, Equal.Equal { + readonly [ResultTypeId]: ResultTypeId +} + +export const ResultPrototype: ResultPrototype = Object.freeze({ + ...Pipeable.Prototype, + [ResultTypeId]: ResultTypeId, + + [Equal.symbol](this: Result, that: Result): boolean { + if (this._tag !== that._tag || (this as Flags)._flag !== (that as Flags)._flag) + return false + if (hasRefreshingFlag(this) && !Equal.equals(this.progress, (that as Refreshing).progress)) + return false + return Match.value(this).pipe( + Match.tag("Initial", () => true), + Match.tag("Running", self => Equal.equals(self.progress, (that as Running).progress)), + Match.tag("Success", self => Equal.equals(self.value, (that as Success).value)), + Match.tag("Failure", self => Equal.equals(self.cause, (that as Failure).cause)), + Match.exhaustive, + ) + }, + + [Hash.symbol](this: Result): number { + return pipe(Hash.string(this._tag), + tagHash => Match.value(this).pipe( + Match.tag("Initial", () => tagHash), + Match.tag("Running", self => Hash.combine(Hash.hash(self.progress))(tagHash)), + Match.tag("Success", self => Hash.combine(Hash.hash(self.value))(tagHash)), + Match.tag("Failure", self => Hash.combine(Hash.hash(self.cause))(tagHash)), + Match.exhaustive, + ), + Hash.combine(Hash.hash((this as Flags)._flag)), + hash => hasRefreshingFlag(this) + ? Hash.combine(Hash.hash(this.progress))(hash) + : hash, + ) + }, +} as const) + + +export const isResult = (u: unknown): u is Result => Predicate.hasProperty(u, ResultTypeId) +export const isFinal = (u: unknown): u is Final => isResult(u) && (isSuccess(u) || isFailure(u)) +export const isInitial = (u: unknown): u is Initial => isResult(u) && u._tag === "Initial" +export const isRunning = (u: unknown): u is Running => isResult(u) && u._tag === "Running" +export const isSuccess = (u: unknown): u is Success => isResult(u) && u._tag === "Success" +export const isFailure = (u: unknown): u is Failure => isResult(u) && u._tag === "Failure" +export const hasFlag = (u: unknown): u is Flags => isResult(u) && Predicate.hasProperty(u, "_flag") +export const hasWillFetchFlag = (u: unknown): u is WillFetch => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "WillFetch" +export const hasWillRefreshFlag = (u: unknown): u is WillRefresh => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "WillRefresh" +export const hasRefreshingFlag = (u: unknown): u is Refreshing => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "Refreshing" + +export const initial: { + (): Initial + (): Result +} = (): Initial => Object.setPrototypeOf({ _tag: "Initial" }, ResultPrototype) +export const running =

(progress?: P): Running

=> Object.setPrototypeOf({ _tag: "Running", progress }, ResultPrototype) +export const succeed = (value: A): Success => Object.setPrototypeOf({ _tag: "Success", value }, ResultPrototype) +export const fail = (cause: Cause.Cause ): Failure => Object.setPrototypeOf({ _tag: "Failure", cause }, ResultPrototype) + +export const willFetch = >( + result: R +): Omit & WillFetch => Object.setPrototypeOf( + Object.assign({}, result, { _flag: "WillFetch" }), + Object.getPrototypeOf(result), +) + +export const willRefresh = >( + result: R +): Omit & WillRefresh => Object.setPrototypeOf( + Object.assign({}, result, { _flag: "WillRefresh" }), + Object.getPrototypeOf(result), +) + +export const refreshing = , P = never>( + result: R, + progress?: P, +): Omit & Refreshing

=> Object.setPrototypeOf( + Object.assign({}, result, { _flag: "Refreshing", progress }), + Object.getPrototypeOf(result), +) + +export const fromExit: { + (exit: Exit.Success): Success + (exit: Exit.Failure): Failure + (exit: Exit.Exit): Success | Failure +} = exit => (exit._tag === "Success" ? succeed(exit.value) : fail(exit.cause)) as any + +export const toExit: { + (self: Success): Exit.Success + (self: Failure): Exit.Failure + (self: Final): Exit.Exit + (self: Result): Exit.Exit +} = (self: Result): any => { + switch (self._tag) { + case "Success": + return Exit.succeed(self.value) + case "Failure": + return Exit.failCause(self.cause) + default: + return Exit.fail(new Cause.NoSuchElementError()) + } +} + + +export interface Progress

{ + readonly progress: Lens.Lens +} +export const Progress =

() => Context.Service>("@effect-fc/Result/Progress") + +export class PreviousResultNotRunningNorRefreshing extends Data.TaggedError("@effect-fc/Result/PreviousResultNotRunningNorRefreshing")<{ + readonly previous: Result +}> {} + +export const makeProgressLayer = ( + state: Lens.Lens, never, never, never, never> +): Layer.Layer | Progress, never, never> => Layer.succeed( + Progress

() as Context.Service | Progress, Progress

| Progress>, + { + progress: state.pipe( + Lens.mapEffect( + a => (isRunning(a) || hasRefreshingFlag(a)) + ? Effect.succeed(a) + : Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous: a })), + (_, b) => Effect.succeed(b), + ), + Lens.map( + a => a.progress, + (a, b) => isRunning(a) + ? running(b) + : refreshing(a, b) as Final & Refreshing

, + ), + ) + }, +) + + +export namespace unsafeForkEffect { + export type OutputContext = Exclude | Progress> + + export interface Options { + readonly initial?: Initial | Final + readonly initialProgress?: P + } +} + +export const unsafeForkEffect = Effect.fnUntraced(function* ( + effect: Effect.Effect, + options?: unsafeForkEffect.Options, NoInfer, P>, +): Effect.fn.Return< + readonly [result: Subscribable.Subscribable, never, never>, fiber: Fiber.Fiber], + never, + Scope.Scope | unsafeForkEffect.OutputContext +> { + const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make>( + options?.initial ?? initial(), + )) + + const fiber = yield* Effect.gen(function*() { + yield* Lens.set( + state, + (isFinal(options?.initial) && hasWillRefreshFlag(options?.initial)) + ? refreshing(options.initial, options?.initialProgress) as Result + : running(options?.initialProgress), + ) + return yield* Effect.onExit(effect, exit => Lens.set(state, fromExit(exit))) + }).pipe( + Effect.forkScoped, + Effect.provide(makeProgressLayer(state)), + ) + + return [state, fiber] as const +}) + +export namespace forkEffect { + export type InputContext = R extends Progress ? [X] extends [P] ? R : never : R + export type OutputContext = unsafeForkEffect.OutputContext + export interface Options extends unsafeForkEffect.Options {} +} + +export const forkEffect: { + ( + effect: Effect.Effect>>, + options?: forkEffect.Options, NoInfer, P>, + ): Effect.Effect< + readonly [result: Subscribable.Subscribable, never, never>, fiber: Fiber.Fiber], + never, + Scope.Scope | forkEffect.OutputContext + > +} = unsafeForkEffect diff --git a/packages/effect-fc-next/src/SetStateAction.ts b/packages/effect-fc-next/src/SetStateAction.ts new file mode 100644 index 0000000..ee5db34 --- /dev/null +++ b/packages/effect-fc-next/src/SetStateAction.ts @@ -0,0 +1,12 @@ +import { Function } from "effect" +import type * as React from "react" + + +export const value: { + (self: React.SetStateAction, prevState: S): S + (prevState: S): (self: React.SetStateAction) => S +} = Function.dual(2, (self: React.SetStateAction, prevState: S): S => + typeof self === "function" + ? (self as (prevState: S) => S)(prevState) + : self +) diff --git a/packages/effect-fc-next/src/Stream.ts b/packages/effect-fc-next/src/Stream.ts new file mode 100644 index 0000000..67bf82e --- /dev/null +++ b/packages/effect-fc-next/src/Stream.ts @@ -0,0 +1,33 @@ +import { Effect, Equivalence, Option, Stream } from "effect" +import * as React from "react" +import * as Component from "./Component.js" + + +export const use: { + ( + stream: Stream.Stream + ): Effect.Effect, never, R> + , E, R>( + stream: Stream.Stream, + initialValue: A, + ): Effect.Effect, never, R> +} = Effect.fnUntraced(function* , E, R>( + stream: Stream.Stream, + initialValue?: A, +) { + const [reactStateValue, setReactStateValue] = React.useState(() => initialValue + ? Option.some(initialValue) + : Option.none() + ) + + yield* Component.useReactEffect(() => Effect.forkScoped( + Stream.runForEach( + Stream.changesWith(stream, Equivalence.strictEqual()), + v => Effect.sync(() => setReactStateValue(Option.some(v))), + ) + ), [stream]) + + return reactStateValue as Option.Some +}) + +export * from "effect/Stream" diff --git a/packages/effect-fc-next/src/SubmittableForm.ts b/packages/effect-fc-next/src/SubmittableForm.ts new file mode 100644 index 0000000..79dfe46 --- /dev/null +++ b/packages/effect-fc-next/src/SubmittableForm.ts @@ -0,0 +1,212 @@ +import { + Array, + Cause, + type Context, + Effect, + Fiber, + Option, + Pipeable, + Predicate, + Schema, + SchemaIssue, + SchemaParser, + type Scope, + Semaphore, + SubscriptionRef, +} from "effect" +import * as Form from "./Form.js" +import * as Lens from "./Lens.js" +import * as Mutation from "./Mutation.js" +import * as Result from "./Result.js" +import * as Subscribable from "./Subscribable.js" + + +type FormSchema = Schema.Top & { + readonly Type: A + readonly Encoded: I + readonly DecodingServices: R +} + +export const SubmittableFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SubmittableForm") +export type SubmittableFormTypeId = typeof SubmittableFormTypeId + +export interface SubmittableForm +extends Form.Form { + readonly [SubmittableFormTypeId]: SubmittableFormTypeId + readonly schema: FormSchema + readonly context: Context.Context + readonly mutation: Mutation.Mutation< + readonly [value: A, form: SubmittableForm], + MA, ME, MR, MP + > + readonly validationFiber: Subscribable.Subscribable>, never, never> + readonly run: Effect.Effect + readonly submit: Effect.Effect>, Cause.NoSuchElementError> +} + +export class SubmittableFormImpl +extends Pipeable.Class implements SubmittableForm { + readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId + readonly [SubmittableFormTypeId]: SubmittableFormTypeId = SubmittableFormTypeId + readonly path = [] as const + readonly encodedValue: Lens.Lens + readonly isValidating: Subscribable.Subscribable + readonly canCommit: Subscribable.Subscribable + readonly isCommitting: Subscribable.Subscribable + + constructor( + readonly schema: FormSchema, + readonly context: Context.Context, + readonly mutation: Mutation.Mutation< + readonly [value: A, form: SubmittableForm], + MA, ME, MR, MP + >, + readonly value: Lens.Lens, never, never, never, never>, + readonly internalEncodedValue: Lens.Lens, + readonly issues: Lens.Lens, + readonly validationFiber: Lens.Lens>, never, never, never, never>, + readonly runSemaphore: Semaphore.Semaphore, + ) { + super() + this.encodedValue = Lens.make({ + get: Lens.get(internalEncodedValue), + changes: internalEncodedValue.changes, + commit: encoded => Effect.andThen( + Lens.set(internalEncodedValue, encoded), + this.synchronizeEncodedValue(encoded), + ), + lock: Lens.asLensImpl(internalEncodedValue).lock, + }) + this.isValidating = Subscribable.map(validationFiber, Option.isSome) + const commitState = Subscribable.zipLatestAll( + value as any, + issues as any, + validationFiber as any, + mutation.result as any, + ) as unknown as Subscribable.Subscribable, + readonly Form.FormIssue[], + Option.Option>, + Result.Result, + ]> + this.canCommit = Subscribable.map( + commitState, + ([current, currentIssues, fiber, result]: readonly [Option.Option, readonly Form.FormIssue[], Option.Option>, Result.Result]) => Option.isSome(current) + && currentIssues.length === 0 + && Option.isNone(fiber) + && !(Result.isRunning(result) || Result.hasRefreshingFlag(result)), + ) + this.isCommitting = Subscribable.map( + mutation.result, + result => Result.isRunning(result) || Result.hasRefreshingFlag(result), + ) + } + + synchronizeEncodedValue(encodedValue: I): Effect.Effect { + const self = this + return Effect.gen(function*() { + const current = yield* Lens.get(self.validationFiber) + if (Option.isSome(current)) yield* Fiber.interrupt(current.value) + const fiber = yield* Effect.forkScoped( + Effect.ensuring( + SchemaParser.decodeEffect(self.schema)(encodedValue), + Lens.set(self.validationFiber, Option.none()), + ), + ) + yield* Lens.set(self.validationFiber, Option.some(fiber)) + const decoded = yield* Fiber.join(fiber).pipe( + Effect.tap(value => Effect.andThen( + Lens.set(self.issues, Array.empty()), + Lens.set(self.value, Option.some(value)), + )), + Effect.catchIf(SchemaIssue.isIssue, issue => Lens.set(self.issues, formatIssue(issue))), + ) + void decoded + }).pipe(Effect.provide(this.context)) as Effect.Effect + } + + get run(): Effect.Effect { + return Effect.flatMap( + Lens.get(this.encodedValue), + SchemaParser.decodeEffect(this.schema), + ).pipe( + Effect.option, + Effect.flatMap(value => Lens.set(this.value, value)), + Effect.provide(this.context), + this.runSemaphore.withPermits(1), + ) + } + + get submit(): Effect.Effect>, Cause.NoSuchElementError> { + return Effect.flatMap(Lens.get(this.value), value => Effect.flatMap(Effect.fromOption(value), decoded => this.submitValue(decoded))) + } + + submitValue(value: A): Effect.Effect>> { + return Effect.flatMap(this.canCommit.get, canCommit => { + if (!canCommit) return Effect.succeed(Option.none()) + return Effect.map( + Effect.tap(this.mutation.mutate([value, this as any]), result => { + if (!Result.isFailure(result)) return Effect.succeed(undefined) + const issue = Cause.findErrorOption(result.cause) + return Option.isSome(issue) && SchemaIssue.isIssue(issue.value) + ? Lens.set(this.issues, formatIssue(issue.value)) + : Effect.succeed(undefined) + }), + Option.some, + ) + }) + } +} + +const formatIssue = (issue: SchemaIssue.Issue): readonly Form.FormIssue[] => { + const formatted = SchemaIssue.makeFormatterStandardSchemaV1()(issue) + return formatted.issues.map(item => ({ + path: (item.path ?? []) as readonly PropertyKey[], + message: item.message, + })) +} + +export const isSubmittableForm = (u: unknown): u is SubmittableForm => Predicate.hasProperty(u, SubmittableFormTypeId) + +export declare namespace make { + export interface Options + extends Mutation.make.Options< + readonly [value: NoInfer, form: SubmittableForm, NoInfer, NoInfer, unknown, unknown, unknown>], + MA, ME, MR, MP + > { + readonly schema: FormSchema + readonly initialEncodedValue: NoInfer + } +} + +export const make = Effect.fnUntraced(function* ( + options: make.Options, +): Effect.fn.Return< + SubmittableForm, MP>, + never, + Scope.Scope | R | Result.forkEffect.OutputContext +> { + return new SubmittableFormImpl( + options.schema, + yield* Effect.context(), + yield* Mutation.make(options), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none())), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Array.empty())), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none>())), + yield* Semaphore.make(1), + ) +}) + +export declare namespace service { + export interface Options + extends make.Options {} +} + +export const service = ( + options: service.Options, +): Effect.Effect< + SubmittableForm, MP>, + never, + Scope.Scope | R | Result.forkEffect.OutputContext +> => Effect.tap(make(options), form => Effect.asVoid(Effect.forkScoped(form.run))) diff --git a/packages/effect-fc-next/src/Subscribable.ts b/packages/effect-fc-next/src/Subscribable.ts new file mode 100644 index 0000000..e8fafe6 --- /dev/null +++ b/packages/effect-fc-next/src/Subscribable.ts @@ -0,0 +1,53 @@ +import { Effect, Equivalence, Stream } from "effect" +import { Subscribable } from "effect-lens" +import * as React from "react" +import * as Component from "./Component.js" + + +export * from "effect-lens/Subscribable" + +export const zipLatestAll = []>( + ...elements: T +): Subscribable.Subscribable< + [T[number]] extends [never] + ? never + : { [K in keyof T]: T[K] extends Subscribable.Subscribable ? A : never }, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? E : never, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? R : never +> => Subscribable.make({ + get: Effect.all(elements.map(v => v.get)), + changes: Stream.zipLatestAll(...elements.map(v => v.changes)), +}) as any + +export declare namespace useAll { + export type Success[]> = [T[number]] extends [never] + ? never + : { [K in keyof T]: T[K] extends Subscribable.Subscribable ? A : never } + + export interface Options { + readonly equivalence?: Equivalence.Equivalence + } +} + +export const useAll = Effect.fnUntraced(function* []>( + elements: T, + options?: useAll.Options>>, +): Effect.fn.Return< + useAll.Success, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? E : never, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? R : never +> { + const [reactStateValue, setReactStateValue] = React.useState( + yield* Component.useOnMount(() => Effect.all(elements.map(v => v.get))) + ) + + yield* Component.useReactEffect(() => Stream.zipLatestAll(...elements.map(ref => ref.changes)).pipe( + Stream.changesWith((options?.equivalence as Equivalence.Equivalence | undefined) ?? Equivalence.Array(Equivalence.strictEqual())), + Stream.runForEach(v => + Effect.sync(() => setReactStateValue(v)) + ), + Effect.forkScoped, + ), elements) + + return reactStateValue as any +}) diff --git a/packages/effect-fc-next/src/SynchronizedForm.ts b/packages/effect-fc-next/src/SynchronizedForm.ts new file mode 100644 index 0000000..330628a --- /dev/null +++ b/packages/effect-fc-next/src/SynchronizedForm.ts @@ -0,0 +1,204 @@ +import { + Array, + type Context, + Effect, + Equal, + Fiber, + Option, + Pipeable, + Predicate, + Schema, + SchemaIssue, + SchemaParser, + type Scope, + Semaphore, + Stream, + SubscriptionRef, +} from "effect" +import * as Form from "./Form.js" +import * as Lens from "./Lens.js" +import * as Subscribable from "./Subscribable.js" + + +type FormSchema = Schema.Top & { + readonly Type: A + readonly Encoded: I + readonly DecodingServices: R + readonly EncodingServices: R +} + +export const SynchronizedFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SynchronizedForm") +export type SynchronizedFormTypeId = typeof SynchronizedFormTypeId + +export interface SynchronizedForm< + in out A, + in out I = A, + in out R = never, + in out TER = never, + in out TEW = never, + in out TRR = never, + in out TRW = never, +> extends Form.Form { + readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId + readonly schema: FormSchema + readonly context: Context.Context + readonly target: Lens.Lens + readonly validationFiber: Subscribable.Subscribable>, never, never> + readonly run: Effect.Effect +} + +export class SynchronizedFormImpl< + in out A, + in out I = A, + in out R = never, + in out TER = never, + in out TEW = never, + in out TRR = never, + in out TRW = never, +> extends Pipeable.Class implements SynchronizedForm { + readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId + readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId = SynchronizedFormTypeId + readonly path = [] as const + readonly value: Subscribable.Subscribable, TER, never> + readonly encodedValue: Lens.Lens + readonly isValidating: Subscribable.Subscribable + readonly canCommit: Subscribable.Subscribable + + constructor( + readonly schema: FormSchema, + readonly context: Context.Context, + readonly target: Lens.Lens, + readonly internalEncodedValue: Lens.Lens, + readonly issues: Lens.Lens, + readonly validationFiber: Lens.Lens>, never, never, never, never>, + readonly isCommitting: Lens.Lens, + readonly runSemaphore: Semaphore.Semaphore, + ) { + super() + this.value = Subscribable.make({ + get: Effect.provide(Effect.map(target.get, Option.some), context), + changes: Stream.provideContext( + target.changes.pipe( + Stream.map(Option.some), + Stream.catchCause(() => Stream.make(Option.none())), + ), + context, + ), + }) + this.encodedValue = Lens.make({ + get: Lens.get(internalEncodedValue), + changes: internalEncodedValue.changes, + commit: encoded => Effect.andThen( + Lens.set(internalEncodedValue, encoded), + this.synchronizeEncodedValue(encoded), + ), + lock: Lens.asLensImpl(internalEncodedValue).lock, + }) as unknown as Lens.Lens + this.isValidating = Subscribable.map(validationFiber, Option.isSome) + const commitState = Subscribable.zipLatestAll(issues as any, validationFiber as any, isCommitting as any) as unknown as Subscribable.Subscribable>, + boolean, + ]> + this.canCommit = Subscribable.map( + commitState, + ([currentIssues, fiber, committing]) => currentIssues.length === 0 && Option.isNone(fiber) && !committing, + ) + } + + synchronizeEncodedValue(encodedValue: I): Effect.Effect { + const self = this + return Effect.gen(function*() { + const current = yield* Lens.get(self.validationFiber) + if (Option.isSome(current)) yield* Fiber.interrupt(current.value) + const fiber = yield* Effect.forkScoped( + Effect.ensuring( + SchemaParser.decodeEffect(self.schema)(encodedValue), + Lens.set(self.validationFiber, Option.none()), + ), + ) + yield* Lens.set(self.validationFiber, Option.some(fiber)) + yield* Fiber.join(fiber).pipe( + Effect.flatMap(value => Effect.ensuring( + Effect.andThen( + Lens.set(self.isCommitting, true), + Effect.andThen(Lens.set(self.issues, Array.empty()), Lens.set(self.target, value)), + ), + Lens.set(self.isCommitting, false), + )), + Effect.catchIf(SchemaIssue.isIssue, issue => Lens.set(self.issues, formatIssue(issue))), + ) + }).pipe(Effect.provide(this.context)) as Effect.Effect + } + + get run(): Effect.Effect { + return this.runSemaphore.withPermits(1)(Effect.provide( + Stream.runForEach(Stream.drop(this.target.changes, 1), targetValue => Effect.ignore( + Effect.flatMap(SchemaParser.encodeEffect(this.schema)(targetValue), encodedValue => Effect.flatMap( + Lens.get(this.internalEncodedValue), + current => Equal.equals(encodedValue, current) + ? Effect.succeed(undefined) + : Effect.andThen( + Lens.set(this.issues, Array.empty()), + Lens.set(this.internalEncodedValue, encodedValue), + ), + )), + )), + this.context, + )) + } +} + +const formatIssue = (issue: SchemaIssue.Issue): readonly Form.FormIssue[] => { + const formatted = SchemaIssue.makeFormatterStandardSchemaV1()(issue) + return formatted.issues.map(item => ({ + path: (item.path ?? []) as readonly PropertyKey[], + message: item.message, + })) +} + +export const isSynchronizedForm = (u: unknown): u is SynchronizedForm => Predicate.hasProperty(u, SynchronizedFormTypeId) + +export declare namespace make { + export interface Options { + readonly schema: FormSchema + readonly target: Lens.Lens + readonly initialEncodedValue?: NoInfer + } +} + +export const make = Effect.fnUntraced(function* ( + options: make.Options, +): Effect.fn.Return< + SynchronizedForm, + SchemaIssue.Issue | TER, + Scope.Scope | R | TRR | TRW +> { + const initialEncodedValue = options.initialEncodedValue !== undefined + ? options.initialEncodedValue + : yield* Effect.flatMap(Lens.get(options.target), SchemaParser.encodeEffect(options.schema)) + + return new SynchronizedFormImpl( + options.schema, + yield* Effect.context(), + options.target, + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(initialEncodedValue)), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Array.empty())), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none>())), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(false)), + yield* Semaphore.make(1), + ) +}) + +export declare namespace service { + export interface Options + extends make.Options {} +} + +export const service = ( + options: service.Options, +): Effect.Effect< + SynchronizedForm, + SchemaIssue.Issue | TER, + Scope.Scope | R | TRR | TRW +> => Effect.tap(make(options), form => Effect.asVoid(Effect.forkScoped(form.run))) diff --git a/packages/effect-fc-next/src/index.ts b/packages/effect-fc-next/src/index.ts new file mode 100644 index 0000000..8829e81 --- /dev/null +++ b/packages/effect-fc-next/src/index.ts @@ -0,0 +1,17 @@ +export * as Async from "./Async.js" +export * as Component from "./Component.js" +export * as ErrorObserver from "./ErrorObserver.js" +export * as Form from "./Form.js" +export * as Lens from "./Lens.js" +export * as Memoized from "./Memoized.js" +export * as Mutation from "./Mutation.js" +export * as PubSub from "./PubSub.js" +export * as Query from "./Query.js" +export * as QueryClient from "./QueryClient.js" +export * as ReactRuntime from "./ReactRuntime.js" +export * as Result from "./Result.js" +export * as SetStateAction from "./SetStateAction.js" +export * as Stream from "./Stream.js" +export * as SubmittableForm from "./SubmittableForm.js" +export * as Subscribable from "./Subscribable.js" +export * as SynchronizedForm from "./SynchronizedForm.js" diff --git a/packages/effect-fc-next/src/utils.ts b/packages/effect-fc-next/src/utils.ts new file mode 100644 index 0000000..44ed408 --- /dev/null +++ b/packages/effect-fc-next/src/utils.ts @@ -0,0 +1,3 @@ +export type ExcludeKeys = K extends keyof T ? ( + { [P in K]?: never } & Omit +) : T diff --git a/packages/effect-fc-next/test/Component.test.tsx b/packages/effect-fc-next/test/Component.test.tsx new file mode 100644 index 0000000..9401608 --- /dev/null +++ b/packages/effect-fc-next/test/Component.test.tsx @@ -0,0 +1,354 @@ +import { render, screen, waitFor } from "@testing-library/react" +import { Context, Effect, Layer } from "effect" +import * as React from "react" +import { afterEach, describe, expect, it, vi } from "vitest" +import * as Component from "../src/Component.js" +import * as ReactRuntime from "../src/ReactRuntime.js" + + +class ValueService extends Context.Service()("ValueService") {} + +afterEach(() => { + vi.useRealTimers() +}) + +describe("Component", () => { + it("runs useOnMount only once across rerenders", async () => { + const onMount = vi.fn(() => Effect.succeed("mounted")) + const runtime = ReactRuntime.make(Layer.empty) + const effectRuntime = await runtime.runtime.context() + + const Probe = Component.makeUntraced("UseOnMountProbe")(function*() { + const value = yield* Component.useOnMount(onMount) + return

+ }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("mounted") + expect(onMount).toHaveBeenCalledTimes(1) + + view.rerender( + + + + ) + expect(await screen.findByText("mounted")).toBeTruthy() + expect(onMount).toHaveBeenCalledTimes(1) + + view.unmount() + await runtime.runtime.dispose() + }) + + it("recomputes useOnChange only when dependencies change", async () => { + const onChange = vi.fn((value: number) => Effect.succeed(`value:${value}`)) + const runtime = ReactRuntime.make(Layer.empty) + const effectRuntime = await runtime.runtime.context() + + const Probe = Component.makeUntraced("UseOnChangeProbe")(function*(props: { readonly value: number }) { + const result = yield* Component.useOnChange(() => onChange(props.value), [props.value], { + finalizerExecutionDebounce: 0, + }) + + return
{result}
+ }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("value:1") + expect(onChange).toHaveBeenCalledTimes(1) + + view.rerender( + + + + ) + + expect(await screen.findByText("value:1")).toBeTruthy() + expect(onChange).toHaveBeenCalledTimes(1) + + view.rerender( + + + + ) + + await screen.findByText("value:2") + expect(onChange).toHaveBeenCalledTimes(2) + + view.unmount() + await runtime.runtime.dispose() + }) + + it("closes the previous scope on dependency changes and unmount", async () => { + const cleanup = vi.fn() + const runtime = ReactRuntime.make(Layer.empty) + const effectRuntime = await runtime.runtime.context() + + const Probe = Component.makeUntraced("ScopeCleanupProbe")(function*(props: { readonly value: string }) { + const result = yield* Component.useOnChange( + () => Effect.gen(function*() { + yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value))) + return props.value + }), + [props.value], + { finalizerExecutionDebounce: 0 }, + ) + + return
{result}
+ }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("first") + expect(cleanup).not.toHaveBeenCalled() + + view.rerender( + + + + ) + + await screen.findByText("second") + await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first")) + expect(cleanup).toHaveBeenCalledTimes(1) + + view.unmount() + + await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second")) + expect(cleanup).toHaveBeenCalledTimes(2) + await runtime.runtime.dispose() + }) + + it("runs useReactEffect setup and cleanup when dependencies change", async () => { + const lifecycle = vi.fn<(message: string) => void>() + const runtime = ReactRuntime.make(Layer.empty) + const effectRuntime = await runtime.runtime.context() + + const Probe = Component.makeUntraced("UseReactEffectProbe")(function*(props: { readonly value: string }) { + yield* Component.useReactEffect(() => + Effect.gen(function*() { + yield* Effect.sync(() => lifecycle(`mount:${props.value}`)) + yield* Effect.addFinalizer(() => Effect.sync(() => lifecycle(`cleanup:${props.value}`))) + }), + [props.value]) + + return
{props.value}
+ }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("first") + await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:first")) + + view.rerender( + + + + ) + + await screen.findByText("second") + await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:first")) + await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:second")) + + view.unmount() + + await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:second")) + expect(lifecycle.mock.calls.map(([message]) => message)).toEqual([ + "mount:first", + "cleanup:first", + "mount:second", + "cleanup:second", + ]) + await runtime.runtime.dispose() + }) + + it("keeps useCallbackSync stable until dependencies change", async () => { + const runtime = ReactRuntime.make(Layer.empty) + const effectRuntime = await runtime.runtime.context() + const seenCallbacks: Array<(value: number) => string> = [] + + const Probe = Component.makeUntraced("UseCallbackSyncProbe")(function*(props: { readonly prefix: string }) { + const callback = yield* Component.useCallbackSync( + (value: number) => Effect.succeed(`${props.prefix}:${value}`), + [props.prefix], + ) + + yield* Component.useOnMount(() => Effect.sync(() => { + seenCallbacks.push(callback) + })) + + React.useEffect(() => { + seenCallbacks.push(callback) + }, [callback]) + + return
{callback(1)}
+ }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("a:1") + expect(seenCallbacks).toHaveLength(2) + expect(seenCallbacks[0]).toBe(seenCallbacks[1]) + expect(seenCallbacks[0]?.(2)).toBe("a:2") + + view.rerender( + + + + ) + + await screen.findByText("a:1") + expect(seenCallbacks).toHaveLength(2) + + view.rerender( + + + + ) + + await screen.findByText("b:1") + await waitFor(() => expect(seenCallbacks).toHaveLength(3)) + expect(seenCallbacks[2]).not.toBe(seenCallbacks[1]) + expect(seenCallbacks[2]?.(2)).toBe("b:2") + + view.unmount() + await runtime.runtime.dispose() + }) + + it("delays cleanup according to finalizerExecutionDebounce", async () => { + const cleanup = vi.fn() + const runtime = ReactRuntime.make(Layer.empty) + const effectRuntime = await runtime.runtime.context() + + const Probe = Component.makeUntraced("DebouncedCleanupProbe")(function*(props: { readonly value: string }) { + const result = yield* Component.useOnChange( + () => Effect.gen(function*() { + yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value))) + return props.value + }), + [props.value], + { finalizerExecutionDebounce: "20 millis" }, + ) + + return
{result}
+ }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("first") + + view.rerender( + + + + ) + + await screen.findByText("second") + expect(cleanup).not.toHaveBeenCalled() + + await new Promise(resolve => setTimeout(resolve, 5)) + expect(cleanup).not.toHaveBeenCalled() + + await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"), { timeout: 100 }) + + view.unmount() + await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"), { timeout: 100 }) + await runtime.runtime.dispose() + }) + + it("does not remount a component when only nonReactiveTags change", async () => { + const mounts = vi.fn() + const unmounts = vi.fn() + const runtime = ReactRuntime.make(Layer.empty) + const effectRuntime = await runtime.runtime.context() + + const SubComponent = Component.makeUntraced("NonReactiveSubComponent")(function*() { + const service = yield* ValueService + + yield* Component.useOnMount(() => Effect.gen(function*() { + yield* Effect.sync(() => mounts()) + yield* Effect.addFinalizer(() => Effect.sync(() => unmounts())) + })) + + return
{service.value}
+ }).pipe( + Component.withOptions({ nonReactiveTags: [ValueService] }) + ) + + const Parent = Component.makeUntraced("NonReactiveParent")(function*(props: { readonly value: string }) { + const serviceLayer = React.useMemo( + () => Layer.succeed(ValueService, { value: props.value }), + [props.value], + ) + const context = yield* Component.useContextFromLayer(serviceLayer, { + finalizerExecutionDebounce: 0, + }) + const Child = yield* Effect.provide(SubComponent.use, context) + + return + }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("first") + expect(mounts).toHaveBeenCalledTimes(1) + expect(unmounts).not.toHaveBeenCalled() + + view.rerender( + + + + ) + + await screen.findByText("second") + expect(mounts).toHaveBeenCalledTimes(1) + expect(unmounts).not.toHaveBeenCalled() + + view.unmount() + await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1)) + await runtime.runtime.dispose() + }) +}) diff --git a/packages/effect-fc-next/test/Lens.test.tsx b/packages/effect-fc-next/test/Lens.test.tsx new file mode 100644 index 0000000..3407f14 --- /dev/null +++ b/packages/effect-fc-next/test/Lens.test.tsx @@ -0,0 +1,165 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { Effect, Layer, SubscriptionRef } from "effect" +import * as React from "react" +import { describe, expect, it } from "vitest" +import * as Component from "../src/Component.js" +import * as Lens from "../src/Lens.js" +import * as ReactRuntime from "../src/ReactRuntime.js" + + +const makeRuntime = async () => { + const runtime = ReactRuntime.make(Layer.empty) + const effectRuntime = await runtime.runtime.context() + + return { + runtime, + effectRuntime, + dispose: () => runtime.runtime.dispose(), + } +} + +describe("Lens", () => { + it("useState stays in sync with lens updates in both directions", async () => { + const { runtime, effectRuntime, dispose } = await makeRuntime() + const ref = await Effect.runPromise(SubscriptionRef.make(0)) + const lens = Lens.fromSubscriptionRef(ref) + + const Probe = Component.makeUntraced("LensUseStateProbe")(function*() { + const [value, setValue] = yield* Lens.useState(lens) + + return ( + <> +
{value}
+ + + ) + }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("0") + + await Effect.runPromise(Lens.set(lens, 5)) + await screen.findByText("5") + + fireEvent.click(screen.getByRole("button", { name: "increment" })) + await screen.findByText("6") + expect(await Effect.runPromise(Lens.get(lens))).toBe(6) + + view.unmount() + await dispose() + }) + + it("useState respects the provided equivalence when subscribing to lens changes", async () => { + const { runtime, effectRuntime, dispose } = await makeRuntime() + const ref = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" })) + const lens = Lens.fromSubscriptionRef(ref) + + const Probe = Component.makeUntraced("LensUseStateEquivalenceProbe")(function*() { + const [value] = yield* Lens.useState(lens, { + equivalence: (self, that) => self.id === that.id, + }) + + return
{value.label}
+ }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("first") + + await Effect.runPromise(Lens.set(lens, { id: 1, label: "ignored" })) + await waitFor(() => expect(screen.getByText("first")).toBeTruthy()) + expect(screen.queryByText("ignored")).toBeNull() + + await Effect.runPromise(Lens.set(lens, { id: 2, label: "updated" })) + await screen.findByText("updated") + + view.unmount() + await dispose() + }) + + it("useFromReactState writes React state changes into the returned lens", async () => { + const { runtime, effectRuntime, dispose } = await makeRuntime() + let lens: Lens.Lens | undefined + + const Probe = Component.makeUntraced("LensUseFromReactStateProbe")(function*() { + const [value, setValue] = React.useState("hello") + const reactLens = yield* Lens.useFromReactState([value, setValue]) + + yield* Component.useOnMount(() => Effect.sync(() => { + lens = reactLens + })) + + return + }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("hello") + await waitFor(() => expect(lens).toBeDefined()) + + fireEvent.click(screen.getByRole("button", { name: "hello" })) + await screen.findByText("hello!") + await waitFor(async () => expect(await Effect.runPromise(Lens.get(lens!))).toBe("hello!")) + + view.unmount() + await dispose() + }) + + it("useFromReactState respects equivalence when lens updates flow back into React state", async () => { + const { runtime, effectRuntime, dispose } = await makeRuntime() + let lens: Lens.Lens<{ readonly id: number; readonly label: string }, never, never, never, never> | undefined + + const Probe = Component.makeUntraced("LensUseFromReactStateEquivalenceProbe")(function*() { + const [value, setValue] = React.useState({ id: 1, label: "first" }) + const reactLens = yield* Lens.useFromReactState([value, setValue], { + equivalence: (self, that) => self.id === that.id, + }) + + yield* Component.useOnMount(() => Effect.sync(() => { + lens = reactLens + })) + + return
{value.label}
+ }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("first") + await waitFor(() => expect(lens).toBeDefined()) + + await Effect.runPromise(Lens.set(lens!, { id: 1, label: "ignored" })) + await waitFor(() => expect(screen.getByText("first")).toBeTruthy()) + expect(screen.queryByText("ignored")).toBeNull() + + await Effect.runPromise(Lens.set(lens!, { id: 2, label: "updated" })) + await screen.findByText("updated") + + view.unmount() + await dispose() + }) +}) diff --git a/packages/effect-fc-next/test/Query.test.ts b/packages/effect-fc-next/test/Query.test.ts new file mode 100644 index 0000000..ee6544f --- /dev/null +++ b/packages/effect-fc-next/test/Query.test.ts @@ -0,0 +1,169 @@ +import { Effect, Option, type Scope, Stream } from "effect" +import { describe, expect, it } from "vitest" +import * as Query from "../src/Query.js" +import * as QueryClient from "../src/QueryClient.js" +import * as Result from "../src/Result.js" + + +const runQueryTest = (effect: Effect.Effect) => + Effect.runPromise(Effect.scoped(effect.pipe( + Effect.provide(QueryClient.QueryClient.Default), + ))) + +const expectSuccessValue = ( + result: Result.Result, +): A => { + expect(Result.isSuccess(result)).toBe(true) + + if (!Result.isSuccess(result)) + throw new Error(`Expected Success result, received ${result._tag}`) + + return result.value +} + +const expectSomeValue =
(option: Option.Option): A => { + expect(Option.isSome(option)).toBe(true) + + if (!Option.isSome(option)) + throw new Error("Expected Some option, received None") + + return option.value +} + +describe("Query", () => { + it("fetch caches successful results until they are invalidated or stale", async () => { + let calls = 0 + const key = Stream.empty as Stream.Stream + + const result = await runQueryTest(Effect.gen(function*() { + const query = yield* Query.make({ + key, + f: ([id]: readonly [number]) => Effect.sync(() => { + calls += 1 + return `value:${id}:${calls}` + }), + staleTime: "1 minute", + }) + + const first = yield* query.fetch([1]) + const second = yield* query.fetch([1]) + + return [first, second] as const + })) + + expect(calls).toBe(1) + expect(result[0]._tag).toBe("Success") + expect(result[1]._tag).toBe("Success") + expect(expectSuccessValue(result[0])).toBe("value:1:1") + expect(expectSuccessValue(result[1])).toBe("value:1:1") + }) + + it("refresh reruns the latest query key", async () => { + let calls = 0 + const key = Stream.empty as Stream.Stream + + const result = await runQueryTest(Effect.gen(function*() { + const query = yield* Query.make({ + key, + f: ([id]: readonly [number]) => Effect.sync(() => { + calls += 1 + return `value:${id}:${calls}` + }), + staleTime: "0 millis", + }) + + const first = yield* query.fetch([1]) + yield* Effect.sleep("1 millis") + const refreshed = yield* query.refresh + + return [first, refreshed] as const + })) + + expect(calls).toBe(2) + expect(expectSuccessValue(result[0])).toBe("value:1:1") + expect(expectSuccessValue(result[1])).toBe("value:1:2") + }) + + it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => { + let calls = 0 + const key = Stream.empty as Stream.Stream + + const result = await runQueryTest(Effect.gen(function*() { + const query = yield* Query.make({ + key, + f: ([id]: readonly [number]) => Effect.sync(() => { + calls += 1 + return `value:${id}:${calls}` + }), + staleTime: "1 minute", + }) + + const first = yield* query.fetch([1]) + yield* query.invalidateCacheEntry([1]) + const second = yield* query.fetch([1]) + + return [first, second] as const + })) + + expect(calls).toBe(2) + expect(expectSuccessValue(result[0])).toBe("value:1:1") + expect(expectSuccessValue(result[1])).toBe("value:1:2") + }) + + it("invalidateCache clears cached entries for the query function", async () => { + let calls = 0 + const key = Stream.empty as Stream.Stream + + const result = await runQueryTest(Effect.gen(function*() { + const query = yield* Query.make({ + key, + f: ([id]: readonly [number]) => Effect.sync(() => { + calls += 1 + return `value:${id}:${calls}` + }), + staleTime: "1 minute", + }) + + const first = yield* query.fetch([1]) + yield* query.invalidateCache + const second = yield* query.fetch([1]) + + return [first, second] as const + })) + + expect(calls).toBe(2) + expect(expectSuccessValue(result[0])).toBe("value:1:1") + expect(expectSuccessValue(result[1])).toBe("value:1:2") + }) + + it("service starts the key stream automatically and updates latest state", async () => { + let calls = 0 + const key = Stream.make([1] as const) as Stream.Stream + + const effect = Effect.gen(function*() { + const query = yield* Query.service({ + key, + f: ([id]: readonly [number]) => Effect.sync(() => { + calls += 1 + return `value:${id}:${calls}` + }), + staleTime: "1 minute", + }) + + yield* Effect.sleep("10 millis") + + return { + final: yield* query.result.get, + latestKey: yield* query.latestKey.get, + latestFinalResult: yield* query.latestFinalResult.get, + } + }) + + const result = await runQueryTest(effect) + + expect(calls).toBe(1) + expect(expectSuccessValue(result.final)).toBe("value:1:1") + expect(expectSomeValue(result.latestKey)).toEqual([1]) + expect(expectSuccessValue(expectSomeValue(result.latestFinalResult))).toBe("value:1:1") + }) +}) diff --git a/packages/effect-fc-next/test/Subscribable.test.tsx b/packages/effect-fc-next/test/Subscribable.test.tsx new file mode 100644 index 0000000..5641d1b --- /dev/null +++ b/packages/effect-fc-next/test/Subscribable.test.tsx @@ -0,0 +1,129 @@ +import { render, screen, waitFor } from "@testing-library/react" +import { Effect, Fiber, Layer, Stream, SubscriptionRef } from "effect" +import { Lens } from "effect-lens" +import { describe, expect, it } from "vitest" +import * as Component from "../src/Component.js" +import * as ReactRuntime from "../src/ReactRuntime.js" +import * as Subscribable from "../src/Subscribable.js" + + +const makeRuntime = async () => { + const runtime = ReactRuntime.make(Layer.empty) + const effectRuntime = await runtime.runtime.context() + + return { + runtime, + effectRuntime, + dispose: () => runtime.runtime.dispose(), + } +} + +describe("Subscribable", () => { + it("zipLatestAll reads current values from all inputs", async () => { + const leftRef = await Effect.runPromise(SubscriptionRef.make(1)) + const rightRef = await Effect.runPromise(SubscriptionRef.make("a")) + const left = Lens.fromSubscriptionRef(leftRef) + const right = Lens.fromSubscriptionRef(rightRef) + + const zipped = Subscribable.zipLatestAll(left, right) + + expect(await Effect.runPromise(zipped.get)).toEqual([1, "a"]) + }) + + it("zipLatestAll emits updates when any input changes", async () => { + const leftRef = await Effect.runPromise(SubscriptionRef.make(1)) + const rightRef = await Effect.runPromise(SubscriptionRef.make("a")) + const left = Lens.fromSubscriptionRef(leftRef) + const right = Lens.fromSubscriptionRef(rightRef) + + const zipped = Subscribable.zipLatestAll(left, right) + const values: Array = [] + + const collector = Effect.runFork(Effect.scoped(zipped.changes.pipe( + Stream.runForEach(value => Effect.sync(() => { + values.push(value as readonly [number, string]) + })), + ))) + + await Effect.runPromise(Lens.set(left, 2)) + await waitFor(() => expect(values).toContainEqual([2, "a"])) + + await Effect.runPromise(Lens.set(right, "b")) + await waitFor(() => expect(values).toContainEqual([2, "b"])) + + await Effect.runPromise(Fiber.interrupt(collector)) + }) + + it("useAll returns the latest values and rerenders when any input changes", async () => { + const { runtime, effectRuntime, dispose } = await makeRuntime() + const countRef = await Effect.runPromise(SubscriptionRef.make(1)) + const labelRef = await Effect.runPromise(SubscriptionRef.make("a")) + const count = Lens.fromSubscriptionRef(countRef) + const label = Lens.fromSubscriptionRef(labelRef) + + const Probe = Component.makeUntraced("SubscribableUseAllProbe")(function*() { + const [currentCount, currentLabel] = yield* Subscribable.useAll([count, label]) + + return
{`${currentCount}:${currentLabel}`}
+ }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("1:a") + + await Effect.runPromise(Lens.set(count, 2)) + await screen.findByText("2:a") + + await Effect.runPromise(Lens.set(label, "b")) + await screen.findByText("2:b") + + view.unmount() + await dispose() + }) + + it("useAll respects the provided equivalence when processing updates", async () => { + const { runtime, effectRuntime, dispose } = await makeRuntime() + const itemRef = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" })) + const flagRef = await Effect.runPromise(SubscriptionRef.make(true)) + const item = Lens.fromSubscriptionRef(itemRef) + const flag = Lens.fromSubscriptionRef(flagRef) + + const Probe = Component.makeUntraced("SubscribableUseAllEquivalenceProbe")(function*() { + const [currentItem, currentFlag] = yield* Subscribable.useAll([item, flag], { + equivalence: ([selfItem, selfFlag], [thatItem, thatFlag]) => + selfItem.id === thatItem.id && selfFlag === thatFlag, + }) + + return
{`${currentItem.label}:${currentFlag ? "on" : "off"}`}
+ }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("first:on") + + await Effect.runPromise(Lens.set(item, { id: 1, label: "ignored" })) + await waitFor(() => expect(screen.getByText("first:on")).toBeTruthy()) + expect(screen.queryByText("ignored:on")).toBeNull() + + await Effect.runPromise(Lens.set(flag, false)) + await screen.findByText("ignored:off") + + await Effect.runPromise(Lens.set(item, { id: 2, label: "updated" })) + await screen.findByText("updated:off") + + view.unmount() + await dispose() + }) +}) diff --git a/packages/effect-fc-next/tsconfig.json b/packages/effect-fc-next/tsconfig.json new file mode 100644 index 0000000..6ad0810 --- /dev/null +++ b/packages/effect-fc-next/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "NodeNext", + "moduleDetection": "force", + "jsx": "react-jsx", + // "allowJs": true, + + // Bundler mode + "moduleResolution": "NodeNext", + // "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + // "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + // Build + "rootDir": "./src", + "outDir": "./dist", + "declaration": true, + "sourceMap": true, + + "plugins": [ + { "name": "@effect/language-service" } + ] + }, + + "include": ["./src"], + "exclude": ["**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/effect-fc-next/vitest.config.ts b/packages/effect-fc-next/vitest.config.ts new file mode 100644 index 0000000..cec0e39 --- /dev/null +++ b/packages/effect-fc-next/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["test/**/*.test.ts?(x)"], + }, +}) diff --git a/packages/example-next/.gitignore b/packages/example-next/.gitignore new file mode 100644 index 0000000..a014dba --- /dev/null +++ b/packages/example-next/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.tanstack diff --git a/packages/example-next/README.md b/packages/example-next/README.md new file mode 100644 index 0000000..aef5174 --- /dev/null +++ b/packages/example-next/README.md @@ -0,0 +1,10 @@ +# Effect FC Next Example + +Minimal React example for `effect-fc-next`, Effect V4, and `effect-lens@2`. + +```bash +bun run dev +``` + +The counter demonstrates a V4 `SubscriptionRef` exposed as an Effect Lens and +rendered through an Effect-FC component. diff --git a/packages/example-next/biome.json b/packages/example-next/biome.json new file mode 100644 index 0000000..77ba81c --- /dev/null +++ b/packages/example-next/biome.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://biomejs.dev/schemas/latest/schema.json", + "root": false, + "extends": "//", + "files": { + "includes": ["./src/**", "!src/routeTree.gen.ts"] + } +} diff --git a/packages/example-next/index.html b/packages/example-next/index.html new file mode 100644 index 0000000..e7e38b3 --- /dev/null +++ b/packages/example-next/index.html @@ -0,0 +1,13 @@ + + + + + + + Effect FC Next Example + + +
+ + + diff --git a/packages/example-next/package.json b/packages/example-next/package.json new file mode 100644 index 0000000..a37ebbd --- /dev/null +++ b/packages/example-next/package.json @@ -0,0 +1,34 @@ +{ + "name": "@effect-fc/example-next", + "version": "0.0.0", + "type": "module", + "private": true, + "scripts": { + "build": "tsc -b && vite build", + "dev": "vite", + "lint:tsc": "tsc --noEmit", + "lint:biome": "biome lint", + "preview": "vite preview", + "clean:cache": "rm -rf .turbo node_modules/.tmp node_modules/.vite* .tanstack", + "clean:dist": "rm -rf dist", + "clean:modules": "rm -rf node_modules" + }, + "devDependencies": { + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "globals": "^17.6.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "vite": "^8.0.16" + }, + "dependencies": { + "effect": "4.0.0-beta.85", + "effect-fc-next": "workspace:*" + }, + "overrides": { + "@types/react": "^19.2.15", + "effect": "4.0.0-beta.85", + "react": "^19.2.6" + } +} diff --git a/packages/example-next/src/index.css b/packages/example-next/src/index.css new file mode 100644 index 0000000..8f84e7d --- /dev/null +++ b/packages/example-next/src/index.css @@ -0,0 +1,28 @@ +:root { + color: #17202a; + font-family: system-ui, sans-serif; + background: #f4f6f7; +} + +body { + display: grid; + min-width: 320px; + min-height: 100vh; + margin: 0; + place-items: center; +} + +main { + padding: 2rem; + text-align: center; +} + +button { + padding: 0.65rem 1rem; + border: 0; + border-radius: 0.5rem; + color: white; + font: inherit; + background: #7d3c98; + cursor: pointer; +} diff --git a/packages/example-next/src/main.tsx b/packages/example-next/src/main.tsx new file mode 100644 index 0000000..125ca5b --- /dev/null +++ b/packages/example-next/src/main.tsx @@ -0,0 +1,40 @@ +import { Effect, Layer, SubscriptionRef } from "effect" +import { Component, Lens, ReactRuntime, Subscribable } from "effect-fc-next" +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" +import "./index.css" + + +const Counter = Component.make("Counter")(function*() { + const count = yield* Component.useOnMount(() => Effect.map( + SubscriptionRef.make(0), + Lens.fromSubscriptionRef, + )) + const [value] = yield* Subscribable.useAll([count]) + const increment = yield* Component.useCallbackSync( + () => Lens.update(count, n => n + 1), + [count], + ) + + return ( +
+

Effect FC Next

+

Running on Effect V4.

+ +
+ ) +}) + +const runtime = ReactRuntime.make(Layer.empty) +const CounterApp = Counter.pipe(Component.withRuntime(runtime.context)) + +// biome-ignore lint/style/noNonNullAssertion: the Vite template provides this element +createRoot(document.getElementById("root")!).render( + + + + + , +) diff --git a/packages/example-next/src/vite-env.d.ts b/packages/example-next/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/packages/example-next/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/example-next/tsconfig.app.json b/packages/example-next/tsconfig.app.json new file mode 100644 index 0000000..f95336a --- /dev/null +++ b/packages/example-next/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + "plugins": [ + { "name": "@effect/language-service" } + ] + }, + + "include": ["src"] +} diff --git a/packages/example-next/tsconfig.json b/packages/example-next/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/packages/example-next/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/packages/example-next/tsconfig.node.json b/packages/example-next/tsconfig.node.json new file mode 100644 index 0000000..0d067e8 --- /dev/null +++ b/packages/example-next/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + + "include": ["vite.config.ts"] +} diff --git a/packages/example-next/vite.config.ts b/packages/example-next/vite.config.ts new file mode 100644 index 0000000..5c94b15 --- /dev/null +++ b/packages/example-next/vite.config.ts @@ -0,0 +1,10 @@ +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + react(), + ], +})