73 Commits

Author SHA1 Message Date
renovate-bot f23e54b96c Update dependency @effect/language-service to ^0.53.0
Lint / lint (push) Successful in 43s
Test build / test-build (pull_request) Successful in 17s
2025-11-01 12:01:17 +00:00
Julien Valverdé 56f05e93e7 Fix
Lint / lint (push) Successful in 11s
2025-10-31 15:52:12 +01:00
Julien Valverdé 9beddc0877 Example fix
Lint / lint (push) Successful in 12s
2025-10-31 15:50:01 +01:00
Julien Valverdé 8a354b5519 Fix
Lint / lint (push) Successful in 12s
2025-10-31 15:40:47 +01:00
Julien Valverdé 5de4773974 Cleanup
Lint / lint (push) Failing after 11s
2025-10-31 15:38:08 +01:00
Julien Valverdé 1090a685d2 Fix
Lint / lint (push) Failing after 11s
2025-10-31 15:33:59 +01:00
Julien Valverdé f537490f40 Add forkEffect
Lint / lint (push) Successful in 13s
2025-10-31 00:52:59 +01:00
Julien Valverdé 2348ea9bc1 Fix
Lint / lint (push) Successful in 13s
2025-10-30 20:56:31 +01:00
Julien Valverdé 0619af6524 Fix
Lint / lint (push) Successful in 12s
2025-10-30 14:36:47 +01:00
Julien Valverdé 993e97676f Fix
Lint / lint (push) Successful in 12s
2025-10-30 12:23:19 +01:00
Julien Valverdé 95f53b8a00 Add useOnMountResult
Lint / lint (push) Failing after 41s
2025-10-30 11:58:06 +01:00
Julien Valverdé 8b948b2251 useOnChangeResult
Lint / lint (push) Failing after 11s
2025-10-29 16:52:56 +01:00
Julien Valverdé 626a9292d5 Fix forkEffectScoped
Lint / lint (push) Failing after 1m40s
2025-10-29 15:07:49 +01:00
renovate-bot cb40ecff06 Update dependency node to v24 (#19)
Lint / lint (push) Failing after 11s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [node](https://github.com/actions/node-versions) | uses-with | major | `22` -> `24` |

---

### Release Notes

<details>
<summary>actions/node-versions (node)</summary>

### [`v24.11.0`](https://github.com/actions/node-versions/releases/tag/24.11.0-18894910158): 24.11.0

[Compare Source](https://github.com/actions/node-versions/compare/24.10.0-18453495281...24.11.0-18894910158)

Node.js 24.11.0

### [`v24.10.0`](https://github.com/actions/node-versions/releases/tag/24.10.0-18453495281): 24.10.0

[Compare Source](https://github.com/actions/node-versions/compare/24.9.0-18024003193...24.10.0-18453495281)

Node.js 24.10.0

### [`v24.9.0`](https://github.com/actions/node-versions/releases/tag/24.9.0-18024003193): 24.9.0

[Compare Source](https://github.com/actions/node-versions/compare/24.8.0-17630522236...24.9.0-18024003193)

Node.js 24.9.0

### [`v24.8.0`](https://github.com/actions/node-versions/releases/tag/24.8.0-17630522236): 24.8.0

[Compare Source](https://github.com/actions/node-versions/compare/24.7.0-17283839804...24.8.0-17630522236)

Node.js 24.8.0

### [`v24.7.0`](https://github.com/actions/node-versions/releases/tag/24.7.0-17283839804): 24.7.0

[Compare Source](https://github.com/actions/node-versions/compare/24.6.0-16980723897...24.7.0-17283839804)

Node.js 24.7.0

### [`v24.6.0`](https://github.com/actions/node-versions/releases/tag/24.6.0-16980723897): 24.6.0

[Compare Source](https://github.com/actions/node-versions/compare/24.5.0-16666195981...24.6.0-16980723897)

Node.js 24.6.0

### [`v24.5.0`](https://github.com/actions/node-versions/releases/tag/24.5.0-16666195981): 24.5.0

[Compare Source](https://github.com/actions/node-versions/compare/24.4.1-16309768053...24.5.0-16666195981)

Node.js 24.5.0

### [`v24.4.1`](https://github.com/actions/node-versions/releases/tag/24.4.1-16309768053): 24.4.1

[Compare Source](https://github.com/actions/node-versions/compare/24.4.0-16210503505...24.4.1-16309768053)

Node.js 24.4.1

### [`v24.4.0`](https://github.com/actions/node-versions/releases/tag/24.4.0-16210503505): 24.4.0

[Compare Source](https://github.com/actions/node-versions/compare/24.3.0-15866716565...24.4.0-16210503505)

Node.js 24.4.0

### [`v24.3.0`](https://github.com/actions/node-versions/releases/tag/24.3.0-15866716565): 24.3.0

[Compare Source](https://github.com/actions/node-versions/compare/24.2.0-15549907769...24.3.0-15866716565)

Node.js 24.3.0

### [`v24.2.0`](https://github.com/actions/node-versions/releases/tag/24.2.0-15549907769): 24.2.0

[Compare Source](https://github.com/actions/node-versions/compare/24.1.0-15177436545...24.2.0-15549907769)

Node.js 24.2.0

### [`v24.1.0`](https://github.com/actions/node-versions/releases/tag/24.1.0-15177436545): 24.1.0

[Compare Source](https://github.com/actions/node-versions/compare/24.0.2-15035852679...24.1.0-15177436545)

Node.js 24.1.0

### [`v24.0.2`](https://github.com/actions/node-versions/releases/tag/24.0.2-15035852679): 24.0.2

[Compare Source](https://github.com/actions/node-versions/compare/24.0.1-14928016774...24.0.2-15035852679)

Node.js 24.0.2

### [`v24.0.1`](https://github.com/actions/node-versions/releases/tag/24.0.1-14928016774): 24.0.1

[Compare Source](https://github.com/actions/node-versions/compare/24.0.0-14863421234...24.0.1-14928016774)

Node.js 24.0.1

### [`v24.0.0`](https://github.com/actions/node-versions/releases/tag/24.0.0-14863421234): 24.0.0

[Compare Source](https://github.com/actions/node-versions/compare/22.21.1-18894912842...24.0.0-14863421234)

Node.js 24.0.0

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNjMuMSIsInVwZGF0ZWRJblZlciI6IjQxLjE2NS4yIiwidGFyZ2V0QnJhbmNoIjoibmV4dCIsImxhYmVscyI6W119-->

Reviewed-on: #19
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Co-committed-by: Renovate Bot <renovate-bot@valverde.cloud>
2025-10-29 14:34:14 +01:00
renovate-bot b9b9f37859 Update dependency @effect/language-service to ^0.49.0 (#20)
Lint / lint (push) Failing after 11s
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [@effect/language-service](https://github.com/Effect-TS/language-service) | [`^0.48.0` -> `^0.49.0`](https://renovatebot.com/diffs/npm/@effect%2flanguage-service/0.48.0/0.49.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@effect%2flanguage-service/0.49.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@effect%2flanguage-service/0.48.0/0.49.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>Effect-TS/language-service (@&#8203;effect/language-service)</summary>

### [`v0.49.0`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0490)

[Compare Source](https://github.com/Effect-TS/language-service/compare/v0.48.0...v0.49.0)

##### Minor Changes

- [#&#8203;445](https://github.com/Effect-TS/language-service/pull/445) [`fe0e390`](https://github.com/Effect-TS/language-service/commit/fe0e390f02d12f959966d651bfec256c4f313ffb) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Use the Graph module for outline line graph and layer magic

##### Patch Changes

- [#&#8203;449](https://github.com/Effect-TS/language-service/pull/449) [`ff11b7d`](https://github.com/Effect-TS/language-service/commit/ff11b7da9b55a3da91131c4b5932c93c6af71fc8) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Update effect package version to [`97ff1dc`](https://github.com/Effect-TS/language-service/commit/97ff1dc). This version improves handling of special characters in layer graph mermaid diagrams by properly escaping HTML entities (parentheses, braces, quotes) to ensure correct rendering.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNjUuMiIsInVwZGF0ZWRJblZlciI6IjQxLjE2NS4yIiwidGFyZ2V0QnJhbmNoIjoibmV4dCIsImxhYmVscyI6W119-->

Reviewed-on: #20
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Co-committed-by: Renovate Bot <renovate-bot@valverde.cloud>
2025-10-29 14:33:36 +01:00
Julien Valverdé 363c7d24f4 Fix forkEffectScoped
Lint / lint (push) Failing after 11s
2025-10-29 14:32:45 +01:00
Julien Valverdé d57654d872 Fix
Lint / lint (push) Successful in 12s
2025-10-28 21:35:54 +01:00
Julien Valverdé 0b7d9383ec Fix
Lint / lint (push) Successful in 13s
2025-10-28 21:16:11 +01:00
Julien Valverdé c380fe9d08 Result work
Lint / lint (push) Successful in 13s
2025-10-28 21:03:31 +01:00
Julien Valverdé 92722444cf Revert "Fix Result"
Lint / lint (push) Successful in 13s
This reverts commit 882054b53d.
2025-10-28 11:28:36 +01:00
Julien Valverdé 882054b53d Fix Result
Lint / lint (push) Failing after 40s
2025-10-28 01:07:10 +01:00
Julien Valverdé 1c0519cfaf Progress work
Lint / lint (push) Successful in 12s
2025-10-27 21:21:23 +01:00
Julien Valverdé f69125012e Cleanup
Lint / lint (push) Successful in 13s
2025-10-27 18:42:05 +01:00
Julien Valverdé 8c8560b63c Fix
Lint / lint (push) Successful in 12s
2025-10-27 18:36:41 +01:00
Julien Valverdé 86e8a7bd92 Refactor Form
Lint / lint (push) Successful in 12s
2025-10-27 18:11:11 +01:00
Julien Valverdé 12878cd76b Result work
Lint / lint (push) Failing after 14s
2025-10-27 16:47:58 +01:00
Julien Valverdé 308025d662 Add Result type
Lint / lint (push) Successful in 44s
2025-10-27 11:33:15 +01:00
Julien Valverdé 2094f254b3 Fix
Lint / lint (push) Successful in 12s
2025-10-25 09:43:04 +02:00
Julien Valverdé 8ce4a959a6 Merge branch 'master' into next
Lint / lint (push) Successful in 12s
2025-10-24 01:37:32 +02:00
renovate-bot 3708059da4 Update dependency @effect/language-service to ^0.48.0 (#17)
Lint / lint (push) Successful in 11s
Test build / test-build (pull_request) Successful in 18s
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [@effect/language-service](https://github.com/Effect-TS/language-service) | [`^0.46.0` -> `^0.48.0`](https://renovatebot.com/diffs/npm/@effect%2flanguage-service/0.46.0/0.48.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@effect%2flanguage-service/0.48.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@effect%2flanguage-service/0.46.0/0.48.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>Effect-TS/language-service (@&#8203;effect/language-service)</summary>

### [`v0.48.0`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0480)

[Compare Source](https://github.com/Effect-TS/language-service/compare/v0.47.3...v0.48.0)

##### Minor Changes

- [#&#8203;441](https://github.com/Effect-TS/language-service/pull/441) [`ed1db9e`](https://github.com/Effect-TS/language-service/commit/ed1db9ef2432d9d94df80e1835eb42491f0cfbf2) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add `default-hashed` pattern for deterministic keys

  A new `default-hashed` pattern option is now available for service and error key patterns. This pattern works like the `default` pattern but hashes the resulting string, which is useful when you want deterministic keys but are concerned about potentially exposing service names in builds.

  Example configuration:

  ```json
  {
    "keyPatterns": [
      { "target": "service", "pattern": "default-hashed" },
      { "target": "error", "pattern": "default-hashed" }
    ]
  }
  ```

##### Patch Changes

- [#&#8203;442](https://github.com/Effect-TS/language-service/pull/442) [`44f4304`](https://github.com/Effect-TS/language-service/commit/44f43041ced08ef1e6e6242baccbc855e056dfa7) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Tone down try/catch message to ignore try/finally blocks

- [#&#8203;439](https://github.com/Effect-TS/language-service/pull/439) [`b73c231`](https://github.com/Effect-TS/language-service/commit/b73c231dc13fc2db31eaeb3475a129cdeeca21dc) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Fix regression in type unification for union types and prevent infinite recursion in layerMagic refactor

  - Fixed `toggleTypeAnnotation` refactor to properly unify boolean types instead of expanding them to `true | false`
  - Fixed infinite recursion issue in `layerMagic` refactor's `adjustedNode` function when processing variable and property declarations

### [`v0.47.3`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0473)

[Compare Source](https://github.com/Effect-TS/language-service/compare/v0.47.2...v0.47.3)

##### Patch Changes

- [#&#8203;437](https://github.com/Effect-TS/language-service/pull/437) [`e583192`](https://github.com/Effect-TS/language-service/commit/e583192cf73404da7c777f1e7fafd2d6ed968a96) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - In toggle return type refactors, skip type parameters if they are the same as the function default in some cases.

### [`v0.47.2`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0472)

[Compare Source](https://github.com/Effect-TS/language-service/compare/v0.47.1...v0.47.2)

##### Patch Changes

- [#&#8203;433](https://github.com/Effect-TS/language-service/pull/433) [`f359cdb`](https://github.com/Effect-TS/language-service/commit/f359cdb1069b03b978259dac74c1ba209dd26ae6) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Improve memory by properly evicting older cached members

### [`v0.47.1`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0471)

[Compare Source](https://github.com/Effect-TS/language-service/compare/v0.47.0...v0.47.1)

##### Patch Changes

- [#&#8203;431](https://github.com/Effect-TS/language-service/pull/431) [`acbbc55`](https://github.com/Effect-TS/language-service/commit/acbbc55f30a4223a14623d69b2b3097c74644647) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Fix nested project references relative paths in CLI diagnostics command

  The CLI diagnostics command now correctly resolves paths for nested project references by:

  - Using absolute paths when parsing tsconfig files
  - Correctly resolving the base directory for relative paths in project references
  - Processing files in batches to improve memory usage and prevent leaks

### [`v0.47.0`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0470)

[Compare Source](https://github.com/Effect-TS/language-service/compare/v0.46.0...v0.47.0)

##### Minor Changes

- [#&#8203;429](https://github.com/Effect-TS/language-service/pull/429) [`351d7fb`](https://github.com/Effect-TS/language-service/commit/351d7fbec1158294f6cf309eafdb99f5260de8d5) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add new `diagnostics` CLI command to check Effect-specific diagnostics for files or projects

  The new `effect-language-service diagnostics` command provides a way to get Effect-specific diagnostics through the CLI without patching your TypeScript installation. It supports:

  - `--file` option to get diagnostics for a specific file
  - `--project` option with a tsconfig file to check an entire project

  The command outputs diagnostics in the same format as the TypeScript compiler, showing errors, warnings, and messages with their locations and descriptions.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNTcuMCIsInVwZGF0ZWRJblZlciI6IjQxLjE1OC4wIiwidGFyZ2V0QnJhbmNoIjoibmV4dCIsImxhYmVscyI6W119-->

Reviewed-on: #17
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Co-committed-by: Renovate Bot <renovate-bot@valverde.cloud>
2025-10-24 01:28:12 +02:00
Julien Valverdé cd8b5e6364 Version bump
Lint / lint (push) Successful in 12s
2025-10-24 01:27:36 +02:00
Julien Valverdé a48b623822 Fix
Lint / lint (push) Successful in 12s
2025-10-24 01:26:01 +02:00
Julien Valverdé 499e1e174b Fix
Lint / lint (push) Successful in 12s
2025-10-24 00:48:21 +02:00
Julien Valverdé 6b9c177ae7 Fix
Lint / lint (push) Successful in 11s
2025-10-24 00:00:14 +02:00
Julien Valverdé b73b053cc8 Fix
Lint / lint (push) Successful in 12s
2025-10-23 23:50:30 +02:00
Julien Valverdé bbad86bf97 Cleanup
Lint / lint (push) Successful in 12s
2025-10-23 23:36:30 +02:00
Julien Valverdé 6ae311cdfd Refactor
Lint / lint (push) Successful in 12s
2025-10-23 23:01:27 +02:00
Julien Valverdé 03eca8a1af Fix useOnChange
Lint / lint (push) Successful in 12s
2025-10-23 16:36:53 +02:00
Julien Valverdé c03d697361 Fix
Lint / lint (push) Successful in 13s
2025-10-23 16:20:30 +02:00
Julien Valverdé 3847686d54 Add Stream module
Lint / lint (push) Successful in 12s
2025-10-23 16:08:25 +02:00
Julien Valverdé 9801444c0a Fix Component
Lint / lint (push) Successful in 12s
2025-10-23 15:42:19 +02:00
Julien Valverdé 68d8c9fa84 Refactor Component
Lint / lint (push) Successful in 12s
2025-10-23 15:19:47 +02:00
Julien Valverdé cba42bfa52 Fix useScope
Lint / lint (push) Successful in 17s
2025-10-23 14:31:51 +02:00
Julien Valverdé 874da0b963 Refactor component
Lint / lint (push) Successful in 12s
2025-10-23 12:11:35 +02:00
Julien Valverdé bb0579408d Fix
Lint / lint (push) Successful in 12s
2025-10-23 10:49:00 +02:00
Julien Valverdé b39c5946f9 Fix
Lint / lint (push) Successful in 12s
2025-10-23 10:42:27 +02:00
Julien Valverdé aaf494e27a Refactor component creation
Lint / lint (push) Successful in 12s
2025-10-23 10:36:33 +02:00
Julien Valverdé dbc5694b6d Fix
Lint / lint (push) Successful in 13s
2025-10-23 09:48:37 +02:00
Julien Valverdé 86582de0c5 Fix
Lint / lint (push) Successful in 12s
2025-10-23 02:30:32 +02:00
Julien Valverdé 72495bb9b5 Refactor useScope
Lint / lint (push) Successful in 41s
2025-10-23 02:23:48 +02:00
Julien Valverdé 312c103e71 Fix
Lint / lint (push) Successful in 13s
2025-10-22 13:29:35 +02:00
Julien Valverdé a252cfec27 Fix
Lint / lint (push) Successful in 19s
2025-10-22 13:07:59 +02:00
Julien Valverdé 4a5f4c329d Fix
Lint / lint (push) Successful in 13s
2025-10-22 12:59:33 +02:00
Julien Valverdé 6f96608f64 Refactor
Lint / lint (push) Successful in 15s
2025-10-22 11:58:48 +02:00
renovate-bot 0bc29b2cb9 Update dependency @effect/language-service to ^0.46.0 (#16)
Lint / lint (push) Failing after 10s
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [@effect/language-service](https://github.com/Effect-TS/language-service) | [`^0.45.0` -> `^0.46.0`](https://renovatebot.com/diffs/npm/@effect%2flanguage-service/0.45.1/0.46.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@effect%2flanguage-service/0.46.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@effect%2flanguage-service/0.45.1/0.46.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>Effect-TS/language-service (@&#8203;effect/language-service)</summary>

### [`v0.46.0`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0460)

[Compare Source](https://github.com/Effect-TS/language-service/compare/v0.45.1...v0.46.0)

##### Minor Changes

- [#&#8203;424](https://github.com/Effect-TS/language-service/pull/424) [`4bbfdb0`](https://github.com/Effect-TS/language-service/commit/4bbfdb0a4894ee442e93b0a6cfa845447a2a045f) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add support to mark a service as "leakable" via JSDoc tag. Services marked with `@effect-leakable-service` will be excluded from the leaking requirements diagnostic, allowing requirements that are expected to be provided per method invocation (e.g. HttpServerRequest).

  Example:

  ```ts
  /**
   * @&#8203;effect-leakable-service
   */
  export class FileSystem extends Context.Tag("FileSystem")<
    FileSystem,
    {
      writeFile: (content: string) => Effect.Effect<void>;
    }
  >() {}
  ```

- [#&#8203;428](https://github.com/Effect-TS/language-service/pull/428) [`ebaa8e8`](https://github.com/Effect-TS/language-service/commit/ebaa8e85d1c372fb3f584a49b6ea3600c467ac33) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add diagnostic to warn when `@effect-diagnostics-next-line` comments have no effect. This helps identify unused suppression comments that don't actually suppress any diagnostics, improving code cleanliness.

  The new `missingDiagnosticNextLine` option controls the severity of this diagnostic (default: "warning"). Set to "off" to disable.

  Example:

  ```ts
  // This comment will trigger a warning because it doesn't suppress any diagnostic
  // @&#8203;effect-diagnostics-next-line effect/floatingEffect:off
  const x = 1;

  // This comment is correctly suppressing a diagnostic
  // @&#8203;effect-diagnostics-next-line effect/floatingEffect:off
  Effect.succeed(1);
  ```

##### Patch Changes

- [#&#8203;426](https://github.com/Effect-TS/language-service/pull/426) [`22717bd`](https://github.com/Effect-TS/language-service/commit/22717bda12a889f00bc4b78719a487e62da74bef) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Improve Layer Magic refactor with enhanced dependency sorting and cycle detection

  The Layer Magic refactor now includes:

  - Better handling of complex layer composition scenarios
  - Support for detecting missing layer implementations with helpful error messages

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNTYuMSIsInVwZGF0ZWRJblZlciI6IjQxLjE1Ni4xIiwidGFyZ2V0QnJhbmNoIjoibmV4dCIsImxhYmVscyI6W119-->

Reviewed-on: #16
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Co-committed-by: Renovate Bot <renovate-bot@valverde.cloud>
2025-10-22 09:00:28 +02:00
Julien Valverdé 8642619a6a Form work
Lint / lint (push) Failing after 9s
2025-10-21 14:49:53 +02:00
Julien Valverdé e8b8df9449 Form work
Lint / lint (push) Failing after 11s
2025-10-21 14:01:19 +02:00
Julien Valverdé 3695128923 Refactor Hooks
Lint / lint (push) Successful in 41s
2025-10-21 06:16:54 +02:00
Julien Valverdé 1f14e8be6b Add useOnChange
Lint / lint (push) Successful in 12s
2025-10-20 21:24:27 +02:00
Julien Valverdé adc8835304 Add useOnMount
Lint / lint (push) Successful in 12s
2025-10-20 07:09:49 +02:00
Julien Valverdé 8b06c56ec0 Merge branch 'master' into next
Lint / lint (push) Successful in 12s
2025-10-20 06:37:12 +02:00
Julien Valverdé 003d2f19a2 Version bump
Lint / lint (push) Successful in 12s
Test build / test-build (pull_request) Successful in 18s
2025-10-20 06:33:06 +02:00
Julien Valverdé 15f6d695f8 Fix
Lint / lint (push) Successful in 11s
2025-10-20 05:57:00 +02:00
Julien Valverdé 64583601dc Fix
Lint / lint (push) Successful in 12s
2025-10-20 05:55:58 +02:00
Julien Valverdé cf4ba5805f Refactor Form
Lint / lint (push) Successful in 12s
2025-10-20 05:36:45 +02:00
Julien Valverdé 90db94e905 Refactor Subscribable
Lint / lint (push) Successful in 40s
2025-10-20 04:35:11 +02:00
Julien Valverdé 336ea67ea2 Add Subscribable.flatMapSubscriptionRef
Lint / lint (push) Successful in 12s
2025-10-19 07:46:36 +02:00
Julien Valverdé 1af839f036 Fix example
Lint / lint (push) Successful in 12s
2025-10-19 07:16:39 +02:00
Julien Valverdé 6bdf2a4d87 submit -> submitFn
Lint / lint (push) Successful in 41s
2025-10-19 02:51:43 +02:00
renovate-bot 8d55a67e75 Update dependency @effect/language-service to ^0.45.0 (#14)
Lint / lint (push) Successful in 12s
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [@effect/language-service](https://github.com/Effect-TS/language-service) | [`^0.44.0` -> `^0.45.0`](https://renovatebot.com/diffs/npm/@effect%2flanguage-service/0.44.1/0.45.1) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@effect%2flanguage-service/0.45.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@effect%2flanguage-service/0.44.1/0.45.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>Effect-TS/language-service (@&#8203;effect/language-service)</summary>

### [`v0.45.1`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0451)

[Compare Source](https://github.com/Effect-TS/language-service/compare/v0.45.0...v0.45.1)

##### Patch Changes

- [#&#8203;423](https://github.com/Effect-TS/language-service/pull/423) [`70d8734`](https://github.com/Effect-TS/language-service/commit/70d8734558c4ba3abfd69fafce785b7f58a70a52) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add code fix to rewrite Schema class constructor overrides as static 'new' methods

  When detecting constructor overrides in Schema classes, the diagnostic now provides a new code fix option that automatically rewrites the constructor as a static 'new' method. This preserves the custom initialization logic while maintaining Schema's decoding behavior.

  Example:

  ```typescript
  // Before (with constructor override)
  class MyClass extends Schema.Class<MyClass>("MyClass")({ a: Schema.Number }) {
    b: number;
    constructor() {
      super({ a: 42 });
      this.b = 56;
    }
  }

  // After (using static 'new' method)
  class MyClass extends Schema.Class<MyClass>("MyClass")({ a: Schema.Number }) {
    b: number;
    public static new() {
      const _this = new this({ a: 42 });
      _this.b = 56;
      return _this;
    }
  }
  ```

- [#&#8203;421](https://github.com/Effect-TS/language-service/pull/421) [`8c455ed`](https://github.com/Effect-TS/language-service/commit/8c455ed7a459665d26c30f1e5d90338e48794815) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Update dependencies to their latest versions including Effect 3.18.4, TypeScript 5.9.3, and various ESLint and build tooling packages

### [`v0.45.0`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0450)

[Compare Source](https://github.com/Effect-TS/language-service/compare/v0.44.1...v0.45.0)

##### Minor Changes

- [#&#8203;419](https://github.com/Effect-TS/language-service/pull/419) [`7cd7216`](https://github.com/Effect-TS/language-service/commit/7cd7216abc8e3057098acf1889c7494d17a869d6) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add support for custom APIs in deterministicKeys diagnostic using the `@effect-identifier` JSDoc tag.

  You can now enforce deterministic keys in custom APIs that follow an `extends MyApi("identifier")` pattern by:

  - Adding `extendedKeyDetection: true` to plugin options to enable detection
  - Marking the identifier parameter with `/** @&#8203;effect-identifier */` JSDoc tag

  Example:

  ```ts
  export function Repository(/** @&#8203;effect-identifier */ identifier: string) {
    return Context.Tag("Repository/" + identifier);
  }

  export class UserRepo extends Repository("user-repo")<
    UserRepo,
    {
      /** ... */
    }
  >() {}
  ```

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNTAuMCIsInVwZGF0ZWRJblZlciI6IjQxLjE1MC4wIiwidGFyZ2V0QnJhbmNoIjoibmV4dCIsImxhYmVscyI6W119-->

Reviewed-on: #14
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Co-committed-by: Renovate Bot <renovate-bot@valverde.cloud>
2025-10-16 21:34:20 +02:00
Julien Valverdé a1ec5c4781 Handle ParseError on form submit
Lint / lint (push) Successful in 41s
2025-10-16 02:00:10 +02:00
renovate-bot 756b652861 Update actions/setup-node action to v6 (#13)
Lint / lint (push) Successful in 13s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/setup-node](https://github.com/actions/setup-node) | action | major | `v5` -> `v6` |

---

### Release Notes

<details>
<summary>actions/setup-node (actions/setup-node)</summary>

### [`v6`](https://github.com/actions/setup-node/compare/v5...v6)

[Compare Source](https://github.com/actions/setup-node/compare/v5...v6)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNDguMyIsInVwZGF0ZWRJblZlciI6IjQxLjE0OC4zIiwidGFyZ2V0QnJhbmNoIjoibmV4dCIsImxhYmVscyI6W119-->

Reviewed-on: #13
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Co-committed-by: Renovate Bot <renovate-bot@valverde.cloud>
2025-10-14 16:50:05 +02:00
renovate-bot 59f9358b9a Update dependency @effect/language-service to ^0.44.0 (#12)
Lint / lint (push) Successful in 12s
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [@effect/language-service](https://github.com/Effect-TS/language-service) | [`^0.42.0` -> `^0.44.0`](https://renovatebot.com/diffs/npm/@effect%2flanguage-service/0.42.0/0.44.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@effect%2flanguage-service/0.44.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@effect%2flanguage-service/0.42.0/0.44.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>Effect-TS/language-service (@&#8203;effect/language-service)</summary>

### [`v0.44.0`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0440)

[Compare Source](https://github.com/Effect-TS/language-service/compare/v0.43.2...v0.44.0)

##### Minor Changes

- [#&#8203;415](https://github.com/Effect-TS/language-service/pull/415) [`42c66a1`](https://github.com/Effect-TS/language-service/commit/42c66a12658d712671b482fdcce0c5b608171d4f) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add `diagnosticsName` option to include rule names in diagnostic messages. When enabled (default: true), diagnostic messages will display the rule name at the end, e.g., "Effect must be yielded or assigned to a variable. effect(floatingEffect)"

### [`v0.43.2`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0432)

[Compare Source](https://github.com/Effect-TS/language-service/compare/v0.43.1...v0.43.2)

##### Patch Changes

- [#&#8203;410](https://github.com/Effect-TS/language-service/pull/410) [`0b40c04`](https://github.com/Effect-TS/language-service/commit/0b40c04625cadc0a8dfc3b194daafea1f751a3b9) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Defer typescript loading in CLI

### [`v0.43.1`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0431)

[Compare Source](https://github.com/Effect-TS/language-service/compare/v0.43.0...v0.43.1)

##### Patch Changes

- [#&#8203;408](https://github.com/Effect-TS/language-service/pull/408) [`9ccd800`](https://github.com/Effect-TS/language-service/commit/9ccd8007b338e0524e17d3061acb722ad5c0e87b) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Fix handling of leading/trailing slashes

### [`v0.43.0`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0430)

[Compare Source](https://github.com/Effect-TS/language-service/compare/v0.42.0...v0.43.0)

##### Minor Changes

- [#&#8203;407](https://github.com/Effect-TS/language-service/pull/407) [`6590590`](https://github.com/Effect-TS/language-service/commit/6590590c0decd83f0baa4fd47655f0f67b6c5db9) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add deterministicKeys diagnostic to enforce consistent key patterns for Services and Errors

  This new diagnostic helps maintain consistent and unique keys for Effect Services and Tagged Errors by validating them against configurable patterns. The diagnostic is disabled by default and can be enabled via the `deterministicKeys` diagnosticSeverity setting.

  Two patterns are supported:

  - `default`: Constructs keys from package name + file path + class identifier (e.g., `@effect/package/FileName/ClassIdentifier`)
  - `package-identifier`: Uses package name + identifier for flat project structures

  Example configuration:

  ```jsonc
  {
    "diagnosticSeverity": {
      "deterministicKeys": "error"
    },
    "keyPatterns": [
      {
        "target": "service",
        "pattern": "default",
        "skipLeadingPath": ["src/"]
      }
    ]
  }
  ```

  The diagnostic also provides auto-fix code actions to update keys to match the configured patterns.

##### Patch Changes

- [#&#8203;405](https://github.com/Effect-TS/language-service/pull/405) [`f43b3ab`](https://github.com/Effect-TS/language-service/commit/f43b3ab32cad347fb2eb0af740771e35a6c7ff66) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Fix wrapWithEffectGen refactor not working on class heritage clauses

  The wrapWithEffectGen refactor now correctly skips expressions in heritage clauses (e.g., `extends` clauses in class declarations) to avoid wrapping them inappropriately.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4xMzguNCIsInVwZGF0ZWRJblZlciI6IjQxLjE0Ni4wIiwidGFyZ2V0QnJhbmNoIjoibmV4dCIsImxhYmVscyI6W119-->

Reviewed-on: #12
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Co-committed-by: Renovate Bot <renovate-bot@valverde.cloud>
2025-10-13 01:00:50 +02:00
69 changed files with 1722 additions and 7592 deletions
View File
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
- name: Clone repo - name: Clone repo
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Lint TypeScript - name: Lint TypeScript
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
- name: Clone repo - name: Clone repo
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Lint TypeScript - name: Lint TypeScript
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
with: with:
node-version: "24" node-version: "24"
- name: Clone repo - name: Clone repo
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Lint TypeScript - name: Lint TypeScript
+1 -1
View File
@@ -1,6 +1,6 @@
# Effect FC Monorepo # Effect FC Monorepo
[Effect-TS](https://effect.website/) integration for React 19.2+ that allows you to write function components using Effect generators. [Effect-TS](https://effect.website/) integration for React 19+ that allows you to write function components using Effect generators.
This monorepo contains: This monorepo contains:
- [The `effect-fc` library](packages/effect-fc) - [The `effect-fc` library](packages/effect-fc)
+197 -2921
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "@effect-fc/monorepo", "name": "@effect-fc/monorepo",
"packageManager": "bun@1.3.14", "packageManager": "bun@1.2.23",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"./packages/*" "./packages/*"
@@ -15,12 +15,12 @@
"clean:modules": "turbo clean:modules && rm -rf node_modules" "clean:modules": "turbo clean:modules && rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.16", "@biomejs/biome": "^2.2.5",
"@effect/language-service": "^0.86.2", "@effect/language-service": "^0.53.0",
"@types/bun": "^1.3.14", "@types/bun": "^1.2.23",
"npm-check-updates": "^22.2.1", "npm-check-updates": "^19.0.0",
"npm-sort": "^0.0.4", "npm-sort": "^0.0.4",
"turbo": "^2.9.16", "turbo": "^2.5.8",
"typescript": "^6.0.3" "typescript": "^5.9.3"
} }
} }
-20
View File
@@ -1,20 +0,0 @@
# Dependencies
/node_modules
# Production
/build
# Generated files
.docusaurus
.cache-loader
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
-17
View File
@@ -1,17 +0,0 @@
# effect-fc Docs
The documentation site is built with Docusaurus.
## Local Development
```bash
bun run --cwd packages/docs start
```
## Build
```bash
bun run --cwd packages/docs build
```
The static site is written to `packages/docs/build`.
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"root": false,
"extends": "//",
"files": {
"includes": ["./src/**"]
}
}
-208
View File
@@ -1,208 +0,0 @@
---
sidebar_position: 1
title: Getting Started
---
# Getting Started
`effect-fc` lets React components be written as Effect programs. Inside a
component body you can yield services, run Effects, subscribe to Effect-powered
state, and still export a normal React function component at the edge of your
app.
This guide starts with the smallest useful setup:
1. Install `effect-fc` with its peer dependencies.
2. Create a React runtime from an Effect `Layer`.
3. Wrap your React app with `ReactRuntime.Provider`.
4. Write a component with `Component.make`.
5. Convert it to a React component with `Component.withRuntime`.
## Install
Install `effect-fc` alongside `effect` and React 19.2 or newer:
```bash npm2yarn
npm install effect-fc effect react react-dom
```
If your project uses TypeScript, also install React's type packages:
```bash npm2yarn
npm install --save-dev @types/react @types/react-dom
```
## Create A Runtime
An Effect-FC app needs an Effect runtime. Build one from the services your UI
needs, then share it with React through `ReactRuntime.Provider`.
For an empty app, `Layer.empty` is enough:
```tsx title="src/runtime.ts"
import { Layer } from "effect"
import { ReactRuntime } from "effect-fc"
export const runtime = ReactRuntime.make(Layer.empty)
```
As your app grows, add services to the layer:
```tsx title="src/runtime.ts"
import { FetchHttpClient } from "@effect/platform"
import { Layer } from "effect"
import { ReactRuntime } from "effect-fc"
const AppLive = Layer.empty.pipe(
Layer.provideMerge(FetchHttpClient.layer),
)
export const runtime = ReactRuntime.make(AppLive)
```
## Provide The Runtime
At the React root, wrap your app with `ReactRuntime.Provider`:
```tsx title="src/main.tsx"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { ReactRuntime } from "effect-fc"
import { App } from "./App"
import { runtime } from "./runtime"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ReactRuntime.Provider runtime={runtime}>
<App />
</ReactRuntime.Provider>
</StrictMode>,
)
```
`ReactRuntime.Provider` also works with routers. Keep it above your router
provider so route components can be converted with the same runtime context.
## Write Your First Component
Use `Component.make` when you want automatic tracing spans, or
`Component.makeUntraced` when you only want the component behavior.
```tsx title="src/Hello.tsx"
import { Effect } from "effect"
import { Component } from "effect-fc"
import { runtime } from "./runtime"
const HelloEffect = Component.make("HelloEffect")(function* (props: {
readonly name: string
}) {
const message = yield* Effect.succeed(`Hello, ${props.name}`)
return <h1>{message}</h1>
})
export const Hello = HelloEffect.pipe(
Component.withRuntime(runtime.context),
)
```
`Hello` is now a regular React component:
```tsx title="src/App.tsx"
import { Hello } from "./Hello"
export function App() {
return <Hello name="Effect" />
}
```
## Use Services
Components can yield Effect services directly. Define services with Effect,
provide them in your runtime layer, then consume them from the component body.
```ts title="src/services.ts"
import { Effect } from "effect"
export class GreetingService extends Effect.Service<GreetingService>()(
"GreetingService",
{
succeed: {
greet: (name: string) => `Welcome, ${name}`,
},
},
) {}
```
Provide the service in your runtime:
```tsx title="src/runtime.ts"
import { Layer } from "effect"
import { ReactRuntime } from "effect-fc"
import { GreetingService } from "./services"
const AppLive = Layer.empty.pipe(
Layer.provideMerge(GreetingService.Default),
)
export const runtime = ReactRuntime.make(AppLive)
```
Then read it inside a component:
```tsx title="src/Greeting.tsx"
import { Component } from "effect-fc"
import { runtime } from "./runtime"
import { GreetingService } from "./services"
const GreetingEffect = Component.make("Greeting")(function* (props: {
readonly name: string
}) {
const greeting = yield* GreetingService
return <p>{greeting.greet(props.name)}</p>
})
export const Greeting = GreetingEffect.pipe(
Component.withRuntime(runtime.context),
)
```
## Mount And Cleanup Effects
Use `Component.useOnMount` for scoped work that should start when the component
mounts and finalize when it unmounts.
```tsx
import { Console, Effect } from "effect"
import { Component } from "effect-fc"
const Mounted = Component.make("Mounted")(function* () {
yield* Component.useOnMount(() =>
Effect.gen(function* () {
yield* Console.log("Mounted")
yield* Effect.addFinalizer(() => Console.log("Unmounted"))
}),
)
return <p>Open the console, then unmount me.</p>
})
```
Finalizers are tied to the component scope, so this is the right place for
subscriptions, resources, and other lifecycle-bound Effects.
## Where To Go Next
Once the runtime and component boundary are in place, the rest of the library
builds on the same idea:
- `Subscribable.useAll` reads Effect subscribables and rerenders when they
change.
- `Lens` connects React state and Effect `SubscriptionRef` values.
- `Query` and `Mutation` model async data and user-triggered operations.
- `Form`, `SubmittableForm`, and `SynchronizedForm` help build Effect-backed
forms.
The important pattern is small and repeatable: write Effect-FC components inside
the runtime, then use `Component.withRuntime` at React boundaries.
-128
View File
@@ -1,128 +0,0 @@
import type * as Preset from "@docusaurus/preset-classic"
import type { Config } from "@docusaurus/types"
import { themes as prismThemes } from "prism-react-renderer"
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
const config: Config = {
title: "effect-fc",
tagline: "Write React function components with Effect",
favicon: "img/favicon.ico",
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
future: {
v4: true, // Improve compatibility with the upcoming Docusaurus v4
},
// Set the production url of your site here
url: "https://thiladev.github.io",
// Set the /<baseUrl>/ pathname under which your site is served
// For GitHub pages deployment, it is often '/<projectName>/'
baseUrl: "/effect-fc/",
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
organizationName: "Thiladev", // Usually your GitHub org/user name.
projectName: "effect-fc", // Usually your repo name.
onBrokenLinks: "throw",
// Even if you don't use internationalization, you can use this field to set
// useful metadata like html lang. For example, if your site is Chinese, you
// may want to replace "en" with "zh-Hans".
i18n: {
defaultLocale: "en",
locales: ["en"],
},
presets: [
[
"classic",
{
docs: {
sidebarPath: "./sidebars.ts",
editUrl:
"https://github.com/Thiladev/effect-fc/tree/main/packages/docs/",
},
blog: false,
theme: {
customCss: "./src/css/custom.css",
},
} satisfies Preset.Options,
],
],
themeConfig: {
// Replace with your project's social card
colorMode: {
respectPrefersColorScheme: true,
},
navbar: {
title: "effect-fc",
logo: {
alt: "effect-fc logo",
src: "img/logo.svg",
},
items: [
{
type: "docSidebar",
sidebarId: "docsSidebar",
position: "left",
label: "Docs",
},
{
href: "https://github.com/Thiladev/effect-fc",
label: "GitHub",
position: "right",
},
],
},
footer: {
style: "dark",
links: [
{
title: "Docs",
items: [
{
label: "Getting Started",
to: "/docs/getting-started",
},
],
},
{
title: "Project",
items: [
{
label: "GitHub",
href: "https://github.com/Thiladev/effect-fc",
},
{
label: "Example App",
href: "https://github.com/Thiladev/effect-fc/tree/main/packages/example",
},
],
},
{
title: "Ecosystem",
items: [
{
label: "Effect",
href: "https://effect.website/",
},
{
label: "React",
href: "https://react.dev/",
},
],
},
],
copyright: `Copyright © ${new Date().getFullYear()} effect-fc contributors. Built with Docusaurus.`,
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
},
} satisfies Preset.ThemeConfig,
}
export default config
-50
View File
@@ -1,50 +0,0 @@
{
"name": "docs",
"version": "0.0.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "3.10.1",
"@docusaurus/faster": "^3.10.1",
"@docusaurus/preset-classic": "3.10.1",
"@mdx-js/react": "^3.1.1",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.4.1",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.10.1",
"@docusaurus/tsconfig": "3.10.1",
"@docusaurus/types": "3.10.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"typescript": "~6.0.3"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
},
"engines": {
"node": ">=20.0"
}
}
-9
View File
@@ -1,9 +0,0 @@
import type { SidebarsConfig } from "@docusaurus/plugin-content-docs"
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
const sidebars: SidebarsConfig = {
docsSidebar: ["getting-started"],
}
export default sidebars
-37
View File
@@ -1,37 +0,0 @@
:root {
--ifm-color-primary: #0b6f74;
--ifm-color-primary-dark: #096469;
--ifm-color-primary-darker: #085e63;
--ifm-color-primary-darkest: #074d51;
--ifm-color-primary-light: #0d7a7f;
--ifm-color-primary-lighter: #0e8085;
--ifm-color-primary-lightest: #109096;
--ifm-background-color: #fffaf1;
--ifm-code-font-size: 95%;
--ifm-font-family-base:
"Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
--docusaurus-highlighted-code-line-bg: rgba(11, 111, 116, 0.1);
}
.button--primary {
--ifm-button-background-color: #0b6f74;
--ifm-button-border-color: #0b6f74;
}
.button--secondary {
--ifm-button-background-color: #f4a636;
--ifm-button-border-color: #f4a636;
--ifm-button-color: #1f2526;
}
[data-theme="dark"] {
--ifm-color-primary: #7ad6d4;
--ifm-color-primary-dark: #5cccca;
--ifm-color-primary-darker: #4cc6c4;
--ifm-color-primary-darkest: #34aaa8;
--ifm-color-primary-light: #98e0de;
--ifm-color-primary-lighter: #a8e6e4;
--ifm-color-primary-lightest: #d5f4f3;
--ifm-background-color: #111c1f;
--docusaurus-highlighted-code-line-bg: rgba(122, 214, 212, 0.18);
}
-80
View File
@@ -1,80 +0,0 @@
.page {
min-height: calc(100vh - var(--ifm-navbar-height));
background:
radial-gradient(circle at top left, rgba(11, 111, 116, 0.22), transparent 32rem),
radial-gradient(circle at 85% 20%, rgba(244, 166, 54, 0.18), transparent 28rem),
linear-gradient(135deg, #f8f4ea 0%, #eef8f7 48%, #fffaf1 100%);
}
.hero {
padding: 8rem 1rem 5rem;
}
.eyebrow {
color: #0b6f74;
font-size: 0.85rem;
font-weight: 800;
letter-spacing: 0.16em;
margin-bottom: 1.25rem;
text-transform: uppercase;
}
.hero h1 {
color: #132c2f;
font-size: clamp(3rem, 9vw, 6.75rem);
letter-spacing: -0.08em;
line-height: 0.9;
margin: 0;
max-width: 880px;
}
.lede {
color: #385256;
font-size: clamp(1.15rem, 2vw, 1.45rem);
line-height: 1.65;
margin: 2rem 0 0;
max-width: 720px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 2.5rem;
}
.cards {
display: grid;
gap: 1.25rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
padding: 0 1rem 6rem;
}
.cards article {
background: rgba(255, 255, 255, 0.74);
border: 1px solid rgba(19, 44, 47, 0.12);
border-radius: 1.5rem;
box-shadow: 0 24px 80px rgba(19, 44, 47, 0.08);
padding: 1.5rem;
}
.cards h2 {
color: #132c2f;
font-size: 1.2rem;
margin-bottom: 0.75rem;
}
.cards p {
color: #486267;
margin: 0;
}
@media screen and (max-width: 996px) {
.hero {
padding-top: 5rem;
}
.cards {
grid-template-columns: 1fr;
}
}
-62
View File
@@ -1,62 +0,0 @@
import Link from "@docusaurus/Link"
import Layout from "@theme/Layout"
import clsx from "clsx"
import type { ReactNode } from "react"
import styles from "./index.module.css"
export default function Home(): ReactNode {
return (
<Layout
title="effect-fc"
description="Write React function components with Effect"
>
<main className={styles.page}>
<section className={clsx("container", styles.hero)}>
<p className={styles.eyebrow}>Effect for React function components</p>
<h1>Write components as Effect programs.</h1>
<p className={styles.lede}>
effect-fc gives React 19 components access to Effect services,
scopes, subscriptions, and async workflows without giving up normal
React boundaries.
</p>
<div className={styles.actions}>
<Link className="button button--primary button--lg" to="/docs/getting-started">
Get Started
</Link>
<Link
className="button button--secondary button--lg"
to="https://github.com/Thiladev/effect-fc"
>
GitHub
</Link>
</div>
</section>
<section className={clsx("container", styles.cards)}>
<article>
<h2>Generator components</h2>
<p>
Use <code>Component.make</code> to yield Effects and return JSX
from the same component body.
</p>
</article>
<article>
<h2>Runtime at the edge</h2>
<p>
Provide your app layer once with <code>ReactRuntime.Provider</code>
and convert Effect-FC components at React boundaries.
</p>
</article>
<article>
<h2>Scoped lifecycles</h2>
<p>
Tie subscriptions and resources to component scopes so finalizers
run when React unmounts.
</p>
</article>
</section>
</main>
</Layout>
)
}
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

-6
View File
@@ -1,6 +0,0 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="128" height="128" rx="32" fill="#0B6F74"/>
<path d="M34 39H78C88.4934 39 97 47.5066 97 58C97 68.4934 88.4934 77 78 77H51V95H34V39Z" fill="#FFF9ED"/>
<path d="M51 55V62H78C80.2091 62 82 60.2091 82 58C82 55.7909 80.2091 55 78 55H51Z" fill="#0B6F74"/>
<path d="M51 77H86V95H51V77Z" fill="#F4A636"/>
</svg>

Before

Width:  |  Height:  |  Size: 424 B

-9
View File
@@ -1,9 +0,0 @@
{
// This file is not used in compilation. It is here just for a nice editor experience.
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": ".",
"ignoreDeprecations": "6.0"
},
"exclude": [".docusaurus", "build"]
}
+22 -14
View File
@@ -1,51 +1,59 @@
# Effect FC # Effect FC
[Effect-TS](https://effect.website/) integration for React 19.2+ that allows you to write function components using Effect generators. [Effect-TS](https://effect.website/) integration for React 19+ 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. 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. Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory.
## Peer dependencies ## Peer dependencies
- `effect` 3.19+ - `effect` 3.15+
- `react` & `@types/react` 19.2+ - `react` & `@types/react` 19+
## Known issues ## Known issues
- React Refresh doesn't work for Effect FC's yet. Page reload is required to view changes. Regular React components are unaffected. - 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 ## What writing components looks like
```typescript ```typescript
export class TodosView extends Component.make("TodosView")(function*() { import { Component } from "effect-fc"
const state = yield* TodosState import { useOnce, useSubscribables } from "effect-fc/Hooks"
const [todos] = yield* Component.useSubscribables([state.subscriptionRef]) import { Todo } from "./Todo"
import { TodosState } from "./TodosState.service"
yield* Component.useOnMount(() => Effect.andThen(
export class Todos extends Component.makeUntraced("Todos")(function*() {
const state = yield* TodosState
const [todos] = yield* useSubscribables(state.ref)
yield* useOnce(() => Effect.andThen(
Console.log("Todos mounted"), Console.log("Todos mounted"),
Effect.addFinalizer(() => Console.log("Todos unmounted")), Effect.addFinalizer(() => Console.log("Todos unmounted")),
)) ))
const Todo = yield* TodoView.use const TodoFC = yield* Todo
return ( return (
<Container> <Container>
<Heading align="center">Todos</Heading> <Heading align="center">Todos</Heading>
<Flex direction="column" align="stretch" gap="2" mt="2"> <Flex direction="column" align="stretch" gap="2" mt="2">
<Todo _tag="new" /> <TodoFC _tag="new" />
{Chunk.map(todos, todo => {Chunk.map(todos, todo =>
<Todo key={todo.id} _tag="edit" id={todo.id} /> <TodoFC key={todo.id} _tag="edit" id={todo.id} />
)} )}
</Flex> </Flex>
</Container> </Container>
) )
}) {} }) {}
const Index = Component.make("IndexView")(function*() { const TodosStateLive = TodosState.Default("todos")
const context = yield* Component.useContextFromLayer(TodosState.Default)
const Todos = yield* Effect.provide(TodosView.use, context)
return <Todos /> const Index = Component.makeUntraced("Index")(function*() {
const context = yield* useContext(TodosStateLive, { finalizerExecutionMode: "fork" })
const TodosFC = yield* Effect.provide(Todos, context)
return <TodosFC />
}).pipe( }).pipe(
Component.withRuntime(runtime.context) Component.withRuntime(runtime.context)
) )
+4 -14
View File
@@ -1,7 +1,7 @@
{ {
"name": "effect-fc", "name": "effect-fc",
"description": "Write React function components with Effect", "description": "Write React function components with Effect",
"version": "0.3.0", "version": "0.2.0",
"type": "module", "type": "module",
"files": [ "files": [
"./README.md", "./README.md",
@@ -32,24 +32,14 @@
"build": "tsc", "build": "tsc",
"lint:tsc": "tsc --noEmit", "lint:tsc": "tsc --noEmit",
"lint:biome": "biome lint", "lint:biome": "biome lint",
"test": "vitest run",
"pack": "npm pack", "pack": "npm pack",
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo", "clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
"clean:dist": "rm -rf dist", "clean:dist": "rm -rf dist",
"clean:modules": "rm -rf node_modules" "clean:modules": "rm -rf node_modules"
}, },
"devDependencies": {
"@effect/platform-browser": "^0.76.0",
"@testing-library/react": "^16.3.0",
"jsdom": "^26.1.0",
"vitest": "^4.0.0"
},
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0", "@types/react": "^19.0.0",
"effect": "^3.21.0", "effect": "^3.15.0",
"react": "^19.2.0" "react": "^19.0.0"
},
"dependencies": {
"effect-lens": "^0.2.0"
} }
} }
+31 -117
View File
@@ -1,49 +1,35 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ /** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { Effect, type Equivalence, Function, Predicate, Runtime, Scope } from "effect" import { Effect, Function, Predicate, Runtime, Scope } from "effect"
import * as React from "react" import * as React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async") export const TypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
export type AsyncTypeId = typeof AsyncTypeId export type TypeId = typeof TypeId
export interface Async extends Async.Options {
/** readonly [TypeId]: TypeId
* A trait for `Component`'s that allows them running asynchronous effects.
*/
export interface Async extends AsyncPrototype, AsyncOptions {}
export interface AsyncPrototype {
readonly [AsyncTypeId]: AsyncTypeId
} }
/** export namespace Async {
* Configuration options for `Async` components. export interface Options {
*/ readonly defaultFallback?: React.ReactNode
export interface AsyncOptions { }
/**
* The default fallback React node to display while the async operation is pending. export type Props = Omit<React.SuspenseProps, "children">
* Used if no fallback is provided to the component when rendering.
*/
readonly defaultFallback?: React.ReactNode
} }
/**
* Props for `Async` components.
*/
export type AsyncProps = Omit<React.SuspenseProps, "children">
const SuspenseProto = Object.freeze({
[TypeId]: TypeId,
export const AsyncPrototype: AsyncPrototype = Object.freeze({ makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
[AsyncTypeId]: AsyncTypeId, this: Component.Component<P, A, E, R> & Async,
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Component.Signature>(
this: Component.Component<P, A, E, R, F> & Async,
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>, runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
) { ) {
const Inner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise) const SuspenseInner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
return ({ fallback, name, ...props }: AsyncProps) => { return ({ fallback, name, ...props }: Async.Props) => {
const promise = Runtime.runPromise(runtimeRef.current)( const promise = Runtime.runPromise(runtimeRef.current)(
Effect.andThen( Effect.andThen(
Component.useScope([], this), Component.useScope([], this),
@@ -54,117 +40,45 @@ export const AsyncPrototype: AsyncPrototype = Object.freeze({
return React.createElement( return React.createElement(
React.Suspense, React.Suspense,
{ fallback: fallback ?? this.defaultFallback, name }, { fallback: fallback ?? this.defaultFallback, name },
React.createElement(Inner, { promise }), React.createElement(SuspenseInner, { promise }),
) )
} }
}, },
} as const) } 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<AsyncProps> = (
self: Record<string, unknown>,
that: Record<string, unknown>,
) => {
if (self === that)
return true
for (const key in self) { export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, TypeId)
if (key === "fallback")
continue
if (!(key in that) || !Object.is(self[key], that[key]))
return false
}
for (const key in that) { export const async = <T extends Component.Component<any, any, any, any>>(
if (key === "fallback") self: T
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 = <T extends Component.Component.Any>(
self: T & (
"promise" extends keyof Component.Component.Props<T>
? "The 'promise' prop name is restricted for Async components. Please rename the 'promise' prop to something else."
: T
)
): ( ): (
& Omit<T, keyof Component.Component.AsComponent<T>> & Omit<T, keyof Component.Component.AsComponent<T>>
& Component.Component< & Component.Component<
Component.Component.Props<T> & AsyncProps, Component.Component.Props<T> & Async.Props,
Component.Component.Success<T>, Component.Component.Success<T>,
Component.Component.Error<T>, Component.Component.Error<T>,
Component.Component.Context<T>, Component.Component.Context<T>
Component.Component.DefaultSignature<Component.Component.Props<T> & AsyncProps, Component.Component.Success<T>>
> >
& Async & Async
) => Object.setPrototypeOf( ) => Object.setPrototypeOf(
Object.assign(function() {}, self, { propsEquivalence: defaultPropsEquivalence }), Object.assign(function() {}, self),
Object.freeze(Object.setPrototypeOf( Object.freeze(Object.setPrototypeOf(
Object.assign({}, AsyncPrototype), Object.assign({}, SuspenseProto),
Object.getPrototypeOf(self), 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: <p>Loading...</p> }),
* )
*
* // Uncurried
* const MyAsyncComponent = Async.withOptions(
* Async.async(MyComponent),
* { defaultFallback: <p>Loading...</p> },
* )
* ```
*/
export const withOptions: { export const withOptions: {
<T extends Component.Component.Any & Async>( <T extends Component.Component<any, any, any, any> & Async>(
options: Partial<AsyncOptions> options: Partial<Async.Options>
): (self: T) => T ): (self: T) => T
<T extends Component.Component.Any & Async>( <T extends Component.Component<any, any, any, any> & Async>(
self: T, self: T,
options: Partial<AsyncOptions>, options: Partial<Async.Options>,
): T ): T
} = Function.dual(2, <T extends Component.Component.Any & Async>( } = Function.dual(2, <T extends Component.Component<any, any, any, any> & Async>(
self: T, self: T,
options: Partial<AsyncOptions>, options: Partial<Async.Options>,
): T => Object.setPrototypeOf( ): T => Object.setPrototypeOf(
Object.assign(function() {}, self, options), Object.assign(function() {}, self, options),
Object.getPrototypeOf(self), Object.getPrototypeOf(self),
File diff suppressed because it is too large Load Diff
-62
View File
@@ -1,62 +0,0 @@
import { type Cause, Context, Effect, Exit, Layer, Option, Pipeable, Predicate, PubSub, type Queue, type Scope, Supervisor } from "effect"
export const ErrorObserverTypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
export type ErrorObserverTypeId = typeof ErrorObserverTypeId
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable {
readonly [ErrorObserverTypeId]: ErrorObserverTypeId
handle<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
}
export const ErrorObserver = <E = never>(): Context.Tag<ErrorObserver, ErrorObserver<E>> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver")
export class ErrorObserverImpl<in out E = never>
extends Pipeable.Class() implements ErrorObserver<E> {
readonly [ErrorObserverTypeId]: ErrorObserverTypeId = ErrorObserverTypeId
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
constructor(
readonly pubsub: PubSub.PubSub<Cause.Cause<E>>
) {
super()
this.subscribe = pubsub.subscribe
}
handle<A, EffE, R>(effect: Effect.Effect<A, EffE, R>): Effect.Effect<A, EffE, R> {
return Effect.tapErrorCause(effect, cause => PubSub.publish(this.pubsub, cause as Cause.Cause<E>))
}
}
export class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
readonly value = Effect.void
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<never>>) {
super()
}
onEnd<A, E>(_value: Exit.Exit<A, E>): void {
if (Exit.isFailure(_value)) {
Effect.runSync(PubSub.publish(this.pubsub, _value.cause as Cause.Cause<never>))
}
}
}
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, ErrorObserverTypeId)
export const layer: Layer.Layer<ErrorObserver> = Layer.unwrapEffect(Effect.map(
PubSub.unbounded<Cause.Cause<never>>(),
pubsub => Layer.merge(
Supervisor.addSupervisor(new ErrorObserverSupervisorImpl(pubsub)),
Layer.succeed(ErrorObserver(), new ErrorObserverImpl(pubsub)),
),
))
export const handle = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => Effect.andThen(
Effect.serviceOption(ErrorObserver()),
Option.match({
onSome: observer => observer.handle(effect),
onNone: () => effect,
}),
)
+266 -175
View File
@@ -1,203 +1,297 @@
import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, type ParseResult, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect" import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect"
import type * as React from "react" import type { NoSuchElementException } from "effect/Cause"
import * as React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
import * as Lens from "./Lens.js" import * as PropertyPath from "./PropertyPath.js"
import * as Result from "./Result.js"
import * as Subscribable from "./Subscribable.js" import * as Subscribable from "./Subscribable.js"
import * as SubscriptionRef from "./SubscriptionRef.js"
import * as SubscriptionSubRef from "./SubscriptionSubRef.js"
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form") export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
export type FormTypeId = typeof FormTypeId export type FormTypeId = typeof FormTypeId
export interface Form<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never> export interface Form<in out A, in out I = A, out R = never, in out SA = void, in out SE = A, out SR = never>
extends Pipeable.Pipeable { extends Pipeable.Pipeable {
readonly [FormTypeId]: FormTypeId readonly [FormTypeId]: FormTypeId
readonly path: P readonly schema: Schema.Schema<A, I, R>
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never> readonly onSubmit: (value: NoInfer<A>) => Effect.Effect<SA, SE, SR>
readonly encodedValue: Lens.Lens<I, ER, EW, never, never> readonly autosubmit: boolean
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never> readonly debounce: Option.Option<Duration.DurationInput>
readonly isValidating: Subscribable.Subscribable<boolean, ER, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never> readonly valueRef: SubscriptionRef.SubscriptionRef<Option.Option<A>>
readonly isCommitting: Subscribable.Subscribable<boolean, never, never> readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>
readonly errorRef: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>
readonly validationFiberRef: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<void, never>>>
readonly submitResultRef: SubscriptionRef.SubscriptionRef<Result.Result<SA, SE>>
readonly canSubmitSubscribable: Subscribable.Subscribable<boolean>
} }
export class FormImpl<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never> class FormImpl<in out A, in out I = A, out R = never, in out SA = void, in out SE = A, out SR = never>
extends Pipeable.Class() implements Form<P, A, I, ER, EW> { extends Pipeable.Class() implements Form<A, I, R, SA, SE, SR> {
readonly [FormTypeId]: FormTypeId = FormTypeId readonly [FormTypeId]: FormTypeId = FormTypeId
constructor( constructor(
readonly path: P, readonly schema: Schema.Schema<A, I, R>,
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>, readonly onSubmit: (value: NoInfer<A>) => Effect.Effect<SA, SE, SR>,
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>, readonly autosubmit: boolean,
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>, readonly debounce: Option.Option<Duration.DurationInput>,
readonly isValidating: Subscribable.Subscribable<boolean, never, never>,
readonly canCommit: Subscribable.Subscribable<boolean, never, never>, readonly valueRef: SubscriptionRef.SubscriptionRef<Option.Option<A>>,
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>, readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>,
readonly errorRef: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>,
readonly validationFiberRef: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<void, never>>>,
readonly submitResultRef: SubscriptionRef.SubscriptionRef<Result.Result<SA, SE>>,
readonly canSubmitSubscribable: Subscribable.Subscribable<boolean>,
) { ) {
super() super()
} }
} }
export const isForm = (u: unknown): u is Form<unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
export const isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown> => Predicate.hasProperty(u, FormTypeId) export namespace make {
export interface Options<in out A, in out I, in out R, in out SA = void, in out SE = A, out SR = never> {
readonly schema: Schema.Schema<A, I, R>
readonly initialEncodedValue: NoInfer<I>
readonly onSubmit: (
this: Form<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>,
value: NoInfer<A>,
) => Effect.Effect<SA, SE, SR>
readonly autosubmit?: boolean
readonly debounce?: Duration.DurationInput
}
}
export const make: {
<A, I = A, R = never, SA = void, SE = A, SR = never>(
options: make.Options<A, I, R, SA, SE, SR>
): Effect.Effect<Form<A, I, R, SA, SE, SR>>
} = Effect.fnUntraced(function* <A, I = A, R = never, SA = void, SE = A, SR = never>(
options: make.Options<A, I, R, SA, SE, SR>
) {
const valueRef = yield* SubscriptionRef.make(Option.none<A>())
const errorRef = yield* SubscriptionRef.make(Option.none<ParseResult.ParseError>())
const validationFiberRef = yield* SubscriptionRef.make(Option.none<Fiber.Fiber<void, never>>())
const submitResultRef = yield* SubscriptionRef.make<Result.Result<SA, SE>>(Result.initial())
const filterIssuesByPath = ( return new FormImpl(
issues: readonly ParseResult.ArrayFormatterIssue[], options.schema,
path: readonly PropertyKey[], options.onSubmit,
): readonly ParseResult.ArrayFormatterIssue[] => Array.filter(issues, issue => options.autosubmit ?? false,
issue.path.length >= path.length && Array.every(path, (p, i) => p === issue.path[i]) Option.fromNullable(options.debounce),
valueRef,
yield* SubscriptionRef.make(options.initialEncodedValue),
errorRef,
validationFiberRef,
submitResultRef,
Subscribable.map(
Subscribable.zipLatestAll(valueRef, errorRef, validationFiberRef, submitResultRef),
([value, error, validationFiber, submitResult]) => (
Option.isSome(value) &&
Option.isNone(error) &&
Option.isNone(validationFiber) &&
!(Result.isRunning(submitResult) || Result.isRefreshing(submitResult))
),
),
)
})
export const run = <A, I, R, SA, SE, SR>(
self: Form<A, I, R, SA, SE, SR>
): Effect.Effect<void, never, Scope.Scope | R | SR> => Stream.runForEach(
self.encodedValueRef.changes.pipe(
Option.isSome(self.debounce) ? Stream.debounce(self.debounce.value) : identity
),
encodedValue => self.validationFiberRef.pipe(
Effect.andThen(Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
})),
Effect.andThen(
Effect.addFinalizer(() => Ref.set(self.validationFiberRef, Option.none())).pipe(
Effect.andThen(Schema.decode(self.schema, { errors: "all" })(encodedValue)),
Effect.exit,
Effect.andThen(flow(
Exit.matchEffect({
onSuccess: v => Ref.set(self.valueRef, Option.some(v)).pipe(
Effect.andThen(Ref.set(self.errorRef, Option.none())),
Effect.as(Option.some(v)),
),
onFailure: c => Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError").pipe(
Option.match({
onSome: e => Ref.set(self.errorRef, Option.some(e)),
onNone: () => Effect.void,
}),
Effect.as(Option.none<A>()),
),
}),
Effect.uninterruptible,
)),
Effect.scoped,
Effect.andThen(value => Option.isSome(value) && self.autosubmit
? Effect.asVoid(Effect.forkScoped(submit(self)))
: Effect.void
),
Effect.forkScoped,
)
),
Effect.andThen(fiber => Ref.set(self.validationFiberRef, Option.some(fiber)))
),
) )
export const focusObjectOn: { export const submit = <A, I, R, SA, SE, SR>(
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>( self: Form<A, I, R, SA, SE, SR>
self: Form<P, A, I, ER, EW>, ): Effect.Effect<Option.Option<Result.Result<SA, SE>>, NoSuchElementException, Scope.Scope | SR> => Effect.whenEffect(
key: K, self.valueRef.pipe(
): Form<readonly [...P, K], A[K], I[K], ER, EW> Effect.andThen(identity),
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>( Effect.andThen(value => Result.forkEffectDequeue(
key: K, self.onSubmit(value) as Effect.Effect<SA, SE, Result.forkEffectDequeue.InputContext<SR, never>>)
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW> ),
} = Function.dual(2, <P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>( Effect.andThen(Stream.fromQueue),
self: Form<P, A, I, ER, EW>, Stream.unwrap,
key: K, Stream.runFoldEffect(
): Form<readonly [...P, K], A[K], I[K], ER, EW> => { Result.initial() as Result.Result<SA, SE>,
const form = self as FormImpl<P, A, I, ER, EW> (_, result) => Effect.as(Ref.set(self.submitResultRef, result), result),
const path = [...form.path, key] as const ),
),
return new FormImpl( self.canSubmitSubscribable.get,
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: { export namespace service {
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>( export interface Options<in out A, in out I, in out R, in out SA = void, in out SE = A, out SR = never>
self: Form<P, A, I, ER, EW>, extends make.Options<A, I, R, SA, SE, SR> {}
index: number, }
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementException, EW | Cause.NoSuchElementException>
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
index: number,
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementException, EW | Cause.NoSuchElementException>
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
self: Form<P, A, I, ER, EW>,
index: number,
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementException, EW | Cause.NoSuchElementException> => {
const form = self as FormImpl<P, A, I, ER, EW>
const path = [...form.path, index] as const
return new FormImpl( export const service = <A, I = A, R = never, SA = void, SE = A, SR = never>(
path, options: service.Options<A, I, R, SA, SE, SR>
Subscribable.mapOptionEffect(form.value, Array.get(index)), ): Effect.Effect<Form<A, I, R, SA, SE, SR>, never, Scope.Scope | R | SR> => Effect.tap(
Lens.focusArrayAt(form.encodedValue, index), make(options),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)), form => Effect.forkScoped(run(form)),
form.isValidating, )
form.canCommit,
form.isCommitting,
)
})
export const focusTupleAt: { export const field = <A, I, R, SA, SE, SR, const P extends PropertyPath.Paths<NoInfer<I>>>(
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>( self: Form<A, I, R, SA, SE, SR>,
self: Form<P, A, I, ER, EW>, path: P,
index: K, ): FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>> => new FormFieldImpl(
): Form<readonly [...P, K], A[K], I[K], ER, EW> Subscribable.mapEffect(self.valueRef, Option.match({
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>( onSome: v => Option.map(PropertyPath.get(v, path), Option.some),
index: K, onNone: () => Option.some(Option.none()),
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW> })),
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>( SubscriptionSubRef.makeFromPath(self.encodedValueRef, path),
self: Form<P, A, I, ER, EW>, Subscribable.mapEffect(self.errorRef, Option.match({
index: K, onSome: flow(
): Form<readonly [...P, K], A[K], I[K], ER, EW> => { ParseResult.ArrayFormatter.formatError,
const form = self as FormImpl<P, A, I, ER, EW> Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))),
const path = [...form.path, index] as const ),
onNone: () => Effect.succeed([]),
})),
Subscribable.map(self.validationFiberRef, Option.isSome),
Subscribable.map(self.submitResultRef, result => Result.isRunning(result) || Result.isRefreshing(result)),
)
return new FormImpl(
path,
Subscribable.mapOption(form.value, Array.unsafeGet(index)),
Lens.focusTupleAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
export const focusChunkAt: { export const FormFieldTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormField")
<P extends readonly PropertyKey[], A, I, ER, EW>( export type FormFieldTypeId = typeof FormFieldTypeId
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
index: number,
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW>
<P extends readonly PropertyKey[], A, I, ER, EW>(
index: number,
): (self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>) => Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW>
} = Function.dual(2, <P extends readonly PropertyKey[], A, I, ER, EW>(
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
index: number,
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW> => {
const form = self as FormImpl<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>
const path = [...form.path, index] as const
return new FormImpl( export interface FormField<in out A, in out I = A>
path, extends Pipeable.Pipeable {
Subscribable.mapOptionEffect(form.value, Chunk.get(index)), readonly [FormFieldTypeId]: FormFieldTypeId
Lens.focusChunkAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
readonly valueSubscribable: Subscribable.Subscribable<Option.Option<A>, NoSuchElementException>
readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>
readonly issuesSubscribable: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>
readonly isValidatingSubscribable: Subscribable.Subscribable<boolean>
readonly isSubmittingSubscribable: Subscribable.Subscribable<boolean>
}
class FormFieldImpl<in out A, in out I = A>
extends Pipeable.Class() implements FormField<A, I> {
readonly [FormFieldTypeId]: FormFieldTypeId = FormFieldTypeId
constructor(
readonly valueSubscribable: Subscribable.Subscribable<Option.Option<A>, NoSuchElementException>,
readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>,
readonly issuesSubscribable: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>,
readonly isValidatingSubscribable: Subscribable.Subscribable<boolean>,
readonly isSubmittingSubscribable: Subscribable.Subscribable<boolean>,
) {
super()
}
}
export const isFormField = (u: unknown): u is FormField<unknown, unknown> => Predicate.hasProperty(u, FormFieldTypeId)
export const useSubmit = <A, I, R, SA, SE, SR>(
self: Form<A, I, R, SA, SE, SR>
): Effect.Effect<
() => Promise<Option.Option<Result.Result<SA, SE>>>,
never,
Scope.Scope | SR
> => Component.useCallbackPromise(() => submit(self), [self])
export const useField = <A, I, R, SA, SE, SR, const P extends PropertyPath.Paths<NoInfer<I>>>(
self: Form<A, I, R, SA, SE, SR>,
path: P,
): FormField<
PropertyPath.ValueFromPath<A, P>,
PropertyPath.ValueFromPath<I, P>
// biome-ignore lint/correctness/useExhaustiveDependencies: individual path components need to be compared
> => React.useMemo(() => field(self, path), [self, ...path])
export namespace useInput { export namespace useInput {
export interface Options { export interface Options {
readonly debounce?: Duration.DurationInput readonly debounce?: Duration.DurationInput
} }
export interface Success<T> { export interface Result<T> {
readonly value: T readonly value: T
readonly setValue: React.Dispatch<React.SetStateAction<T>> readonly setValue: React.Dispatch<React.SetStateAction<T>>
} }
} }
export const useInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>( export const useInput: {
form: Form<P, A, I, ER, EW>, <A, I>(
field: FormField<A, I>,
options?: useInput.Options,
): Effect.Effect<useInput.Result<I>, NoSuchElementException, Scope.Scope>
} = Effect.fnUntraced(function* <A, I>(
field: FormField<A, I>,
options?: useInput.Options, options?: useInput.Options,
): Effect.fn.Return<useInput.Success<I>, ER, Scope.Scope> { ) {
const internalValueLens = yield* Component.useOnChange(() => Effect.gen(function*() { const internalValueRef = yield* Component.useOnChange(() => Effect.tap(
const internalValueLens = yield* Lens.get(form.encodedValue).pipe( Effect.andThen(field.encodedValueRef, SubscriptionRef.make),
Effect.flatMap(SubscriptionRef.make), internalValueRef => Effect.forkScoped(Effect.all([
Effect.map(Lens.fromSubscriptionRef),
)
yield* Effect.forkScoped(Effect.all([
Stream.runForEach( Stream.runForEach(
Stream.drop(form.encodedValue.changes, 1), Stream.drop(field.encodedValueRef, 1),
upstreamEncodedValue => Effect.whenEffect( upstreamEncodedValue => Effect.whenEffect(
Lens.set(internalValueLens, upstreamEncodedValue), Ref.set(internalValueRef, upstreamEncodedValue),
Effect.andThen(Lens.get(internalValueLens), internalValue => !Equal.equals(upstreamEncodedValue, internalValue)), Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
), ),
), ),
Stream.runForEach( Stream.runForEach(
internalValueLens.changes.pipe( internalValueRef.changes.pipe(
Stream.drop(1), Stream.drop(1),
Stream.changesWith(Equal.equivalence()), Stream.changesWith(Equal.equivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity, options?.debounce ? Stream.debounce(options.debounce) : identity,
), ),
internalValue => Lens.set(form.encodedValue, internalValue), internalValue => Ref.set(field.encodedValueRef, internalValue),
), ),
], { concurrency: "unbounded", discard: true })) ], { concurrency: "unbounded" })),
), [field, options?.debounce])
return internalValueLens const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
}), [form, options?.debounce])
const [value, setValue] = yield* Lens.useState(internalValueLens)
return { value, setValue } return { value, setValue }
}) })
@@ -206,69 +300,66 @@ export namespace useOptionalInput {
readonly defaultValue: T readonly defaultValue: T
} }
export interface Success<T> extends useInput.Success<T> { export interface Result<T> extends useInput.Result<T> {
readonly enabled: boolean readonly enabled: boolean
readonly setEnabled: React.Dispatch<React.SetStateAction<boolean>> readonly setEnabled: React.Dispatch<React.SetStateAction<boolean>>
} }
} }
export const useOptionalInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>( export const useOptionalInput: {
field: Form<P, A, Option.Option<I>, ER, EW>, <A, I>(
field: FormField<A, Option.Option<I>>,
options: useOptionalInput.Options<I>,
): Effect.Effect<useOptionalInput.Result<I>, NoSuchElementException, Scope.Scope>
} = Effect.fnUntraced(function* <A, I>(
field: FormField<A, Option.Option<I>>,
options: useOptionalInput.Options<I>, options: useOptionalInput.Options<I>,
): Effect.fn.Return<useOptionalInput.Success<I>, ER, Scope.Scope> { ) {
const [enabledLens, internalValueLens] = yield* Component.useOnChange(() => Effect.gen(function*() { const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap(
const [enabledLens, internalValueLens] = yield* Effect.flatMap( Effect.andThen(
Lens.get(field.encodedValue), field.encodedValueRef,
Option.match({ Option.match({
onSome: v => Effect.all([ onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]),
Effect.map(SubscriptionRef.make(true), Lens.fromSubscriptionRef), onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]),
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([ ([enabledRef, internalValueRef]) => Effect.forkScoped(Effect.all([
Stream.runForEach( Stream.runForEach(
Stream.drop(field.encodedValue.changes, 1), Stream.drop(field.encodedValueRef, 1),
upstreamEncodedValue => Effect.whenEffect( upstreamEncodedValue => Effect.whenEffect(
Option.match(upstreamEncodedValue, { Option.match(upstreamEncodedValue, {
onSome: v => Effect.andThen( onSome: v => Effect.andThen(
Lens.set(enabledLens, true), Ref.set(enabledRef, true),
Lens.set(internalValueLens, v), Ref.set(internalValueRef, v),
), ),
onNone: () => Effect.andThen( onNone: () => Effect.andThen(
Lens.set(enabledLens, false), Ref.set(enabledRef, false),
Lens.set(internalValueLens, options.defaultValue), Ref.set(internalValueRef, options.defaultValue),
), ),
}), }),
Effect.andThen( Effect.andThen(
Effect.all([Lens.get(enabledLens), Lens.get(internalValueLens)]), Effect.all([enabledRef, internalValueRef]),
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()), ([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
), ),
), ),
), ),
Stream.runForEach( Stream.runForEach(
enabledLens.changes.pipe( enabledRef.changes.pipe(
Stream.zipLatest(internalValueLens.changes), Stream.zipLatest(internalValueRef.changes),
Stream.drop(1), Stream.drop(1),
Stream.changesWith(Equal.equivalence()), Stream.changesWith(Equal.equivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity, options?.debounce ? Stream.debounce(options.debounce) : identity,
), ),
([enabled, internalValue]) => Lens.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()), ([enabled, internalValue]) => Ref.set(field.encodedValueRef, enabled ? Option.some(internalValue) : Option.none()),
), ),
], { concurrency: "unbounded" })) ], { concurrency: "unbounded" })),
), [field, options.debounce])
return [enabledLens, internalValueLens] as const const [enabled, setEnabled] = yield* SubscriptionRef.useSubscriptionRefState(enabledRef)
}), [field, options.debounce]) const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
const [enabled, setEnabled] = yield* Lens.useState(enabledLens)
const [value, setValue] = yield* Lens.useState(internalValueLens)
return { enabled, setEnabled, value, setValue } return { enabled, setEnabled, value, setValue }
}) })
-62
View File
@@ -1,62 +0,0 @@
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<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useState = Effect.fnUntraced(function* <A, ER, EW, RR, RW>(
lens: Lens.Lens<A, ER, EW, RR, RW>,
options?: useState.Options<NoInfer<A>>,
): Effect.fn.Return<readonly [A, React.Dispatch<React.SetStateAction<A>>], ER, 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.strict()),
v => Effect.sync(() => setReactStateValue(v)),
)
), [lens])
const setValue = yield* Component.useCallbackSync(
(setStateAction: React.SetStateAction<A>) => Effect.andThen(
Lens.updateAndGet(lens, prevState => SetStateAction.value(setStateAction, prevState)),
v => setReactStateValue(v),
),
[lens],
)
return [reactStateValue, setValue]
})
export declare namespace useFromReactState {
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useFromReactState = Effect.fnUntraced(function* <A>(
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
options?: useFromReactState.Options<NoInfer<A>>,
): Effect.fn.Return<Lens.Lens<A, never, never, never, never>> {
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.strict()),
v => Effect.sync(() => setValue(v)),
)), [setValue])
yield* Component.useReactEffect(() => Lens.set(lens, value), [value])
return lens
})
+19 -80
View File
@@ -1,111 +1,50 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ /** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { type Equivalence, Function, Predicate } from "effect" import { type Equivalence, Function, Predicate } from "effect"
import * as React from "react"
import type * as Component from "./Component.js" import type * as Component from "./Component.js"
export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized") export const TypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
export type MemoizedTypeId = typeof MemoizedTypeId export type TypeId = typeof TypeId
export interface Memoized<P> extends Memoized.Options<P> {
/** readonly [TypeId]: TypeId
* 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<P> extends MemoizedPrototype, MemoizedOptions<P> {}
export interface MemoizedPrototype {
readonly [MemoizedTypeId]: MemoizedTypeId
} }
/** export namespace Memoized {
* Configuration options for Memoized components. export interface Options<P> {
* readonly propsAreEqual?: Equivalence.Equivalence<P>
* @template P The props type of the component }
*/
export interface MemoizedOptions<P> {
/**
* 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<P>
} }
export const MemoizedPrototype: MemoizedPrototype = Object.freeze({ const MemoizedProto = Object.freeze({
[MemoizedTypeId]: MemoizedTypeId, [TypeId]: TypeId
transformFunctionComponent<P extends {}>(
this: Memoized<P>,
f: React.FC<P>,
) {
return React.memo(f, this.propsEquivalence)
},
} as const) } as const)
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, MemoizedTypeId) export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, TypeId)
/** export const memoized = <T extends Component.Component<any, any, any, any>>(
* 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 = <T extends Component.Component.Any>(
self: T self: T
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf( ): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
Object.assign(function() {}, self), Object.assign(function() {}, self),
Object.freeze(Object.setPrototypeOf( Object.freeze(Object.setPrototypeOf(
Object.assign({}, MemoizedPrototype), Object.assign({}, MemoizedProto),
Object.getPrototypeOf(self), 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: { export const withOptions: {
<T extends Component.Component.Any & Memoized<any>>( <T extends Component.Component<any, any, any, any> & Memoized<any>>(
options: Partial<MemoizedOptions<Component.Component.Props<T>>> options: Partial<Memoized.Options<Component.Component.Props<T>>>
): (self: T) => T ): (self: T) => T
<T extends Component.Component.Any & Memoized<any>>( <T extends Component.Component<any, any, any, any> & Memoized<any>>(
self: T, self: T,
options: Partial<MemoizedOptions<Component.Component.Props<T>>>, options: Partial<Memoized.Options<Component.Component.Props<T>>>,
): T ): T
} = Function.dual(2, <T extends Component.Component.Any & Memoized<any>>( } = Function.dual(2, <T extends Component.Component<any, any, any, any> & Memoized<any>>(
self: T, self: T,
options: Partial<MemoizedOptions<Component.Component.Props<T>>>, options: Partial<Memoized.Options<Component.Component.Props<T>>>,
): T => Object.setPrototypeOf( ): T => Object.setPrototypeOf(
Object.assign(function() {}, self, options), Object.assign(function() {}, self, options),
Object.getPrototypeOf(self), Object.getPrototypeOf(self),
-130
View File
@@ -1,130 +0,0 @@
import { type Context, Effect, Equal, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, type Subscribable, SubscriptionRef } from "effect"
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<in out K extends Mutation.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
extends Pipeable.Pipeable {
readonly [MutationTypeId]: MutationTypeId
readonly context: Context.Context<Scope.Scope | R>
readonly f: (key: K) => Effect.Effect<A, E, R>
readonly initialProgress: P
readonly latestKey: Subscribable.Subscribable<Option.Option<K>>
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
readonly result: Subscribable.Subscribable<Result.Result<A, E, P>>
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Final<A, E, P>>>
mutate(key: K): Effect.Effect<Result.Final<A, E, P>>
mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>>
}
export declare namespace Mutation {
export type AnyKey = readonly any[]
}
export class MutationImpl<in out K extends Mutation.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
readonly [MutationTypeId]: MutationTypeId = MutationTypeId
constructor(
readonly context: Context.Context<Scope.Scope | R>,
readonly f: (key: K) => Effect.Effect<A, E, R>,
readonly initialProgress: P,
readonly latestKey: SubscriptionRef.SubscriptionRef<Option.Option<K>>,
readonly fiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>,
readonly result: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>,
readonly latestFinalResult: SubscriptionRef.SubscriptionRef<Option.Option<Result.Final<A, E, P>>>,
) {
super()
}
mutate(key: K): Effect.Effect<Result.Final<A, E, P>> {
return SubscriptionRef.set(this.latestKey, Option.some(key)).pipe(
Effect.andThen(this.start(key)),
Effect.andThen(sub => this.watch(sub)),
Effect.provide(this.context),
)
}
mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> {
return SubscriptionRef.set(this.latestKey, 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<Result.Result<A, E, P>>,
never,
Scope.Scope | R
> {
return this.latestFinalResult.pipe(
Effect.andThen(initial => Result.unsafeForkEffect(
Effect.onExit(this.f(key), () => Effect.andThen(
Effect.all([Effect.fiberId, this.fiber]),
([currentFiberId, fiber]) => Option.match(fiber, {
onSome: v => Equal.equals(currentFiberId, v.id())
? SubscriptionRef.set(this.fiber, Option.none())
: Effect.void,
onNone: () => Effect.void,
}),
)),
{
initial: Option.isSome(initial) ? Result.willFetch(initial.value) : Result.initial(),
initialProgress: this.initialProgress,
} as Result.unsafeForkEffect.Options<A, E, P>,
)),
Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))),
Effect.map(([sub]) => sub),
)
}
watch(
sub: Subscribable.Subscribable<Result.Result<A, E, P>>
): Effect.Effect<Result.Final<A, E, P>> {
return sub.get.pipe(
Effect.andThen(initial => Stream.runFoldEffect(
sub.changes,
initial,
(_, result) => Effect.as(SubscriptionRef.set(this.result, result), result),
) as Effect.Effect<Result.Final<A, E, P>>),
Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))),
)
}
}
export const isMutation = (u: unknown): u is Mutation<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
export declare namespace make {
export interface Options<K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never> {
readonly f: (key: K) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
readonly initialProgress?: P
}
}
export const make = Effect.fnUntraced(function* <const K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never>(
options: make.Options<K, A, E, R, P>
): Effect.fn.Return<
Mutation<K, A, E, Result.forkEffect.OutputContext<R, P>, P>,
never,
Scope.Scope | Result.forkEffect.OutputContext<R, P>
> {
return new MutationImpl(
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<R, P>>(),
options.f as any,
options.initialProgress as P,
yield* SubscriptionRef.make(Option.none<K>()),
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>()),
yield* SubscriptionRef.make(Result.initial<A, E, P>()),
yield* SubscriptionRef.make(Option.none<Result.Final<A, E, P>>()),
)
})
+98
View File
@@ -0,0 +1,98 @@
import { Array, Equivalence, Function, Option, Predicate } from "effect"
export type PropertyPath = readonly PropertyKey[]
type Prev = readonly [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
export type Paths<T, D extends number = 5, Seen = never> = readonly [] | (
D extends never ? readonly [] :
T extends Seen ? readonly [] :
T extends readonly any[] ? {
[K in keyof T as K extends number ? K : never]:
| readonly [K]
| readonly [K, ...Paths<T[K], Prev[D], Seen | T>]
} extends infer O
? O[keyof O]
: never
:
T extends object ? {
[K in keyof T as K extends string | number | symbol ? K : never]-?:
NonNullable<T[K]> extends infer V
? readonly [K] | readonly [K, ...Paths<V, Prev[D], Seen>]
: never
} extends infer O
? O[keyof O]
: never
:
never
)
export type ValueFromPath<T, P extends readonly any[]> = P extends readonly [infer Head, ...infer Tail]
? Head extends keyof T
? ValueFromPath<T[Head], Tail>
: T extends readonly any[]
? Head extends number
? ValueFromPath<T[number], Tail>
: never
: never
: T
export const equivalence: Equivalence.Equivalence<PropertyPath> = Equivalence.array(Equivalence.strict())
export const unsafeGet: {
<T, const P extends Paths<T>>(path: P): (self: T) => ValueFromPath<T, P>
<T, const P extends Paths<T>>(self: T, path: P): ValueFromPath<T, P>
} = Function.dual(2, <T, const P extends Paths<T>>(self: T, path: P): ValueFromPath<T, P> =>
path.reduce((acc: any, key: any) => acc?.[key], self)
)
export const get: {
<T, const P extends Paths<T>>(path: P): (self: T) => Option.Option<ValueFromPath<T, P>>
<T, const P extends Paths<T>>(self: T, path: P): Option.Option<ValueFromPath<T, P>>
} = Function.dual(2, <T, const P extends Paths<T>>(self: T, path: P): Option.Option<ValueFromPath<T, P>> =>
path.reduce(
(acc: Option.Option<any>, key: any): Option.Option<any> => Option.isSome(acc)
? Predicate.hasProperty(acc.value, key)
? Option.some(acc.value[key])
: Option.none()
: acc,
Option.some(self),
)
)
export const immutableSet: {
<T, const P extends Paths<T>>(path: P, value: ValueFromPath<T, P>): (self: T) => Option.Option<T>
<T, const P extends Paths<T>>(self: T, path: P, value: ValueFromPath<T, P>): Option.Option<T>
} = Function.dual(3, <T, const P extends Paths<T>>(self: T, path: P, value: ValueFromPath<T, P>): Option.Option<T> => {
const key = Array.head(path as PropertyPath)
if (Option.isNone(key))
return Option.some(value as T)
if (!Predicate.hasProperty(self, key.value))
return Option.none()
const child = immutableSet<any, any>(self[key.value], Option.getOrThrow(Array.tail(path as PropertyPath)), value)
if (Option.isNone(child))
return child
if (Array.isArray(self))
return typeof key.value === "number"
? Option.some([
...self.slice(0, key.value),
child.value,
...self.slice(key.value + 1),
] as T)
: Option.none()
if (typeof self === "object")
return Option.some(
Object.assign(
Object.create(Object.getPrototypeOf(self)),
{ ...self, [key.value]: child.value },
)
)
return Option.none()
})
-14
View File
@@ -1,14 +0,0 @@
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* <const A extends React.DependencyList>(
values: A
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
yield* Component.useReactEffect(() => Effect.unlessEffect(PubSub.publish(pubsub, values), PubSub.isShutdown(pubsub)), values)
return pubsub
})
export * from "effect/PubSub"
-319
View File
@@ -1,319 +0,0 @@
import { type Cause, type Context, type Duration, Effect, Equal, Fiber, identity, Option, Pipeable, Predicate, type Scope, Stream, Subscribable, SubscriptionRef } from "effect"
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<in out K extends Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never, in out P = never>
extends Pipeable.Pipeable {
readonly [QueryTypeId]: QueryTypeId
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>
readonly key: Stream.Stream<K, KE, KR>
readonly f: (key: K) => Effect.Effect<A, E, R>
readonly initialProgress: P
readonly staleTime: Duration.DurationInput
readonly refreshOnWindowFocus: boolean
readonly latestKey: Subscribable.Subscribable<Option.Option<K>>
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
readonly result: Subscribable.Subscribable<Result.Result<A, E, P>>
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Final<A, E, P>>>
readonly run: Effect.Effect<void>
fetch(key: K): Effect.Effect<Result.Final<A, E, P>>
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>>
readonly refresh: Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException>
readonly refreshSubscribable: Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementException>
readonly invalidateCache: Effect.Effect<void>
invalidateCacheEntry(key: K): Effect.Effect<void>
}
export declare namespace Query {
export type AnyKey = readonly any[]
}
export class QueryImpl<in out K extends Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never, in out P = never>
extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
readonly [QueryTypeId]: QueryTypeId = QueryTypeId
constructor(
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>,
readonly key: Stream.Stream<K, KE, KR>,
readonly f: (key: K) => Effect.Effect<A, E, R>,
readonly initialProgress: P,
readonly staleTime: Duration.DurationInput,
readonly refreshOnWindowFocus: boolean,
readonly latestKey: SubscriptionRef.SubscriptionRef<Option.Option<K>>,
readonly fiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>,
readonly result: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>,
readonly latestFinalResult: SubscriptionRef.SubscriptionRef<Option.Option<Result.Final<A, E, P>>>,
readonly runSemaphore: Effect.Semaphore,
) {
super()
}
get run(): Effect.Effect<void> {
return Effect.all([
Stream.runForEach(this.key, key => this.fetchSubscribable(key)),
Effect.promise(() => import("@effect/platform-browser")).pipe(
Effect.andThen(({ BrowserStream }) => this.refreshOnWindowFocus
? Stream.runForEach(
BrowserStream.fromEventListenerWindow("focus"),
() => this.refreshSubscribable,
)
: Effect.void
),
Effect.catchAllDefect(() => Effect.void),
),
], { concurrency: "unbounded" }).pipe(
Effect.ignore,
this.runSemaphore.withPermits(1),
Effect.provide(this.context),
)
}
get interrupt(): Effect.Effect<void> {
return Effect.andThen(this.fiber, Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
}))
}
fetch(key: K): Effect.Effect<Result.Final<A, E, P>> {
return this.interrupt.pipe(
Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))),
Effect.andThen(this.latestFinalResult),
Effect.andThen(previous => this.startCached(key, Option.isSome(previous)
? Result.willFetch(previous.value) as Result.Final<A, E, P>
: Result.initial()
)),
Effect.andThen(sub => this.watch(key, sub)),
Effect.provide(this.context),
)
}
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> {
return this.interrupt.pipe(
Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))),
Effect.andThen(this.latestFinalResult),
Effect.andThen(previous => this.startCached(key, Option.isSome(previous)
? Result.willFetch(previous.value) as Result.Final<A, E, P>
: Result.initial()
)),
Effect.tap(sub => Effect.forkScoped(this.watch(key, sub))),
Effect.provide(this.context),
)
}
get refresh(): Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException> {
return this.interrupt.pipe(
Effect.andThen(Effect.Do),
Effect.bind("latestKey", () => Effect.andThen(this.latestKey, identity)),
Effect.bind("latestFinalResult", () => this.latestFinalResult),
Effect.bind("subscribable", ({ latestKey, latestFinalResult }) =>
this.startCached(latestKey, Option.isSome(latestFinalResult)
? Result.willRefresh(latestFinalResult.value) as Result.Final<A, E, P>
: Result.initial()
)
),
Effect.andThen(({ latestKey, subscribable }) => this.watch(latestKey, subscribable)),
Effect.provide(this.context),
)
}
get refreshSubscribable(): Effect.Effect<
Subscribable.Subscribable<Result.Result<A, E, P>>,
Cause.NoSuchElementException
> {
return this.interrupt.pipe(
Effect.andThen(Effect.Do),
Effect.bind("latestKey", () => Effect.andThen(this.latestKey, identity)),
Effect.bind("latestFinalResult", () => this.latestFinalResult),
Effect.bind("subscribable", ({ latestKey, latestFinalResult }) =>
this.startCached(latestKey, Option.isSome(latestFinalResult)
? Result.willRefresh(latestFinalResult.value) as Result.Final<A, E, P>
: Result.initial()
)
),
Effect.tap(({ latestKey, subscribable }) => Effect.forkScoped(this.watch(latestKey, subscribable))),
Effect.map(({ subscribable }) => subscribable),
Effect.provide(this.context),
)
}
startCached(
key: K,
initial: Result.Initial | Result.Final<A, E, P>,
): Effect.Effect<
Subscribable.Subscribable<Result.Result<A, E, P>>,
never,
Scope.Scope | QueryClient.QueryClient | R
> {
return Effect.andThen(this.getCacheEntry(key), Option.match({
onSome: entry => Effect.andThen(
QueryClient.isQueryClientCacheEntryStale(entry),
isStale => isStale
? this.start(key, Result.willRefresh(entry.result) as Result.Final<A, E, P>)
: Effect.succeed(Subscribable.make({
get: Effect.succeed(entry.result as Result.Result<A, E, P>),
get changes() { return Stream.make(entry.result as Result.Result<A, E, P>) },
})),
),
onNone: () => this.start(key, initial),
}))
}
start(
key: K,
initial: Result.Initial | Result.Final<A, E, P>,
): Effect.Effect<
Subscribable.Subscribable<Result.Result<A, E, P>>,
never,
Scope.Scope | R
> {
return Result.unsafeForkEffect(
Effect.onExit(this.f(key), () => Effect.andThen(
Effect.all([Effect.fiberId, this.fiber]),
([currentFiberId, fiber]) => Option.match(fiber, {
onSome: v => Equal.equals(currentFiberId, v.id())
? SubscriptionRef.set(this.fiber, Option.none())
: Effect.void,
onNone: () => Effect.void,
}),
)),
{
initial,
initialProgress: this.initialProgress,
} as Result.unsafeForkEffect.Options<A, E, P>,
).pipe(
Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))),
Effect.map(([sub]) => sub),
)
}
watch(
key: K,
sub: Subscribable.Subscribable<Result.Result<A, E, P>>
): Effect.Effect<Result.Final<A, E, P>, never, QueryClient.QueryClient> {
return sub.get.pipe(
Effect.andThen(initial => Stream.runFoldEffect(
sub.changes,
initial,
(_, result) => Effect.as(SubscriptionRef.set(this.result, result), result),
) as Effect.Effect<Result.Final<A, E, P>>),
Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))),
Effect.tap(result => Result.isSuccess(result)
? this.setCacheEntry(key, result)
: Effect.void
),
)
}
makeCacheKey(key: K): QueryClient.QueryClientCacheKey {
return new QueryClient.QueryClientCacheKey(key, this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>)
}
getCacheEntry(
key: K
): Effect.Effect<Option.Option<QueryClient.QueryClientCacheEntry>, never, QueryClient.QueryClient> {
return Effect.andThen(
Effect.all([
Effect.succeed(this.makeCacheKey(key)),
QueryClient.QueryClient,
]),
([key, client]) => client.getCacheEntry(key),
)
}
setCacheEntry(
key: K,
result: Result.Success<A>,
): Effect.Effect<QueryClient.QueryClientCacheEntry, never, QueryClient.QueryClient> {
return Effect.andThen(
Effect.all([
Effect.succeed(this.makeCacheKey(key)),
QueryClient.QueryClient,
]),
([key, client]) => client.setCacheEntry(key, result, this.staleTime),
)
}
get invalidateCache(): Effect.Effect<void> {
return QueryClient.QueryClient.pipe(
Effect.andThen(client => client.invalidateCacheEntries(this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>)),
Effect.provide(this.context),
)
}
invalidateCacheEntry(key: K): Effect.Effect<void> {
return Effect.all([
Effect.succeed(this.makeCacheKey(key)),
QueryClient.QueryClient,
]).pipe(
Effect.andThen(([key, client]) => client.invalidateCacheEntry(key)),
Effect.provide(this.context),
)
}
}
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown> => Predicate.hasProperty(u, QueryTypeId)
export declare namespace make {
export interface Options<K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never> {
readonly key: Stream.Stream<K, KE, KR>
readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
readonly initialProgress?: P
readonly staleTime?: Duration.DurationInput
readonly refreshOnWindowFocus?: boolean
}
}
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
options: make.Options<K, A, KE, KR, E, R, P>
): Effect.fn.Return<
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
never,
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
> {
const client = yield* QueryClient.QueryClient
return new QueryImpl<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>(
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>>(),
options.key,
options.f as any,
options.initialProgress as P,
options.staleTime ?? client.defaultStaleTime,
options.refreshOnWindowFocus ?? client.defaultRefreshOnWindowFocus,
yield* SubscriptionRef.make(Option.none<K>()),
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>()),
yield* SubscriptionRef.make(Result.initial<A, E, P>()),
yield* SubscriptionRef.make(Option.none<Result.Final<A, E, P>>()),
yield* Effect.makeSemaphore(1),
)
})
export const service = <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
options: make.Options<K, A, KE, KR, E, R, P>
): Effect.Effect<
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
never,
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
> => Effect.tap(
make(options),
query => Effect.forkScoped(query.run),
)
-173
View File
@@ -1,173 +0,0 @@
import { DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, type Option, Pipeable, Predicate, Schedule, type Scope, type Subscribable, SubscriptionRef } from "effect"
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<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly cacheGcTime: Duration.DurationInput
readonly defaultStaleTime: Duration.DurationInput
readonly defaultRefreshOnWindowFocus: boolean
readonly run: Effect.Effect<void>
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>>
setCacheEntry(
key: QueryClientCacheKey,
result: Result.Success<unknown>,
staleTime: Duration.DurationInput,
): Effect.Effect<QueryClientCacheEntry>
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void>
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void>
}
export class QueryClient extends Effect.Service<QueryClient>()("@effect-fc/QueryClient/QueryClient", {
scoped: Effect.suspend(() => service())
}) {}
export class QueryClientServiceImpl
extends Pipeable.Class()
implements QueryClientService {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId
constructor(
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
readonly cacheGcTime: Duration.DurationInput,
readonly defaultStaleTime: Duration.DurationInput,
readonly defaultRefreshOnWindowFocus: boolean,
readonly runSemaphore: Effect.Semaphore,
) {
super()
}
get run(): Effect.Effect<void> {
return this.runSemaphore.withPermits(1)(Effect.repeat(
Effect.andThen(
DateTime.now,
now => SubscriptionRef.update(this.cache, HashMap.filter(entry =>
Duration.lessThan(
DateTime.distanceDuration(entry.lastAccessedAt, now),
Duration.sum(entry.staleTime, this.cacheGcTime),
)
)),
),
Schedule.spaced("30 second"),
))
}
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>> {
return Effect.all([
Effect.andThen(this.cache, HashMap.get(key)),
DateTime.now,
]).pipe(
Effect.map(([entry, now]) => new QueryClientCacheEntry(entry.result, entry.staleTime, entry.createdAt, now)),
Effect.tap(entry => SubscriptionRef.update(this.cache, HashMap.set(key, entry))),
Effect.option,
)
}
setCacheEntry(
key: QueryClientCacheKey,
result: Result.Success<unknown>,
staleTime: Duration.DurationInput,
): Effect.Effect<QueryClientCacheEntry> {
return DateTime.now.pipe(
Effect.map(now => new QueryClientCacheEntry(result, staleTime, now, now)),
Effect.tap(entry => SubscriptionRef.update(this.cache, HashMap.set(key, entry))),
)
}
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void> {
return SubscriptionRef.update(this.cache, HashMap.filter((_, key) => !Equivalence.strict()(key.f, f)))
}
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void> {
return SubscriptionRef.update(this.cache, 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.DurationInput
readonly defaultStaleTime?: Duration.DurationInput
readonly defaultRefreshOnWindowFocus?: boolean
}
}
export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return<QueryClientService> {
return new QueryClientServiceImpl(
yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()),
options.cacheGcTime ?? "5 minutes",
options.defaultStaleTime ?? "0 minutes",
options.defaultRefreshOnWindowFocus ?? true,
yield* Effect.makeSemaphore(1),
)
})
export declare namespace service {
export interface Options extends make.Options {}
}
export const service = (options?: service.Options): Effect.Effect<QueryClientService, never, Scope.Scope> => Effect.tap(
make(options),
client => 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 Pipeable.Pipeable, Equal.Equal {
readonly [QueryClientCacheKeyTypeId]: QueryClientCacheKeyTypeId = QueryClientCacheKeyTypeId
constructor(
readonly key: Query.Query.AnyKey,
readonly f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>,
) {
super()
}
[Equal.symbol](that: Equal.Equal) {
return isQueryClientCacheKey(that) && Equivalence.array(Equal.equivalence())(this.key, that.key) && Equivalence.strict()(this.f, that.f)
}
[Hash.symbol]() {
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()
implements Pipeable.Pipeable {
readonly [QueryClientCacheEntryTypeId]: QueryClientCacheEntryTypeId = QueryClientCacheEntryTypeId
constructor(
readonly result: Result.Success<unknown>,
readonly staleTime: Duration.DurationInput,
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<boolean> => Effect.andThen(
DateTime.now,
now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), self.staleTime),
)
+15 -31
View File
@@ -1,9 +1,7 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ /** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { Effect, Layer, ManagedRuntime, Predicate, Runtime, Scope } from "effect" import { Effect, Layer, ManagedRuntime, Predicate, type Runtime } from "effect"
import * as React from "react" import * as React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
import * as ErrorObserver from "./ErrorObserver.js"
import * as QueryClient from "./QueryClient.js"
export const TypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime") export const TypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime")
@@ -18,26 +16,16 @@ export interface ReactRuntime<R, ER> {
const ReactRuntimeProto = Object.freeze({ [TypeId]: TypeId } as const) const ReactRuntimeProto = Object.freeze({ [TypeId]: TypeId } as const)
export const Prelude: Layer.Layer<
| Component.ScopeMap
| ErrorObserver.ErrorObserver
| QueryClient.QueryClient
> = Layer.mergeAll(
Component.ScopeMap.Default,
ErrorObserver.layer,
QueryClient.QueryClient.Default,
)
export const isReactRuntime = (u: unknown): u is ReactRuntime<unknown, unknown> => Predicate.hasProperty(u, TypeId) export const isReactRuntime = (u: unknown): u is ReactRuntime<unknown, unknown> => Predicate.hasProperty(u, TypeId)
export const make = <R, ER>( export const make = <R, ER>(
layer: Layer.Layer<R, ER>, layer: Layer.Layer<R, ER>,
memoMap?: Layer.MemoMap, memoMap?: Layer.MemoMap,
): ReactRuntime<Layer.Layer.Success<typeof Prelude> | R, ER> => Object.setPrototypeOf( ): ReactRuntime<R | Component.ScopeMap, ER> => Object.setPrototypeOf(
Object.assign(function() {}, { Object.assign(function() {}, {
runtime: ManagedRuntime.make( runtime: ManagedRuntime.make(
Layer.merge(layer, Prelude), Layer.merge(layer, Component.ScopeMap.Default),
memoMap, memoMap,
), ),
// biome-ignore lint/style/noNonNullAssertion: context initialization // biome-ignore lint/style/noNonNullAssertion: context initialization
@@ -66,20 +54,16 @@ export const Provider = <R, ER>(
) )
} }
const ProviderInner = <R, ER>( interface ProviderInnerProps<R, ER> {
{ runtime, promise, children }: { readonly runtime: ReactRuntime<R, ER>
readonly runtime: ReactRuntime<R, ER> readonly promise: Promise<Runtime.Runtime<R>>
readonly promise: Promise<Runtime.Runtime<R>> readonly children?: React.ReactNode
readonly children?: React.ReactNode
}
): React.ReactNode => {
const effectRuntime = React.use(promise)
const scope = Runtime.runSync(effectRuntime)(Component.useScope([effectRuntime]))
Runtime.runSync(effectRuntime)(Effect.provideService(
Component.useOnChange(() => Effect.addFinalizer(() => runtime.runtime.disposeEffect), [scope]),
Scope.Scope,
scope,
))
return React.createElement(runtime.context, { value: effectRuntime }, children)
} }
const ProviderInner = <R, ER>(
{ runtime, promise, children }: ProviderInnerProps<R, ER>
): React.ReactNode => React.createElement(
runtime.context,
{ value: React.use(promise) },
children,
)
+157 -173
View File
@@ -1,5 +1,4 @@
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, type Subscribable, SynchronizedRef } from "effect" import { Cause, Context, Data, Effect, Equal, Exit, Hash, Layer, Match, Option, Pipeable, Predicate, pipe, Queue, Ref, type Scope, type Subscribable, SubscriptionRef } from "effect"
import { Lens } from "effect-lens"
export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result") export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result")
@@ -8,150 +7,130 @@ export type ResultTypeId = typeof ResultTypeId
export type Result<A, E = never, P = never> = ( export type Result<A, E = never, P = never> = (
| Initial | Initial
| Running<P> | Running<P>
| Final<A, E, P> | Success<A>
| (Success<A> & Refreshing<P>)
| Failure<A, E>
| (Failure<A, E> & Refreshing<P>)
) )
// biome-ignore lint/complexity/noBannedTypes: "{}" is relevant here export namespace Result {
export type Final<A, E = never, P = never> = (Success<A> | Failure<E>) & ({} | Flags<P>) export interface Prototype extends Pipeable.Pipeable, Equal.Equal {
export type Flags<P = never> = WillFetch | WillRefresh | Refreshing<P> readonly [ResultTypeId]: ResultTypeId
}
export declare namespace Result {
export type Success<R extends Result<any, any, any>> = [R] extends [Result<infer A, infer _E, infer _P>] ? A : never export type Success<R extends Result<any, any, any>> = [R] extends [Result<infer A, infer _E, infer _P>] ? A : never
export type Failure<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer E, infer _P>] ? E : never export type Failure<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer E, infer _P>] ? E : never
export type Progress<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer _E, infer P>] ? P : never export type Progress<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer _E, infer P>] ? P : never
} }
export declare namespace Flags { export interface Initial extends Result.Prototype {
export type Keys = keyof WillFetch & WillRefresh & Refreshing<any>
}
export interface Initial extends ResultPrototype {
readonly _tag: "Initial" readonly _tag: "Initial"
} }
export interface Running<P = never> extends ResultPrototype { export interface Running<P = never> extends Result.Prototype {
readonly _tag: "Running" readonly _tag: "Running"
readonly progress: P readonly progress: P
} }
export interface Success<A> extends ResultPrototype { export interface Success<A> extends Result.Prototype {
readonly _tag: "Success" readonly _tag: "Success"
readonly value: A readonly value: A
} }
export interface Failure<E = never> extends ResultPrototype { export interface Failure<A, E = never> extends Result.Prototype {
readonly _tag: "Failure" readonly _tag: "Failure"
readonly cause: Cause.Cause<E> readonly cause: Cause.Cause<E>
} readonly previousSuccess: Option.Option<Success<A>>
export interface WillFetch {
readonly _flag: "WillFetch"
}
export interface WillRefresh {
readonly _flag: "WillRefresh"
} }
export interface Refreshing<P = never> { export interface Refreshing<P = never> {
readonly _flag: "Refreshing" readonly refreshing: true
readonly progress: P readonly progress: P
} }
export interface ResultPrototype extends Pipeable.Pipeable, Equal.Equal { const ResultPrototype = Object.freeze({
readonly [ResultTypeId]: ResultTypeId
}
export const ResultPrototype: ResultPrototype = Object.freeze({
...Pipeable.Prototype, ...Pipeable.Prototype,
[ResultTypeId]: ResultTypeId, [ResultTypeId]: ResultTypeId,
[Equal.symbol](this: Result<any, any, any>, that: Result<any, any, any>): boolean { [Equal.symbol](this: Result<any, any, any>, that: Result<any, any, any>): boolean {
if (this._tag !== that._tag || (this as Flags)._flag !== (that as Flags)._flag) if (this._tag !== that._tag)
return false
if (hasRefreshingFlag(this) && !Equal.equals(this.progress, (that as Refreshing<any>).progress))
return false return false
return Match.value(this).pipe( return Match.value(this).pipe(
Match.tag("Initial", () => true), Match.tag("Initial", () => true),
Match.tag("Running", self => Equal.equals(self.progress, (that as Running<any>).progress)), Match.tag("Running", self => Equal.equals(self.progress, (that as Running<any>).progress)),
Match.tag("Success", self => Equal.equals(self.value, (that as Success<any>).value)), Match.tag("Success", self =>
Match.tag("Failure", self => Equal.equals(self.cause, (that as Failure<any>).cause)), Equal.equals(self.value, (that as Success<any>).value) &&
(isRefreshing(self) ? self.refreshing : false) === (isRefreshing(that) ? that.refreshing : false) &&
Equal.equals(isRefreshing(self) ? self.progress : undefined, isRefreshing(that) ? that.progress : undefined)
),
Match.tag("Failure", self =>
Equal.equals(self.cause, (that as Failure<any, any>).cause) &&
(isRefreshing(self) ? self.refreshing : false) === (isRefreshing(that) ? that.refreshing : false) &&
Equal.equals(isRefreshing(self) ? self.progress : undefined, isRefreshing(that) ? that.progress : undefined)
),
Match.exhaustive, Match.exhaustive,
) )
}, },
[Hash.symbol](this: Result<any, any, any>): number { [Hash.symbol](this: Result<any, any, any>): number {
return pipe(Hash.string(this._tag), const tagHash = Hash.string(this._tag)
tagHash => Match.value(this).pipe(
Match.tag("Initial", () => tagHash), return Match.value(this).pipe(
Match.tag("Running", self => Hash.combine(Hash.hash(self.progress))(tagHash)), Match.tag("Initial", () => tagHash),
Match.tag("Success", self => Hash.combine(Hash.hash(self.value))(tagHash)), Match.tag("Running", self => Hash.combine(Hash.hash(self.progress))(tagHash)),
Match.tag("Failure", self => Hash.combine(Hash.hash(self.cause))(tagHash)), Match.tag("Success", self => pipe(tagHash,
Match.exhaustive, Hash.combine(Hash.hash(self.value)),
), Hash.combine(Hash.hash(isRefreshing(self) ? self.progress : undefined)),
Hash.combine(Hash.hash((this as Flags)._flag)), )),
hash => hasRefreshingFlag(this) Match.tag("Failure", self => pipe(tagHash,
? Hash.combine(Hash.hash(this.progress))(hash) Hash.combine(Hash.hash(self.cause)),
: hash, Hash.combine(Hash.hash(isRefreshing(self) ? self.progress : undefined)),
)),
Match.exhaustive,
Hash.cached(this), Hash.cached(this),
) )
}, },
} as const) } as const satisfies Result.Prototype)
export const isResult = (u: unknown): u is Result<unknown, unknown, unknown> => Predicate.hasProperty(u, ResultTypeId) export const isResult = (u: unknown): u is Result<unknown, unknown, unknown> => Predicate.hasProperty(u, ResultTypeId)
export const isFinal = (u: unknown): u is Final<unknown, unknown, unknown> => isResult(u) && (isSuccess(u) || isFailure(u))
export const isInitial = (u: unknown): u is Initial => isResult(u) && u._tag === "Initial" export const isInitial = (u: unknown): u is Initial => isResult(u) && u._tag === "Initial"
export const isRunning = (u: unknown): u is Running<unknown> => isResult(u) && u._tag === "Running" export const isRunning = (u: unknown): u is Running<unknown> => isResult(u) && u._tag === "Running"
export const isSuccess = (u: unknown): u is Success<unknown> => isResult(u) && u._tag === "Success" export const isSuccess = (u: unknown): u is Success<unknown> => isResult(u) && u._tag === "Success"
export const isFailure = (u: unknown): u is Failure<unknown> => isResult(u) && u._tag === "Failure" export const isFailure = (u: unknown): u is Failure<unknown, unknown> => isResult(u) && u._tag === "Failure"
export const hasFlag = (u: unknown): u is Flags => isResult(u) && Predicate.hasProperty(u, "_flag") export const isRefreshing = (u: unknown): u is Refreshing<unknown> => isResult(u) && Predicate.hasProperty(u, "refreshing") && u.refreshing
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<unknown> => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "Refreshing"
export const initial: { export const initial = (): Initial => Object.setPrototypeOf({ _tag: "Initial" }, ResultPrototype)
(): Initial
<A, E = never, P = never>(): Result<A, E, P>
} = (): Initial => Object.setPrototypeOf({ _tag: "Initial" }, ResultPrototype)
export const running = <P = never>(progress?: P): Running<P> => Object.setPrototypeOf({ _tag: "Running", progress }, ResultPrototype) export const running = <P = never>(progress?: P): Running<P> => Object.setPrototypeOf({ _tag: "Running", progress }, ResultPrototype)
export const succeed = <A>(value: A): Success<A> => Object.setPrototypeOf({ _tag: "Success", value }, ResultPrototype) export const succeed = <A>(value: A): Success<A> => Object.setPrototypeOf({ _tag: "Success", value }, ResultPrototype)
export const fail = <E>(cause: Cause.Cause<E> ): Failure<E> => Object.setPrototypeOf({ _tag: "Failure", cause }, ResultPrototype)
export const willFetch = <R extends Final<any, any, any>>( export const fail = <E, A = never>(
result: R cause: Cause.Cause<E>,
): Omit<R, keyof Flags.Keys> & WillFetch => Object.setPrototypeOf( previousSuccess?: Success<A>,
Object.assign({}, result, { _flag: "WillFetch" }), ): Failure<A, E> => Object.setPrototypeOf({
Object.getPrototypeOf(result), _tag: "Failure",
) cause,
previousSuccess: Option.fromNullable(previousSuccess),
export const willRefresh = <R extends Final<any, any, any>>( }, ResultPrototype)
result: R export const refreshing = <R extends Success<any> | Failure<any, any>, P = never>(
): Omit<R, keyof Flags.Keys> & WillRefresh => Object.setPrototypeOf(
Object.assign({}, result, { _flag: "WillRefresh" }),
Object.getPrototypeOf(result),
)
export const refreshing = <R extends Final<any, any, any>, P = never>(
result: R, result: R,
progress?: P, progress?: P,
): Omit<R, keyof Flags.Keys> & Refreshing<P> => Object.setPrototypeOf( ): Omit<R, keyof Refreshing<Result.Progress<R>>> & Refreshing<P> => Object.setPrototypeOf(
Object.assign({}, result, { _flag: "Refreshing", progress }), Object.assign({}, result, { progress }),
Object.getPrototypeOf(result), Object.getPrototypeOf(result),
) )
export const fromExit: { export const fromExit = <A, E>(
<A, E>(exit: Exit.Success<A, E>): Success<A> exit: Exit.Exit<A, E>
<A, E>(exit: Exit.Failure<A, E>): Failure<E> ): Success<A> | Failure<A, E> => exit._tag === "Success"
<A, E>(exit: Exit.Exit<A, E>): Success<A> | Failure<E> ? succeed(exit.value)
} = exit => (exit._tag === "Success" ? succeed(exit.value) : fail(exit.cause)) as any : fail(exit.cause)
export const toExit: { export const toExit = <A, E, P>(
<A>(self: Success<A>): Exit.Success<A, never> self: Result<A, E, P>
<E>(self: Failure<E>): Exit.Failure<never, E> ): Exit.Exit<A, E | Cause.NoSuchElementException> => {
<A, E, P>(self: Final<A, E, P>): Exit.Exit<A, E>
<A, E, P>(self: Result<A, E, P>): Exit.Exit<A, E | Cause.NoSuchElementException>
} = <A, E, P>(self: Result<A, E, P>): any => {
switch (self._tag) { switch (self._tag) {
case "Success": case "Success":
return Exit.succeed(self.value) return Exit.succeed(self.value)
@@ -163,107 +142,112 @@ export const toExit: {
} }
export interface Progress<P = never> { export interface State<A, E = never, P = never> {
readonly progress: Lens.Lens<P, PreviousResultNotRunningNorRefreshing, never, never, never> readonly get: Effect.Effect<Result<A, E, P>>
readonly set: (v: Result<A, E, P>) => Effect.Effect<void>
}
export const State = <A, E = never, P = never>(): Context.Tag<State<A, E, P>, State<A, E, P>> => Context.GenericTag("@effect-fc/Result/State")
export interface Progress<P = never> {
readonly update: <E, R>(
f: (previous: P) => Effect.Effect<P, E, R>
) => Effect.Effect<void, PreviousResultNotRunningNorRefreshing | E, R>
} }
export const Progress = <P = never>(): Context.Tag<Progress<P>, Progress<P>> => Context.GenericTag("@effect-fc/Result/Progress")
export class PreviousResultNotRunningNorRefreshing extends Data.TaggedError("@effect-fc/Result/PreviousResultNotRunningNorRefreshing")<{ export class PreviousResultNotRunningNorRefreshing extends Data.TaggedError("@effect-fc/Result/PreviousResultNotRunningNorRefreshing")<{
readonly previous: Result<unknown, unknown, unknown> readonly previous: Result<unknown, unknown, unknown>
}> {} }> {}
export const makeProgressLayer = <A, E, P = never>( export const Progress = <P = never>(): Context.Tag<Progress<P>, Progress<P>> => Context.GenericTag("@effect-fc/Result/Progress")
state: Lens.Lens<Result<A, E, P>, never, never, never, never>
): Layer.Layer<Progress<P> | Progress<never>, never, never> => Layer.succeed( export const makeProgressLayer = <A, E, P = never>(): Layer.Layer<
Progress<P>() as Context.Tag<Progress<P> | Progress<never>, Progress<P> | Progress<never>>, Progress<P>,
{ never,
progress: state.pipe( State<A, E, P>
Lens.mapEffect( > => Layer.effect(Progress<P>(), Effect.gen(function*() {
a => (isRunning(a) || hasRefreshingFlag(a)) const state = yield* State<A, E, P>()
? Effect.succeed(a)
: Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous: a })), return {
(_, b) => Effect.succeed(b), update: <E, R>(f: (previous: P) => Effect.Effect<P, E, R>) => Effect.Do.pipe(
), Effect.bind("previous", () => Effect.andThen(state.get, previous =>
Lens.map( isRunning(previous) || isRefreshing(previous)
a => a.progress, ? Effect.succeed(previous)
(a, b) => isRunning(a) : Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous })),
? running(b) )),
: refreshing(a, b) as Final<A, E, P> & Refreshing<P>, Effect.bind("progress", ({ previous }) => f(previous.progress)),
), Effect.let("next", ({ previous, progress }) => Object.setPrototypeOf(
) Object.assign({}, previous, { progress }),
}, Object.getPrototypeOf(previous),
) )),
Effect.andThen(({ next }) => state.set(next)),
),
}
}))
export namespace unsafeForkEffect { export namespace forkEffect {
export type OutputContext<R, P> = Exclude<R, Progress<P> | Progress<never>> export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R
export type OutputContext<R> = Scope.Scope | Exclude<R, Progress<any> | Progress<never>>
export interface Options<A, E, P> { export interface Options<P> {
readonly initial?: Initial | Final<A, E, P>
readonly initialProgress?: P readonly initialProgress?: P
} }
} }
export const unsafeForkEffect = Effect.fnUntraced(function* <A, E, R, P = never>( export const forkEffect = <A, E, R, P = never>(
effect: Effect.Effect<A, E, R>, effect: Effect.Effect<A, E, forkEffect.InputContext<R, NoInfer<P>>>,
options?: unsafeForkEffect.Options<NoInfer<A>, NoInfer<E>, P>, options?: forkEffect.Options<P>,
): Effect.fn.Return< ): Effect.Effect<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>], Subscribable.Subscribable<Result<A, E, P>>,
never, never,
Scope.Scope | unsafeForkEffect.OutputContext<R, P> forkEffect.OutputContext<R>
> { > => Effect.tap(
const ref = (yield* SynchronizedRef.make( SubscriptionRef.make<Result<A, E, P>>(initial()),
options?.initial ?? initial<A, E, P>() ref => Effect.forkScoped(State<A, E, P>().pipe(
)) as Lens.SynchronizedRefLensImpl.SynchronizedRefWithInternals<Result<A, E, P>> Effect.andThen(state => state.set(running(options?.initialProgress)).pipe(
const pubsub = yield* PubSub.unbounded<Result<A, E, P>>() Effect.andThen(effect),
Effect.onExit(exit => state.set(fromExit(exit))),
)),
Effect.provide(Layer.empty.pipe(
Layer.provideMerge(makeProgressLayer<A, E, P>()),
Layer.provideMerge(Layer.succeed(State<A, E, P>(), {
get: ref,
set: v => Ref.set(ref, v),
})),
)),
)),
) as Effect.Effect<Subscribable.Subscribable<Result<A, E, P>>, never, Scope.Scope>
const state = Lens.make({ export namespace forkEffectDequeue {
get: Ref.get(ref.ref), export type InputContext<R, P> = forkEffect.InputContext<R, P>
get changes() { export type OutputContext<R> = forkEffect.OutputContext<R>
return Stream.unwrapScoped(Effect.map( export interface Options<P> extends forkEffect.Options<P> {}
Effect.all([Ref.get(ref.ref), Stream.fromPubSub(pubsub, { scoped: true })]),
([latest, stream]) => Stream.concat(Stream.make(latest), stream),
))
},
commit: value => Effect.zipLeft(
Ref.set(ref.ref, value),
PubSub.publish(pubsub, value),
),
lock: Effect.succeed(ref.withLock),
})
const fiber = yield* Effect.gen(function*() {
yield* Lens.set(
state,
(isFinal(options?.initial) && hasWillRefreshFlag(options?.initial))
? refreshing(options.initial, options?.initialProgress) as Result<A, E, P>
: running(options?.initialProgress),
)
return yield* Effect.onExit(effect, exit => Effect.andThen(
Lens.set(state, fromExit(exit)),
Effect.forkScoped(PubSub.shutdown(pubsub)),
))
}).pipe(
Effect.forkScoped,
Effect.provide(makeProgressLayer(state)),
)
return [state, fiber] as const
})
export namespace forkEffect {
export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R
export type OutputContext<R, P> = unsafeForkEffect.OutputContext<R, P>
export interface Options<A, E, P> extends unsafeForkEffect.Options<A, E, P> {}
} }
export const forkEffect: { export const forkEffectDequeue = <A, E, R, P = never>(
<A, E, R, P = never>( effect: Effect.Effect<A, E, forkEffectDequeue.InputContext<R, NoInfer<P>>>,
effect: Effect.Effect<A, E, forkEffect.InputContext<R, NoInfer<P>>>, options?: forkEffectDequeue.Options<P>,
options?: forkEffect.Options<NoInfer<A>, NoInfer<E>, P>, ): Effect.Effect<
): Effect.Effect< Queue.Dequeue<Result<A, E, P>>,
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>], never,
never, forkEffectDequeue.OutputContext<R>
Scope.Scope | forkEffect.OutputContext<R, P> > => Effect.all([
> Ref.make<Result<A, E, P>>(initial()),
} = unsafeForkEffect Queue.unbounded<Result<A, E, P>>(),
]).pipe(
Effect.tap(([ref, queue]) => Effect.forkScoped(State<A, E, P>().pipe(
Effect.andThen(state => state.set(running(options?.initialProgress)).pipe(
Effect.andThen(effect),
Effect.onExit(exit => Effect.andThen(state.set(fromExit(exit)), Queue.shutdown(queue))),
)),
Effect.provide(Layer.empty.pipe(
Layer.provideMerge(makeProgressLayer<A, E, P>()),
Layer.provideMerge(Layer.succeed(State<A, E, P>(), {
get: ref,
set: v => Effect.andThen(Ref.set(ref, v), Queue.offer(queue, v))
})),
)),
))),
Effect.map(([, queue]) => queue),
) as Effect.Effect<Queue.Dequeue<Result<A, E, P>>, never, Scope.Scope>
+1 -1
View File
@@ -3,8 +3,8 @@ import type * as React from "react"
export const value: { export const value: {
<S>(self: React.SetStateAction<S>, prevState: S): S
<S>(prevState: S): (self: React.SetStateAction<S>) => S <S>(prevState: S): (self: React.SetStateAction<S>) => S
<S>(self: React.SetStateAction<S>, prevState: S): S
} = Function.dual(2, <S>(self: React.SetStateAction<S>, prevState: S): S => } = Function.dual(2, <S>(self: React.SetStateAction<S>, prevState: S): S =>
typeof self === "function" typeof self === "function"
? (self as (prevState: S) => S)(prevState) ? (self as (prevState: S) => S)(prevState)
+27 -2
View File
@@ -1,9 +1,9 @@
import { Effect, Equivalence, Option, Stream } from "effect" import { Effect, Equivalence, Option, PubSub, Ref, type Scope, Stream } from "effect"
import * as React from "react" import * as React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
export const use: { export const useStream: {
<A, E, R>( <A, E, R>(
stream: Stream.Stream<A, E, R> stream: Stream.Stream<A, E, R>
): Effect.Effect<Option.Option<A>, never, R> ): Effect.Effect<Option.Option<A>, never, R>
@@ -30,4 +30,29 @@ export const use: {
return reactStateValue as Option.Some<A> return reactStateValue as Option.Some<A>
}) })
export const useStreamFromReactiveValues: {
<const A extends React.DependencyList>(
values: A
): Effect.Effect<Stream.Stream<A>, never, Scope.Scope>
} = Effect.fnUntraced(function* <const A extends React.DependencyList>(values: A) {
const { latest, pubsub, stream } = yield* Component.useOnMount(() => Effect.Do.pipe(
Effect.bind("latest", () => Ref.make(values)),
Effect.bind("pubsub", () => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown)),
Effect.let("stream", ({ latest, pubsub }) => latest.pipe(
Effect.flatMap(a => Effect.map(
Stream.fromPubSub(pubsub, { scoped: true }),
s => Stream.concat(Stream.make(a), s),
)),
Stream.unwrapScoped,
)),
))
yield* Component.useReactEffect(() => Ref.set(latest, values).pipe(
Effect.andThen(PubSub.publish(pubsub, values)),
Effect.unlessEffect(PubSub.isShutdown(pubsub)),
), values)
return stream
})
export * from "effect/Stream" export * from "effect/Stream"
-220
View File
@@ -1,220 +0,0 @@
import { Array, Cause, Chunk, type Context, Effect, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Schema, type Scope, 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"
export const SubmittableFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SubmittableForm")
export type SubmittableFormTypeId = typeof SubmittableFormTypeId
export interface SubmittableForm<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Form.Form<readonly [], A, I, never, never> {
readonly [SubmittableFormTypeId]: SubmittableFormTypeId
readonly schema: Schema.Schema<A, I, R>
readonly context: Context.Context<Scope.Scope | R>
readonly mutation: Mutation.Mutation<
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP
>
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never>
readonly run: Effect.Effect<void>
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException>
}
export class SubmittableFormImpl<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Pipeable.Class() implements SubmittableForm<A, I, R, MA, ME, MR, MP> {
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
readonly [SubmittableFormTypeId]: SubmittableFormTypeId = SubmittableFormTypeId
readonly path = [] as const
readonly encodedValue: Lens.Lens<I, never, never, never, never>
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
constructor(
readonly schema: Schema.Schema<A, I, R>,
readonly context: Context.Context<Scope.Scope | R>,
readonly mutation: Mutation.Mutation<
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP
>,
readonly value: Lens.Lens<Option.Option<A>, never, never, never, never>,
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
readonly issues: Lens.Lens<readonly ParseResult.ArrayFormatterIssue[], never, never, never, never>,
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never, never, never>,
readonly runSemaphore: Effect.Semaphore,
) {
super()
this.encodedValue = Effect.all([
Effect.succeed(this),
Effect.succeed(Lens.asLensImpl(this.internalEncodedValue)),
]).pipe(
Effect.map(([self, parent]) => Lens.make({
get: parent.get,
get changes() { return parent.changes },
commit: a => Effect.andThen(
Effect.flatMap(
parent.resolve,
resolved => resolved.commit(Effect.succeed(a)),
),
self.synchronizeEncodedValue(a),
),
lock: parent.lock,
})),
Lens.unwrap,
)
this.isValidating = Effect.succeed(this).pipe(
Effect.map(self => Subscribable.map(self.validationFiber, Option.isSome)),
Subscribable.unwrap,
)
this.canCommit = Effect.succeed(this).pipe(
Effect.map(self => Subscribable.map(
Subscribable.zipLatestAll(self.value, self.issues, self.validationFiber, self.mutation.result),
([value, issues, validationFiber, result]) => (
Option.isSome(value) &&
Array.isEmptyReadonlyArray(issues) &&
Option.isNone(validationFiber) &&
!(Result.isRunning(result) || Result.hasRefreshingFlag(result))
),
)),
Subscribable.unwrap,
)
this.isCommitting = Effect.succeed(this).pipe(
Effect.map(self => Subscribable.map(
self.mutation.result,
result => Result.isRunning(result) || Result.hasRefreshingFlag(result),
)),
Subscribable.unwrap,
)
}
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, never, never> {
return Lens.get(this.validationFiber).pipe(
Effect.andThen(Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
})),
Effect.andThen(Effect.forkScoped(
Effect.ensuring(
Schema.decode(this.schema, { errors: "all" })(encodedValue),
Lens.set(this.validationFiber, Option.none()),
)
)),
Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))),
Effect.flatMap(Fiber.join),
Effect.tap(() => Lens.set(this.issues, Array.empty())),
Effect.flatMap(value => Lens.set(this.value, Option.some(value))),
Effect.catchIf(
ParseResult.isParseError,
flow(
ParseResult.ArrayFormatter.formatError,
Effect.flatMap(v => Lens.set(this.issues, v)),
),
),
Effect.provide(this.context),
)
}
get run(): Effect.Effect<void, never, never> {
return Lens.get(this.encodedValue).pipe(
Effect.flatMap(v => Schema.decode(this.schema)(v)),
Effect.option,
Effect.flatMap(v => Lens.set(this.value, v)),
Effect.provide(this.context),
this.runSemaphore.withPermits(1),
)
}
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException, never> {
return Lens.get(this.value).pipe(
Effect.flatMap(identity),
Effect.flatMap(value => this.submitValue(value)),
)
}
submitValue(value: A): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, never, never> {
return Effect.whenEffect(
Effect.tap(
this.mutation.mutate([value, this as any]),
result => Result.isFailure(result)
? Option.match(
Chunk.findFirst(
Cause.failures(result.cause as Cause.Cause<ParseResult.ParseError>),
e => e._tag === "ParseError",
),
{
onSome: e => Effect.flatMap(
ParseResult.ArrayFormatter.formatError(e),
v => Lens.set(this.issues, v),
),
onNone: () => Effect.void,
},
)
: Effect.void
),
this.canCommit.get,
)
}
}
export const isSubmittableForm = (u: unknown): u is SubmittableForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SubmittableFormTypeId)
export declare namespace make {
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Mutation.make.Options<
readonly [value: NoInfer<A>, form: SubmittableForm<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>],
MA, ME, MR, MP
> {
readonly schema: Schema.Schema<A, I, R>
readonly initialEncodedValue: NoInfer<I>
}
}
export const make = Effect.fnUntraced(function* <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
options: make.Options<A, I, R, MA, ME, MR, MP>
): Effect.fn.Return<
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
never,
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
> {
return new SubmittableFormImpl(
options.schema,
yield* Effect.context<Scope.Scope | R>(),
yield* Mutation.make(options),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<A>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly ParseResult.ArrayFormatterIssue[]>(Array.empty())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>())),
yield* Effect.makeSemaphore(1),
)
})
export declare namespace service {
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends make.Options<A, I, R, MA, ME, MR, MP> {}
}
export const service = <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
options: service.Options<A, I, R, MA, ME, MR, MP>
): Effect.Effect<
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
never,
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
> => Effect.tap(
make(options),
form => Effect.forkScoped(form.run),
)
+20 -26
View File
@@ -1,11 +1,8 @@
import { Effect, Equivalence, Stream } from "effect" import { Effect, Equivalence, pipe, type Scope, Stream, Subscribable } from "effect"
import { Subscribable } from "effect-lens"
import * as React from "react" import * as React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
export * from "effect-lens/Subscribable"
export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<any, any, any>[]>( export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
...elements: T ...elements: T
): Subscribable.Subscribable< ): Subscribable.Subscribable<
@@ -19,35 +16,32 @@ export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<
changes: Stream.zipLatestAll(...elements.map(v => v.changes)), changes: Stream.zipLatestAll(...elements.map(v => v.changes)),
}) as any }) as any
export declare namespace useAll { export const useSubscribables: {
export type Success<T extends readonly Subscribable.Subscribable<any, any, any>[]> = [T[number]] extends [never] <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
? never ...elements: T
: { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never } ): Effect.Effect<
[T[number]] extends [never]
export interface Options<A> { ? never
readonly equivalence?: Equivalence.Equivalence<A> : { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never },
} [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer E, infer _R> ? E : never,
} ([T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never) | Scope.Scope
>
export const useAll = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>( } = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
elements: T, ...elements: T
options?: useAll.Options<useAll.Success<NoInfer<T>>>, ) {
): Effect.fn.Return<
useAll.Success<T>,
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer E, infer _R> ? E : never,
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never
> {
const [reactStateValue, setReactStateValue] = React.useState( const [reactStateValue, setReactStateValue] = React.useState(
yield* Component.useOnMount(() => Effect.all(elements.map(v => v.get))) yield* Component.useOnMount(() => Effect.all(elements.map(v => v.get)))
) )
yield* Component.useReactEffect(() => Stream.zipLatestAll(...elements.map(ref => ref.changes)).pipe( yield* Component.useReactEffect(() => Effect.forkScoped(pipe(
Stream.changesWith((options?.equivalence as Equivalence.Equivalence<any[]> | undefined) ?? Equivalence.array(Equivalence.strict())), elements.map(ref => Stream.changesWith(ref.changes, Equivalence.strict())),
streams => Stream.zipLatestAll(...streams),
Stream.runForEach(v => Stream.runForEach(v =>
Effect.sync(() => setReactStateValue(v)) Effect.sync(() => setReactStateValue(v))
), ),
Effect.forkScoped, )), elements)
), elements)
return reactStateValue as any return reactStateValue as any
}) })
export * from "effect/Subscribable"
+67
View File
@@ -0,0 +1,67 @@
import { Effect, Equivalence, Ref, type Scope, Stream, SubscriptionRef } from "effect"
import * as React from "react"
import * as Component from "./Component.js"
import * as SetStateAction from "./SetStateAction.js"
export namespace useSubscriptionRefState {
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useSubscriptionRefState: {
<A>(
ref: SubscriptionRef.SubscriptionRef<A>,
options?: useSubscriptionRefState.Options<NoInfer<A>>,
): Effect.Effect<readonly [A, React.Dispatch<React.SetStateAction<A>>], never, Scope.Scope>
} = Effect.fnUntraced(function* <A>(
ref: SubscriptionRef.SubscriptionRef<A>,
options?: useSubscriptionRefState.Options<NoInfer<A>>,
) {
const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => ref))
yield* Component.useReactEffect(() => Effect.forkScoped(
Stream.runForEach(
Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()),
v => Effect.sync(() => setReactStateValue(v)),
)
), [ref])
const setValue = yield* Component.useCallbackSync((setStateAction: React.SetStateAction<A>) =>
Effect.andThen(
Ref.updateAndGet(ref, prevState => SetStateAction.value(setStateAction, prevState)),
v => setReactStateValue(v),
),
[ref])
return [reactStateValue, setValue]
})
export namespace useSubscriptionRefFromState {
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useSubscriptionRefFromState: {
<A>(
state: readonly [A, React.Dispatch<React.SetStateAction<A>>],
options?: useSubscriptionRefFromState.Options<NoInfer<A>>,
): Effect.Effect<SubscriptionRef.SubscriptionRef<A>, never, Scope.Scope>
} = Effect.fnUntraced(function*([value, setValue], options) {
const ref = yield* Component.useOnChange(() => Effect.tap(
SubscriptionRef.make(value),
ref => Effect.forkScoped(
Stream.runForEach(
Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()),
v => Effect.sync(() => setValue(v)),
)
),
), [setValue])
yield* Component.useReactEffect(() => Ref.set(ref, value), [value])
return ref
})
export * from "effect/SubscriptionRef"
@@ -0,0 +1,186 @@
import { Chunk, Effect, Effectable, Option, Predicate, Readable, Ref, Stream, Subscribable, SubscriptionRef, SynchronizedRef, type Types, type Unify } from "effect"
import * as PropertyPath from "./PropertyPath.js"
export const SubscriptionSubRefTypeId: unique symbol = Symbol.for("@effect-fc/SubscriptionSubRef/SubscriptionSubRef")
export type SubscriptionSubRefTypeId = typeof SubscriptionSubRefTypeId
export interface SubscriptionSubRef<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
extends SubscriptionSubRef.Variance<A, B>, SubscriptionRef.SubscriptionRef<A> {
readonly parent: B
readonly [Unify.typeSymbol]?: unknown
readonly [Unify.unifySymbol]?: SubscriptionSubRefUnify<this>
readonly [Unify.ignoreSymbol]?: SubscriptionSubRefUnifyIgnore
}
export declare namespace SubscriptionSubRef {
export interface Variance<in out A, in out B> {
readonly [SubscriptionSubRefTypeId]: {
readonly _A: Types.Invariant<A>
readonly _B: Types.Invariant<B>
}
}
}
export interface SubscriptionSubRefUnify<A extends { [Unify.typeSymbol]?: any }> extends SubscriptionRef.SubscriptionRefUnify<A> {
SubscriptionSubRef?: () => Extract<A[Unify.typeSymbol], SubscriptionSubRef<any, any>>
}
export interface SubscriptionSubRefUnifyIgnore extends SubscriptionRef.SubscriptionRefUnifyIgnore {
SubscriptionRef?: true
}
const refVariance = { _A: (_: any) => _ }
const synchronizedRefVariance = { _A: (_: any) => _ }
const subscriptionRefVariance = { _A: (_: any) => _ }
const subscriptionSubRefVariance = { _A: (_: any) => _, _B: (_: any) => _ }
class SubscriptionSubRefImpl<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
extends Effectable.Class<A> implements SubscriptionSubRef<A, B> {
readonly [Readable.TypeId]: Readable.TypeId = Readable.TypeId
readonly [Subscribable.TypeId]: Subscribable.TypeId = Subscribable.TypeId
readonly [Ref.RefTypeId] = refVariance
readonly [SynchronizedRef.SynchronizedRefTypeId] = synchronizedRefVariance
readonly [SubscriptionRef.SubscriptionRefTypeId] = subscriptionRefVariance
readonly [SubscriptionSubRefTypeId] = subscriptionSubRefVariance
readonly get: Effect.Effect<A>
constructor(
readonly parent: B,
readonly getter: (parentValue: Effect.Effect.Success<B>) => A,
readonly setter: (parentValue: Effect.Effect.Success<B>, value: A) => Effect.Effect.Success<B>,
) {
super()
this.get = Effect.map(this.parent, this.getter)
}
commit() {
return this.get
}
get changes(): Stream.Stream<A> {
return Stream.unwrap(
Effect.map(this.get, a => Stream.concat(
Stream.make(a),
Stream.map(this.parent.changes, this.getter),
))
)
}
modify<C>(f: (a: A) => readonly [C, A]): Effect.Effect<C> {
return this.modifyEffect(a => Effect.succeed(f(a)))
}
modifyEffect<C, E, R>(f: (a: A) => Effect.Effect<readonly [C, A], E, R>): Effect.Effect<C, E, R> {
return Effect.Do.pipe(
Effect.bind("b", (): Effect.Effect<Effect.Effect.Success<B>> => this.parent),
Effect.bind("ca", ({ b }) => f(this.getter(b))),
Effect.tap(({ b, ca: [, a] }) => SubscriptionRef.set(this.parent, this.setter(b, a))),
Effect.map(({ ca: [c] }) => c),
)
}
}
export const isSubscriptionSubRef = (u: unknown): u is SubscriptionSubRef<unknown, SubscriptionRef.SubscriptionRef<unknown>> => Predicate.hasProperty(u, SubscriptionSubRefTypeId)
export const makeFromGetSet = <A, B extends SubscriptionRef.SubscriptionRef<any>>(
parent: B,
options: {
readonly get: (parentValue: Effect.Effect.Success<B>) => A
readonly set: (parentValue: Effect.Effect.Success<B>, value: A) => Effect.Effect.Success<B>
},
): SubscriptionSubRef<A, B> => new SubscriptionSubRefImpl(parent, options.get, options.set)
export const makeFromPath = <
B extends SubscriptionRef.SubscriptionRef<any>,
const P extends PropertyPath.Paths<Effect.Effect.Success<B>>,
>(
parent: B,
path: P,
): SubscriptionSubRef<PropertyPath.ValueFromPath<Effect.Effect.Success<B>, P>, B> => new SubscriptionSubRefImpl(
parent,
parentValue => Option.getOrThrow(PropertyPath.get(parentValue, path)),
(parentValue, value) => Option.getOrThrow(PropertyPath.immutableSet(parentValue, path, value)),
)
export const makeFromChunkIndex: {
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
parent: B,
index: number,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
B
>
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
parent: B,
index: number,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
B
>
} = (
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>,
index: number,
) => new SubscriptionSubRefImpl(
parent,
parentValue => Chunk.unsafeGet(parentValue, index),
(parentValue, value) => Chunk.replace(parentValue, index, value),
) as any
export const makeFromChunkFindFirst: {
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
parent: B,
findFirstPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
B
>
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
parent: B,
findFirstPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
B
>
} = (
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<never>>,
findFirstPredicate: Predicate.Predicate.Any,
) => new SubscriptionSubRefImpl(
parent,
parentValue => Option.getOrThrow(Chunk.findFirst(parentValue, findFirstPredicate)),
(parentValue, value) => Option.getOrThrow(Option.andThen(
Chunk.findFirstIndex(parentValue, findFirstPredicate),
index => Chunk.replace(parentValue, index, value),
)),
) as any
export const makeFromChunkFindLast: {
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
parent: B,
findLastPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
B
>
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
parent: B,
findLastPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
B
>
} = (
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<never>>,
findLastPredicate: Predicate.Predicate.Any,
) => new SubscriptionSubRefImpl(
parent,
parentValue => Option.getOrThrow(Chunk.findLast(parentValue, findLastPredicate)),
(parentValue, value) => Option.getOrThrow(Option.andThen(
Chunk.findLastIndex(parentValue, findLastPredicate),
index => Chunk.replace(parentValue, index, value),
)),
) as any
-223
View File
@@ -1,223 +0,0 @@
import { Array, type Context, Effect, Equal, Fiber, flow, Option, ParseResult, Pipeable, Predicate, Schema, type Scope, Stream, SubscriptionRef } from "effect"
import * as Form from "./Form.js"
import * as Lens from "./Lens.js"
import * as Subscribable from "./Subscribable.js"
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 [], A, I, TER, TER | TEW> {
readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId
readonly schema: Schema.Schema<A, I, R>
readonly context: Context.Context<Scope.Scope | R | TRR | TRW>
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never>
readonly run: Effect.Effect<void, TER>
}
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<A, I, R, TER, TEW, TRR, TRW> {
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId = SynchronizedFormTypeId
readonly path = [] as const
readonly value: Subscribable.Subscribable<Option.Option<A>, never, never>
readonly encodedValue: Lens.Lens<I, TER, TER | TEW, never, never>
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
constructor(
readonly schema: Schema.Schema<A, I, R>,
readonly context: Context.Context<Scope.Scope | R | TRR | TRW>,
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>,
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
readonly issues: Lens.Lens<readonly ParseResult.ArrayFormatterIssue[], never, never, never, never>,
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never, never, never>,
readonly isCommitting: Lens.Lens<boolean, never, never>,
readonly runSemaphore: Effect.Semaphore,
) {
super()
this.value = Effect.succeed(this).pipe(
Effect.map(self => Subscribable.make({
get: Effect.provide(Effect.option(self.target.get), self.context),
get changes() {
return Stream.provideContext(
self.target.changes.pipe(
Stream.map(Option.some),
Stream.catchAll(() => Stream.make(Option.none())),
),
self.context,
)
},
})),
Subscribable.unwrap,
)
this.encodedValue = Effect.all([
Effect.succeed(this),
Effect.succeed(Lens.asLensImpl(this.internalEncodedValue)),
]).pipe(
Effect.map(([self, parent]) => Lens.make<I, TER, TER | TEW, never, never>({
get: parent.get,
get changes() { return parent.changes },
commit: a => Effect.andThen(
Effect.flatMap(
parent.resolve,
resolved => resolved.commit(Effect.succeed(a)),
),
self.synchronizeEncodedValue(a),
),
lock: parent.lock,
})),
Lens.unwrap,
)
this.isValidating = Effect.succeed(this).pipe(
Effect.map(self => Subscribable.map(self.validationFiber, Option.isSome)),
Subscribable.unwrap,
)
this.canCommit = Effect.succeed(this).pipe(
Effect.map(self => Subscribable.map(
Subscribable.zipLatestAll(self.issues, self.validationFiber, self.isCommitting),
([issues, validationFiber, isCommitting]) => (
Array.isEmptyReadonlyArray(issues) &&
Option.isNone(validationFiber) &&
!isCommitting
),
)),
Subscribable.unwrap,
)
}
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, TER | TEW, never> {
return Lens.get(this.validationFiber).pipe(
Effect.andThen(Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
})),
Effect.andThen(Effect.forkScoped(
Effect.ensuring(
Schema.decode(this.schema, { errors: "all" })(encodedValue),
Lens.set(this.validationFiber, Option.none()),
)
)),
Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))),
Effect.flatMap(Fiber.join),
Effect.flatMap(value => Effect.ensuring(
Lens.set(this.isCommitting, true).pipe(
Effect.andThen(Lens.set(this.issues, Array.empty())),
Effect.andThen(Lens.set(this.target, value)),
),
Lens.set(this.isCommitting, false),
)),
Effect.catchIf(
ParseResult.isParseError,
flow(
ParseResult.ArrayFormatter.formatError,
Effect.flatMap(v => Lens.set(this.issues, v)),
),
),
Effect.provide(this.context),
)
}
get run(): Effect.Effect<void, TER, never> {
return this.runSemaphore.withPermits(1)(Effect.provide(
Stream.runForEach(
Stream.drop(this.target.changes, 1),
targetValue => Schema.encode(this.schema, { errors: "all" })(targetValue).pipe(
Effect.flatMap(encodedValue => Effect.whenEffect(
Effect.andThen(
Lens.set(this.issues, Array.empty()),
Lens.set(this.internalEncodedValue, encodedValue),
),
Effect.map(
Lens.get(this.internalEncodedValue),
currentEncodedValue => !Equal.equals(encodedValue, currentEncodedValue),
),
)),
Effect.ignore,
),
),
this.context,
))
}
}
export const isSynchronizedForm = (u: unknown): u is SynchronizedForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SynchronizedFormTypeId)
export declare namespace make {
export interface Options<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> {
readonly schema: Schema.Schema<A, I, R>
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
readonly initialEncodedValue?: NoInfer<I>
}
}
export const make = Effect.fnUntraced(function* <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: make.Options<A, I, R, TER, TEW, TRR, TRW>
): Effect.fn.Return<
SynchronizedForm<A, I, R, TER, TEW, TRR, TRW>,
ParseResult.ParseError | TER,
Scope.Scope | R | TRR | TRW
> {
const initialEncodedValue = options.initialEncodedValue !== undefined
? options.initialEncodedValue
: yield* Effect.flatMap(
Lens.get(options.target),
Schema.encode(options.schema, { errors: "all" }),
)
return new SynchronizedFormImpl(
options.schema,
yield* Effect.context<Scope.Scope | R | TRR | TRW>(),
options.target,
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(initialEncodedValue)),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly ParseResult.ArrayFormatterIssue[]>(Array.empty())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(false)),
yield* Effect.makeSemaphore(1),
)
})
export declare namespace service {
export interface Options<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 make.Options<A, I, R, TER, TEW, TRR, TRW> {}
}
export const service = <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: service.Options<A, I, R, TER, TEW, TRR, TRW>
): Effect.Effect<
SynchronizedForm<A, I, R, TER, TEW, TRR, TRW>,
ParseResult.ParseError | TER,
Scope.Scope | R | TRR | TRW
> => Effect.tap(
make(options),
form => Effect.forkScoped(form.run),
)
+3 -8
View File
@@ -1,17 +1,12 @@
export * as Async from "./Async.js" export * as Async from "./Async.js"
export * as Component from "./Component.js" export * as Component from "./Component.js"
export * as ErrorObserver from "./ErrorObserver.js"
export * as Form from "./Form.js" export * as Form from "./Form.js"
export * as Lens from "./Lens.js"
export * as Memoized from "./Memoized.js" export * as Memoized from "./Memoized.js"
export * as Mutation from "./Mutation.js" export * as PropertyPath from "./PropertyPath.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 ReactRuntime from "./ReactRuntime.js"
export * as Result from "./Result.js" export * as Result from "./Result.js"
export * as SetStateAction from "./SetStateAction.js" export * as SetStateAction from "./SetStateAction.js"
export * as Stream from "./Stream.js" export * as Stream from "./Stream.js"
export * as SubmittableForm from "./SubmittableForm.js"
export * as Subscribable from "./Subscribable.js" export * as Subscribable from "./Subscribable.js"
export * as SynchronizedForm from "./SynchronizedForm.js" export * as SubscriptionRef from "./SubscriptionRef.js"
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"
-354
View File
@@ -1,354 +0,0 @@
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.Tag("ValueService")<ValueService, { readonly value: string }>() {}
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 Effect.runPromise(runtime.runtime.runtimeEffect)
const Probe = Component.makeUntraced("UseOnMountProbe")(function*() {
const value = yield* Component.useOnMount(onMount)
return <div>{value}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("mounted")
expect(onMount).toHaveBeenCalledTimes(1)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
expect(await screen.findByText("mounted")).toBeTruthy()
expect(onMount).toHaveBeenCalledTimes(1)
view.unmount()
await Effect.runPromise(runtime.runtime.disposeEffect)
})
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 Effect.runPromise(runtime.runtime.runtimeEffect)
const Probe = Component.makeUntraced("UseOnChangeProbe")(function*(props: { readonly value: number }) {
const result = yield* Component.useOnChange(() => onChange(props.value), [props.value], {
finalizerExecutionDebounce: 0,
})
return <div>{result}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value={1} />
</runtime.context.Provider>
)
await screen.findByText("value:1")
expect(onChange).toHaveBeenCalledTimes(1)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value={1} />
</runtime.context.Provider>
)
expect(await screen.findByText("value:1")).toBeTruthy()
expect(onChange).toHaveBeenCalledTimes(1)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value={2} />
</runtime.context.Provider>
)
await screen.findByText("value:2")
expect(onChange).toHaveBeenCalledTimes(2)
view.unmount()
await Effect.runPromise(runtime.runtime.disposeEffect)
})
it("closes the previous scope on dependency changes and unmount", async () => {
const cleanup = vi.fn()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
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 <div>{result}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
expect(cleanup).not.toHaveBeenCalled()
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value="second" />
</runtime.context.Provider>
)
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 Effect.runPromise(runtime.runtime.disposeEffect)
})
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 Effect.runPromise(runtime.runtime.runtimeEffect)
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 <div>{props.value}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:first"))
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value="second" />
</runtime.context.Provider>
)
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 Effect.runPromise(runtime.runtime.disposeEffect)
})
it("keeps useCallbackSync stable until dependencies change", async () => {
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
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 <div>{callback(1)}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe prefix="a" />
</runtime.context.Provider>
)
await screen.findByText("a:1")
expect(seenCallbacks).toHaveLength(2)
expect(seenCallbacks[0]).toBe(seenCallbacks[1])
expect(seenCallbacks[0]?.(2)).toBe("a:2")
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe prefix="a" />
</runtime.context.Provider>
)
await screen.findByText("a:1")
expect(seenCallbacks).toHaveLength(2)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe prefix="b" />
</runtime.context.Provider>
)
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 Effect.runPromise(runtime.runtime.disposeEffect)
})
it("delays cleanup according to finalizerExecutionDebounce", async () => {
const cleanup = vi.fn()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
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 <div>{result}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value="second" />
</runtime.context.Provider>
)
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 Effect.runPromise(runtime.runtime.disposeEffect)
})
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 Effect.runPromise(runtime.runtime.runtimeEffect)
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 <div>{service.value}</div>
}).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 <Child />
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Parent value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
expect(mounts).toHaveBeenCalledTimes(1)
expect(unmounts).not.toHaveBeenCalled()
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Parent value="second" />
</runtime.context.Provider>
)
await screen.findByText("second")
expect(mounts).toHaveBeenCalledTimes(1)
expect(unmounts).not.toHaveBeenCalled()
view.unmount()
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
await Effect.runPromise(runtime.runtime.disposeEffect)
})
})
-165
View File
@@ -1,165 +0,0 @@
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 Effect.runPromise(runtime.runtime.runtimeEffect)
return {
runtime,
effectRuntime,
dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
}
}
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 (
<>
<div>{value}</div>
<button onClick={() => setValue(previous => previous + 1)}>increment</button>
</>
)
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
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 <div>{value.label}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
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<string, never, never, never, never> | 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 <button onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
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 <div>{value.label}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
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()
})
})
-169
View File
@@ -1,169 +0,0 @@
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 = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
Effect.runPromise(Effect.scoped(effect.pipe(
Effect.provide(QueryClient.QueryClient.Default),
)))
const expectSuccessValue = <A, E, P>(
result: Result.Result<A, E, P>,
): 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 = <A>(option: Option.Option<A>): 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<readonly [number]>
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<readonly [number]>
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<readonly [number]>
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<readonly [number]>
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<readonly [number]>
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")
})
})
@@ -1,129 +0,0 @@
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 Effect.runPromise(runtime.runtime.runtimeEffect)
return {
runtime,
effectRuntime,
dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
}
}
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<readonly [number, string]> = []
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"]))
Fiber.interruptFork(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 <div>{`${currentCount}:${currentLabel}`}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
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 <div>{`${currentItem.label}:${currentFlag ? "on" : "off"}`}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
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()
})
})
+1 -3
View File
@@ -25,7 +25,6 @@
"noPropertyAccessFromIndexSignature": false, "noPropertyAccessFromIndexSignature": false,
// Build // Build
"rootDir": "./src",
"outDir": "./dist", "outDir": "./dist",
"declaration": true, "declaration": true,
"sourceMap": true, "sourceMap": true,
@@ -35,6 +34,5 @@
] ]
}, },
"include": ["./src"], "include": ["./src"]
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
} }
-9
View File
@@ -1,9 +0,0 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "jsdom",
include: ["test/**/*.test.ts?(x)"],
},
})
+19 -19
View File
@@ -13,30 +13,30 @@
"clean:modules": "rm -rf node_modules" "clean:modules": "rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/react-router": "^1.170.10", "@tanstack/react-router": "^1.132.31",
"@tanstack/react-router-devtools": "^1.167.0", "@tanstack/react-router-devtools": "^1.132.31",
"@tanstack/router-plugin": "^1.168.13", "@tanstack/router-plugin": "^1.132.31",
"@types/react": "^19.2.15", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^5.0.4",
"globals": "^17.6.0", "globals": "^16.4.0",
"react": "^19.2.6", "react": "^19.2.0",
"react-dom": "^19.2.6", "react-dom": "^19.2.0",
"type-fest": "^5.7.0", "type-fest": "^5.0.1",
"vite": "^8.0.16" "vite": "^7.1.8"
}, },
"dependencies": { "dependencies": {
"@effect/platform": "^0.96.1", "@effect/platform": "^0.92.1",
"@effect/platform-browser": "^0.76.0", "@effect/platform-browser": "^0.72.0",
"@radix-ui/themes": "^3.3.0", "@radix-ui/themes": "^3.2.1",
"@typed/id": "^0.17.2", "@typed/id": "^0.17.2",
"effect": "^3.21.2", "effect": "^3.18.1",
"effect-fc": "workspace:*", "effect-fc": "workspace:*",
"react-icons": "^5.6.0" "react-icons": "^5.5.0"
}, },
"overrides": { "overrides": {
"@types/react": "^19.2.15", "@types/react": "^19.2.0",
"effect": "^3.21.2", "effect": "^3.18.1",
"react": "^19.2.6" "react": "^19.2.0"
} }
} }
@@ -0,0 +1,75 @@
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
interface Props
extends TextField.RootProps, Form.useInput.Options {
readonly optional?: false
readonly field: Form.FormField<any, string>
}
interface OptionalProps
extends Omit<TextField.RootProps, "optional" | "defaultValue">, Form.useOptionalInput.Options<string> {
readonly optional: true
readonly field: Form.FormField<any, Option.Option<string>>
}
export type TextFieldFormInputProps = Props | OptionalProps
export class TextFieldFormInput extends Component.makeUntraced("TextFieldFormInput")(function*(props: TextFieldFormInputProps) {
const input: (
| { readonly optional: true } & Form.useOptionalInput.Result<string>
| { readonly optional: false } & Form.useInput.Result<string>
) = props.optional
// biome-ignore lint/correctness/useHookAtTopLevel: "optional" reactivity not supported
? { optional: true, ...yield* Form.useOptionalInput(props.field, props) }
// biome-ignore lint/correctness/useHookAtTopLevel: "optional" reactivity not supported
: { optional: false, ...yield* Form.useInput(props.field, props) }
const [issues, isValidating, isSubmitting] = yield* Subscribable.useSubscribables(
props.field.issuesSubscribable,
props.field.isValidatingSubscribable,
props.field.isSubmittingSubscribable,
)
return (
<Flex direction="column" gap="1">
<TextField.Root
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={(input.optional && !input.enabled) || isSubmitting}
{...Struct.omit(props, "optional", "defaultValue")}
>
{input.optional &&
<TextField.Slot side="left">
<Switch
size="1"
checked={input.enabled}
onCheckedChange={input.setEnabled}
/>
</TextField.Slot>
}
{isValidating &&
<TextField.Slot side="right">
<Spinner />
</TextField.Slot>
}
{props.children}
</TextField.Root>
{Option.match(Array.head(issues), {
onSome: issue => (
<Callout.Root>
<Callout.Text>{issue.message}</Callout.Text>
</Callout.Root>
),
onNone: () => <></>,
})}
</Flex>
)
}) {}
@@ -1,56 +0,0 @@
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
import type * as React from "react"
export declare namespace TextFieldFormInputView {
export interface Props<out P extends readonly PropertyKey[], A, ER, EW>
extends Omit<TextField.RootProps, "form">, Form.useInput.Options {
readonly form: Form.Form<P, A, string, ER, EW>
}
export type Signature = <P extends readonly PropertyKey[], A, ER, EW>(props: Props<P, A, ER, EW>) => React.ReactNode
}
export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(function*(
props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
) {
const input = yield* Form.useInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
])
return (
<Flex direction="column" gap="1">
<TextField.Root
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={isCommitting}
{...Struct.omit(props, "form")}
>
{isValidating &&
<TextField.Slot side="right">
<Spinner />
</TextField.Slot>
}
{props.children}
</TextField.Root>
{Option.match(Array.head(issues), {
onSome: issue => (
<Callout.Root>
<Callout.Text>{issue.message}</Callout.Text>
</Callout.Root>
),
onNone: () => <></>,
})}
</Flex>
)
}).pipe(
Component.withSignature<TextFieldFormInputView.Signature>()
)
@@ -1,64 +0,0 @@
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
import type * as React from "react"
export declare namespace TextFieldOptionalFormInputView {
export interface Props<out P extends readonly PropertyKey[], A, ER, EW>
extends Omit<TextField.RootProps, "form" | "defaultValue">, Form.useOptionalInput.Options<string> {
readonly form: Form.Form<P, A, Option.Option<string>, ER, EW>
}
export type Signature = <P extends readonly PropertyKey[], A, ER, EW>(props: Props<P, A, ER, EW>) => React.ReactNode
}
export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalFormInputView")(function*(
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
) {
const input = yield* Form.useOptionalInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
])
return (
<Flex direction="column" gap="1">
<TextField.Root
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={!input.enabled || isCommitting}
{...Struct.omit(props, "form", "defaultValue")}
>
<TextField.Slot side="left">
<Switch
size="1"
checked={input.enabled}
onCheckedChange={input.setEnabled}
/>
</TextField.Slot>
{isValidating &&
<TextField.Slot side="right">
<Spinner />
</TextField.Slot>
}
{props.children}
</TextField.Root>
{Option.match(Array.head(issues), {
onSome: issue => (
<Callout.Root>
<Callout.Text>{issue.message}</Callout.Text>
</Callout.Root>
),
onNone: () => <></>,
})}
</Flex>
)
}).pipe(
Component.withSignature<TextFieldOptionalFormInputView.Signature>()
)
+21 -42
View File
@@ -10,24 +10,18 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as ResultRouteImport } from './routes/result' import { Route as ResultRouteImport } from './routes/result'
import { Route as QueryRouteImport } from './routes/query'
import { Route as FormRouteImport } from './routes/form' import { Route as FormRouteImport } from './routes/form'
import { Route as BlankRouteImport } from './routes/blank' import { Route as BlankRouteImport } from './routes/blank'
import { Route as AsyncRouteImport } from './routes/async'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as DevMemoRouteImport } from './routes/dev/memo' import { Route as DevMemoRouteImport } from './routes/dev/memo'
import { Route as DevContextRouteImport } from './routes/dev/context' import { Route as DevContextRouteImport } from './routes/dev/context'
import { Route as DevAsyncRenderingRouteImport } from './routes/dev/async-rendering'
const ResultRoute = ResultRouteImport.update({ const ResultRoute = ResultRouteImport.update({
id: '/result', id: '/result',
path: '/result', path: '/result',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const QueryRoute = QueryRouteImport.update({
id: '/query',
path: '/query',
getParentRoute: () => rootRouteImport,
} as any)
const FormRoute = FormRouteImport.update({ const FormRoute = FormRouteImport.update({
id: '/form', id: '/form',
path: '/form', path: '/form',
@@ -38,11 +32,6 @@ const BlankRoute = BlankRouteImport.update({
path: '/blank', path: '/blank',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AsyncRoute = AsyncRouteImport.update({
id: '/async',
path: '/async',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
@@ -58,35 +47,37 @@ const DevContextRoute = DevContextRouteImport.update({
path: '/dev/context', path: '/dev/context',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({
id: '/dev/async-rendering',
path: '/dev/async-rendering',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/query': typeof QueryRoute
'/result': typeof ResultRoute '/result': typeof ResultRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/context': typeof DevContextRoute '/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute '/dev/memo': typeof DevMemoRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/query': typeof QueryRoute
'/result': typeof ResultRoute '/result': typeof ResultRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/context': typeof DevContextRoute '/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute '/dev/memo': typeof DevMemoRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/query': typeof QueryRoute
'/result': typeof ResultRoute '/result': typeof ResultRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/context': typeof DevContextRoute '/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute '/dev/memo': typeof DevMemoRoute
} }
@@ -94,42 +85,38 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/' | '/'
| '/async'
| '/blank' | '/blank'
| '/form' | '/form'
| '/query'
| '/result' | '/result'
| '/dev/async-rendering'
| '/dev/context' | '/dev/context'
| '/dev/memo' | '/dev/memo'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/async'
| '/blank' | '/blank'
| '/form' | '/form'
| '/query'
| '/result' | '/result'
| '/dev/async-rendering'
| '/dev/context' | '/dev/context'
| '/dev/memo' | '/dev/memo'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/async'
| '/blank' | '/blank'
| '/form' | '/form'
| '/query'
| '/result' | '/result'
| '/dev/async-rendering'
| '/dev/context' | '/dev/context'
| '/dev/memo' | '/dev/memo'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AsyncRoute: typeof AsyncRoute
BlankRoute: typeof BlankRoute BlankRoute: typeof BlankRoute
FormRoute: typeof FormRoute FormRoute: typeof FormRoute
QueryRoute: typeof QueryRoute
ResultRoute: typeof ResultRoute ResultRoute: typeof ResultRoute
DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute
DevContextRoute: typeof DevContextRoute DevContextRoute: typeof DevContextRoute
DevMemoRoute: typeof DevMemoRoute DevMemoRoute: typeof DevMemoRoute
} }
@@ -143,13 +130,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ResultRouteImport preLoaderRoute: typeof ResultRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/query': {
id: '/query'
path: '/query'
fullPath: '/query'
preLoaderRoute: typeof QueryRouteImport
parentRoute: typeof rootRouteImport
}
'/form': { '/form': {
id: '/form' id: '/form'
path: '/form' path: '/form'
@@ -164,13 +144,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof BlankRouteImport preLoaderRoute: typeof BlankRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/async': {
id: '/async'
path: '/async'
fullPath: '/async'
preLoaderRoute: typeof AsyncRouteImport
parentRoute: typeof rootRouteImport
}
'/': { '/': {
id: '/' id: '/'
path: '/' path: '/'
@@ -192,16 +165,22 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DevContextRouteImport preLoaderRoute: typeof DevContextRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/dev/async-rendering': {
id: '/dev/async-rendering'
path: '/dev/async-rendering'
fullPath: '/dev/async-rendering'
preLoaderRoute: typeof DevAsyncRenderingRouteImport
parentRoute: typeof rootRouteImport
}
} }
} }
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AsyncRoute: AsyncRoute,
BlankRoute: BlankRoute, BlankRoute: BlankRoute,
FormRoute: FormRoute, FormRoute: FormRoute,
QueryRoute: QueryRoute,
ResultRoute: ResultRoute, ResultRoute: ResultRoute,
DevAsyncRenderingRoute: DevAsyncRenderingRoute,
DevContextRoute: DevContextRoute, DevContextRoute: DevContextRoute,
DevMemoRoute: DevMemoRoute, DevMemoRoute: DevMemoRoute,
} }
-71
View File
@@ -1,71 +0,0 @@
import { HttpClient } from "@effect/platform"
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Array, Effect, flow, Option, Schema } from "effect"
import { Async, Component, Memoized } from "effect-fc"
import * as React from "react"
import { runtime } from "@/runtime"
const Post = Schema.Struct({
userId: Schema.Int,
id: Schema.Int,
title: Schema.String,
body: Schema.String,
})
interface AsyncFetchPostViewProps {
readonly id: number
}
class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) {
const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)),
Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(Post)),
), [props.id])
return (
<div>
<Heading>{post.title}</Heading>
<Text>{post.body}</Text>
</div>
)
}).pipe(
Async.async,
Async.withOptions({ defaultFallback: <Text>Default fallback</Text> }),
Memoized.memoized,
) {}
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
const [text, setText] = React.useState("Typing here should not trigger a refetch of the post")
const [id, setId] = React.useState(1)
const AsyncFetchPost = yield* AsyncFetchPostView.use
return (
<Container>
<Flex direction="column" align="stretch" gap="2">
<TextField.Root
value={text}
onChange={e => setText(e.currentTarget.value)}
/>
<Slider
value={[id]}
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
/>
<AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} />
</Flex>
</Container>
)
}).pipe(
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/async")({
component: AsyncRouteComponent,
})
@@ -0,0 +1,78 @@
import { Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Effect } from "effect"
import { Async, Component, Memoized } from "effect-fc"
import * as React from "react"
import { runtime } from "@/runtime"
// Generator version
const RouteComponent = Component.makeUntraced(function* AsyncRendering() {
const MemoizedAsyncComponentFC = yield* MemoizedAsyncComponent
const AsyncComponentFC = yield* AsyncComponent
const [input, setInput] = React.useState("")
return (
<Flex direction="column" align="stretch" gap="2">
<TextField.Root
value={input}
onChange={e => setInput(e.target.value)}
/>
<MemoizedAsyncComponentFC fallback={React.useMemo(() => <p>Loading memoized...</p>, [])} />
<AsyncComponentFC />
</Flex>
)
}).pipe(
Component.withRuntime(runtime.context)
)
// Pipeline version
// const RouteComponent = Component.make("RouteComponent")(() => Effect.Do,
// Effect.bind("VMemoizedAsyncComponent", () => Component.useFC(MemoizedAsyncComponent)),
// Effect.bind("VAsyncComponent", () => Component.useFC(AsyncComponent)),
// Effect.let("input", () => React.useState("")),
// Effect.map(({ input: [input, setInput], VAsyncComponent, VMemoizedAsyncComponent }) =>
// <Flex direction="column" align="stretch" gap="2">
// <TextField.Root
// value={input}
// onChange={e => setInput(e.target.value)}
// />
// <VMemoizedAsyncComponent />
// <VAsyncComponent />
// </Flex>
// ),
// ).pipe(
// Component.withRuntime(runtime.context)
// )
class AsyncComponent extends Component.makeUntraced("AsyncComponent")(function*() {
const SubComponentFC = yield* SubComponent
yield* Effect.sleep("500 millis") // Async operation
// Cannot use React hooks after the async operation
return (
<Flex direction="column" align="stretch">
<Text>Rendered!</Text>
<SubComponentFC />
</Flex>
)
}).pipe(
Async.async,
Async.withOptions({ defaultFallback: <p>Loading...</p> }),
) {}
class MemoizedAsyncComponent extends Memoized.memoized(AsyncComponent) {}
class SubComponent extends Component.makeUntraced("SubComponent")(function*() {
const [state] = React.useState(yield* Component.useOnMount(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)))
return <Text>{state}</Text>
}) {}
export const Route = createFileRoute("/dev/async-rendering")({
component: RouteComponent
})
+1 -1
View File
@@ -23,7 +23,7 @@ const SubComponent = Component.makeUntraced("SubComponent")(function*() {
const ContextView = Component.makeUntraced("ContextView")(function*() { const ContextView = Component.makeUntraced("ContextView")(function*() {
const [serviceValue, setServiceValue] = React.useState("test") const [serviceValue, setServiceValue] = React.useState("test")
const SubServiceLayer = React.useMemo(() => SubService.Default(serviceValue), [serviceValue]) const SubServiceLayer = React.useMemo(() => SubService.Default(serviceValue), [serviceValue])
const SubComponentFC = yield* Effect.provide(SubComponent.use, yield* Component.useContextFromLayer(SubServiceLayer)) const SubComponentFC = yield* Effect.provide(SubComponent, yield* Component.useContext(SubServiceLayer))
return ( return (
<Container> <Container>
+2 -2
View File
@@ -17,8 +17,8 @@ const RouteComponent = Component.makeUntraced("RouteComponent")(function*() {
onChange={e => setValue(e.target.value)} onChange={e => setValue(e.target.value)}
/> />
{yield* Effect.map(SubComponent.use, FC => <FC />)} {yield* Effect.map(SubComponent, FC => <FC />)}
{yield* Effect.map(MemoizedSubComponent.use, FC => <FC />)} {yield* Effect.map(MemoizedSubComponent, FC => <FC />)}
</Flex> </Flex>
) )
}).pipe( }).pipe(
+43 -69
View File
@@ -1,9 +1,8 @@
import { Button, Container, Flex, Text } from "@radix-ui/themes" import { Button, Container, Flex, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect" import { Console, Effect, Match, Option, ParseResult, Schema } from "effect"
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc" import { Component, Form, Subscribable } from "effect-fc"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView" import { TextFieldFormInput } from "@/lib/form/TextFieldFormInput"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { DateTimeUtcFromZonedInput } from "@/lib/schema" import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { runtime } from "@/runtime" import { runtime } from "@/runtime"
@@ -24,63 +23,39 @@ const RegisterFormSchema = Schema.Struct({
birth: Schema.OptionFromSelf(DateTimeUtcFromZonedInput), birth: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
}) })
const RegisterFormSubmitSchema = Schema.Struct({ class RegisterForm extends Effect.Service<RegisterForm>()("RegisterForm", {
email: Schema.transformOrFail( scoped: Form.service({
Schema.String, schema: RegisterFormSchema.pipe(
Schema.String, Schema.compose(
{ Schema.transformOrFail(
decode: (input, _options, ast) => input !== "admin@admin.com" Schema.typeSchema(RegisterFormSchema),
? ParseResult.succeed(input) Schema.typeSchema(RegisterFormSchema),
: ParseResult.fail(new ParseResult.Type(ast, input, "This email is already in use.")), {
encode: ParseResult.succeed, decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
}, encode: ParseResult.succeed,
), },
password: Schema.String,
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
})
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
scoped: Effect.gen(function*() {
const form = yield* SubmittableForm.service({
schema: RegisterFormSchema.pipe(
Schema.compose(
Schema.transformOrFail(
Schema.typeSchema(RegisterFormSchema),
Schema.typeSchema(RegisterFormSchema),
{
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
encode: ParseResult.succeed,
},
),
), ),
), ),
),
initialEncodedValue: { email: "", password: "", birth: Option.none() }, initialEncodedValue: { email: "", password: "", birth: Option.none() },
f: Effect.fnUntraced(function*([value]) { onSubmit: v => Effect.sleep("500 millis").pipe(
yield* Effect.sleep("500 millis") Effect.andThen(Console.log(v)),
return yield* Schema.decode(RegisterFormSubmitSchema)(value) Effect.andThen(Effect.sync(() => alert("Done!"))),
}), ),
}) debounce: "500 millis",
return {
form,
emailField: Form.focusObjectOn(form, "email"),
passwordField: Form.focusObjectOn(form, "password"),
birthField: Form.focusObjectOn(form, "birth"),
} as const
}) })
}) {} }) {}
class RegisterFormView extends Component.make("RegisterFormView")(function*() { class RegisterFormView extends Component.makeUntraced("RegisterFormView")(function*() {
const form = yield* RegisterFormService const form = yield* RegisterForm
const [canCommit, submitResult] = yield* Subscribable.useAll([ const submit = yield* Form.useSubmit(form)
form.form.canCommit, const [canSubmit, submitResult] = yield* Subscribable.useSubscribables(
form.form.mutation.result, form.canSubmitSubscribable,
]) form.submitResultRef,
)
const runPromise = yield* Component.useRunPromise() const TextFieldFormInputFC = yield* TextFieldFormInput
const TextFieldFormInput = yield* TextFieldFormInputView.use
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
yield* Component.useOnMount(() => Effect.gen(function*() { yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted")) yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
@@ -92,26 +67,25 @@ class RegisterFormView extends Component.make("RegisterFormView")(function*() {
<Container width="300"> <Container width="300">
<form onSubmit={e => { <form onSubmit={e => {
e.preventDefault() e.preventDefault()
void runPromise(form.form.submit) void submit()
}}> }}>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<TextFieldFormInput <TextFieldFormInputFC
form={form.emailField} field={Form.useField(form, ["email"])}
debounce="250 millis"
/> />
<TextFieldFormInput <TextFieldFormInputFC
form={form.passwordField} field={Form.useField(form, ["password"])}
debounce="250 millis"
/> />
<TextFieldOptionalFormInput <TextFieldFormInputFC
optional
type="datetime-local" type="datetime-local"
form={form.birthField} field={Form.useField(form, ["birth"])}
defaultValue="" defaultValue=""
/> />
<Button disabled={!canCommit}>Submit</Button> <Button disabled={!canSubmit}>Submit</Button>
</Flex> </Flex>
</form> </form>
@@ -119,20 +93,20 @@ class RegisterFormView extends Component.make("RegisterFormView")(function*() {
Match.tag("Initial", () => <></>), Match.tag("Initial", () => <></>),
Match.tag("Running", () => <Text>Submitting...</Text>), Match.tag("Running", () => <Text>Submitting...</Text>),
Match.tag("Success", () => <Text>Submitted successfully!</Text>), Match.tag("Success", () => <Text>Submitted successfully!</Text>),
Match.tag("Failure", e => <Text>Error: {e.cause.toString()}</Text>), Match.tag("Failure", v => <Text>Error: {v.cause.toString()}</Text>),
Match.exhaustive, Match.exhaustive,
)} )}
</Container> </Container>
) )
}) {} }) {}
const RegisterPage = Component.make("RegisterPageView")(function*() { const RegisterPage = Component.makeUntraced("RegisterPage")(function*() {
const RegisterForm = yield* Effect.provide( const RegisterFormViewFC = yield* Effect.provide(
RegisterFormView.use, RegisterFormView,
yield* Component.useContextFromLayer(RegisterFormService.Default), yield* Component.useContext(RegisterForm.Default),
) )
return <RegisterForm /> return <RegisterFormViewFC />
}).pipe( }).pipe(
Component.withRuntime(runtime.context) Component.withRuntime(runtime.context)
) )
+7 -7
View File
@@ -2,19 +2,19 @@ import { createFileRoute } from "@tanstack/react-router"
import { Effect } from "effect" import { Effect } from "effect"
import { Component } from "effect-fc" import { Component } from "effect-fc"
import { runtime } from "@/runtime" import { runtime } from "@/runtime"
import { TodosState } from "@/todo/TodosState" import { Todos } from "@/todo/Todos"
import { TodosView } from "@/todo/TodosView" import { TodosState } from "@/todo/TodosState.service"
const TodosStateLive = TodosState.Default("todos") const TodosStateLive = TodosState.Default("todos")
const Index = Component.make("IndexView")(function*() { const Index = Component.makeUntraced("Index")(function*() {
const Todos = yield* Effect.provide( const TodosFC = yield* Effect.provide(
TodosView.use, Todos,
yield* Component.useContextFromLayer(TodosStateLive), yield* Component.useContext(TodosStateLive),
) )
return <Todos /> return <TodosFC />
}).pipe( }).pipe(
Component.withRuntime(runtime.context) Component.withRuntime(runtime.context)
) )
-117
View File
@@ -1,117 +0,0 @@
import { HttpClient, type HttpClientError } from "@effect/platform"
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect"
import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc"
import { runtime } from "@/runtime"
const Post = Schema.Struct({
userId: Schema.Int,
id: Schema.Int,
title: Schema.String,
body: Schema.String,
})
const ResultView = Component.make("ResultView")(function*() {
const runPromise = yield* Component.useRunPromise()
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1))
const key = Stream.map(idLens.changes, id => [id] as const)
const query = yield* Query.service({
key,
f: ([id]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(Post)),
),
staleTime: "10 seconds",
})
const mutation = yield* Mutation.make({
f: ([id]: readonly [id: number]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(Post)),
),
})
return [idLens, query, mutation] as const
}))
const [id, setId] = yield* Lens.useState(idLens)
const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result])
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
Effect.andThen(observer => observer.subscribe),
Effect.andThen(Stream.fromQueue),
Stream.unwrapScoped,
Stream.runForEach(flow(
Cause.failures,
Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"),
Option.match({
onSome: e => Console.log("ResultView HttpClient error", e),
onNone: () => Effect.void,
}),
)),
Effect.forkScoped,
))
return (
<Container>
<Flex direction="column" align="center" gap="2">
<Slider
value={[id]}
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
/>
<div>
{Match.value(queryResult).pipe(
Match.tag("Running", () => <Text>Loading...</Text>),
Match.tag("Success", result => <>
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
</>),
Match.tag("Failure", result =>
<Text>An error has occured: {result.cause.toString()}</Text>
),
Match.orElse(() => <></>),
)}
</div>
<Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => runPromise(query.refresh)}>Refresh</Button>
<Button onClick={() => runPromise(query.invalidateCache)}>Invalidate cache</Button>
</Flex>
<div>
{Match.value(mutationResult).pipe(
Match.tag("Running", () => <Text>Loading...</Text>),
Match.tag("Success", result => <>
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
</>),
Match.tag("Failure", result =>
<Text>An error has occured: {result.cause.toString()}</Text>
),
Match.orElse(() => <></>),
)}
</div>
<Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button>
</Flex>
</Flex>
</Container>
)
})
export const Route = createFileRoute("/query")({
component: Component.withRuntime(ResultView, runtime.context)
})
+5 -20
View File
@@ -1,8 +1,8 @@
import { HttpClient, type HttpClientError } from "@effect/platform" import { HttpClient } from "@effect/platform"
import { Container, Heading, Text } from "@radix-ui/themes" import { Container, Heading, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream } from "effect" import { Effect, Match, Schema } from "effect"
import { Component, ErrorObserver, Result, Subscribable } from "effect-fc" import { Component, Result, Subscribable } from "effect-fc"
import { runtime } from "@/runtime" import { runtime } from "@/runtime"
@@ -14,29 +14,14 @@ const Post = Schema.Struct({
}) })
const ResultView = Component.makeUntraced("Result")(function*() { const ResultView = Component.makeUntraced("Result")(function*() {
const [resultSubscribable] = yield* Component.useOnMount(() => HttpClient.HttpClient.pipe( const resultSubscribable = yield* Component.useOnMount(() => HttpClient.HttpClient.pipe(
Effect.andThen(client => client.get("https://jsonplaceholder.typicode.com/posts/1")), Effect.andThen(client => client.get("https://jsonplaceholder.typicode.com/posts/1")),
Effect.andThen(response => response.json), Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(Post)), Effect.andThen(Schema.decodeUnknown(Post)),
Effect.tap(Effect.sleep("250 millis")), Effect.tap(Effect.sleep("250 millis")),
Result.forkEffect, Result.forkEffect,
)) ))
const [result] = yield* Subscribable.useAll([resultSubscribable]) const [result] = yield* Subscribable.useSubscribables(resultSubscribable)
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
Effect.andThen(observer => observer.subscribe),
Effect.andThen(Stream.fromQueue),
Stream.unwrapScoped,
Stream.runForEach(flow(
Cause.failures,
Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"),
Option.match({
onSome: e => Console.log("ResultView HttpClient error", e),
onNone: () => Effect.void,
}),
)),
Effect.forkScoped,
))
return ( return (
<Container> <Container>
@@ -1,88 +0,0 @@
import { Box, Flex, IconButton } from "@radix-ui/themes"
import { Effect } from "effect"
import { Component, Form, Subscribable, SynchronizedForm } from "effect-fc"
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
import { FaDeleteLeft } from "react-icons/fa6"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { TodoFormSchema } from "./TodoFormSchema"
import { TodosState } from "./TodosState"
export interface EditTodoViewProps {
readonly id: string
}
export class EditTodoView extends Component.make("TodoView")(function*(props: EditTodoViewProps) {
const state = yield* TodosState
const [
indexSubscribable,
contentField,
completedAtField,
] = yield* Component.useOnChange(() => Effect.gen(function*() {
const indexSubscribable = state.getIndexSubscribable(props.id)
const form = yield* SynchronizedForm.service({
schema: TodoFormSchema,
target: state.getElementLens(props.id),
})
return [
indexSubscribable,
Form.focusObjectOn(form, "content"),
Form.focusObjectOn(form, "completedAt"),
] as const
}), [props.id])
const [index, size] = yield* Subscribable.useAll([
indexSubscribable,
state.sizeSubscribable,
])
const runSync = yield* Component.useRunSync()
const TextFieldFormInput = yield* TextFieldFormInputView.use
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
return (
<Flex direction="row" align="center" gap="2">
<Box flexGrow="1">
<Flex direction="column" align="stretch" gap="2">
<TextFieldFormInput
form={contentField}
debounce="250 millis"
/>
<Flex direction="row" justify="center" align="center" gap="2">
<TextFieldOptionalFormInput
form={completedAtField}
type="datetime-local"
defaultValue=""
/>
</Flex>
</Flex>
</Box>
<Flex direction="column" justify="center" align="center" gap="1">
<IconButton
disabled={index <= 0}
onClick={() => runSync(state.moveLeft(props.id))}
>
<FaArrowUp />
</IconButton>
<IconButton
disabled={index >= size - 1}
onClick={() => runSync(state.moveRight(props.id))}
>
<FaArrowDown />
</IconButton>
<IconButton onClick={() => runSync(state.remove(props.id))}>
<FaDeleteLeft />
</IconButton>
</Flex>
</Flex>
)
}) {}
-78
View File
@@ -1,78 +0,0 @@
import { Box, Button, Flex } from "@radix-ui/themes"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Chunk, type DateTime, Effect, Option, Schema } from "effect"
import { Component, Form, Lens, SubmittableForm, Subscribable } from "effect-fc"
import * as Domain from "@/domain"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { TodoFormSchema } from "./TodoFormSchema"
import { TodosState } from "./TodosState"
const makeTodo = makeUuid4.pipe(
Effect.map(id => Domain.Todo.Todo.make({
id,
content: "",
completedAt: Option.none(),
})),
Effect.provide(GetRandomValues.CryptoRandom),
)
export class NewTodoView extends Component.make("NewTodoView")(function*() {
const state = yield* TodosState
const [
form,
contentField,
completedAtField,
] = yield* Component.useOnMount(() => Effect.gen(function*() {
const form = yield* SubmittableForm.service({
schema: TodoFormSchema,
initialEncodedValue: yield* Schema.encode(TodoFormSchema)(yield* makeTodo),
f: ([todo, form]) => Lens.update(state.lens, Chunk.prepend(todo)).pipe(
Effect.andThen(makeTodo),
Effect.andThen(Schema.encode(TodoFormSchema)),
Effect.andThen(v => Lens.set(form.encodedValue, v)),
),
})
return [
form,
Form.focusObjectOn(form, "content"),
Form.focusObjectOn(form, "completedAt"),
] as const
}))
const [canCommit] = yield* Subscribable.useAll([form.canCommit])
const runPromise = yield* Component.useRunPromise<DateTime.CurrentTimeZone>()
const TextFieldFormInput = yield* TextFieldFormInputView.use
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
return (
<Flex direction="row" align="center" gap="2">
<Box flexGrow="1">
<Flex direction="column" align="stretch" gap="2">
<TextFieldFormInput
form={contentField}
debounce="250 millis"
/>
<Flex direction="row" justify="center" align="center" gap="2">
<TextFieldOptionalFormInput
form={completedAtField}
type="datetime-local"
defaultValue=""
/>
<Button disabled={!canCommit} onClick={() => void runPromise(form.submit)}>
Add
</Button>
</Flex>
</Flex>
</Box>
</Flex>
)
}) {}
+136
View File
@@ -0,0 +1,136 @@
import { Box, Button, Flex, IconButton } from "@radix-ui/themes"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Chunk, Effect, Match, Option, Ref, Runtime, Schema, Stream } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
import { FaDeleteLeft } from "react-icons/fa6"
import * as Domain from "@/domain"
import { TextFieldFormInput } from "@/lib/form/TextFieldFormInput"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { TodosState } from "./TodosState.service"
const TodoFormSchema = Schema.compose(Schema.Struct({
...Domain.Todo.Todo.fields,
completedAt: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
}), Domain.Todo.Todo)
const makeTodo = makeUuid4.pipe(
Effect.map(id => Domain.Todo.Todo.make({
id,
content: "",
completedAt: Option.none(),
})),
Effect.provide(GetRandomValues.CryptoRandom),
)
export type TodoProps = (
| { readonly _tag: "new" }
| { readonly _tag: "edit", readonly id: string }
)
export class Todo extends Component.makeUntraced("Todo")(function*(props: TodoProps) {
const runtime = yield* Effect.runtime()
const state = yield* TodosState
const [
indexRef,
form,
contentField,
completedAtField,
] = yield* Component.useOnChange(() => Effect.gen(function*() {
const indexRef = Match.value(props).pipe(
Match.tag("new", () => Subscribable.make({ get: Effect.succeed(-1), changes: Stream.make(-1) })),
Match.tag("edit", ({ id }) => state.getIndexSubscribable(id)),
Match.exhaustive,
)
const form = yield* Form.service({
schema: TodoFormSchema,
initialEncodedValue: yield* Schema.encode(TodoFormSchema)(
yield* Match.value(props).pipe(
Match.tag("new", () => makeTodo),
Match.tag("edit", ({ id }) => state.getElementRef(id)),
Match.exhaustive,
)
),
onSubmit: function(todo) {
return Match.value(props).pipe(
Match.tag("new", () => Ref.update(state.ref, Chunk.prepend(todo)).pipe(
Effect.andThen(makeTodo),
Effect.andThen(Schema.encode(TodoFormSchema)),
Effect.andThen(v => Ref.set(this.encodedValueRef, v)),
)),
Match.tag("edit", ({ id }) => Ref.set(state.getElementRef(id), todo)),
Match.exhaustive,
)
},
autosubmit: props._tag === "edit",
debounce: "250 millis",
})
return [
indexRef,
form,
Form.field(form, ["content"]),
Form.field(form, ["completedAt"]),
] as const
}), [props._tag, props._tag === "edit" ? props.id : undefined])
const [index, size, canSubmit] = yield* Subscribable.useSubscribables(
indexRef,
state.sizeSubscribable,
form.canSubmitSubscribable,
)
const submit = yield* Form.useSubmit(form)
const TextFieldFormInputFC = yield* TextFieldFormInput
return (
<Flex direction="row" align="center" gap="2">
<Box flexGrow="1">
<Flex direction="column" align="stretch" gap="2">
<TextFieldFormInputFC field={contentField} />
<Flex direction="row" justify="center" align="center" gap="2">
<TextFieldFormInputFC
optional
field={completedAtField}
type="datetime-local"
defaultValue=""
/>
{props._tag === "new" &&
<Button disabled={!canSubmit} onClick={() => submit()}>
Add
</Button>
}
</Flex>
</Flex>
</Box>
{props._tag === "edit" &&
<Flex direction="column" justify="center" align="center" gap="1">
<IconButton
disabled={index <= 0}
onClick={() => Runtime.runSync(runtime)(state.moveLeft(props.id))}
>
<FaArrowUp />
</IconButton>
<IconButton
disabled={index >= size - 1}
onClick={() => Runtime.runSync(runtime)(state.moveRight(props.id))}
>
<FaArrowDown />
</IconButton>
<IconButton onClick={() => Runtime.runSync(runtime)(state.remove(props.id))}>
<FaDeleteLeft />
</IconButton>
</Flex>
}
</Flex>
)
}) {}
@@ -1,9 +0,0 @@
import { Schema } from "effect"
import * as Domain from "@/domain"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
export const TodoFormSchema = Schema.compose(Schema.Struct({
...Domain.Todo.Todo.fields,
completedAt: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
}), Domain.Todo.Todo)
@@ -1,32 +1,30 @@
import { Container, Flex, Heading } from "@radix-ui/themes" import { Container, Flex, Heading } from "@radix-ui/themes"
import { Chunk, Console, Effect } from "effect" import { Chunk, Console, Effect } from "effect"
import { Component, Subscribable } from "effect-fc" import { Component, Subscribable } from "effect-fc"
import { EditTodoView } from "./EditTodoView" import { Todo } from "./Todo"
import { NewTodoView } from "./NewTodoView" import { TodosState } from "./TodosState.service"
import { TodosState } from "./TodosState"
export class TodosView extends Component.make("TodosView")(function*() { export class Todos extends Component.makeUntraced("Todos")(function*() {
const state = yield* TodosState const state = yield* TodosState
const [todos] = yield* Subscribable.useAll([state.lens]) const [todos] = yield* Subscribable.useSubscribables(state.ref)
yield* Component.useOnMount(() => Effect.andThen( yield* Component.useOnMount(() => Effect.andThen(
Console.log("Todos mounted"), Console.log("Todos mounted"),
Effect.addFinalizer(() => Console.log("Todos unmounted")), Effect.addFinalizer(() => Console.log("Todos unmounted")),
)) ))
const NewTodo = yield* NewTodoView.use const TodoFC = yield* Todo
const EditTodo = yield* EditTodoView.use
return ( return (
<Container> <Container>
<Heading align="center">Todos</Heading> <Heading align="center">Todos</Heading>
<Flex direction="column" align="stretch" gap="2" mt="2"> <Flex direction="column" align="stretch" gap="2" mt="2">
<NewTodo /> <TodoFC _tag="new" />
{Chunk.map(todos, todo => {Chunk.map(todos, todo =>
<EditTodo key={todo.id} id={todo.id} /> <TodoFC key={todo.id} _tag="edit" id={todo.id} />
)} )}
</Flex> </Flex>
</Container> </Container>
@@ -1,7 +1,7 @@
import { KeyValueStore } from "@effect/platform" import { KeyValueStore } from "@effect/platform"
import { BrowserKeyValueStore } from "@effect/platform-browser" import { BrowserKeyValueStore } from "@effect/platform-browser"
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect" import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
import { Lens, Subscribable } from "effect-fc" import { Subscribable, SubscriptionSubRef } from "effect-fc"
import { Todo } from "@/domain" import { Todo } from "@/domain"
@@ -30,29 +30,27 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
: kv.remove(key) : kv.remove(key)
) )
const lens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(yield* readFromLocalStorage)) const ref = yield* SubscriptionRef.make(yield* readFromLocalStorage)
yield* Effect.forkScoped(lens.changes.pipe( yield* Effect.forkScoped(ref.changes.pipe(
Stream.debounce("500 millis"), Stream.debounce("500 millis"),
Stream.runForEach(saveToLocalStorage), Stream.runForEach(saveToLocalStorage),
)) ))
yield* Effect.addFinalizer(() => Lens.get(lens).pipe( yield* Effect.addFinalizer(() => ref.pipe(
Effect.andThen(saveToLocalStorage), Effect.andThen(saveToLocalStorage),
Effect.ignore, Effect.ignore,
)) ))
const sizeSubscribable = Subscribable.map(lens, Chunk.size) const sizeSubscribable = Subscribable.make({
get: Effect.andThen(ref, Chunk.size),
get changes() { return Stream.map(ref.changes, Chunk.size) },
})
const getElementRef = (id: string) => SubscriptionSubRef.makeFromChunkFindFirst(ref, v => v.id === id)
const getIndexSubscribable = (id: string) => Subscribable.make({
get: Effect.flatMap(ref, Chunk.findFirstIndex(v => v.id === id)),
get changes() { return Stream.flatMap(ref.changes, Chunk.findFirstIndex(v => v.id === id)) },
})
const getElementLens = (id: string) => Lens.mapEffect( const moveLeft = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
lens,
Chunk.findFirst(v => v.id === id),
(a, b) => Effect.flatMap(
Chunk.findFirstIndex(a, v => v.id === id),
i => Chunk.replaceOption(a, i, b),
)
)
const getIndexSubscribable = (id: string) => Subscribable.mapEffect(lens, Chunk.findFirstIndex(v => v.id === id))
const moveLeft = (id: string) => Lens.updateEffect(lens, todos => Effect.Do.pipe(
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)), Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)), Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
Effect.bind("previous", ({ index }) => Chunk.get(todos, index - 1)), Effect.bind("previous", ({ index }) => Chunk.get(todos, index - 1)),
@@ -64,7 +62,7 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
: todos : todos
), ),
)) ))
const moveRight = (id: string) => Lens.updateEffect(lens, todos => Effect.Do.pipe( const moveRight = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)), Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)), Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
Effect.bind("next", ({ index }) => Chunk.get(todos, index + 1)), Effect.bind("next", ({ index }) => Chunk.get(todos, index + 1)),
@@ -76,15 +74,15 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
: todos : todos
), ),
)) ))
const remove = (id: string) => Lens.updateEffect(lens, todos => Effect.andThen( const remove = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.andThen(
Chunk.findFirstIndex(todos, v => v.id === id), Chunk.findFirstIndex(todos, v => v.id === id),
index => Chunk.remove(todos, index), index => Chunk.remove(todos, index),
)) ))
return { return {
lens, ref,
sizeSubscribable, sizeSubscribable,
getElementLens, getElementRef,
getIndexSubscribable, getIndexSubscribable,
moveLeft, moveLeft,
moveRight, moveRight,