From d57901b7090f2d5ee99f16db6ad98e368e80311e Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 14:54:37 +0800 Subject: [PATCH 01/51] Update AGENTS.md --- AGENTS.md | 107 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 94 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ddbc6a3..a5fc175d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ yarn electron # Run Electron app - TypeScript strict mode - Prettier for formatting (runs on pre-commit) -- Follow DRY principle—extract reusable components +- **DRY principle**: Always follow the DRY principle when possible. Never repeat UI elements across views—extract them into reusable components in `src/components/`. Same applies to logic—extract into custom hooks in `src/hooks/`. ## React Patterns (Critical) @@ -78,22 +78,41 @@ src/ └── data/ # Static data (default subplebbits, etc.) ``` -## Documentation +## Recommended Skills -The following docs exist for deeper guidance. **Do not read them automatically**—they are large and will bloat the context window. Instead: -- Be aware they exist -- Consult them when relevant to the task or when the user asks -- Offer to read them if the user seems to need React pattern guidance +Skills are more efficient than docs—they inject targeted guidance without bloating the context window. -Available docs: -- **[docs/react-guide.md](docs/react-guide.md)** — Bad vs good React patterns with code examples -- **[docs/you-might-not-need-an-effect.md](docs/you-might-not-need-an-effect.md)** — When to avoid useEffect (comprehensive) +### Context7 (for library docs) + +When you need documentation for libraries like **plebbit-react-hooks** or **plebbit-js**, use the Context7 skill to fetch current docs instead of relying on potentially outdated training data. + +```bash +npx skills add https://github.com/intellectronica/agent-skills --skill context7 +``` + +### Vercel React Best Practices + +For deeper React/Next.js performance guidance. Provides 57 prioritized rules across 8 categories (waterfalls, bundle size, server-side performance, client-side fetching, re-renders, rendering, JS performance, and advanced patterns). + +```bash +npx skills add https://github.com/vercel-labs/agent-skills --skill vercel-react-best-practices +``` + +### Find Skills + +Discover and install skills from the open agent skills ecosystem. + +```bash +npx skills add https://github.com/vercel-labs/skills --skill find-skills +``` ## Recommended MCP Servers -If you need to look up library documentation (like plebbit-react-hooks or plebbit-js), suggest the user install the **Exa MCP server**. Exa's `get_code_context_exa` tool provides accurate, up-to-date docs and code context—it offers broader coverage and fewer hallucinations than alternatives like context7. +### GitHub MCP -If you need to check Dependabot security alerts, read GitHub Actions logs, search issues/PRs, or look up code across GitHub, suggest the user install the **GitHub MCP server** with the `default,dependabot,actions` toolsets enabled. +For Dependabot security alerts, GitHub Actions logs, issue/PR searches, or cross-repo code lookup, use the **GitHub MCP server** (with `default,dependabot,actions` toolsets enabled). + +If not available, suggest the user install it. ### Context Window Warning @@ -105,11 +124,73 @@ Each MCP server injects its tool definitions into the context window, consuming If you notice many MCP tools in your context, or if the user reports degraded responses, warn them that they may have too many MCP servers enabled and suggest disabling unused ones to free up context space. +## Translations + +This project uses i18next with translation files in `public/translations/{lang}/default.json`. + +### Adding/Updating Translations + +Use `scripts/update-translations.js` to update translations across all languages. **Do not manually edit each language file.** + +**Workflow:** + +1. Create a temporary dictionary file (e.g., `translations-temp.json`) with translations for each language: + ```json + { + "en": "English text", + "es": "Spanish text", + "fr": "French text", + "de": "German text", + ... + } + ``` + +2. Run the script with the `--map` flag: + ```bash + node scripts/update-translations.js --key my_new_key --map translations-temp.json --include-en --write + ``` + +3. Delete the temporary dictionary file after the script completes. + +**Other useful commands:** + +```bash +# Copy a key's value from English to all languages (dry run first) +node scripts/update-translations.js --key some_key --from en --dry +node scripts/update-translations.js --key some_key --from en --write + +# Delete a key from all languages +node scripts/update-translations.js --key obsolete_key --delete --write + +# Audit for unused translation keys +node scripts/update-translations.js --audit --dry +node scripts/update-translations.js --audit --write +``` + +## Workflow + +### GitHub Commits + +When proposing or implementing code changes, always suggest a short GitHub commit title, and if the commit title isn't exhaustive enough, then provide also a commit description. Format: + +- **Title**: Use [Conventional Commits](https://www.conventionalcommits.org/) style (e.g., `fix: ...`, `feat: ...`, `perf: ...`, `refactor: ...`, `docs: ...`, `chore: ...`). Use the `perf` type for performance optimizations (not `fix`). Keep it short. Use markdown. +- **Description**: If the title is missing important information, also provide a description, consisting of 2-3 informal sentences describing the solution (not the problem) that is being committed. Concise, technical, no bullet points. Use markdown. + +### Github Issues + +When proposing or implementing code changes, always suggest a GitHub issue title and description to keep track of the problem that was fixed. Format: + +- **Title**: As short as possible, may use commas to list related commits that resolve the same Github issue. Use markdown. +- **Description**: 2-3 informal sentences describing the problem (not the solution). Write as if the issue hasn't been fixed yet. Bullet points are encouranged but may not always be necessary. Use markdown. + +### Troubleshooting + +When stuck on a bug or issue, search the web for solutions. Developer communities often have recent fixes or workarounds that aren't in training data. + ## Boundaries - Never commit secrets or API keys - Use yarn, not npm - Keep components focused—split large components -- Add comments for complex logic, skip obvious code +- Add comments for complex/unclear code (especially custom functions in this FOSS project with many contributors). Skip comments for obvious code - Test on mobile viewport (this is a responsive app) - From 0e41853cb8151d500308c1ea54beb25677b62445 Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 15:07:45 +0800 Subject: [PATCH 02/51] Update README.md --- README.md | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 25702952..0f66630a 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,36 @@ +[![Build Status](https://img.shields.io/github/actions/workflow/status/bitsocialhq/seedit/test.yml?branch=master)](https://github.com/bitsocialhq/seedit/actions/workflows/test.yml) +[![Release](https://img.shields.io/github/v/release/bitsocialhq/seedit)](https://github.com/bitsocialhq/seedit/releases/latest) +[![License](https://img.shields.io/badge/license-GPL--2.0--only-red.svg)](https://github.com/bitsocialhq/seedit/blob/master/LICENSE) +[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) + _Telegram group for this repo https://t.me/seeditreact_ # Seedit -Seedit is a serverless, adminless, decentralized reddit alternative. Seedit is a client (interface) for the Plebbit protocol, which is a decentralized social network where anyone can create and fully own unstoppable communities. Learn more: https://plebbit.com +Seedit is a serverless, adminless, decentralized and open-sorce (old)reddit alternative built on the [Bitsocial protocol](https://bitsocial.net). Like reddit, anyone can create a seedit community, It features a similar homepage structure as reddit, but with a crucial difference: **anyone can create and own communities, and multiple communities can compete for each default community slot**. - Seedit web version: https://seedit.app — or, using Brave/IPFS Companion: https://seedit.eth ### Downloads -- Seedit desktop version (full p2p plebbit node, seeds automatically): available for Mac/Windows/Linux, [download link in the release page](https://github.com/plebbit/seedit/releases/latest) -- Seedit mobile version: available for Android, [download link in the release page](https://github.com/plebbit/seedit/releases/latest) +- Seedit desktop version (full p2p bitsocial node, seeds automatically): available for Mac/Windows/Linux, [download link in the release page](https://github.com/bitsocialhq/seedit/releases/latest) +- Seedit mobile version: available for Android, [download link in the release page](https://github.com/bitsocialhq/seedit/releases/latest)
## How to create a community -In the plebbit protocol, a seedit community is called a _subplebbit_. To run a subplebbit, you can choose between two options: +To run a community, you can choose between two options: -1. If you prefer to use a **GUI**, download the desktop version of the Seedit client, available for Windows, MacOS and Linux: [latest release](https://github.com/plebbit/seedit/releases/latest). Create a subplebbit using using the familiar old.reddit-like UI, and modify its settings to your liking. The app runs an IPFS node, meaning you have to keep it running to have your board online. -2. If you prefer to use a **command line interface**, install plebbit-cli, available for Windows, MacOS and Linux: [latest release](https://github.com/plebbit/plebbit-cli/releases/latest). Follow the instructions in the readme of the repo. When running the daemon for the first time, it will output WebUI links you can use to manage your subplebbit with the ease of the GUI. +1. If you prefer to use a **GUI**, download the desktop version of the Seedit client, available for Windows, MacOS and Linux: [latest release](https://github.com/bitsocialhq/seedit/releases/latest). Create a community using using the familiar old.reddit-like UI, and modify its settings to your liking. The app runs an IPFS node, meaning you have to keep it running to have your board online. +2. If you prefer to use a **command line interface**, install bitsocial-cli, available for Windows, MacOS and Linux: [latest release](https://github.com/bitsocialhq/bitsocial-cli/releases/latest). Follow the instructions in the readme of the repo. When running the daemon for the first time, it will output WebUI links you can use to manage your community with the ease of the GUI. -Peers can connect to your subplebbit using any plebbit client, such as Plebchan or Seedit. They only need the subplebbit's address, which is not stored in any central database, as plebbit is a pure peer-to-peer protocol. +Peers can connect to your bitsocial community using any bitsocial client, such as Seedit or [5chan](https://github.com/bitsocialhq/5chan). They only need the community address, which is not stored in any central database, as bitsocial is a pure peer-to-peer protocol. -### How to add a community to the default list (p/all) -The default list of communities, used on p/all on Seedit, is plebbit's [temporary default subplebbits](https://github.com/plebbit/lists) list. You can open a pull request in that repo to add your subplebbit to the list, or contact devs via telegram [@plebbit](https://t.me/plebbit). In the future, this process will be automated by submitting proposals to a plebbit DAO, using the [plebbit token](https://etherscan.io/token/0xea81dab2e0ecbc6b5c4172de4c22b6ef6e55bd8f). +### How to add a community to the default list (s/all) +The default list of communities, used on s/all on Seedit, is bitsocial's [default-multisub.json list](https://github.com/bitsocialhq/lists/blob/master/default-multisub.json). You can open a pull request in that repo to add your community to the list. ## To run locally @@ -44,4 +49,4 @@ The default list of communities, used on p/all on Seedit, is plebbit's [temporar ### Build: -The linux/windows/mac/android build scripts are in https://github.com/plebbit/seedit/blob/master/.github/workflows/release.yml +The linux/windows/mac/android build scripts are in https://github.com/bitsocialhq/seedit/blob/master/.github/workflows/release.yml From ec19e9a31b079d6d80dbdca750bac2d91c49c2ca Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 15:08:46 +0800 Subject: [PATCH 03/51] typo --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index a5fc175d..c3f0dc89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,7 +181,7 @@ When proposing or implementing code changes, always suggest a short GitHub commi When proposing or implementing code changes, always suggest a GitHub issue title and description to keep track of the problem that was fixed. Format: - **Title**: As short as possible, may use commas to list related commits that resolve the same Github issue. Use markdown. -- **Description**: 2-3 informal sentences describing the problem (not the solution). Write as if the issue hasn't been fixed yet. Bullet points are encouranged but may not always be necessary. Use markdown. +- **Description**: 2-3 informal sentences describing the problem (not the solution). Write as if the issue hasn't been fixed yet. Bullet points are encouraged but may not always be necessary. Use markdown. ### Troubleshooting From 73c3f10ac071fe626d8094af04856360d89bc2c9 Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 15:09:45 +0800 Subject: [PATCH 04/51] typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0f66630a..74af8933 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ _Telegram group for this repo https://t.me/seeditreact_ # Seedit -Seedit is a serverless, adminless, decentralized and open-sorce (old)reddit alternative built on the [Bitsocial protocol](https://bitsocial.net). Like reddit, anyone can create a seedit community, It features a similar homepage structure as reddit, but with a crucial difference: **anyone can create and own communities, and multiple communities can compete for each default community slot**. +Seedit is a serverless, adminless, decentralized and open-source (old)reddit alternative built on the [Bitsocial protocol](https://bitsocial.net). Like reddit, anyone can create a seedit community, It features a similar homepage structure as reddit, but with a crucial difference: **anyone can create and own communities, and multiple communities can compete for each default community slot**. - Seedit web version: https://seedit.app — or, using Brave/IPFS Companion: https://seedit.eth From c96ff9db42afac0f2a9b55d84b782bc7dd012ecc Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 15:13:19 +0800 Subject: [PATCH 05/51] Delete notifications.yml not needed, we now use https://github.com/mhkafadar/notifine --- .github/workflows/notifications.yml | 93 ----------------------------- 1 file changed, 93 deletions(-) delete mode 100644 .github/workflows/notifications.yml diff --git a/.github/workflows/notifications.yml b/.github/workflows/notifications.yml deleted file mode 100644 index d390b403..00000000 --- a/.github/workflows/notifications.yml +++ /dev/null @@ -1,93 +0,0 @@ -# send notifications to telegram group on commits, releases, issues -name: notifications - -on: - pull_request: - branches: - - master - # push: - # branches: - # - master - release: - types: [published] - issues: - types: [opened] - -jobs: -# push: -# if: ${{ github.event_name == 'push' }} -# runs-on: ubuntu-latest -# strategy: -# matrix: -# commit: ${{ github.event.commits }} -# steps: -# # - name: debug -# # env: -# # DEBUG: ${{ toJSON(matrix.commit) }} -# # run: echo "$DEBUG" - -# # write message to file -# - run: echo "${{ matrix.commit.message }}" >> message.txt -# - run: echo "" >> message.txt -# - run: echo "by ${{ matrix.commit.author.username }}" >> message.txt -# - run: echo "${{ matrix.commit.url }}" >> message.txt -# - run: date -d "${{ matrix.commit.timestamp }}" +"%d/%m/%y at %H:%M" >> message.txt - -# # send message, @plebbit telegram chat id is -1001665335693 -# - name: "telegram notification" -# uses: appleboy/telegram-action@master -# with: -# to: -1001665335693 -# token: ${{ secrets.TELEGRAM_TOKEN }} -# # commit links don't show anything useful in preview -# disable_web_page_preview: true -# format: html -# message_file: message.txt - - release: - if: ${{ github.event_name == 'release' }} - runs-on: ubuntu-latest - steps: - # - name: debug - # env: - # DEBUG: ${{ toJSON(github) }} - # run: echo "$DEBUG" - - # write message to file - - run: echo "${{ github.event.repository.name }} ${{ github.event.release.name }}" >> message.txt - - run: echo "" >> message.txt - - run: echo "${{ github.event.release.body }}" >> message.txt - - run: echo "${{ github.event.release.html_url }}" >> message.txt - - # send message, @plebbit telegram chat id is -1001665335693 - - name: "telegram notification" - uses: appleboy/telegram-action@master - with: - to: -1001665335693 - token: ${{ secrets.TELEGRAM_TOKEN }} - format: html - message_file: message.txt - - issue: - if: ${{ github.event_name == 'issues' }} - runs-on: ubuntu-latest - steps: - # - name: debug - # env: - # DEBUG: ${{ toJSON(github) }} - # run: echo "$DEBUG" - - # write message to file - - run: echo "${{ github.event.issue.title }}" >> message.txt - - run: echo "" >> message.txt - - run: echo "by ${{ github.event.issue.user.login }}" >> message.txt - - run: echo "${{ github.event.issue.html_url }}" >> message.txt - - # send message, @plebbit telegram chat id is -1001665335693 - - name: "telegram notification" - uses: appleboy/telegram-action@master - with: - to: -1001665335693 - token: ${{ secrets.TELEGRAM_TOKEN }} - format: html - message_file: message.txt From f9a12e3557bf98fc87d28af83f4ec003890dd190 Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 16:01:48 +0800 Subject: [PATCH 06/51] fix(ci): update macos runner image --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 68c4ee9c..219f8c52 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -146,7 +146,7 @@ jobs: test-mac-arm: name: Test Mac (Apple Silicon) - runs-on: macOS-14 + runs-on: macos-latest steps: - uses: actions/checkout@v4 with: From 0eec52fd6de2411d86f3b78618f381190eceb1a6 Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 16:08:53 +0800 Subject: [PATCH 07/51] fix(release): update macos runner images, avoid environment error --- .github/workflows/release.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b302121..5390722f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,9 +60,9 @@ jobs: fail-fast: false matrix: include: - - runner: macOS-13 # Intel x64 + - runner: macos-15-intel # Intel x64 arch: x64 - - runner: macOS-14 # Apple Silicon arm64 + - runner: macos-latest # Apple Silicon arm64 arch: arm64 runs-on: ${{ matrix.runner }} permissions: @@ -82,6 +82,12 @@ jobs: with: python-version: '3.12' + # install missing dep for sqlite (use setup-python to avoid PEP 668 externally-managed-environment error) + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install setuptools + - name: Install dependencies (with Node v22) run: yarn install --frozen-lockfile --ignore-engines # make sure the ipfs executable is executable From 04d7752ba9839d1920154fb3c6f1131788a27d39 Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 16:23:21 +0800 Subject: [PATCH 08/51] Update plebbit.keystore --- android/plebbit.keystore | Bin 2747 -> 2730 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/android/plebbit.keystore b/android/plebbit.keystore index 2acc2e33b02ac3f019e570e2b93ef37f943f0b6e..4f98bb21b1560c3e9dbfe5ef50eedeac7b7aa6a2 100644 GIT binary patch delta 2537 zcmV7E9S-qZs97U=NM1eSL(i=cI$yEXxiW(}*DoBwCW>}|;EcMVM^bpxa)A7wZ zP&(%*EOc;kQ}tkahL2<2U7!e3+#41W&UB;m%DPiJP6RIg^+B^ZF;gQ}BY(lO&MNRQ zj~rFEN`v`Z__$T}_SoX}WfH}6qTe*WfU)4GsXlEFF%7{(A{Osu6O-riiI*L0w7UBY zB^FSgPq+u)n$ubcjnlgG3W+DP=k17@1%yrL0PsyEMG<4zPT zi;~Q5!zrG;IVTF-*bw%~Cx6*HIQ9yVV^deS(bj({`T|PKEoGTTD=?++>?^wF*QX<5 zQ4os3%CTqCcC8gG3V9Lm_N=UT58tE78Y_z5#Rhx)J$Oga0i(2_R|+|`5S!vg6Dq9k z&jRu)pA$?oW41uQlN(jjuSzi?Wtvu3fDj1?Ifz9(k}iFS~ODUOyvLz0B2jV)xlGKKd!c2xof`%%a4;x!B2Gy zmO#=)s*nIU7$s0dmVYF(rfrda!Hiyrj8spH^2J`n)}j$InO>e&gsAPk-ApI0evBpkL+hryOHD zEU&qWzwB!B9t)T(IRUxrD5!%wv<>ZG4(0CVcaQoNM{zInxRVp|x`OvW?d^F;1@ zVrOprs)YV=*h^qb(3L9zaCv#QD%%Ke(KObRDzliIR$(WxLEP`gOnO$mophoo>kwd_ zxCccqHK(z7c!hA1+zX450!-&`mLOPm5c;=2>yfNIkbeZ`nO{ax!OK(Sir9lL;a+Wj z!NPT{tR+HI(=$kMWxo{0yHa3zS3`QYY-vc%_XCwNG&kIkNZ|ReHW_^q{+9x_ZtYmF zUH;GoXNt}LSh33OuML|^%u3Ra7I&tb_dhl250?Xto!YE_I+sa_3rr33QiB9Ds4gPO z2|6Q-(|-f#{*FMZMMT^t(m$+;sc%+qU=%*E!goOJ5f_4i5-xrTJi|yQX6zL*=ZSfo z2r|X~g#Bf#Pqts@!R5?&^dG zpC1A`4yTAgxJ-yhg@CU3hQ7O4ffk+7nN_2MkAE5(?y^a5UD;lw=?RBzt9ICu;Re~s z4?oX+Il4~Rk?9OF0#^mFD+sD)YUzbmqh(JL_Wvtz{-D#nB(UtDVi|W&*EYwG7#Tls z&SW++XPyM26o)6jMSkc*;X`wov2qYE;c|6Y`q6TZ>x(%tLNFZ$2`Yw2hW8Bt2^299 z9+RX6CMq{JIW;gcF*!6bGchoN1c3$#Duzgg_YDCD2B3ljaxj7fZUO-SFoFbUlk^2E z6iex_>HAGP<08x4>nw(T_s`fk-I0nUe?ZN3M757T5%1WlGgWpq=753&@Hrj*JN>b9 zBno?UqZPROo8kuPRkWUvxeHCI&8gDmIX46Tq-&cUh-5H=^{Y(727&oLerdeUkIwRV z`?9+I)gYdNND6~kT;~!o7pPk8D^Z)d4VV3?DZo-0HO61b4~jeJ|7pm^o-}6yeIb7{dE9&x>xO%IyUt59;KVTF()KZ*XLWp36GN|hz5$>UTROblF41@e$A$b zX5Sso#=X;!sjf?G^k?JKaJ`GuAL8FZ0lxWtQR?&CX-2+ZBsStftyA=Jb~%zQFvOR^ zTw~i`#*$AP$ip0}y!79}Y3~MLf4sq&0V9u$>Pj73?As%SUtQ0Gm?`*dWVNOuW@N>1>~1D!OWd4xmqdU`&E^fKns^%u`c6egoB zxry8*Kz=r~za1&5M;zVXW+_5!@J|;}(a@SvG$zD}98~1qTN%#^H~f;Kf6S#Sa?VxK z%Ke`MPdV$9$vK7ORC*}>FlGd-;BV30pSq$-^~WI$k9tW>*eqKB6Vaj2X-5ph(7EdM z3_~i(q|e;)2TB*Ul_`tEpPF7`&`i8*jlJlz{?uK|EdhGW>^X~t7d zn$0b{B6=3l>1Kc%MKFY?f5TCZ)-uRwrfRc-V9|sY%rvB&0_e5}@RwZM>?`;`xEq)8 z33WGdERy>mPnVVHfP=0s{1}i-`JbEVYvB^kUKmKPMsva;w=wE$cC1=CQ=0MLPJMiF z<=B{!dV|S^?;1H!TdI{+ZIrTxzgrcs^$-D&6F%z1^`UHape0;bf8=v47Pw2Mrr-t` zPk6@}(Q`B_%)XPa^xE^}YIQ5vvh$$cy5VZDyx$Fg1UK%T7MCHDASqbcDbe2o%s<*X zwP$BS$@lOiDSfqhya!Ab#gt|VsZ>ytGw2CCq)o%Tx`ZI1pKM!JhsqVUuApPhC4XQ70OHt_^Cynl~vf{^jEZ< zrbU!-qIeA|sWZPF_l^i?#X@~gF^lPrp76qos~PoAq$Cylf9fPM-!s9J{`@(7`&xDL zP#SG@E;?zKtcbLIn~Yf)ZQ{K8EPXd<9D97ehjPggiFH|NAqtYuy~hS|s0L-1eh>Zm z!a&TpPg?W<&JJe)kirb#svDi_FS00ce62G|sK89Ka7X3=DQpc=7}kJjL$vbk6Sa^`3MtxTo_lRS6gX0s#f#W8KiyBdcDQ2D2!@xF2CMDSG<3q{)G-!-a!GUQjB!6V6fp*p! zz9j+oDR$?n5OET}Zy;M9=n>`yWMA^z;W$|OXp6^?uUC0909bEIp|iQNO;^CahzhAo z!9Io%`brTcO>wDod*%>dLpy^Vd+D$$P#nuhI{LqKp3<#vTZ>vc5Jx%G*R*zSPp#B# z1Y(~DtKrl0bFcOTeS7;VDu1keA}Xy56)173QpN;tQqY4!>)Fn#s})orJrxkpe2i)r zcgiFpP1`1aePDKa?dsFAs<6USoG9KOB$+b#!wF^w<(if~jJ^zZZV3zdNmVer*Zl7P*B2mPV zAsjV-+eu`TW-^_n^TYOm}L!l_yCxOxsU30sM2nz__+AR zcS&rwwvP*!fmM&)DQBlQtxr&Yedl2lxlpCNZUrfOTecL9ursxxyYywY{5qsNh|*I? z+cga3@Xds59+Gu`reOuu}r}Eq$A@?EUz|7{s@*YdK0_7H(so zl><;v;dCm`INC7cF|0mxamLLYwpqU?;Uc#xLP{aukF2Zdq78a&=2joN3g+XNp7a*U zHb0t3OTAKjQ-88+tQm&4L_g_vh4_I;Zdlvd{2LVFjRXt)JU|1N)J!)N+9e2%m``@? zbY~c?j*@OPK}|| zg~0Z3g=s3D5h{BEqXh#qtQ9d$P>q$bT~XiSkht`=R*jmJ-P_5mx~&D1YCZb+tCy?1 zOtNxGM1NSJNhKzywy3w?0j+4B23%a+(PZ?;@+EW%N)AMHcd4`ykz2Llu{^33!@IG_ zu>V1u$2}W4eEFPbwEaFzBafmUcO|6>&+fEiirE`C`Fki$(XdW~e-}h&`zmIQ44W z`zkEl6_?jHzsSwXJw`o*-wE6h8%w$i65HJ;x5&y(W#>-ZuNU3n<=i6-V01qM7e&%8wMK>)-p{P5cfM@J*GaSjUoBuz^8h z!+i&Rys3MiV$)!>5a>kYH^wX0;39!i-kb0f%msaXR(zM+RGHC~Ft&OORdFl*f00m- zwqDyUvy!mwer@7O7G*Pf<(imXyr+^4fy5b5Ud>v z9O#Ko8eZ4;Nojr~skr=Er+I0W7gOId7HMmu( z6DOaGGaNy2zdmQBI=PJ|?UaSEsz2APqCf*0miZ6HijMCGvqHWl>FaLaf2vStU23{K9TiIQlEh7-~q^xn3G1tBVS7r7iJL_+;^@Z7l@x6kOo|+0b1k5Qp1XK z2?f8uxLAPjFwXf^f*FY*f5d0JpcK>>9ELGH5!43C`@f)i6DQeF)wn{NfRca5&mj4N zb%~Foei&GGH5o47hx2y$xaiVoM@6%H1yj|u^XPkzLR2RbU0XYR$p?=2Z)60p8Bobl z1^^ow4`9;twQyxk@h(jvM=|_Ue`(QBde^((NPpq5K$zmOCw2c%gBs3xjfog&0tBu)CpS$Hmd z(?*xM*(Ihh!FSKCf5`8+IdO&;)dsnb>I6iQyKRMY*(P$pLjr|G!TZX~jG4NT)gy?vv>~Mq&+m-kYlB7G!KG8Mu=Z1WUUYZcdRGNaSvV9 z^0k0)lFm}$(uD&CZdu<4XKJn}a@(EdR##(tPPEBI#$h9yfBodb9=2UeetzkGieVg$ zn9N*iB0BM6nQt+qBow`b9S%eC$GJwn;q4iqUJJbCQ+Vw#9IvS4t`ci^Ll3c7KFxpG z?cv~V^EqlLYy1@=!jZwjTJnye9*zy{?&uR1i*e4<+x}d$$~jt?|66CV@g#@lfb@aA zcJ4lW&@ZkFY&m_PI&IsLHWkf1u#r-< Date: Tue, 27 Jan 2026 16:23:30 +0800 Subject: [PATCH 09/51] Update release.yml --- .github/workflows/release.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5390722f..d72782ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -183,8 +183,7 @@ jobs: - run: sed -i "s/versionName \"1.0\"/versionName \"$(node -e "console.log(require('./package.json').version)")\"/" ./android/app/build.gradle - run: cat ./android/app/build.gradle # build apk - - run: npx cap update - - run: npx cap copy + - run: npx cap sync android - run: cd android && gradle bundle - run: cd android && ./gradlew assembleRelease --stacktrace # optimize apk From c81b6fa00630adbd574b894d57f4bcba567a0445 Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 16:23:56 +0800 Subject: [PATCH 10/51] fix(deps): update capacitor --- package.json | 6 +++--- yarn.lock | 34 +++++++++++++++++----------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index c3133564..7b637d44 100755 --- a/package.json +++ b/package.json @@ -98,9 +98,9 @@ ] }, "devDependencies": { - "@capacitor/android": "7.2.0", - "@capacitor/cli": "7.2.0", - "@capacitor/core": "7.2.0", + "@capacitor/android": "7.4.5", + "@capacitor/cli": "7.4.5", + "@capacitor/core": "7.4.5", "@electron/rebuild": "4.0.1", "@react-scan/vite-plugin-react-scan": "0.1.8", "@types/memoizee": "0.4.9", diff --git a/yarn.lock b/yarn.lock index fe19df88..774bde27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -940,20 +940,20 @@ ipaddr.js "^2.2.0" punycode "^2.3.1" -"@capacitor/android@7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@capacitor/android/-/android-7.2.0.tgz#f30e313315ab92c500bd57d9f0c4716a46b42f40" - integrity sha512-zdhEy3jZPG5Toe/pGzKtDgIiBGywjaoEuQWnGVjBYPlSAEUtAhpZ2At7V0SCb26yluAuzrAUV0Ue+LQeEtHwFQ== +"@capacitor/android@7.4.5": + version "7.4.5" + resolved "https://registry.yarnpkg.com/@capacitor/android/-/android-7.4.5.tgz#3832b457e0a3a7079a645bf5be3cc23d406c2971" + integrity sha512-G76C+PGIFkG+hxj6JLlY003txRaGZwsXBPo4b4xiZMZaD6DG++sDCoumYl/5xL40dL2b8P5tb3CKf/khWyL2Gw== "@capacitor/app@7.0.1": version "7.0.1" resolved "https://registry.yarnpkg.com/@capacitor/app/-/app-7.0.1.tgz#0d0709fb4dde5046c24853f2d6b77a7ea411f748" integrity sha512-ArlVZAAla4MwQoKh26x2AaTDOBh5Vhp1VhMKR3RwqZSsZnazKTFGNrPbr9Ez5r1knnEDfApyjwp1uZnXK1WTYQ== -"@capacitor/cli@7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@capacitor/cli/-/cli-7.2.0.tgz#ea279f14e575b059edb7fe8dc64a8e3379088616" - integrity sha512-RNW9vtYYYSDmOdguYBSW0VpRnG/d6lGydlc9DLrJ7qbSPxFrotTz9IjkM48O+SruUma61DyuSqJttdbay2xSxg== +"@capacitor/cli@7.4.5": + version "7.4.5" + resolved "https://registry.yarnpkg.com/@capacitor/cli/-/cli-7.4.5.tgz#056d8acc0d27ec3f8f79a5588e6cd6539db2895d" + integrity sha512-ApKxpzcWTqmyTD+WxBvL7UJt+x0xr9Vqa+S4Iqbl/R2kC0tf1ay22wE9pOrdWXbTqFbNhBuUuRvTvZ6Edp7QzQ== dependencies: "@ionic/cli-framework-output" "^2.2.8" "@ionic/utils-subprocess" "^3.0.1" @@ -963,7 +963,7 @@ env-paths "^2.2.0" fs-extra "^11.2.0" kleur "^4.1.5" - native-run "^2.0.1" + native-run "^2.0.3" open "^8.4.0" plist "^3.1.0" prompts "^2.4.2" @@ -973,10 +973,10 @@ tslib "^2.8.1" xml2js "^0.6.2" -"@capacitor/core@7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@capacitor/core/-/core-7.2.0.tgz#fdd1c5d2fbfa257887ce6065f66151a51f0e6d1b" - integrity sha512-2zCnA6RJeZ9ec4470o8QMZEQTWpekw9FNoqm5TLc10jeCrhvHVI8MPgxdZVc3mOdFlyieYu4AS1fNxSqbS57Pw== +"@capacitor/core@7.4.5": + version "7.4.5" + resolved "https://registry.yarnpkg.com/@capacitor/core/-/core-7.4.5.tgz#334d77356fa473e95d51cf6375e9b36471faf2d6" + integrity sha512-JnUywi0WRbqm4QYoplNsMQ7KAVuyrQFha0dOH9YDVCyrVHUvPcvrnK+9j5elUMgy+pOh3ve+pk6BVoySr91c+A== dependencies: tslib "^2.1.0" @@ -11224,10 +11224,10 @@ napi-build-utils@^2.0.0: resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz#13c22c0187fcfccce1461844136372a47ddc027e" integrity sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA== -native-run@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/native-run/-/native-run-2.0.1.tgz#a9b213c32824b007cbdd0279e0edd3c24bcc2f7a" - integrity sha512-XfG1FBZLM50J10xH9361whJRC9SHZ0Bub4iNRhhI61C8Jv0e1ud19muex6sNKB51ibQNUJNuYn25MuYET/rE6w== +native-run@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/native-run/-/native-run-2.0.3.tgz#b1ceb39c8d24abf4916c07ae9aad5a9fbcd31a02" + integrity sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q== dependencies: "@ionic/utils-fs" "^3.1.7" "@ionic/utils-terminal" "^2.3.4" From 8335aaf46a77232d4a43573c8d3258e2b83b6867 Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 16:32:44 +0800 Subject: [PATCH 11/51] fix(ci): make smoke tests dynamically find electron artifacts --- .github/workflows/test.yml | 221 +++++++++++++++++++++++++------------ 1 file changed, 148 insertions(+), 73 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 219f8c52..4539988b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,24 +50,54 @@ jobs: - name: Smoke Test run: | - echo "Testing AppImage startup..." - APPIMAGE=$(find dist -name "*.AppImage" | head -n 1) - echo "Found AppImage: $APPIMAGE" - chmod +x "$APPIMAGE" - # Use --appimage-extract-and-run to avoid FUSE requirement in CI - # Run with timeout and expect it to start (will be killed after timeout) - timeout 10s "$APPIMAGE" --appimage-extract-and-run --no-sandbox & - APP_PID=$! - sleep 5 - # Check if process is still running (means it started successfully) - if kill -0 $APP_PID 2>/dev/null; then - echo "✓ App started successfully" - kill $APP_PID 2>/dev/null || true - exit 0 - else - echo "✗ App failed to start" - exit 1 + echo "Listing dist directory..." + ls -la dist/ + + # Try unpacked directory first (more reliable for testing) + if [ -d "dist/linux-unpacked" ]; then + echo "Testing unpacked executable..." + EXE=$(find dist/linux-unpacked -maxdepth 1 -type f -executable | head -n 1) + if [ -n "$EXE" ]; then + echo "Found executable: $EXE" + "$EXE" --no-sandbox & + APP_PID=$! + sleep 10 + if kill -0 $APP_PID 2>/dev/null; then + echo "✓ App started successfully" + kill $APP_PID 2>/dev/null || true + pkill -P $APP_PID 2>/dev/null || true + pkill -f ipfs 2>/dev/null || true + exit 0 + else + echo "✗ App failed to start or crashed" + exit 1 + fi + fi fi + + # Fallback to AppImage + APPIMAGE=$(find dist -name "*.AppImage" | head -n 1) + if [ -n "$APPIMAGE" ]; then + echo "Found AppImage: $APPIMAGE" + chmod +x "$APPIMAGE" + "$APPIMAGE" --appimage-extract-and-run --no-sandbox & + APP_PID=$! + sleep 10 + if kill -0 $APP_PID 2>/dev/null; then + echo "✓ App started successfully" + kill $APP_PID 2>/dev/null || true + pkill -P $APP_PID 2>/dev/null || true + pkill -f ipfs 2>/dev/null || true + exit 0 + else + echo "✗ App failed to start or crashed" + exit 1 + fi + fi + + echo "✗ No runnable artifact found" + ls -R dist/ + exit 1 test-mac-intel: name: Test Mac (Intel) @@ -115,32 +145,50 @@ jobs: - name: Build Electron App # Use --dir to build only the .app bundle without DMG # This avoids flaky hdiutil "Resource busy" errors in CI - run: yarn build && yarn build:preload && yarn electron-builder build --publish never -m --dir + run: yarn build && yarn build:preload && yarn electron-rebuild && yarn electron-builder build --publish never -m --dir - name: Smoke Test run: | - if [ -d "dist/mac/seedit.app" ]; then - echo "Testing dist/mac/seedit.app..." - # Run the app in background - it will start IPFS which takes time - # We just verify it launches without crashing - ./dist/mac/seedit.app/Contents/MacOS/seedit & - APP_PID=$! - sleep 10 - # Check if process is still running (means it started successfully) - if kill -0 $APP_PID 2>/dev/null; then - echo "✓ App started successfully" - kill $APP_PID 2>/dev/null || true - # Also kill any child processes (IPFS) - pkill -P $APP_PID 2>/dev/null || true - pkill -f ipfs 2>/dev/null || true - exit 0 - else - echo "✗ App failed to start or crashed" - exit 1 - fi + echo "Listing dist directory..." + ls -la dist/ + + # Find any .app bundle in dist/mac/ + APP_PATH=$(find dist/mac -maxdepth 1 -name "*.app" -type d 2>/dev/null | head -n 1) + if [ -z "$APP_PATH" ]; then + echo "✗ No .app bundle found in dist/mac/" + ls -R dist/ + exit 1 + fi + + echo "Testing $APP_PATH..." + # Find the executable inside the app bundle + APP_NAME=$(basename "$APP_PATH" .app) + EXE_PATH="$APP_PATH/Contents/MacOS/$APP_NAME" + + if [ ! -f "$EXE_PATH" ]; then + # Try to find any executable in MacOS folder + EXE_PATH=$(find "$APP_PATH/Contents/MacOS" -type f -perm +111 | head -n 1) + fi + + if [ -z "$EXE_PATH" ] || [ ! -f "$EXE_PATH" ]; then + echo "✗ Could not find executable in $APP_PATH" + ls -la "$APP_PATH/Contents/MacOS/" + exit 1 + fi + + echo "Running: $EXE_PATH" + "$EXE_PATH" & + APP_PID=$! + sleep 10 + + if kill -0 $APP_PID 2>/dev/null; then + echo "✓ App started successfully" + kill $APP_PID 2>/dev/null || true + pkill -P $APP_PID 2>/dev/null || true + pkill -f ipfs 2>/dev/null || true + exit 0 else - echo "Could not find dist/mac/seedit.app to test" - ls -R dist + echo "✗ App failed to start or crashed" exit 1 fi @@ -191,31 +239,49 @@ jobs: # On M1 runner, this should produce arm64 build # Use --dir to build only the .app bundle without DMG # This avoids flaky hdiutil "Resource busy" errors in CI - run: yarn build && yarn build:preload && yarn electron-builder build --publish never -m --dir + run: yarn build && yarn build:preload && yarn electron-rebuild && yarn electron-builder build --publish never -m --dir - name: Smoke Test run: | - if [ -d "dist/mac-arm64/seedit.app" ]; then - APP_PATH="dist/mac-arm64/seedit.app" - elif [ -d "dist/mac/seedit.app" ]; then - APP_PATH="dist/mac/seedit.app" - else - echo "Could not find seedit.app to test" - ls -R dist + echo "Listing dist directory..." + ls -la dist/ + + # Find any .app bundle in dist/mac-arm64/ or dist/mac/ + APP_PATH=$(find dist/mac-arm64 -maxdepth 1 -name "*.app" -type d 2>/dev/null | head -n 1) + if [ -z "$APP_PATH" ]; then + APP_PATH=$(find dist/mac -maxdepth 1 -name "*.app" -type d 2>/dev/null | head -n 1) + fi + + if [ -z "$APP_PATH" ]; then + echo "✗ No .app bundle found" + ls -R dist/ exit 1 fi echo "Testing $APP_PATH..." - # Run the app in background - it will start IPFS which takes time - # We just verify it launches without crashing - "./$APP_PATH/Contents/MacOS/seedit" & + # Find the executable inside the app bundle + APP_NAME=$(basename "$APP_PATH" .app) + EXE_PATH="$APP_PATH/Contents/MacOS/$APP_NAME" + + if [ ! -f "$EXE_PATH" ]; then + # Try to find any executable in MacOS folder + EXE_PATH=$(find "$APP_PATH/Contents/MacOS" -type f -perm +111 | head -n 1) + fi + + if [ -z "$EXE_PATH" ] || [ ! -f "$EXE_PATH" ]; then + echo "✗ Could not find executable in $APP_PATH" + ls -la "$APP_PATH/Contents/MacOS/" + exit 1 + fi + + echo "Running: $EXE_PATH" + "$EXE_PATH" & APP_PID=$! sleep 10 - # Check if process is still running (means it started successfully) + if kill -0 $APP_PID 2>/dev/null; then echo "✓ App started successfully" kill $APP_PID 2>/dev/null || true - # Also kill any child processes (IPFS) pkill -P $APP_PID 2>/dev/null || true pkill -f ipfs 2>/dev/null || true exit 0 @@ -270,26 +336,35 @@ jobs: - name: Smoke Test shell: bash run: | - # Try to find the unpacked executable first as it's easiest to run + echo "Listing dist directory..." + ls -la dist/ + + # Try to find the unpacked executable if [ -d "dist/win-unpacked" ]; then - echo "Testing unpacked exe..." - # Run with timeout - app starts IPFS so won't exit on its own - timeout 15s ./dist/win-unpacked/seedit.exe & - APP_PID=$! - sleep 8 - # Check if process started successfully - if kill -0 $APP_PID 2>/dev/null; then - echo "✓ App started successfully" - taskkill //F //PID $APP_PID 2>/dev/null || true - # Kill any IPFS processes - taskkill //F //IM ipfs.exe 2>/dev/null || true - exit 0 - else - echo "✗ App failed to start" - exit 1 - fi - else - echo "No unpacked directory found" - ls -R dist - exit 1 + echo "Contents of dist/win-unpacked:" + ls -la dist/win-unpacked/ + + # Find any .exe file (case insensitive) + EXE=$(find dist/win-unpacked -maxdepth 1 -iname "*.exe" | head -n 1) + if [ -n "$EXE" ]; then + echo "Testing: $EXE" + timeout 15s "$EXE" & + APP_PID=$! + sleep 10 + if kill -0 $APP_PID 2>/dev/null; then + echo "✓ App started successfully" + taskkill //F //PID $APP_PID 2>/dev/null || true + taskkill //F //IM ipfs.exe 2>/dev/null || true + exit 0 + else + echo "✗ App failed to start or crashed" + exit 1 + fi + else + echo "✗ No .exe found in dist/win-unpacked/" + fi fi + + echo "✗ No runnable artifact found" + ls -R dist/ + exit 1 From 033372badc81d4f53147208dd5f4a1581ea65da9 Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 16:46:28 +0800 Subject: [PATCH 12/51] perf(CI): remove redundant yarn build from Electron build step --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4539988b..dfd018df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -145,7 +145,7 @@ jobs: - name: Build Electron App # Use --dir to build only the .app bundle without DMG # This avoids flaky hdiutil "Resource busy" errors in CI - run: yarn build && yarn build:preload && yarn electron-rebuild && yarn electron-builder build --publish never -m --dir + run: yarn build:preload && yarn electron-rebuild && yarn electron-builder build --publish never -m --dir - name: Smoke Test run: | From 1d6ab6f2212aed7c094159612ad2b84aef6e26f6 Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 16:49:30 +0800 Subject: [PATCH 13/51] feat(search-bar): add same community alert --- public/translations/ar/default.json | 3 ++- public/translations/bn/default.json | 3 ++- public/translations/cs/default.json | 3 ++- public/translations/da/default.json | 3 ++- public/translations/de/default.json | 3 ++- public/translations/el/default.json | 3 ++- public/translations/en/default.json | 3 ++- public/translations/es/default.json | 3 ++- public/translations/fa/default.json | 3 ++- public/translations/fi/default.json | 3 ++- public/translations/fil/default.json | 3 ++- public/translations/fr/default.json | 3 ++- public/translations/he/default.json | 3 ++- public/translations/hi/default.json | 3 ++- public/translations/hu/default.json | 3 ++- public/translations/id/default.json | 3 ++- public/translations/it/default.json | 3 ++- public/translations/ja/default.json | 3 ++- public/translations/ko/default.json | 3 ++- public/translations/mr/default.json | 3 ++- public/translations/nl/default.json | 3 ++- public/translations/no/default.json | 3 ++- public/translations/pl/default.json | 3 ++- public/translations/pt/default.json | 3 ++- public/translations/ro/default.json | 3 ++- public/translations/ru/default.json | 3 ++- public/translations/sq/default.json | 3 ++- public/translations/sv/default.json | 3 ++- public/translations/te/default.json | 3 ++- public/translations/th/default.json | 3 ++- public/translations/tr/default.json | 3 ++- public/translations/uk/default.json | 3 ++- public/translations/ur/default.json | 3 ++- public/translations/vi/default.json | 3 ++- public/translations/zh/default.json | 3 ++- src/components/search-bar/search-bar.tsx | 10 +++++++++- 36 files changed, 79 insertions(+), 36 deletions(-) diff --git a/public/translations/ar/default.json b/public/translations/ar/default.json index b34a73ee..265329df 100644 --- a/public/translations/ar/default.json +++ b/public/translations/ar/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "أنت تشاهد هذه المجموعة بالفعل" } diff --git a/public/translations/bn/default.json b/public/translations/bn/default.json index a1ff8415..dd1a2c7a 100644 --- a/public/translations/bn/default.json +++ b/public/translations/bn/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "আপনি ইতিমধ্যে এই সম্প্রদায়টি দেখছেন" } diff --git a/public/translations/cs/default.json b/public/translations/cs/default.json index 3e34524a..30532682 100644 --- a/public/translations/cs/default.json +++ b/public/translations/cs/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Tuto komunitu již prohlížíte" } diff --git a/public/translations/da/default.json b/public/translations/da/default.json index 97e75f76..69d3ca52 100644 --- a/public/translations/da/default.json +++ b/public/translations/da/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Du ser allerede denne fællesskab" } diff --git a/public/translations/de/default.json b/public/translations/de/default.json index 68a17058..3b50656d 100644 --- a/public/translations/de/default.json +++ b/public/translations/de/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Sie sehen diese Community bereits" } diff --git a/public/translations/el/default.json b/public/translations/el/default.json index 31e9c210..2cab893d 100644 --- a/public/translations/el/default.json +++ b/public/translations/el/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Εμφανίζετε ήδη αυτήν την κοινότητα" } diff --git a/public/translations/en/default.json b/public/translations/en/default.json index 5e672e11..45894edb 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -386,5 +386,6 @@ "tags": "Tags", "moderator_of": "moderator of", "not_subscriber_nor_moderator": "You are not a subscriber nor a moderator of any community.", - "more_posts_last_year": "{{count}} posts last {{currentTimeFilterName}}: <1>show more posts from last year" + "more_posts_last_year": "{{count}} posts last {{currentTimeFilterName}}: <1>show more posts from last year", + "already_in_community": "You're already viewing this community" } diff --git a/public/translations/es/default.json b/public/translations/es/default.json index abc886e2..26139c38 100644 --- a/public/translations/es/default.json +++ b/public/translations/es/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Ya estás viendo esta comunidad" } diff --git a/public/translations/fa/default.json b/public/translations/fa/default.json index 99034533..407dbf3a 100644 --- a/public/translations/fa/default.json +++ b/public/translations/fa/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "شما در حال مشاهده این انجمن هستید" } diff --git a/public/translations/fi/default.json b/public/translations/fi/default.json index 5faaa6d1..4fc565fb 100644 --- a/public/translations/fi/default.json +++ b/public/translations/fi/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Katsot jo tätä yhteisöä" } diff --git a/public/translations/fil/default.json b/public/translations/fil/default.json index 44df2927..a6d980af 100644 --- a/public/translations/fil/default.json +++ b/public/translations/fil/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Nakikita mo na ang komunidad na ito" } diff --git a/public/translations/fr/default.json b/public/translations/fr/default.json index 44a4c7aa..08364a48 100644 --- a/public/translations/fr/default.json +++ b/public/translations/fr/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Vous consultez déjà cette communauté" } diff --git a/public/translations/he/default.json b/public/translations/he/default.json index e47ce267..09fcaba2 100644 --- a/public/translations/he/default.json +++ b/public/translations/he/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "אתה כבר צופה בקהילה זו" } diff --git a/public/translations/hi/default.json b/public/translations/hi/default.json index 04c1f218..d78921cc 100644 --- a/public/translations/hi/default.json +++ b/public/translations/hi/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "आप पहले से ही इस समुदाय को देख रहे हैं" } diff --git a/public/translations/hu/default.json b/public/translations/hu/default.json index 05e6d25d..25719693 100644 --- a/public/translations/hu/default.json +++ b/public/translations/hu/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Már ezt a közösséget nézed" } diff --git a/public/translations/id/default.json b/public/translations/id/default.json index 2a8d246e..35f8a54b 100644 --- a/public/translations/id/default.json +++ b/public/translations/id/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Anda sudah melihat komunitas ini" } diff --git a/public/translations/it/default.json b/public/translations/it/default.json index a30a76fd..86b3676f 100644 --- a/public/translations/it/default.json +++ b/public/translations/it/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Stai già visualizzando questa comunità" } diff --git a/public/translations/ja/default.json b/public/translations/ja/default.json index 3736fa62..5a7e72ec 100644 --- a/public/translations/ja/default.json +++ b/public/translations/ja/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "このコミュニティは既に表示中です" } diff --git a/public/translations/ko/default.json b/public/translations/ko/default.json index 4656fa8f..4b7fb52d 100644 --- a/public/translations/ko/default.json +++ b/public/translations/ko/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "이미 이 커뮤니티를 보고 있습니다" } diff --git a/public/translations/mr/default.json b/public/translations/mr/default.json index 2316e997..0793407b 100644 --- a/public/translations/mr/default.json +++ b/public/translations/mr/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "तुम्ही आधीच हे समुदाय पाहत आहात" } diff --git a/public/translations/nl/default.json b/public/translations/nl/default.json index c77f9d48..b26330fe 100644 --- a/public/translations/nl/default.json +++ b/public/translations/nl/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Je bekijkt deze community al" } diff --git a/public/translations/no/default.json b/public/translations/no/default.json index 506c831d..310b0eac 100644 --- a/public/translations/no/default.json +++ b/public/translations/no/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Du ser allerede på dette fellesskapet" } diff --git a/public/translations/pl/default.json b/public/translations/pl/default.json index 7a9dd0ed..1b5a1151 100644 --- a/public/translations/pl/default.json +++ b/public/translations/pl/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Już przeglądasz tę społeczność" } diff --git a/public/translations/pt/default.json b/public/translations/pt/default.json index ea68ccaa..f7b59c36 100644 --- a/public/translations/pt/default.json +++ b/public/translations/pt/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Você já está visualizando esta comunidade" } diff --git a/public/translations/ro/default.json b/public/translations/ro/default.json index de898a80..6957f59a 100644 --- a/public/translations/ro/default.json +++ b/public/translations/ro/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Vizualizezi deja această comunitate" } diff --git a/public/translations/ru/default.json b/public/translations/ru/default.json index be28cec4..f83d56f8 100644 --- a/public/translations/ru/default.json +++ b/public/translations/ru/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Вы уже просматриваете это сообщество" } diff --git a/public/translations/sq/default.json b/public/translations/sq/default.json index 579ff87c..7bb9400e 100644 --- a/public/translations/sq/default.json +++ b/public/translations/sq/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Ju po shikoni tashmë këtë komunitet" } diff --git a/public/translations/sv/default.json b/public/translations/sv/default.json index edfd221d..78ff3825 100644 --- a/public/translations/sv/default.json +++ b/public/translations/sv/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Du visar redan denna gemenskap" } diff --git a/public/translations/te/default.json b/public/translations/te/default.json index 28c63bd0..f6a1162d 100644 --- a/public/translations/te/default.json +++ b/public/translations/te/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "మీరు ఇప్పటికే ఈ కమ్యూనిటీని చూస్తున్నారు" } diff --git a/public/translations/th/default.json b/public/translations/th/default.json index 56a733ac..6d635b48 100644 --- a/public/translations/th/default.json +++ b/public/translations/th/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "คุณกำลังดูชุมชนนี้อยู่แล้ว" } diff --git a/public/translations/tr/default.json b/public/translations/tr/default.json index cf410bed..571e5261 100644 --- a/public/translations/tr/default.json +++ b/public/translations/tr/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Bu topluluğu zaten görüntülüyorsunuz" } diff --git a/public/translations/uk/default.json b/public/translations/uk/default.json index 264fc064..1a85de53 100644 --- a/public/translations/uk/default.json +++ b/public/translations/uk/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Ви вже переглядаєте це співтовариство" } diff --git a/public/translations/ur/default.json b/public/translations/ur/default.json index af8f212e..4f985a64 100644 --- a/public/translations/ur/default.json +++ b/public/translations/ur/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "آپ پہلے سے ہی اس کمیونٹی کو دیکھ رہے ہیں" } diff --git a/public/translations/vi/default.json b/public/translations/vi/default.json index ae61c4ab..17c255f0 100644 --- a/public/translations/vi/default.json +++ b/public/translations/vi/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "Bạn đã đang xem cộng đồng này" } diff --git a/public/translations/zh/default.json b/public/translations/zh/default.json index 2ec69ccd..65398267 100644 --- a/public/translations/zh/default.json +++ b/public/translations/zh/default.json @@ -386,5 +386,6 @@ "no_global_rules": "...no global rules.", "for_reddits_downfall": "...for Reddit's downfall.", "evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.", - "no_gods_no_global_admins": "...no gods, no global admins." + "no_gods_no_global_admins": "...no gods, no global admins.", + "already_in_community": "您已经在查看此社区" } diff --git a/src/components/search-bar/search-bar.tsx b/src/components/search-bar/search-bar.tsx index 7ac6f79d..90607113 100644 --- a/src/components/search-bar/search-bar.tsx +++ b/src/components/search-bar/search-bar.tsx @@ -141,6 +141,10 @@ const SearchBar = ({ isFocused = false, onExpandoChange }: SearchBarProps) => { } const searchInput = searchInputRef.current?.value; if (searchInput) { + if (searchInput.toLowerCase() === params.subplebbitAddress?.toLowerCase()) { + alert(t('already_in_community')); + return; + } setInputValue(''); navigate(`/s/${searchInput}`); } @@ -179,6 +183,10 @@ const SearchBar = ({ isFocused = false, onExpandoChange }: SearchBarProps) => { const handleCommunitySelect = useCallback( (address: string) => { + if (address.toLowerCase() === params.subplebbitAddress?.toLowerCase()) { + alert(t('already_in_community')); + return; + } setInputValue(''); setIsInputFocused(false); setActiveDropdownIndex(-1); @@ -186,7 +194,7 @@ const SearchBar = ({ isFocused = false, onExpandoChange }: SearchBarProps) => { searchInputRef.current?.blur(); navigate(`/s/${address}`); }, - [navigate, setInputValue, setIsInputFocused, setActiveDropdownIndex], + [navigate, setInputValue, setIsInputFocused, setActiveDropdownIndex, params.subplebbitAddress, t], ); const handleKeyDown = useCallback( From 5e1c40cc723e585f66f70ad9aa8c419f3a5efd13 Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 17:07:19 +0800 Subject: [PATCH 14/51] fix(ci): add explicit executableName to electron-builder config --- .github/workflows/test.yml | 235 +++++++++++++------------------------ package.json | 9 +- 2 files changed, 86 insertions(+), 158 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dfd018df..96cecc95 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,8 +25,8 @@ jobs: uses: actions/cache@v4 with: path: ~/.cache/electron - key: ${{ runner.os }}-electron-${{ hashFiles('**/yarn.lock') }} - restore-keys: ${{ runner.os }}-electron- + key: ${{ runner.os }}-electron-v2-${{ hashFiles('**/yarn.lock') }} + restore-keys: ${{ runner.os }}-electron-v2- - name: Install dependencies run: | @@ -50,54 +50,24 @@ jobs: - name: Smoke Test run: | - echo "Listing dist directory..." - ls -la dist/ - - # Try unpacked directory first (more reliable for testing) - if [ -d "dist/linux-unpacked" ]; then - echo "Testing unpacked executable..." - EXE=$(find dist/linux-unpacked -maxdepth 1 -type f -executable | head -n 1) - if [ -n "$EXE" ]; then - echo "Found executable: $EXE" - "$EXE" --no-sandbox & - APP_PID=$! - sleep 10 - if kill -0 $APP_PID 2>/dev/null; then - echo "✓ App started successfully" - kill $APP_PID 2>/dev/null || true - pkill -P $APP_PID 2>/dev/null || true - pkill -f ipfs 2>/dev/null || true - exit 0 - else - echo "✗ App failed to start or crashed" - exit 1 - fi - fi - fi - - # Fallback to AppImage + echo "Testing AppImage startup..." APPIMAGE=$(find dist -name "*.AppImage" | head -n 1) - if [ -n "$APPIMAGE" ]; then - echo "Found AppImage: $APPIMAGE" - chmod +x "$APPIMAGE" - "$APPIMAGE" --appimage-extract-and-run --no-sandbox & - APP_PID=$! - sleep 10 - if kill -0 $APP_PID 2>/dev/null; then - echo "✓ App started successfully" - kill $APP_PID 2>/dev/null || true - pkill -P $APP_PID 2>/dev/null || true - pkill -f ipfs 2>/dev/null || true - exit 0 - else - echo "✗ App failed to start or crashed" - exit 1 - fi + echo "Found AppImage: $APPIMAGE" + chmod +x "$APPIMAGE" + # Use --appimage-extract-and-run to avoid FUSE requirement in CI + # Run with timeout and expect it to start (will be killed after timeout) + timeout 10s "$APPIMAGE" --appimage-extract-and-run --no-sandbox & + APP_PID=$! + sleep 5 + # Check if process is still running (means it started successfully) + if kill -0 $APP_PID 2>/dev/null; then + echo "✓ App started successfully" + kill $APP_PID 2>/dev/null || true + exit 0 + else + echo "✗ App failed to start" + exit 1 fi - - echo "✗ No runnable artifact found" - ls -R dist/ - exit 1 test-mac-intel: name: Test Mac (Intel) @@ -122,8 +92,8 @@ jobs: uses: actions/cache@v4 with: path: ~/Library/Caches/electron - key: ${{ runner.os }}-electron-${{ hashFiles('**/yarn.lock') }} - restore-keys: ${{ runner.os }}-electron- + key: ${{ runner.os }}-electron-v2-${{ hashFiles('**/yarn.lock') }} + restore-keys: ${{ runner.os }}-electron-v2- - name: Install dependencies run: | @@ -145,50 +115,32 @@ jobs: - name: Build Electron App # Use --dir to build only the .app bundle without DMG # This avoids flaky hdiutil "Resource busy" errors in CI - run: yarn build:preload && yarn electron-rebuild && yarn electron-builder build --publish never -m --dir + run: yarn build && yarn build:preload && yarn electron-builder build --publish never -m --dir - name: Smoke Test run: | - echo "Listing dist directory..." - ls -la dist/ - - # Find any .app bundle in dist/mac/ - APP_PATH=$(find dist/mac -maxdepth 1 -name "*.app" -type d 2>/dev/null | head -n 1) - if [ -z "$APP_PATH" ]; then - echo "✗ No .app bundle found in dist/mac/" - ls -R dist/ - exit 1 - fi - - echo "Testing $APP_PATH..." - # Find the executable inside the app bundle - APP_NAME=$(basename "$APP_PATH" .app) - EXE_PATH="$APP_PATH/Contents/MacOS/$APP_NAME" - - if [ ! -f "$EXE_PATH" ]; then - # Try to find any executable in MacOS folder - EXE_PATH=$(find "$APP_PATH/Contents/MacOS" -type f -perm +111 | head -n 1) - fi - - if [ -z "$EXE_PATH" ] || [ ! -f "$EXE_PATH" ]; then - echo "✗ Could not find executable in $APP_PATH" - ls -la "$APP_PATH/Contents/MacOS/" - exit 1 - fi - - echo "Running: $EXE_PATH" - "$EXE_PATH" & - APP_PID=$! - sleep 10 - - if kill -0 $APP_PID 2>/dev/null; then - echo "✓ App started successfully" - kill $APP_PID 2>/dev/null || true - pkill -P $APP_PID 2>/dev/null || true - pkill -f ipfs 2>/dev/null || true - exit 0 + if [ -d "dist/mac/seedit.app" ]; then + echo "Testing dist/mac/seedit.app..." + # Run the app in background - it will start IPFS which takes time + # We just verify it launches without crashing + ./dist/mac/seedit.app/Contents/MacOS/seedit & + APP_PID=$! + sleep 10 + # Check if process is still running (means it started successfully) + if kill -0 $APP_PID 2>/dev/null; then + echo "✓ App started successfully" + kill $APP_PID 2>/dev/null || true + # Also kill any child processes (IPFS) + pkill -P $APP_PID 2>/dev/null || true + pkill -f ipfs 2>/dev/null || true + exit 0 + else + echo "✗ App failed to start or crashed" + exit 1 + fi else - echo "✗ App failed to start or crashed" + echo "Could not find dist/mac/seedit.app to test" + ls -R dist exit 1 fi @@ -215,8 +167,8 @@ jobs: uses: actions/cache@v4 with: path: ~/Library/Caches/electron - key: ${{ runner.os }}-electron-${{ hashFiles('**/yarn.lock') }} - restore-keys: ${{ runner.os }}-electron- + key: ${{ runner.os }}-electron-v2-${{ hashFiles('**/yarn.lock') }} + restore-keys: ${{ runner.os }}-electron-v2- - name: Install dependencies run: | @@ -239,49 +191,31 @@ jobs: # On M1 runner, this should produce arm64 build # Use --dir to build only the .app bundle without DMG # This avoids flaky hdiutil "Resource busy" errors in CI - run: yarn build && yarn build:preload && yarn electron-rebuild && yarn electron-builder build --publish never -m --dir + run: yarn build && yarn build:preload && yarn electron-builder build --publish never -m --dir - name: Smoke Test run: | - echo "Listing dist directory..." - ls -la dist/ - - # Find any .app bundle in dist/mac-arm64/ or dist/mac/ - APP_PATH=$(find dist/mac-arm64 -maxdepth 1 -name "*.app" -type d 2>/dev/null | head -n 1) - if [ -z "$APP_PATH" ]; then - APP_PATH=$(find dist/mac -maxdepth 1 -name "*.app" -type d 2>/dev/null | head -n 1) - fi - - if [ -z "$APP_PATH" ]; then - echo "✗ No .app bundle found" - ls -R dist/ + if [ -d "dist/mac-arm64/seedit.app" ]; then + APP_PATH="dist/mac-arm64/seedit.app" + elif [ -d "dist/mac/seedit.app" ]; then + APP_PATH="dist/mac/seedit.app" + else + echo "Could not find seedit.app to test" + ls -R dist exit 1 fi echo "Testing $APP_PATH..." - # Find the executable inside the app bundle - APP_NAME=$(basename "$APP_PATH" .app) - EXE_PATH="$APP_PATH/Contents/MacOS/$APP_NAME" - - if [ ! -f "$EXE_PATH" ]; then - # Try to find any executable in MacOS folder - EXE_PATH=$(find "$APP_PATH/Contents/MacOS" -type f -perm +111 | head -n 1) - fi - - if [ -z "$EXE_PATH" ] || [ ! -f "$EXE_PATH" ]; then - echo "✗ Could not find executable in $APP_PATH" - ls -la "$APP_PATH/Contents/MacOS/" - exit 1 - fi - - echo "Running: $EXE_PATH" - "$EXE_PATH" & + # Run the app in background - it will start IPFS which takes time + # We just verify it launches without crashing + "./$APP_PATH/Contents/MacOS/seedit" & APP_PID=$! sleep 10 - + # Check if process is still running (means it started successfully) if kill -0 $APP_PID 2>/dev/null; then echo "✓ App started successfully" kill $APP_PID 2>/dev/null || true + # Also kill any child processes (IPFS) pkill -P $APP_PID 2>/dev/null || true pkill -f ipfs 2>/dev/null || true exit 0 @@ -308,8 +242,8 @@ jobs: uses: actions/cache@v4 with: path: ~/AppData/Local/electron/Cache - key: ${{ runner.os }}-electron-${{ hashFiles('**/yarn.lock') }} - restore-keys: ${{ runner.os }}-electron- + key: ${{ runner.os }}-electron-v2-${{ hashFiles('**/yarn.lock') }} + restore-keys: ${{ runner.os }}-electron-v2- - name: Install dependencies shell: bash @@ -336,35 +270,26 @@ jobs: - name: Smoke Test shell: bash run: | - echo "Listing dist directory..." - ls -la dist/ - - # Try to find the unpacked executable + # Try to find the unpacked executable first as it's easiest to run if [ -d "dist/win-unpacked" ]; then - echo "Contents of dist/win-unpacked:" - ls -la dist/win-unpacked/ - - # Find any .exe file (case insensitive) - EXE=$(find dist/win-unpacked -maxdepth 1 -iname "*.exe" | head -n 1) - if [ -n "$EXE" ]; then - echo "Testing: $EXE" - timeout 15s "$EXE" & - APP_PID=$! - sleep 10 - if kill -0 $APP_PID 2>/dev/null; then - echo "✓ App started successfully" - taskkill //F //PID $APP_PID 2>/dev/null || true - taskkill //F //IM ipfs.exe 2>/dev/null || true - exit 0 - else - echo "✗ App failed to start or crashed" - exit 1 - fi - else - echo "✗ No .exe found in dist/win-unpacked/" - fi + echo "Testing unpacked exe..." + # Run with timeout - app starts IPFS so won't exit on its own + timeout 15s ./dist/win-unpacked/seedit.exe & + APP_PID=$! + sleep 8 + # Check if process started successfully + if kill -0 $APP_PID 2>/dev/null; then + echo "✓ App started successfully" + taskkill //F //PID $APP_PID 2>/dev/null || true + # Kill any IPFS processes + taskkill //F //IM ipfs.exe 2>/dev/null || true + exit 0 + else + echo "✗ App failed to start" + exit 1 + fi + else + echo "No unpacked directory found" + ls -R dist + exit 1 fi - - echo "✗ No runnable artifact found" - ls -R dist/ - exit 1 diff --git a/package.json b/package.json index 7b637d44..7ef98327 100755 --- a/package.json +++ b/package.json @@ -175,17 +175,20 @@ "target": "dmg", "category": "public.app-category.social-networking", "type": "distribution", - "identity": null + "identity": null, + "executableName": "seedit" }, "win": { "target": [ "portable", "nsis" - ] + ], + "executableName": "seedit" }, "linux": { "target": "AppImage", - "category": "Network" + "category": "Network", + "executableName": "seedit" } }, "lint-staged": { From 7fc87583416457b985b0e05f3c34afc7b8356d22 Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 17:12:40 +0800 Subject: [PATCH 15/51] style(post): update share link domain to seedit.app --- src/components/sidebar/sidebar.tsx | 2 +- src/lib/utils/url-utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/sidebar/sidebar.tsx b/src/components/sidebar/sidebar.tsx index 68230f1c..385c6967 100644 --- a/src/components/sidebar/sidebar.tsx +++ b/src/components/sidebar/sidebar.tsx @@ -85,7 +85,7 @@ const PostInfo = ({ comment }: { comment: Comment | undefined }) => { {`(${postScore === '?' ? '?' : `${upvotePercentage}`}% ${t('upvoted')})`}
- {t('share_link')}: + {t('share_link')}:
); diff --git a/src/lib/utils/url-utils.ts b/src/lib/utils/url-utils.ts index a0bf9da0..9b9a68a4 100644 --- a/src/lib/utils/url-utils.ts +++ b/src/lib/utils/url-utils.ts @@ -18,7 +18,7 @@ export const isValidURL = (url: string) => { }; export const copyShareLinkToClipboard = async (subplebbitAddress: string, cid: string) => { - const shareLink = `https://pleb.bz/s/${subplebbitAddress}/c/${cid}`; + const shareLink = `https://seedit.app/s/${subplebbitAddress}/c/${cid}`; await copyToClipboard(shareLink); }; From b8f9df8dc980d930ac704e7d1896c76000b2bf9b Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 17:51:48 +0800 Subject: [PATCH 16/51] Update package.json --- package.json | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 7ef98327..3d77c5b6 100755 --- a/package.json +++ b/package.json @@ -174,21 +174,17 @@ "mac": { "target": "dmg", "category": "public.app-category.social-networking", - "type": "distribution", - "identity": null, - "executableName": "seedit" + "type": "distribution" }, "win": { "target": [ "portable", "nsis" - ], - "executableName": "seedit" + ] }, "linux": { "target": "AppImage", - "category": "Network", - "executableName": "seedit" + "category": "Network" } }, "lint-staged": { From f5daffd1145e1b10c23989aad312b5bb794dc197 Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 27 Jan 2026 17:58:50 +0800 Subject: [PATCH 17/51] delete docs not needed, using AGENTS.md instead --- docs/react-guide.md | 813 ------------ docs/you-might-not-need-an-effect.md | 1745 -------------------------- 2 files changed, 2558 deletions(-) delete mode 100644 docs/react-guide.md delete mode 100644 docs/you-might-not-need-an-effect.md diff --git a/docs/react-guide.md b/docs/react-guide.md deleted file mode 100644 index 957de419..00000000 --- a/docs/react-guide.md +++ /dev/null @@ -1,813 +0,0 @@ -# React guide - -A lot of AI-generated React code is heavily biased toward using useState and useEffect, often in verbose or naive ways. That’s because these hooks are: - -1. Explicit, making them easy for LLMs to pattern-match. -2. Safe defaults, requiring less architectural decision-making. -3. Well-documented, so the model’s training data is oversaturated with examples. - -But modern, well-architected React apps rarely rely on raw useState/useEffect beyond simple UI state or lifecycle quirks. Here’s a breakdown of what serious devs use instead—and why this is the real moat for human engineers. - -Alternatives to useState and useEffect - -1. State Machines / Statecharts (e.g. Zustand) -- Why: Replaces ad-hoc local state management with deterministic, declarative logic. -- Advantage: Less bugs, easier to test, clearer state transitions. -- AI gap: LLMs struggle to model state transitions declaratively. LLM Agent must overcome this. - -2. React Query / SWR for Data Fetching -- Why: Avoids useEffect-based data fetching and loading/error state juggling. -- Advantage: Caching, stale-while-revalidate, retries, pagination—all handled out of the box. -- AI gap: AI often uses useEffect + fetch and forgets cleanup, race conditions, or caching. - -3. Global State with Zustand, Jotai, Recoil, or Redux Toolkit -- Why: Local useState doesn’t scale for shared or persistent state. -- Advantage: Better performance, devtools support, persistence. -- AI gap: Defaults to prop-drilling or bloated context APIs. - -4. Custom Hooks and Composition -- Why: Reuse logic cleanly without bloating components or copy-pasting useEffect. -- Advantage: Separation of concerns, encapsulation. -- AI gap: Often fails to factor logic out, keeping everything in one component. - -## Why This Matters - -AI can mimic patterns, but it can’t: -- Architect a system for long-term maintainability. -- Predict runtime performance tradeoffs. -- Refactor spaghetti effects into custom hooks or state machines. -- Decide when state should be colocated vs globalized. -- Avoid footguns like stale closures, unnecessary re-renders, or race conditions. - -In short: AI is great at writing code, but bad at engineering software. - -Takeaway -- Use fewer useEffect and useState hooks. -- Learn to design systems, not just code components. -- Reach for declarative, composable patterns. -- Understand caching, reactivity, and scalability beyond local state. - -Here’s a side-by-side breakdown of common AI-generated React patterns (bad) vs what experienced engineers write (good) across 4 key areas: state management, data fetching, side effects, and logic reuse. - -## 1. State Management - -❌ Bad (AI-style useState) - -```typescript -function TodoApp() { - const [todos, setTodos] = useState([]); - const [input, setInput] = useState(''); - - const addTodo = () => { - setTodos([...todos, { text: input }]); - setInput(''); - }; - - return ( - <> - setInput(e.target.value)} /> - -
    - {todos.map((todo, i) =>
  • {todo.text}
  • )} -
- - ); -} -``` - -✅ Good (Zustand store, state extracted) - -```typescript -import { create } from 'zustand'; - -const useTodoStore = create((set) => ({ - todos: [], - input: '', - addTodo: () => set((state) => ({ - todos: [...state.todos, { text: state.input }], - input: '', - })), - setInput: (val) => set({ input: val }), -})); - -function TodoApp() { - const { todos, input, setInput, addTodo } = useTodoStore(); - - return ( - <> - setInput(e.target.value)} /> - -
    - {todos.map((todo, i) =>
  • {todo.text}
  • )} -
- - ); -} - -``` - -## 2. Data Fetching - -❌ Bad (AI-style useEffect) - -```typescript -function UserList() { - const [users, setUsers] = useState([]); - - useEffect(() => { - fetch('/api/users') - .then(res => res.json()) - .then(setUsers); - }, []); - - return ( -
    - {users.map(u =>
  • {u.name}
  • )} -
- ); -} -``` - -✅ Good (React Query) - -```typescript -import { useQuery } from '@tanstack/react-query'; - -function UserList() { - const { data: users = [], isLoading } = useQuery({ - queryKey: ['users'], - queryFn: () => fetch('/api/users').then(res => res.json()), - }); - - if (isLoading) return
Loading...
; - - return ( -
    - {users.map(u =>
  • {u.name}
  • )} -
- ); -} -``` - -## 3. Side Effects - -❌ Bad (naive effect with race condition) - -```typescript -function Search({ term }) { - const [results, setResults] = useState([]); - - useEffect(() => { - fetch(`/api/search?q=${term}`) - .then(res => res.json()) - .then(setResults); - }, [term]); - - return
{results.length} results
; -} -``` - -✅ Good (abort previous fetch with AbortController) - -```typescript -function Search({ term }) { - const [results, setResults] = useState([]); - - useEffect(() => { - const controller = new AbortController(); - - fetch(`/api/search?q=${term}`, { signal: controller.signal }) - .then(res => res.json()) - .then(setResults) - .catch((e) => { - if (e.name !== 'AbortError') console.error(e); - }); - - return () => controller.abort(); - }, [term]); - - return
{results.length} results
; -} -``` - -Better yet? Don’t use useEffect at all. Use React Query with term as a key. - -## 4. Logic Reuse - -❌ Bad (repeated logic inline) - -```typescript -function ComponentA() { - const [count, setCount] = useState(0); - useEffect(() => { - const id = setInterval(() => setCount(c => c + 1), 1000); - return () => clearInterval(id); - }, []); - return
{count}
; -} - -function ComponentB() { - const [count, setCount] = useState(0); - useEffect(() => { - const id = setInterval(() => setCount(c => c + 1), 1000); - return () => clearInterval(id); - }, []); - return
{count}
; -} -``` - -✅ Good (custom hook) - -```typescript -function useCounter(intervalMs = 1000) { - const [count, setCount] = useState(0); - useEffect(() => { - const id = setInterval(() => setCount(c => c + 1), intervalMs); - return () => clearInterval(id); - }, [intervalMs]); - return count; -} - -function ComponentA() { - const count = useCounter(); - return
{count}
; -} - -function ComponentB() { - const count = useCounter(); - return
{count}
; -} - -``` - -## 5. State Machines with Zustand - -❌ Bad (AI-style complex useState with boolean flags) - -```typescript -function OrderProcess() { - const [isLoading, setIsLoading] = useState(false); - const [isError, setIsError] = useState(false); - const [isSuccess, setIsSuccess] = useState(false); - const [cart, setCart] = useState([]); - const [paymentDetails, setPaymentDetails] = useState(null); - const [orderPlaced, setOrderPlaced] = useState(false); - - const addToCart = (item) => { - setCart([...cart, item]); - }; - - const submitPayment = async (details) => { - setIsLoading(true); - setIsError(false); - - try { - await processPayment(details); - setPaymentDetails(details); - setIsSuccess(true); - setIsLoading(false); - } catch (error) { - setIsError(true); - setIsLoading(false); - } - }; - - const placeOrder = async () => { - setIsLoading(true); - setIsError(false); - - try { - await submitOrder(cart, paymentDetails); - setOrderPlaced(true); - setIsSuccess(true); - setIsLoading(false); - } catch (error) { - setIsError(true); - setIsLoading(false); - } - }; - - return ( -
- {/* Complex conditional rendering based on multiple state variables */} - {isLoading && } - {isError && } - {!isLoading && !isError && !orderPlaced && ( - <> - - - {paymentDetails && } - - )} - {orderPlaced && } -
- ); -} -``` - -✅ Good (Zustand state machine) - -```typescript -import { create } from 'zustand'; - -// Create a Zustand store with state machine pattern -const useOrderStore = create((set, get) => ({ - // State machine's current state - state: 'browsing', - - // Context/data - cart: [], - paymentDetails: null, - error: null, - - // Actions that transition between states - addToCart: (item) => set((state) => ({ - cart: [...state.cart, item] - })), - - goToCheckout: () => set({ state: 'paymentEntry' }), - - goBack: () => { - const { state: currentState } = get(); - if (currentState === 'paymentEntry') { - set({ state: 'browsing' }); - } else if (currentState === 'confirmOrder') { - set({ state: 'paymentEntry' }); - } - }, - - submitPayment: async (details) => { - set({ state: 'processingPayment', error: null }); - - try { - const result = await processPayment(details); - set({ - state: 'confirmOrder', - paymentDetails: result - }); - } catch (error) { - set({ - state: 'paymentEntry', - error: error.message - }); - } - }, - - placeOrder: async () => { - const { cart, paymentDetails } = get(); - set({ state: 'processingOrder', error: null }); - - try { - await submitOrder(cart, paymentDetails); - set({ state: 'orderComplete' }); - } catch (error) { - set({ - state: 'confirmOrder', - error: error.message - }); - } - } -})); - -function OrderProcess() { - const { - state, - cart, - paymentDetails, - error, - addToCart, - goToCheckout, - goBack, - submitPayment, - placeOrder - } = useOrderStore(); - - // Render UI based on current state - return ( -
- {error && } - - {state === 'browsing' && ( - - )} - - {state === 'paymentEntry' && ( - - )} - - {state === 'processingPayment' && ( - - )} - - {state === 'confirmOrder' && ( - - )} - - {state === 'processingOrder' && ( - - )} - - {state === 'orderComplete' && ( - - )} -
- ); -} -``` - -### Key Benefits of the Zustand State Machine Approach: - -1. **Explicit states**: The system can only be in one well-defined state at a time -2. **Centralized logic**: All state transitions are defined in one place -3. **Predictable transitions**: State changes follow clear patterns -4. **Simplified UI logic**: Components just render based on the current state -5. **Async handling**: Asynchronous operations are contained within the state transitions -6. **Shared state**: Any component can access the store without prop drilling -7. **Improved debugging**: The current state is always clear and predictable -8. **No impossible states**: Unlike boolean flags that could create invalid combinations -9. **Simpler API**: Compared to XState, Zustand offers a more approachable API for many developers -10. **Smaller bundle size**: Zustand is significantly smaller than XState - -This approach gives most of the benefits of formal state machines while maintaining the simplicity and familiarity of React state management. It's a pragmatic middle ground that's often easier to adopt in existing projects. - -## 6. Concurrent UI Techniques - -❌ Bad (Blocking UI updates without concurrency): -```jsx -// Bad example: Synchronously fetching data in useEffect can cause UI jank. -function SearchResults({ query }) { - const [results, setResults] = React.useState([]); - - React.useEffect(() => { - fetch(`/api/search?q=${query}`) - .then(res => res.json()) - .then(data => setResults(data)); - }, [query]); - - return ( -
- {results.map(item =>

{item.title}

)} -
- ); -} -``` - -✅ Good (Using useTransition for non-blocking updates): -```jsx -// Good example: Leveraging useTransition to mark state updates as low-priority. -function SearchResults({ query }) { - const [results, setResults] = React.useState([]); - const [isPending, startTransition] = React.useTransition(); - - React.useEffect(() => { - startTransition(() => { - fetch(`/api/search?q=${query}`) - .then(res => res.json()) - .then(data => setResults(data)); - }); - }, [query, startTransition]); - - return ( -
- {isPending &&
Loading...
} - {results.map(item =>

{item.title}

)} -
- ); -} -``` - -## 7. Routing and Navigation (Not applicable with Next.js apps) - -❌ Bad (Manual routing without a dedicated library): -```jsx -// Bad example: Handling routing manually using state and window history. -function App() { - const [route, setRoute] = React.useState(window.location.pathname); - - React.useEffect(() => { - const onPopState = () => setRoute(window.location.pathname); - window.addEventListener('popstate', onPopState); - return () => window.removeEventListener('popstate', onPopState); - }, []); - - return ( -
- {route === '/' && } - {route === '/about' && } - -
- ); -} -``` - -✅ Good (Using React Router v6 with lazy-loaded routes): -```jsx -// Good example: Utilizing React Router to manage routes and lazy load components. -import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'; -import React, { lazy, Suspense } from 'react'; - -const Home = lazy(() => import('./Home')); -const About = lazy(() => import('./About')); - -function App() { - return ( - - - Loading...}> - - } /> - } /> - - - - ); -} -``` - -## 8. Error Handling - -❌ Bad (No error boundary; crashes on errors): -```jsx -// Bad example: A component that throws errors without an error boundary. -function MyComponent({ data }) { - if (!data) { - throw new Error("Data not found"); - } - return
{data}
; -} - -function App() { - return ; -} -``` - -✅ Good (Wrapping components with an Error Boundary): -```jsx -// Good example: Using an ErrorBoundary component to catch and handle errors gracefully. -import React from 'react'; - -class ErrorBoundary extends React.Component { - constructor(props) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError(error) { - return { hasError: true }; - } - - componentDidCatch(error, errorInfo) { - console.error("Error caught in boundary:", error, errorInfo); - } - - render() { - if (this.state.hasError) { - return

