8 Commits

Author SHA1 Message Date
401e6c84e7 Update dependency npm-check-updates to v22
Some checks failed
Lint / lint (push) Successful in 44s
Test build / test-build (pull_request) Failing after 15s
2026-04-26 12:01:25 +00:00
Julien Valverdé
2206829f6c Fix
All checks were successful
Lint / lint (push) Successful in 13s
2026-04-24 11:33:22 +02:00
Julien Valverdé
dbf5d00590 Refactor Form
All checks were successful
Lint / lint (push) Successful in 42s
2026-04-24 11:19:24 +02:00
Julien Valverdé
3e78121d26 Regenerate lockfile
All checks were successful
Lint / lint (push) Successful in 13s
2026-04-23 15:53:31 +02:00
ffa23718a8 Update dependency npm-check-updates to v21 (#46)
Some checks failed
Lint / lint (push) Failing after 7s
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [npm-check-updates](https://github.com/raineorshine/npm-check-updates) | [`^19.6.6` → `^21.0.0`](https://renovatebot.com/diffs/npm/npm-check-updates/19.6.6/21.0.3) | ![age](https://developer.mend.io/api/mc/badges/age/npm/npm-check-updates/21.0.3?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/npm-check-updates/19.6.6/21.0.3?slim=true) |

---

### Release Notes

<details>
<summary>raineorshine/npm-check-updates (npm-check-updates)</summary>

### [`v21.0.3`](https://github.com/raineorshine/npm-check-updates/releases/tag/v21.0.3)

[Compare Source](https://github.com/raineorshine/npm-check-updates/compare/v21.0.2...v21.0.3)

#### What's Changed

- fix: chmod build/cli.js executable after vite build by [@&#8203;raineorshine](https://github.com/raineorshine) in [#&#8203;1678](https://github.com/raineorshine/npm-check-updates/pull/1678)
- fix: migrate from rc-config-loader to cosmiconfig for ESM config support (closes [#&#8203;1674](https://github.com/raineorshine/npm-check-updates/issues/1674)) by [@&#8203;onemen](https://github.com/onemen) in [#&#8203;1676](https://github.com/raineorshine/npm-check-updates/pull/1676)

**Full Changelog**: <https://github.com/raineorshine/npm-check-updates/compare/v21.0.2...v21.0.3>

### [`v21.0.2`](https://github.com/raineorshine/npm-check-updates/releases/tag/v21.0.2)

[Compare Source](https://github.com/raineorshine/npm-check-updates/compare/v21.0.1...v21.0.2)

#### What's Changed

- fix: skip intersects() for non-semver specs like catalog: in peer dep checks by [@&#8203;terminalchai](https://github.com/terminalchai) in [#&#8203;1675](https://github.com/raineorshine/npm-check-updates/pull/1675)

**Full Changelog**: <https://github.com/raineorshine/npm-check-updates/compare/v21.0.1...v21.0.2>

### [`v21.0.1`](https://github.com/raineorshine/npm-check-updates/releases/tag/v21.0.1)

[Compare Source](https://github.com/raineorshine/npm-check-updates/compare/v21.0.0...v21.0.1)

#### What's Changed

- fix: seeing catalog when inside workspaces by [@&#8203;Zamiell](https://github.com/Zamiell) in [#&#8203;1656](https://github.com/raineorshine/npm-check-updates/pull/1656)
- Bump [@&#8203;types/bun](https://github.com/types/bun) from 1.3.11 to 1.3.12 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1663](https://github.com/raineorshine/npm-check-updates/pull/1663)
- Bump globals from 17.4.0 to 17.5.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1664](https://github.com/raineorshine/npm-check-updates/pull/1664)
- Bump [@&#8203;typescript-eslint/parser](https://github.com/typescript-eslint/parser) from 8.58.1 to 8.58.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1665](https://github.com/raineorshine/npm-check-updates/pull/1665)
- Bump prettier from 3.8.1 to 3.8.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1667](https://github.com/raineorshine/npm-check-updates/pull/1667)
- Bump verdaccio from 6.4.0 to 6.5.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1669](https://github.com/raineorshine/npm-check-updates/pull/1669)
- Bump sinon from 21.0.3 to 21.1.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1668](https://github.com/raineorshine/npm-check-updates/pull/1668)
- Skip CI on PRs with no file changes by [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;1672](https://github.com/raineorshine/npm-check-updates/pull/1672)
- Bump [@&#8203;typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/eslint-plugin) from 8.58.1 to 8.58.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1666](https://github.com/raineorshine/npm-check-updates/pull/1666)
- Fix wrong message when all packages are within cooldown window by [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;1671](https://github.com/raineorshine/npm-check-updates/pull/1671)

**Full Changelog**: <https://github.com/raineorshine/npm-check-updates/compare/v21.0.0...v21.0.1>

### [`v21.0.0`](https://github.com/raineorshine/npm-check-updates/blob/HEAD/CHANGELOG.md#2100---2026-04-14)

[Compare Source](https://github.com/raineorshine/npm-check-updates/compare/v20.0.2...v21.0.0)

##### ⚠️ Breaking Changes

This is a **major breaking change** with significant architectural updates.

##### ESM Migration & Module System

- **Pure ESM:** Converted to pure ESM with dual-build support (ESM/CJS) via Vite 8.
- **Import Syntax:** Programmatic usage now requires named imports or namespace imports.
  - **Old:** `import ncu from 'npm-check-updates'`
  - **New:** `import * as ncu from 'npm-check-updates'` or `import { run } from 'npm-check-updates'`
- **Node.js Requirements:** Now requires `^20.19.0 || ^22.12.0 || >=24.0.0`. This is required for native `require(esm)` support and the Rolldown engine.
- **npm Requirements:** Minimum version increased to `>=10.0.0`.

##### Configuration Files (`.ncurc.js`)

- Files named `.ncurc.js` that use `module.exports` will now fail in projects that are `"type": "module"`.
- **Fix:** Rename these files to `.ncurc.cjs` or convert them to use `export default`.

##### Dependency Updates (Pure ESM versions)

| Package            | Old Version | New Version |
| :----------------- | :---------- | :---------- |
| `camelcase`        | `^6.3.0`    | `^9.0.0`    |
| `chai`             | `^4.3.10`   | `^6.2.2`    |
| `chai-as-promised` | `^7.1.2`    | `^8.0.2`    |
| `find-up`          | `5.0.0`     | `8.0.0`     |
| `p-map`            | `^4.0.0`    | `^7.0.4`    |
| `untildify`        | `^4.0.0`    | `^6.0.0`    |

##### Tooling & Build Changes

- **Vite 8 Upgrade:** Migrated to Vite 8 with the new Rust-based **Rolldown** bundler (10-30x faster builds).
- **TypeScript 6.0:** Adopted latest type-system features and performance improvements.
- **Strip ANSI:** Replaced `strip-ansi` with Node.js built-in `util.stripVTControlCharacters`.
- **Test Runner:** Replaced `vite-node` with `tsx` for TypeScript support in ESM context.

***

##### Migration Guide

If you are upgrading to v21 from earlier versions:

##### 1. Environment Check

- Ensure you meet the new Node.js requirement: `^20.19.0 || ^22.12.0 || >=24.0.0`.
- Update npm to at least `10.0.0`.

##### 2. Update Configuration Files

If you have a `.ncurc.js` file:

- **Option A:** Rename it to `.ncurc.cjs`.
- **Option B:** Convert it to ESM:

  ```js
  import { defineConfig } from 'npm-check-updates'

  export default defineConfig({
    upgrade: true,
    filter: name => name.startsWith('@&#8203;myorg/'),
  })
  ```

##### 3. Update Programmatic Usage

If you import `npm-check-updates` in your scripts:

- **ESM:** Change `import ncu from ...` to `import * as ncu from 'npm-check-updates'`.
- **CommonJS:** Ensure you are destructuring the named exports or using the full object:

```js
const ncu = require('npm-check-updates')
// Use ncu.run(...)
```

***

##### Testing

Tests now use `tsx` for module loading. When running tests manually:

```sh
mocha --node-option import=tsx 'test/**/*.test.ts'
```

Or use the npm script:

```sh
npm test
```

##### Related Issues & PRs

[PR 1649](https://github.com/raineorshine/npm-check-updates/pull/1649)

***

### [`v20.0.2`](https://github.com/raineorshine/npm-check-updates/releases/tag/v20.0.2)

[Compare Source](https://github.com/raineorshine/npm-check-updates/compare/v20.0.1...v20.0.2)

#### What's Changed

- Show auto-detected cooldown source at normal log level; fix test isolation by [@&#8203;bayraak](https://github.com/bayraak) in [#&#8203;1662](https://github.com/raineorshine/npm-check-updates/pull/1662)

#### New Contributors

- [@&#8203;bayraak](https://github.com/bayraak) made their first contribution in [#&#8203;1662](https://github.com/raineorshine/npm-check-updates/pull/1662)

**Full Changelog**: <https://github.com/raineorshine/npm-check-updates/compare/v20.0.1...v20.0.2>

### [`v20.0.1`](https://github.com/raineorshine/npm-check-updates/releases/tag/v20.0.1)

[Compare Source](https://github.com/raineorshine/npm-check-updates/compare/v20.0.0...v20.0.1)

#### What's Changed

- Add CI workflow to verify build output is committed by [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;1645](https://github.com/raineorshine/npm-check-updates/pull/1645)
- Bump lodash-es from 4.17.23 to 4.18.1 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1647](https://github.com/raineorshine/npm-check-updates/pull/1647)
- Add Node v24 to test workflow by [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;1608](https://github.com/raineorshine/npm-check-updates/pull/1608)
- feat: remove pre-push git hook by [@&#8203;Zamiell](https://github.com/Zamiell) in [#&#8203;1658](https://github.com/raineorshine/npm-check-updates/pull/1658)
- feat: add verbose output when packages are skipped due to cooldown by [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;1659](https://github.com/raineorshine/npm-check-updates/pull/1659)

**Full Changelog**: <https://github.com/raineorshine/npm-check-updates/compare/v20.0.0...v20.0.1>

### [`v20.0.0`](https://github.com/raineorshine/npm-check-updates/blob/HEAD/CHANGELOG.md#2000---2026-03-31)

[Compare Source](https://github.com/raineorshine/npm-check-updates/compare/v19.6.6...v20.0.0)

##### Auto Cooldown

The cooldown option is now automatically applied from the respective package manager's config:

- **npm** - `min-release-age` ([#&#8203;1632](https://github.com/raineorshine/npm-check-updates/issues/1632))
- **yarn** - `npmMinimalAgeGate` (excluding `npmPreapprovedPackages`) ([#&#8203;1643](https://github.com/raineorshine/npm-check-updates/issues/1643))
- **pnpm** - `minimumReleaseAge` (excluding `minimumReleaseAgeExclude`) ([#&#8203;1639](https://github.com/raineorshine/npm-check-updates/issues/1639))

Why is this a breaking change?

- If you use any of the above configs, npm-check-updates will automatically exclude releases that do not exceed the specified minimum age as described in <https://github.com/raineorshine/npm-check-updates#cooldown>.
- Otherwise, you don't need to do anything.

##### Other changes

- Bump strip-ansi from 7.1.2 to 7.2.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1620](https://github.com/raineorshine/npm-check-updates/pull/1620)
- Bump lodash and [@&#8203;types/lodash](https://github.com/types/lodash) by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1615](https://github.com/raineorshine/npm-check-updates/pull/1615)
- Bump [@&#8203;typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/eslint-plugin) from 8.44.1 to 8.57.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1619](https://github.com/raineorshine/npm-check-updates/pull/1619)
- Bump hosted-git-info from 9.0.0 to 9.0.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1622](https://github.com/raineorshine/npm-check-updates/pull/1622)
- Bump glob and markdownlint-cli by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1625](https://github.com/raineorshine/npm-check-updates/pull/1625)
- update dependencies; fix vulnerabilities by [@&#8203;onemen](https://github.com/onemen) in [#&#8203;1630](https://github.com/raineorshine/npm-check-updates/pull/1630)
- Potential fix for code scanning alert no. 13: Incomplete string escaping or encoding by [@&#8203;raineorshine](https://github.com/raineorshine) in [#&#8203;1640](https://github.com/raineorshine/npm-check-updates/pull/1640)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- 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:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjEuMCIsInVwZGF0ZWRJblZlciI6IjQzLjEzNy4wIiwidGFyZ2V0QnJhbmNoIjoibmV4dCIsImxhYmVscyI6W119-->

Reviewed-on: https://git.valverde.cloud/Thilawyn/effect-fc/pulls/46
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Co-committed-by: Renovate Bot <renovate-bot@valverde.cloud>
2026-04-23 10:36:40 +02:00
78a2d2dede Update bun minor+patch updates (#45)
Some checks failed
Lint / lint (push) Has been cancelled
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@docusaurus/core](https://github.com/facebook/docusaurus) ([source](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus)) | [`3.9.2` → `3.10.0`](https://renovatebot.com/diffs/npm/@docusaurus%2fcore/3.9.2/3.10.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@docusaurus%2fcore/3.10.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@docusaurus%2fcore/3.9.2/3.10.0?slim=true) |
| [@docusaurus/module-type-aliases](https://github.com/facebook/docusaurus) ([source](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-module-type-aliases)) | [`3.9.2` → `3.10.0`](https://renovatebot.com/diffs/npm/@docusaurus%2fmodule-type-aliases/3.9.2/3.10.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@docusaurus%2fmodule-type-aliases/3.10.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@docusaurus%2fmodule-type-aliases/3.9.2/3.10.0?slim=true) |
| [@docusaurus/preset-classic](https://github.com/facebook/docusaurus) ([source](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-preset-classic)) | [`3.9.2` → `3.10.0`](https://renovatebot.com/diffs/npm/@docusaurus%2fpreset-classic/3.9.2/3.10.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@docusaurus%2fpreset-classic/3.10.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@docusaurus%2fpreset-classic/3.9.2/3.10.0?slim=true) |
| [@docusaurus/tsconfig](https://github.com/facebook/docusaurus) ([source](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-tsconfig)) | [`3.9.2` → `3.10.0`](https://renovatebot.com/diffs/npm/@docusaurus%2ftsconfig/3.9.2/3.10.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@docusaurus%2ftsconfig/3.10.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@docusaurus%2ftsconfig/3.9.2/3.10.0?slim=true) |
| [@docusaurus/types](https://github.com/facebook/docusaurus) ([source](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-types)) | [`3.9.2` → `3.10.0`](https://renovatebot.com/diffs/npm/@docusaurus%2ftypes/3.9.2/3.10.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@docusaurus%2ftypes/3.10.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@docusaurus%2ftypes/3.9.2/3.10.0?slim=true) |
| [@effect/language-service](https://github.com/Effect-TS/language-service) | [`^0.84.2` → `^0.85.0`](https://renovatebot.com/diffs/npm/@effect%2flanguage-service/0.84.3/0.85.1) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@effect%2flanguage-service/0.85.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@effect%2flanguage-service/0.84.3/0.85.1?slim=true) |

---

### Release Notes

<details>
<summary>facebook/docusaurus (@&#8203;docusaurus/core)</summary>

### [`v3.10.0`](https://github.com/facebook/docusaurus/blob/HEAD/CHANGELOG.md#3100-2026-04-07)

[Compare Source](https://github.com/facebook/docusaurus/compare/v3.9.2...v3.10.0)

##### 🚀 New Feature

- `docusaurus-types`, `docusaurus`
  - [#&#8203;11896](https://github.com/facebook/docusaurus/pull/11896) feat(core): add `future.v4.mdx1CompatDisabledByDefault` flag ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11797](https://github.com/facebook/docusaurus/pull/11797) feat(core): promote `siteConfig.storage` to stable + add `future.v4.siteStorageNamespacing` flag \[Claude] ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11571](https://github.com/facebook/docusaurus/pull/11571) feat(core): support custom html elements in head tags ([@&#8203;lebalz](https://github.com/lebalz))
- `create-docusaurus`
  - [#&#8203;11897](https://github.com/facebook/docusaurus/pull/11897) feat(create-docusaurus): update init template to `.mdx` extension and strict MDX syntax ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11696](https://github.com/facebook/docusaurus/pull/11696) feat(create-docusaurus): Newly initialized TS sites should use "strict: true" ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11611](https://github.com/facebook/docusaurus/pull/11611) feat(create-docusaurus): enable creation in current directory ([@&#8203;Mcheung7272](https://github.com/Mcheung7272))
- Other
  - [#&#8203;11874](https://github.com/facebook/docusaurus/pull/11874) feat(ci): improve npm supply chain security - improve Dependabot config ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11712](https://github.com/facebook/docusaurus/pull/11712) feat(publish): Use trusted publishing (OIDC) for canary releases ([@&#8203;slorber](https://github.com/slorber))
- `create-docusaurus`, `docusaurus-bundler`, `docusaurus-plugin-content-blog`, `docusaurus-plugin-content-docs`, `docusaurus-plugin-content-pages`, `docusaurus-plugin-pwa`, `docusaurus-types`, `docusaurus`
  - [#&#8203;11802](https://github.com/facebook/docusaurus/pull/11802) feat(core): Docusaurus Faster is stable + v4 future flag turns it on by default ([@&#8203;slorber](https://github.com/slorber))
- `docusaurus-mdx-loader`, `docusaurus-utils`, `docusaurus`
  - [#&#8203;11777](https://github.com/facebook/docusaurus/pull/11777) feat(cli): `write-heading-ids` CLI now supports the `--syntax` and `--migrate` options ([@&#8203;slorber](https://github.com/slorber))
- `docusaurus-mdx-loader`
  - [#&#8203;11755](https://github.com/facebook/docusaurus/pull/11755) feat(mdx-loader): add support for explicit `headingId` based on MD/MDX comments ([@&#8203;slorber](https://github.com/slorber))
- `docusaurus-theme-live-codeblock`, `docusaurus-theme-translations`
  - [#&#8203;11675](https://github.com/facebook/docusaurus/pull/11675) feat(theme-live-codeblock): reset button + wire `position` prop ([@&#8203;NPX2218](https://github.com/NPX2218))
- `docusaurus-theme-classic`, `docusaurus-theme-common`
  - [#&#8203;11734](https://github.com/facebook/docusaurus/pull/11734) feat(theme): Split `<DocCard>`, improve extensibility, better handling of emoji icons, stable classNames ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11733](https://github.com/facebook/docusaurus/pull/11733) feat(theme): Use React context for `<Tabs>`, allow custom `<TabItem>` components ([@&#8203;slorber](https://github.com/slorber))
- `docusaurus-faster`, `docusaurus`
  - [#&#8203;11715](https://github.com/facebook/docusaurus/pull/11715) feat(bundler): upgrade to Rspack 1.7, remove useless experimental feature flags ([@&#8203;slorber](https://github.com/slorber))
- `docusaurus-plugin-content-pages`
  - [#&#8203;11666](https://github.com/facebook/docusaurus/pull/11666) feat(pages): add support for Markdown file path links ([@&#8203;VedantMadane](https://github.com/VedantMadane))
- `docusaurus-mdx-loader`, `docusaurus-theme-classic`
  - [#&#8203;11642](https://github.com/facebook/docusaurus/pull/11642) feat(mdx-loader): add admonitions directive support for class/id shortcuts ([@&#8203;lebalz](https://github.com/lebalz))
- `docusaurus-theme-classic`
  - [#&#8203;11635](https://github.com/facebook/docusaurus/pull/11635) feat(theme): add MDXComponents/Li to swizzle config ([@&#8203;moskalakamil](https://github.com/moskalakamil))
- `docusaurus-theme-search-algolia`
  - [#&#8203;11581](https://github.com/facebook/docusaurus/pull/11581) feat(theme-search-algolia): allow overriding transformSearchClient ([@&#8203;hugohaggmark](https://github.com/hugohaggmark))
  - [#&#8203;11541](https://github.com/facebook/docusaurus/pull/11541) feat(theme-search-algolia): add support for DocSearch v4.3.2 and new Suggested Questions ([@&#8203;NatanTechofNY](https://github.com/NatanTechofNY))
- `create-docusaurus`, `docusaurus-plugin-content-blog`, `docusaurus-plugin-content-docs`, `docusaurus-plugin-content-pages`, `docusaurus-plugin-sitemap`, `docusaurus-types`, `docusaurus-utils`, `docusaurus`
  - [#&#8203;11512](https://github.com/facebook/docusaurus/pull/11512) feat(core): New siteConfig `future.experimental_vcs` API + `future.experimental_faster.gitEagerVcs` flag ([@&#8203;slorber](https://github.com/slorber))

##### 🐛 Bug Fix

- `docusaurus`
  - [#&#8203;11844](https://github.com/facebook/docusaurus/pull/11844) fix(core): fix `url.resolve()` Node.js deprecation warning ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11833](https://github.com/facebook/docusaurus/pull/11833) fix(core): upgrade serve handler min version to for upgrade users to a secure version ([@&#8203;BearAlliance](https://github.com/BearAlliance))
  - [#&#8203;11763](https://github.com/facebook/docusaurus/pull/11763) fix(cli): fix `write-heading-ids` CLI when no files provided ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11693](https://github.com/facebook/docusaurus/pull/11693) fix(core): Remove deprecated experiments.lazyBarrel config for RsPack ([@&#8203;VedikaGupt](https://github.com/VedikaGupt))
  - [#&#8203;11604](https://github.com/facebook/docusaurus/pull/11604) fix(core): webpack aliases shouldn't be created for test files and typedefs ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11603](https://github.com/facebook/docusaurus/pull/11603) fix(core): Fix openBrowser AppleScript support for Arc ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11579](https://github.com/facebook/docusaurus/pull/11579) fix(core): in `isInternalUrl()`, URI protocol scheme detection should implement the spec more strictly ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11550](https://github.com/facebook/docusaurus/pull/11550) fix(core): optimize i18n integration for site builds + improve inference of locale config ([@&#8203;slorber](https://github.com/slorber))
- `docusaurus-faster`, `docusaurus`
  - [#&#8203;11817](https://github.com/facebook/docusaurus/pull/11817) fix(faster): upgrade Rspack, fix Yarn PnP support ([@&#8203;slorber](https://github.com/slorber))
- `create-docusaurus`, `docusaurus-logger`, `docusaurus-plugin-content-blog`, `docusaurus-plugin-content-docs`, `docusaurus-plugin-google-gtag`, `docusaurus-plugin-pwa`, `docusaurus`
  - [#&#8203;11843](https://github.com/facebook/docusaurus/pull/11843) fix(create-docusaurus): fix support for TypeScript 6.0 + fix our CI ([@&#8203;slorber](https://github.com/slorber))
- `docusaurus-utils`
  - [#&#8203;11804](https://github.com/facebook/docusaurus/pull/11804) fix(utils): Git Eager VSC should have better DX ([@&#8203;slorber](https://github.com/slorber))
- `docusaurus-theme-classic`
  - [#&#8203;11796](https://github.com/facebook/docusaurus/pull/11796) fix(theme): restore copy-text-to-clipboard as lazy fallback for non-secure contexts ([@&#8203;dmoranp](https://github.com/dmoranp))
  - [#&#8203;11513](https://github.com/facebook/docusaurus/pull/11513) fix(a11y): add Space key support for navbar dropdowns ([@&#8203;TheCyperpunk](https://github.com/TheCyperpunk))
  - [#&#8203;11565](https://github.com/facebook/docusaurus/pull/11565) fix(theme): Change code block line from span to div, fix Firefox text selection/copy bug ([@&#8203;slorber](https://github.com/slorber))
- `docusaurus-plugin-content-docs`
  - [#&#8203;11794](https://github.com/facebook/docusaurus/pull/11794) fix(content-docs): translate generated-index category titles in pagination links ([@&#8203;dmoranp](https://github.com/dmoranp))
  - [#&#8203;11743](https://github.com/facebook/docusaurus/pull/11743) fix(content-docs): use category key for generated-index translation lookup ([@&#8203;4RH1T3CT0R7](https://github.com/4RH1T3CT0R7))
  - [#&#8203;11616](https://github.com/facebook/docusaurus/pull/11616) fix(docs): breadcrumb APIs only return category/docs items, ignoring links ([@&#8203;Chesars](https://github.com/Chesars))
- `docusaurus-plugin-google-gtag`
  - [#&#8203;11770](https://github.com/facebook/docusaurus/pull/11770) fix(create-docusaurus): update [@&#8203;types/gtag](https://github.com/types/gtag).js to 0.0.20 ([@&#8203;fresh3nough](https://github.com/fresh3nough))
- `docusaurus-theme-search-algolia`
  - [#&#8203;11683](https://github.com/facebook/docusaurus/pull/11683) fix(algolia): upgrade to DocSearch 4.5 + fix types ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11560](https://github.com/facebook/docusaurus/pull/11560) fix(theme-search-algolia): preserve query strings in useSearchResultUrlProcessor ([@&#8203;pyrytakala](https://github.com/pyrytakala))
- `docusaurus-plugin-content-blog`
  - [#&#8203;11736](https://github.com/facebook/docusaurus/pull/11736) fix(content-blog): fix wrong path variable in feed XSLT CSS file validation ([@&#8203;akshatsinha0](https://github.com/akshatsinha0))
  - [#&#8203;11577](https://github.com/facebook/docusaurus/pull/11577) fix(blog): Fix author paginated page url: `/blog/authors/<author>/page/2` ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11562](https://github.com/facebook/docusaurus/pull/11562) chore(blog): refactor blog Content, remove useless `blogListPaginated` attribute ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11559](https://github.com/facebook/docusaurus/pull/11559) fix(content-blog): filter unlisted posts from author pages ([@&#8203;pyrytakala](https://github.com/pyrytakala))
- `docusaurus-theme-classic`, `docusaurus-theme-common`
  - [#&#8203;11713](https://github.com/facebook/docusaurus/pull/11713) fix(a11y): remove `useKeyboardNavigation` hook ([@&#8203;nmggithub](https://github.com/nmggithub))
- `docusaurus-plugin-ideal-image`
  - [#&#8203;11659](https://github.com/facebook/docusaurus/pull/11659) fix(ideal-image): `<IdealImage>` should forward remaining props to the underlying component ([@&#8203;tempoz](https://github.com/tempoz))
- `eslint-plugin`
  - [#&#8203;11587](https://github.com/facebook/docusaurus/pull/11587) fix(eslint-plugin): specify exact type of `no-untranslated-text` rule options ([@&#8203;andreww2012](https://github.com/andreww2012))
- `docusaurus-mdx-loader`
  - [#&#8203;11530](https://github.com/facebook/docusaurus/pull/11530) fix(mdx-loader): fix url.parse deprecation warning on Node 24+ ([@&#8203;kou029w](https://github.com/kou029w))
- `docusaurus-bundler`, `docusaurus-faster`, `docusaurus-theme-mermaid`
  - [#&#8203;11496](https://github.com/facebook/docusaurus/pull/11496) fix(faster): fix server build SWC / browserslist node target ([@&#8203;slorber](https://github.com/slorber))

##### :running\_woman: Performance

- `docusaurus-plugin-content-blog`
  - [#&#8203;11707](https://github.com/facebook/docusaurus/pull/11707) refactor(content-blog): decouple getTagsFile from generateBlogPosts ([@&#8203;garry00107](https://github.com/garry00107))
- `create-docusaurus`, `docusaurus-utils`, `docusaurus`
  - [#&#8203;11684](https://github.com/facebook/docusaurus/pull/11684) refactor(create-docusaurus): remove useless dependencies (docusaurus-utils, execa, fs-extra) + simplify some code ([@&#8203;slorber](https://github.com/slorber))
- `create-docusaurus`
  - [#&#8203;11653](https://github.com/facebook/docusaurus/pull/11653) refactor(create-docusaurus): replace lodash with native implementation ([@&#8203;torresgol10](https://github.com/torresgol10))

##### 📝 Documentation

- `docusaurus`
  - [#&#8203;11779](https://github.com/facebook/docusaurus/pull/11779) chore(website): migrate MDX heading ids to comment syntax + upgrade Crowdin parser version ([@&#8203;slorber](https://github.com/slorber))
- Other
  - [#&#8203;11784](https://github.com/facebook/docusaurus/pull/11784) docs(website): change recommended syntax for math equations ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11623](https://github.com/facebook/docusaurus/pull/11623) docs: Add expose-markdown-docusaurus-plugin resource ([@&#8203;FlyNumber](https://github.com/FlyNumber))

##### 🤖 Dependencies

- Other
  - [#&#8203;11886](https://github.com/facebook/docusaurus/pull/11886) chore(deps): bump react-json-view-lite from 2.3.0 to 2.5.0 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11885](https://github.com/facebook/docusaurus/pull/11885) chore(deps): bump postcss from 8.5.4 to 8.5.8 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11888](https://github.com/facebook/docusaurus/pull/11888) chore(deps): bump lodash from 4.17.23 to 4.18.1 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11882](https://github.com/facebook/docusaurus/pull/11882) chore(deps): bump [@&#8203;babel/core](https://github.com/babel/core) from 7.28.6 to 7.29.0 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11880](https://github.com/facebook/docusaurus/pull/11880) chore(deps): bump fs-extra and [@&#8203;types/fs-extra](https://github.com/types/fs-extra) ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11861](https://github.com/facebook/docusaurus/pull/11861) chore(deps): bump preactjs/compressed-size-action from 2.9.0 to 2.9.1 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11851](https://github.com/facebook/docusaurus/pull/11851) chore(deps): bump handlebars from 4.7.7 to 4.7.9 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11849](https://github.com/facebook/docusaurus/pull/11849) chore(deps): bump convict from 6.2.4 to 6.2.5 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11857](https://github.com/facebook/docusaurus/pull/11857) chore(deps): bump node-forge from 1.3.2 to 1.4.0 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11838](https://github.com/facebook/docusaurus/pull/11838) chore(deps-dev): bump picomatch from 2.3.1 to 2.3.2 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11822](https://github.com/facebook/docusaurus/pull/11822) chore(deps): bump flatted from 3.3.1 to 3.4.2 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11810](https://github.com/facebook/docusaurus/pull/11810) chore(deps): bump marocchino/sticky-pull-request-comment from 2.9.4 to 3.0.2 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11811](https://github.com/facebook/docusaurus/pull/11811) chore(deps): bump treosh/lighthouse-ci-action from 12.6.1 to 12.6.2 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11813](https://github.com/facebook/docusaurus/pull/11813) chore(deps): bump socket.io-parser from 4.2.4 to 4.2.6 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11806](https://github.com/facebook/docusaurus/pull/11806) chore(deps): bump yauzl from 3.1.3 to 3.2.1 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11789](https://github.com/facebook/docusaurus/pull/11789) chore(deps): bump actions/dependency-review-action from 4.8.3 to 4.9.0 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11790](https://github.com/facebook/docusaurus/pull/11790) chore(deps): bump actions/setup-node from 6.2.0 to 6.3.0 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11776](https://github.com/facebook/docusaurus/pull/11776) chore(deps): bump dompurify from 3.2.5 to 3.3.2 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11768](https://github.com/facebook/docusaurus/pull/11768) chore(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11774](https://github.com/facebook/docusaurus/pull/11774) chore(deps): bump svgo from 3.2.0 to 3.3.3 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11762](https://github.com/facebook/docusaurus/pull/11762) chore(deps): bump rollup from 2.79.2 to 2.80.0 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11756](https://github.com/facebook/docusaurus/pull/11756) chore(deps): bump actions/dependency-review-action from 4.8.2 to 4.8.3 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11692](https://github.com/facebook/docusaurus/pull/11692) chore(deps): bump actions/checkout from 6.0.1 to 6.0.2 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11679](https://github.com/facebook/docusaurus/pull/11679) chore(deps): bump lodash from 4.17.21 to 4.17.23 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11674](https://github.com/facebook/docusaurus/pull/11674) chore(deps): bump actions/setup-node from 6.1.0 to 6.2.0 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11625](https://github.com/facebook/docusaurus/pull/11625) chore(deps): bump preactjs/compressed-size-action from 2.8.0 to 2.9.0 - pin all remaining GitHub actions ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11608](https://github.com/facebook/docusaurus/pull/11608) chore(deps): bump actions/setup-node from 6.0.0 to 6.1.0 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11609](https://github.com/facebook/docusaurus/pull/11609) chore(deps): bump actions/checkout from 6.0.0 to 6.0.1 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11589](https://github.com/facebook/docusaurus/pull/11589) chore(deps): bump mdast-util-to-hast from 13.2.0 to 13.2.1 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11574](https://github.com/facebook/docusaurus/pull/11574) chore(deps): bump node-forge from 1.3.1 to 1.3.2 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11557](https://github.com/facebook/docusaurus/pull/11557) chore(deps): bump actions/dependency-review-action from 4.8.1 to 4.8.2 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11569](https://github.com/facebook/docusaurus/pull/11569) chore(deps): bump actions/checkout from 5.0.0 to 6.0.0 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11551](https://github.com/facebook/docusaurus/pull/11551) chore(deps): bump js-yaml from 4.1.0 to 4.1.1 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11514](https://github.com/facebook/docusaurus/pull/11514) chore(deps): bump actions/upload-artifact from 4 to 5 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11515](https://github.com/facebook/docusaurus/pull/11515) chore(deps): bump github/codeql-action from 4.30.9 to 4.31.0 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11504](https://github.com/facebook/docusaurus/pull/11504) chore(deps): bump github/codeql-action from 4.30.8 to 4.30.9 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
  - [#&#8203;11503](https://github.com/facebook/docusaurus/pull/11503) chore(deps): bump actions/setup-node from 5.0.0 to 6.0.0 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))
- `docusaurus-bundler`, `docusaurus-mdx-loader`
  - [#&#8203;11717](https://github.com/facebook/docusaurus/pull/11717) chore(deps): bump webpack from 5.95.0 to 5.104.1 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))

##### 🔧 Maintenance

- Other
  - [#&#8203;11846](https://github.com/facebook/docusaurus/pull/11846) chore(website): disable `mdx1Compat.comments` on our site ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11845](https://github.com/facebook/docusaurus/pull/11845) chore(website): Upgrade to Algolia v4.6 ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11795](https://github.com/facebook/docusaurus/pull/11795) chore(ci): canary/trusted publishing shouldn't use any caching ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11753](https://github.com/facebook/docusaurus/pull/11753) chore: Add basic AGENTS.md ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11639](https://github.com/facebook/docusaurus/pull/11639) test(jest): simplify Jest snapshotPathNormalizer.ts ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11626](https://github.com/facebook/docusaurus/pull/11626) chore(website): upgrade to DocSearch 4.4.0 + fix little website theming issues ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11553](https://github.com/facebook/docusaurus/pull/11553) chore(ci): upgrade Netlify to Node 24 (LTS) + add `git backfill` command ([@&#8203;slorber](https://github.com/slorber))
- `create-docusaurus`, `docusaurus-babel`, `docusaurus-bundler`, `docusaurus-cssnano-preset`, `docusaurus-faster`, `docusaurus-logger`, `docusaurus-mdx-loader`, `docusaurus-module-type-aliases`, `docusaurus-plugin-client-redirects`, `docusaurus-plugin-content-blog`, `docusaurus-plugin-content-docs`, `docusaurus-plugin-content-pages`, `docusaurus-plugin-css-cascade-layers`, `docusaurus-plugin-debug`, `docusaurus-plugin-google-analytics`, `docusaurus-plugin-google-gtag`, `docusaurus-plugin-google-tag-manager`, `docusaurus-plugin-ideal-image`, `docusaurus-plugin-pwa`, `docusaurus-plugin-rsdoctor`, `docusaurus-plugin-sitemap`, `docusaurus-plugin-svgr`, `docusaurus-plugin-vercel-analytics`, `docusaurus-preset-classic`, `docusaurus-remark-plugin-npm2yarn`, `docusaurus-theme-classic`, `docusaurus-theme-common`, `docusaurus-theme-live-codeblock`, `docusaurus-theme-mermaid`, `docusaurus-theme-search-algolia`, `docusaurus-theme-translations`, `docusaurus-tsconfig`, `docusaurus-types`, `docusaurus-utils-common`, `docusaurus-utils-validation`, `docusaurus-utils`, `docusaurus`, `eslint-plugin`, `lqip-loader`, `stylelint-copyright`
  - [#&#8203;11823](https://github.com/facebook/docusaurus/pull/11823) chore(ci): fixes for the npm trusted publishing workflow ([@&#8203;slorber](https://github.com/slorber))
  - [#&#8203;11819](https://github.com/facebook/docusaurus/pull/11819) chore(ci): add Trusted Publishing release workflow through dispatch action ([@&#8203;slorber](https://github.com/slorber))
- `docusaurus-plugin-content-docs`, `docusaurus-plugin-ideal-image`, `docusaurus-theme-classic`, `docusaurus-theme-common`, `docusaurus-theme-mermaid`, `docusaurus-utils`, `docusaurus`
  - [#&#8203;11698](https://github.com/facebook/docusaurus/pull/11698) chore(monorepo): upgrade React packages to v19 ([@&#8203;slorber](https://github.com/slorber))
- `docusaurus-cssnano-preset`, `docusaurus-logger`, `docusaurus-mdx-loader`, `docusaurus-plugin-client-redirects`, `docusaurus-plugin-content-blog`, `docusaurus-plugin-content-docs`, `docusaurus-plugin-content-pages`, `docusaurus-plugin-ideal-image`, `docusaurus-remark-plugin-npm2yarn`, `docusaurus-theme-classic`, `docusaurus-theme-common`, `docusaurus-utils-validation`, `docusaurus-utils`, `docusaurus`
  - [#&#8203;11702](https://github.com/facebook/docusaurus/pull/11702) chore(monorepo): upgrade to Jest 30 ([@&#8203;slorber](https://github.com/slorber))
- `docusaurus-theme-classic`, `docusaurus-theme-common`, `docusaurus`
  - [#&#8203;11697](https://github.com/facebook/docusaurus/pull/11697) chore(monorepo): upgrade React monorepo types to v19 ([@&#8203;slorber](https://github.com/slorber))
- `docusaurus-babel`
  - [#&#8203;11586](https://github.com/facebook/docusaurus/pull/11586) chore(deps): remove unused [@&#8203;babel/runtime-corejs3](https://github.com/babel/runtime-corejs3) dependency ([@&#8203;JustinBeckwith](https://github.com/JustinBeckwith))
- `docusaurus-plugin-content-blog`
  - [#&#8203;11564](https://github.com/facebook/docusaurus/pull/11564) test(blog): Add basic tests for blog routes. ([@&#8203;slorber](https://github.com/slorber))

##### :globe\_with\_meridians: Translations

- `docusaurus-theme-translations`
  - [#&#8203;11632](https://github.com/facebook/docusaurus/pull/11632) feat(i18n): add Urdu (ur) default theme translations ([@&#8203;hammadurrehman2006](https://github.com/hammadurrehman2006))
  - [#&#8203;11533](https://github.com/facebook/docusaurus/pull/11533) fix(translations): complete theme translations for Algolia pt-br ([@&#8203;luicfrr](https://github.com/luicfrr))

##### Committers: 41

- Akshat Sinha ([@&#8203;akshatsinha0](https://github.com/akshatsinha0))
- Aleksandar Zgonjan ([@&#8203;acosoft](https://github.com/acosoft))
- Andrew Kazakov ([@&#8203;andreww2012](https://github.com/andreww2012))
- Anukool Pandey ([@&#8203;ANUKOOL324](https://github.com/ANUKOOL324))
- Artem Lytkin ([@&#8203;4RH1T3CT0R7](https://github.com/4RH1T3CT0R7))
- Balthasar Hofer ([@&#8203;lebalz](https://github.com/lebalz))
- Bhoomi Sharma ([@&#8203;Bhoomi070](https://github.com/Bhoomi070))
- Cesar Garcia ([@&#8203;Chesars](https://github.com/Chesars))
- Denny Morán ([@&#8203;dmoranp](https://github.com/dmoranp))
- Dmitriy Rotaenko ([@&#8203;dmitriyrotaenko](https://github.com/dmitriyrotaenko))
- Eoin Shaughnessy ([@&#8203;EoinTrial](https://github.com/EoinTrial))
- Gaurav Sulsule ([@&#8203;garry00107](https://github.com/garry00107))
- Gnana Eswar Gunturu ([@&#8203;GnanaEswarGunturu](https://github.com/GnanaEswarGunturu))
- Hugo Häggmark ([@&#8203;hugohaggmark](https://github.com/hugohaggmark))
- Ivan Torres ([@&#8203;torresgol10](https://github.com/torresgol10))
- Justin Beckwith ([@&#8203;JustinBeckwith](https://github.com/JustinBeckwith))
- Kamil Moskała ([@&#8203;moskalakamil](https://github.com/moskalakamil))
- Kohei Watanabe ([@&#8203;kou029w](https://github.com/kou029w))
- Kuldeep Prasad Mishra ([@&#8203;kmish9685](https://github.com/kmish9685))
- Kunwardeep Singh ([@&#8203;work109677-sudo](https://github.com/work109677-sudo))
- Luiz Carlos ([@&#8203;luicfrr](https://github.com/luicfrr))
- Matthew Cheung ([@&#8203;Mcheung7272](https://github.com/Mcheung7272))
- Max Clayton Clowes ([@&#8203;mcclowes](https://github.com/mcclowes))
- Misrilal ([@&#8203;Misrilal-Sah](https://github.com/Misrilal-Sah))
- Muhammad Hammad ur Rehman ([@&#8203;hammadurrehman2006](https://github.com/hammadurrehman2006))
- Nader Jaber ([@&#8203;FlyNumber](https://github.com/FlyNumber))
- Natan Yagudayev ([@&#8203;NatanTechofNY](https://github.com/NatanTechofNY))
- Neel Bansal ([@&#8203;NPX2218](https://github.com/NPX2218))
- Nick Cacace ([@&#8203;BearAlliance](https://github.com/BearAlliance))
- Noah Gregory ([@&#8203;nmggithub](https://github.com/nmggithub))
- Poetry Of Code ([@&#8203;poetryofcode](https://github.com/poetryofcode))
- Pyry Takala ([@&#8203;pyrytakala](https://github.com/pyrytakala))
- Salman Chishti ([@&#8203;salmanmkc](https://github.com/salmanmkc))
- Sreehari Upas ([@&#8203;SreehariU](https://github.com/SreehariU))
- Sébastien Lorber ([@&#8203;slorber](https://github.com/slorber))
- Vedant Madane ([@&#8203;VedantMadane](https://github.com/VedantMadane))
- Vedika Gupta ([@&#8203;VedikaGupt](https://github.com/VedikaGupt))
- Zoey Greer ([@&#8203;tempoz](https://github.com/tempoz))
- [@&#8203;TheCyperpunk](https://github.com/TheCyperpunk)
- [@&#8203;snikkrs](https://github.com/snikkrs)
- fre$h ([@&#8203;fresh3nough](https://github.com/fresh3nough)

</details>

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

### [`v0.85.1`](https://github.com/Effect-TS/language-service/releases/tag/%40effect/language-service%400.85.1)

[Compare Source](https://github.com/Effect-TS/language-service/compare/@effect/language-service@0.85.0...@effect/language-service@0.85.1)

##### Patch Changes

- [#&#8203;726](https://github.com/Effect-TS/language-service/pull/726) [`fd4a8da`](fd4a8da7f4) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Update the Effect v4 beta examples and type parsing to match the renamed Context APIs in the latest 4.0.0-beta releases.

- [#&#8203;724](https://github.com/Effect-TS/language-service/pull/724) [`14d5798`](14d57985e2) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Refactor Effect context tracking to use cached node context flags and direct generator lookups.

  This aligns the TypeScript implementation more closely with the TSGo version and simplifies diagnostics that need to detect whether code is inside an Effect generator.

### [`v0.85.0`](https://github.com/Effect-TS/language-service/releases/tag/%40effect/language-service%400.85.0)

[Compare Source](https://github.com/Effect-TS/language-service/compare/@effect/language-service@0.84.3...@effect/language-service@0.85.0)

##### Minor Changes

- [#&#8203;720](https://github.com/Effect-TS/language-service/pull/720) [`4229bb9`](4229bb9ec8) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add the `nestedEffectGenYield` diagnostic to detect `yield* Effect.gen(...)` inside an existing Effect generator context.

  Example:

  ```ts
  Effect.gen(function* () {
    yield* Effect.gen(function* () {
      yield* Effect.succeed(1);
    });
  });
  ```

- [#&#8203;723](https://github.com/Effect-TS/language-service/pull/723) [`da9cc4b`](da9cc4bed7) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add the `effectMapFlatten` style diagnostic for `Effect.map(...)` immediately followed by `Effect.flatten` in pipe flows.

  Example:

  ```ts
  import { Effect } from "effect";

  const program = Effect.succeed(1).pipe(
    Effect.map((n) => Effect.succeed(n + 1)),
    Effect.flatten
  );
  ```

- [#&#8203;718](https://github.com/Effect-TS/language-service/pull/718) [`0af7c0f`](0af7c0f48f) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add the `lazyPromiseInEffectSync` diagnostic to catch `Effect.sync(() => Promise...)` patterns and suggest using `Effect.promise` or `Effect.tryPromise` for async work.

  Example:

  ```ts
  Effect.sync(() => Promise.resolve(1));
  ```

- [#&#8203;714](https://github.com/Effect-TS/language-service/pull/714) [`32985b2`](32985b2cf6) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add `processEnv` and `processEnvInEffect` diagnostics to guide `process.env.*` reads toward Effect `Config` APIs.

  Examples:

  - `process.env.PORT`
  - `process.env["API_KEY"]`

- [#&#8203;721](https://github.com/Effect-TS/language-service/pull/721) [`f05ae89`](f05ae898bb) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add the `unnecessaryArrowBlock` style diagnostic for arrow functions whose block body only returns an expression.

  Example:

  ```ts
  const trim = (value: string) => {
    return value.trim();
  };
  ```

- [#&#8203;717](https://github.com/Effect-TS/language-service/pull/717) [`b77848a`](b77848a6ed) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add `newPromise` and `asyncFunction` effect-native diagnostics to report manual `Promise` construction and async function declarations, with guidance toward Effect-based async control flow.

- [#&#8203;722](https://github.com/Effect-TS/language-service/pull/722) [`6f19858`](6f19858808) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add the `effectDoNotation` style diagnostic for `Effect.Do` usage and suggest migrating to `Effect.gen` or `Effect.fn`.

  Example:

  ```ts
  import { pipe } from "effect/Function";
  import { Effect } from "effect";

  const program = pipe(
    Effect.Do,
    Effect.bind("a", () => Effect.succeed(1)),
    Effect.let("b", ({ a }) => a + 1)
  );
  ```

- [#&#8203;716](https://github.com/Effect-TS/language-service/pull/716) [`c3f67b0`](c3f67b0411) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Add `cryptoRandomUUID` and `cryptoRandomUUIDInEffect` diagnostics for Effect v4 to discourage `crypto.randomUUID()` in favor of the Effect `Random` module, which uses Effect-injected randomness instead of the global crypto implementation.

##### Patch Changes

- [#&#8203;719](https://github.com/Effect-TS/language-service/pull/719) [`d23980a`](d23980a785) Thanks [@&#8203;mattiamanzati](https://github.com/mattiamanzati)! - Update the Effect v4 beta dependencies to `4.0.0-beta.43` for the language service and v4 harness packages.

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- 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.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- 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:eyJjcmVhdGVkSW5WZXIiOiI0My4xMDkuNSIsInVwZGF0ZWRJblZlciI6IjQzLjExMC4xNCIsInRhcmdldEJyYW5jaCI6Im5leHQiLCJsYWJlbHMiOltdfQ==-->

Reviewed-on: #45
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Co-committed-by: Renovate Bot <renovate-bot@valverde.cloud>
2026-04-23 10:36:24 +02:00
ff13e941e3 0.2.5 (#43)
All checks were successful
Publish / publish (push) Successful in 52s
Lint / lint (push) Successful in 14s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Reviewed-on: #43
2026-03-31 21:01:12 +02:00
67b01d4621 0.2.4 (#38)
All checks were successful
Publish / publish (push) Successful in 59s
Lint / lint (push) Successful in 15s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Reviewed-on: #38
2026-03-16 00:30:17 +01:00
40 changed files with 2213 additions and 1228 deletions

0
.codex Normal file
View File

713
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@effect-fc/monorepo", "name": "@effect-fc/monorepo",
"packageManager": "bun@1.3.6", "packageManager": "bun@1.3.11",
"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.3.11", "@biomejs/biome": "^2.4.9",
"@effect/language-service": "^0.75.0", "@effect/language-service": "^0.85.0",
"@types/bun": "^1.3.6", "@types/bun": "^1.3.11",
"npm-check-updates": "^19.3.1", "npm-check-updates": "^22.0.0",
"npm-sort": "^0.0.4", "npm-sort": "^0.0.4",
"turbo": "^2.7.5", "turbo": "^2.8.21",
"typescript": "^5.9.3" "typescript": "^6.0.2"
} }
} }

8
packages/docs/biome.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"root": false,
"extends": "//",
"files": {
"includes": ["./src/**"]
}
}

View File

@@ -4,7 +4,7 @@ sidebar_position: 1
# Effect FC # Effect FC
Welcome to **Effect FC** a powerful integration of [Effect-TS](https://effect.website/) with React 19.2+ that enables you to write function components using Effect generators. Welcome to **Effect FC** (as in: Effect **F**unction **C**omponent) a powerful integration of [Effect](https://effect.website/) with React 19.2+ that enables you to write React function components using Effect generators.
## What is Effect FC? ## What is Effect FC?
@@ -12,42 +12,54 @@ Effect FC allows you to harness the full power of Effect-TS within your React co
### Key Features ### Key Features
- **Effect-TS Integration**: Write components using Effect generators for powerful, composable effects - **Effect Integration**: Write your function component logic using Effect.
- **Type Safety**: Full TypeScript support with Effect's comprehensive type system - **Type Safety**: Full TypeScript support with Effect's comprehensive type system
- **Resource Management**: Automatic cleanup and finalization of resources - **Dependency Injection**: Built-in support for providing dependencies to components using Effect services.
- **Dependency Injection**: Built-in support for providing dependencies to components - **Resource Management**: Automatic cleanup and finalization of component resources using the `Scope` API.
- **React 19.2+ Compatible**: Leverages the latest React features
## Quick Example ## Quick Example
Here's what writing an Effect FC component looks like: Here's what writing an Effect FC component looks like:
```typescript ```typescript
export class Todos extends Component.make("Todos")(function*() { export class TodosView extends Component.make("TodosView")(function*() {
const state = yield* TodosState const state = yield* TodosState
const [todos] = yield* useSubscribables(state.ref) const [todos] = yield* Component.useSubscribables([state.subscriptionRef])
yield* 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 TodoFC = yield* Todo const Todo = yield* TodoView.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">
<TodoFC _tag="new" /> <Todo _tag="new" />
{Chunk.map(todos, todo => {Chunk.map(todos, todo =>
<TodoFC key={todo.id} _tag="edit" id={todo.id} /> <Todo key={todo.id} _tag="edit" id={todo.id} />
)} )}
</Flex> </Flex>
</Container> </Container>
) )
}) {} }) {}
const Index = Component.make("IndexView")(function*() {
const context = yield* Component.useContextFromLayer(TodosState.Default)
const Todos = yield* Effect.provide(TodosView.use, context)
return <Todos />
}).pipe(
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/")({
component: Index
})
``` ```
## Getting Started ## Getting Started
@@ -80,7 +92,6 @@ pnpm add effect-fc effect react
- Explore the [Tutorial Basics](./tutorial-basics/create-a-document.md) to learn the fundamentals - Explore the [Tutorial Basics](./tutorial-basics/create-a-document.md) to learn the fundamentals
- Check out the [Example Project](https://github.com/your-repo/packages/example) for a complete working application - Check out the [Example Project](https://github.com/your-repo/packages/example) for a complete working application
- Read the [API Documentation](./api/) for detailed reference
## Important Notes ## Important Notes

View File

@@ -1,6 +1,6 @@
import {themes as prismThemes} from 'prism-react-renderer';
import type {Config} from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic'; 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...) // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)

View File

@@ -15,8 +15,8 @@
"typecheck": "tsc" "typecheck": "tsc"
}, },
"dependencies": { "dependencies": {
"@docusaurus/core": "3.9.2", "@docusaurus/core": "3.10.0",
"@docusaurus/preset-classic": "3.9.2", "@docusaurus/preset-classic": "3.10.0",
"@mdx-js/react": "^3.0.0", "@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0", "prism-react-renderer": "^2.3.0",
@@ -24,10 +24,10 @@
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@docusaurus/module-type-aliases": "3.9.2", "@docusaurus/module-type-aliases": "3.10.0",
"@docusaurus/tsconfig": "3.9.2", "@docusaurus/tsconfig": "3.10.0",
"@docusaurus/types": "3.9.2", "@docusaurus/types": "3.10.0",
"typescript": "~5.6.2" "typescript": "~6.0.0"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [

View File

@@ -1,10 +1,10 @@
import type {ReactNode} from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link'; import Link from '@docusaurus/Link';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import HomepageFeatures from '@site/src/components/HomepageFeatures'; import HomepageFeatures from '@site/src/components/HomepageFeatures';
import Heading from '@theme/Heading'; import Heading from '@theme/Heading';
import Layout from '@theme/Layout';
import clsx from 'clsx';
import type {ReactNode} from 'react';
import styles from './index.module.css'; import styles from './index.module.css';

View File

@@ -15,39 +15,37 @@ Documentation is currently being written. In the meantime, you can take a look a
## What writing components looks like ## What writing components looks like
```typescript ```typescript
export class Todos extends Component.make("Todos")(function*() { export class TodosView extends Component.make("TodosView")(function*() {
const state = yield* TodosState const state = yield* TodosState
const [todos] = yield* useSubscribables(state.ref) const [todos] = yield* Component.useSubscribables([state.subscriptionRef])
yield* 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 TodoFC = yield* Todo const Todo = yield* TodoView.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">
<TodoFC _tag="new" /> <Todo _tag="new" />
{Chunk.map(todos, todo => {Chunk.map(todos, todo =>
<TodoFC key={todo.id} _tag="edit" id={todo.id} /> <Todo key={todo.id} _tag="edit" id={todo.id} />
)} )}
</Flex> </Flex>
</Container> </Container>
) )
}) {} }) {}
const TodosStateLive = TodosState.Default("todos") const Index = Component.make("IndexView")(function*() {
const context = yield* Component.useContextFromLayer(TodosState.Default)
const Todos = yield* Effect.provide(TodosView.use, context)
const Index = Component.make("Index")(function*() { return <Todos />
const context = yield* useContext(TodosStateLive)
const TodosFC = yield* Effect.provide(Todos, context)
return <TodosFC />
}).pipe( }).pipe(
Component.withRuntime(runtime.context) Component.withRuntime(runtime.context)
) )

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.2.3", "version": "0.2.5",
"type": "module", "type": "module",
"files": [ "files": [
"./README.md", "./README.md",
@@ -38,11 +38,14 @@
"clean:modules": "rm -rf node_modules" "clean:modules": "rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"@effect/platform-browser": "^0.74.0" "@effect/platform-browser": "^0.76.0"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"effect": "^3.19.0", "effect": "^3.21.0",
"react": "^19.2.0" "react": "^19.2.0"
},
"dependencies": {
"effect-lens": "^0.1.4"
} }
} }

View File

@@ -1,35 +1,49 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ /** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { Effect, Function, Predicate, Runtime, Scope } from "effect" import { Effect, type Equivalence, 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 TypeId: unique symbol = Symbol.for("@effect-fc/Async/Async") export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
export type TypeId = typeof TypeId export type AsyncTypeId = typeof AsyncTypeId
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 { /**
export interface Options { * Configuration options for `Async` components.
readonly defaultFallback?: React.ReactNode */
} export interface AsyncOptions {
/**
export type Props = Omit<React.SuspenseProps, "children"> * The default fallback React node to display while the async operation is pending.
* Used if no fallback is provided to the component when rendering.
*/
readonly defaultFallback?: React.ReactNode
} }
/**
* Props for `Async` components.
*/
export type AsyncProps = Omit<React.SuspenseProps, "children">
const AsyncProto = Object.freeze({
[TypeId]: TypeId,
makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R>( export const AsyncPrototype: AsyncPrototype = Object.freeze({
[AsyncTypeId]: AsyncTypeId,
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
this: Component.Component<P, A, E, R> & Async, this: Component.Component<P, A, E, R> & Async,
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>, runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
) { ) {
const SuspenseInner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise) const Inner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
return ({ fallback, name, ...props }: Async.Props) => { return ({ fallback, name, ...props }: AsyncProps) => {
const promise = Runtime.runPromise(runtimeRef.current)( const promise = Runtime.runPromise(runtimeRef.current)(
Effect.andThen( Effect.andThen(
Component.useScope([], this), Component.useScope([], this),
@@ -40,45 +54,116 @@ const AsyncProto = Object.freeze({
return React.createElement( return React.createElement(
React.Suspense, React.Suspense,
{ fallback: fallback ?? this.defaultFallback, name }, { fallback: fallback ?? this.defaultFallback, name },
React.createElement(SuspenseInner, { promise }), React.createElement(Inner, { 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
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, TypeId) for (const key in self) {
if (key === "fallback")
continue
if (!(key in that) || !Object.is(self[key], that[key]))
return false
}
for (const key in that) {
if (key === "fallback")
continue
if (!(key in self))
return false
}
return true
}
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, AsyncTypeId)
/**
* Converts a Component into an `Async` component that supports running asynchronous effects.
*
* Note: The component cannot have a prop named "promise" as it's reserved for internal use.
*
* @param self - The component to convert to an Async component
* @returns A new `Async` component with the same body, error, and context types as the input
*
* @example
* ```ts
* const MyAsyncComponent = MyComponent.pipe(
* Async.async,
* )
* ```
*/
export const async = <T extends Component.Component<any, any, any, any>>( export const async = <T extends Component.Component<any, any, any, any>>(
self: T 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> & Async.Props, Component.Component.Props<T> & AsyncProps,
Component.Component.Success<T>, Component.Component.Success<T>,
Component.Component.Error<T>, Component.Component.Error<T>,
Component.Component.Context<T> Component.Component.Context<T>
> >
& Async & Async
) => Object.setPrototypeOf( ) => Object.setPrototypeOf(
Object.assign(function() {}, self), Object.assign(function() {}, self, { propsEquivalence: defaultPropsEquivalence }),
Object.freeze(Object.setPrototypeOf( Object.freeze(Object.setPrototypeOf(
Object.assign({}, AsyncProto), Object.assign({}, AsyncPrototype),
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, any, any, any> & Async>( <T extends Component.Component<any, any, any, any> & Async>(
options: Partial<Async.Options> options: Partial<AsyncOptions>
): (self: T) => T ): (self: T) => T
<T extends Component.Component<any, any, any, any> & Async>( <T extends Component.Component<any, any, any, any> & Async>(
self: T, self: T,
options: Partial<Async.Options>, options: Partial<AsyncOptions>,
): T ): T
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Async>( } = Function.dual(2, <T extends Component.Component<any, any, any, any> & Async>(
self: T, self: T,
options: Partial<Async.Options>, options: Partial<AsyncOptions>,
): T => Object.setPrototypeOf( ): T => Object.setPrototypeOf(
Object.assign(function() {}, self, options), Object.assign(function() {}, self, options),
Object.getPrototypeOf(self), Object.getPrototypeOf(self),

View File

@@ -1,38 +1,25 @@
/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */ /** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ /** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { Context, type Duration, Effect, Effectable, Equivalence, ExecutionStrategy, Exit, Fiber, Function, HashMap, Layer, ManagedRuntime, Option, Predicate, Ref, Runtime, Scope, Tracer, type Utils } from "effect" import { Context, type Duration, Effect, Equivalence, ExecutionStrategy, Exit, Fiber, Function, HashMap, identity, Layer, ManagedRuntime, Option, Pipeable, Predicate, Ref, Runtime, Scope, Tracer, type Utils } from "effect"
import * as React from "react" import * as React from "react"
import { Memoized } from "./index.js"
export const TypeId: unique symbol = Symbol.for("@effect-fc/Component/Component") export const ComponentTypeId: unique symbol = Symbol.for("@effect-fc/Component/Component")
export type TypeId = typeof TypeId export type ComponentTypeId = typeof ComponentTypeId
/** /**
* Interface representing an Effect-based React Component. * Represents an Effect-based React Component that integrates the Effect system with React.
*
* This is both:
* - an Effect that produces a React function component
* - a constructor-like object with component metadata and options
*/ */
export interface Component<P extends {}, A extends React.ReactNode, E, R> export interface Component<P extends {}, A extends React.ReactNode, E, R>
extends extends ComponentPrototype<P, A, R>, ComponentOptions {
Effect.Effect<(props: P) => A, never, Exclude<R, Scope.Scope>>,
Component.Options
{
new(_: never): Record<string, never> new(_: never): Record<string, never>
readonly [TypeId]: TypeId readonly [ComponentTypeId]: ComponentTypeId
readonly "~Props": P readonly "~Props": P
readonly "~Success": A readonly "~Success": A
readonly "~Error": E readonly "~Error": E
readonly "~Context": R readonly "~Context": R
readonly body: (props: P) => Effect.Effect<A, E, R> readonly body: (props: P) => Effect.Effect<A, E, R>
/** @internal */
makeFunctionComponent(
runtimeRef: React.Ref<Runtime.Runtime<Exclude<R, Scope.Scope>>>
): (props: P) => A
} }
export declare namespace Component { export declare namespace Component {
@@ -42,56 +29,29 @@ export declare namespace Component {
export type Context<T extends Component<any, any, any, any>> = [T] extends [Component<infer _P, infer _A, infer _E, infer R>] ? R : never export type Context<T extends Component<any, any, any, any>> = [T] extends [Component<infer _P, infer _A, infer _E, infer R>] ? R : never
export type AsComponent<T extends Component<any, any, any, any>> = Component<Props<T>, Success<T>, Error<T>, Context<T>> export type AsComponent<T extends Component<any, any, any, any>> = Component<Props<T>, Success<T>, Error<T>, Context<T>>
/**
* Options that can be set on the component
*/
export interface Options {
/** Custom displayName for React DevTools and debugging. */
readonly displayName?: string
/**
* Strategy used when executing finalizers on unmount/scope close.
* @default ExecutionStrategy.sequential
*/
readonly finalizerExecutionStrategy: ExecutionStrategy.ExecutionStrategy
/**
* Debounce time before executing finalizers after component unmount.
* Helps avoid unnecessary work during fast remount/remount cycles.
* @default "100 millis"
*/
readonly finalizerExecutionDebounce: Duration.DurationInput
}
} }
const ComponentProto = Object.freeze({ export interface ComponentPrototype<P extends {}, A extends React.ReactNode, R>
...Effectable.CommitPrototype, extends Pipeable.Pipeable {
[TypeId]: TypeId, readonly [ComponentTypeId]: ComponentTypeId
readonly use: Effect.Effect<(props: P) => A, never, Exclude<R, Scope.Scope>>
commit: Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode, E, R>( asFunctionComponent(
this: Component<P, A, E, R> runtimeRef: React.Ref<Runtime.Runtime<Exclude<R, Scope.Scope>>>
) { ): (props: P) => A
// biome-ignore lint/style/noNonNullAssertion: React ref initialization
const runtimeRef = React.useRef<Runtime.Runtime<Exclude<R, Scope.Scope>>>(null!)
runtimeRef.current = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
return yield* React.useState(() => Runtime.runSync(runtimeRef.current)(Effect.cachedFunction( setFunctionComponentName(f: React.FC<P>): void
(_services: readonly any[]) => Effect.sync(() => { transformFunctionComponent(f: React.FC<P>): React.FC<P>
const f: React.FC<P> = this.makeFunctionComponent(runtimeRef) }
f.displayName = this.displayName ?? "Anonymous"
return Memoized.isMemoized(this)
? React.memo(f, this.propsAreEqual)
: f
}),
Equivalence.array(Equivalence.strict()),
)))[0](Array.from(
Context.omit(...nonReactiveTags)(runtimeRef.current.context).unsafeMap.values()
))
}),
makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R>( export const ComponentPrototype: ComponentPrototype<any, any, any> = Object.freeze({
[ComponentTypeId]: ComponentTypeId,
...Pipeable.Prototype,
get use() { return use(this) },
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
this: Component<P, A, E, R>, this: Component<P, A, E, R>,
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>, runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
) { ) {
@@ -102,17 +62,76 @@ const ComponentProto = Object.freeze({
) )
) )
}, },
setFunctionComponentName<P extends {}, A extends React.ReactNode, E, R>(
this: Component<P, A, E, R>,
f: React.FC<P>,
) {
f.displayName = this.displayName ?? "Anonymous"
},
transformFunctionComponent: identity,
} as const) } as const)
const defaultOptions: Component.Options = { const use = Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode, E, R>(
self: Component<P, A, E, R>
) {
// biome-ignore lint/style/noNonNullAssertion: React ref initialization
const runtimeRef = React.useRef<Runtime.Runtime<Exclude<R, Scope.Scope>>>(null!)
runtimeRef.current = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
return yield* React.useState(() => Runtime.runSync(runtimeRef.current)(Effect.cachedFunction(
(_services: readonly any[]) => Effect.sync(() => {
const f: React.FC<P> = self.asFunctionComponent(runtimeRef)
self.setFunctionComponentName(f)
return self.transformFunctionComponent(f)
}),
Equivalence.array(Equivalence.strict()),
)))[0](Array.from(
Context.omit(...self.nonReactiveTags)(runtimeRef.current.context).unsafeMap.values()
))
})
export interface ComponentOptions {
/**
* Custom display name for the component in React DevTools and debugging utilities.
*/
readonly displayName?: string
/**
* Context tags that should not trigger component remount when their values change.
*
* @default [Tracer.ParentSpan]
*/
readonly nonReactiveTags: readonly Context.Tag<any, any>[]
/**
* Specifies the execution strategy for finalizers when the component unmounts or its scope closes.
* Determines whether finalizers execute sequentially or in parallel.
*
* @default ExecutionStrategy.sequential
*/
readonly finalizerExecutionStrategy: ExecutionStrategy.ExecutionStrategy
/**
* Debounce duration before executing finalizers after component unmount.
* Prevents unnecessary cleanup work during rapid remount/unmount cycles,
* which is common in development and certain UI patterns.
*
* @default "100 millis"
*/
readonly finalizerExecutionDebounce: Duration.DurationInput
}
export const defaultOptions: ComponentOptions = {
nonReactiveTags: [Tracer.ParentSpan],
finalizerExecutionStrategy: ExecutionStrategy.sequential, finalizerExecutionStrategy: ExecutionStrategy.sequential,
finalizerExecutionDebounce: "100 millis", finalizerExecutionDebounce: "100 millis",
} }
const nonReactiveTags = [Tracer.ParentSpan] as const
export const isComponent = (u: unknown): u is Component<{}, React.ReactNode, unknown, unknown> => Predicate.hasProperty(u, ComponentTypeId)
export const isComponent = (u: unknown): u is Component<{}, React.ReactNode, unknown, unknown> => Predicate.hasProperty(u, TypeId)
export declare namespace make { export declare namespace make {
export type Gen = { export type Gen = {
@@ -340,17 +359,51 @@ export declare namespace make {
} }
/** /**
* Creates an Effect-FC Component following the same overloads and pipeline style as `Effect.fn`. * Creates an Effect-FC Component using the same overloads and pipeline composition style as `Effect.fn`.
* *
* This is the **recommended** way to define components. It supports: * This is the **recommended** approach for defining Effect-FC components. It provides comprehensive
* - Generator syntax (yield* style) — most ergonomic and readable * support for multiple component definition patterns:
* - Direct Effect return (non-generator)
* - Chained transformation functions (like Effect.fn pipelines)
* - Optional tracing span with automatic `displayName`
* *
* When you provide a `spanName` as the first argument, two things happen automatically: * - **Generator syntax** (yield* style): Most ergonomic and readable approach for sequential operations
* 1. A tracing span is created with that name (unless using `makeUntraced`) * - **Direct Effect return**: For simple components that return an Effect directly
* 2. The resulting React component gets `displayName = spanName` * - **Chained transformation functions**: Enables Effect.fn-style pipelines for composable transformations
* - **Automatic tracing**: Optional tracing span creation with automatic `displayName` assignment
*
* When a `spanName` string is provided, the following occurs automatically:
* 1. A distributed tracing span is created with the specified name
* 2. The resulting React component receives `displayName = spanName` for DevTools visibility
*
* @example
* ```tsx
* const MyComponent = Component.make("MyComponent")(function* (props: { count: number }) {
* const value = yield* someEffect
* return <div>{value}</div>
* })
* ```
*
* @example As an opaque type using class syntax
* ```tsx
* class MyComponent extends Component.make("MyComponent")(function* (props: { count: number }) {
* const value = yield* someEffect
* return <div>{value}</div>
* }) {}
* ```
*
* @example Without name
* ```tsx
* class MyComponent extends Component.make(function* (props: { count: number }) {
* const value = yield* someEffect
* return <div>{value}</div>
* }) {}
* ```
*
* @example Using pipeline
* ```tsx
* class MyComponent extends Component.make("MyComponent")(
* (props: { count: number }) => someEffect,
* Effect.map(value => <div>{value}</div>),
* ) {}
* ```
*/ */
export const make: ( export const make: (
& make.Gen & make.Gen
@@ -365,7 +418,7 @@ export const make: (
Object.assign(function() {}, defaultOptions, { Object.assign(function() {}, defaultOptions, {
body: Effect.fn(spanNameOrBody as any, ...pipeables), body: Effect.fn(spanNameOrBody as any, ...pipeables),
}), }),
ComponentProto, ComponentPrototype,
) )
} }
else { else {
@@ -375,21 +428,56 @@ export const make: (
body: Effect.fn(spanNameOrBody, spanOptions)(body, ...pipeables as []), body: Effect.fn(spanNameOrBody, spanOptions)(body, ...pipeables as []),
displayName: spanNameOrBody, displayName: spanNameOrBody,
}), }),
ComponentProto, ComponentPrototype,
) )
} }
} }
/** /**
* Same as `make`, but creates an **untraced** version — no automatic tracing span is created. * Creates an Effect-FC Component without automatic distributed tracing.
* *
* Follows the exact same API shape as `Effect.fnUntraced`. * This function provides the same API surface as `make`, but does not create automatic tracing spans.
* Useful for: * It follows the exact same overload structure as `Effect.fnUntraced`.
* - Components where you want full manual control over tracing
* - Avoiding span noise in deeply nested UI
* *
* When a string is provided as first argument, it is **only** used as the React component's `displayName` * Use this variant when you need:
* (no tracing span is created). * - Full manual control over tracing instrumentation
* - To reduce tracing overhead in deeply nested component hierarchies
* - To avoid span noise in performance-sensitive applications
*
* When a `spanName` string is provided, it is used **exclusively** as the React component's
* `displayName` for DevTools identification. No tracing span is created.
*
* @example
* ```tsx
* const MyComponent = Component.makeUntraced("MyComponent")(function* (props: { count: number }) {
* const value = yield* someEffect
* return <div>{value}</div>
* })
* ```
*
* @example As an opaque type using class syntax
* ```tsx
* class MyComponent extends Component.makeUntraced("MyComponent")(function* (props: { count: number }) {
* const value = yield* someEffect
* return <div>{value}</div>
* }) {}
* ```
*
* @example Without name
* ```tsx
* class MyComponent extends Component.makeUntraced(function* (props: { count: number }) {
* const value = yield* someEffect
* return <div>{value}</div>
* }) {}
* ```
*
* @example Using pipeline
* ```tsx
* class MyComponent extends Component.makeUntraced("MyComponent")(
* (props: { count: number }) => someEffect,
* Effect.map(value => <div>{value}</div>),
* ) {}
* ```
*/ */
export const makeUntraced: ( export const makeUntraced: (
& make.Gen & make.Gen
@@ -401,52 +489,71 @@ export const makeUntraced: (
Object.assign(function() {}, defaultOptions, { Object.assign(function() {}, defaultOptions, {
body: Effect.fnUntraced(spanNameOrBody as any, ...pipeables as []), body: Effect.fnUntraced(spanNameOrBody as any, ...pipeables as []),
}), }),
ComponentProto, ComponentPrototype,
) )
: (body: any, ...pipeables: any[]) => Object.setPrototypeOf( : (body: any, ...pipeables: any[]) => Object.setPrototypeOf(
Object.assign(function() {}, defaultOptions, { Object.assign(function() {}, defaultOptions, {
body: Effect.fnUntraced(body, ...pipeables as []), body: Effect.fnUntraced(body, ...pipeables as []),
displayName: spanNameOrBody, displayName: spanNameOrBody,
}), }),
ComponentProto, ComponentPrototype,
) )
) )
/** /**
* Creates a new component with modified options while preserving original behavior. * Creates a new component with modified configuration options while preserving all original behavior.
*
* This function allows you to customize component-level options such as finalizer execution strategy
* and debounce timing.
*
* @example
* ```tsx
* const MyComponentWithCustomOptions = MyComponent.pipe(
* Component.withOptions({
* finalizerExecutionStrategy: ExecutionStrategy.parallel,
* finalizerExecutionDebounce: "50 millis",
* })
* )
* ```
*/ */
export const withOptions: { export const withOptions: {
<T extends Component<any, any, any, any>>( <T extends Component<any, any, any, any>>(
options: Partial<Component.Options> options: Partial<ComponentOptions>
): (self: T) => T ): (self: T) => T
<T extends Component<any, any, any, any>>( <T extends Component<any, any, any, any>>(
self: T, self: T,
options: Partial<Component.Options>, options: Partial<ComponentOptions>,
): T ): T
} = Function.dual(2, <T extends Component<any, any, any, any>>( } = Function.dual(2, <T extends Component<any, any, any, any>>(
self: T, self: T,
options: Partial<Component.Options>, options: Partial<ComponentOptions>,
): T => Object.setPrototypeOf( ): T => Object.setPrototypeOf(
Object.assign(function() {}, self, options), Object.assign(function() {}, self, options),
Object.getPrototypeOf(self), Object.getPrototypeOf(self),
)) ))
/** /**
* Wraps an Effect-FC `Component` and turns it into a regular React function component * Wraps an Effect-FC Component and converts it into a standard React function component,
* that serves as an **entrypoint** into an Effect-FC component hierarchy. * serving as an **entrypoint** into an Effect-FC component hierarchy.
* *
* This is the recommended way to connect Effect-FC components to the rest of your React app, * This is how Effect-FC components are integrated with the broader React ecosystem,
* especially when using routers (TanStack Router, React Router, etc.), lazy-loaded routes, * particularly when:
* or any place where a standard React component is expected. * - Using client-side routers (TanStack Router, React Router, etc.)
* - Implementing lazy-loaded or code-split routes
* - Connecting to third-party libraries expecting standard React components
* - Creating component boundaries between Effect-FC and non-Effect-FC code
* *
* The runtime is obtained from the provided React Context, allowing you to: * The Effect runtime is obtained from the provided React Context.
* - Provide dependencies once at a high level
* - Use the same runtime across an entire route tree or feature
* *
* @example Using TanStack Router * @param self - The Effect-FC Component to be rendered as a standard React component
* @param context - React Context providing the Effect Runtime for this component tree.
* Create this using the `ReactRuntime` module.
*
* @example Integration with TanStack Router
* ```tsx * ```tsx
* // Main * // Application root
* export const runtime = ReactRuntime.make(Layer.empty) * export const runtime = ReactRuntime.make(Layer.empty)
*
* function App() { * function App() {
* return ( * return (
* <ReactRuntime.Provider runtime={runtime}> * <ReactRuntime.Provider runtime={runtime}>
@@ -455,14 +562,12 @@ export const withOptions: {
* ) * )
* } * }
* *
* // Route * // Route definition
* export const Route = createFileRoute("/")({ * export const Route = createFileRoute("/")({
* component: Component.withRuntime(HomePage, runtime.context) * component: Component.withRuntime(HomePage, runtime.context)
* }) * })
* ``` * ```
* *
* @param self - The Effect-FC Component you want to render as a regular React component.
* @param context - React Context that holds the Runtime to use for this component tree. See the `ReactRuntime` module to create one.
*/ */
export const withRuntime: { export const withRuntime: {
<P extends {}, A extends React.ReactNode, E, R>( <P extends {}, A extends React.ReactNode, E, R>(
@@ -477,15 +582,17 @@ export const withRuntime: {
context: React.Context<Runtime.Runtime<R>>, context: React.Context<Runtime.Runtime<R>>,
) => function WithRuntime(props: P) { ) => function WithRuntime(props: P) {
return React.createElement( return React.createElement(
Runtime.runSync(React.useContext(context))(self), Runtime.runSync(React.useContext(context))(self.use),
props, props,
) )
}) })
/** /**
* Service that keeps track of scopes associated with React components * Internal Effect service that maintains a registry of scopes associated with React component instances.
* (used internally by the `useScope` hook). *
* This service is used internally by the `useScope` hook to manage the lifecycle of component scopes,
* including tracking active scopes and coordinating their cleanup when components unmount or dependencies change.
*/ */
export class ScopeMap extends Effect.Service<ScopeMap>()("@effect-fc/Component/ScopeMap", { export class ScopeMap extends Effect.Service<ScopeMap>()("@effect-fc/Component/ScopeMap", {
effect: Effect.bind(Effect.Do, "ref", () => Ref.make(HashMap.empty<object, ScopeMap.Entry>())) effect: Effect.bind(Effect.Do, "ref", () => Ref.make(HashMap.empty<object, ScopeMap.Entry>()))
@@ -507,13 +614,22 @@ export declare namespace useScope {
} }
/** /**
* Hook that creates and manages a `Scope` for the current component instance. * Effect hook that creates and manages a `Scope` for the current component instance.
* *
* Automatically closes the scope whenever `deps` changes or the component unmounts. * This hook establishes a new scope that is automatically closed when:
* - The component unmounts
* - The dependency array `deps` changes
* *
* @param deps - dependency array like in `React.useEffect` * The scope provides a resource management boundary for any Effects executed within the component,
* @param options - finalizer execution control * ensuring proper cleanup of resources and execution of finalizers.
*/ *
* @param deps - Dependency array following React.useEffect semantics. The scope is recreated
* whenever any dependency changes.
* @param options - Configuration for finalizer execution behavior, including execution strategy
* and debounce timing.
*
* @returns An Effect that produces a `Scope` for resource management
*/
export const useScope = Effect.fnUntraced(function*( export const useScope = Effect.fnUntraced(function*(
deps: React.DependencyList, deps: React.DependencyList,
options?: useScope.Options, options?: useScope.Options,
@@ -567,7 +683,23 @@ export const useScope = Effect.fnUntraced(function*(
}) })
/** /**
* Runs an effect and returns its result only once on component mount. * Effect hook that executes an Effect once when the component mounts and caches the result.
*
* This hook is useful for one-time initialization logic that should not be re-executed
* when the component re-renders. The Effect is executed exactly once during the component's
* initial mount, and the cached result is returned on all subsequent renders.
*
* @param f - A function that returns the Effect to execute on mount
*
* @returns An Effect that produces the cached result of the Effect
*
* @example
* ```tsx
* const MyComponent = Component.make(function*() {
* const initialData = yield* Component.useOnMount(() => getData)
* return <div>{initialData}</div>
* })
* ```
*/ */
export const useOnMount = Effect.fnUntraced(function* <A, E, R>( export const useOnMount = Effect.fnUntraced(function* <A, E, R>(
f: () => Effect.Effect<A, E, R> f: () => Effect.Effect<A, E, R>
@@ -581,9 +713,33 @@ export declare namespace useOnChange {
} }
/** /**
* Runs an effect and returns its result whenever dependencies change. * Effect hook that executes an Effect whenever dependencies change and caches the result.
* *
* Provides its own `Scope` which closes whenever `deps` changes or the component unmounts. * This hook combines the dependency-tracking behavior of React.useEffect with Effect caching.
* The Effect is re-executed whenever any dependency in the `deps` array changes, and the result
* is cached until the next dependency change.
*
* A dedicated scope is created for each dependency change, ensuring proper resource cleanup:
* - The scope closes when dependencies change
* - The scope closes when the component unmounts
* - All finalizers are executed according to the configured execution strategy
*
* @param f - A function that returns the Effect to execute
* @param deps - Dependency array following React.useEffect semantics
* @param options - Configuration for scope and finalizer behavior
*
* @returns An Effect that produces the cached result of the Effect
*
* @example
* ```tsx
* const MyComponent = Component.make(function* (props: { userId: string }) {
* const userData = yield* Component.useOnChange(
* getUser(props.userId),
* [props.userId],
* )
* return <div>{userData.name}</div>
* })
* ```
*/ */
export const useOnChange = Effect.fnUntraced(function* <A, E, R>( export const useOnChange = Effect.fnUntraced(function* <A, E, R>(
f: () => Effect.Effect<A, E, R>, f: () => Effect.Effect<A, E, R>,
@@ -607,9 +763,36 @@ export declare namespace useReactEffect {
} }
/** /**
* Like `React.useEffect` but accepts an effect. * Effect hook that provides Effect-based semantics for React.useEffect.
* *
* Cleanup logic is handled through the `Scope` API rather than using imperative cleanup. * This hook bridges React's useEffect with the Effect system, allowing you to use Effects
* for React side effects while maintaining React's dependency tracking and lifecycle semantics.
*
* Unlike React.useEffect which uses imperative cleanup functions, this hook leverages the
* Effect Scope API for resource management. Cleanup logic is expressed declaratively through
* finalizers registered with the scope, providing better composability and error handling.
*
* @param f - A function that returns an Effect to execute as a side effect
* @param deps - Optional dependency array following React.useEffect semantics.
* If omitted, the effect runs after every render.
* @param options - Configuration for finalizer execution mode (sync or fork) and strategy
*
* @returns An Effect that produces void
*
* @example
* ```tsx
* const MyComponent = Component.make(function* (props: { id: string }) {
* yield* Component.useReactEffect(
* () => getNotificationStreamForUser(props.id).pipe(
* Stream.unwrap,
* Stream.runForEach(notification => Console.log(`Notification received: ${ notification }`),
* Effect.forkScoped,
* ),
* [props.id],
* )
* return <div>Subscribed to notifications for {props.id}</div>
* })
* ```
*/ */
export const useReactEffect = Effect.fnUntraced(function* <E, R>( export const useReactEffect = Effect.fnUntraced(function* <E, R>(
f: () => Effect.Effect<void, E, R>, f: () => Effect.Effect<void, E, R>,
@@ -648,9 +831,43 @@ export declare namespace useReactLayoutEffect {
} }
/** /**
* Like `React.useReactLayoutEffect` but accepts an effect. * Effect hook that provides Effect-based semantics for React.useLayoutEffect.
* *
* Cleanup logic is handled through the `Scope` API rather than using imperative cleanup. * This hook is identical to `useReactEffect` but executes synchronously after DOM mutations
* but before the browser paints, following React.useLayoutEffect semantics.
*
* Use this hook when you need to:
* - Measure DOM elements (e.g., for layout calculations)
* - Synchronously update state based on DOM measurements
* - Avoid visual flicker from asynchronous updates
*
* Like `useReactEffect`, cleanup logic is handled through the Effect Scope API rather than
* imperative cleanup functions, providing declarative and composable resource management.
*
* @param f - A function that returns an Effect to execute as a layout side effect
* @param deps - Optional dependency array following React.useLayoutEffect semantics.
* If omitted, the effect runs after every render.
* @param options - Configuration for finalizer execution mode (sync or fork) and strategy
*
* @returns An Effect that produces void
*
* @example
* ```tsx
* const MyComponent = Component.make(function*() {
* const ref = React.useRef<HTMLDivElement>(null)
* yield* Component.useReactLayoutEffect(
* () => Effect.gen(function* () {
* const element = ref.current
* if (element) {
* const rect = element.getBoundingClientRect()
* yield* Console.log(`Element dimensions: ${ rect.width }x${ rect.height }`)
* }
* }),
* [],
* )
* return <div ref={ref}>Content</div>
* })
* ```
*/ */
export const useReactLayoutEffect = Effect.fnUntraced(function* <E, R>( export const useReactLayoutEffect = Effect.fnUntraced(function* <E, R>(
f: () => Effect.Effect<void, E, R>, f: () => Effect.Effect<void, E, R>,
@@ -663,7 +880,23 @@ export const useReactLayoutEffect = Effect.fnUntraced(function* <E, R>(
}) })
/** /**
* Get a synchronous run function for the current runtime context. * Effect hook that provides a synchronous function to execute Effects within the current runtime context.
*
* This hook returns a function that can execute Effects synchronously, blocking until completion.
* Use this when you need to run Effects from non-Effect code (e.g., event handlers, callbacks)
* within a component.
*
* @returns An Effect that produces a function capable of synchronously executing Effects
*
* @example
* ```tsx
* const MyComponent = Component.make(function*() {
* const runSync = yield* Component.useRunSync<SomeService>() // Specify required services
* const runSync = yield* Component.useRunSync() // Or no service requirements
*
* return <button onClick={() => runSync(someEffect)}>Click me</button>
* })
* ```
*/ */
export const useRunSync = <R = never>(): Effect.Effect< export const useRunSync = <R = never>(): Effect.Effect<
<A, E = never>(effect: Effect.Effect<A, E, Scope.Scope | R>) => A, <A, E = never>(effect: Effect.Effect<A, E, Scope.Scope | R>) => A,
@@ -672,7 +905,23 @@ export const useRunSync = <R = never>(): Effect.Effect<
> => Effect.andThen(Effect.runtime(), Runtime.runSync) > => Effect.andThen(Effect.runtime(), Runtime.runSync)
/** /**
* Get a Promise-based run function for the current runtime context. * Effect hook that provides an asynchronous function to execute Effects within the current runtime context.
*
* This hook returns a function that executes Effects asynchronously, returning a Promise that resolves
* with the Effect's result. Use this when you need to run Effects from non-Effect code (e.g., event handlers,
* async callbacks) and want to handle the result asynchronously.
*
* @returns An Effect that produces a function capable of asynchronously executing Effects
*
* @example
* ```tsx
* const MyComponent = Component.make(function*() {
* const runPromise = yield* Component.useRunPromise<SomeService>() // Specify required services
* const runPromise = yield* Component.useRunPromise() // Or no service requirements
*
* return <button onClick={() => runPromise(someEffect)}>Click me</button>
* })
* ```
*/ */
export const useRunPromise = <R = never>(): Effect.Effect< export const useRunPromise = <R = never>(): Effect.Effect<
<A, E = never>(effect: Effect.Effect<A, E, Scope.Scope | R>) => Promise<A>, <A, E = never>(effect: Effect.Effect<A, E, Scope.Scope | R>) => Promise<A>,
@@ -681,7 +930,32 @@ export const useRunPromise = <R = never>(): Effect.Effect<
> => Effect.andThen(Effect.runtime(), context => Runtime.runPromise(context)) > => Effect.andThen(Effect.runtime(), context => Runtime.runPromise(context))
/** /**
* Turns a function returning an effect into a memoized synchronous function. * Effect hook that memoizes a function that returns an Effect, providing synchronous execution.
*
* This hook wraps a function that returns an Effect and returns a memoized version that:
* - Executes the Effect synchronously when called
* - Is memoized based on the provided dependency array
* - Maintains referential equality across renders when dependencies don't change
*
* Use this to create stable callback references for event handlers and other scenarios
* where you need to execute Effects synchronously from non-Effect code.
*
* @param f - A function that accepts arguments and returns an Effect
* @param deps - Dependency array. The memoized function is recreated when dependencies change.
*
* @returns An Effect that produces a memoized function with the same signature as `f`
*
* @example
* ```tsx
* const MyComponent = Component.make(function* (props: { onSave: (data: Data) => void }) {
* const handleSave = yield* Component.useCallbackSync(
* (data: Data) => Effect.sync(() => props.onSave(data)),
* [props.onSave],
* )
*
* return <button onClick={() => handleSave(myData)}>Save</button>
* })
* ```
*/ */
export const useCallbackSync = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>( export const useCallbackSync = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
f: (...args: Args) => Effect.Effect<A, E, R>, f: (...args: Args) => Effect.Effect<A, E, R>,
@@ -696,7 +970,32 @@ export const useCallbackSync = Effect.fnUntraced(function* <Args extends unknown
}) })
/** /**
* Turns a function returning an effect into a memoized Promise-based asynchronous function. * Effect hook that memoizes a function that returns an Effect, providing asynchronous execution.
*
* This hook wraps a function that returns an Effect and returns a memoized version that:
* - Executes the Effect asynchronously when called, returning a Promise
* - Is memoized based on the provided dependency array
* - Maintains referential equality across renders when dependencies don't change
*
* Use this to create stable callback references for async event handlers and other scenarios
* where you need to execute Effects asynchronously from non-Effect code.
*
* @param f - A function that accepts arguments and returns an Effect
* @param deps - Dependency array. The memoized function is recreated when dependencies change.
*
* @returns An Effect that produces a memoized function that returns a Promise
*
* @example
* ```tsx
* const MyComponent = Component.make(function* (props: { onSave: (data: Data) => void }) {
* const handleSave = yield* Component.useCallbackPromise(
* (data: Data) => Effect.promise(() => props.onSave(data)),
* [props.onSave],
* )
*
* return <button onClick={() => handleSave(myData)}>Save</button>
* })
* ```
*/ */
export const useCallbackPromise = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>( export const useCallbackPromise = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
f: (...args: Args) => Effect.Effect<A, E, R>, f: (...args: Args) => Effect.Effect<A, E, R>,
@@ -715,16 +1014,70 @@ export declare namespace useContext {
} }
/** /**
* Hook that constructs a layer and returns the created context. * Effect hook that constructs an Effect Layer and returns the resulting context.
* *
* The layer gets reconstructed everytime `layer` changes, so make sure its value is stable. * This hook creates a managed runtime from the provided layer and returns the context it produces.
* The layer is reconstructed whenever its value changes, so ensure the layer reference is stable
* (typically by memoizing it or defining it outside the component).
* *
* Building a layer containing asynchronous effects require the component calling this hook to be made async using `Async.async`. * The hook automatically manages the layer's lifecycle:
* - The layer is built when the component mounts or when the layer reference changes
* - Resources are properly released when the component unmounts or dependencies change
* - Finalizers are executed according to the configured execution strategy
*
* @param layer - The Effect Layer to construct. Should be a stable reference to avoid unnecessary
* reconstruction. Consider memoizing with React.useMemo if defined inline.
* @param options - Configuration for scope and finalizer behavior
*
* @returns An Effect that produces the context created by the layer
*
* @throws If the layer contains asynchronous effects, the component must be wrapped with `Async.async`
*
* @example
* ```tsx
* const MyLayer = Layer.succeed(MyService, new MyServiceImpl())
* const MyComponent = Component.make(function*() {
* const context = yield* Component.useContextFromLayer(MyLayer)
* const Sub = yield* SubComponent.use.pipe(
* Effect.provide(context)
* )
*
* return <Sub />
* })
* ```
*
* @example With memoized layer
* ```tsx
* const MyComponent = Component.make(function*(props: { id: string })) {
* const context = yield* Component.useContextFromLayer(
* React.useMemo(() => Layer.succeed(MyService, new MyServiceImpl(props.id)), [props.id])
* )
* const Sub = yield* SubComponent.use.pipe(
* Effect.provide(context)
* )
*
* return <Sub />
* })
* ```
*
* @example With async layer
* ```tsx
* const MyAsyncLayer = Layer.effect(MyService, someAsyncEffect)
* const MyComponent = Component.make(function*() {
* const context = yield* Component.useContextFromLayer(MyAsyncLayer)
* const Sub = yield* SubComponent.use.pipe(
* Effect.provide(context)
* )
*
* return <Sub />
* }).pipe(
* Async.async // Required to handle async layer effects
* )
*/ */
export const useContext = <ROut, E, RIn>( export const useContextFromLayer = <ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>, layer: Layer.Layer<ROut, E, RIn>,
options?: useContext.Options, options?: useContext.Options,
): Effect.Effect<Context.Context<ROut>, E, Exclude<RIn, Scope.Scope>> => useOnChange(() => Effect.context<RIn>().pipe( ): Effect.Effect<Context.Context<ROut>, E, RIn | Scope.Scope> => useOnChange(() => Effect.context<RIn>().pipe(
Effect.map(context => ManagedRuntime.make(Layer.provide(layer, Layer.succeedContext(context)))), Effect.map(context => ManagedRuntime.make(Layer.provide(layer, Layer.succeedContext(context)))),
Effect.tap(runtime => Effect.addFinalizer(() => runtime.disposeEffect)), Effect.tap(runtime => Effect.addFinalizer(() => runtime.disposeEffect)),
Effect.andThen(runtime => runtime.runtimeEffect), Effect.andThen(runtime => runtime.runtimeEffect),

View File

@@ -1,20 +1,20 @@
import { type Cause, Context, Effect, Exit, Layer, Option, Pipeable, Predicate, PubSub, type Queue, type Scope, Supervisor } from "effect" import { type Cause, Context, Effect, Exit, Layer, Option, Pipeable, Predicate, PubSub, type Queue, type Scope, Supervisor } from "effect"
export const TypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver") export const ErrorObserverTypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
export type TypeId = typeof TypeId export type ErrorObserverTypeId = typeof ErrorObserverTypeId
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable { export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable {
readonly [TypeId]: TypeId readonly [ErrorObserverTypeId]: ErrorObserverTypeId
handle<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> 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> 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 const ErrorObserver = <E = never>(): Context.Tag<ErrorObserver, ErrorObserver<E>> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver")
class ErrorObserverImpl<in out E = never> export class ErrorObserverImpl<in out E = never>
extends Pipeable.Class() implements ErrorObserver<E> { extends Pipeable.Class() implements ErrorObserver<E> {
readonly [TypeId]: TypeId = TypeId readonly [ErrorObserverTypeId]: ErrorObserverTypeId = ErrorObserverTypeId
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope> readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
constructor( constructor(
@@ -29,7 +29,7 @@ extends Pipeable.Class() implements ErrorObserver<E> {
} }
} }
class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> { export class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
readonly value = Effect.void readonly value = Effect.void
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<never>>) { constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<never>>) {
super() super()
@@ -43,7 +43,7 @@ class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
} }
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, TypeId) export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, ErrorObserverTypeId)
export const layer: Layer.Layer<ErrorObserver> = Layer.unwrapEffect(Effect.map( export const layer: Layer.Layer<ErrorObserver> = Layer.unwrapEffect(Effect.map(
PubSub.unbounded<Cause.Cause<never>>(), PubSub.unbounded<Cause.Cause<never>>(),

View File

@@ -1,105 +1,99 @@
import { Array, Cause, Chunk, type Context, type Duration, Effect, Equal, Exit, Fiber, flow, Hash, HashMap, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect" import { Array, Cause, Chunk, type Context, type Duration, Effect, Equal, Exit, Fiber, Function, identity, Option, ParseResult, Pipeable, Predicate, Schema, type Scope, Stream } from "effect"
import type * as React from "react" import type * as React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
import * as Lens from "./Lens.js"
import * as Mutation from "./Mutation.js" import * as Mutation from "./Mutation.js"
import * as PropertyPath from "./PropertyPath.js"
import * as Result from "./Result.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 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<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> export interface Form<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
extends Pipeable.Pipeable { extends Pipeable.Pipeable {
readonly [FormTypeId]: FormTypeId readonly [FormTypeId]: FormTypeId
readonly path: P
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>
readonly isValidating: Subscribable.Subscribable<boolean, ER, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
}
export class FormImpl<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
extends Pipeable.Class() implements Form<P, A, I, ER, EW> {
readonly [FormTypeId]: FormTypeId = FormTypeId
constructor(
readonly path: P,
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>,
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>,
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>,
readonly isValidating: Subscribable.Subscribable<boolean, never, never>,
readonly canCommit: Subscribable.Subscribable<boolean, never, never>,
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>,
) {
super()
}
}
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<readonly [], A, I, never, never> {
readonly [SubmittableFormTypeId]: SubmittableFormTypeId
readonly schema: Schema.Schema<A, I, R> readonly schema: Schema.Schema<A, I, R>
readonly context: Context.Context<Scope.Scope | R> readonly context: Context.Context<Scope.Scope | R>
readonly mutation: Mutation.Mutation< readonly mutation: Mutation.Mutation<
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>], readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP MA, ME, MR, MP
> >
readonly autosubmit: boolean readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never>
readonly debounce: Option.Option<Duration.DurationInput>
readonly value: Subscribable.Subscribable<Option.Option<A>>
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>
readonly error: Subscribable.Subscribable<Option.Option<ParseResult.ParseError>>
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>
readonly canSubmit: Subscribable.Subscribable<boolean>
field<const P extends PropertyPath.Paths<I>>(
path: P
): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>>
readonly run: Effect.Effect<void> readonly run: Effect.Effect<void>
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException> readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException>
} }
export class FormImpl<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> 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 Form<A, I, R, MA, ME, MR, MP> { extends Pipeable.Class() implements SubmittableForm<A, I, R, MA, ME, MR, MP> {
readonly [FormTypeId]: FormTypeId = FormTypeId readonly [FormTypeId]: FormTypeId = FormTypeId
readonly [SubmittableFormTypeId]: SubmittableFormTypeId = SubmittableFormTypeId
readonly path = [] as const
constructor( constructor(
readonly schema: Schema.Schema<A, I, R>, readonly schema: Schema.Schema<A, I, R>,
readonly context: Context.Context<Scope.Scope | R>, readonly context: Context.Context<Scope.Scope | R>,
readonly mutation: Mutation.Mutation< readonly mutation: Mutation.Mutation<
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>], readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP MA, ME, MR, MP
>, >,
readonly autosubmit: boolean, readonly value: Lens.Lens<Option.Option<A>, never, never, never, never>,
readonly debounce: Option.Option<Duration.DurationInput>, readonly encodedValue: 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 isValidating: Subscribable.Subscribable<boolean, never, never>,
readonly value: SubscriptionRef.SubscriptionRef<Option.Option<A>>, readonly canCommit: Subscribable.Subscribable<boolean, never, never>,
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>, readonly isCommitting: Subscribable.Subscribable<boolean, never, never>,
readonly error: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>,
readonly validationFiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>,
readonly runSemaphore: Effect.Semaphore, readonly runSemaphore: Effect.Semaphore,
readonly fieldCache: Ref.Ref<HashMap.HashMap<FormFieldKey, FormField<unknown, unknown>>>,
) { ) {
super() super()
this.canSubmit = Subscribable.map(
Subscribable.zipLatestAll(this.value, this.error, this.validationFiber, this.mutation.result),
([value, error, validationFiber, result]) => (
Option.isSome(value) &&
Option.isNone(error) &&
Option.isNone(validationFiber) &&
!(Result.isRunning(result) || Result.hasRefreshingFlag(result))
),
)
} }
field<const P extends PropertyPath.Paths<I>>(
path: P
): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>> {
const key = new FormFieldKey(path)
return this.fieldCache.pipe(
Effect.map(HashMap.get(key)),
Effect.flatMap(Option.match({
onSome: v => Effect.succeed(v as FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>),
onNone: () => Effect.tap(
Effect.succeed(makeFormField(this as Form<A, I, R, MA, ME, MR, MP>, path)),
v => Ref.update(this.fieldCache, HashMap.set(key, v as FormField<unknown, unknown>)),
),
})),
)
}
readonly canSubmit: Subscribable.Subscribable<boolean>
get run(): Effect.Effect<void> { get run(): Effect.Effect<void> {
return this.runSemaphore.withPermits(1)(Stream.runForEach( return this.runSemaphore.withPermits(1)(Stream.runForEach(
this.encodedValue.changes.pipe( this.encodedValue.changes,
Option.isSome(this.debounce) ? Stream.debounce(this.debounce.value) : identity
),
encodedValue => this.validationFiber.pipe( encodedValue => Lens.get(this.validationFiber).pipe(
Effect.andThen(Option.match({ Effect.andThen(Option.match({
onSome: Fiber.interrupt, onSome: Fiber.interrupt,
onNone: () => Effect.void, onNone: () => Effect.void,
@@ -110,23 +104,22 @@ extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
exit => Effect.andThen( exit => Effect.andThen(
Exit.matchEffect(exit, { Exit.matchEffect(exit, {
onSuccess: v => Effect.andThen( onSuccess: v => Effect.andThen(
Ref.set(this.value, Option.some(v)), Lens.set(this.value, Option.some(v)),
Ref.set(this.error, Option.none()), Lens.set(this.issues, Array.empty()),
), ),
onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), { onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), {
onSome: e => Ref.set(this.error, Option.some(e)), onSome: e => Effect.flatMap(
ParseResult.ArrayFormatter.formatError(e),
v => Lens.set(this.issues, v),
),
onNone: () => Effect.void, onNone: () => Effect.void,
}), }),
}), }),
Ref.set(this.validationFiber, Option.none()), Lens.set(this.validationFiber, Option.none()),
), ),
)).pipe( )).pipe(
Effect.tap(fiber => Ref.set(this.validationFiber, Option.some(fiber))), Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))),
Effect.andThen(Fiber.join), Effect.andThen(Fiber.join),
Effect.andThen(value => this.autosubmit
? Effect.asVoid(Effect.forkScoped(this.submitValue(value)))
: Effect.void
),
Effect.forkScoped, Effect.forkScoped,
) )
), ),
@@ -136,7 +129,7 @@ extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
} }
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException> { get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException> {
return this.value.pipe( return Lens.get(this.value).pipe(
Effect.andThen(identity), Effect.andThen(identity),
Effect.andThen(value => this.submitValue(value)), Effect.andThen(value => this.submitValue(value)),
) )
@@ -153,141 +146,385 @@ extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
e => e._tag === "ParseError", e => e._tag === "ParseError",
), ),
{ {
onSome: e => Ref.set(this.error, Option.some(e)), onSome: e => Effect.flatMap(
ParseResult.ArrayFormatter.formatError(e),
v => Lens.set(this.issues, v),
),
onNone: () => Effect.void, onNone: () => Effect.void,
}, },
) )
: Effect.void : Effect.void
), ),
this.canSubmit.get, this.canCommit.get,
) )
} }
} }
export const isForm = (u: unknown): u is Form<unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
export declare namespace make { 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<readonly [], A, I, never, never> {
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, ParseResult.ParseError | TER | TEW>
}
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 [FormTypeId]: FormTypeId = FormTypeId
readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId = SynchronizedFormTypeId
readonly path = [] as const
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 value: Lens.Lens<Option.Option<A>, never, never, never, never>,
readonly encodedValue: 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 isValidating: Subscribable.Subscribable<boolean, never, never>,
readonly canCommit: Subscribable.Subscribable<boolean, never, never>,
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>,
readonly runSemaphore: Effect.Semaphore,
) {
super()
}
get run(): Effect.Effect<void, ParseResult.ParseError | TER | TEW> {
return this.runSemaphore.withPermits(1)(Effect.provide(
Effect.all([
Stream.runForEach(
this.encodedValue.changes,
encodedValue => Lens.get(this.validationFiber).pipe(
Effect.andThen(Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
})),
Effect.andThen(
Effect.forkScoped(Effect.onExit(
Schema.decode(this.schema, { errors: "all" })(encodedValue),
exit => Effect.andThen(
Exit.matchEffect(exit, {
onSuccess: v => Lens.set(this.value, Option.some(v)).pipe(
Effect.andThen(Lens.set(this.issues, Array.empty())),
),
onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), {
onSome: e => Effect.flatMap(
ParseResult.ArrayFormatter.formatError(e),
v => Lens.set(this.issues, v),
),
onNone: () => Effect.void,
}),
}),
Lens.set(this.validationFiber, Option.none()),
),
)).pipe(
Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))),
Effect.andThen(Fiber.join),
Effect.tap(value => Lens.set(this.target, value)),
Effect.forkScoped,
)
),
),
),
Stream.runForEach(
Stream.drop(this.target.changes, 1),
targetValue => Schema.encode(this.schema, { errors: "all" })(targetValue).pipe(
Effect.flatMap(encodedValue => Effect.whenEffect(
Lens.set(this.encodedValue, encodedValue),
Effect.map(
Lens.get(this.encodedValue),
currentEncodedValue => !Equal.equals(encodedValue, currentEncodedValue),
),
)),
),
),
], { concurrency: "unbounded", discard: true }),
this.context,
))
}
}
export const isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
export const isSubmittableForm = (u: unknown): u is SubmittableForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SubmittableFormTypeId)
export const isSynchronizedForm = (u: unknown): u is SynchronizedForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SynchronizedFormTypeId)
const falseSubscribable: Subscribable.Subscribable<boolean, never, never> = Subscribable.make({
get: Effect.succeed(false),
changes: Stream.make(false),
})
export declare namespace makeSubmittable {
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> 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< extends Mutation.make.Options<
readonly [value: NoInfer<A>, form: Form<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>], readonly [value: NoInfer<A>, form: SubmittableForm<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>],
MA, ME, MR, MP MA, ME, MR, MP
> { > {
readonly schema: Schema.Schema<A, I, R> readonly schema: Schema.Schema<A, I, R>
readonly initialEncodedValue: NoInfer<I> readonly initialEncodedValue: NoInfer<I>
readonly autosubmit?: boolean
readonly debounce?: Duration.DurationInput
} }
} }
export const make = Effect.fnUntraced(function* <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>( export const makeSubmittable = 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> options: makeSubmittable.Options<A, I, R, MA, ME, MR, MP>
): Effect.fn.Return< ): Effect.fn.Return<
Form<A, I, R, MA, ME, Result.forkEffect.OutputContext<MA, ME, MR, MP>, MP>, SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
never, never,
Scope.Scope | R | Result.forkEffect.OutputContext<MA, ME, MR, MP> Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
> { > {
return new FormImpl( const mutation = yield* Mutation.make(options)
const valueLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<A>()))
const issuesLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly ParseResult.ArrayFormatterIssue[]>(Array.empty()))
const validationFiberLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>()))
return new SubmittableFormImpl(
options.schema, options.schema,
yield* Effect.context<Scope.Scope | R>(), yield* Effect.context<Scope.Scope | R>(),
yield* Mutation.make(options), mutation,
options.autosubmit ?? false,
Option.fromNullable(options.debounce),
yield* SubscriptionRef.make(Option.none<A>()), valueLens,
yield* SubscriptionRef.make(options.initialEncodedValue), Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)),
yield* SubscriptionRef.make(Option.none<ParseResult.ParseError>()), issuesLens,
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>()), validationFiberLens,
Subscribable.map(validationFiberLens, Option.isSome),
Subscribable.map(
Subscribable.zipLatestAll(valueLens, issuesLens, validationFiberLens, mutation.result),
([value, issues, validationFiber, result]) => (
Option.isSome(value) &&
Array.isEmptyReadonlyArray(issues) &&
Option.isNone(validationFiber) &&
!(Result.isRunning(result) || Result.hasRefreshingFlag(result))
),
),
Subscribable.map(mutation.result, result => Result.isRunning(result) || Result.hasRefreshingFlag(result)),
yield* Effect.makeSemaphore(1), yield* Effect.makeSemaphore(1),
yield* Ref.make(HashMap.empty<FormFieldKey, FormField<unknown, unknown>>()),
) )
}) })
export declare namespace service { export declare namespace serviceSubmittable {
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> 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> {} extends makeSubmittable.Options<A, I, R, MA, ME, MR, MP> {}
} }
export const service = <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>( export const serviceSubmittable = <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
options: service.Options<A, I, R, MA, ME, MR, MP> options: serviceSubmittable.Options<A, I, R, MA, ME, MR, MP>
): Effect.Effect< ): Effect.Effect<
Form<A, I, R, MA, ME, Result.forkEffect.OutputContext<MA, ME, MR, MP>, MP>, SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
never, never,
Scope.Scope | R | Result.forkEffect.OutputContext<MA, ME, MR, MP> Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
> => Effect.tap( > => Effect.tap(
make(options), makeSubmittable(options),
form => Effect.forkScoped(form.run),
)
export declare namespace makeSynchronized {
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 makeSynchronized = Effect.fnUntraced(function* <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: makeSynchronized.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 valueLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<A>()))
const issuesLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly ParseResult.ArrayFormatterIssue[]>(Array.empty()))
const validationFiberLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>()))
const initialEncodedValue = yield* Lens.get(options.target).pipe(
Effect.flatMap(Schema.encode(options.schema, { errors: "all" })),
)
return new SynchronizedFormImpl(
options.schema,
yield* Effect.context<Scope.Scope | R | TRR | TRW>(),
options.target,
valueLens,
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(initialEncodedValue)),
issuesLens,
validationFiberLens,
Subscribable.map(validationFiberLens, Option.isSome),
Subscribable.map(
Subscribable.zipLatestAll(valueLens, issuesLens, validationFiberLens),
([value, issues, validationFiber]) => (
Option.isSome(value) &&
Array.isEmptyReadonlyArray(issues) &&
Option.isNone(validationFiber)
),
),
falseSubscribable,
yield* Effect.makeSemaphore(1),
)
})
export declare namespace serviceSynchronized {
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 makeSynchronized.Options<A, I, R, TER, TEW, TRR, TRW> {}
}
export const serviceSynchronized = <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: serviceSynchronized.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(
makeSynchronized(options),
form => Effect.forkScoped(form.run), form => Effect.forkScoped(form.run),
) )
export const FormFieldTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormField") const filterIssuesByPath = (
export type FormFieldTypeId = typeof FormFieldTypeId issues: readonly ParseResult.ArrayFormatterIssue[],
path: readonly PropertyKey[],
): readonly ParseResult.ArrayFormatterIssue[] => Array.filter(issues, issue =>
issue.path.length >= path.length && Array.every(path, (p, i) => p === issue.path[i])
)
export interface FormField<in out A, in out I = A> export const focusObjectField: {
extends Pipeable.Pipeable { <P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
readonly [FormFieldTypeId]: FormFieldTypeId self: Form<P, A, I, ER, EW>,
key: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW>
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
key: K,
): (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>(
self: Form<P, A, I, ER, EW>,
key: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
const form = self as FormImpl<P, A, I, ER, EW>
const path = [...form.path, key] as const
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException> return new FormImpl(
readonly encodedValue: SubscriptionRef.SubscriptionRef<I> path,
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]> Subscribable.mapOption(form.value, a => a[key]),
readonly isValidating: Subscribable.Subscribable<boolean> Lens.focusObjectField(form.encodedValue, key),
readonly isSubmitting: Subscribable.Subscribable<boolean> Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
} form.isValidating,
form.canCommit,
class FormFieldImpl<in out A, in out I = A> form.isCommitting,
extends Pipeable.Class() implements FormField<A, I> {
readonly [FormFieldTypeId]: FormFieldTypeId = FormFieldTypeId
constructor(
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>,
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>,
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>,
readonly isValidating: Subscribable.Subscribable<boolean>,
readonly isSubmitting: Subscribable.Subscribable<boolean>,
) {
super()
}
}
const FormFieldKeyTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormFieldKey")
type FormFieldKeyTypeId = typeof FormFieldKeyTypeId
class FormFieldKey implements Equal.Equal {
readonly [FormFieldKeyTypeId]: FormFieldKeyTypeId = FormFieldKeyTypeId
constructor(readonly path: PropertyPath.PropertyPath) {}
[Equal.symbol](that: Equal.Equal) {
return isFormFieldKey(that) && PropertyPath.equivalence(this.path, that.path)
}
[Hash.symbol]() {
return Hash.array(this.path)
}
}
export const isFormField = (u: unknown): u is FormField<unknown, unknown> => Predicate.hasProperty(u, FormFieldTypeId)
const isFormFieldKey = (u: unknown): u is FormFieldKey => Predicate.hasProperty(u, FormFieldKeyTypeId)
export const makeFormField = <A, I, R, MA, ME, MR, MP, const P extends PropertyPath.Paths<NoInfer<I>>>(
self: Form<A, I, R, MA, ME, MR, MP>,
path: P,
): FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>> => {
return new FormFieldImpl(
Subscribable.mapEffect(self.value, Option.match({
onSome: v => Option.map(PropertyPath.get(v, path), Option.some),
onNone: () => Option.some(Option.none()),
})),
SubscriptionSubRef.makeFromPath(self.encodedValue, path),
Subscribable.mapEffect(self.error, Option.match({
onSome: flow(
ParseResult.ArrayFormatter.formatError,
Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))),
),
onNone: () => Effect.succeed([]),
})),
Subscribable.map(self.validationFiber, Option.isSome),
Subscribable.map(self.mutation.result, result => Result.isRunning(result) || Result.hasRefreshingFlag(result)),
) )
} })
export const focusArrayAt: {
<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>
<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(
path,
Subscribable.mapOptionEffect(form.value, Array.get(index)),
Lens.focusArrayAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
export const focusTupleAt: {
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
self: Form<P, A, I, ER, EW>,
index: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW>
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
index: K,
): (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>(
self: Form<P, A, I, ER, EW>,
index: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
const form = self as FormImpl<P, A, I, ER, EW>
const path = [...form.path, index] as const
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: {
<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>
<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(
path,
Subscribable.mapOptionEffect(form.value, Chunk.get(index)),
Lens.focusChunkAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
export namespace useInput { export namespace useInput {
@@ -301,33 +538,39 @@ export namespace useInput {
} }
} }
export const useInput = Effect.fnUntraced(function* <A, I>( export const useInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
field: FormField<A, I>, form: Form<P, A, I, ER, EW>,
options?: useInput.Options, options?: useInput.Options,
): Effect.fn.Return<useInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> { ): Effect.fn.Return<useInput.Success<I>, ER, Scope.Scope> {
const internalValueRef = yield* Component.useOnChange(() => Effect.tap( const internalValueLens = yield* Component.useOnChange(() => Effect.gen(function*() {
Effect.andThen(field.encodedValue, SubscriptionRef.make), const internalValueLens = yield* Lens.get(form.encodedValue).pipe(
internalValueRef => Effect.forkScoped(Effect.all([ Effect.flatMap(SubscriptionRef.make),
Effect.map(Lens.fromSubscriptionRef),
)
yield* Effect.forkScoped(Effect.all([
Stream.runForEach( Stream.runForEach(
Stream.drop(field.encodedValue, 1), Stream.drop(form.encodedValue.changes, 1),
upstreamEncodedValue => Effect.whenEffect( upstreamEncodedValue => Effect.whenEffect(
Ref.set(internalValueRef, upstreamEncodedValue), Lens.set(internalValueLens, upstreamEncodedValue),
Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)), Effect.andThen(Lens.get(internalValueLens), internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
), ),
), ),
Stream.runForEach( Stream.runForEach(
internalValueRef.changes.pipe( internalValueLens.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 => Ref.set(field.encodedValue, internalValue), internalValue => Lens.set(form.encodedValue, internalValue),
), ),
], { concurrency: "unbounded" })), ], { concurrency: "unbounded" }))
), [field, options?.debounce])
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef) return internalValueLens
}), [form, options?.debounce])
const [value, setValue] = yield* Lens.useState(internalValueLens)
return { value, setValue } return { value, setValue }
}) })
@@ -342,55 +585,63 @@ export namespace useOptionalInput {
} }
} }
export const useOptionalInput = Effect.fnUntraced(function* <A, I>( export const useOptionalInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
field: FormField<A, Option.Option<I>>, field: Form<P, A, Option.Option<I>, ER, EW>,
options: useOptionalInput.Options<I>, options: useOptionalInput.Options<I>,
): Effect.fn.Return<useOptionalInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> { ): Effect.fn.Return<useOptionalInput.Success<I>, ER, Scope.Scope> {
const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap( const [enabledLens, internalValueLens] = yield* Component.useOnChange(() => Effect.gen(function*() {
Effect.andThen( const [enabledLens, internalValueLens] = yield* Effect.flatMap(
field.encodedValue, Lens.get(field.encodedValue),
Option.match({ Option.match({
onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]), onSome: v => Effect.all([
onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]), Effect.map(SubscriptionRef.make(true), Lens.fromSubscriptionRef),
Effect.map(SubscriptionRef.make(v), Lens.fromSubscriptionRef),
]),
onNone: () => Effect.all([
Effect.map(SubscriptionRef.make(false), Lens.fromSubscriptionRef),
Effect.map(SubscriptionRef.make(options.defaultValue), Lens.fromSubscriptionRef),
]),
}), }),
), )
([enabledRef, internalValueRef]) => Effect.forkScoped(Effect.all([ yield* Effect.forkScoped(Effect.all([
Stream.runForEach( Stream.runForEach(
Stream.drop(field.encodedValue, 1), Stream.drop(field.encodedValue.changes, 1),
upstreamEncodedValue => Effect.whenEffect( upstreamEncodedValue => Effect.whenEffect(
Option.match(upstreamEncodedValue, { Option.match(upstreamEncodedValue, {
onSome: v => Effect.andThen( onSome: v => Effect.andThen(
Ref.set(enabledRef, true), Lens.set(enabledLens, true),
Ref.set(internalValueRef, v), Lens.set(internalValueLens, v),
), ),
onNone: () => Effect.andThen( onNone: () => Effect.andThen(
Ref.set(enabledRef, false), Lens.set(enabledLens, false),
Ref.set(internalValueRef, options.defaultValue), Lens.set(internalValueLens, options.defaultValue),
), ),
}), }),
Effect.andThen( Effect.andThen(
Effect.all([enabledRef, internalValueRef]), Effect.all([Lens.get(enabledLens), Lens.get(internalValueLens)]),
([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(
enabledRef.changes.pipe( enabledLens.changes.pipe(
Stream.zipLatest(internalValueRef.changes), Stream.zipLatest(internalValueLens.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]) => Ref.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()), ([enabled, internalValue]) => Lens.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
), ),
], { concurrency: "unbounded" })), ], { concurrency: "unbounded" }))
), [field, options.debounce])
const [enabled, setEnabled] = yield* SubscriptionRef.useSubscriptionRefState(enabledRef) return [enabledLens, internalValueLens] as const
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef) }), [field, options.debounce])
const [enabled, setEnabled] = yield* Lens.useState(enabledLens)
const [value, setValue] = yield* Lens.useState(internalValueLens)
return { enabled, setEnabled, value, setValue } return { enabled, setEnabled, value, setValue }
}) })

View File

@@ -0,0 +1,63 @@
import { Effect, Equivalence, Stream } from "effect"
import { Lens } from "effect-lens"
import * as React from "react"
import * as Component from "./Component.js"
import * as SetStateAction from "./SetStateAction.js"
import * as SubscriptionRef from "./SubscriptionRef.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 useFromState {
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useFromState = Effect.fnUntraced(function* <A>(
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
options?: useFromState.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
})

View File

@@ -1,50 +1,111 @@
/** 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 TypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized") export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
export type TypeId = typeof TypeId export type MemoizedTypeId = typeof MemoizedTypeId
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 { /**
export interface Options<P> { * Configuration options for Memoized components.
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>
} }
const MemoizedProto = Object.freeze({ export const MemoizedPrototype: MemoizedPrototype = Object.freeze({
[TypeId]: TypeId [MemoizedTypeId]: MemoizedTypeId,
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, TypeId) export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, MemoizedTypeId)
/**
* Converts a Component into a `Memoized` component that optimizes re-renders using `React.memo`.
*
* @param self - The component to convert to a Memoized component
* @returns A new `Memoized` component with the same body, error, and context types as the input
*
* @example
* ```ts
* const MyMemoizedComponent = MyComponent.pipe(
* Memoized.memoized,
* )
* ```
*/
export const memoized = <T extends Component.Component<any, any, any, any>>( export const memoized = <T extends Component.Component<any, any, any, 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({}, MemoizedProto), Object.assign({}, MemoizedPrototype),
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, any, any, any> & Memoized<any>>( <T extends Component.Component<any, any, any, any> & Memoized<any>>(
options: Partial<Memoized.Options<Component.Component.Props<T>>> options: Partial<MemoizedOptions<Component.Component.Props<T>>>
): (self: T) => T ): (self: T) => T
<T extends Component.Component<any, any, any, any> & Memoized<any>>( <T extends Component.Component<any, any, any, any> & Memoized<any>>(
self: T, self: T,
options: Partial<Memoized.Options<Component.Component.Props<T>>>, options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
): T ): T
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Memoized<any>>( } = Function.dual(2, <T extends Component.Component<any, any, any, any> & Memoized<any>>(
self: T, self: T,
options: Partial<Memoized.Options<Component.Component.Props<T>>>, options: Partial<MemoizedOptions<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),

View File

@@ -111,12 +111,12 @@ export declare namespace make {
export const make = Effect.fnUntraced(function* <const K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never>( 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> options: make.Options<K, A, E, R, P>
): Effect.fn.Return< ): Effect.fn.Return<
Mutation<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>, Mutation<K, A, E, Result.forkEffect.OutputContext<R, P>, P>,
never, never,
Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P> Scope.Scope | Result.forkEffect.OutputContext<R, P>
> { > {
return new MutationImpl( return new MutationImpl(
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>>(), yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<R, P>>(),
options.f as any, options.f as any,
options.initialProgress as P, options.initialProgress as P,

View File

@@ -0,0 +1,67 @@
import { describe, expect, test } from "bun:test"
import { Option } from "effect"
import * as PropertyPath from "./PropertyPath.js"
describe("immutableSet with arrays", () => {
test("sets a top-level array element", () => {
const arr = [1, 2, 3]
const result = PropertyPath.immutableSet(arr, [1], 99)
expect(result).toEqual(Option.some([1, 99, 3]))
})
test("does not mutate the original array", () => {
const arr = [1, 2, 3]
PropertyPath.immutableSet(arr, [0], 42)
expect(arr).toEqual([1, 2, 3])
})
test("sets the first element of an array", () => {
const arr = ["a", "b", "c"]
const result = PropertyPath.immutableSet(arr, [0], "z")
expect(result).toEqual(Option.some(["z", "b", "c"]))
})
test("sets the last element of an array", () => {
const arr = [10, 20, 30]
const result = PropertyPath.immutableSet(arr, [2], 99)
expect(result).toEqual(Option.some([10, 20, 99]))
})
test("sets a nested array element inside an object", () => {
const obj = { tags: ["foo", "bar", "baz"] }
const result = PropertyPath.immutableSet(obj, ["tags", 1], "qux")
expect(result).toEqual(Option.some({ tags: ["foo", "qux", "baz"] }))
})
test("sets a deeply nested value inside an array of objects", () => {
const obj = { items: [{ name: "alice" }, { name: "bob" }] }
const result = PropertyPath.immutableSet(obj, ["items", 0, "name"], "charlie")
expect(result).toEqual(Option.some({ items: [{ name: "charlie" }, { name: "bob" }] }))
})
test("sets a value in a nested array", () => {
const matrix = [[1, 2], [3, 4]]
const result = PropertyPath.immutableSet(matrix, [1, 0], 99)
expect(result).toEqual(Option.some([[1, 2], [99, 4]]))
})
test("returns Option.none() for an out-of-bounds index", () => {
const arr = [1, 2, 3]
const result = PropertyPath.immutableSet(arr, [5], 99)
expect(result).toEqual(Option.none())
})
test("returns Option.none() for a non-numeric key on an array", () => {
const arr = [1, 2, 3]
// @ts-expect-error intentionally wrong key type
const result = PropertyPath.immutableSet(arr, ["length"], 0)
expect(result).toEqual(Option.none())
})
test("empty path returns Option.some of the value itself", () => {
const arr = [1, 2, 3]
const result = PropertyPath.immutableSet(arr, [], [9, 9, 9] as any)
expect(result).toEqual(Option.some([9, 9, 9]))
})
})

View File

@@ -6,12 +6,12 @@ import * as Result from "./Result.js"
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query") export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
export type QueryTypeId = typeof QueryTypeId export type QueryTypeId = typeof QueryTypeId
export interface Query<in out K extends Query.AnyKey, in out A, in out E = never, in out R = never, in out P = never> 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 { extends Pipeable.Pipeable {
readonly [QueryTypeId]: QueryTypeId readonly [QueryTypeId]: QueryTypeId
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R> readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>
readonly key: Stream.Stream<K> readonly key: Stream.Stream<K, KE, KR>
readonly f: (key: K) => Effect.Effect<A, E, R> readonly f: (key: K) => Effect.Effect<A, E, R>
readonly initialProgress: P readonly initialProgress: P
@@ -37,13 +37,13 @@ export declare namespace Query {
export type AnyKey = readonly any[] export type AnyKey = readonly any[]
} }
export class QueryImpl<in out K extends Query.AnyKey, in out A, in out E = never, in out R = never, in out P = never> 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, E, R, P> { extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
readonly [QueryTypeId]: QueryTypeId = QueryTypeId readonly [QueryTypeId]: QueryTypeId = QueryTypeId
constructor( constructor(
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>, readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>,
readonly key: Stream.Stream<K>, readonly key: Stream.Stream<K, KE, KR>,
readonly f: (key: K) => Effect.Effect<A, E, R>, readonly f: (key: K) => Effect.Effect<A, E, R>,
readonly initialProgress: P, readonly initialProgress: P,
@@ -77,6 +77,7 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
], { concurrency: "unbounded" }).pipe( ], { concurrency: "unbounded" }).pipe(
Effect.ignore, Effect.ignore,
this.runSemaphore.withPermits(1), this.runSemaphore.withPermits(1),
Effect.provide(this.context),
) )
} }
@@ -265,11 +266,11 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
} }
} }
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, QueryTypeId) export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown> => Predicate.hasProperty(u, QueryTypeId)
export declare namespace make { export declare namespace make {
export interface Options<K extends Query.AnyKey, A, E = never, R = never, P = never> { export interface Options<K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never> {
readonly key: Stream.Stream<K> readonly key: Stream.Stream<K, KE, KR>
readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>> readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
readonly initialProgress?: P readonly initialProgress?: P
readonly staleTime?: Duration.DurationInput readonly staleTime?: Duration.DurationInput
@@ -277,17 +278,17 @@ export declare namespace make {
} }
} }
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, E = never, R = never, P = never>( 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, E, R, P> options: make.Options<K, A, KE, KR, E, R, P>
): Effect.fn.Return< ): Effect.fn.Return<
Query<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>, Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
never, never,
Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P> Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
> { > {
const client = yield* QueryClient.QueryClient const client = yield* QueryClient.QueryClient
return new QueryImpl( return new QueryImpl<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>(
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>>(), yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>>(),
options.key, options.key,
options.f as any, options.f as any,
options.initialProgress as P, options.initialProgress as P,
@@ -304,12 +305,12 @@ export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, E =
) )
}) })
export const service = <K extends Query.AnyKey, A, E = never, R = never, P = never>( export const service = <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
options: make.Options<K, A, E, R, P> options: make.Options<K, A, KE, KR, E, R, P>
): Effect.Effect< ): Effect.Effect<
Query<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>, Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
never, never,
Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P> Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
> => Effect.tap( > => Effect.tap(
make(options), make(options),
query => Effect.forkScoped(query.run), query => Effect.forkScoped(query.run),

View File

@@ -1,4 +1,5 @@
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, Subscribable } from "effect" import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, pipe, Pipeable, Predicate, PubSub, Ref, type Scope, Stream, type Subscribable, SynchronizedRef } 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")
@@ -15,10 +16,6 @@ export type Final<A, E = never, P = never> = (Success<A> | Failure<E>) & ({} | F
export type Flags<P = never> = WillFetch | WillRefresh | Refreshing<P> export type Flags<P = never> = WillFetch | WillRefresh | Refreshing<P>
export declare namespace Result { export declare namespace Result {
export interface Prototype extends Pipeable.Pipeable, Equal.Equal {
readonly [ResultTypeId]: ResultTypeId
}
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
@@ -28,21 +25,21 @@ export declare namespace Flags {
export type Keys = keyof WillFetch & WillRefresh & Refreshing<any> export type Keys = keyof WillFetch & WillRefresh & Refreshing<any>
} }
export interface Initial extends Result.Prototype { export interface Initial extends ResultPrototype {
readonly _tag: "Initial" readonly _tag: "Initial"
} }
export interface Running<P = never> extends Result.Prototype { export interface Running<P = never> extends ResultPrototype {
readonly _tag: "Running" readonly _tag: "Running"
readonly progress: P readonly progress: P
} }
export interface Success<A> extends Result.Prototype { export interface Success<A> extends ResultPrototype {
readonly _tag: "Success" readonly _tag: "Success"
readonly value: A readonly value: A
} }
export interface Failure<E = never> extends Result.Prototype { export interface Failure<E = never> extends ResultPrototype {
readonly _tag: "Failure" readonly _tag: "Failure"
readonly cause: Cause.Cause<E> readonly cause: Cause.Cause<E>
} }
@@ -61,7 +58,11 @@ export interface Refreshing<P = never> {
} }
const ResultPrototype = Object.freeze({ export interface ResultPrototype extends Pipeable.Pipeable, Equal.Equal {
readonly [ResultTypeId]: ResultTypeId
}
export const ResultPrototype: ResultPrototype = Object.freeze({
...Pipeable.Prototype, ...Pipeable.Prototype,
[ResultTypeId]: ResultTypeId, [ResultTypeId]: ResultTypeId,
@@ -95,7 +96,7 @@ const ResultPrototype = Object.freeze({
Hash.cached(this), Hash.cached(this),
) )
}, },
} as const satisfies Result.Prototype) } as const)
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)
@@ -162,52 +163,40 @@ export const toExit: {
} }
export interface State<A, E = never, P = 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> { export interface Progress<P = never> {
readonly update: <E, R>( readonly progress: Lens.Lens<P, PreviousResultNotRunningNorRefreshing, never, never, never>
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 Progress = <P = never>(): Context.Tag<Progress<P>, Progress<P>> => Context.GenericTag("@effect-fc/Result/Progress") export const makeProgressLayer = <A, E, P = never>(
state: Lens.Lens<Result<A, E, P>, never, never, never, never>
export const makeProgressLayer = <A, E, P = never>(): Layer.Layer< ): Layer.Layer<Progress<P> | Progress<never>, never, never> => Layer.succeed(
Progress<P>, Progress<P>() as Context.Tag<Progress<P> | Progress<never>, Progress<P> | Progress<never>>,
never, {
State<A, E, P> progress: state.pipe(
> => Layer.effect(Progress<P>(), Effect.gen(function*() { Lens.mapEffect(
const state = yield* State<A, E, P>() a => (isRunning(a) || hasRefreshingFlag(a))
? Effect.succeed(a)
return { : Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous: a })),
update: <FE, FR>(f: (previous: P) => Effect.Effect<P, FE, FR>) => Effect.Do.pipe( (_, b) => Effect.succeed(b),
Effect.bind("previous", () => Effect.andThen(state.get, previous =>
(isRunning(previous) || hasRefreshingFlag(previous))
? Effect.succeed(previous)
: Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous })),
)),
Effect.bind("progress", ({ previous }) => f(previous.progress)),
Effect.let("next", ({ previous, progress }) => isRunning(previous)
? running(progress)
: refreshing(previous, progress) as Final<A, E, P> & Refreshing<P>
), ),
Effect.andThen(({ next }) => state.set(next)), Lens.map(
), a => a.progress,
} (a, b) => isRunning(a)
})) ? running(b)
: refreshing(a, b) as Final<A, E, P> & Refreshing<P>,
),
)
},
)
export namespace unsafeForkEffect { export namespace unsafeForkEffect {
export type OutputContext<A, E, R, P> = Exclude<R, State<A, E, P> | Progress<P> | Progress<never>> export type OutputContext<R, P> = Exclude<R, Progress<P> | Progress<never>>
export interface Options<A, E, P> { export interface Options<A, E, P> {
readonly initial?: Initial | Final<A, E, P> readonly initial?: Initial | Final<A, E, P>
@@ -215,55 +204,56 @@ export namespace unsafeForkEffect {
} }
} }
export const unsafeForkEffect = <A, E, R, P = never>( export const unsafeForkEffect = Effect.fnUntraced(function* <A, E, R, P = never>(
effect: Effect.Effect<A, E, R>, effect: Effect.Effect<A, E, R>,
options?: unsafeForkEffect.Options<NoInfer<A>, NoInfer<E>, P>, options?: unsafeForkEffect.Options<NoInfer<A>, NoInfer<E>, P>,
): Effect.Effect< ): Effect.fn.Return<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>], readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never, never,
Scope.Scope | unsafeForkEffect.OutputContext<A, E, R, P> Scope.Scope | unsafeForkEffect.OutputContext<R, P>
> => Effect.Do.pipe( > {
Effect.bind("ref", () => Ref.make(options?.initial ?? initial<A, E, P>())), const ref = yield* SynchronizedRef.make<Result<A, E, P>>(options?.initial ?? initial<A, E, P>())
Effect.bind("pubsub", () => PubSub.unbounded<Result<A, E, P>>()), const pubsub = yield* PubSub.unbounded<Result<A, E, P>>()
Effect.bind("fiber", ({ ref, pubsub }) => Effect.forkScoped(State<A, E, P>().pipe(
Effect.andThen(state => state.set( const state = Lens.make<Result<A, E, P>, never, never, never, never>({
get get() { return Ref.get(ref) },
get changes() {
return Stream.unwrapScoped(Effect.map(
Effect.all([Ref.get(ref), Stream.fromPubSub(pubsub, { scoped: true })]),
([latest, stream]) => Stream.concat(Stream.make(latest), stream),
))
},
modify: f => Ref.get(ref).pipe(
Effect.flatMap(f),
Effect.flatMap(([b, a]) => Ref.set(ref, a).pipe(
Effect.as(b),
Effect.zipLeft(PubSub.publish(pubsub, a))
)),
),
})
const fiber = yield* Effect.gen(function*() {
yield* Lens.set(
state,
(isFinal(options?.initial) && hasWillRefreshFlag(options?.initial)) (isFinal(options?.initial) && hasWillRefreshFlag(options?.initial))
? refreshing(options.initial, options?.initialProgress) as Result<A, E, P> ? refreshing(options.initial, options?.initialProgress) as Result<A, E, P>
: running(options?.initialProgress) : running(options?.initialProgress),
).pipe( )
Effect.andThen(effect), return yield* Effect.onExit(effect, exit => Effect.andThen(
Effect.onExit(exit => Effect.andThen( Lens.set(state, fromExit(exit)),
state.set(fromExit(exit)), Effect.forkScoped(PubSub.shutdown(pubsub)),
Effect.forkScoped(PubSub.shutdown(pubsub)), ))
)), }).pipe(
)), Effect.forkScoped,
Effect.provide(Layer.empty.pipe( Effect.provide(makeProgressLayer(state)),
Layer.provideMerge(makeProgressLayer<A, E, P>()), )
Layer.provideMerge(Layer.succeed(State<A, E, P>(), {
get: ref, return [state, fiber] as const
set: v => Effect.andThen(Ref.set(ref, v), PubSub.publish(pubsub, v)) })
})),
)),
))),
Effect.map(({ ref, pubsub, fiber }) => [
Subscribable.make({
get: ref,
changes: Stream.unwrapScoped(Effect.map(
Effect.all([ref, Stream.fromPubSub(pubsub, { scoped: true })]),
([latest, stream]) => Stream.concat(Stream.make(latest), stream),
)),
}),
fiber,
]),
) as Effect.Effect<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never,
Scope.Scope | unsafeForkEffect.OutputContext<A, E, R, P>
>
export namespace forkEffect { export namespace forkEffect {
export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R
export type OutputContext<A, E, R, P> = unsafeForkEffect.OutputContext<A, E, R, P> export type OutputContext<R, P> = unsafeForkEffect.OutputContext<R, P>
export interface Options<A, E, P> extends unsafeForkEffect.Options<A, E, P> {} export interface Options<A, E, P> extends unsafeForkEffect.Options<A, E, P> {}
} }
@@ -274,6 +264,6 @@ export const forkEffect: {
): Effect.Effect< ): Effect.Effect<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>], readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never, never,
Scope.Scope | forkEffect.OutputContext<A, E, R, P> Scope.Scope | forkEffect.OutputContext<R, P>
> >
} = unsafeForkEffect } = unsafeForkEffect

View File

@@ -3,8 +3,8 @@ import type * as React from "react"
export const value: { export const value: {
<S>(prevState: S): (self: React.SetStateAction<S>) => S
<S>(self: React.SetStateAction<S>, prevState: S): S <S>(self: React.SetStateAction<S>, prevState: S): S
<S>(prevState: S): (self: React.SetStateAction<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)

View File

@@ -1,8 +1,11 @@
import { Effect, Equivalence, Stream, Subscribable } from "effect" import { Effect, Equivalence, Stream } 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<
@@ -48,5 +51,3 @@ export const useSubscribables = Effect.fnUntraced(function* <const T extends rea
return reactStateValue as any return reactStateValue as any
}) })
export * from "effect/Subscribable"

View File

@@ -0,0 +1,183 @@
import { describe, expect, test } from "bun:test"
import { Chunk, Effect, Ref, SubscriptionRef } from "effect"
import * as SubscriptionSubRef from "./SubscriptionSubRef.js"
describe("SubscriptionSubRef with array refs", () => {
test("creates a subref for a single array element using path", async () => {
const value = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([{ name: "alice" }, { name: "bob" }, { name: "charlie" }]),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [1, "name"])
return subref.get
},
),
)
expect(value).toBe("bob")
})
test("modifies a single array element via subref", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([{ name: "alice" }, { name: "bob" }, { name: "charlie" }]),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [1, "name"])
return Effect.flatMap(
Ref.set(subref, "bob-updated"),
() => Ref.get(parent),
)
},
),
)
expect(result).toEqual([{ name: "alice" }, { name: "bob-updated" }, { name: "charlie" }])
})
test("modifies array element at index 0", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([10, 20, 30]),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [0])
return Effect.flatMap(
Ref.set(subref, 99),
() => Ref.get(parent),
)
},
),
)
expect(result).toEqual([99, 20, 30])
})
test("modifies array element at last index", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make(["a", "b", "c"]),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [2])
return Effect.flatMap(
Ref.set(subref, "z"),
() => Ref.get(parent),
)
},
),
)
expect(result).toEqual(["a", "b", "z"])
})
test("modifies nested array element", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([[1, 2], [3, 4], [5, 6]]),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [1, 0])
return Effect.flatMap(
Ref.set(subref, 99),
() => Ref.get(parent),
)
},
),
)
expect(result).toEqual([[1, 2], [99, 4], [5, 6]])
})
test("uses modifyEffect to transform array element", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([{ count: 1 }, { count: 2 }, { count: 3 }]),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [1, "count"])
return Effect.flatMap(
Ref.update(subref, count => count + 100),
() => Effect.map(Ref.get(parent), parentValue => ({ result: 102, parentValue })),
)
},
),
)
expect(result.result).toBe(102) // count + 100
expect(result.parentValue).toEqual([{ count: 1 }, { count: 102 }, { count: 3 }]) // count + 100
})
test("uses modify to transform array element", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([10, 20, 30]),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [1])
return Effect.flatMap(
Ref.update(subref, x => x + 5),
() => Effect.map(Ref.get(parent), parentValue => ({ result: 25, parentValue })),
)
},
),
)
expect(result.result).toBe(25) // 20 + 5
expect(result.parentValue).toEqual([10, 25, 30]) // 20 + 5
})
test("makeFromChunkIndex modifies chunk element", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make(Chunk.make(100, 200, 300)),
parent => {
const subref = SubscriptionSubRef.makeFromChunkIndex(parent, 1)
return Effect.flatMap(
Ref.set(subref, 999),
() => Ref.get(parent),
)
},
),
)
expect(Chunk.toReadonlyArray(result)).toEqual([100, 999, 300])
})
test("makeFromGetSet with custom getter/setter for array element", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([{ id: 1, value: "a" }, { id: 2, value: "b" }]),
parent => {
const subref = SubscriptionSubRef.makeFromGetSet(parent, {
get: arr => arr[0].value,
set: (arr, newValue) => [
{ ...arr[0], value: newValue },
...arr.slice(1),
],
})
return Effect.flatMap(
Ref.set(subref, "updated"),
() => Ref.get(parent),
)
},
),
)
expect(result).toEqual([{ id: 1, value: "updated" }, { id: 2, value: "b" }])
})
test("does not mutate original array when modifying via subref", async () => {
const original = [{ name: "alice" }, { name: "bob" }]
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make(original),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [0, "name"])
return Effect.flatMap(
Ref.set(subref, "alice-updated"),
() => Ref.get(parent),
)
},
),
)
expect(original).toEqual([{ name: "alice" }, { name: "bob" }]) // original unchanged
expect(result).toEqual([{ name: "alice-updated" }, { name: "bob" }]) // new value in ref
})
})

View File

@@ -2,6 +2,7 @@ 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 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 Mutation from "./Mutation.js"
export * as PropertyPath from "./PropertyPath.js" export * as PropertyPath from "./PropertyPath.js"

View File

@@ -25,6 +25,7 @@
"noPropertyAccessFromIndexSignature": false, "noPropertyAccessFromIndexSignature": false,
// Build // Build
"rootDir": "./src",
"outDir": "./dist", "outDir": "./dist",
"declaration": true, "declaration": true,
"sourceMap": true, "sourceMap": true,
@@ -34,5 +35,6 @@
] ]
}, },
"include": ["./src"] "include": ["./src"],
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
} }

View File

@@ -13,30 +13,30 @@
"clean:modules": "rm -rf node_modules" "clean:modules": "rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/react-router": "^1.154.12", "@tanstack/react-router": "^1.168.3",
"@tanstack/react-router-devtools": "^1.154.12", "@tanstack/react-router-devtools": "^1.166.11",
"@tanstack/router-plugin": "^1.154.12", "@tanstack/router-plugin": "^1.167.4",
"@types/react": "^19.2.9", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^6.0.1",
"globals": "^17.0.0", "globals": "^17.4.0",
"react": "^19.2.3", "react": "^19.2.4",
"react-dom": "^19.2.3", "react-dom": "^19.2.4",
"type-fest": "^5.4.1", "type-fest": "^5.5.0",
"vite": "^7.3.1" "vite": "^8.0.2"
}, },
"dependencies": { "dependencies": {
"@effect/platform": "^0.94.2", "@effect/platform": "^0.96.0",
"@effect/platform-browser": "^0.74.0", "@effect/platform-browser": "^0.76.0",
"@radix-ui/themes": "^3.2.1", "@radix-ui/themes": "^3.3.0",
"@typed/id": "^0.17.2", "@typed/id": "^0.17.2",
"effect": "^3.19.15", "effect": "^3.21.0",
"effect-fc": "workspace:*", "effect-fc": "workspace:*",
"react-icons": "^5.5.0" "react-icons": "^5.6.0"
}, },
"overrides": { "overrides": {
"@types/react": "^19.2.9", "@types/react": "^19.2.14",
"effect": "^3.19.15", "effect": "^3.21.0",
"react": "^19.2.3" "react": "^19.2.4"
} }
} }

View File

@@ -1,75 +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"
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.Success<string>
| { readonly optional: false } & Form.useInput.Success<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.issues,
props.field.isValidating,
props.field.isSubmitting,
])
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>
)
}) {}

View File

@@ -0,0 +1,50 @@
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
export declare namespace TextFieldFormInputView {
export interface Props extends Omit<TextField.RootProps, "form">, Form.useInput.Options {
readonly form: Form.Form<readonly PropertyKey[], any, string>
}
}
export class TextFieldFormInputView extends Component.make("TextFieldFormInputView")(function*(
props: TextFieldFormInputView.Props
) {
const input = yield* Form.useInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* Subscribable.useSubscribables([
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>
)
}) {}

View File

@@ -0,0 +1,58 @@
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
export declare namespace TextFieldOptionalFormInputView {
export interface Props extends Omit<TextField.RootProps, "form" | "defaultValue">, Form.useOptionalInput.Options<string> {
readonly form: Form.Form<readonly PropertyKey[], any, Option.Option<string>>
}
}
export class TextFieldOptionalFormInputView extends Component.make("TextFieldOptionalFormInputView")(function*(
props: TextFieldOptionalFormInputView.Props
) {
const input = yield* Form.useOptionalInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* Subscribable.useSubscribables([
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>
)
}) {}

View File

@@ -13,10 +13,10 @@ import { Route as ResultRouteImport } from './routes/result'
import { Route as QueryRouteImport } from './routes/query' 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',
@@ -38,6 +38,11 @@ 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: '/',
@@ -53,40 +58,35 @@ 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 '/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 '/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 '/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 +94,42 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/' | '/'
| '/async'
| '/blank' | '/blank'
| '/form' | '/form'
| '/query' | '/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' | '/query'
| '/result' | '/result'
| '/dev/async-rendering'
| '/dev/context' | '/dev/context'
| '/dev/memo' | '/dev/memo'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/async'
| '/blank' | '/blank'
| '/form' | '/form'
| '/query' | '/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 QueryRoute: typeof QueryRoute
ResultRoute: typeof ResultRoute ResultRoute: typeof ResultRoute
DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute
DevContextRoute: typeof DevContextRoute DevContextRoute: typeof DevContextRoute
DevMemoRoute: typeof DevMemoRoute DevMemoRoute: typeof DevMemoRoute
} }
@@ -164,6 +164,13 @@ 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: '/'
@@ -185,23 +192,16 @@ 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, QueryRoute: QueryRoute,
ResultRoute: ResultRoute, ResultRoute: ResultRoute,
DevAsyncRenderingRoute: DevAsyncRenderingRoute,
DevContextRoute: DevContextRoute, DevContextRoute: DevContextRoute,
DevMemoRoute: DevMemoRoute, DevMemoRoute: DevMemoRoute,
} }

View File

@@ -0,0 +1,71 @@
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,
})

View File

@@ -1,78 +0,0 @@
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
})

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, yield* Component.useContext(SubServiceLayer)) const SubComponentFC = yield* Effect.provide(SubComponent.use, yield* Component.useContextFromLayer(SubServiceLayer))
return ( return (
<Container> <Container>

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, FC => <FC />)} {yield* Effect.map(SubComponent.use, FC => <FC />)}
{yield* Effect.map(MemoizedSubComponent, FC => <FC />)} {yield* Effect.map(MemoizedSubComponent.use, FC => <FC />)}
</Flex> </Flex>
) )
}).pipe( }).pipe(

View File

@@ -1,10 +1,11 @@
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { runtime } from "@/runtime"
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, Subscribable } from "effect-fc" import { Component, Form, Subscribable } from "effect-fc"
import { TextFieldFormInput } from "@/lib/form/TextFieldFormInput"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { runtime } from "@/runtime"
const email = Schema.pattern<typeof Schema.String>( const email = Schema.pattern<typeof Schema.String>(
@@ -38,39 +39,48 @@ const RegisterFormSubmitSchema = Schema.Struct({
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf), birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
}) })
class RegisterForm extends Effect.Service<RegisterForm>()("RegisterForm", { class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
scoped: Form.service({ scoped: Effect.gen(function*() {
schema: RegisterFormSchema.pipe( const form = yield* Form.service({
Schema.compose( schema: RegisterFormSchema.pipe(
Schema.transformOrFail( Schema.compose(
Schema.typeSchema(RegisterFormSchema), Schema.transformOrFail(
Schema.typeSchema(RegisterFormSchema), Schema.typeSchema(RegisterFormSchema),
{ Schema.typeSchema(RegisterFormSchema),
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)), {
encode: ParseResult.succeed, 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]) { f: Effect.fnUntraced(function*([value]) {
yield* Effect.sleep("500 millis") yield* Effect.sleep("500 millis")
return yield* Schema.decode(RegisterFormSubmitSchema)(value) return yield* Schema.decode(RegisterFormSubmitSchema)(value)
}), }),
debounce: "500 millis", })
return {
form,
emailField: Form.focusObjectField(form, "email"),
passwordField: Form.focusObjectField(form, "password"),
birthField: Form.focusObjectField(form, "birth"),
} as const
}) })
}) {} }) {}
class RegisterFormView extends Component.makeUntraced("RegisterFormView")(function*() { class RegisterFormView extends Component.make("RegisterFormView")(function*() {
const form = yield* RegisterForm const form = yield* RegisterFormService
const [canSubmit, submitResult] = yield* Subscribable.useSubscribables([ const [canCommit, submitResult] = yield* Subscribable.useSubscribables([
form.canSubmit, form.form.canCommit,
form.mutation.result, form.form.mutation.result,
]) ])
const runPromise = yield* Component.useRunPromise() 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"))
@@ -82,25 +92,26 @@ class RegisterFormView extends Component.makeUntraced("RegisterFormView")(functi
<Container width="300"> <Container width="300">
<form onSubmit={e => { <form onSubmit={e => {
e.preventDefault() e.preventDefault()
void runPromise(form.submit) void runPromise(form.form.submit)
}}> }}>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<TextFieldFormInputFC <TextFieldFormInput
field={yield* form.field(["email"])} form={form.emailField}
debounce="250 millis"
/> />
<TextFieldFormInputFC <TextFieldFormInput
field={yield* form.field(["password"])} form={form.passwordField}
debounce="250 millis"
/> />
<TextFieldFormInputFC <TextFieldOptionalFormInput
optional
type="datetime-local" type="datetime-local"
field={yield* form.field(["birth"])} form={form.birthField}
defaultValue="" defaultValue=""
/> />
<Button disabled={!canSubmit}>Submit</Button> <Button disabled={!canCommit}>Submit</Button>
</Flex> </Flex>
</form> </form>
@@ -115,13 +126,13 @@ class RegisterFormView extends Component.makeUntraced("RegisterFormView")(functi
) )
}) {} }) {}
const RegisterPage = Component.makeUntraced("RegisterPage")(function*() { const RegisterPage = Component.make("RegisterPageView")(function*() {
const RegisterFormViewFC = yield* Effect.provide( const RegisterForm = yield* Effect.provide(
RegisterFormView, RegisterFormView.use,
yield* Component.useContext(RegisterForm.Default), yield* Component.useContextFromLayer(RegisterFormService.Default),
) )
return <RegisterFormViewFC /> return <RegisterForm />
}).pipe( }).pipe(
Component.withRuntime(runtime.context) Component.withRuntime(runtime.context)
) )

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 { Todos } from "@/todo/Todos" import { TodosState } from "@/todo/TodosState"
import { TodosState } from "@/todo/TodosState.service" import { TodosView } from "@/todo/TodosView"
const TodosStateLive = TodosState.Default("todos") const TodosStateLive = TodosState.Default("todos")
const Index = Component.makeUntraced("Index")(function*() { const Index = Component.make("IndexView")(function*() {
const TodosFC = yield* Effect.provide( const Todos = yield* Effect.provide(
Todos, TodosView.use,
yield* Component.useContext(TodosStateLive), yield* Component.useContextFromLayer(TodosStateLive),
) )
return <TodosFC /> return <Todos />
}).pipe( }).pipe(
Component.withRuntime(runtime.context) Component.withRuntime(runtime.context)
) )

View File

@@ -13,15 +13,16 @@ const Post = Schema.Struct({
body: Schema.String, body: Schema.String,
}) })
const ResultView = Component.makeUntraced("Result")(function*() { const ResultView = Component.make("ResultView")(function*() {
const runPromise = yield* Component.useRunPromise() const runPromise = yield* Component.useRunPromise()
const [idRef, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() { const [idRef, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
const idRef = yield* SubscriptionRef.make(1) const idRef = yield* SubscriptionRef.make(1)
const key = Stream.map(idRef.changes, id => [id] as const)
const query = yield* Query.service({ const query = yield* Query.service({
key: Stream.zipLatest(Stream.make("posts" as const), idRef.changes), key,
f: ([, id]) => HttpClient.HttpClient.pipe( f: ([id]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")), Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json), Effect.andThen(response => response.json),

View File

@@ -1,13 +1,14 @@
import * as Domain from "@/domain"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { Box, Button, Flex, IconButton } from "@radix-ui/themes" import { Box, Button, Flex, IconButton } from "@radix-ui/themes"
import { GetRandomValues, makeUuid4 } from "@typed/id" import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Chunk, type DateTime, Effect, Match, Option, Ref, Schema, Stream } from "effect" import { Chunk, type DateTime, Effect, Match, Option, Ref, Schema, Stream } from "effect"
import { Component, Form, Subscribable } from "effect-fc" import { Component, Form, Lens, Subscribable } from "effect-fc"
import { FaArrowDown, FaArrowUp } from "react-icons/fa" import { FaArrowDown, FaArrowUp } from "react-icons/fa"
import { FaDeleteLeft } from "react-icons/fa6" import { FaDeleteLeft } from "react-icons/fa6"
import * as Domain from "@/domain" import { TodosState } from "./TodosState"
import { TextFieldFormInput } from "@/lib/form/TextFieldFormInput"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { TodosState } from "./TodosState.service"
const TodoFormSchema = Schema.compose(Schema.Struct({ const TodoFormSchema = Schema.compose(Schema.Struct({
@@ -30,7 +31,7 @@ export type TodoProps = (
| { readonly _tag: "edit", readonly id: string } | { readonly _tag: "edit", readonly id: string }
) )
export class Todo extends Component.makeUntraced("Todo")(function*(props: TodoProps) { export class TodoView extends Component.make("TodoView")(function*(props: TodoProps) {
const state = yield* TodosState const state = yield* TodosState
const [ const [
@@ -58,50 +59,51 @@ export class Todo extends Component.makeUntraced("Todo")(function*(props: TodoPr
Match.tag("new", () => Ref.update(state.ref, Chunk.prepend(todo)).pipe( Match.tag("new", () => Ref.update(state.ref, Chunk.prepend(todo)).pipe(
Effect.andThen(makeTodo), Effect.andThen(makeTodo),
Effect.andThen(Schema.encode(TodoFormSchema)), Effect.andThen(Schema.encode(TodoFormSchema)),
Effect.andThen(v => Ref.set(form.encodedValue, v)), Effect.andThen(v => Lens.set(form.encodedValue, v)),
)), )),
Match.tag("edit", ({ id }) => Ref.set(state.getElementRef(id), todo)), Match.tag("edit", ({ id }) => Ref.set(state.getElementRef(id), todo)),
Match.exhaustive, Match.exhaustive,
), ),
autosubmit: props._tag === "edit",
debounce: "250 millis",
}) })
return [ return [
indexRef, indexRef,
form, form,
yield* form.field(["content"]), Form.focusObjectField(form, "content"),
yield* form.field(["completedAt"]), Form.focusObjectField(form, "completedAt"),
] as const ] as const
}), [props._tag, props._tag === "edit" ? props.id : undefined]) }), [props._tag, props._tag === "edit" ? props.id : undefined])
const [index, size, canSubmit] = yield* Subscribable.useSubscribables([ const [index, size, canCommit] = yield* Subscribable.useSubscribables([
indexRef, indexRef,
state.sizeSubscribable, state.sizeSubscribable,
form.canSubmit, form.canCommit,
]) ])
const runSync = yield* Component.useRunSync() const runSync = yield* Component.useRunSync()
const runPromise = yield* Component.useRunPromise<DateTime.CurrentTimeZone>() const runPromise = yield* Component.useRunPromise<DateTime.CurrentTimeZone>()
const TextFieldFormInputFC = yield* TextFieldFormInput const TextFieldFormInput = yield* TextFieldFormInputView.use
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
return ( return (
<Flex direction="row" align="center" gap="2"> <Flex direction="row" align="center" gap="2">
<Box flexGrow="1"> <Box flexGrow="1">
<Flex direction="column" align="stretch" gap="2"> <Flex direction="column" align="stretch" gap="2">
<TextFieldFormInputFC field={contentField} /> <TextFieldFormInput
form={contentField}
debounce="250 millis"
/>
<Flex direction="row" justify="center" align="center" gap="2"> <Flex direction="row" justify="center" align="center" gap="2">
<TextFieldFormInputFC <TextFieldOptionalFormInput
optional form={completedAtField}
field={completedAtField}
type="datetime-local" type="datetime-local"
defaultValue="" defaultValue=""
/> />
{props._tag === "new" && {props._tag === "new" &&
<Button disabled={!canSubmit} onClick={() => void runPromise(form.submit)}> <Button disabled={!canCommit} onClick={() => void runPromise(form.submit)}>
Add Add
</Button> </Button>
} }

View File

@@ -1,11 +1,11 @@
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 { Todo } from "./Todo" import { TodosState } from "./TodosState"
import { TodosState } from "./TodosState.service" import { TodoView } from "./TodoView"
export class Todos extends Component.makeUntraced("Todos")(function*() { export class TodosView extends Component.make("TodosView")(function*() {
const state = yield* TodosState const state = yield* TodosState
const [todos] = yield* Subscribable.useSubscribables([state.ref]) const [todos] = yield* Subscribable.useSubscribables([state.ref])
@@ -14,17 +14,17 @@ export class Todos extends Component.makeUntraced("Todos")(function*() {
Effect.addFinalizer(() => Console.log("Todos unmounted")), Effect.addFinalizer(() => Console.log("Todos unmounted")),
)) ))
const TodoFC = yield* Todo const Todo = yield* TodoView.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">
<TodoFC _tag="new" /> <Todo _tag="new" />
{Chunk.map(todos, todo => {Chunk.map(todos, todo =>
<TodoFC key={todo.id} _tag="edit" id={todo.id} /> <Todo key={todo.id} _tag="edit" id={todo.id} />
)} )}
</Flex> </Flex>
</Container> </Container>