mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
perf: reduce browsing rerenders and startup cost (#1189)
* chore(profiling): collect serializable react-scan reports * perf(routing): rerender only when directory winner changes * perf(zustand): remove shallow warning hot path * perf(state): narrow directory lifecycle updates * perf(home): avoid redundant stats render work * perf(posts): isolate live loading subscriptions * perf(chrome): skip unchanged shell rerenders * fix(profiling): drop unsupported render metric * perf(feeds): skip unchanged loading state renders * perf(home): stop resolved stats collector renders * chore(agent run): record rerender verification * perf(home): defer stats requests until metadata resolves * perf(startup): load protocol modules on demand * chore(agent run): record acquisition and startup profiles
This commit is contained in:
@@ -17,7 +17,9 @@ You receive from the parent agent:
|
||||
|
||||
## How It Works
|
||||
|
||||
The app has `react-scan` configured with `report: true` in dev mode (`src/lib/react-scan.ts`). It exposes `window.__getReactScanReport()` which returns per-component render counts and times: `{ ComponentName: { count, time } }`.
|
||||
`src/lib/react-scan.ts` runs react-scan in dev mode and accumulates render data via its `onRender` option. It exposes `window.__getReactScanReport()`, which returns a plain, JSON-serializable object of per-component render counts and times: `{ ComponentName: { count, time } }`, plus `window.__resetReactScanReport()` to zero it between phases.
|
||||
|
||||
Do NOT call react-scan's own `getReport()` — in 0.5.3 it reads a Map that is never written to, and a `Map` stringifies to `"{}"` anyway.
|
||||
|
||||
The profiler's `addInitScript` also intercepts `__REACT_DEVTOOLS_GLOBAL_HOOK__` to count React commits independently (works even if react-scan is not loaded).
|
||||
|
||||
@@ -163,7 +165,7 @@ Routes profiled: /route1, /route2, ...
|
||||
- Always use the `-s=SESSION` flag on every playwright-cli command
|
||||
- Replace `SESSION` and `ROUTE` placeholders with actual values
|
||||
- **Collect per-route data before navigating to the next route** — goto resets the document
|
||||
- If `__getReactScanReport` returns null, note "react-scan report unavailable" and rely on commit counts
|
||||
- If `__getReactScanReport` is undefined or returns `{}`, wait ~1s and retry once (it is a dynamic import); if still empty, note "react-scan report unavailable" and rely on commit counts
|
||||
- If a route has no content or fails to load, note it in Info and move on
|
||||
- **Always stop tracing and close the browser when done, even on errors** — wrap your workflow in a try/finally mindset: if any step fails, still run `tracing-stop` and `close`
|
||||
- Board codes (`biz`, `pol`, `g`, etc.) map to community addresses via the app's directory
|
||||
|
||||
@@ -16,10 +16,14 @@ Two-layer profiling: browser-level symptoms (Web Vitals, long tasks, scroll jank
|
||||
|
||||
### react-scan (already configured)
|
||||
|
||||
The app has `react-scan` set up in `src/lib/react-scan.ts` with `report: true`. In dev mode it:
|
||||
`src/lib/react-scan.ts` runs react-scan in dev mode. It:
|
||||
- Highlights rerendering components visually (toolbar + overlay)
|
||||
- Tracks per-component render counts and times internally
|
||||
- Exposes `window.__getReactScanReport()` for programmatic collection
|
||||
- Accumulates per-component render counts and times via react-scan's `onRender` option
|
||||
- Exposes `window.__getReactScanReport()` and `window.__resetReactScanReport()` for programmatic collection
|
||||
|
||||
`__getReactScanReport()` returns a plain object: `{ ComponentName: { count, time } }`.
|
||||
|
||||
**Do not use react-scan's own `getReport()`.** It reads `Store.legacyReportData`, which react-scan 0.5.3 never writes to, so it always returns an empty `Map`. The live `Store.reportData` is no better: it is only populated while the toolbar is visible *and* a component is manually focused in the inspector, neither of which holds under automation. The app's `onRender` collector exists precisely because of this. Also note a `Map` cannot be serialized — `JSON.stringify(new Map())` is `"{}"` regardless of contents — which is why the collector returns a plain object.
|
||||
|
||||
The profiler's `addInitScript` sets `window.__PROFILING__ = true` before the app loads, which tells react-scan to disable its toolbar and sounds during automated runs.
|
||||
|
||||
@@ -160,4 +164,4 @@ playwright-cli -s=prof-3 close 2>/dev/null
|
||||
- **addInitScript persistence**: Instrumentation re-injects automatically in each new document.
|
||||
- **Tracing**: Each subagent produces a `trace.zip` viewable in [Trace Viewer](https://trace.playwright.dev).
|
||||
- **Board codes**: `biz`, `pol`, `g`, `a`, `v`, etc. map to community addresses via the directory.
|
||||
- **Without react-scan**: If `__getReactScanReport` returns null, the profiler falls back to commit counts + render bursts (still useful, just no component names).
|
||||
- **Empty react-scan report**: react-scan is a dynamic import, so `__getReactScanReport()` returns `{}` for the first moment after a `goto`. If it is empty, wait ~1s and re-read before falling back to commit counts + render bursts (still useful, just no component names).
|
||||
|
||||
@@ -16,10 +16,14 @@ Two-layer profiling: browser-level symptoms (Web Vitals, long tasks, scroll jank
|
||||
|
||||
### react-scan (already configured)
|
||||
|
||||
The app has `react-scan` set up in `src/lib/react-scan.ts` with `report: true`. In dev mode it:
|
||||
`src/lib/react-scan.ts` runs react-scan in dev mode. It:
|
||||
- Highlights rerendering components visually (toolbar + overlay)
|
||||
- Tracks per-component render counts and times internally
|
||||
- Exposes `window.__getReactScanReport()` for programmatic collection
|
||||
- Accumulates per-component render counts and times via react-scan's `onRender` option
|
||||
- Exposes `window.__getReactScanReport()` and `window.__resetReactScanReport()` for programmatic collection
|
||||
|
||||
`__getReactScanReport()` returns a plain object: `{ ComponentName: { count, time } }`.
|
||||
|
||||
**Do not use react-scan's own `getReport()`.** It reads `Store.legacyReportData`, which react-scan 0.5.3 never writes to, so it always returns an empty `Map`. The live `Store.reportData` is no better: it is only populated while the toolbar is visible *and* a component is manually focused in the inspector, neither of which holds under automation. The app's `onRender` collector exists precisely because of this. Also note a `Map` cannot be serialized — `JSON.stringify(new Map())` is `"{}"` regardless of contents — which is why the collector returns a plain object.
|
||||
|
||||
The profiler's `addInitScript` sets `window.__PROFILING__ = true` before the app loads, which tells react-scan to disable its toolbar and sounds during automated runs.
|
||||
|
||||
@@ -160,4 +164,4 @@ playwright-cli -s=prof-3 close 2>/dev/null
|
||||
- **addInitScript persistence**: Instrumentation re-injects automatically in each new document.
|
||||
- **Tracing**: Each subagent produces a `trace.zip` viewable in [Trace Viewer](https://trace.playwright.dev).
|
||||
- **Board codes**: `biz`, `pol`, `g`, `a`, `v`, etc. map to community addresses via the directory.
|
||||
- **Without react-scan**: If `__getReactScanReport` returns null, the profiler falls back to commit counts + render bursts (still useful, just no component names).
|
||||
- **Empty react-scan report**: react-scan is a dynamic import, so `__getReactScanReport()` returns `{}` for the first moment after a `goto`. If it is empty, wait ~1s and re-read before falling back to commit counts + render bursts (still useful, just no component names).
|
||||
|
||||
@@ -17,7 +17,9 @@ You receive from the parent agent:
|
||||
|
||||
## How It Works
|
||||
|
||||
The app has `react-scan` configured with `report: true` in dev mode (`src/lib/react-scan.ts`). It exposes `window.__getReactScanReport()` which returns per-component render counts and times: `{ ComponentName: { count, time } }`.
|
||||
`src/lib/react-scan.ts` runs react-scan in dev mode and accumulates render data via its `onRender` option. It exposes `window.__getReactScanReport()`, which returns a plain, JSON-serializable object of per-component render counts and times: `{ ComponentName: { count, time } }`, plus `window.__resetReactScanReport()` to zero it between phases.
|
||||
|
||||
Do NOT call react-scan's own `getReport()` — in 0.5.3 it reads a Map that is never written to, and a `Map` stringifies to `"{}"` anyway.
|
||||
|
||||
The profiler's `addInitScript` also intercepts `__REACT_DEVTOOLS_GLOBAL_HOOK__` to count React commits independently (works even if react-scan is not loaded).
|
||||
|
||||
@@ -163,7 +165,7 @@ Routes profiled: /route1, /route2, ...
|
||||
- Always use the `-s=SESSION` flag on every playwright-cli command
|
||||
- Replace `SESSION` and `ROUTE` placeholders with actual values
|
||||
- **Collect per-route data before navigating to the next route** — goto resets the document
|
||||
- If `__getReactScanReport` returns null, note "react-scan report unavailable" and rely on commit counts
|
||||
- If `__getReactScanReport` is undefined or returns `{}`, wait ~1s and retry once (it is a dynamic import); if still empty, note "react-scan report unavailable" and rely on commit counts
|
||||
- If a route has no content or fails to load, note it in Info and move on
|
||||
- **Always stop tracing and close the browser when done, even on errors** — wrap your workflow in a try/finally mindset: if any step fails, still run `tracing-stop` and `close`
|
||||
- Board codes (`biz`, `pol`, `g`, etc.) map to community addresses via the app's directory
|
||||
|
||||
@@ -16,10 +16,14 @@ Two-layer profiling: browser-level symptoms (Web Vitals, long tasks, scroll jank
|
||||
|
||||
### react-scan (already configured)
|
||||
|
||||
The app has `react-scan` set up in `src/lib/react-scan.ts` with `report: true`. In dev mode it:
|
||||
`src/lib/react-scan.ts` runs react-scan in dev mode. It:
|
||||
- Highlights rerendering components visually (toolbar + overlay)
|
||||
- Tracks per-component render counts and times internally
|
||||
- Exposes `window.__getReactScanReport()` for programmatic collection
|
||||
- Accumulates per-component render counts and times via react-scan's `onRender` option
|
||||
- Exposes `window.__getReactScanReport()` and `window.__resetReactScanReport()` for programmatic collection
|
||||
|
||||
`__getReactScanReport()` returns a plain object: `{ ComponentName: { count, time } }`.
|
||||
|
||||
**Do not use react-scan's own `getReport()`.** It reads `Store.legacyReportData`, which react-scan 0.5.3 never writes to, so it always returns an empty `Map`. The live `Store.reportData` is no better: it is only populated while the toolbar is visible *and* a component is manually focused in the inspector, neither of which holds under automation. The app's `onRender` collector exists precisely because of this. Also note a `Map` cannot be serialized — `JSON.stringify(new Map())` is `"{}"` regardless of contents — which is why the collector returns a plain object.
|
||||
|
||||
The profiler's `addInitScript` sets `window.__PROFILING__ = true` before the app loads, which tells react-scan to disable its toolbar and sounds during automated runs.
|
||||
|
||||
@@ -160,4 +164,4 @@ playwright-cli -s=prof-3 close 2>/dev/null
|
||||
- **addInitScript persistence**: Instrumentation re-injects automatically in each new document.
|
||||
- **Tracing**: Each subagent produces a `trace.zip` viewable in [Trace Viewer](https://trace.playwright.dev).
|
||||
- **Board codes**: `biz`, `pol`, `g`, `a`, `v`, etc. map to community addresses via the directory.
|
||||
- **Without react-scan**: If `__getReactScanReport` returns null, the profiler falls back to commit counts + render bursts (still useful, just no component names).
|
||||
- **Empty react-scan report**: react-scan is a dynamic import, so `__getReactScanReport()` returns `{}` for the first moment after a `goto`. If it is empty, wait ~1s and re-read before falling back to commit counts + render bursts (still useful, just no component names).
|
||||
|
||||
@@ -158,3 +158,13 @@ If uncertain, ask the developer before adding an entry.
|
||||
- **Impact:** Notarization aborts after a successful signing pass; the error message looks like a signing failure and invites debugging the certificate/keychain instead of the real cause. Any tool that shells out to `codesign` with a bare relative path can hit this because the app is literally named `5chan`.
|
||||
- **Mitigation:** Keep the yarn patch `.yarn/patches/@electron-notarize-npm-2.5.0-*.patch` (backport of electron/notarize#245, prefixes the basename with `./`) until electron-forge depends on `@electron/notarize` >= 3.x. When invoking `codesign` manually on the app bundle, always use an absolute or `./`-prefixed path.
|
||||
- **Status:** confirmed
|
||||
|
||||
### react-scan's `getReport()` is dead API and can never return data
|
||||
|
||||
- **Date:** 2026-07-27
|
||||
- **Observed by:** contributor + Claude
|
||||
- **Context:** Running the `profile-browsing` skill against a branch to measure excessive rerenders, and getting no component data back
|
||||
- **What was surprising:** Three independent failures stacked up silently. (1) `getReport()` returns `Store.legacyReportData`, which react-scan 0.5.3 initializes as an empty `Map` and never writes to anywhere in the bundle. (2) The live `Store.reportData` is only populated inside `if (options.showToolbar !== false && Store.inspectState.value.kind === 'focused')` — but the profiler sets `__PROFILING__=true`, which sets `showToolbar: false`, and `'focused'` requires a human clicking the inspector onto one component, so it is unreachable under automation. (3) `getReport()` returns a `Map`, and `JSON.stringify(new Map())` is `"{}"` regardless of contents, so the skill's collection line would have printed `{}` even if data existed. The skill and profiler agent additionally claimed the app was configured with `report: true`; react-scan 0.5.3 has no `report` option at all, and passing one logs `[React Scan] Invalid options: - Unknown option "report"`.
|
||||
- **Impact:** Every profiling run reported zero react-scan component data without erroring, so rerender hotspots looked invisible and profiling silently degraded to raw commit counts.
|
||||
- **Mitigation:** `src/lib/react-scan.ts` now accumulates render data through react-scan's `onRender` option, which is only skipped when `isPaused && inspectorInactive` (verified `isPaused: false` with the toolbar off). It exposes `window.__getReactScanReport()` returning a plain, JSON-serializable object and `window.__resetReactScanReport()`. Never reintroduce `getReport()`, and never `JSON.stringify` a `Map`. Set `window.__PROFILING_UNNECESSARY__ = true` to opt into `trackUnnecessaryRenders`; it is off by default because it adds overhead that skews the `time` field.
|
||||
- **Status:** confirmed
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"task": "excessive-rerenders",
|
||||
"last_updated": "2026-07-30",
|
||||
"items": [
|
||||
{
|
||||
"id": "F001",
|
||||
"priority": 1,
|
||||
"status": "verified",
|
||||
"description": "Add serializable React profiling and establish route baselines for /biz, /all, /all/catalog, and the homepage.",
|
||||
"verification": [
|
||||
"Profile fresh isolated Chromium sessions with window.__getReactScanReport().",
|
||||
"Confirm the branch-scoped Portless URL is served by exactly one dev server."
|
||||
],
|
||||
"files": [
|
||||
"src/lib/react-scan.ts"
|
||||
],
|
||||
"notes": "Committed as 0f0b6118c. Baselines captured before optimization."
|
||||
},
|
||||
{
|
||||
"id": "F002",
|
||||
"priority": 2,
|
||||
"status": "verified",
|
||||
"description": "Narrow routing, directory, feed, post, shell, and homepage state subscriptions so unchanged lifecycle ticks do not rerender unrelated UI.",
|
||||
"verification": [
|
||||
"yarn test",
|
||||
"yarn lint",
|
||||
"yarn type-check",
|
||||
"yarn build",
|
||||
"yarn doctor --scope changed --base master"
|
||||
],
|
||||
"files": [],
|
||||
"notes": "Implemented incrementally in commits 912e1bf00 through 066e64a7d."
|
||||
},
|
||||
{
|
||||
"id": "F003",
|
||||
"priority": 3,
|
||||
"status": "verified",
|
||||
"description": "Derive multi-community loading text from primitive store selectors so hidden and visible feed footers stop rerendering on unchanged state.",
|
||||
"verification": [
|
||||
"yarn test",
|
||||
"yarn lint",
|
||||
"yarn type-check",
|
||||
"yarn build",
|
||||
"Fresh /all profile reaches the first post and records an untouched idle sample.",
|
||||
"Fresh /all/catalog plus back/forward checks preserve ordering, anchoring, scroll restoration, and mobile width."
|
||||
],
|
||||
"files": [
|
||||
"src/hooks/use-state-string.ts",
|
||||
"src/hooks/__tests__/use-state-string.test.tsx",
|
||||
"src/lib/bitsocial-internals/utils.ts"
|
||||
],
|
||||
"notes": "Committed as a72054e5d. /all first post improved from 42.21s to 3.88s; cached /all idle improved from 260 commits/5.03s to zero."
|
||||
},
|
||||
{
|
||||
"id": "F004",
|
||||
"priority": 4,
|
||||
"status": "verified",
|
||||
"description": "Stop resolved homepage community-stat collectors from subscribing to full community lifecycle objects while preserving stat refresh when statsCid changes.",
|
||||
"verification": [
|
||||
"Add focused collector lifecycle regression tests.",
|
||||
"Profile the homepage until community stats have loaded, then collect an untouched idle sample.",
|
||||
"Run yarn test, yarn lint, yarn type-check, yarn build, and scoped React Doctor."
|
||||
],
|
||||
"files": [
|
||||
"src/hooks/use-communities-stats.ts",
|
||||
"src/hooks/__tests__/use-communities-stats.test.ts"
|
||||
],
|
||||
"notes": "Committed as d6f2fa8b6. With 64 boards displayed, the loaded homepage improved from 235-260 commits per two seconds to zero commits across five seconds."
|
||||
},
|
||||
{
|
||||
"id": "F005",
|
||||
"priority": 5,
|
||||
"status": "verified",
|
||||
"description": "Run final advisory reviews, low-spec Chromium verification, cross-browser UI checks, and summarize remaining non-regression performance opportunities.",
|
||||
"verification": [
|
||||
"Run both React best-practice reviews and the effect review for new hook usage.",
|
||||
"Run the code-quality-review skill.",
|
||||
"Run a throttled Chromium pass plus desktop and mobile Firefox/WebKit verification.",
|
||||
"Confirm a clean worktree and incremental local commits."
|
||||
],
|
||||
"files": [],
|
||||
"notes": "React and effect reviews found no new issue. Code-quality review found no high-confidence advisory finding. Mid-tier throttled Chromium plus current Firefox/WebKit desktop and mobile checks passed without an app exception or horizontal overflow."
|
||||
},
|
||||
{
|
||||
"id": "F006",
|
||||
"priority": 6,
|
||||
"status": "verified",
|
||||
"description": "Reduce homepage acquisition-phase React commits while decentralized community stats resolve, without changing the displayed results.",
|
||||
"verification": [
|
||||
"Capture a fresh homepage acquisition baseline with component and commit timings.",
|
||||
"Add focused regression tests for any subscription or batching change.",
|
||||
"Reprofile through the loaded-board condition, compare the first twenty seconds, and record an untouched idle sample."
|
||||
],
|
||||
"files": [
|
||||
"src/hooks/use-communities-stats.ts",
|
||||
"src/hooks/__tests__/use-communities-stats.test.ts",
|
||||
"src/views/home/home.tsx",
|
||||
"src/views/home/__tests__/home.test.tsx"
|
||||
],
|
||||
"notes": "Committed as 28fda1ce3. First-twenty-second commits improved from 1,211 to 862 (-29%), and CommunityStatsRequest renders from 1,569 to 491 (-69%). All 64 boards resolved at 21.06s, about one second after the baseline run, then the next five seconds recorded zero commits."
|
||||
},
|
||||
{
|
||||
"id": "F007",
|
||||
"priority": 7,
|
||||
"status": "verified",
|
||||
"description": "Measure representative production cold startup under constrained CPU and network, then reduce only verified application-owned bottlenecks.",
|
||||
"verification": [
|
||||
"Separate Vite development-module overhead from a production build served locally.",
|
||||
"Record production navigation, LCP, long-task, transfer, and bundle evidence under the mid-tier throttle.",
|
||||
"Rebuild and remeasure after any retained optimization."
|
||||
],
|
||||
"files": [
|
||||
"vite.config.js"
|
||||
],
|
||||
"notes": "Committed as 0de61ac56. In a Brotli production build with the service worker blocked for a controlled A/B, first content improved from 8.31s to 4.66s, LCP from 8.37s to 4.72s, DOMContentLoaded from 4.97s to 1.07s, and long-task time from 1.85s to 1.51s. The PWA precache fell from 4.06 MiB to 439.45 KiB. Production Chrome, Firefox, and WebKit retained the full board list, active stats resolution, mobile width, and no app error boundary."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# Progress Log
|
||||
|
||||
## 2026-07-30 00:04 HKT
|
||||
|
||||
- Item: F003
|
||||
- Summary: Resumed after the permission interruption and completed controlled post-fix `/all` and `/all/catalog` profiling. The selector-scoped loading text fix removes the hidden and visible footer loops while preserving feed ordering, scroll anchoring, exact back/forward restoration, mobile width, and search interaction.
|
||||
- Files: `src/hooks/use-state-string.ts`, `src/hooks/__tests__/use-state-string.test.tsx`, `src/lib/bitsocial-internals/utils.ts`
|
||||
- Verification: `./scripts/agent-init.sh --smoke`; fresh Chromium `/all` load and five-second idle profile; `/all` ordering and 15-second anchor check; fresh `/all/catalog` idle and cached back/forward profile; desktop and mobile Firefox/WebKit smoke checks
|
||||
- Blockers: none
|
||||
- Next: Complete F004 by making homepage community-stat collectors dormant after resolving the current `statsCid`, then reprofile the loaded homepage.
|
||||
|
||||
## 2026-07-30 00:17 HKT
|
||||
|
||||
- Item: F004, F005
|
||||
- Summary: Split homepage stat collection into a CID-selecting wrapper and a request component that unmounts after the current stats CID resolves. A changed CID remounts the request and ignores the previous CID's cached result until the data hook publishes a fresh object. The 64-board homepage is now fully idle instead of continuously committing collector updates.
|
||||
- Files: `src/hooks/use-communities-stats.ts`, `src/hooks/__tests__/use-communities-stats.test.ts`
|
||||
- Verification: `corepack yarn test --configLoader runner`; `corepack yarn lint`; `corepack yarn type-check`; `corepack yarn build`; `corepack yarn doctor --scope changed --base master`; React best-practice and effect reviews; code-quality review; fresh Chromium homepage profiling; mid-tier throttled Chromium homepage and `/all`; desktop and 390px mobile Firefox/WebKit homepage smoke checks
|
||||
- Blockers: none
|
||||
- Next: Keep acquisition-phase decentralized provider work and cold bundle loading as separate follow-up opportunities; this task's excessive steady-state rerender loops are resolved.
|
||||
|
||||
## 2026-07-30 01:06 HKT
|
||||
|
||||
- Item: F006
|
||||
- Summary: Added one shared homepage metadata loader and held each stats request until its stable stats CID exists. This removes the pre-CID request lifecycles and key-driven restarts while preserving refreshes when a CID changes.
|
||||
- Files: `src/hooks/use-communities-stats.ts`, `src/hooks/__tests__/use-communities-stats.test.ts`, `src/views/home/home.tsx`, `src/views/home/__tests__/home.test.tsx`
|
||||
- Verification: focused tests; full 1,374-test suite; lint; type-check; build; scoped React Doctor; React best-practice, effect, and code-quality reviews; fresh acquisition profiles; desktop and mobile Chrome, Firefox, and WebKit homepage checks
|
||||
- Result: First-twenty-second commits improved from 1,211 to 862 (-29%), CommunityStatsRequest renders improved from 1,569 to 491 (-69%), all 64 boards resolved at 21.06s, and the following five seconds were commit-free.
|
||||
- Blockers: none
|
||||
- Next: Establish a production-build cold-start baseline before changing startup code.
|
||||
|
||||
## 2026-07-30 01:06 HKT
|
||||
|
||||
- Item: F007
|
||||
- Summary: Removed the forced whole-package protocol chunk so the browser can render the homepage from the smaller hook/core graph and load deeper decentralized protocol modules on demand.
|
||||
- Files: `vite.config.js`
|
||||
- Verification: controlled Brotli production-build A/B with service workers blocked and identical 4x CPU plus mid-tier network throttling; final production build; lint; type-check; production desktop and mobile checks in Chrome, Firefox, and WebKit
|
||||
- Result: First content improved from 8.31s to 4.66s, LCP from 8.37s to 4.72s, DOMContentLoaded from 4.97s to 1.07s, load from 5.63s to 1.83s, long-task time from 1.85s to 1.51s, and PWA precache from 4.06 MiB to 439.45 KiB. The full board list remains present while decentralized stats continue resolving.
|
||||
- Blockers: none
|
||||
- Next: None; F001-F007 are verified.
|
||||
@@ -974,6 +974,16 @@ If uncertain, ask the developer before adding an entry.
|
||||
- **Impact:** Notarization aborts after a successful signing pass; the error message looks like a signing failure and invites debugging the certificate/keychain instead of the real cause. Any tool that shells out to `codesign` with a bare relative path can hit this because the app is literally named `5chan`.
|
||||
- **Mitigation:** Keep the yarn patch `.yarn/patches/@electron-notarize-npm-2.5.0-*.patch` (backport of electron/notarize#245, prefixes the basename with `./`) until electron-forge depends on `@electron/notarize` >= 3.x. When invoking `codesign` manually on the app bundle, always use an absolute or `./`-prefixed path.
|
||||
- **Status:** confirmed
|
||||
|
||||
### react-scan's `getReport()` is dead API and can never return data
|
||||
|
||||
- **Date:** 2026-07-27
|
||||
- **Observed by:** contributor + Claude
|
||||
- **Context:** Running the `profile-browsing` skill against a branch to measure excessive rerenders, and getting no component data back
|
||||
- **What was surprising:** Three independent failures stacked up silently. (1) `getReport()` returns `Store.legacyReportData`, which react-scan 0.5.3 initializes as an empty `Map` and never writes to anywhere in the bundle. (2) The live `Store.reportData` is only populated inside `if (options.showToolbar !== false && Store.inspectState.value.kind === 'focused')` — but the profiler sets `__PROFILING__=true`, which sets `showToolbar: false`, and `'focused'` requires a human clicking the inspector onto one component, so it is unreachable under automation. (3) `getReport()` returns a `Map`, and `JSON.stringify(new Map())` is `"{}"` regardless of contents, so the skill's collection line would have printed `{}` even if data existed. The skill and profiler agent additionally claimed the app was configured with `report: true`; react-scan 0.5.3 has no `report` option at all, and passing one logs `[React Scan] Invalid options: - Unknown option "report"`.
|
||||
- **Impact:** Every profiling run reported zero react-scan component data without erroring, so rerender hotspots looked invisible and profiling silently degraded to raw commit counts.
|
||||
- **Mitigation:** `src/lib/react-scan.ts` now accumulates render data through react-scan's `onRender` option, which is only skipped when `isPaused && inspectorInactive` (verified `isPaused: false` with the toolbar off). It exposes `window.__getReactScanReport()` returning a plain, JSON-serializable object and `window.__resetReactScanReport()`. Never reintroduce `getReport()`, and never `JSON.stringify` a `Map`. Set `window.__PROFILING_UNNECESSARY__ = true` to opt into `trackUnnecessaryRenders`; it is off by default because it adds overhead that skews the `time` field.
|
||||
- **Status:** confirmed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
||||
import { useAccountComment, useCommunity } from '@bitsocial/bitsocial-react-hooks';
|
||||
@@ -44,7 +44,8 @@ const OfflineIndicator = ({ communityAddress }: { communityAddress: string | und
|
||||
);
|
||||
};
|
||||
|
||||
const BoardHeader = () => {
|
||||
// No props, so parent rerenders never change its output.
|
||||
const BoardHeader = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
@@ -136,6 +137,7 @@ const BoardHeader = () => {
|
||||
{!isInArchiveView && <hr />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
BoardHeader.displayName = 'BoardHeader';
|
||||
|
||||
export default BoardHeader;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAccountComment } from '@bitsocial/bitsocial-react-hooks';
|
||||
@@ -96,7 +96,9 @@ const findBoardAddressByCode = (code: string, directories: DirectoryCommunity[])
|
||||
return entry?.address || null;
|
||||
};
|
||||
|
||||
const BoardsBarDesktop = () => {
|
||||
// Takes no props and renders one Link per directory board, so a parent rerender is pure
|
||||
// waste: it was re-rendering ~80 Links on every store notification.
|
||||
const BoardsBarDesktop = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
@@ -305,9 +307,10 @@ const BoardsBarDesktop = () => {
|
||||
{showSearchBar && <SearchBar setShowSearchBar={setShowSearchBar} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
BoardsBarDesktop.displayName = 'BoardsBarDesktop';
|
||||
|
||||
const BoardsBarMobile = ({ communityAddress }: { communityAddress?: string }) => {
|
||||
const BoardsBarMobile = memo(({ communityAddress }: { communityAddress?: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const directories = useDirectories();
|
||||
@@ -415,9 +418,10 @@ const BoardsBarMobile = ({ communityAddress }: { communityAddress?: string }) =>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
BoardsBarMobile.displayName = 'BoardsBarMobile';
|
||||
|
||||
const BoardsBar = () => {
|
||||
const BoardsBar = memo(() => {
|
||||
const params = useParams();
|
||||
const accountComment = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) });
|
||||
const resolvedCommunityAddress = useResolvedCommunityAddress();
|
||||
@@ -429,6 +433,7 @@ const BoardsBar = () => {
|
||||
<BoardsBarMobile communityAddress={communityAddress} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
BoardsBar.displayName = 'BoardsBar';
|
||||
|
||||
export default BoardsBar;
|
||||
|
||||
@@ -67,7 +67,7 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight,
|
||||
};
|
||||
const loadingStyle = { opacity: isLoaded ? 1 : 0 };
|
||||
|
||||
const { imageSize } = useCatalogStyleStore();
|
||||
const imageSize = useCatalogStyleStore((state) => state.imageSize);
|
||||
|
||||
let displayWidth, displayHeight;
|
||||
const maxThumbnailSize = imageSize === 'Large' ? 250 : 150;
|
||||
@@ -248,7 +248,8 @@ const CatalogPost = memo(
|
||||
</div>
|
||||
);
|
||||
|
||||
const { imageSize, showOPComment } = useCatalogStyleStore();
|
||||
const imageSize = useCatalogStyleStore((state) => state.imageSize);
|
||||
const showOPComment = useCatalogStyleStore((state) => state.showOPComment);
|
||||
const maxWidth = imageSize === 'Large' ? '250px' : '150px';
|
||||
const maxHeight = imageSize === 'Large' ? '250px' : '150px';
|
||||
const CSSProperties = {
|
||||
|
||||
@@ -81,6 +81,22 @@ const RepliesFooter = ({ hasMore, loadingString }: { hasMore: boolean; loadingSt
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// Owns the `useStateString` subscription so PostDesktop does not, matching the BoardFooter
|
||||
// pattern in board.tsx. That subscription follows client IPFS states and ticks continuously while
|
||||
// a board downloads; keeping it in PostDesktop rerendered every row in the feed (and its whole
|
||||
// child tree) on every tick — to build a string only ever rendered in post-page view.
|
||||
const PostLoadingState = ({ post }: { post: Comment | undefined }) => {
|
||||
const { t } = useTranslation();
|
||||
const stateString = useStateString(post) || t('downloading_board');
|
||||
|
||||
return (
|
||||
<div className={styles.stateString}>
|
||||
<br />
|
||||
<LoadingEllipsis string={stateString} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Store scroll position for replies virtuoso across navigations
|
||||
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
||||
|
||||
@@ -222,7 +238,9 @@ const PostInfo = ({
|
||||
|
||||
const { hidden } = useHide({ cid: cid || '' });
|
||||
|
||||
const { openReplyModal } = useReplyModalStore();
|
||||
// Selector-scoped: every post row mounts this, so a full-store subscription rerendered
|
||||
// the whole feed whenever any reply-modal field changed.
|
||||
const openReplyModal = useReplyModalStore((state) => state.openReplyModal);
|
||||
|
||||
const onReplyModalClick = () => {
|
||||
if (deleted) {
|
||||
@@ -917,7 +935,6 @@ const PostDesktop = ({
|
||||
});
|
||||
const linksCount = totalLinksCount - visiblelinksCount;
|
||||
|
||||
const stateString = useStateString(resolvedPost) || t('downloading_board');
|
||||
const hasFailedState = state === 'failed';
|
||||
const { canDeleteFailedPost, canRetryFailedPost, isDeletingFailedPost, isRetryingFailedPost, onDeleteFailedPost, onRetryFailedPost } = useDeleteFailedPost(
|
||||
resolvedPost,
|
||||
@@ -1276,16 +1293,12 @@ const PostDesktop = ({
|
||||
)}
|
||||
</div>
|
||||
{!isInPendingPostView &&
|
||||
stateString &&
|
||||
!hasFailedState &&
|
||||
state !== 'succeeded' &&
|
||||
!shouldSuppressPostLoadingState(resolvedPost) &&
|
||||
isInPostPageView &&
|
||||
!(!showReplies && !showAllReplies) ? (
|
||||
<div className={styles.stateString}>
|
||||
<br />
|
||||
<LoadingEllipsis string={stateString} />
|
||||
</div>
|
||||
<PostLoadingState post={resolvedPost} />
|
||||
) : (
|
||||
hasFailedState && <span className={styles.error}>{t('failed')}</span>
|
||||
)}
|
||||
|
||||
@@ -171,7 +171,8 @@ const PostInfoAndMedia = ({
|
||||
|
||||
const { hidden } = useHide({ cid: cid || '' });
|
||||
|
||||
const { openReplyModal } = useReplyModalStore();
|
||||
// Selector-scoped: see post-desktop.tsx.
|
||||
const openReplyModal = useReplyModalStore((state) => state.openReplyModal);
|
||||
|
||||
const onReplyModalClick = () => {
|
||||
if (deleted) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Version from '../version/version';
|
||||
@@ -10,7 +11,7 @@ type SiteLegalMetaProps = {
|
||||
order?: SiteLegalMetaOrder;
|
||||
};
|
||||
|
||||
const LicenseText = () => {
|
||||
const LicenseText = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -21,9 +22,10 @@ const LicenseText = () => {
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
});
|
||||
LicenseText.displayName = 'LicenseText';
|
||||
|
||||
const VersionFeedbackContributors = () => {
|
||||
const VersionFeedbackContributors = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -34,9 +36,10 @@ const VersionFeedbackContributors = () => {
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
VersionFeedbackContributors.displayName = 'VersionFeedbackContributors';
|
||||
|
||||
const SiteLegalMeta = ({ order = 'version-first' }: SiteLegalMetaProps) => {
|
||||
const SiteLegalMeta = memo(({ order = 'version-first' }: SiteLegalMetaProps) => {
|
||||
const first = order === 'version-first' ? <VersionFeedbackContributors /> : <LicenseText />;
|
||||
const second = order === 'version-first' ? <LicenseText /> : <VersionFeedbackContributors />;
|
||||
|
||||
@@ -60,6 +63,7 @@ const SiteLegalMeta = ({ order = 'version-first' }: SiteLegalMetaProps) => {
|
||||
<span style={{ display: 'block', marginTop: 5 }}>{second}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
SiteLegalMeta.displayName = 'SiteLegalMeta';
|
||||
|
||||
export default SiteLegalMeta;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { currentAppVersion } from '../../lib/app-version';
|
||||
|
||||
@@ -5,7 +6,7 @@ const commitRef = `${import.meta.env.VITE_COMMIT_REF || ''}`.trim();
|
||||
const shortCommitRef = commitRef.slice(0, 7);
|
||||
const isElectron = window.electronApi?.isElectron === true;
|
||||
|
||||
const Version = () => {
|
||||
const Version = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
@@ -34,6 +35,7 @@ const Version = () => {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
Version.displayName = 'Version';
|
||||
|
||||
export default Version;
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { communitiesStore } from '../../lib/bitsocial-internals/stores';
|
||||
import { CommunityStatsCollector, CommunityStatsMetadataLoader, useCommunitiesStatsStore } from '../use-communities-stats';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
const statsHookState = vi.hoisted(() => ({
|
||||
calls: 0,
|
||||
communitiesCalls: 0,
|
||||
listeners: new Set<() => void>(),
|
||||
requestedCommunities: [] as Array<{ name?: string; publicKey?: string }>,
|
||||
snapshot: { state: 'uninitialized' } as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocial/bitsocial-react-hooks', async () => {
|
||||
const ReactModule = await vi.importActual<typeof import('react')>('react');
|
||||
return {
|
||||
useCommunities: ({ communities = [] }: { communities?: Array<{ name?: string; publicKey?: string }> }) => {
|
||||
statsHookState.communitiesCalls++;
|
||||
statsHookState.requestedCommunities = communities;
|
||||
return { communities: [], state: communities.length > 0 ? 'fetching-ipns' : 'uninitialized' };
|
||||
},
|
||||
useCommunityStats: () => {
|
||||
statsHookState.calls++;
|
||||
return ReactModule.useSyncExternalStore(
|
||||
(listener) => {
|
||||
statsHookState.listeners.add(listener);
|
||||
return () => statsHookState.listeners.delete(listener);
|
||||
},
|
||||
() => statsHookState.snapshot,
|
||||
() => statsHookState.snapshot,
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../use-community-identifiers', () => ({
|
||||
useCommunityIdentifier: (communityAddress: string) => ({
|
||||
name: communityAddress,
|
||||
publicKey: `${communityAddress}-key`,
|
||||
}),
|
||||
useCommunityIdentifiers: (communityAddresses: string[]) =>
|
||||
communityAddresses.map((communityAddress) => ({
|
||||
name: communityAddress,
|
||||
publicKey: `${communityAddress}-key`,
|
||||
})),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const setStatsSnapshot = (snapshot: Record<string, unknown>) => {
|
||||
act(() => {
|
||||
statsHookState.snapshot = snapshot;
|
||||
for (const listener of statsHookState.listeners) {
|
||||
listener();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
describe('useCommunitiesStatsStore', () => {
|
||||
beforeEach(() => {
|
||||
useCommunitiesStatsStore.setState({ communityStats: {} });
|
||||
communitiesStore.setState({ communities: {} });
|
||||
statsHookState.calls = 0;
|
||||
statsHookState.communitiesCalls = 0;
|
||||
statsHookState.listeners.clear();
|
||||
statsHookState.requestedCommunities = [];
|
||||
statsHookState.snapshot = { state: 'uninitialized' };
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('does not notify subscribers when the displayed stats are unchanged', () => {
|
||||
const address = 'business-posting.bso';
|
||||
const firstStats = {
|
||||
allPostCount: 12,
|
||||
allReplyCount: 34,
|
||||
weekActiveUserCount: 5,
|
||||
state: 'succeeded',
|
||||
};
|
||||
|
||||
useCommunitiesStatsStore.getState().setCommunityStats(address, firstStats);
|
||||
const statsBeforeDuplicateWrite = useCommunitiesStatsStore.getState().communityStats;
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = useCommunitiesStatsStore.subscribe(listener);
|
||||
|
||||
useCommunitiesStatsStore.getState().setCommunityStats(address, { ...firstStats });
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(useCommunitiesStatsStore.getState().communityStats).toBe(statsBeforeDuplicateWrite);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('notifies subscribers when a displayed stat changes', () => {
|
||||
const address = 'business-posting.bso';
|
||||
const initialStats = {
|
||||
allPostCount: 12,
|
||||
allReplyCount: 34,
|
||||
weekActiveUserCount: 5,
|
||||
state: 'succeeded',
|
||||
};
|
||||
|
||||
useCommunitiesStatsStore.getState().setCommunityStats(address, initialStats);
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = useCommunitiesStatsStore.subscribe(listener);
|
||||
|
||||
useCommunitiesStatsStore.getState().setCommunityStats(address, {
|
||||
...initialStats,
|
||||
allReplyCount: 35,
|
||||
});
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(useCommunitiesStatsStore.getState().communityStats[address].allReplyCount).toBe(35);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('notifies subscribers when the source stats CID changes', () => {
|
||||
const address = 'business-posting.bso';
|
||||
const initialStats = {
|
||||
allPostCount: 12,
|
||||
allReplyCount: 34,
|
||||
weekActiveUserCount: 5,
|
||||
state: 'succeeded',
|
||||
sourceStatsCid: 'stats-cid-1',
|
||||
};
|
||||
|
||||
useCommunitiesStatsStore.getState().setCommunityStats(address, initialStats);
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = useCommunitiesStatsStore.subscribe(listener);
|
||||
|
||||
useCommunitiesStatsStore.getState().setCommunityStats(address, {
|
||||
...initialStats,
|
||||
sourceStatsCid: 'stats-cid-2',
|
||||
});
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(useCommunitiesStatsStore.getState().communityStats[address].sourceStatsCid).toBe('stats-cid-2');
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('waits for metadata to expose a stats CID before mounting a request', () => {
|
||||
const address = 'business-posting.bso';
|
||||
const communityKey = `${address}-key`;
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(CommunityStatsCollector, { communityAddress: address }));
|
||||
});
|
||||
expect(statsHookState.calls).toBe(0);
|
||||
|
||||
act(() => {
|
||||
communitiesStore.setState({
|
||||
communities: {
|
||||
[communityKey]: {
|
||||
address,
|
||||
updatingState: 'fetching-ipns',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(statsHookState.calls).toBe(0);
|
||||
|
||||
act(() => {
|
||||
communitiesStore.setState({
|
||||
communities: {
|
||||
[communityKey]: {
|
||||
address,
|
||||
statsCid: 'stats-cid-1',
|
||||
updatingState: 'succeeded',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(statsHookState.calls).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('loads only communities that have not exposed a stats CID', () => {
|
||||
const resolvedAddress = 'business-posting.bso';
|
||||
const pendingAddress = 'technology-posting.bso';
|
||||
communitiesStore.setState({
|
||||
communities: {
|
||||
[`${resolvedAddress}-key`]: {
|
||||
address: resolvedAddress,
|
||||
statsCid: 'stats-cid-1',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(CommunityStatsMetadataLoader, { communityAddresses: [resolvedAddress, pendingAddress] }));
|
||||
});
|
||||
|
||||
expect(statsHookState.requestedCommunities).toEqual([
|
||||
{
|
||||
name: pendingAddress,
|
||||
publicKey: `${pendingAddress}-key`,
|
||||
},
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
communitiesStore.setState({
|
||||
communities: {
|
||||
[`${resolvedAddress}-key`]: {
|
||||
address: resolvedAddress,
|
||||
statsCid: 'stats-cid-1',
|
||||
},
|
||||
[`${pendingAddress}-key`]: {
|
||||
address: pendingAddress,
|
||||
statsCid: 'stats-cid-2',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(statsHookState.requestedCommunities).toEqual([]);
|
||||
});
|
||||
|
||||
it('unmounts resolved requests and remounts only when statsCid changes', () => {
|
||||
const address = 'business-posting.bso';
|
||||
const communityKey = `${address}-key`;
|
||||
communitiesStore.setState({
|
||||
communities: {
|
||||
[communityKey]: {
|
||||
address,
|
||||
statsCid: 'stats-cid-1',
|
||||
updatingState: 'fetching-ipns',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(CommunityStatsCollector, { communityAddress: address }));
|
||||
});
|
||||
|
||||
expect(statsHookState.calls).toBeGreaterThan(0);
|
||||
|
||||
setStatsSnapshot({
|
||||
allPostCount: 12,
|
||||
allReplyCount: 34,
|
||||
weekActiveUserCount: 5,
|
||||
state: 'succeeded',
|
||||
});
|
||||
|
||||
expect(useCommunitiesStatsStore.getState().communityStats[address]).toMatchObject({
|
||||
allPostCount: 12,
|
||||
sourceStatsCid: 'stats-cid-1',
|
||||
});
|
||||
const callsAfterFirstResult = statsHookState.calls;
|
||||
|
||||
act(() => {
|
||||
communitiesStore.setState({
|
||||
communities: {
|
||||
[communityKey]: {
|
||||
address,
|
||||
statsCid: 'stats-cid-1',
|
||||
updatingState: 'succeeded',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(statsHookState.calls).toBe(callsAfterFirstResult);
|
||||
|
||||
act(() => {
|
||||
communitiesStore.setState({
|
||||
communities: {
|
||||
[communityKey]: {
|
||||
address,
|
||||
statsCid: 'stats-cid-2',
|
||||
updatingState: 'fetching-ipfs',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(statsHookState.calls).toBeGreaterThan(callsAfterFirstResult);
|
||||
expect(useCommunitiesStatsStore.getState().communityStats[address].sourceStatsCid).toBe('stats-cid-1');
|
||||
|
||||
setStatsSnapshot({
|
||||
allPostCount: 12,
|
||||
allReplyCount: 34,
|
||||
weekActiveUserCount: 5,
|
||||
state: 'succeeded',
|
||||
});
|
||||
|
||||
expect(useCommunitiesStatsStore.getState().communityStats[address].sourceStatsCid).toBe('stats-cid-2');
|
||||
const callsAfterSecondResult = statsHookState.calls;
|
||||
|
||||
act(() => {
|
||||
communitiesStore.setState({
|
||||
communities: {
|
||||
[communityKey]: {
|
||||
address,
|
||||
statsCid: 'stats-cid-2',
|
||||
updatingState: 'succeeded',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(statsHookState.calls).toBe(callsAfterSecondResult);
|
||||
});
|
||||
});
|
||||
@@ -38,11 +38,15 @@ vi.mock('react-i18next', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/use-community-offline-store', () => ({
|
||||
default: () => ({
|
||||
initializeCommunityOfflineState: testState.initializeMock,
|
||||
setCommunityOfflineState: testState.setOfflineStateMock,
|
||||
communityOfflineState: testState.communityOfflineState,
|
||||
}),
|
||||
// Mirrors zustand's selector API so the hook can subscribe to a single community's entry.
|
||||
default: (selector?: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
initializeCommunityOfflineState: testState.initializeMock,
|
||||
setCommunityOfflineState: testState.setOfflineStateMock,
|
||||
communityOfflineState: testState.communityOfflineState,
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/use-communities-loading-start-timestamps-store', () => ({
|
||||
|
||||
@@ -29,7 +29,8 @@ const testState = vi.hoisted(() => ({
|
||||
communities: {} as Record<string, { address?: string; name?: string; publicKey?: string; state?: string; updatedAt?: number }>,
|
||||
syncStatuses: {} as Record<string, { syncState: CommunitySyncState }>,
|
||||
candidatePublicKeys: {} as Record<string, string | undefined>,
|
||||
offlineSelections: [] as unknown[],
|
||||
communityStoreListeners: [] as Array<() => void>,
|
||||
offlineStoreListeners: [] as Array<() => void>,
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
@@ -53,21 +54,38 @@ vi.mock('../use-directory-list', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../stores/use-community-offline-store', () => ({
|
||||
default: <T,>(selector: (state: { communityOfflineState: typeof testState.offlineStates }) => T) => {
|
||||
const selected = selector({ communityOfflineState: testState.offlineStates });
|
||||
testState.offlineSelections.push(selected);
|
||||
return selected;
|
||||
},
|
||||
}));
|
||||
vi.mock('../../stores/use-community-offline-store', () => {
|
||||
const getState = () => ({ communityOfflineState: testState.offlineStates });
|
||||
const store = Object.assign(<T,>(selector: (state: ReturnType<typeof getState>) => T) => selector(getState()), {
|
||||
getState,
|
||||
subscribe: (listener: () => void) => {
|
||||
testState.offlineStoreListeners.push(listener);
|
||||
return () => {
|
||||
testState.offlineStoreListeners = testState.offlineStoreListeners.filter((candidate) => candidate !== listener);
|
||||
};
|
||||
},
|
||||
});
|
||||
return { default: store };
|
||||
});
|
||||
|
||||
vi.mock('../../lib/bitsocial-internals/stores', () => ({
|
||||
communitiesStore: <T,>(selector: (state: { communities: typeof testState.communities; syncStatuses: typeof testState.syncStatuses }) => T) =>
|
||||
selector({
|
||||
communities: testState.communities,
|
||||
syncStatuses: testState.syncStatuses,
|
||||
}),
|
||||
}));
|
||||
vi.mock('../../lib/bitsocial-internals/stores', () => {
|
||||
const getState = () => ({
|
||||
communities: testState.communities,
|
||||
syncStatuses: testState.syncStatuses,
|
||||
});
|
||||
const communitiesStore = Object.assign(<T,>(selector: (state: ReturnType<typeof getState>) => T) => selector(getState()), {
|
||||
getState,
|
||||
subscribe: (listener: () => void) => {
|
||||
testState.communityStoreListeners.push(listener);
|
||||
return () => {
|
||||
testState.communityStoreListeners = testState.communityStoreListeners.filter((candidate) => candidate !== listener);
|
||||
};
|
||||
},
|
||||
});
|
||||
return {
|
||||
communitiesStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../lib/utils/directory-list-lookup-utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../lib/utils/directory-list-lookup-utils')>('../../lib/utils/directory-list-lookup-utils');
|
||||
@@ -84,8 +102,10 @@ let latestValue: string | undefined;
|
||||
let latestDirectoryBoardPath: { boardPath: string | undefined; isDirectoryCandidate: boolean };
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let hookRenderCount: number;
|
||||
|
||||
const HookHarness = () => {
|
||||
hookRenderCount += 1;
|
||||
latestValue = useResolvedCommunityAddress(testState.boardIdentifierOverride);
|
||||
latestDirectoryBoardPath = useResolvedDirectoryBoardPath(testState.boardIdentifier);
|
||||
return null;
|
||||
@@ -113,7 +133,9 @@ describe('useResolvedCommunityAddress', () => {
|
||||
testState.communities = {};
|
||||
testState.syncStatuses = {};
|
||||
testState.candidatePublicKeys = {};
|
||||
testState.offlineSelections = [];
|
||||
testState.communityStoreListeners = [];
|
||||
testState.offlineStoreListeners = [];
|
||||
hookRenderCount = 0;
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
@@ -304,7 +326,44 @@ describe('useResolvedCommunityAddress', () => {
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('custom-board.bso');
|
||||
expect(testState.offlineSelections.every((selection) => selection === undefined)).toBe(true);
|
||||
expect(testState.communityStoreListeners).toHaveLength(0);
|
||||
expect(testState.offlineStoreListeners).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not rerender when an unrelated community publishes lifecycle progress', async () => {
|
||||
await renderHook();
|
||||
const rendersBeforeUnrelatedUpdate = hookRenderCount;
|
||||
|
||||
testState.communities = {
|
||||
unrelated: {
|
||||
address: 'unrelated.bso',
|
||||
state: 'updating',
|
||||
updatedAt: 1_704_067_210,
|
||||
},
|
||||
};
|
||||
await act(async () => {
|
||||
testState.communityStoreListeners.forEach((listener) => listener());
|
||||
});
|
||||
|
||||
expect(latestValue).toBe('business-and-finance.bso');
|
||||
expect(hookRenderCount).toBe(rendersBeforeUnrelatedUpdate);
|
||||
});
|
||||
|
||||
it('rerenders when lifecycle progress changes the directory winner', async () => {
|
||||
await renderHook();
|
||||
const rendersBeforeWinnerChange = hookRenderCount;
|
||||
|
||||
testState.syncStatuses = {
|
||||
'12D3KooWBusiness': {
|
||||
syncState: 'failed',
|
||||
},
|
||||
};
|
||||
await act(async () => {
|
||||
testState.communityStoreListeners.forEach((listener) => listener());
|
||||
});
|
||||
|
||||
expect(latestValue).toBe('bizraelis.bso');
|
||||
expect(hookRenderCount).toBeGreaterThan(rendersBeforeWinnerChange);
|
||||
});
|
||||
|
||||
it('switches away from a directory board when it crosses the offline threshold while mounted', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { communitiesStore } from '../../lib/bitsocial-internals/stores';
|
||||
import useStateString, { useFeedStateString } from '../use-state-string';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -16,7 +17,6 @@ const testState = vi.hoisted(() => ({
|
||||
updatingState?: string;
|
||||
}
|
||||
| undefined,
|
||||
communitiesStates: {} as Record<string, { clientUrls: string[]; communityAddresses: string[] }>,
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
||||
@@ -24,9 +24,10 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
||||
states: testState.clientsStates,
|
||||
}),
|
||||
useCommunity: () => testState.community,
|
||||
useCommunitiesStates: () => ({
|
||||
states: testState.communitiesStates,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../use-community-identifiers', () => ({
|
||||
useCommunityIdentifiers: (addresses?: string[]) => (addresses ?? []).map((address) => (address.includes('.') ? { name: address } : { publicKey: address })),
|
||||
}));
|
||||
|
||||
vi.mock('lodash/debounce', () => ({
|
||||
@@ -40,6 +41,32 @@ vi.mock('lodash/debounce', () => ({
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let latestValue: string | undefined;
|
||||
let feedHarnessRenderCount: number;
|
||||
|
||||
const createLoadingCommunity = ({
|
||||
address,
|
||||
state,
|
||||
clientUrls,
|
||||
pageClientUrls = [],
|
||||
}: {
|
||||
address: string;
|
||||
state: string;
|
||||
clientUrls: string[];
|
||||
pageClientUrls?: string[];
|
||||
}) => ({
|
||||
address,
|
||||
clients: {
|
||||
ipfsGateways: Object.fromEntries(clientUrls.map((clientUrl) => [clientUrl, { state }])),
|
||||
},
|
||||
posts: {
|
||||
clients: {
|
||||
ipfsGateways: {
|
||||
hot: Object.fromEntries(pageClientUrls.map((clientUrl) => [clientUrl, { state: 'fetching-ipfs' }])),
|
||||
},
|
||||
},
|
||||
},
|
||||
updatingState: state,
|
||||
});
|
||||
|
||||
const StateStringHarness = ({
|
||||
value,
|
||||
@@ -55,6 +82,7 @@ const StateStringHarness = ({
|
||||
};
|
||||
|
||||
const FeedStateStringHarness = ({ addresses }: { addresses?: string[] }) => {
|
||||
feedHarnessRenderCount += 1;
|
||||
latestValue = useFeedStateString(addresses);
|
||||
return null;
|
||||
};
|
||||
@@ -65,7 +93,8 @@ describe('use-state-string', () => {
|
||||
localStorage.removeItem('5chan:pure-p2p-browser-enabled');
|
||||
testState.clientsStates = {};
|
||||
testState.community = undefined;
|
||||
testState.communitiesStates = {};
|
||||
communitiesStore.setState({ communities: {} });
|
||||
feedHarnessRenderCount = 0;
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -197,55 +226,116 @@ describe('use-state-string', () => {
|
||||
});
|
||||
|
||||
it('aggregates multi-board feed states across address resolution, threads, and pages', () => {
|
||||
testState.communitiesStates = {
|
||||
'fetching-ipfs': {
|
||||
clientUrls: ['https://ipfs.io'],
|
||||
communityAddresses: ['music-posting.eth'],
|
||||
const addresses = ['music-posting.eth', 'tech-posting.eth', 'video-posting.eth', 'finance-posting.eth', 'photo-posting.eth'];
|
||||
communitiesStore.setState({
|
||||
communities: {
|
||||
'music-posting.eth': createLoadingCommunity({
|
||||
address: 'music-posting.eth',
|
||||
state: 'fetching-ipns',
|
||||
clientUrls: ['https://gateway.example.com'],
|
||||
pageClientUrls: ['https://ipfs.io'],
|
||||
}),
|
||||
'tech-posting.eth': createLoadingCommunity({
|
||||
address: 'tech-posting.eth',
|
||||
state: 'fetching-ipns',
|
||||
clientUrls: ['https://gateway.example.com'],
|
||||
}),
|
||||
'video-posting.eth': createLoadingCommunity({
|
||||
address: 'video-posting.eth',
|
||||
state: 'fetching-ipfs',
|
||||
clientUrls: ['https://ipfs.io'],
|
||||
}),
|
||||
'finance-posting.eth': createLoadingCommunity({
|
||||
address: 'finance-posting.eth',
|
||||
state: 'resolving-address',
|
||||
clientUrls: ['https://ens.example.com'],
|
||||
}),
|
||||
'photo-posting.eth': createLoadingCommunity({
|
||||
address: 'photo-posting.eth',
|
||||
state: 'resolving-address',
|
||||
clientUrls: ['https://ens.example.com'],
|
||||
}),
|
||||
},
|
||||
'fetching-ipns': {
|
||||
clientUrls: ['https://gateway.example.com'],
|
||||
communityAddresses: ['music-posting.eth', 'tech-posting.eth'],
|
||||
},
|
||||
'page-1': {
|
||||
clientUrls: ['https://gateway.example.com', 'https://ipfs.io'],
|
||||
communityAddresses: ['music-posting.eth'],
|
||||
},
|
||||
'resolving-address': {
|
||||
clientUrls: ['https://ens.example.com'],
|
||||
communityAddresses: ['music-posting.eth', 'tech-posting.eth'],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(FeedStateStringHarness, { addresses: ['music-posting.eth', 'tech-posting.eth'] }));
|
||||
root.render(createElement(FeedStateStringHarness, { addresses }));
|
||||
});
|
||||
|
||||
expect(latestValue).toBe('Resolving 2 board addresses, downloading 2 boards (music-posting.eth, tech-posting.eth), 1 thread, 1 page via IPFS');
|
||||
});
|
||||
|
||||
it('aggregates browser libp2p feed states as peer downloads', () => {
|
||||
testState.communitiesStates = {
|
||||
'fetching-ipfs': {
|
||||
clientUrls: ['libp2pjs'],
|
||||
communityAddresses: ['music-posting.eth'],
|
||||
const addresses = ['music-posting.eth', 'tech-posting.eth', 'video-posting.eth'];
|
||||
communitiesStore.setState({
|
||||
communities: {
|
||||
'music-posting.eth': createLoadingCommunity({
|
||||
address: 'music-posting.eth',
|
||||
state: 'fetching-ipns',
|
||||
clientUrls: ['libp2pjs'],
|
||||
pageClientUrls: ['libp2pjs'],
|
||||
}),
|
||||
'tech-posting.eth': createLoadingCommunity({
|
||||
address: 'tech-posting.eth',
|
||||
state: 'fetching-ipns',
|
||||
clientUrls: ['libp2pjs'],
|
||||
}),
|
||||
'video-posting.eth': createLoadingCommunity({
|
||||
address: 'video-posting.eth',
|
||||
state: 'fetching-ipfs',
|
||||
clientUrls: ['libp2pjs'],
|
||||
}),
|
||||
},
|
||||
'fetching-ipns': {
|
||||
clientUrls: ['libp2pjs'],
|
||||
communityAddresses: ['music-posting.eth', 'tech-posting.eth'],
|
||||
},
|
||||
'page-1': {
|
||||
clientUrls: ['libp2pjs'],
|
||||
communityAddresses: ['music-posting.eth'],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(FeedStateStringHarness, { addresses: ['music-posting.eth', 'tech-posting.eth'] }));
|
||||
root.render(createElement(FeedStateStringHarness, { addresses }));
|
||||
});
|
||||
|
||||
expect(latestValue).toBe('Downloading 2 boards (music-posting.eth, tech-posting.eth), 1 thread, 1 page from peers');
|
||||
});
|
||||
|
||||
it('does not rerender when raw community state changes preserve the loading text', () => {
|
||||
const addresses = ['music-posting.eth', 'tech-posting.eth'];
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(FeedStateStringHarness, { addresses }));
|
||||
});
|
||||
|
||||
expect(latestValue).toBe('Downloading 2 boards (music-posting.eth, tech-posting.eth)');
|
||||
expect(feedHarnessRenderCount).toBe(1);
|
||||
|
||||
act(() => {
|
||||
communitiesStore.setState({
|
||||
communities: {
|
||||
'music-posting.eth': createLoadingCommunity({
|
||||
address: 'music-posting.eth',
|
||||
state: 'initializing',
|
||||
clientUrls: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(latestValue).toBe('Downloading 2 boards (music-posting.eth, tech-posting.eth)');
|
||||
expect(feedHarnessRenderCount).toBe(1);
|
||||
|
||||
act(() => {
|
||||
communitiesStore.setState({
|
||||
communities: {
|
||||
'music-posting.eth': createLoadingCommunity({
|
||||
address: 'music-posting.eth',
|
||||
state: 'fetching-ipns',
|
||||
clientUrls: ['https://gateway.example.com'],
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(latestValue).toBe('Downloading 1 board (music-posting.eth) via IPFS');
|
||||
expect(feedHarnessRenderCount).toBe(2);
|
||||
});
|
||||
|
||||
it('shows an immediate board-specific loading string before detailed multi-board states arrive', () => {
|
||||
act(() => {
|
||||
root.render(createElement(FeedStateStringHarness, { addresses: ['music-posting.eth', 'tech-posting.eth'] }));
|
||||
|
||||
@@ -1,31 +1,98 @@
|
||||
import { useEffect } from 'react';
|
||||
import { createElement, memo, useEffect, useMemo, useRef } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { useCommunityStats } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { useCommunityIdentifier } from './use-community-identifiers';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useCommunities, useCommunityStats, type CommunityIdentifier } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { useCommunityIdentifier, useCommunityIdentifiers } from './use-community-identifiers';
|
||||
import { communitiesStore } from '../lib/bitsocial-internals/stores';
|
||||
|
||||
type CommunityStatsState = {
|
||||
communityStats: { [communityAddress: string]: any };
|
||||
setCommunityStats: (communityAddress: string, stats: any) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The only fields any consumer reads off `communityStats` (see the aggregation in home.tsx).
|
||||
* pkc-js hands back a fresh stats object on every loading-state tick, so comparing identity is
|
||||
* useless here; comparing these values is what actually tells us whether a write is worth making.
|
||||
* If a consumer ever starts reading another field, add it here or it will silently read a stale value.
|
||||
*/
|
||||
const isSameStats = (a: any, b: any) =>
|
||||
!!a &&
|
||||
!!b &&
|
||||
a.allPostCount === b.allPostCount &&
|
||||
a.allReplyCount === b.allReplyCount &&
|
||||
a.weekActiveUserCount === b.weekActiveUserCount &&
|
||||
a.state === b.state &&
|
||||
a.sourceStatsCid === b.sourceStatsCid;
|
||||
|
||||
export const useCommunitiesStatsStore = create<CommunityStatsState>((set) => ({
|
||||
communityStats: {},
|
||||
setCommunityStats: (communityAddress, stats) =>
|
||||
set((state) => ({
|
||||
communityStats: { ...state.communityStats, [communityAddress]: stats },
|
||||
})),
|
||||
set((state) => {
|
||||
// Returning the existing state object is a no-op in zustand: it skips notifying listeners
|
||||
// entirely. Without this, every tick produced one store write per board, and each write
|
||||
// rerendered Home and therefore every collector under it.
|
||||
if (isSameStats(state.communityStats[communityAddress], stats)) {
|
||||
return state;
|
||||
}
|
||||
return { communityStats: { ...state.communityStats, [communityAddress]: stats } };
|
||||
}),
|
||||
}));
|
||||
|
||||
export const CommunityStatsCollector = ({ communityAddress }: { communityAddress: string }) => {
|
||||
const community = useCommunityIdentifier(communityAddress);
|
||||
const stats = useCommunityStats(community ? { community } : undefined);
|
||||
const CommunityStatsRequest = ({
|
||||
communityAddress,
|
||||
community,
|
||||
sourceStatsCid,
|
||||
}: {
|
||||
communityAddress: string;
|
||||
community: CommunityIdentifier | undefined;
|
||||
sourceStatsCid: string | undefined;
|
||||
}) => {
|
||||
const statsOptions = useMemo(() => (community ? { community } : undefined), [community]);
|
||||
const stats = useCommunityStats(statsOptions);
|
||||
const initialStats = useRef(stats);
|
||||
const setCommunityStats = useCommunitiesStatsStore((state) => state.setCommunityStats);
|
||||
|
||||
useEffect(() => {
|
||||
if (stats && (stats.allPostCount !== undefined || stats.state === 'failed')) {
|
||||
setCommunityStats(communityAddress, stats);
|
||||
// useCommunityStats can synchronously return the previous CID's cached value while it
|
||||
// starts the request for a new statsCid. Wait for its result object to change before
|
||||
// recording the CID, otherwise the collector could mark stale values as current.
|
||||
if (stats !== initialStats.current && (stats.allPostCount !== undefined || stats.state === 'failed')) {
|
||||
setCommunityStats(communityAddress, { ...stats, sourceStatsCid });
|
||||
}
|
||||
}, [stats, communityAddress, setCommunityStats]);
|
||||
}, [communityAddress, setCommunityStats, sourceStatsCid, stats]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const CommunityStatsMetadataLoader = memo(({ communityAddresses }: { communityAddresses: string[] }) => {
|
||||
const communities = useCommunityIdentifiers(communityAddresses);
|
||||
const pendingCommunities = communitiesStore(
|
||||
useShallow((state) => communities.filter((community) => !state.communities[community.publicKey ?? community.name ?? '']?.statsCid)),
|
||||
);
|
||||
|
||||
useCommunities({ communities: pendingCommunities });
|
||||
return null;
|
||||
});
|
||||
CommunityStatsMetadataLoader.displayName = 'CommunityStatsMetadataLoader';
|
||||
|
||||
// Keep the expensive upstream hook mounted only while resolving the current statsCid.
|
||||
// Once resolved, this wrapper subscribes to the primitive CID instead of the full live
|
||||
// community object, so routine lifecycle ticks cannot rerender ~80 homepage collectors.
|
||||
export const CommunityStatsCollector = memo(({ communityAddress }: { communityAddress: string }) => {
|
||||
const community = useCommunityIdentifier(communityAddress);
|
||||
const communityKey = community?.publicKey ?? community?.name;
|
||||
const sourceStatsCid = communitiesStore((state) => (communityKey ? state.communities[communityKey]?.statsCid : undefined));
|
||||
const collectedStatsCid = useCommunitiesStatsStore((state) => state.communityStats[communityAddress]?.sourceStatsCid);
|
||||
|
||||
if (!sourceStatsCid || collectedStatsCid === sourceStatsCid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createElement(CommunityStatsRequest, {
|
||||
communityAddress,
|
||||
community,
|
||||
sourceStatsCid,
|
||||
});
|
||||
});
|
||||
CommunityStatsCollector.displayName = 'CommunityStatsCollector';
|
||||
|
||||
+66
-127
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { vendoredDirectoryLists as directoryListsData, vendoredDirectoryDefaults as directoryDefaultsData } from '../data/vendored-directory-lists';
|
||||
import { isSpecialBoardAddress, isSpecialBoardCode } from '../lib/special-boards';
|
||||
import {
|
||||
@@ -57,6 +58,8 @@ export const __resetDirectoriesModuleStateForTests = () => {
|
||||
cacheDefaults = null;
|
||||
fallbackDirectoriesData = null;
|
||||
fallbackDirectoryDefaults = null;
|
||||
directoriesHydrationStarted = false;
|
||||
useDirectoriesStore.setState({ communities: getFallbackDirectoriesData().communities, loading: true, error: null });
|
||||
};
|
||||
|
||||
const getDirectoryIdentifiers = (community: DirectoryCommunity): string[] => [
|
||||
@@ -443,77 +446,70 @@ const fetchDirectoriesFromGitHubDeduped = async (): Promise<DirectoriesData | nu
|
||||
return inFlightGitHubFetch;
|
||||
};
|
||||
|
||||
export const useDirectories = () => {
|
||||
// Use vendored data as initial state to prevent theme flash on first load
|
||||
// This ensures NSFW status is known synchronously before first render
|
||||
const [state, setState] = useState<DirectoriesState>({
|
||||
communities: getFallbackDirectoriesData().communities,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
/**
|
||||
* Directory data is global, so it lives in one store rather than in per-caller `useState`.
|
||||
* `useDirectories` has ~60 call sites, several of them inside list rows (catalog tiles, markdown
|
||||
* bodies), and the previous implementation gave every instance its own state plus its own
|
||||
* hydration effect. Hydration therefore fanned out into one `setState` per mounted instance.
|
||||
* One store means a single write that React batches, and subscribers whose slice is unchanged
|
||||
* do not rerender at all.
|
||||
*/
|
||||
const useDirectoriesStore = create<DirectoriesState>(() => ({
|
||||
communities: getFallbackDirectoriesData().communities,
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const hydrateCommunities = (data: DirectoriesData) => {
|
||||
cacheCommunities = data.communities;
|
||||
if (isMounted) {
|
||||
setState({
|
||||
communities: data.communities,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
let directoriesHydrationStarted = false;
|
||||
|
||||
const setDirectoriesCommunities = (data: DirectoriesData) => {
|
||||
cacheCommunities = data.communities;
|
||||
useDirectoriesStore.setState({ communities: data.communities, loading: false, error: null });
|
||||
};
|
||||
|
||||
const hydrateDirectoriesOnce = () => {
|
||||
if (directoriesHydrationStarted) {
|
||||
return;
|
||||
}
|
||||
directoriesHydrationStarted = true;
|
||||
|
||||
void (async () => {
|
||||
if (cacheCommunities) {
|
||||
useDirectoriesStore.setState({ communities: cacheCommunities, loading: false, error: null });
|
||||
} else {
|
||||
// Check localStorage first
|
||||
const cachedData = getFromLocalStorage();
|
||||
if (cachedData) {
|
||||
setDirectoriesCommunities(cachedData);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// Refresh from GitHub when the session cache is stale, without refetching for every hook mount.
|
||||
const directories = await fetchDirectoriesFromGitHubDeduped();
|
||||
if (directories) {
|
||||
setDirectoriesCommunities(directories);
|
||||
} else if (!cacheCommunities) {
|
||||
setDirectoriesCommunities(getFallbackDirectoriesData());
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch directories from GitHub:', e);
|
||||
if (cacheCommunities) {
|
||||
setState({
|
||||
communities: cacheCommunities,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
useDirectoriesStore.setState({ communities: cacheCommunities, loading: false, error: null });
|
||||
} else {
|
||||
// Check localStorage first
|
||||
const cachedData = getFromLocalStorage();
|
||||
if (cachedData) {
|
||||
hydrateCommunities(cachedData);
|
||||
}
|
||||
setDirectoriesCommunities(getFallbackDirectoriesData());
|
||||
}
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
try {
|
||||
// Refresh from GitHub when the session cache is stale, without refetching for every hook mount.
|
||||
const directories = await fetchDirectoriesFromGitHubDeduped();
|
||||
if (directories) {
|
||||
hydrateCommunities(directories);
|
||||
} else if (!cacheCommunities) {
|
||||
hydrateCommunities(getFallbackDirectoriesData());
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch directories from GitHub:', e);
|
||||
// Keep each hook instance in sync even if a sibling hook populated the module cache first.
|
||||
if (cacheCommunities) {
|
||||
if (isMounted) {
|
||||
setState({
|
||||
communities: cacheCommunities,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
hydrateCommunities(getFallbackDirectoriesData());
|
||||
}
|
||||
}
|
||||
})();
|
||||
export const useDirectories = () => {
|
||||
const communities = useDirectoriesStore((state) => state.communities);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
useEffect(hydrateDirectoriesOnce, []);
|
||||
|
||||
// Always prefer cacheCommunities (module-level, stable reference) when available
|
||||
// Only use state.communities during initial load before cache is populated
|
||||
// This ensures a stable reference for memoization in consuming hooks
|
||||
return cacheCommunities || state.communities || getFallbackDirectoriesData().communities;
|
||||
// Prefer the module-level cache so consuming hooks keep a stable reference for memoization.
|
||||
return cacheCommunities || communities || getFallbackDirectoriesData().communities;
|
||||
};
|
||||
|
||||
export const useDirectoryDefaults = (): DirectoryDefaultsData => {
|
||||
@@ -522,73 +518,16 @@ export const useDirectoryDefaults = (): DirectoryDefaultsData => {
|
||||
return cacheDefaults ?? getFallbackDirectoryDefaults();
|
||||
};
|
||||
|
||||
export const useDirectoriesState = () => {
|
||||
// Use vendored data as fallback to prevent theme flash on first load
|
||||
const [state, setState] = useState<DirectoriesState>({
|
||||
communities: cacheCommunities || getFallbackDirectoriesData().communities,
|
||||
loading: !cacheCommunities,
|
||||
error: null,
|
||||
});
|
||||
export const useDirectoriesState = (): DirectoriesState => {
|
||||
// Field-level selectors rather than a whole-store subscription, so consumers only rerender
|
||||
// when the field they actually read changes.
|
||||
const communities = useDirectoriesStore((state) => state.communities);
|
||||
const loading = useDirectoriesStore((state) => state.loading);
|
||||
const error = useDirectoriesStore((state) => state.error);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const hydrateCommunities = (data: DirectoriesData) => {
|
||||
cacheCommunities = data.communities;
|
||||
if (isMounted) {
|
||||
setState({
|
||||
communities: data.communities,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
useEffect(hydrateDirectoriesOnce, []);
|
||||
|
||||
(async () => {
|
||||
if (cacheCommunities) {
|
||||
setState({
|
||||
communities: cacheCommunities,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
} else {
|
||||
// Check localStorage first
|
||||
const cachedData = getFromLocalStorage();
|
||||
if (cachedData) {
|
||||
hydrateCommunities(cachedData);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Refresh from GitHub when the session cache is stale, without refetching for every hook mount.
|
||||
const directories = await fetchDirectoriesFromGitHubDeduped();
|
||||
if (directories) {
|
||||
hydrateCommunities(directories);
|
||||
} else if (!cacheCommunities) {
|
||||
hydrateCommunities(getFallbackDirectoriesData());
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch directories from GitHub:', e);
|
||||
// Keep each hook instance in sync even if a sibling hook populated the module cache first.
|
||||
if (cacheCommunities) {
|
||||
if (isMounted) {
|
||||
setState({
|
||||
communities: cacheCommunities,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
hydrateCommunities(getFallbackDirectoriesData());
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
return useMemo(() => ({ communities, loading, error }), [communities, loading, error]);
|
||||
};
|
||||
|
||||
export const useDirectoryAddresses = () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import useAllFeedFilterStore from '../stores/use-all-feed-filter-store';
|
||||
|
||||
export const useFilteredDirectoryAddresses = () => {
|
||||
const directories = useDirectories();
|
||||
const { filter } = useAllFeedFilterStore();
|
||||
const filter = useAllFeedFilterStore((state) => state.filter);
|
||||
|
||||
const filteredAddresses = useMemo(() => {
|
||||
if (filter === 'all') {
|
||||
|
||||
@@ -17,14 +17,18 @@ const useIsCommunityOffline = (community?: CommunityWithSyncLifecycle | undefine
|
||||
const { state, syncState, hasCachedData, updatedAt } = community || {};
|
||||
const communityKey = getCommunityOfflineKey(community, communityAddressHint);
|
||||
const nowSeconds = useNowSeconds(!!communityKey);
|
||||
const { communityOfflineState, setCommunityOfflineState, initializeCommunityOfflineState } = useCommunityOfflineStore();
|
||||
// Subscribe to this community's entry only. The directory view renders one of these per board,
|
||||
// so a whole-store subscription meant every board's state change rerendered every row.
|
||||
const storedOfflineState = useCommunityOfflineStore((state) => (communityKey ? state.communityOfflineState[communityKey] : undefined));
|
||||
const setCommunityOfflineState = useCommunityOfflineStore((state) => state.setCommunityOfflineState);
|
||||
const initializeCommunityOfflineState = useCommunityOfflineStore((state) => state.initializeCommunityOfflineState);
|
||||
const communitiesLoadingStartTimestamps = useCommunitiesLoadingStartTimestamps(communityKey ? [communityKey] : undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (communityKey && !communityOfflineState[communityKey]) {
|
||||
if (communityKey && !storedOfflineState) {
|
||||
initializeCommunityOfflineState(communityKey);
|
||||
}
|
||||
}, [communityKey, communityOfflineState, initializeCommunityOfflineState]);
|
||||
}, [communityKey, storedOfflineState, initializeCommunityOfflineState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (communityKey) {
|
||||
@@ -36,7 +40,7 @@ const useIsCommunityOffline = (community?: CommunityWithSyncLifecycle | undefine
|
||||
return { isOffline: false, isOnlineStatusLoading: false, offlineIconClass: '', offlineTitle: false };
|
||||
}
|
||||
|
||||
const offlineState = communityOfflineState[communityKey] || { initialLoad: true };
|
||||
const offlineState = storedOfflineState || { initialLoad: true };
|
||||
const loadingStartTimestamp = communitiesLoadingStartTimestamps[0] || 0;
|
||||
const isStale = isCommunityUpdateStale(updatedAt, nowSeconds);
|
||||
const hasUsableCachedData = (hasCachedData ?? typeof updatedAt === 'number') && updatedAt !== undefined;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { CommunitySyncState } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { normalizeBoardAddress, useDirectories } from './use-directories';
|
||||
@@ -99,6 +99,48 @@ const getDirectoryBoardFreshnessState = (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to the two lifecycle stores while exposing only the winning board address to React.
|
||||
* Both stores publish high-frequency sync progress updates, but a directory route only needs to
|
||||
* rerender when those updates actually change its winner.
|
||||
*/
|
||||
const useDirectoryWinnerAddress = (boards: DirectoryListBoard[] | undefined, enabled: boolean, nowSeconds: number): string | undefined => {
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => {
|
||||
if (!enabled) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const unsubscribeCommunities = useCommunitiesStore.subscribe(onStoreChange);
|
||||
const unsubscribeOfflineStates = useCommunityOfflineStore.subscribe(onStoreChange);
|
||||
return () => {
|
||||
unsubscribeCommunities();
|
||||
unsubscribeOfflineStates();
|
||||
};
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!enabled || !boards?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { communities, syncStatuses } = useCommunitiesStore.getState() as {
|
||||
communities?: StoredCommunities;
|
||||
syncStatuses?: CommunitySyncStatuses;
|
||||
};
|
||||
const offlineStates = useCommunityOfflineStore.getState().communityOfflineState;
|
||||
const winner = pickDirectoryWinner(boards, (board) =>
|
||||
isCommunityKnownOffline(getDirectoryBoardFreshnessState(communities, syncStatuses, offlineStates?.[board.address], board), nowSeconds),
|
||||
);
|
||||
|
||||
return winner?.address;
|
||||
}, [boards, enabled, nowSeconds]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a board identifier to its canonical community address.
|
||||
*
|
||||
@@ -112,21 +154,16 @@ export const useResolvedCommunityAddress = (boardIdentifierOverride?: string): s
|
||||
const boardIdentifier = boardIdentifierOverride ?? params.boardIdentifier;
|
||||
const isCode = !!boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
||||
const { list } = useDirectoryList(isCode ? boardIdentifier : undefined);
|
||||
const offlineStates = useCommunityOfflineStore((state) => (isCode ? state.communityOfflineState : undefined));
|
||||
const communities = useCommunitiesStore((state) => (isCode ? state.communities : undefined));
|
||||
const syncStatuses = useCommunitiesStore((state) => (isCode ? state.syncStatuses : undefined));
|
||||
const nowSeconds = useNowSeconds(isCode);
|
||||
const winnerAddress = useDirectoryWinnerAddress(list?.boards, isCode, nowSeconds);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!boardIdentifier) return undefined;
|
||||
if (isCode && list && list.boards.length > 0) {
|
||||
const isOffline = (board: DirectoryListBoard) =>
|
||||
isCommunityKnownOffline(getDirectoryBoardFreshnessState(communities, syncStatuses, offlineStates?.[board.address], board), nowSeconds);
|
||||
const winner = pickDirectoryWinner(list.boards, isOffline);
|
||||
if (winner) return winner.address;
|
||||
if (isCode && winnerAddress) {
|
||||
return winnerAddress;
|
||||
}
|
||||
return getCommunityAddress(boardIdentifier, directories);
|
||||
}, [boardIdentifier, communities, directories, isCode, list, offlineStates, nowSeconds, syncStatuses]);
|
||||
}, [boardIdentifier, directories, isCode, winnerAddress]);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -138,10 +175,8 @@ export const useResolvedDirectoryBoardPath = (boardIdentifier: string | undefine
|
||||
const isCode = !!boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
||||
const directoryCode = useMemo(() => (boardIdentifier && !isCode ? getDirectoryCodeForBoardAddress(boardIdentifier) : undefined), [boardIdentifier, isCode]);
|
||||
const { list } = useDirectoryList(directoryCode);
|
||||
const offlineStates = useCommunityOfflineStore((state) => (directoryCode ? state.communityOfflineState : undefined));
|
||||
const communities = useCommunitiesStore((state) => (directoryCode ? state.communities : undefined));
|
||||
const syncStatuses = useCommunitiesStore((state) => (directoryCode ? state.syncStatuses : undefined));
|
||||
const nowSeconds = useNowSeconds(!!directoryCode);
|
||||
const winnerAddress = useDirectoryWinnerAddress(list?.boards, !!directoryCode, nowSeconds);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!boardIdentifier || !directoryCode) {
|
||||
@@ -152,15 +187,11 @@ export const useResolvedDirectoryBoardPath = (boardIdentifier: string | undefine
|
||||
return { boardPath: undefined, isDirectoryCandidate: true };
|
||||
}
|
||||
|
||||
const isOffline = (board: DirectoryListBoard) =>
|
||||
isCommunityKnownOffline(getDirectoryBoardFreshnessState(communities, syncStatuses, offlineStates?.[board.address], board), nowSeconds);
|
||||
const winner = pickDirectoryWinner(list.boards, isOffline);
|
||||
|
||||
return {
|
||||
boardPath: winner && areSameBoardAddress(winner.address, boardIdentifier) ? directoryCode : undefined,
|
||||
boardPath: winnerAddress && areSameBoardAddress(winnerAddress, boardIdentifier) ? directoryCode : undefined,
|
||||
isDirectoryCandidate: true,
|
||||
};
|
||||
}, [boardIdentifier, communities, directoryCode, list, offlineStates, nowSeconds, syncStatuses]);
|
||||
}, [boardIdentifier, directoryCode, list, winnerAddress]);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,38 @@ import { normalizeBoardAddress } from './use-directories';
|
||||
|
||||
type CommunityLike = Record<string, unknown> | Community | undefined;
|
||||
|
||||
/**
|
||||
* The store keys communities by publicKey (see getCommunityRefKey in bitsocial-react-hooks),
|
||||
* while callers look up by board address, so the exact-key hit almost always misses and the
|
||||
* fallback has to scan every community. That scan used to run once per subscriber per store
|
||||
* notification; pkc-js emits a notification per loading-state tick, so on a multiboard feed it
|
||||
* was O(communities x subscribers) string normalizations per tick.
|
||||
*
|
||||
* The normalized index is derived once per `communities` object instead. The store replaces that
|
||||
* object on every write, so the WeakMap entry is naturally invalidated and collected with it.
|
||||
*/
|
||||
const normalizedIndexCache = new WeakMap<object, Map<string, unknown>>();
|
||||
|
||||
const getNormalizedCommunityIndex = (communities: Record<string, unknown>) => {
|
||||
const cached = normalizedIndexCache.get(communities);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const index = new Map<string, unknown>();
|
||||
for (const [key, community] of Object.entries(communities)) {
|
||||
const candidateAddress = typeof (community as CommunityLike)?.address === 'string' ? ((community as CommunityLike)?.address as string) : key;
|
||||
const normalized = normalizeBoardAddress(candidateAddress);
|
||||
// First match wins, matching the previous Array.prototype.find behaviour.
|
||||
if (normalized && !index.has(normalized)) {
|
||||
index.set(normalized, community);
|
||||
}
|
||||
}
|
||||
|
||||
normalizedIndexCache.set(communities, index);
|
||||
return index;
|
||||
};
|
||||
|
||||
const getCommunityByAddress = (communities: Record<string, unknown> | undefined, communityAddress: string | undefined) => {
|
||||
if (!communities || !communityAddress) {
|
||||
return undefined;
|
||||
@@ -14,11 +46,7 @@ const getCommunityByAddress = (communities: Record<string, unknown> | undefined,
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
const normalizedAddress = normalizeBoardAddress(communityAddress);
|
||||
return Object.entries(communities).find(([key, community]) => {
|
||||
const candidateAddress = typeof (community as CommunityLike)?.address === 'string' ? (community as CommunityLike)?.address : key;
|
||||
return normalizeBoardAddress(candidateAddress) === normalizedAddress;
|
||||
})?.[1];
|
||||
return getNormalizedCommunityIndex(communities).get(normalizeBoardAddress(communityAddress));
|
||||
};
|
||||
|
||||
const shallowEqual = (obj1: Record<string, any> | undefined, obj2: Record<string, any> | undefined): boolean => {
|
||||
|
||||
+159
-90
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useClientsStates, useCommunity, useCommunitiesStates } from '@bitsocial/bitsocial-react-hooks';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useClientsStates, useCommunity, type Communities, type CommunityIdentifier } from '@bitsocial/bitsocial-react-hooks';
|
||||
import getShortAddress from '../lib/get-short-address';
|
||||
import { communitiesStore } from '../lib/bitsocial-internals/stores';
|
||||
import { communityPostsCacheExpired } from '../lib/bitsocial-internals/utils';
|
||||
import { isBrowserPureP2PEnabled } from '../lib/p2p-runtime';
|
||||
import { useCommunityIdentifiers } from './use-community-identifiers';
|
||||
|
||||
@@ -20,8 +21,14 @@ type CommunityLoadingState = {
|
||||
clientUrls: string[];
|
||||
};
|
||||
|
||||
const isCommunityLoadingState = (state: string[] | CommunityLoadingState | undefined): state is CommunityLoadingState =>
|
||||
Boolean(state && !Array.isArray(state) && 'communityAddresses' in state && 'clientUrls' in state);
|
||||
type CommunityLoadingStates = Record<string, CommunityLoadingState>;
|
||||
|
||||
type MutableCommunityLoadingState = {
|
||||
communityAddresses: Set<string>;
|
||||
clientUrls: Set<string>;
|
||||
};
|
||||
|
||||
type MutableCommunityLoadingStates = Record<string, MutableCommunityLoadingState>;
|
||||
|
||||
const isBrowserLibp2pClient = (clientUrl: string) => clientUrl === 'libp2pjs';
|
||||
|
||||
@@ -75,29 +82,162 @@ const sanitizeSingleFeedLoadingState = (stateString?: string): string | undefine
|
||||
.replace(/\bloading thread\b/g, 'loading board');
|
||||
};
|
||||
|
||||
const getOrCreateCommunityLoadingState = (states: MutableCommunityLoadingStates, state: string): MutableCommunityLoadingState => {
|
||||
states[state] ??= {
|
||||
communityAddresses: new Set(),
|
||||
clientUrls: new Set(),
|
||||
};
|
||||
return states[state];
|
||||
};
|
||||
|
||||
const getCommunitiesLoadingStates = (storedCommunities: Communities, communityIdentifiers: CommunityIdentifier[]): CommunityLoadingStates => {
|
||||
const states: MutableCommunityLoadingStates = {};
|
||||
|
||||
for (const communityIdentifier of communityIdentifiers) {
|
||||
const communityKey = communityIdentifier.publicKey || communityIdentifier.name;
|
||||
if (!communityKey) {
|
||||
continue;
|
||||
}
|
||||
const community = storedCommunities[communityKey];
|
||||
if (!community?.updatingState) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatingState = community.updatingState as string;
|
||||
if ((!community.updatedAt || communityPostsCacheExpired(community)) && updatingState !== 'stopped' && updatingState !== 'succeeded') {
|
||||
const communityState = getOrCreateCommunityLoadingState(states, updatingState);
|
||||
communityState.communityAddresses.add(community.address as string);
|
||||
|
||||
for (const clientType in community.clients ?? {}) {
|
||||
if (clientType === 'chainProviders') {
|
||||
for (const chainTicker in community.clients.chainProviders ?? {}) {
|
||||
for (const clientUrl in community.clients.chainProviders[chainTicker] ?? {}) {
|
||||
if (community.clients.chainProviders[chainTicker][clientUrl].state === updatingState) {
|
||||
communityState.clientUrls.add(clientUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const clientUrl in community.clients[clientType] ?? {}) {
|
||||
if (community.clients[clientType][clientUrl].state === updatingState) {
|
||||
communityState.clientUrls.add(clientUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const clientType in community.posts?.clients ?? {}) {
|
||||
for (const sortType in community.posts.clients[clientType] ?? {}) {
|
||||
for (const clientUrl in community.posts.clients[clientType][sortType] ?? {}) {
|
||||
const clientState = community.posts.clients[clientType][sortType][clientUrl].state;
|
||||
if (clientState === 'stopped') {
|
||||
continue;
|
||||
}
|
||||
const pageState = getOrCreateCommunityLoadingState(states, `${clientState}-page-${sortType}`);
|
||||
pageState.communityAddresses.add(community.address as string);
|
||||
pageState.clientUrls.add(clientUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(states).map(([state, value]) => [
|
||||
state,
|
||||
{
|
||||
communityAddresses: [...value.communityAddresses],
|
||||
clientUrls: [...value.clientUrls],
|
||||
},
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
const getMultipleCommunitiesFeedStateString = (
|
||||
states: CommunityLoadingStates,
|
||||
communityAddresses: string[] | undefined,
|
||||
isBrowserPureP2P: boolean,
|
||||
): string | undefined => {
|
||||
let stateString = '';
|
||||
|
||||
if (states['resolving-address']) {
|
||||
const resolvingState = states['resolving-address'];
|
||||
const count = resolvingState.communityAddresses.length;
|
||||
stateString += `resolving ${count} board ${count === 1 ? 'address' : 'addresses'}`;
|
||||
}
|
||||
|
||||
const pagesStatesCommunityAddresses = new Set<string>();
|
||||
const downloadingClientUrls: string[] = [];
|
||||
for (const state in states) {
|
||||
if (state.match('page')) {
|
||||
states[state].communityAddresses.forEach((address) => pagesStatesCommunityAddresses.add(address));
|
||||
downloadingClientUrls.push(...states[state].clientUrls);
|
||||
}
|
||||
}
|
||||
|
||||
if (states['fetching-ipns'] || states['fetching-ipfs'] || pagesStatesCommunityAddresses.size) {
|
||||
if (stateString) stateString += ', ';
|
||||
stateString += 'downloading ';
|
||||
if (states['fetching-ipns']) {
|
||||
const fetchingIpnsState = states['fetching-ipns'];
|
||||
downloadingClientUrls.push(...fetchingIpnsState.clientUrls);
|
||||
const count = fetchingIpnsState.communityAddresses.length;
|
||||
stateString += `${count} ${count === 1 ? 'board' : 'boards'}`;
|
||||
if (count <= 5) {
|
||||
stateString += ` (${fetchingIpnsState.communityAddresses.map((address) => getShortAddress(address) || address).join(', ')})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (states['fetching-ipfs']) {
|
||||
const fetchingIpfsState = states['fetching-ipfs'];
|
||||
downloadingClientUrls.push(...fetchingIpfsState.clientUrls);
|
||||
if (stateString[stateString.length - 1] !== ' ') {
|
||||
stateString += ', ';
|
||||
}
|
||||
const count = fetchingIpfsState.communityAddresses.length;
|
||||
stateString += `${count} ${count === 1 ? 'thread' : 'threads'}`;
|
||||
}
|
||||
|
||||
if (pagesStatesCommunityAddresses.size) {
|
||||
if (states['fetching-ipns'] || states['fetching-ipfs']) stateString += ', ';
|
||||
const count = pagesStatesCommunityAddresses.size;
|
||||
stateString += `${count} ${count === 1 ? 'page' : 'pages'}`;
|
||||
}
|
||||
|
||||
stateString += getDownloadSourceSuffix(downloadingClientUrls, isBrowserPureP2P);
|
||||
}
|
||||
|
||||
if (!stateString && communityAddresses?.length) {
|
||||
const count = communityAddresses.length;
|
||||
stateString = `downloading ${count} ${count === 1 ? 'board' : 'boards'}`;
|
||||
if (count <= 5) {
|
||||
stateString += ` (${communityAddresses.map((address) => getShortAddress(address) || address).join(', ')})`;
|
||||
}
|
||||
}
|
||||
|
||||
stateString = stateString.charAt(0).toUpperCase() + stateString.slice(1);
|
||||
return stateString === '' ? undefined : stateString;
|
||||
};
|
||||
|
||||
const useStateString = (commentOrCommunity: CommentOrCommunity | undefined): string | undefined => {
|
||||
const { states: rawStates } = useClientsStates({ comment: commentOrCommunity }) as { states: States };
|
||||
const isBrowserPureP2P = isBrowserPureP2PEnabled();
|
||||
|
||||
const debouncedStates = useMemo(() => {
|
||||
const debouncedValue = debounce((value: States) => value, 300);
|
||||
return debouncedValue(rawStates);
|
||||
}, [rawStates]);
|
||||
|
||||
return useMemo(() => {
|
||||
let stateString: string | undefined = '';
|
||||
const resolvingParts: string[] = [];
|
||||
const downloadingParts: string[] = [];
|
||||
const downloadingClientUrls: string[] = [];
|
||||
|
||||
for (const state in debouncedStates) {
|
||||
if (debouncedStates[state].length === 0) continue;
|
||||
for (const state in rawStates) {
|
||||
if (rawStates[state].length === 0) continue;
|
||||
const friendlyName = getFriendlyStateName(state);
|
||||
if (state.includes('resolving')) {
|
||||
resolvingParts.push(friendlyName);
|
||||
} else {
|
||||
downloadingParts.push(friendlyName);
|
||||
downloadingClientUrls.push(...debouncedStates[state]);
|
||||
downloadingClientUrls.push(...rawStates[state]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +265,7 @@ const useStateString = (commentOrCommunity: CommentOrCommunity | undefined): str
|
||||
}
|
||||
|
||||
return stateString === '' ? undefined : stateString;
|
||||
}, [debouncedStates, commentOrCommunity, isBrowserPureP2P]);
|
||||
}, [rawStates, commentOrCommunity, isBrowserPureP2P]);
|
||||
};
|
||||
|
||||
export const useFeedStateString = (communityAddresses?: string[]): string | undefined => {
|
||||
@@ -139,87 +279,16 @@ export const useFeedStateString = (communityAddresses?: string[]): string | unde
|
||||
const rawSingleCommunityFeedStateString = useStateString(communityAddress ? community : undefined);
|
||||
const singleCommunityFeedStateString = communityAddress ? sanitizeSingleFeedLoadingState(rawSingleCommunityFeedStateString) : undefined;
|
||||
|
||||
// multiple community feed state string
|
||||
const { states } = useCommunitiesStates({ communities });
|
||||
|
||||
const multipleCommunitiesFeedStateString = useMemo(() => {
|
||||
// Every caller already owns the data-loading hook. Observe only the derived text here so
|
||||
// high-frequency per-community lifecycle changes do not create no-op React commits.
|
||||
const multipleCommunitiesFeedStateString = communitiesStore((state) => {
|
||||
if (communityAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
let stateString = '';
|
||||
|
||||
if (states['resolving-address']) {
|
||||
const resolvingState = states['resolving-address'];
|
||||
if (isCommunityLoadingState(resolvingState)) {
|
||||
const { communityAddresses } = resolvingState;
|
||||
const count = communityAddresses.length;
|
||||
stateString += `resolving ${count} board ${count === 1 ? 'address' : 'addresses'}`;
|
||||
}
|
||||
}
|
||||
|
||||
const pagesStatesCommunityAddresses = new Set<string>();
|
||||
const downloadingClientUrls: string[] = [];
|
||||
for (const state in states) {
|
||||
if (state.match('page')) {
|
||||
const communityState = states[state];
|
||||
if (isCommunityLoadingState(communityState)) {
|
||||
communityState.communityAddresses.forEach((address: string) => pagesStatesCommunityAddresses.add(address));
|
||||
downloadingClientUrls.push(...communityState.clientUrls);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (states['fetching-ipns'] || states['fetching-ipfs'] || pagesStatesCommunityAddresses.size) {
|
||||
if (stateString) stateString += ', ';
|
||||
stateString += 'downloading ';
|
||||
if (states['fetching-ipns']) {
|
||||
const fetchingIpnsState = states['fetching-ipns'];
|
||||
if (isCommunityLoadingState(fetchingIpnsState)) {
|
||||
downloadingClientUrls.push(...fetchingIpnsState.clientUrls);
|
||||
const count = fetchingIpnsState.communityAddresses.length;
|
||||
stateString += `${count} ${count === 1 ? 'board' : 'boards'}`;
|
||||
if (count <= 5) {
|
||||
stateString += ` (${fetchingIpnsState.communityAddresses.map((a: string) => getShortAddress(a) || a).join(', ')})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (states['fetching-ipfs']) {
|
||||
const fetchingIpfsState = states['fetching-ipfs'];
|
||||
if (isCommunityLoadingState(fetchingIpfsState)) {
|
||||
downloadingClientUrls.push(...fetchingIpfsState.clientUrls);
|
||||
if (stateString[stateString.length - 1] !== ' ') {
|
||||
stateString += ', ';
|
||||
}
|
||||
const count = fetchingIpfsState.communityAddresses.length;
|
||||
stateString += `${count} ${count === 1 ? 'thread' : 'threads'}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (pagesStatesCommunityAddresses.size) {
|
||||
if (states['fetching-ipns'] || states['fetching-ipfs']) stateString += ', ';
|
||||
const count = pagesStatesCommunityAddresses.size;
|
||||
stateString += `${count} ${count === 1 ? 'page' : 'pages'}`;
|
||||
}
|
||||
|
||||
stateString += getDownloadSourceSuffix(downloadingClientUrls, isBrowserPureP2P);
|
||||
}
|
||||
|
||||
if (!stateString && communityAddresses?.length) {
|
||||
const count = communityAddresses.length;
|
||||
stateString = `downloading ${count} ${count === 1 ? 'board' : 'boards'}`;
|
||||
if (count <= 5) {
|
||||
stateString += ` (${communityAddresses.map((a) => getShortAddress(a) || a).join(', ')})`;
|
||||
}
|
||||
}
|
||||
|
||||
// capitalize first letter
|
||||
stateString = stateString.charAt(0).toUpperCase() + stateString.slice(1);
|
||||
|
||||
// if string is empty, return undefined instead
|
||||
return stateString === '' ? undefined : stateString;
|
||||
}, [states, communityAddress, communityAddresses, isBrowserPureP2P]);
|
||||
const states = getCommunitiesLoadingStates(state.communities, communities);
|
||||
return getMultipleCommunitiesFeedStateString(states, communityAddresses, isBrowserPureP2P);
|
||||
});
|
||||
|
||||
if (singleCommunityFeedStateString) {
|
||||
return singleCommunityFeedStateString;
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
// elsewhere in production code.
|
||||
|
||||
export { default as localForageLru } from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js';
|
||||
export { flattenCommentsPages } from '@bitsocial/bitsocial-react-hooks/dist/lib/utils/index.js';
|
||||
export { communityPostsCacheExpired, flattenCommentsPages } from '@bitsocial/bitsocial-react-hooks/dist/lib/utils/index.js';
|
||||
export { getEquivalentCommunityAddressGroupKey, pickPreferredEquivalentCommunityAddress } from '@bitsocial/bitsocial-react-hooks/dist/lib/community-address.js';
|
||||
|
||||
+26
-2
@@ -1,10 +1,35 @@
|
||||
type ReactScanReportRow = { count: number; time: number };
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
import('react-scan').then(({ scan, getReport }) => {
|
||||
import('react-scan').then(({ scan }) => {
|
||||
// react-scan's own `getReport()` is unusable here: it reads `Store.legacyReportData`, which
|
||||
// 0.5.3 never writes to, and the live `Store.reportData` is only populated while the toolbar
|
||||
// is visible AND a component is manually focused in the inspector — neither holds under
|
||||
// automation. `onRender` has no such gate, so we accumulate the report ourselves.
|
||||
const report = new Map<string, ReactScanReportRow>();
|
||||
|
||||
scan({
|
||||
enabled: true,
|
||||
showToolbar: !(window as any).__PROFILING__,
|
||||
onRender: (fiber, renders) => {
|
||||
for (const render of renders) {
|
||||
const name = render.componentName || (fiber?.type as any)?.displayName || (fiber?.type as any)?.name;
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const row = report.get(name) || { count: 0, time: 0 };
|
||||
row.count += render.count || 1;
|
||||
row.time += render.time || 0;
|
||||
report.set(name, row);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Returns a plain object rather than a Map: callers serialize this with JSON.stringify, and
|
||||
// JSON.stringify(new Map()) is always "{}" regardless of contents.
|
||||
(window as any).__getReactScanReport = () => Object.fromEntries(report);
|
||||
(window as any).__resetReactScanReport = () => report.clear();
|
||||
|
||||
const notReady = async () => ({
|
||||
error: 'element-source is not ready yet.',
|
||||
});
|
||||
@@ -22,7 +47,6 @@ if (import.meta.env.DEV) {
|
||||
formatStack: () => '',
|
||||
};
|
||||
|
||||
(window as any).__getReactScanReport = getReport;
|
||||
(window as any).__ELEMENT_SOURCE__ = elementSourceApi;
|
||||
|
||||
import('element-source')
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// zustand v4's `zustand/shallow` default export wraps the real comparator in a
|
||||
// console.warn deprecation notice that fires on EVERY call. bitsocial-react-hooks
|
||||
// imports it as a default (comments.js, communities.js, feeds.js) and passes it as
|
||||
// the zustand equality function, so the warning fires once per subscriber per store
|
||||
// notification — ~1000 times/second while a feed streams.
|
||||
//
|
||||
// Aliasing `zustand/shallow` to this shim keeps identical comparison semantics while
|
||||
// dropping the per-call console.warn. Remove once bitsocial-react-hooks switches to
|
||||
// the named `import { shallow } from 'zustand/shallow'`.
|
||||
// Imported from 'zustand/vanilla/shallow' (not 'zustand/shallow') so the alias that
|
||||
// points at this file does not resolve back into itself.
|
||||
import { shallow } from 'zustand/vanilla/shallow';
|
||||
|
||||
export { shallow };
|
||||
export default shallow;
|
||||
@@ -67,6 +67,8 @@ vi.mock('../../../hooks/use-directory-list', async () => {
|
||||
vi.mock('../../../hooks/use-communities-stats', () => ({
|
||||
CommunityStatsCollector: ({ communityAddress }: { communityAddress: string }) =>
|
||||
createElement('div', { 'data-testid': 'stats-collector', 'data-address': communityAddress }),
|
||||
CommunityStatsMetadataLoader: ({ communityAddresses }: { communityAddresses: string[] }) =>
|
||||
createElement('div', { 'data-testid': 'stats-metadata-loader', 'data-addresses': communityAddresses.join(',') }),
|
||||
useCommunitiesStatsStore: (selector: (state: { communityStats: typeof testState.communityStats }) => unknown) => selector({ communityStats: testState.communityStats }),
|
||||
}));
|
||||
|
||||
@@ -167,12 +169,15 @@ describe('Home', () => {
|
||||
it('renders the home view chrome, child sections, collectors, and aggregated stats', () => {
|
||||
renderHome();
|
||||
|
||||
expect(vi.mocked(useFeedStateString)).toHaveBeenCalledWith([]);
|
||||
// Nothing is loading, so the loading indicator/ellipsis never mount and the live client-state
|
||||
// subscription is never opened. Stats previously subscribed with an empty list regardless.
|
||||
expect(vi.mocked(useFeedStateString)).not.toHaveBeenCalled();
|
||||
expect(document.title).toBe('5chan');
|
||||
expect(container.querySelector('[data-testid="disclaimer-modal"]')?.textContent).toBe('disclaimer-modal');
|
||||
expect(container.querySelector('[data-testid="directory-modal"]')?.textContent).toBe('directory-modal');
|
||||
expect(container.querySelector('[data-testid="boards-list"]')?.textContent).toBe('boards:2');
|
||||
expect(container.querySelector('[data-testid="popular-threads-box"]')?.textContent).toBe('popular:2:2');
|
||||
expect(container.querySelector('[data-testid="stats-metadata-loader"]')?.getAttribute('data-addresses')).toBe('music-posting.eth,tech-posting.eth');
|
||||
expect(container.querySelectorAll('[data-testid="stats-collector"]')).toHaveLength(2);
|
||||
expect(testState.directoryListCodes).toEqual([]);
|
||||
expect(container.textContent).toContain('stats');
|
||||
|
||||
+35
-12
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { memo, useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import styles from './home.module.css';
|
||||
import { type DirectoryCommunity, useDirectories, useDirectoryAddresses } from '../../hooks/use-directories';
|
||||
import { sortDirectoryBoardsByRank, useDirectoryLists } from '../../hooks/use-directory-list';
|
||||
import { CommunityStatsCollector, useCommunitiesStatsStore } from '../../hooks/use-communities-stats';
|
||||
import { CommunityStatsCollector, CommunityStatsMetadataLoader, useCommunitiesStatsStore } from '../../hooks/use-communities-stats';
|
||||
import PopularThreadsBox from './popular-threads-box';
|
||||
import BoardsList from './boards-list';
|
||||
import SiteLegalMeta from '../../components/site-legal-meta';
|
||||
@@ -190,6 +190,36 @@ const StatsOptionsModal = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Follows client states for every collected board, so it updates constantly while boards load.
|
||||
* The two components below own it instead of `Stats`, which otherwise rerendered the whole stats
|
||||
* box (and its tooltip portal) on every tick just to refresh this one string.
|
||||
*/
|
||||
const useStatsLoadingString = (addresses: string[]) => {
|
||||
const { t } = useTranslation();
|
||||
return useFeedStateString(addresses) || t('loading');
|
||||
};
|
||||
|
||||
const StatsLoadingIndicator = memo(({ addresses }: { addresses: string[] }) => {
|
||||
const loadingStateString = useStatsLoadingString(addresses);
|
||||
|
||||
return (
|
||||
<span className={styles.statsLoadingIconWrapper}>
|
||||
<Tooltip content={loadingStateString}>
|
||||
<span className={`${styles.statsLoadingIcon} yellowOfflineIcon`} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
StatsLoadingIndicator.displayName = 'StatsLoadingIndicator';
|
||||
|
||||
const StatsLoadingEllipsis = memo(({ addresses }: { addresses: string[] }) => {
|
||||
const loadingStateString = useStatsLoadingString(addresses);
|
||||
|
||||
return <LoadingEllipsis string={loadingStateString} />;
|
||||
});
|
||||
StatsLoadingEllipsis.displayName = 'StatsLoadingEllipsis';
|
||||
|
||||
const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const statsScope = useHomepageStatsOptionsStore((state) => state.statsScope);
|
||||
@@ -252,11 +282,10 @@ const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
|
||||
}, [collectorAddresses, communitiesStats]);
|
||||
const hasDisplayableStats = boardsWithStats > 0 || (collectorAddresses.length > 0 && boardsLoaded === collectorAddresses.length);
|
||||
const isStatsLoading = !hasDisplayableStats || boardsLoaded < collectorAddresses.length;
|
||||
const loadingStateString = useFeedStateString(isStatsLoading ? collectorAddresses : EMPTY_STATS_LIST) || t('loading');
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Render collectors to fetch stats for each community */}
|
||||
<CommunityStatsMetadataLoader communityAddresses={collectorAddresses} />
|
||||
{collectorAddresses.map((address) => (
|
||||
<CommunityStatsCollector key={address} communityAddress={address} />
|
||||
))}
|
||||
@@ -264,13 +293,7 @@ const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
|
||||
<div className={`${styles.boxBar} ${styles.color2ColorBar}`}>
|
||||
<h2 className={styles.statsTitle}>
|
||||
{t('stats')}
|
||||
{isStatsLoading && (
|
||||
<span className={styles.statsLoadingIconWrapper}>
|
||||
<Tooltip content={loadingStateString}>
|
||||
<span className={`${styles.statsLoadingIcon} yellowOfflineIcon`} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
{isStatsLoading && <StatsLoadingIndicator addresses={collectorAddresses} />}
|
||||
</h2>
|
||||
<StatsOptionsModal />
|
||||
</div>
|
||||
@@ -288,7 +311,7 @@ const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<LoadingEllipsis string={loadingStateString} />
|
||||
<StatsLoadingEllipsis addresses={collectorAddresses} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-3
@@ -490,6 +490,10 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
// bitsocial-react-hooks imports zustand/shallow's deprecated default export and
|
||||
// passes it as a store equality fn, so its console.warn fires on every comparator
|
||||
// call (~1000/s while feeds stream). Redirect to an unwrapped re-export.
|
||||
'zustand/shallow': resolve(__dirname, 'src/lib/zustand-shallow-shim.ts'),
|
||||
'node-fetch': 'isomorphic-fetch',
|
||||
assert: 'assert',
|
||||
stream: 'stream-browserify',
|
||||
@@ -524,9 +528,6 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (/[\\/]node_modules[\\/](@pkcprotocol[\\/]pkc-js)[\\/]/.test(id)) {
|
||||
return 'pkc-js';
|
||||
}
|
||||
if (/[\\/]node_modules[\\/](@bitsocialnet[\\/]bitsocial-react-hooks)[\\/]/.test(id)) {
|
||||
return 'bitsocial-react-hooks';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user