Something went wrong.

; - } - return this.props.children; - } -} - -function MyComponent({ data }) { - if (!data) { - throw new Error("Data not found"); - } - return
{data}
; -} - -function App() { - return ( - - - - ); -} -``` - -## 9. Form Management - -❌ Bad (Manual state handling with no validation): -```jsx -// Bad example: Uncontrolled form without validation; error handling is omitted. -function ContactForm() { - const [formData, setFormData] = React.useState({ name: '', email: '' }); - - const handleSubmit = (e) => { - e.preventDefault(); - console.log(formData); - }; - - return ( -
- setFormData({ ...formData, name: e.target.value })} - placeholder="Name" - /> - setFormData({ ...formData, email: e.target.value })} - placeholder="Email" - /> - -
- ); -} -``` - -✅ Good (Using React Hook Form with Zod for schema validation): - -```jsx -import React from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; - -// 1. Define your schema with Zod -const contactSchema = z.object({ - name: z.string().nonempty('Name is required'), - email: z - .string() - .nonempty('Email is required') - .email('Invalid email'), -}); - -// 2. Infer the TypeScript type of your form data -type ContactFormData = z.infer; - -export function ContactForm() { - // 3. Hook up RHF with the zodResolver - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(contactSchema), - }); - - const onSubmit = (data: ContactFormData) => { - console.log(data); - }; - - return ( -
-
- - {errors.name &&

{errors.name.message}

} -
- -
- - {errors.email &&

{errors.email.message}

} -
- - -
- ); -} -``` - -## 10. Code Splitting and Lazy Loading - -❌ Bad (Eagerly importing heavy components, increasing the initial bundle size): -```jsx -// Bad example: Directly importing a large component causing a heavier bundle. -import HeavyComponent from './HeavyComponent'; - -function App() { - return ( -
- -
- ); -} -``` - -✅ Good (Lazy loading heavy components with React.lazy and Suspense): -```jsx -// Good example: Dynamically importing components to reduce initial load time. -import React, { lazy, Suspense } from 'react'; - -const HeavyComponent = lazy(() => import('./HeavyComponent')); - -function App() { - return ( - Loading Component...}> - - - ); -} -``` - -## 11. Testing Strategies - -❌ Bad (Testing implementation details, resulting in brittle tests): -```jsx -// Bad example: Relying on internal DOM structure which may change. -import { render } from '@testing-library/react'; -import MyComponent from './MyComponent'; - -test('MyComponent renders data', () => { - const { container } = render(); - expect(container.querySelector('.data')).toBeDefined(); -}); -``` - -✅ Good (Focusing on user-centric behaviors using React Testing Library): -```jsx -// Good example: Testing component behavior by simulating user interactions. -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import MyComponent from './MyComponent'; - -test('MyComponent displays loaded data after user action', async () => { - render(); - - // Check for an initial loading state - expect(screen.getByText(/Loading/i)).toBeInTheDocument(); - - // Simulate user action that triggers data loading - const button = screen.getByRole('button', { name: /load data/i }); - userEvent.click(button); - - // Wait and assert that data is displayed - expect(await screen.findByText(/Data loaded/i)).toBeInTheDocument(); -}); -``` - -## 12. Accessibility & Theming - -❌ Bad (Non-semantic elements and missing ARIA labels): -```jsx -// Bad example: Using a div as a clickable element without semantics or accessibility. -function ThemedButton() { - return
console.log('Clicked!')}>Click me
; -} -``` - -✅ Good (Semantic button with ARIA attributes and theming via CSS variables): -```jsx -// Good example: Accessible button component that utilizes theming. -function ThemedButton() { - return ( - - ); -} - -/* CSS (in a separate file or styled-components): -:root { - --primary-color: #007bff; -} -*/ -``` - -# Summary Table - -| Concern | Bad AI Pattern | Solid Engineering Practice | -|---------|---------------|----------------------------| -| **State Management** | `useState` everywhere, prop‑drilling | Central store (Zustand / Redux Toolkit) or colocated custom hooks | -| **Data Fetching** | `useEffect` + `fetch`, manual loading/error juggling | React Query / SWR with caching, retries, stale‑while‑revalidate | -| **Side Effects** | Effects without cleanup → race conditions, leaks | AbortController or query library handles cancellation & retries | -| **Logic Reuse** | Copy‑pasted hooks and state in every component | Extract cross‑cutting logic into custom hooks | -| **Workflow / State Machine** | Boolean‑flag soup, impossible states | Deterministic statechart in a single store (Zustand / XState) | -| **Concurrent UI Updates** | Blocking synchronous work inside effects | `useTransition` / `startTransition` for low‑priority updates | -| **Routing & Navigation** | DIY history manipulation, inline route state | React Router v6 (or Next.js built‑ins) with lazy‑loaded routes | -| **Error Handling** | No error boundary → full‑app crashes | Top‑level `ErrorBoundary` plus logging in `componentDidCatch` | -| **Form Management** | Manual state, zero validation or feedback | React Hook Form + Yup/Zod schema for declarative validation | -| **Code Splitting & Lazy Loading** | Eager imports ballooning bundle size | `React.lazy` + `Suspense` for on‑demand loading | -| **Testing Strategy** | Brittle tests keyed to DOM structure | User‑centric tests with React Testing Library & jest‑dom | -| **Accessibility & Theming** | Non‑semantic elements, missing ARIA, inline colors | Semantic HTML, ARIA labels, CSS variables / theme provider | \ No newline at end of file diff --git a/docs/you-might-not-need-an-effect.md b/docs/you-might-not-need-an-effect.md deleted file mode 100644 index fa91579f..00000000 --- a/docs/you-might-not-need-an-effect.md +++ /dev/null @@ -1,1745 +0,0 @@ ---- -title: 'You Might Not Need an Effect' -source: https://react.dev/learn/you-might-not-need-an-effect ---- - - - -Effects are an escape hatch from the React paradigm. They let you "step outside" of React and synchronize your components with some external system like a non-React widget, network, or the browser DOM. If there is no external system involved (for example, if you want to update a component's state when some props or state change), you shouldn't need an Effect. Removing unnecessary Effects will make your code easier to follow, faster to run, and less error-prone. - - - - - -* Why and how to remove unnecessary Effects from your components -* How to cache expensive computations without Effects -* How to reset and adjust component state without Effects -* How to share logic between event handlers -* Which logic should be moved to event handlers -* How to notify parent components about changes - - - -## How to remove unnecessary Effects {/*how-to-remove-unnecessary-effects*/} - -There are two common cases in which you don't need Effects: - -* **You don't need Effects to transform data for rendering.** For example, let's say you want to filter a list before displaying it. You might feel tempted to write an Effect that updates a state variable when the list changes. However, this is inefficient. When you update the state, React will first call your component functions to calculate what should be on the screen. Then React will ["commit"](/learn/render-and-commit) these changes to the DOM, updating the screen. Then React will run your Effects. If your Effect *also* immediately updates the state, this restarts the whole process from scratch! To avoid the unnecessary render passes, transform all the data at the top level of your components. That code will automatically re-run whenever your props or state change. -* **You don't need Effects to handle user events.** For example, let's say you want to send an `/api/buy` POST request and show a notification when the user buys a product. In the Buy button click event handler, you know exactly what happened. By the time an Effect runs, you don't know *what* the user did (for example, which button was clicked). This is why you'll usually handle user events in the corresponding event handlers. - -You *do* need Effects to [synchronize](/learn/synchronizing-with-effects#what-are-effects-and-how-are-they-different-from-events) with external systems. For example, you can write an Effect that keeps a jQuery widget synchronized with the React state. You can also fetch data with Effects: for example, you can synchronize the search results with the current search query. Keep in mind that modern [frameworks](/learn/start-a-new-react-project#full-stack-frameworks) provide more efficient built-in data fetching mechanisms than writing Effects directly in your components. - -To help you gain the right intuition, let's look at some common concrete examples! - -### Updating state based on props or state {/*updating-state-based-on-props-or-state*/} - -Suppose you have a component with two state variables: `firstName` and `lastName`. You want to calculate a `fullName` from them by concatenating them. Moreover, you'd like `fullName` to update whenever `firstName` or `lastName` change. Your first instinct might be to add a `fullName` state variable and update it in an Effect: - -```js {5-9} -function Form() { - const [firstName, setFirstName] = useState('Taylor'); - const [lastName, setLastName] = useState('Swift'); - - // 🔴 Avoid: redundant state and unnecessary Effect - const [fullName, setFullName] = useState(''); - useEffect(() => { - setFullName(firstName + ' ' + lastName); - }, [firstName, lastName]); - // ... -} -``` - -This is more complicated than necessary. It is inefficient too: it does an entire render pass with a stale value for `fullName`, then immediately re-renders with the updated value. Remove the state variable and the Effect: - -```js {4-5} -function Form() { - const [firstName, setFirstName] = useState('Taylor'); - const [lastName, setLastName] = useState('Swift'); - // ✅ Good: calculated during rendering - const fullName = firstName + ' ' + lastName; - // ... -} -``` - -**When something can be calculated from the existing props or state, [don't put it in state.](/learn/choosing-the-state-structure#avoid-redundant-state) Instead, calculate it during rendering.** This makes your code faster (you avoid the extra "cascading" updates), simpler (you remove some code), and less error-prone (you avoid bugs caused by different state variables getting out of sync with each other). If this approach feels new to you, [Thinking in React](/learn/thinking-in-react#step-3-find-the-minimal-but-complete-representation-of-ui-state) explains what should go into state. - -### Caching expensive calculations {/*caching-expensive-calculations*/} - -This component computes `visibleTodos` by taking the `todos` it receives by props and filtering them according to the `filter` prop. You might feel tempted to store the result in state and update it from an Effect: - -```js {4-8} -function TodoList({ todos, filter }) { - const [newTodo, setNewTodo] = useState(''); - - // 🔴 Avoid: redundant state and unnecessary Effect - const [visibleTodos, setVisibleTodos] = useState([]); - useEffect(() => { - setVisibleTodos(getFilteredTodos(todos, filter)); - }, [todos, filter]); - - // ... -} -``` - -Like in the earlier example, this is both unnecessary and inefficient. First, remove the state and the Effect: - -```js {3-4} -function TodoList({ todos, filter }) { - const [newTodo, setNewTodo] = useState(''); - // ✅ This is fine if getFilteredTodos() is not slow. - const visibleTodos = getFilteredTodos(todos, filter); - // ... -} -``` - -Usually, this code is fine! But maybe `getFilteredTodos()` is slow or you have a lot of `todos`. In that case you don't want to recalculate `getFilteredTodos()` if some unrelated state variable like `newTodo` has changed. - -You can cache (or ["memoize"](https://en.wikipedia.org/wiki/Memoization)) an expensive calculation by wrapping it in a [`useMemo`](/reference/react/useMemo) Hook: - - - -[React Compiler](/learn/react-compiler) can automatically memoize expensive calculations for you, eliminating the need for manual `useMemo` in many cases. - - - -```js {5-8} -import { useMemo, useState } from 'react'; - -function TodoList({ todos, filter }) { - const [newTodo, setNewTodo] = useState(''); - const visibleTodos = useMemo(() => { - // ✅ Does not re-run unless todos or filter change - return getFilteredTodos(todos, filter); - }, [todos, filter]); - // ... -} -``` - -Or, written as a single line: - -```js {5-6} -import { useMemo, useState } from 'react'; - -function TodoList({ todos, filter }) { - const [newTodo, setNewTodo] = useState(''); - // ✅ Does not re-run getFilteredTodos() unless todos or filter change - const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]); - // ... -} -``` - -**This tells React that you don't want the inner function to re-run unless either `todos` or `filter` have changed.** React will remember the return value of `getFilteredTodos()` during the initial render. During the next renders, it will check if `todos` or `filter` are different. If they're the same as last time, `useMemo` will return the last result it has stored. But if they are different, React will call the inner function again (and store its result). - -The function you wrap in [`useMemo`](/reference/react/useMemo) runs during rendering, so this only works for [pure calculations.](/learn/keeping-components-pure) - - - -#### How to tell if a calculation is expensive? {/*how-to-tell-if-a-calculation-is-expensive*/} - -In general, unless you're creating or looping over thousands of objects, it's probably not expensive. If you want to get more confidence, you can add a console log to measure the time spent in a piece of code: - -```js {1,3} -console.time('filter array'); -const visibleTodos = getFilteredTodos(todos, filter); -console.timeEnd('filter array'); -``` - -Perform the interaction you're measuring (for example, typing into the input). You will then see logs like `filter array: 0.15ms` in your console. If the overall logged time adds up to a significant amount (say, `1ms` or more), it might make sense to memoize that calculation. As an experiment, you can then wrap the calculation in `useMemo` to verify whether the total logged time has decreased for that interaction or not: - -```js -console.time('filter array'); -const visibleTodos = useMemo(() => { - return getFilteredTodos(todos, filter); // Skipped if todos and filter haven't changed -}, [todos, filter]); -console.timeEnd('filter array'); -``` - -`useMemo` won't make the *first* render faster. It only helps you skip unnecessary work on updates. - -Keep in mind that your machine is probably faster than your users' so it's a good idea to test the performance with an artificial slowdown. For example, Chrome offers a [CPU Throttling](https://developer.chrome.com/blog/new-in-devtools-61/#throttling) option for this. - -Also note that measuring performance in development will not give you the most accurate results. (For example, when [Strict Mode](/reference/react/StrictMode) is on, you will see each component render twice rather than once.) To get the most accurate timings, build your app for production and test it on a device like your users have. - - - -### Resetting all state when a prop changes {/*resetting-all-state-when-a-prop-changes*/} - -This `ProfilePage` component receives a `userId` prop. The page contains a comment input, and you use a `comment` state variable to hold its value. One day, you notice a problem: when you navigate from one profile to another, the `comment` state does not get reset. As a result, it's easy to accidentally post a comment on a wrong user's profile. To fix the issue, you want to clear out the `comment` state variable whenever the `userId` changes: - -```js {4-7} -export default function ProfilePage({ userId }) { - const [comment, setComment] = useState(''); - - // 🔴 Avoid: Resetting state on prop change in an Effect - useEffect(() => { - setComment(''); - }, [userId]); - // ... -} -``` - -This is inefficient because `ProfilePage` and its children will first render with the stale value, and then render again. It is also complicated because you'd need to do this in *every* component that has some state inside `ProfilePage`. For example, if the comment UI is nested, you'd want to clear out nested comment state too. - -Instead, you can tell React that each user's profile is conceptually a _different_ profile by giving it an explicit key. Split your component in two and pass a `key` attribute from the outer component to the inner one: - -```js {5,11-12} -export default function ProfilePage({ userId }) { - return ( - - ); -} - -function Profile({ userId }) { - // ✅ This and any other state below will reset on key change automatically - const [comment, setComment] = useState(''); - // ... -} -``` - -Normally, React preserves the state when the same component is rendered in the same spot. **By passing `userId` as a `key` to the `Profile` component, you're asking React to treat two `Profile` components with different `userId` as two different components that should not share any state.** Whenever the key (which you've set to `userId`) changes, React will recreate the DOM and [reset the state](/learn/preserving-and-resetting-state#option-2-resetting-state-with-a-key) of the `Profile` component and all of its children. Now the `comment` field will clear out automatically when navigating between profiles. - -Note that in this example, only the outer `ProfilePage` component is exported and visible to other files in the project. Components rendering `ProfilePage` don't need to pass the key to it: they pass `userId` as a regular prop. The fact `ProfilePage` passes it as a `key` to the inner `Profile` component is an implementation detail. - -### Adjusting some state when a prop changes {/*adjusting-some-state-when-a-prop-changes*/} - -Sometimes, you might want to reset or adjust a part of the state on a prop change, but not all of it. - -This `List` component receives a list of `items` as a prop, and maintains the selected item in the `selection` state variable. You want to reset the `selection` to `null` whenever the `items` prop receives a different array: - -```js {5-8} -function List({ items }) { - const [isReverse, setIsReverse] = useState(false); - const [selection, setSelection] = useState(null); - - // 🔴 Avoid: Adjusting state on prop change in an Effect - useEffect(() => { - setSelection(null); - }, [items]); - // ... -} -``` - -This, too, is not ideal. Every time the `items` change, the `List` and its child components will render with a stale `selection` value at first. Then React will update the DOM and run the Effects. Finally, the `setSelection(null)` call will cause another re-render of the `List` and its child components, restarting this whole process again. - -Start by deleting the Effect. Instead, adjust the state directly during rendering: - -```js {5-11} -function List({ items }) { - const [isReverse, setIsReverse] = useState(false); - const [selection, setSelection] = useState(null); - - // Better: Adjust the state while rendering - const [prevItems, setPrevItems] = useState(items); - if (items !== prevItems) { - setPrevItems(items); - setSelection(null); - } - // ... -} -``` - -[Storing information from previous renders](/reference/react/useState#storing-information-from-previous-renders) like this can be hard to understand, but it’s better than updating the same state in an Effect. In the above example, `setSelection` is called directly during a render. React will re-render the `List` *immediately* after it exits with a `return` statement. React has not rendered the `List` children or updated the DOM yet, so this lets the `List` children skip rendering the stale `selection` value. - -When you update a component during rendering, React throws away the returned JSX and immediately retries rendering. To avoid very slow cascading retries, React only lets you update the *same* component's state during a render. If you update another component's state during a render, you'll see an error. A condition like `items !== prevItems` is necessary to avoid loops. You may adjust state like this, but any other side effects (like changing the DOM or setting timeouts) should stay in event handlers or Effects to [keep components pure.](/learn/keeping-components-pure) - -**Although this pattern is more efficient than an Effect, most components shouldn't need it either.** No matter how you do it, adjusting state based on props or other state makes your data flow more difficult to understand and debug. Always check whether you can [reset all state with a key](#resetting-all-state-when-a-prop-changes) or [calculate everything during rendering](#updating-state-based-on-props-or-state) instead. For example, instead of storing (and resetting) the selected *item*, you can store the selected *item ID:* - -```js {3-5} -function List({ items }) { - const [isReverse, setIsReverse] = useState(false); - const [selectedId, setSelectedId] = useState(null); - // ✅ Best: Calculate everything during rendering - const selection = items.find(item => item.id === selectedId) ?? null; - // ... -} -``` - -Now there is no need to "adjust" the state at all. If the item with the selected ID is in the list, it remains selected. If it's not, the `selection` calculated during rendering will be `null` because no matching item was found. This behavior is different, but arguably better because most changes to `items` preserve the selection. - -### Sharing logic between event handlers {/*sharing-logic-between-event-handlers*/} - -Let's say you have a product page with two buttons (Buy and Checkout) that both let you buy that product. You want to show a notification whenever the user puts the product in the cart. Calling `showNotification()` in both buttons' click handlers feels repetitive so you might be tempted to place this logic in an Effect: - -```js {2-7} -function ProductPage({ product, addToCart }) { - // 🔴 Avoid: Event-specific logic inside an Effect - useEffect(() => { - if (product.isInCart) { - showNotification(`Added ${product.name} to the shopping cart!`); - } - }, [product]); - - function handleBuyClick() { - addToCart(product); - } - - function handleCheckoutClick() { - addToCart(product); - navigateTo('/checkout'); - } - // ... -} -``` - -This Effect is unnecessary. It will also most likely cause bugs. For example, let's say that your app "remembers" the shopping cart between the page reloads. If you add a product to the cart once and refresh the page, the notification will appear again. It will keep appearing every time you refresh that product's page. This is because `product.isInCart` will already be `true` on the page load, so the Effect above will call `showNotification()`. - -**When you're not sure whether some code should be in an Effect or in an event handler, ask yourself *why* this code needs to run. Use Effects only for code that should run *because* the component was displayed to the user.** In this example, the notification should appear because the user *pressed the button*, not because the page was displayed! Delete the Effect and put the shared logic into a function called from both event handlers: - -```js {2-6,9,13} -function ProductPage({ product, addToCart }) { - // ✅ Good: Event-specific logic is called from event handlers - function buyProduct() { - addToCart(product); - showNotification(`Added ${product.name} to the shopping cart!`); - } - - function handleBuyClick() { - buyProduct(); - } - - function handleCheckoutClick() { - buyProduct(); - navigateTo('/checkout'); - } - // ... -} -``` - -This both removes the unnecessary Effect and fixes the bug. - -### Sending a POST request {/*sending-a-post-request*/} - -This `Form` component sends two kinds of POST requests. It sends an analytics event when it mounts. When you fill in the form and click the Submit button, it will send a POST request to the `/api/register` endpoint: - -```js {5-8,10-16} -function Form() { - const [firstName, setFirstName] = useState(''); - const [lastName, setLastName] = useState(''); - - // ✅ Good: This logic should run because the component was displayed - useEffect(() => { - post('/analytics/event', { eventName: 'visit_form' }); - }, []); - - // 🔴 Avoid: Event-specific logic inside an Effect - const [jsonToSubmit, setJsonToSubmit] = useState(null); - useEffect(() => { - if (jsonToSubmit !== null) { - post('/api/register', jsonToSubmit); - } - }, [jsonToSubmit]); - - function handleSubmit(e) { - e.preventDefault(); - setJsonToSubmit({ firstName, lastName }); - } - // ... -} -``` - -Let's apply the same criteria as in the example before. - -The analytics POST request should remain in an Effect. This is because the _reason_ to send the analytics event is that the form was displayed. (It would fire twice in development, but [see here](/learn/synchronizing-with-effects#sending-analytics) for how to deal with that.) - -However, the `/api/register` POST request is not caused by the form being _displayed_. You only want to send the request at one specific moment in time: when the user presses the button. It should only ever happen _on that particular interaction_. Delete the second Effect and move that POST request into the event handler: - -```js {12-13} -function Form() { - const [firstName, setFirstName] = useState(''); - const [lastName, setLastName] = useState(''); - - // ✅ Good: This logic runs because the component was displayed - useEffect(() => { - post('/analytics/event', { eventName: 'visit_form' }); - }, []); - - function handleSubmit(e) { - e.preventDefault(); - // ✅ Good: Event-specific logic is in the event handler - post('/api/register', { firstName, lastName }); - } - // ... -} -``` - -When you choose whether to put some logic into an event handler or an Effect, the main question you need to answer is _what kind of logic_ it is from the user's perspective. If this logic is caused by a particular interaction, keep it in the event handler. If it's caused by the user _seeing_ the component on the screen, keep it in the Effect. - -### Chains of computations {/*chains-of-computations*/} - -Sometimes you might feel tempted to chain Effects that each adjust a piece of state based on other state: - -```js {7-29} -function Game() { - const [card, setCard] = useState(null); - const [goldCardCount, setGoldCardCount] = useState(0); - const [round, setRound] = useState(1); - const [isGameOver, setIsGameOver] = useState(false); - - // 🔴 Avoid: Chains of Effects that adjust the state solely to trigger each other - useEffect(() => { - if (card !== null && card.gold) { - setGoldCardCount(c => c + 1); - } - }, [card]); - - useEffect(() => { - if (goldCardCount > 3) { - setRound(r => r + 1) - setGoldCardCount(0); - } - }, [goldCardCount]); - - useEffect(() => { - if (round > 5) { - setIsGameOver(true); - } - }, [round]); - - useEffect(() => { - alert('Good game!'); - }, [isGameOver]); - - function handlePlaceCard(nextCard) { - if (isGameOver) { - throw Error('Game already ended.'); - } else { - setCard(nextCard); - } - } - - // ... -``` - -There are two problems with this code. - -The first problem is that it is very inefficient: the component (and its children) have to re-render between each `set` call in the chain. In the example above, in the worst case (`setCard` → render → `setGoldCardCount` → render → `setRound` → render → `setIsGameOver` → render) there are three unnecessary re-renders of the tree below. - -The second problem is that even if it weren't slow, as your code evolves, you will run into cases where the "chain" you wrote doesn't fit the new requirements. Imagine you are adding a way to step through the history of the game moves. You'd do it by updating each state variable to a value from the past. However, setting the `card` state to a value from the past would trigger the Effect chain again and change the data you're showing. Such code is often rigid and fragile. - -In this case, it's better to calculate what you can during rendering, and adjust the state in the event handler: - -```js {6-7,14-26} -function Game() { - const [card, setCard] = useState(null); - const [goldCardCount, setGoldCardCount] = useState(0); - const [round, setRound] = useState(1); - - // ✅ Calculate what you can during rendering - const isGameOver = round > 5; - - function handlePlaceCard(nextCard) { - if (isGameOver) { - throw Error('Game already ended.'); - } - - // ✅ Calculate all the next state in the event handler - setCard(nextCard); - if (nextCard.gold) { - if (goldCardCount <= 3) { - setGoldCardCount(goldCardCount + 1); - } else { - setGoldCardCount(0); - setRound(round + 1); - if (round === 5) { - alert('Good game!'); - } - } - } - } - - // ... -``` - -This is a lot more efficient. Also, if you implement a way to view game history, now you will be able to set each state variable to a move from the past without triggering the Effect chain that adjusts every other value. If you need to reuse logic between several event handlers, you can [extract a function](#sharing-logic-between-event-handlers) and call it from those handlers. - -Remember that inside event handlers, [state behaves like a snapshot.](/learn/state-as-a-snapshot) For example, even after you call `setRound(round + 1)`, the `round` variable will reflect the value at the time the user clicked the button. If you need to use the next value for calculations, define it manually like `const nextRound = round + 1`. - -In some cases, you *can't* calculate the next state directly in the event handler. For example, imagine a form with multiple dropdowns where the options of the next dropdown depend on the selected value of the previous dropdown. Then, a chain of Effects is appropriate because you are synchronizing with network. - -### Initializing the application {/*initializing-the-application*/} - -Some logic should only run once when the app loads. - -You might be tempted to place it in an Effect in the top-level component: - -```js {2-6} -function App() { - // 🔴 Avoid: Effects with logic that should only ever run once - useEffect(() => { - loadDataFromLocalStorage(); - checkAuthToken(); - }, []); - // ... -} -``` - -However, you'll quickly discover that it [runs twice in development.](/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) This can cause issues--for example, maybe it invalidates the authentication token because the function wasn't designed to be called twice. In general, your components should be resilient to being remounted. This includes your top-level `App` component. - -Although it may not ever get remounted in practice in production, following the same constraints in all components makes it easier to move and reuse code. If some logic must run *once per app load* rather than *once per component mount*, add a top-level variable to track whether it has already executed: - -```js {1,5-6,10} -let didInit = false; - -function App() { - useEffect(() => { - if (!didInit) { - didInit = true; - // ✅ Only runs once per app load - loadDataFromLocalStorage(); - checkAuthToken(); - } - }, []); - // ... -} -``` - -You can also run it during module initialization and before the app renders: - -```js {1,5} -if (typeof window !== 'undefined') { // Check if we're running in the browser. - // ✅ Only runs once per app load - checkAuthToken(); - loadDataFromLocalStorage(); -} - -function App() { - // ... -} -``` - -Code at the top level runs once when your component is imported--even if it doesn't end up being rendered. To avoid slowdown or surprising behavior when importing arbitrary components, don't overuse this pattern. Keep app-wide initialization logic to root component modules like `App.js` or in your application's entry point. - -### Notifying parent components about state changes {/*notifying-parent-components-about-state-changes*/} - -Let's say you're writing a `Toggle` component with an internal `isOn` state which can be either `true` or `false`. There are a few different ways to toggle it (by clicking or dragging). You want to notify the parent component whenever the `Toggle` internal state changes, so you expose an `onChange` event and call it from an Effect: - -```js {4-7} -function Toggle({ onChange }) { - const [isOn, setIsOn] = useState(false); - - // 🔴 Avoid: The onChange handler runs too late - useEffect(() => { - onChange(isOn); - }, [isOn, onChange]) - - function handleClick() { - setIsOn(!isOn); - } - - function handleDragEnd(e) { - if (isCloserToRightEdge(e)) { - setIsOn(true); - } else { - setIsOn(false); - } - } - - // ... -} -``` - -Like earlier, this is not ideal. The `Toggle` updates its state first, and React updates the screen. Then React runs the Effect, which calls the `onChange` function passed from a parent component. Now the parent component will update its own state, starting another render pass. It would be better to do everything in a single pass. - -Delete the Effect and instead update the state of *both* components within the same event handler: - -```js {5-7,11,16,18} -function Toggle({ onChange }) { - const [isOn, setIsOn] = useState(false); - - function updateToggle(nextIsOn) { - // ✅ Good: Perform all updates during the event that caused them - setIsOn(nextIsOn); - onChange(nextIsOn); - } - - function handleClick() { - updateToggle(!isOn); - } - - function handleDragEnd(e) { - if (isCloserToRightEdge(e)) { - updateToggle(true); - } else { - updateToggle(false); - } - } - - // ... -} -``` - -With this approach, both the `Toggle` component and its parent component update their state during the event. React [batches updates](/learn/queueing-a-series-of-state-updates) from different components together, so there will only be one render pass. - -You might also be able to remove the state altogether, and instead receive `isOn` from the parent component: - -```js {1,2} -// ✅ Also good: the component is fully controlled by its parent -function Toggle({ isOn, onChange }) { - function handleClick() { - onChange(!isOn); - } - - function handleDragEnd(e) { - if (isCloserToRightEdge(e)) { - onChange(true); - } else { - onChange(false); - } - } - - // ... -} -``` - -["Lifting state up"](/learn/sharing-state-between-components) lets the parent component fully control the `Toggle` by toggling the parent's own state. This means the parent component will have to contain more logic, but there will be less state overall to worry about. Whenever you try to keep two different state variables synchronized, try lifting state up instead! - -### Passing data to the parent {/*passing-data-to-the-parent*/} - -This `Child` component fetches some data and then passes it to the `Parent` component in an Effect: - -```js {9-14} -function Parent() { - const [data, setData] = useState(null); - // ... - return ; -} - -function Child({ onFetched }) { - const data = useSomeAPI(); - // 🔴 Avoid: Passing data to the parent in an Effect - useEffect(() => { - if (data) { - onFetched(data); - } - }, [onFetched, data]); - // ... -} -``` - -In React, data flows from the parent components to their children. When you see something wrong on the screen, you can trace where the information comes from by going up the component chain until you find which component passes the wrong prop or has the wrong state. When child components update the state of their parent components in Effects, the data flow becomes very difficult to trace. Since both the child and the parent need the same data, let the parent component fetch that data, and *pass it down* to the child instead: - -```js {4-5} -function Parent() { - const data = useSomeAPI(); - // ... - // ✅ Good: Passing data down to the child - return ; -} - -function Child({ data }) { - // ... -} -``` - -This is simpler and keeps the data flow predictable: the data flows down from the parent to the child. - -### Subscribing to an external store {/*subscribing-to-an-external-store*/} - -Sometimes, your components may need to subscribe to some data outside of the React state. This data could be from a third-party library or a built-in browser API. Since this data can change without React's knowledge, you need to manually subscribe your components to it. This is often done with an Effect, for example: - -```js {2-17} -function useOnlineStatus() { - // Not ideal: Manual store subscription in an Effect - const [isOnline, setIsOnline] = useState(true); - useEffect(() => { - function updateState() { - setIsOnline(navigator.onLine); - } - - updateState(); - - window.addEventListener('online', updateState); - window.addEventListener('offline', updateState); - return () => { - window.removeEventListener('online', updateState); - window.removeEventListener('offline', updateState); - }; - }, []); - return isOnline; -} - -function ChatIndicator() { - const isOnline = useOnlineStatus(); - // ... -} -``` - -Here, the component subscribes to an external data store (in this case, the browser `navigator.onLine` API). Since this API does not exist on the server (so it can't be used for the initial HTML), initially the state is set to `true`. Whenever the value of that data store changes in the browser, the component updates its state. - -Although it's common to use Effects for this, React has a purpose-built Hook for subscribing to an external store that is preferred instead. Delete the Effect and replace it with a call to [`useSyncExternalStore`](/reference/react/useSyncExternalStore): - -```js {11-16} -function subscribe(callback) { - window.addEventListener('online', callback); - window.addEventListener('offline', callback); - return () => { - window.removeEventListener('online', callback); - window.removeEventListener('offline', callback); - }; -} - -function useOnlineStatus() { - // ✅ Good: Subscribing to an external store with a built-in Hook - return useSyncExternalStore( - subscribe, // React won't resubscribe for as long as you pass the same function - () => navigator.onLine, // How to get the value on the client - () => true // How to get the value on the server - ); -} - -function ChatIndicator() { - const isOnline = useOnlineStatus(); - // ... -} -``` - -This approach is less error-prone than manually syncing mutable data to React state with an Effect. Typically, you'll write a custom Hook like `useOnlineStatus()` above so that you don't need to repeat this code in the individual components. [Read more about subscribing to external stores from React components.](/reference/react/useSyncExternalStore) - -### Fetching data {/*fetching-data*/} - -Many apps use Effects to kick off data fetching. It is quite common to write a data fetching Effect like this: - -```js {5-10} -function SearchResults({ query }) { - const [results, setResults] = useState([]); - const [page, setPage] = useState(1); - - useEffect(() => { - // 🔴 Avoid: Fetching without cleanup logic - fetchResults(query, page).then(json => { - setResults(json); - }); - }, [query, page]); - - function handleNextPageClick() { - setPage(page + 1); - } - // ... -} -``` - -You *don't* need to move this fetch to an event handler. - -This might seem like a contradiction with the earlier examples where you needed to put the logic into the event handlers! However, consider that it's not *the typing event* that's the main reason to fetch. Search inputs are often prepopulated from the URL, and the user might navigate Back and Forward without touching the input. - -It doesn't matter where `page` and `query` come from. While this component is visible, you want to keep `results` [synchronized](/learn/synchronizing-with-effects) with data from the network for the current `page` and `query`. This is why it's an Effect. - -However, the code above has a bug. Imagine you type `"hello"` fast. Then the `query` will change from `"h"`, to `"he"`, `"hel"`, `"hell"`, and `"hello"`. This will kick off separate fetches, but there is no guarantee about which order the responses will arrive in. For example, the `"hell"` response may arrive *after* the `"hello"` response. Since it will call `setResults()` last, you will be displaying the wrong search results. This is called a ["race condition"](https://en.wikipedia.org/wiki/Race_condition): two different requests "raced" against each other and came in a different order than you expected. - -**To fix the race condition, you need to [add a cleanup function](/learn/synchronizing-with-effects#fetching-data) to ignore stale responses:** - -```js {5,7,9,11-13} -function SearchResults({ query }) { - const [results, setResults] = useState([]); - const [page, setPage] = useState(1); - useEffect(() => { - let ignore = false; - fetchResults(query, page).then(json => { - if (!ignore) { - setResults(json); - } - }); - return () => { - ignore = true; - }; - }, [query, page]); - - function handleNextPageClick() { - setPage(page + 1); - } - // ... -} -``` - -This ensures that when your Effect fetches data, all responses except the last requested one will be ignored. - -Handling race conditions is not the only difficulty with implementing data fetching. You might also want to think about caching responses (so that the user can click Back and see the previous screen instantly), how to fetch data on the server (so that the initial server-rendered HTML contains the fetched content instead of a spinner), and how to avoid network waterfalls (so that a child can fetch data without waiting for every parent). - -**These issues apply to any UI library, not just React. Solving them is not trivial, which is why modern [frameworks](/learn/start-a-new-react-project#full-stack-frameworks) provide more efficient built-in data fetching mechanisms than fetching data in Effects.** - -If you don't use a framework (and don't want to build your own) but would like to make data fetching from Effects more ergonomic, consider extracting your fetching logic into a custom Hook like in this example: - -```js {4} -function SearchResults({ query }) { - const [page, setPage] = useState(1); - const params = new URLSearchParams({ query, page }); - const results = useData(`/api/search?${params}`); - - function handleNextPageClick() { - setPage(page + 1); - } - // ... -} - -function useData(url) { - const [data, setData] = useState(null); - useEffect(() => { - let ignore = false; - fetch(url) - .then(response => response.json()) - .then(json => { - if (!ignore) { - setData(json); - } - }); - return () => { - ignore = true; - }; - }, [url]); - return data; -} -``` - -You'll likely also want to add some logic for error handling and to track whether the content is loading. You can build a Hook like this yourself or use one of the many solutions already available in the React ecosystem. **Although this alone won't be as efficient as using a framework's built-in data fetching mechanism, moving the data fetching logic into a custom Hook will make it easier to adopt an efficient data fetching strategy later.** - -In general, whenever you have to resort to writing Effects, keep an eye out for when you can extract a piece of functionality into a custom Hook with a more declarative and purpose-built API like `useData` above. The fewer raw `useEffect` calls you have in your components, the easier you will find to maintain your application. - - - -- If you can calculate something during render, you don't need an Effect. -- To cache expensive calculations, add `useMemo` instead of `useEffect`. -- To reset the state of an entire component tree, pass a different `key` to it. -- To reset a particular bit of state in response to a prop change, set it during rendering. -- Code that runs because a component was *displayed* should be in Effects, the rest should be in events. -- If you need to update the state of several components, it's better to do it during a single event. -- Whenever you try to synchronize state variables in different components, consider lifting state up. -- You can fetch data with Effects, but you need to implement cleanup to avoid race conditions. - - - - - -#### Transform data without Effects {/*transform-data-without-effects*/} - -The `TodoList` below displays a list of todos. When the "Show only active todos" checkbox is ticked, completed todos are not displayed in the list. Regardless of which todos are visible, the footer displays the count of todos that are not yet completed. - -Simplify this component by removing all the unnecessary state and Effects. - - - -```js -import { useState, useEffect } from 'react'; -import { initialTodos, createTodo } from './todos.js'; - -export default function TodoList() { - const [todos, setTodos] = useState(initialTodos); - const [showActive, setShowActive] = useState(false); - const [activeTodos, setActiveTodos] = useState([]); - const [visibleTodos, setVisibleTodos] = useState([]); - const [footer, setFooter] = useState(null); - - useEffect(() => { - setActiveTodos(todos.filter(todo => !todo.completed)); - }, [todos]); - - useEffect(() => { - setVisibleTodos(showActive ? activeTodos : todos); - }, [showActive, todos, activeTodos]); - - useEffect(() => { - setFooter( -
- {activeTodos.length} todos left -
- ); - }, [activeTodos]); - - return ( - <> - - setTodos([...todos, newTodo])} /> -
    - {visibleTodos.map(todo => ( -
  • - {todo.completed ? {todo.text} : todo.text} -
  • - ))} -
