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
|
## 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).
|
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
|
- Always use the `-s=SESSION` flag on every playwright-cli command
|
||||||
- Replace `SESSION` and `ROUTE` placeholders with actual values
|
- Replace `SESSION` and `ROUTE` placeholders with actual values
|
||||||
- **Collect per-route data before navigating to the next route** — goto resets the document
|
- **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
|
- 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`
|
- **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
|
- 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)
|
### 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)
|
- Highlights rerendering components visually (toolbar + overlay)
|
||||||
- Tracks per-component render counts and times internally
|
- Accumulates per-component render counts and times via react-scan's `onRender` option
|
||||||
- Exposes `window.__getReactScanReport()` for programmatic collection
|
- 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.
|
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.
|
- **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).
|
- **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.
|
- **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)
|
### 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)
|
- Highlights rerendering components visually (toolbar + overlay)
|
||||||
- Tracks per-component render counts and times internally
|
- Accumulates per-component render counts and times via react-scan's `onRender` option
|
||||||
- Exposes `window.__getReactScanReport()` for programmatic collection
|
- 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.
|
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.
|
- **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).
|
- **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.
|
- **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
|
## 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).
|
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
|
- Always use the `-s=SESSION` flag on every playwright-cli command
|
||||||
- Replace `SESSION` and `ROUTE` placeholders with actual values
|
- Replace `SESSION` and `ROUTE` placeholders with actual values
|
||||||
- **Collect per-route data before navigating to the next route** — goto resets the document
|
- **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
|
- 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`
|
- **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
|
- 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)
|
### 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)
|
- Highlights rerendering components visually (toolbar + overlay)
|
||||||
- Tracks per-component render counts and times internally
|
- Accumulates per-component render counts and times via react-scan's `onRender` option
|
||||||
- Exposes `window.__getReactScanReport()` for programmatic collection
|
- 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.
|
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.
|
- **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).
|
- **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.
|
- **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`.
|
- **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.
|
- **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
|
- **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`.
|
- **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.
|
- **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
|
- **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 { useTranslation } from 'react-i18next';
|
||||||
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useAccountComment, useCommunity } from '@bitsocial/bitsocial-react-hooks';
|
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 { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -136,6 +137,7 @@ const BoardHeader = () => {
|
|||||||
{!isInArchiveView && <hr />}
|
{!isInArchiveView && <hr />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
BoardHeader.displayName = 'BoardHeader';
|
||||||
|
|
||||||
export default 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 { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAccountComment } from '@bitsocial/bitsocial-react-hooks';
|
import { useAccountComment } from '@bitsocial/bitsocial-react-hooks';
|
||||||
@@ -96,7 +96,9 @@ const findBoardAddressByCode = (code: string, directories: DirectoryCommunity[])
|
|||||||
return entry?.address || null;
|
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 { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -305,9 +307,10 @@ const BoardsBarDesktop = () => {
|
|||||||
{showSearchBar && <SearchBar setShowSearchBar={setShowSearchBar} />}
|
{showSearchBar && <SearchBar setShowSearchBar={setShowSearchBar} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
BoardsBarDesktop.displayName = 'BoardsBarDesktop';
|
||||||
|
|
||||||
const BoardsBarMobile = ({ communityAddress }: { communityAddress?: string }) => {
|
const BoardsBarMobile = memo(({ communityAddress }: { communityAddress?: string }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const directories = useDirectories();
|
const directories = useDirectories();
|
||||||
@@ -415,9 +418,10 @@ const BoardsBarMobile = ({ communityAddress }: { communityAddress?: string }) =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
BoardsBarMobile.displayName = 'BoardsBarMobile';
|
||||||
|
|
||||||
const BoardsBar = () => {
|
const BoardsBar = memo(() => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const accountComment = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) });
|
const accountComment = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) });
|
||||||
const resolvedCommunityAddress = useResolvedCommunityAddress();
|
const resolvedCommunityAddress = useResolvedCommunityAddress();
|
||||||
@@ -429,6 +433,7 @@ const BoardsBar = () => {
|
|||||||
<BoardsBarMobile communityAddress={communityAddress} />
|
<BoardsBarMobile communityAddress={communityAddress} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
BoardsBar.displayName = 'BoardsBar';
|
||||||
|
|
||||||
export default BoardsBar;
|
export default BoardsBar;
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight,
|
|||||||
};
|
};
|
||||||
const loadingStyle = { opacity: isLoaded ? 1 : 0 };
|
const loadingStyle = { opacity: isLoaded ? 1 : 0 };
|
||||||
|
|
||||||
const { imageSize } = useCatalogStyleStore();
|
const imageSize = useCatalogStyleStore((state) => state.imageSize);
|
||||||
|
|
||||||
let displayWidth, displayHeight;
|
let displayWidth, displayHeight;
|
||||||
const maxThumbnailSize = imageSize === 'Large' ? 250 : 150;
|
const maxThumbnailSize = imageSize === 'Large' ? 250 : 150;
|
||||||
@@ -248,7 +248,8 @@ const CatalogPost = memo(
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const { imageSize, showOPComment } = useCatalogStyleStore();
|
const imageSize = useCatalogStyleStore((state) => state.imageSize);
|
||||||
|
const showOPComment = useCatalogStyleStore((state) => state.showOPComment);
|
||||||
const maxWidth = imageSize === 'Large' ? '250px' : '150px';
|
const maxWidth = imageSize === 'Large' ? '250px' : '150px';
|
||||||
const maxHeight = imageSize === 'Large' ? '250px' : '150px';
|
const maxHeight = imageSize === 'Large' ? '250px' : '150px';
|
||||||
const CSSProperties = {
|
const CSSProperties = {
|
||||||
|
|||||||
@@ -81,6 +81,22 @@ const RepliesFooter = ({ hasMore, loadingString }: { hasMore: boolean; loadingSt
|
|||||||
</div>
|
</div>
|
||||||
) : null;
|
) : 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
|
// Store scroll position for replies virtuoso across navigations
|
||||||
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
||||||
|
|
||||||
@@ -222,7 +238,9 @@ const PostInfo = ({
|
|||||||
|
|
||||||
const { hidden } = useHide({ cid: cid || '' });
|
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 = () => {
|
const onReplyModalClick = () => {
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
@@ -917,7 +935,6 @@ const PostDesktop = ({
|
|||||||
});
|
});
|
||||||
const linksCount = totalLinksCount - visiblelinksCount;
|
const linksCount = totalLinksCount - visiblelinksCount;
|
||||||
|
|
||||||
const stateString = useStateString(resolvedPost) || t('downloading_board');
|
|
||||||
const hasFailedState = state === 'failed';
|
const hasFailedState = state === 'failed';
|
||||||
const { canDeleteFailedPost, canRetryFailedPost, isDeletingFailedPost, isRetryingFailedPost, onDeleteFailedPost, onRetryFailedPost } = useDeleteFailedPost(
|
const { canDeleteFailedPost, canRetryFailedPost, isDeletingFailedPost, isRetryingFailedPost, onDeleteFailedPost, onRetryFailedPost } = useDeleteFailedPost(
|
||||||
resolvedPost,
|
resolvedPost,
|
||||||
@@ -1276,16 +1293,12 @@ const PostDesktop = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isInPendingPostView &&
|
{!isInPendingPostView &&
|
||||||
stateString &&
|
|
||||||
!hasFailedState &&
|
!hasFailedState &&
|
||||||
state !== 'succeeded' &&
|
state !== 'succeeded' &&
|
||||||
!shouldSuppressPostLoadingState(resolvedPost) &&
|
!shouldSuppressPostLoadingState(resolvedPost) &&
|
||||||
isInPostPageView &&
|
isInPostPageView &&
|
||||||
!(!showReplies && !showAllReplies) ? (
|
!(!showReplies && !showAllReplies) ? (
|
||||||
<div className={styles.stateString}>
|
<PostLoadingState post={resolvedPost} />
|
||||||
<br />
|
|
||||||
<LoadingEllipsis string={stateString} />
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
hasFailedState && <span className={styles.error}>{t('failed')}</span>
|
hasFailedState && <span className={styles.error}>{t('failed')}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -171,7 +171,8 @@ const PostInfoAndMedia = ({
|
|||||||
|
|
||||||
const { hidden } = useHide({ cid: cid || '' });
|
const { hidden } = useHide({ cid: cid || '' });
|
||||||
|
|
||||||
const { openReplyModal } = useReplyModalStore();
|
// Selector-scoped: see post-desktop.tsx.
|
||||||
|
const openReplyModal = useReplyModalStore((state) => state.openReplyModal);
|
||||||
|
|
||||||
const onReplyModalClick = () => {
|
const onReplyModalClick = () => {
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Version from '../version/version';
|
import Version from '../version/version';
|
||||||
@@ -10,7 +11,7 @@ type SiteLegalMetaProps = {
|
|||||||
order?: SiteLegalMetaOrder;
|
order?: SiteLegalMetaOrder;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LicenseText = () => {
|
const LicenseText = memo(() => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,9 +22,10 @@ const LicenseText = () => {
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
LicenseText.displayName = 'LicenseText';
|
||||||
|
|
||||||
const VersionFeedbackContributors = () => {
|
const VersionFeedbackContributors = memo(() => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,9 +36,10 @@ const VersionFeedbackContributors = () => {
|
|||||||
</a>
|
</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 first = order === 'version-first' ? <VersionFeedbackContributors /> : <LicenseText />;
|
||||||
const second = order === 'version-first' ? <LicenseText /> : <VersionFeedbackContributors />;
|
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>
|
<span style={{ display: 'block', marginTop: 5 }}>{second}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
SiteLegalMeta.displayName = 'SiteLegalMeta';
|
||||||
|
|
||||||
export default SiteLegalMeta;
|
export default SiteLegalMeta;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { currentAppVersion } from '../../lib/app-version';
|
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 shortCommitRef = commitRef.slice(0, 7);
|
||||||
const isElectron = window.electronApi?.isElectron === true;
|
const isElectron = window.electronApi?.isElectron === true;
|
||||||
|
|
||||||
const Version = () => {
|
const Version = memo(() => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -34,6 +35,7 @@ const Version = () => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
Version.displayName = 'Version';
|
||||||
|
|
||||||
export default 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', () => ({
|
vi.mock('../../stores/use-community-offline-store', () => ({
|
||||||
default: () => ({
|
// 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,
|
initializeCommunityOfflineState: testState.initializeMock,
|
||||||
setCommunityOfflineState: testState.setOfflineStateMock,
|
setCommunityOfflineState: testState.setOfflineStateMock,
|
||||||
communityOfflineState: testState.communityOfflineState,
|
communityOfflineState: testState.communityOfflineState,
|
||||||
}),
|
};
|
||||||
|
return selector ? selector(state) : state;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../stores/use-communities-loading-start-timestamps-store', () => ({
|
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 }>,
|
communities: {} as Record<string, { address?: string; name?: string; publicKey?: string; state?: string; updatedAt?: number }>,
|
||||||
syncStatuses: {} as Record<string, { syncState: CommunitySyncState }>,
|
syncStatuses: {} as Record<string, { syncState: CommunitySyncState }>,
|
||||||
candidatePublicKeys: {} as Record<string, string | undefined>,
|
candidatePublicKeys: {} as Record<string, string | undefined>,
|
||||||
offlineSelections: [] as unknown[],
|
communityStoreListeners: [] as Array<() => void>,
|
||||||
|
offlineStoreListeners: [] as Array<() => void>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('react-router-dom', async () => {
|
vi.mock('react-router-dom', async () => {
|
||||||
@@ -53,21 +54,38 @@ vi.mock('../use-directory-list', async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('../../stores/use-community-offline-store', () => ({
|
vi.mock('../../stores/use-community-offline-store', () => {
|
||||||
default: <T,>(selector: (state: { communityOfflineState: typeof testState.offlineStates }) => T) => {
|
const getState = () => ({ communityOfflineState: testState.offlineStates });
|
||||||
const selected = selector({ communityOfflineState: testState.offlineStates });
|
const store = Object.assign(<T,>(selector: (state: ReturnType<typeof getState>) => T) => selector(getState()), {
|
||||||
testState.offlineSelections.push(selected);
|
getState,
|
||||||
return selected;
|
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', () => ({
|
vi.mock('../../lib/bitsocial-internals/stores', () => {
|
||||||
communitiesStore: <T,>(selector: (state: { communities: typeof testState.communities; syncStatuses: typeof testState.syncStatuses }) => T) =>
|
const getState = () => ({
|
||||||
selector({
|
|
||||||
communities: testState.communities,
|
communities: testState.communities,
|
||||||
syncStatuses: testState.syncStatuses,
|
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 () => {
|
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');
|
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 latestDirectoryBoardPath: { boardPath: string | undefined; isDirectoryCandidate: boolean };
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
|
let hookRenderCount: number;
|
||||||
|
|
||||||
const HookHarness = () => {
|
const HookHarness = () => {
|
||||||
|
hookRenderCount += 1;
|
||||||
latestValue = useResolvedCommunityAddress(testState.boardIdentifierOverride);
|
latestValue = useResolvedCommunityAddress(testState.boardIdentifierOverride);
|
||||||
latestDirectoryBoardPath = useResolvedDirectoryBoardPath(testState.boardIdentifier);
|
latestDirectoryBoardPath = useResolvedDirectoryBoardPath(testState.boardIdentifier);
|
||||||
return null;
|
return null;
|
||||||
@@ -113,7 +133,9 @@ describe('useResolvedCommunityAddress', () => {
|
|||||||
testState.communities = {};
|
testState.communities = {};
|
||||||
testState.syncStatuses = {};
|
testState.syncStatuses = {};
|
||||||
testState.candidatePublicKeys = {};
|
testState.candidatePublicKeys = {};
|
||||||
testState.offlineSelections = [];
|
testState.communityStoreListeners = [];
|
||||||
|
testState.offlineStoreListeners = [];
|
||||||
|
hookRenderCount = 0;
|
||||||
|
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
@@ -304,7 +326,44 @@ describe('useResolvedCommunityAddress', () => {
|
|||||||
await renderHook();
|
await renderHook();
|
||||||
|
|
||||||
expect(latestValue).toBe('custom-board.bso');
|
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 () => {
|
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 { createElement } from 'react';
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { communitiesStore } from '../../lib/bitsocial-internals/stores';
|
||||||
import useStateString, { useFeedStateString } from '../use-state-string';
|
import useStateString, { useFeedStateString } from '../use-state-string';
|
||||||
|
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
@@ -16,7 +17,6 @@ const testState = vi.hoisted(() => ({
|
|||||||
updatingState?: string;
|
updatingState?: string;
|
||||||
}
|
}
|
||||||
| undefined,
|
| undefined,
|
||||||
communitiesStates: {} as Record<string, { clientUrls: string[]; communityAddresses: string[] }>,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
||||||
@@ -24,9 +24,10 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
|||||||
states: testState.clientsStates,
|
states: testState.clientsStates,
|
||||||
}),
|
}),
|
||||||
useCommunity: () => testState.community,
|
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', () => ({
|
vi.mock('lodash/debounce', () => ({
|
||||||
@@ -40,6 +41,32 @@ vi.mock('lodash/debounce', () => ({
|
|||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
let latestValue: string | undefined;
|
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 = ({
|
const StateStringHarness = ({
|
||||||
value,
|
value,
|
||||||
@@ -55,6 +82,7 @@ const StateStringHarness = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FeedStateStringHarness = ({ addresses }: { addresses?: string[] }) => {
|
const FeedStateStringHarness = ({ addresses }: { addresses?: string[] }) => {
|
||||||
|
feedHarnessRenderCount += 1;
|
||||||
latestValue = useFeedStateString(addresses);
|
latestValue = useFeedStateString(addresses);
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
@@ -65,7 +93,8 @@ describe('use-state-string', () => {
|
|||||||
localStorage.removeItem('5chan:pure-p2p-browser-enabled');
|
localStorage.removeItem('5chan:pure-p2p-browser-enabled');
|
||||||
testState.clientsStates = {};
|
testState.clientsStates = {};
|
||||||
testState.community = undefined;
|
testState.community = undefined;
|
||||||
testState.communitiesStates = {};
|
communitiesStore.setState({ communities: {} });
|
||||||
|
feedHarnessRenderCount = 0;
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
@@ -197,55 +226,116 @@ describe('use-state-string', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('aggregates multi-board feed states across address resolution, threads, and pages', () => {
|
it('aggregates multi-board feed states across address resolution, threads, and pages', () => {
|
||||||
testState.communitiesStates = {
|
const addresses = ['music-posting.eth', 'tech-posting.eth', 'video-posting.eth', 'finance-posting.eth', 'photo-posting.eth'];
|
||||||
'fetching-ipfs': {
|
communitiesStore.setState({
|
||||||
clientUrls: ['https://ipfs.io'],
|
communities: {
|
||||||
communityAddresses: ['music-posting.eth'],
|
'music-posting.eth': createLoadingCommunity({
|
||||||
},
|
address: 'music-posting.eth',
|
||||||
'fetching-ipns': {
|
state: 'fetching-ipns',
|
||||||
clientUrls: ['https://gateway.example.com'],
|
clientUrls: ['https://gateway.example.com'],
|
||||||
communityAddresses: ['music-posting.eth', 'tech-posting.eth'],
|
pageClientUrls: ['https://ipfs.io'],
|
||||||
},
|
}),
|
||||||
'page-1': {
|
'tech-posting.eth': createLoadingCommunity({
|
||||||
clientUrls: ['https://gateway.example.com', 'https://ipfs.io'],
|
address: 'tech-posting.eth',
|
||||||
communityAddresses: ['music-posting.eth'],
|
state: 'fetching-ipns',
|
||||||
},
|
clientUrls: ['https://gateway.example.com'],
|
||||||
'resolving-address': {
|
}),
|
||||||
|
'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'],
|
clientUrls: ['https://ens.example.com'],
|
||||||
communityAddresses: ['music-posting.eth', 'tech-posting.eth'],
|
}),
|
||||||
|
'photo-posting.eth': createLoadingCommunity({
|
||||||
|
address: 'photo-posting.eth',
|
||||||
|
state: 'resolving-address',
|
||||||
|
clientUrls: ['https://ens.example.com'],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
act(() => {
|
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');
|
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', () => {
|
it('aggregates browser libp2p feed states as peer downloads', () => {
|
||||||
testState.communitiesStates = {
|
const addresses = ['music-posting.eth', 'tech-posting.eth', 'video-posting.eth'];
|
||||||
'fetching-ipfs': {
|
communitiesStore.setState({
|
||||||
|
communities: {
|
||||||
|
'music-posting.eth': createLoadingCommunity({
|
||||||
|
address: 'music-posting.eth',
|
||||||
|
state: 'fetching-ipns',
|
||||||
clientUrls: ['libp2pjs'],
|
clientUrls: ['libp2pjs'],
|
||||||
communityAddresses: ['music-posting.eth'],
|
pageClientUrls: ['libp2pjs'],
|
||||||
},
|
}),
|
||||||
'fetching-ipns': {
|
'tech-posting.eth': createLoadingCommunity({
|
||||||
|
address: 'tech-posting.eth',
|
||||||
|
state: 'fetching-ipns',
|
||||||
clientUrls: ['libp2pjs'],
|
clientUrls: ['libp2pjs'],
|
||||||
communityAddresses: ['music-posting.eth', 'tech-posting.eth'],
|
}),
|
||||||
},
|
'video-posting.eth': createLoadingCommunity({
|
||||||
'page-1': {
|
address: 'video-posting.eth',
|
||||||
|
state: 'fetching-ipfs',
|
||||||
clientUrls: ['libp2pjs'],
|
clientUrls: ['libp2pjs'],
|
||||||
communityAddresses: ['music-posting.eth'],
|
}),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
act(() => {
|
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');
|
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', () => {
|
it('shows an immediate board-specific loading string before detailed multi-board states arrive', () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(createElement(FeedStateStringHarness, { addresses: ['music-posting.eth', 'tech-posting.eth'] }));
|
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 { create } from 'zustand';
|
||||||
import { useCommunityStats } from '@bitsocial/bitsocial-react-hooks';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useCommunityIdentifier } from './use-community-identifiers';
|
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 = {
|
type CommunityStatsState = {
|
||||||
communityStats: { [communityAddress: string]: any };
|
communityStats: { [communityAddress: string]: any };
|
||||||
setCommunityStats: (communityAddress: string, stats: any) => void;
|
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) => ({
|
export const useCommunitiesStatsStore = create<CommunityStatsState>((set) => ({
|
||||||
communityStats: {},
|
communityStats: {},
|
||||||
setCommunityStats: (communityAddress, stats) =>
|
setCommunityStats: (communityAddress, stats) =>
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
communityStats: { ...state.communityStats, [communityAddress]: stats },
|
// 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 CommunityStatsRequest = ({
|
||||||
const community = useCommunityIdentifier(communityAddress);
|
communityAddress,
|
||||||
const stats = useCommunityStats(community ? { community } : undefined);
|
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);
|
const setCommunityStats = useCommunitiesStatsStore((state) => state.setCommunityStats);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stats && (stats.allPostCount !== undefined || stats.state === 'failed')) {
|
// useCommunityStats can synchronously return the previous CID's cached value while it
|
||||||
setCommunityStats(communityAddress, stats);
|
// 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;
|
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';
|
||||||
|
|||||||
+45
-106
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { create } from 'zustand';
|
||||||
import { vendoredDirectoryLists as directoryListsData, vendoredDirectoryDefaults as directoryDefaultsData } from '../data/vendored-directory-lists';
|
import { vendoredDirectoryLists as directoryListsData, vendoredDirectoryDefaults as directoryDefaultsData } from '../data/vendored-directory-lists';
|
||||||
import { isSpecialBoardAddress, isSpecialBoardCode } from '../lib/special-boards';
|
import { isSpecialBoardAddress, isSpecialBoardCode } from '../lib/special-boards';
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +58,8 @@ export const __resetDirectoriesModuleStateForTests = () => {
|
|||||||
cacheDefaults = null;
|
cacheDefaults = null;
|
||||||
fallbackDirectoriesData = null;
|
fallbackDirectoriesData = null;
|
||||||
fallbackDirectoryDefaults = null;
|
fallbackDirectoryDefaults = null;
|
||||||
|
directoriesHydrationStarted = false;
|
||||||
|
useDirectoriesStore.setState({ communities: getFallbackDirectoriesData().communities, loading: true, error: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDirectoryIdentifiers = (community: DirectoryCommunity): string[] => [
|
const getDirectoryIdentifiers = (community: DirectoryCommunity): string[] => [
|
||||||
@@ -443,40 +446,41 @@ const fetchDirectoriesFromGitHubDeduped = async (): Promise<DirectoriesData | nu
|
|||||||
return inFlightGitHubFetch;
|
return inFlightGitHubFetch;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDirectories = () => {
|
/**
|
||||||
// Use vendored data as initial state to prevent theme flash on first load
|
* Directory data is global, so it lives in one store rather than in per-caller `useState`.
|
||||||
// This ensures NSFW status is known synchronously before first render
|
* `useDirectories` has ~60 call sites, several of them inside list rows (catalog tiles, markdown
|
||||||
const [state, setState] = useState<DirectoriesState>({
|
* 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,
|
communities: getFallbackDirectoriesData().communities,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
let directoriesHydrationStarted = false;
|
||||||
let isMounted = true;
|
|
||||||
const hydrateCommunities = (data: DirectoriesData) => {
|
const setDirectoriesCommunities = (data: DirectoriesData) => {
|
||||||
cacheCommunities = data.communities;
|
cacheCommunities = data.communities;
|
||||||
if (isMounted) {
|
useDirectoriesStore.setState({ communities: data.communities, loading: false, error: null });
|
||||||
setState({
|
|
||||||
communities: data.communities,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
(async () => {
|
const hydrateDirectoriesOnce = () => {
|
||||||
|
if (directoriesHydrationStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
directoriesHydrationStarted = true;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
if (cacheCommunities) {
|
if (cacheCommunities) {
|
||||||
setState({
|
useDirectoriesStore.setState({ communities: cacheCommunities, loading: false, error: null });
|
||||||
communities: cacheCommunities,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Check localStorage first
|
// Check localStorage first
|
||||||
const cachedData = getFromLocalStorage();
|
const cachedData = getFromLocalStorage();
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
hydrateCommunities(cachedData);
|
setDirectoriesCommunities(cachedData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,36 +488,28 @@ export const useDirectories = () => {
|
|||||||
// Refresh from GitHub when the session cache is stale, without refetching for every hook mount.
|
// Refresh from GitHub when the session cache is stale, without refetching for every hook mount.
|
||||||
const directories = await fetchDirectoriesFromGitHubDeduped();
|
const directories = await fetchDirectoriesFromGitHubDeduped();
|
||||||
if (directories) {
|
if (directories) {
|
||||||
hydrateCommunities(directories);
|
setDirectoriesCommunities(directories);
|
||||||
} else if (!cacheCommunities) {
|
} else if (!cacheCommunities) {
|
||||||
hydrateCommunities(getFallbackDirectoriesData());
|
setDirectoriesCommunities(getFallbackDirectoriesData());
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to fetch directories from GitHub:', 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 (cacheCommunities) {
|
||||||
if (isMounted) {
|
useDirectoriesStore.setState({ communities: cacheCommunities, loading: false, error: null });
|
||||||
setState({
|
|
||||||
communities: cacheCommunities,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
hydrateCommunities(getFallbackDirectoriesData());
|
setDirectoriesCommunities(getFallbackDirectoriesData());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Always prefer cacheCommunities (module-level, stable reference) when available
|
export const useDirectories = () => {
|
||||||
// Only use state.communities during initial load before cache is populated
|
const communities = useDirectoriesStore((state) => state.communities);
|
||||||
// This ensures a stable reference for memoization in consuming hooks
|
|
||||||
return cacheCommunities || state.communities || getFallbackDirectoriesData().communities;
|
useEffect(hydrateDirectoriesOnce, []);
|
||||||
|
|
||||||
|
// Prefer the module-level cache so consuming hooks keep a stable reference for memoization.
|
||||||
|
return cacheCommunities || communities || getFallbackDirectoriesData().communities;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDirectoryDefaults = (): DirectoryDefaultsData => {
|
export const useDirectoryDefaults = (): DirectoryDefaultsData => {
|
||||||
@@ -522,73 +518,16 @@ export const useDirectoryDefaults = (): DirectoryDefaultsData => {
|
|||||||
return cacheDefaults ?? getFallbackDirectoryDefaults();
|
return cacheDefaults ?? getFallbackDirectoryDefaults();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDirectoriesState = () => {
|
export const useDirectoriesState = (): DirectoriesState => {
|
||||||
// Use vendored data as fallback to prevent theme flash on first load
|
// Field-level selectors rather than a whole-store subscription, so consumers only rerender
|
||||||
const [state, setState] = useState<DirectoriesState>({
|
// when the field they actually read changes.
|
||||||
communities: cacheCommunities || getFallbackDirectoriesData().communities,
|
const communities = useDirectoriesStore((state) => state.communities);
|
||||||
loading: !cacheCommunities,
|
const loading = useDirectoriesStore((state) => state.loading);
|
||||||
error: null,
|
const error = useDirectoriesStore((state) => state.error);
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(hydrateDirectoriesOnce, []);
|
||||||
let isMounted = true;
|
|
||||||
const hydrateCommunities = (data: DirectoriesData) => {
|
|
||||||
cacheCommunities = data.communities;
|
|
||||||
if (isMounted) {
|
|
||||||
setState({
|
|
||||||
communities: data.communities,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
(async () => {
|
return useMemo(() => ({ communities, loading, error }), [communities, loading, error]);
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDirectoryAddresses = () => {
|
export const useDirectoryAddresses = () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import useAllFeedFilterStore from '../stores/use-all-feed-filter-store';
|
|||||||
|
|
||||||
export const useFilteredDirectoryAddresses = () => {
|
export const useFilteredDirectoryAddresses = () => {
|
||||||
const directories = useDirectories();
|
const directories = useDirectories();
|
||||||
const { filter } = useAllFeedFilterStore();
|
const filter = useAllFeedFilterStore((state) => state.filter);
|
||||||
|
|
||||||
const filteredAddresses = useMemo(() => {
|
const filteredAddresses = useMemo(() => {
|
||||||
if (filter === 'all') {
|
if (filter === 'all') {
|
||||||
|
|||||||
@@ -17,14 +17,18 @@ const useIsCommunityOffline = (community?: CommunityWithSyncLifecycle | undefine
|
|||||||
const { state, syncState, hasCachedData, updatedAt } = community || {};
|
const { state, syncState, hasCachedData, updatedAt } = community || {};
|
||||||
const communityKey = getCommunityOfflineKey(community, communityAddressHint);
|
const communityKey = getCommunityOfflineKey(community, communityAddressHint);
|
||||||
const nowSeconds = useNowSeconds(!!communityKey);
|
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);
|
const communitiesLoadingStartTimestamps = useCommunitiesLoadingStartTimestamps(communityKey ? [communityKey] : undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (communityKey && !communityOfflineState[communityKey]) {
|
if (communityKey && !storedOfflineState) {
|
||||||
initializeCommunityOfflineState(communityKey);
|
initializeCommunityOfflineState(communityKey);
|
||||||
}
|
}
|
||||||
}, [communityKey, communityOfflineState, initializeCommunityOfflineState]);
|
}, [communityKey, storedOfflineState, initializeCommunityOfflineState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (communityKey) {
|
if (communityKey) {
|
||||||
@@ -36,7 +40,7 @@ const useIsCommunityOffline = (community?: CommunityWithSyncLifecycle | undefine
|
|||||||
return { isOffline: false, isOnlineStatusLoading: false, offlineIconClass: '', offlineTitle: false };
|
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 loadingStartTimestamp = communitiesLoadingStartTimestamps[0] || 0;
|
||||||
const isStale = isCommunityUpdateStale(updatedAt, nowSeconds);
|
const isStale = isCommunityUpdateStale(updatedAt, nowSeconds);
|
||||||
const hasUsableCachedData = (hasCachedData ?? typeof updatedAt === 'number') && updatedAt !== undefined;
|
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 { useParams } from 'react-router-dom';
|
||||||
import type { CommunitySyncState } from '@bitsocial/bitsocial-react-hooks';
|
import type { CommunitySyncState } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import { normalizeBoardAddress, useDirectories } from './use-directories';
|
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.
|
* 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 boardIdentifier = boardIdentifierOverride ?? params.boardIdentifier;
|
||||||
const isCode = !!boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
const isCode = !!boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
||||||
const { list } = useDirectoryList(isCode ? boardIdentifier : undefined);
|
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 nowSeconds = useNowSeconds(isCode);
|
||||||
|
const winnerAddress = useDirectoryWinnerAddress(list?.boards, isCode, nowSeconds);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!boardIdentifier) return undefined;
|
if (!boardIdentifier) return undefined;
|
||||||
if (isCode && list && list.boards.length > 0) {
|
if (isCode && winnerAddress) {
|
||||||
const isOffline = (board: DirectoryListBoard) =>
|
return winnerAddress;
|
||||||
isCommunityKnownOffline(getDirectoryBoardFreshnessState(communities, syncStatuses, offlineStates?.[board.address], board), nowSeconds);
|
|
||||||
const winner = pickDirectoryWinner(list.boards, isOffline);
|
|
||||||
if (winner) return winner.address;
|
|
||||||
}
|
}
|
||||||
return getCommunityAddress(boardIdentifier, directories);
|
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 isCode = !!boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
||||||
const directoryCode = useMemo(() => (boardIdentifier && !isCode ? getDirectoryCodeForBoardAddress(boardIdentifier) : undefined), [boardIdentifier, isCode]);
|
const directoryCode = useMemo(() => (boardIdentifier && !isCode ? getDirectoryCodeForBoardAddress(boardIdentifier) : undefined), [boardIdentifier, isCode]);
|
||||||
const { list } = useDirectoryList(directoryCode);
|
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 nowSeconds = useNowSeconds(!!directoryCode);
|
||||||
|
const winnerAddress = useDirectoryWinnerAddress(list?.boards, !!directoryCode, nowSeconds);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!boardIdentifier || !directoryCode) {
|
if (!boardIdentifier || !directoryCode) {
|
||||||
@@ -152,15 +187,11 @@ export const useResolvedDirectoryBoardPath = (boardIdentifier: string | undefine
|
|||||||
return { boardPath: undefined, isDirectoryCandidate: true };
|
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 {
|
return {
|
||||||
boardPath: winner && areSameBoardAddress(winner.address, boardIdentifier) ? directoryCode : undefined,
|
boardPath: winnerAddress && areSameBoardAddress(winnerAddress, boardIdentifier) ? directoryCode : undefined,
|
||||||
isDirectoryCandidate: true,
|
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;
|
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) => {
|
const getCommunityByAddress = (communities: Record<string, unknown> | undefined, communityAddress: string | undefined) => {
|
||||||
if (!communities || !communityAddress) {
|
if (!communities || !communityAddress) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -14,11 +46,7 @@ const getCommunityByAddress = (communities: Record<string, unknown> | undefined,
|
|||||||
return exactMatch;
|
return exactMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedAddress = normalizeBoardAddress(communityAddress);
|
return getNormalizedCommunityIndex(communities).get(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];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const shallowEqual = (obj1: Record<string, any> | undefined, obj2: Record<string, any> | undefined): boolean => {
|
const shallowEqual = (obj1: Record<string, any> | undefined, obj2: Record<string, any> | undefined): boolean => {
|
||||||
|
|||||||
+159
-90
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useClientsStates, useCommunity, useCommunitiesStates } from '@bitsocial/bitsocial-react-hooks';
|
import { useClientsStates, useCommunity, type Communities, type CommunityIdentifier } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import debounce from 'lodash/debounce';
|
|
||||||
import getShortAddress from '../lib/get-short-address';
|
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 { isBrowserPureP2PEnabled } from '../lib/p2p-runtime';
|
||||||
import { useCommunityIdentifiers } from './use-community-identifiers';
|
import { useCommunityIdentifiers } from './use-community-identifiers';
|
||||||
|
|
||||||
@@ -20,8 +21,14 @@ type CommunityLoadingState = {
|
|||||||
clientUrls: string[];
|
clientUrls: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCommunityLoadingState = (state: string[] | CommunityLoadingState | undefined): state is CommunityLoadingState =>
|
type CommunityLoadingStates = Record<string, CommunityLoadingState>;
|
||||||
Boolean(state && !Array.isArray(state) && 'communityAddresses' in state && 'clientUrls' in state);
|
|
||||||
|
type MutableCommunityLoadingState = {
|
||||||
|
communityAddresses: Set<string>;
|
||||||
|
clientUrls: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MutableCommunityLoadingStates = Record<string, MutableCommunityLoadingState>;
|
||||||
|
|
||||||
const isBrowserLibp2pClient = (clientUrl: string) => clientUrl === 'libp2pjs';
|
const isBrowserLibp2pClient = (clientUrl: string) => clientUrl === 'libp2pjs';
|
||||||
|
|
||||||
@@ -75,29 +82,162 @@ const sanitizeSingleFeedLoadingState = (stateString?: string): string | undefine
|
|||||||
.replace(/\bloading thread\b/g, 'loading board');
|
.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 useStateString = (commentOrCommunity: CommentOrCommunity | undefined): string | undefined => {
|
||||||
const { states: rawStates } = useClientsStates({ comment: commentOrCommunity }) as { states: States };
|
const { states: rawStates } = useClientsStates({ comment: commentOrCommunity }) as { states: States };
|
||||||
const isBrowserPureP2P = isBrowserPureP2PEnabled();
|
const isBrowserPureP2P = isBrowserPureP2PEnabled();
|
||||||
|
|
||||||
const debouncedStates = useMemo(() => {
|
|
||||||
const debouncedValue = debounce((value: States) => value, 300);
|
|
||||||
return debouncedValue(rawStates);
|
|
||||||
}, [rawStates]);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
let stateString: string | undefined = '';
|
let stateString: string | undefined = '';
|
||||||
const resolvingParts: string[] = [];
|
const resolvingParts: string[] = [];
|
||||||
const downloadingParts: string[] = [];
|
const downloadingParts: string[] = [];
|
||||||
const downloadingClientUrls: string[] = [];
|
const downloadingClientUrls: string[] = [];
|
||||||
|
|
||||||
for (const state in debouncedStates) {
|
for (const state in rawStates) {
|
||||||
if (debouncedStates[state].length === 0) continue;
|
if (rawStates[state].length === 0) continue;
|
||||||
const friendlyName = getFriendlyStateName(state);
|
const friendlyName = getFriendlyStateName(state);
|
||||||
if (state.includes('resolving')) {
|
if (state.includes('resolving')) {
|
||||||
resolvingParts.push(friendlyName);
|
resolvingParts.push(friendlyName);
|
||||||
} else {
|
} else {
|
||||||
downloadingParts.push(friendlyName);
|
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;
|
return stateString === '' ? undefined : stateString;
|
||||||
}, [debouncedStates, commentOrCommunity, isBrowserPureP2P]);
|
}, [rawStates, commentOrCommunity, isBrowserPureP2P]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useFeedStateString = (communityAddresses?: string[]): string | undefined => {
|
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 rawSingleCommunityFeedStateString = useStateString(communityAddress ? community : undefined);
|
||||||
const singleCommunityFeedStateString = communityAddress ? sanitizeSingleFeedLoadingState(rawSingleCommunityFeedStateString) : undefined;
|
const singleCommunityFeedStateString = communityAddress ? sanitizeSingleFeedLoadingState(rawSingleCommunityFeedStateString) : undefined;
|
||||||
|
|
||||||
// multiple community feed state string
|
// Every caller already owns the data-loading hook. Observe only the derived text here so
|
||||||
const { states } = useCommunitiesStates({ communities });
|
// high-frequency per-community lifecycle changes do not create no-op React commits.
|
||||||
|
const multipleCommunitiesFeedStateString = communitiesStore((state) => {
|
||||||
const multipleCommunitiesFeedStateString = useMemo(() => {
|
|
||||||
if (communityAddress) {
|
if (communityAddress) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let stateString = '';
|
const states = getCommunitiesLoadingStates(state.communities, communities);
|
||||||
|
return getMultipleCommunitiesFeedStateString(states, communityAddresses, isBrowserPureP2P);
|
||||||
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]);
|
|
||||||
|
|
||||||
if (singleCommunityFeedStateString) {
|
if (singleCommunityFeedStateString) {
|
||||||
return singleCommunityFeedStateString;
|
return singleCommunityFeedStateString;
|
||||||
|
|||||||
@@ -4,5 +4,5 @@
|
|||||||
// elsewhere in production code.
|
// elsewhere in production code.
|
||||||
|
|
||||||
export { default as localForageLru } from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js';
|
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';
|
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) {
|
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({
|
scan({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
showToolbar: !(window as any).__PROFILING__,
|
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 () => ({
|
const notReady = async () => ({
|
||||||
error: 'element-source is not ready yet.',
|
error: 'element-source is not ready yet.',
|
||||||
});
|
});
|
||||||
@@ -22,7 +47,6 @@ if (import.meta.env.DEV) {
|
|||||||
formatStack: () => '',
|
formatStack: () => '',
|
||||||
};
|
};
|
||||||
|
|
||||||
(window as any).__getReactScanReport = getReport;
|
|
||||||
(window as any).__ELEMENT_SOURCE__ = elementSourceApi;
|
(window as any).__ELEMENT_SOURCE__ = elementSourceApi;
|
||||||
|
|
||||||
import('element-source')
|
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', () => ({
|
vi.mock('../../../hooks/use-communities-stats', () => ({
|
||||||
CommunityStatsCollector: ({ communityAddress }: { communityAddress: string }) =>
|
CommunityStatsCollector: ({ communityAddress }: { communityAddress: string }) =>
|
||||||
createElement('div', { 'data-testid': 'stats-collector', 'data-address': communityAddress }),
|
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 }),
|
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', () => {
|
it('renders the home view chrome, child sections, collectors, and aggregated stats', () => {
|
||||||
renderHome();
|
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(document.title).toBe('5chan');
|
||||||
expect(container.querySelector('[data-testid="disclaimer-modal"]')?.textContent).toBe('disclaimer-modal');
|
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="directory-modal"]')?.textContent).toBe('directory-modal');
|
||||||
expect(container.querySelector('[data-testid="boards-list"]')?.textContent).toBe('boards:2');
|
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="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(container.querySelectorAll('[data-testid="stats-collector"]')).toHaveLength(2);
|
||||||
expect(testState.directoryListCodes).toEqual([]);
|
expect(testState.directoryListCodes).toEqual([]);
|
||||||
expect(container.textContent).toContain('stats');
|
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 { Link, useNavigate } from 'react-router-dom';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import styles from './home.module.css';
|
import styles from './home.module.css';
|
||||||
import { type DirectoryCommunity, useDirectories, useDirectoryAddresses } from '../../hooks/use-directories';
|
import { type DirectoryCommunity, useDirectories, useDirectoryAddresses } from '../../hooks/use-directories';
|
||||||
import { sortDirectoryBoardsByRank, useDirectoryLists } from '../../hooks/use-directory-list';
|
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 PopularThreadsBox from './popular-threads-box';
|
||||||
import BoardsList from './boards-list';
|
import BoardsList from './boards-list';
|
||||||
import SiteLegalMeta from '../../components/site-legal-meta';
|
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 Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const statsScope = useHomepageStatsOptionsStore((state) => state.statsScope);
|
const statsScope = useHomepageStatsOptionsStore((state) => state.statsScope);
|
||||||
@@ -252,11 +282,10 @@ const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
|
|||||||
}, [collectorAddresses, communitiesStats]);
|
}, [collectorAddresses, communitiesStats]);
|
||||||
const hasDisplayableStats = boardsWithStats > 0 || (collectorAddresses.length > 0 && boardsLoaded === collectorAddresses.length);
|
const hasDisplayableStats = boardsWithStats > 0 || (collectorAddresses.length > 0 && boardsLoaded === collectorAddresses.length);
|
||||||
const isStatsLoading = !hasDisplayableStats || boardsLoaded < collectorAddresses.length;
|
const isStatsLoading = !hasDisplayableStats || boardsLoaded < collectorAddresses.length;
|
||||||
const loadingStateString = useFeedStateString(isStatsLoading ? collectorAddresses : EMPTY_STATS_LIST) || t('loading');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Render collectors to fetch stats for each community */}
|
<CommunityStatsMetadataLoader communityAddresses={collectorAddresses} />
|
||||||
{collectorAddresses.map((address) => (
|
{collectorAddresses.map((address) => (
|
||||||
<CommunityStatsCollector key={address} communityAddress={address} />
|
<CommunityStatsCollector key={address} communityAddress={address} />
|
||||||
))}
|
))}
|
||||||
@@ -264,13 +293,7 @@ const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
|
|||||||
<div className={`${styles.boxBar} ${styles.color2ColorBar}`}>
|
<div className={`${styles.boxBar} ${styles.color2ColorBar}`}>
|
||||||
<h2 className={styles.statsTitle}>
|
<h2 className={styles.statsTitle}>
|
||||||
{t('stats')}
|
{t('stats')}
|
||||||
{isStatsLoading && (
|
{isStatsLoading && <StatsLoadingIndicator addresses={collectorAddresses} />}
|
||||||
<span className={styles.statsLoadingIconWrapper}>
|
|
||||||
<Tooltip content={loadingStateString}>
|
|
||||||
<span className={`${styles.statsLoadingIcon} yellowOfflineIcon`} />
|
|
||||||
</Tooltip>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h2>
|
</h2>
|
||||||
<StatsOptionsModal />
|
<StatsOptionsModal />
|
||||||
</div>
|
</div>
|
||||||
@@ -288,7 +311,7 @@ const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<LoadingEllipsis string={loadingStateString} />
|
<StatsLoadingEllipsis addresses={collectorAddresses} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-3
@@ -490,6 +490,10 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, 'src'),
|
'@': 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',
|
'node-fetch': 'isomorphic-fetch',
|
||||||
assert: 'assert',
|
assert: 'assert',
|
||||||
stream: 'stream-browserify',
|
stream: 'stream-browserify',
|
||||||
@@ -524,9 +528,6 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks(id) {
|
manualChunks(id) {
|
||||||
if (/[\\/]node_modules[\\/](@pkcprotocol[\\/]pkc-js)[\\/]/.test(id)) {
|
|
||||||
return 'pkc-js';
|
|
||||||
}
|
|
||||||
if (/[\\/]node_modules[\\/](@bitsocialnet[\\/]bitsocial-react-hooks)[\\/]/.test(id)) {
|
if (/[\\/]node_modules[\\/](@bitsocialnet[\\/]bitsocial-react-hooks)[\\/]/.test(id)) {
|
||||||
return 'bitsocial-react-hooks';
|
return 'bitsocial-react-hooks';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user