- {footer} - - ); -} - -function NewTodo({ onAdd }) { - const [text, setText] = useState(''); - - function handleAddClick() { - setText(''); - onAdd(createTodo(text)); - } - - return ( - <> - setText(e.target.value)} /> - - - ); -} -``` - -```js src/todos.js -let nextId = 0; - -export function createTodo(text, completed = false) { - return { - id: nextId++, - text, - completed - }; -} - -export const initialTodos = [ - createTodo('Get apples', true), - createTodo('Get oranges', true), - createTodo('Get carrots'), -]; -``` - -```css -label { display: block; } -input { margin-top: 10px; } -``` - -
- - - -If you can calculate something during rendering, you don't need state or an Effect that updates it. - - - - - -There are only two essential pieces of state in this example: the list of `todos` and the `showActive` state variable which represents whether the checkbox is ticked. All of the other state variables are [redundant](/learn/choosing-the-state-structure#avoid-redundant-state) and can be calculated during rendering instead. This includes the `footer` which you can move directly into the surrounding JSX. - -Your result should end up looking like this: - - - -```js -import { useState } from 'react'; -import { initialTodos, createTodo } from './todos.js'; - -export default function TodoList() { - const [todos, setTodos] = useState(initialTodos); - const [showActive, setShowActive] = useState(false); - const activeTodos = todos.filter(todo => !todo.completed); - const visibleTodos = showActive ? activeTodos : todos; - - return ( - <> - - setTodos([...todos, newTodo])} /> -
    - {visibleTodos.map(todo => ( -
  • - {todo.completed ? {todo.text} : todo.text} -
  • - ))} -
-
- {activeTodos.length} todos left -
- - ); -} - -function NewTodo({ onAdd }) { - const [text, setText] = useState(''); - - function handleAddClick() { - setText(''); - onAdd(createTodo(text)); - } - - return ( - <> - setText(e.target.value)} /> - - - ); -} -``` - -```js src/todos.js -let nextId = 0; - -export function createTodo(text, completed = false) { - return { - id: nextId++, - text, - completed - }; -} - -export const initialTodos = [ - createTodo('Get apples', true), - createTodo('Get oranges', true), - createTodo('Get carrots'), -]; -``` - -```css -label { display: block; } -input { margin-top: 10px; } -``` - -
- -
- -#### Cache a calculation without Effects {/*cache-a-calculation-without-effects*/} - -In this example, filtering the todos was extracted into a separate function called `getVisibleTodos()`. This function contains a `console.log()` call inside of it which helps you notice when it's being called. Toggle "Show only active todos" and notice that it causes `getVisibleTodos()` to re-run. This is expected because visible todos change when you toggle which ones to display. - -Your task is to remove the Effect that recomputes the `visibleTodos` list in the `TodoList` component. However, you need to make sure that `getVisibleTodos()` does *not* re-run (and so does not print any logs) when you type into the input. - - - -One solution is to add a `useMemo` call to cache the visible todos. There is also another, less obvious solution. - - - - - -```js -import { useState, useEffect } from 'react'; -import { initialTodos, createTodo, getVisibleTodos } from './todos.js'; - -export default function TodoList() { - const [todos, setTodos] = useState(initialTodos); - const [showActive, setShowActive] = useState(false); - const [text, setText] = useState(''); - const [visibleTodos, setVisibleTodos] = useState([]); - - useEffect(() => { - setVisibleTodos(getVisibleTodos(todos, showActive)); - }, [todos, showActive]); - - function handleAddClick() { - setText(''); - setTodos([...todos, createTodo(text)]); - } - - return ( - <> - - setText(e.target.value)} /> - -
    - {visibleTodos.map(todo => ( -
  • - {todo.completed ? {todo.text} : todo.text} -
  • - ))} -
- - ); -} -``` - -```js src/todos.js -let nextId = 0; -let calls = 0; - -export function getVisibleTodos(todos, showActive) { - console.log(`getVisibleTodos() was called ${++calls} times`); - const activeTodos = todos.filter(todo => !todo.completed); - const visibleTodos = showActive ? activeTodos : todos; - return visibleTodos; -} - -export function createTodo(text, completed = false) { - return { - id: nextId++, - text, - completed - }; -} - -export const initialTodos = [ - createTodo('Get apples', true), - createTodo('Get oranges', true), - createTodo('Get carrots'), -]; -``` - -```css -label { display: block; } -input { margin-top: 10px; } -``` - -
- - - -Remove the state variable and the Effect, and instead add a `useMemo` call to cache the result of calling `getVisibleTodos()`: - - - -```js -import { useState, useMemo } from 'react'; -import { initialTodos, createTodo, getVisibleTodos } from './todos.js'; - -export default function TodoList() { - const [todos, setTodos] = useState(initialTodos); - const [showActive, setShowActive] = useState(false); - const [text, setText] = useState(''); - const visibleTodos = useMemo( - () => getVisibleTodos(todos, showActive), - [todos, showActive] - ); - - function handleAddClick() { - setText(''); - setTodos([...todos, createTodo(text)]); - } - - return ( - <> - - setText(e.target.value)} /> - -
    - {visibleTodos.map(todo => ( -
  • - {todo.completed ? {todo.text} : todo.text} -
  • - ))} -
- - ); -} -``` - -```js src/todos.js -let nextId = 0; -let calls = 0; - -export function getVisibleTodos(todos, showActive) { - console.log(`getVisibleTodos() was called ${++calls} times`); - const activeTodos = todos.filter(todo => !todo.completed); - const visibleTodos = showActive ? activeTodos : todos; - return visibleTodos; -} - -export function createTodo(text, completed = false) { - return { - id: nextId++, - text, - completed - }; -} - -export const initialTodos = [ - createTodo('Get apples', true), - createTodo('Get oranges', true), - createTodo('Get carrots'), -]; -``` - -```css -label { display: block; } -input { margin-top: 10px; } -``` - -
- -With this change, `getVisibleTodos()` will be called only if `todos` or `showActive` change. Typing into the input only changes the `text` state variable, so it does not trigger a call to `getVisibleTodos()`. - -There is also another solution which does not need `useMemo`. Since the `text` state variable can't possibly affect the list of todos, you can extract the `NewTodo` form into a separate component, and move the `text` state variable inside of it: - - - -```js -import { useState, useMemo } from 'react'; -import { initialTodos, createTodo, getVisibleTodos } from './todos.js'; - -export default function TodoList() { - const [todos, setTodos] = useState(initialTodos); - const [showActive, setShowActive] = useState(false); - const visibleTodos = getVisibleTodos(todos, showActive); - - return ( - <> - - setTodos([...todos, newTodo])} /> -
    - {visibleTodos.map(todo => ( -
  • - {todo.completed ? {todo.text} : todo.text} -
  • - ))} -
- - ); -} - -function NewTodo({ onAdd }) { - const [text, setText] = useState(''); - - function handleAddClick() { - setText(''); - onAdd(createTodo(text)); - } - - return ( - <> - setText(e.target.value)} /> - - - ); -} -``` - -```js src/todos.js -let nextId = 0; -let calls = 0; - -export function getVisibleTodos(todos, showActive) { - console.log(`getVisibleTodos() was called ${++calls} times`); - const activeTodos = todos.filter(todo => !todo.completed); - const visibleTodos = showActive ? activeTodos : todos; - return visibleTodos; -} - -export function createTodo(text, completed = false) { - return { - id: nextId++, - text, - completed - }; -} - -export const initialTodos = [ - createTodo('Get apples', true), - createTodo('Get oranges', true), - createTodo('Get carrots'), -]; -``` - -```css -label { display: block; } -input { margin-top: 10px; } -``` - -
- -This approach satisfies the requirements too. When you type into the input, only the `text` state variable updates. Since the `text` state variable is in the child `NewTodo` component, the parent `TodoList` component won't get re-rendered. This is why `getVisibleTodos()` doesn't get called when you type. (It would still be called if the `TodoList` re-renders for another reason.) - -
- -#### Reset state without Effects {/*reset-state-without-effects*/} - -This `EditContact` component receives a contact object shaped like `{ id, name, email }` as the `savedContact` prop. Try editing the name and email input fields. When you press Save, the contact's button above the form updates to the edited name. When you press Reset, any pending changes in the form are discarded. Play around with this UI to get a feel for it. - -When you select a contact with the buttons at the top, the form resets to reflect that contact's details. This is done with an Effect inside `EditContact.js`. Remove this Effect. Find another way to reset the form when `savedContact.id` changes. - - - -```js src/App.js hidden -import { useState } from 'react'; -import ContactList from './ContactList.js'; -import EditContact from './EditContact.js'; - -export default function ContactManager() { - const [ - contacts, - setContacts - ] = useState(initialContacts); - const [ - selectedId, - setSelectedId - ] = useState(0); - const selectedContact = contacts.find(c => - c.id === selectedId - ); - - function handleSave(updatedData) { - const nextContacts = contacts.map(c => { - if (c.id === updatedData.id) { - return updatedData; - } else { - return c; - } - }); - setContacts(nextContacts); - } - - return ( -
- setSelectedId(id)} - /> -
- -
- ) -} - -const initialContacts = [ - { id: 0, name: 'Taylor', email: 'taylor@mail.com' }, - { id: 1, name: 'Alice', email: 'alice@mail.com' }, - { id: 2, name: 'Bob', email: 'bob@mail.com' } -]; -``` - -```js src/ContactList.js hidden -export default function ContactList({ - contacts, - selectedId, - onSelect -}) { - return ( -
-
    - {contacts.map(contact => -
  • - -
  • - )} -
-
- ); -} -``` - -```js src/EditContact.js active -import { useState, useEffect } from 'react'; - -export default function EditContact({ savedContact, onSave }) { - const [name, setName] = useState(savedContact.name); - const [email, setEmail] = useState(savedContact.email); - - useEffect(() => { - setName(savedContact.name); - setEmail(savedContact.email); - }, [savedContact]); - - return ( -
- - - - -
- ); -} -``` - -```css -ul, li { - list-style: none; - margin: 0; - padding: 0; -} -li { display: inline-block; } -li button { - padding: 10px; -} -label { - display: block; - margin: 10px 0; -} -button { - margin-right: 10px; - margin-bottom: 10px; -} -``` - -
- - - -It would be nice if there was a way to tell React that when `savedContact.id` is different, the `EditContact` form is conceptually a _different contact's form_ and should not preserve state. Do you recall any such way? - - - - - -Split the `EditContact` component in two. Move all the form state into the inner `EditForm` component. Export the outer `EditContact` component, and make it pass `savedContact.id` as the `key` to the inner `EditForm` component. As a result, the inner `EditForm` component resets all of the form state and recreates the DOM whenever you select a different contact. - - - -```js src/App.js hidden -import { useState } from 'react'; -import ContactList from './ContactList.js'; -import EditContact from './EditContact.js'; - -export default function ContactManager() { - const [ - contacts, - setContacts - ] = useState(initialContacts); - const [ - selectedId, - setSelectedId - ] = useState(0); - const selectedContact = contacts.find(c => - c.id === selectedId - ); - - function handleSave(updatedData) { - const nextContacts = contacts.map(c => { - if (c.id === updatedData.id) { - return updatedData; - } else { - return c; - } - }); - setContacts(nextContacts); - } - - return ( -
- setSelectedId(id)} - /> -
- -
- ) -} - -const initialContacts = [ - { id: 0, name: 'Taylor', email: 'taylor@mail.com' }, - { id: 1, name: 'Alice', email: 'alice@mail.com' }, - { id: 2, name: 'Bob', email: 'bob@mail.com' } -]; -``` - -```js src/ContactList.js hidden -export default function ContactList({ - contacts, - selectedId, - onSelect -}) { - return ( -
-
    - {contacts.map(contact => -
  • - -
  • - )} -
-
- ); -} -``` - -```js src/EditContact.js active -import { useState } from 'react'; - -export default function EditContact(props) { - return ( - - ); -} - -function EditForm({ savedContact, onSave }) { - const [name, setName] = useState(savedContact.name); - const [email, setEmail] = useState(savedContact.email); - - return ( -
- - - - -
- ); -} -``` - -```css -ul, li { - list-style: none; - margin: 0; - padding: 0; -} -li { display: inline-block; } -li button { - padding: 10px; -} -label { - display: block; - margin: 10px 0; -} -button { - margin-right: 10px; - margin-bottom: 10px; -} -``` - -
- -
- -#### Submit a form without Effects {/*submit-a-form-without-effects*/} - -This `Form` component lets you send a message to a friend. When you submit the form, the `showForm` state variable is set to `false`. This triggers an Effect calling `sendMessage(message)`, which sends the message (you can see it in the console). After the message is sent, you see a "Thank you" dialog with an "Open chat" button that lets you get back to the form. - -Your app's users are sending way too many messages. To make chatting a little bit more difficult, you've decided to show the "Thank you" dialog *first* rather than the form. Change the `showForm` state variable to initialize to `false` instead of `true`. As soon as you make that change, the console will show that an empty message was sent. Something in this logic is wrong! - -What's the root cause of this problem? And how can you fix it? - - - -Should the message be sent _because_ the user saw the "Thank you" dialog? Or is it the other way around? - - - - - -```js -import { useState, useEffect } from 'react'; - -export default function Form() { - const [showForm, setShowForm] = useState(true); - const [message, setMessage] = useState(''); - - useEffect(() => { - if (!showForm) { - sendMessage(message); - } - }, [showForm, message]); - - function handleSubmit(e) { - e.preventDefault(); - setShowForm(false); - } - - if (!showForm) { - return ( - <> -

Thanks for using our services!

- - - ); - } - - return ( -
-