feat: finalize pretext feed sizing rollout (#1120)

* feat(feeds): add pretext-backed item sizing across board, catalog, and thread replies

* feat: finalize pretext feed sizing rollout

* fix(catalog): raise multiboard viewport buffer

* fix(board): preserve pretext query overrides
This commit is contained in:
Tommaso Casaburi
2026-04-02 19:45:55 +07:00
committed by GitHub
parent 4fb7739991
commit 251e103db3
21 changed files with 2733 additions and 179 deletions
@@ -0,0 +1,78 @@
{
"task": "pretext-feed-sizing",
"last_updated": "2026-04-02",
"items": [
{
"id": "F001",
"priority": 1,
"status": "completed",
"description": "Benchmark and harden Pretext-backed reply virtualization on the real thread reply Virtuoso path so the branch has trustworthy baseline data.",
"verification": [
"Open `?e2e=pretext-benchmark&variant=production&mode=dom&count=1200&seed=42` and run the scroll scenario on desktop and mobile.",
"Open `?e2e=pretext-benchmark&variant=production&mode=item-size&count=1200&seed=42` and compare rect calls, duration, long tasks, and visible estimate error."
],
"files": [
"src/components/post-desktop/post-desktop.tsx",
"src/components/post-mobile/post-mobile.tsx",
"src/e2e/pretext-benchmark-harness.tsx",
"src/hooks/use-reply-height-estimates.ts",
"src/lib/utils/pretext-height-estimates.ts",
"src/views/post/post.tsx"
],
"notes": "Reply virtualization is now production-credible on both desktop and mobile. Desktop production harness: `dom` -> `item-size` reduced rect reads from 2875 to 472 while keeping visible error low (`11.3px` mean, `30px` max). Mobile production harness: `dom` -> `item-size` reduced rect reads from 5059 to 1433, duration from 16863ms to 13452ms, max frame from 100.1ms to 59ms, and slow frames over 16ms from 227 to 99. The mobile follow-up audit sampled all 1200 replies and found `722411px` estimated vs `726218px` actual total height, so the Pretext reply tree is within about `0.5%` of the rendered total."
},
{
"id": "F002",
"priority": 2,
"status": "completed",
"description": "Add a benchmarkable Pretext experiment for board feed Virtuoso items and determine whether feed cards are a better production target than thread replies.",
"verification": [
"Run a deterministic board-feed benchmark on `board.tsx` item virtualization for `dom`, `estimates`, and `item-size` modes.",
"Confirm whether item measurement reads and frame metrics improve materially on desktop and mobile."
],
"files": [
"src/lib/utils/pretext-height-estimates.ts",
"src/hooks/use-reply-height-estimates.ts",
"src/views/board/board.tsx",
"src/views/post/post.tsx",
"src/components/post-desktop/post-desktop.tsx",
"src/components/post-mobile/post-mobile.tsx",
"src/e2e/pretext-benchmark-harness.tsx"
],
"notes": "The board path was the hardest rollout surface, but it is now in a shippable state. Earlier live profiling found multiple board issues that are now addressed: desktop feed cards were still subtracting `14px` per preview reply even after the preview-reply estimator itself had become accurate, board preview replies were still rendering under `content-visibility:auto` while the feed card relied on Pretext sizing, the branch had lost `master` commit `99c0bbfac perf(board): reduce mobile reverse-scroll jank`, the mobile board-card preview calibration was massively over-subtracting once preview replies were present, and mobile multiboard `off` mode was still passing `itemSize={undefined}`, which overrides React Virtuoso's default DOM measurer. Board and catalog now only pass `itemSize` when explicit `item-size` mode is active, so the fallback path measures correctly again. Final matched live mobile A/B probes show the worktree no longer trails master on `/all`, and `/biz` keeps the Pretext sizing path active while matching the same visible card heights and total scroll height as master."
},
{
"id": "F003",
"priority": 3,
"status": "completed",
"description": "Evaluate catalog virtualization for a Pretext-backed sizing strategy if board feed results are promising or if catalog-specific card layout needs a separate model.",
"verification": [
"Benchmark catalog Virtuoso rows under the same `dom` vs Pretext sizing modes.",
"Verify desktop and mobile catalog scrolling in a fresh browser session."
],
"files": [
"src/views/catalog/catalog.tsx",
"src/components/catalog-row/catalog-row.tsx",
"src/e2e/pretext-benchmark-harness.tsx"
],
"notes": "Catalog rows now use a dedicated Pretext estimator plus `item-size` by default, with pure derived matched-filter colors instead of render-time store writes. Benchmark pass: desktop catalog reduced rect reads from 439 to 73 with a small duration win (2272ms -> 2010ms), and mobile reduced rect reads from 1349 to 217 with comparable scroll timing."
},
{
"id": "F004",
"priority": 4,
"status": "completed",
"description": "Prepare a production-safe rollout plan once a Pretext path shows a clear performance win, including feature-flag strategy, regression checks, and default-mode decisions.",
"verification": [
"Run `corepack yarn build`, `corepack yarn lint`, `corepack yarn type-check`, `corepack yarn doctor`, and the relevant browser benchmark flows.",
"Document whether the winning mode should remain opt-in, ship mobile-first, or replace DOM sizing by default."
],
"files": [
"src/lib/utils/pretext-height-estimates.ts",
"src/hooks/use-reply-height-estimates.ts",
"src/views/board/board.tsx",
"src/views/catalog/catalog.tsx"
],
"notes": "The rollout decision is now clear. The current verification set passes (`corepack yarn build`, `corepack yarn lint` with existing warnings only, `corepack yarn type-check`, `corepack yarn doctor` at the repo baseline `86/100`, and `corepack yarn test --run`), deterministic benchmark matrices still show the Pretext wins on catalog/replies/board, and the last board-specific fallback bug on mobile multiboard feeds is fixed. Final live A/B on the RPC-backed mobile `/all` sessions shows the worktree at parity or slightly better than master on the same visible cards, while `/biz` keeps the Pretext path active without the earlier stutter regression. The retained query flags (`pretextFeed`, `pretextCatalog`, `pretextReplies`) still provide an escape hatch, but the branch itself is ready for merge review."
}
]
}
@@ -0,0 +1,111 @@
# Progress Log
Append one entry per session.
## 2026-03-30 15:07
- Item: F001
- Summary: Built a production-path benchmark harness for real thread replies, fixed multiple estimator mismatches, and collected final desktop/mobile `dom` vs `item-size` measurements.
- Files: `src/components/post-desktop/post-desktop.tsx`, `src/components/post-mobile/post-mobile.tsx`, `src/e2e/pretext-benchmark-harness.tsx`, `src/hooks/use-reply-height-estimates.ts`, `src/lib/utils/pretext-height-estimates.ts`, `src/views/post/post.tsx`
- Verification: `./scripts/agent-init.sh --smoke`, `corepack yarn build`, `corepack yarn lint`, `corepack yarn type-check`, `corepack yarn doctor`, `corepack yarn test --run src/lib/utils/__tests__/pretext-height-estimates.test.ts src/components/__tests__/post-community-address-compat.test.tsx`, Playwright benchmark runs on desktop and mobile for `dom`, `estimates`, and `item-size`
- Blockers: Reply virtualization now reduces DOM measurement reads substantially, but the user-visible performance gain is only modest on desktop and still not accurate enough to call mobile safe by default.
- Next: Start F002 by wiring a benchmarkable board-feed path and compare `dom` vs Pretext sizing on feed cards rather than thread replies.
## 2026-03-30 16:00
- Item: F002
- Summary: Added a board-feed benchmark surface, calibrated feed-card estimates from sampled DOM error, and wired the real board Virtuoso behind `pretextFeed=item-size` with hash-router query parsing support.
- Files: `src/components/post-desktop/post-desktop.tsx`, `src/components/post-mobile/post-mobile.tsx`, `src/e2e/pretext-benchmark-harness.tsx`, `src/hooks/use-reply-height-estimates.ts`, `src/lib/utils/__tests__/pretext-height-estimates.test.ts`, `src/lib/utils/pretext-height-estimates.ts`, `src/views/board/board.tsx`, `src/views/post/post.tsx`
- Verification: `corepack yarn type-check`, `corepack yarn lint`, `corepack yarn build`, `corepack yarn doctor`, `corepack yarn test --run src/lib/utils/__tests__/pretext-height-estimates.test.ts src/components/__tests__/post-community-address-compat.test.tsx`, Playwright board benchmark runs on desktop/mobile for `dom`, `estimates`, and `item-size`, real-route smoke test on `#/mu?pretextFeed=item-size`
- Blockers: The synthetic board benchmark is now clearly better than the reply path, but the live `/mu` route only loaded about 15 cards in this dev environment, so the production-route perf comparison is still too small and noisy to justify default-on rollout.
- Next: Continue F004 by profiling larger live board feeds with `pretextFeed=item-size` and decide whether board feed can ship default-on, mobile-first, or stay flag-gated while catalog/reply work continues.
## 2026-03-30 17:08
- Item: F001, F003, F004
- Summary: Finished catalog row sizing, removed catalog matched-filter render-time store writes, calibrated thread-reply estimates for the real Virtuoso path, extended the benchmark harness to cover catalog, and flipped board/catalog/reply virtualization defaults to `item-size`.
- Files: `docs/agent-runs/pretext-feed-sizing/feature-list.json`, `src/components/catalog-row/__tests__/catalog-row.test.tsx`, `src/components/catalog-row/catalog-row.tsx`, `src/e2e/pretext-benchmark-harness.tsx`, `src/hooks/use-reply-height-estimates.ts`, `src/lib/utils/__tests__/pretext-height-estimates.test.ts`, `src/lib/utils/pretext-height-estimates.ts`, `src/views/board/board.tsx`, `src/views/catalog/__tests__/catalog.test.tsx`, `src/views/catalog/catalog.tsx`
- Verification: `corepack yarn type-check`, `corepack yarn test --run`, `corepack yarn build`, `corepack yarn lint` (existing warnings only), `corepack yarn doctor` (baseline `86/100`), `corepack yarn knip`, Playwright benchmark matrices for board/catalog/replies on desktop and mobile, live-route smoke checks on `#/mu`, `#/mu/catalog`, and `#/mu/thread/QmWSMcz1TirxAcVJ9qKr8jygttVbXZ3bZ9mMpZyek5iu3n`
- Blockers: Live-route perf deltas remain hard to observe in this dev environment because feeds are shallow and RPC noise is high, but the production-path benchmark harness now shows clear wins on all three surfaces and the real routes render correctly with the new defaults.
- Next: Keep the URL overrides (`?pretextFeed=off`, `?pretextCatalog=off`, `?pretextReplies=off`) available for rollback while gathering post-merge feedback on deeper live boards and threads.
## 2026-03-30 18:10
- Item: F002, F004
- Summary: Reproduced the user's `/all` board-feed regression against cached live data from a copied Helium Chromium profile, identified that reply-preview cards were massively overestimated in the strict board `item-size` path, and switched the board rollout to a hybrid strategy that only keeps cached Pretext heights on simple cards without preview replies.
- Files: `src/components/__tests__/post-community-address-compat.test.tsx`, `src/components/post-desktop/post-desktop.tsx`, `src/components/post-mobile/post-mobile.tsx`, `src/lib/utils/pretext-height-estimates.ts`
- Verification: `corepack yarn test --run`, `corepack yarn build`, `corepack yarn lint` (existing warnings only), `corepack yarn type-check`, `corepack yarn doctor` (baseline `86/100`), Helium-profile Playwright probes on `#/biz` and `#/all` for master vs worktree plus desktop/mobile geometry checks on the worktree
- Blockers: Catalog remains the only clearly dramatic live win. Board feeds are now layout-safe and at least slightly smoother, but the hybrid fallback means the board path is a correctness-first improvement rather than a large DOM-read reduction.
- Next: Keep catalog and replies on the current Pretext default path, and continue iterating on a lower-error preview-reply estimator if we want a bigger board-feed win than the new hybrid mode provides.
## 2026-03-30 19:10
- Item: F001, F002, F004
- Summary: Removed the board-feed fallback again, separated preview-reply sizing from thread-reply sizing, preserved real line breaks in Pretext input, recalibrated desktop thread replies, compressed the `itemSize` lookup path, and reran deterministic desktop/mobile benchmark matrices. The board surface is now strict Pretext again with close geometry, desktop replies are materially better, and mobile replies remain the last blocker.
- Files: `docs/agent-runs/pretext-feed-sizing/feature-list.json`, `src/components/__tests__/post-community-address-compat.test.tsx`, `src/components/post-desktop/post-desktop.tsx`, `src/components/post-mobile/post-mobile.tsx`, `src/hooks/use-reply-height-estimates.ts`, `src/lib/utils/pretext-height-estimates.ts`, `src/views/board/board.tsx`
- Verification: `corepack yarn build`, `corepack yarn lint` (existing warnings only), `corepack yarn type-check`, `corepack yarn doctor` (`85/100`, one point below the earlier baseline because the repo still carries existing React Doctor findings), `corepack yarn test --run src/lib/utils/__tests__/pretext-height-estimates.test.ts src/components/__tests__/post-community-address-compat.test.tsx src/views/board/__tests__/board.test.tsx`, deterministic Playwright benchmark runs on desktop and mobile for board/catalog/replies `dom` vs `item-size`
- Blockers: Mobile thread replies still have a bad aggregate scroll-height estimate in the benchmark harness even after the latest calibration passes. Desktop board sizing is now accurate enough, but its deterministic benchmark is measurement-cheaper rather than obviously faster, so the strongest “clear win” story remains catalog, mobile board, and desktop replies.
- Next: Either keep iterating specifically on the mobile thread reply estimator or stop forcing `item-size` on that one surface while shipping the surfaces that are already clearly better.
## 2026-03-31 15:45
- Item: F001, F004
- Summary: Re-profiled mobile thread replies, replaced the broken blanket `-350px` mobile thread calibration with a feature-based mobile thread model, and disabled `content-visibility:auto` on the virtualized reply roots only when the Pretext `item-size` path is active. The key follow-up was a row-by-row mobile audit across the full 1200-reply benchmark: the sampled `item-size` reply tree sums to `722411px` estimated vs `726218px` actual, so the Pretext reply sizing is now within about `0.5%` of the rendered total even though the old DOM Virtuoso path still reports a much smaller overall scroll height.
- Files: `docs/agent-runs/pretext-feed-sizing/feature-list.json`, `src/components/post-desktop/post-desktop.tsx`, `src/components/post-mobile/post-mobile.tsx`, `src/lib/utils/pretext-height-estimates.ts`, `src/views/post/post.module.css`
- Verification: `corepack yarn type-check`, `corepack yarn lint` (existing warnings only), `corepack yarn test --run src/lib/utils/__tests__/pretext-height-estimates.test.ts src/components/__tests__/post-community-address-compat.test.tsx src/views/board/__tests__/board.test.tsx`, `corepack yarn build`, `corepack yarn doctor` (`86/100`, repo baseline), `corepack yarn knip`, Playwright production-path reply benchmarks on mobile and desktop plus row-level mobile geometry sampling
- Blockers: Live deep-thread route verification is still limited by RPC/cached-feed availability in this dev environment, so the strongest proof remains the deterministic production-path harness rather than a real cached thread with thousands of replies.
- Next: If the user wants to ship, prepare the branch for commit/PR with the rollback query flags kept in place (`?pretextFeed=off`, `?pretextCatalog=off`, `?pretextReplies=off`) and do one more live-route smoke pass when cached live data is available.
## 2026-04-02 16:48
- Item: F002, F004
- Summary: Tightened the desktop board-feed height model by matching the actual OP body render path more closely. The board estimator now adds the desktop blockquote padding, models the board-view truncated-comment notice, exposes an explicit multiboard board-label height hook for `/all`-style feeds, and applies a small desktop preview-reply calibration instead of treating preview replies like full thread rows. That moved the board benchmark from “measurement-cheaper but visibly off” to near-parity runtime with much lower DOM reads.
- Files: `docs/agent-runs/pretext-feed-sizing/feature-list.json`, `src/components/post-desktop/post-desktop.tsx`, `src/lib/utils/__tests__/pretext-height-estimates.test.ts`, `src/lib/utils/pretext-height-estimates.ts`
- Verification: `corepack yarn type-check`, `corepack yarn test --run src/lib/utils/__tests__/pretext-height-estimates.test.ts`, `corepack yarn build`, `corepack yarn lint` (existing warnings only), `corepack yarn doctor` (`86/100`, repo baseline), `corepack yarn test --run`, Playwright desktop board benchmark at `http://127.0.0.1:1356/?e2e=pretext-benchmark&surface=board&variant=production&count=800&seed=42&mode=dom` and `mode=item-size`, fresh-route Playwright smoke on `#/all` and `#/biz`
- Blockers: Fresh browser route smoke still cannot validate deep live `/all` content because RPC/cached-feed availability is inconsistent in this environment, and `./scripts/agent-init.sh --smoke` is not yet worktree-aware because it waits for the canonical `http://5chan.localhost:1355` URL instead of the branch-scoped Portless route.
- Next: Re-test cached live `/all` and `/biz` against the current branch to confirm the board-feed geometry feels better in real browsing, then decide whether the board path is finally strong enough to merge along with catalog/replies.
## 2026-04-02 17:23
- Item: F002, F004
- Summary: Reprofiled live `/all` with the private Node RPC, found that desktop board cards were still underestimating almost exactly `14px` per preview reply, removed that stale feed-level subtraction, and added a regression test so desktop feed cards now add the preview reply estimates directly. The live result changed materially: a 99-card `/all` sample went from `39.7px` mean absolute error before the fix to `7.1px` after the fix, with the controlled board benchmark regaining a cleaner `item-size` win.
- Files: `docs/agent-runs/pretext-feed-sizing/feature-list.json`, `src/lib/utils/__tests__/pretext-height-estimates.test.ts`, `src/lib/utils/pretext-height-estimates.ts`
- Verification: `corepack yarn type-check`, `corepack yarn build`, `corepack yarn lint` (existing warnings only), `corepack yarn doctor` (`86/100`, repo baseline), `corepack yarn test --run src/lib/utils/__tests__/pretext-height-estimates.test.ts`, `corepack yarn test --run`, live Playwright `/all` sampling on `http://codex-feature-pretext-feed-sizing.5chan.localhost:1355/#/all`, live same-branch A/B on `#/all` vs `#/all?pretextFeed=off`, controlled Playwright board benchmark on `?e2e=pretext-benchmark&surface=board&variant=production&count=800&seed=42&mode=dom` and `mode=item-size`
- Blockers: Live `/all` now looks much healthier, but the route-level perf delta versus `pretextFeed=off` is still modest/noisy rather than a dramatic blowout, and the master-origin comparison is not trustworthy yet because the master session is not loading the same feed depth as the worktree even after the settings pass.
- Next: Do another user-driven real-feed pass on `/all` and `/biz` with the new desktop board fix in place, then decide whether the remaining work should focus on squeezing a larger live-route win or on preparing the branch for merge with the current measurable improvement.
## 2026-04-02 17:27
- Item: F002, F004
- Summary: Confirmed that the remaining single-board feed glitching was not primarily a bad Pretext formula. Desktop board preview replies were still rendering under `content-visibility:auto` with the `120px` intrinsic placeholder while the feed card itself was relying on Pretext sizing. I disabled deferred layout for board preview replies when the feed estimate path is active, added dev-only preview reply audit attributes on desktop, and reran live `/biz` sampling. That change dropped `/biz` from `52px` mean absolute feed-card error to `6.7px`.
- Files: `docs/agent-runs/pretext-feed-sizing/feature-list.json`, `src/components/post-desktop/post-desktop.tsx`, `src/components/post-mobile/post-mobile.tsx`
- Verification: `corepack yarn type-check`, `corepack yarn build`, `corepack yarn lint` (existing warnings only), `corepack yarn doctor` (`86/100`, repo baseline), `corepack yarn test --run src/lib/utils/__tests__/pretext-height-estimates.test.ts`, `corepack yarn test --run`, live Playwright `/biz` sampling before and after the deferred-layout fix on `http://codex-feature-pretext-feed-sizing.5chan.localhost:1355/#/biz`
- Blockers: The branch is now much closer to “merge-ready on real feeds,” but the strongest user-facing proof is still the live geometry/jank sampling plus the deterministic harness rather than a clean master-origin A/B, because the master session is still not consuming the same feed depth reliably.
- Next: Do one more human-eye browsing pass on `/all`, `/biz`, and catalog with the current branch. If the route now feels consistently better than production, prepare the branch for merge with the rollback query flags retained.
## 2026-04-02 18:02
- Item: F002, F004
- Summary: Tracked the mobile `/all` stutter to two separate regressions. First, the Pretext worktree was missing `master` commit `99c0bbfac perf(board): reduce mobile reverse-scroll jank`, so `board.tsx` was still snapshotting Virtuoso state on every `scroll` tick and `comment-media.tsx` was double-running the GIF first-frame hook per card. After porting that, live mobile `/all` audits still showed huge feed-card misses on preview-heavy media posts, so I reprofiled the live cards and found the mobile board preview calibration was stale: cards with five preview replies were underestimating by `+371px` / `+367.9px`. I replaced the old aggressive polynomial calibration with a bounded per-preview-count table, then skipped the full thread-reply estimate hook on board previews and memoized the expensive derived reply maps. That collapsed the live mobile `/all` mean absolute feed-card error from about `135px` to about `18.8px` and pulled the reverse-scroll probe much closer to master.
- Files: `docs/agent-runs/pretext-feed-sizing/feature-list.json`, `src/components/comment-media/comment-media.tsx`, `src/components/post-desktop/post-desktop.tsx`, `src/components/post-mobile/post-mobile.tsx`, `src/hooks/use-reply-height-estimates.ts`, `src/lib/utils/__tests__/pretext-height-estimates.test.ts`, `src/lib/utils/pretext-height-estimates.ts`, `src/views/board/board.tsx`
- Verification: `corepack yarn type-check`, `corepack yarn test --run src/lib/utils/__tests__/pretext-height-estimates.test.ts src/components/__tests__/post-community-address-compat.test.tsx src/views/board/__tests__/board.test.tsx`, `corepack yarn build`, `corepack yarn lint` (existing warnings only), `corepack yarn doctor` (`86/100`, repo baseline), live Playwright mobile `/all` geometry audit on `http://codex-feature-pretext-feed-sizing.5chan.localhost:1355/#/all`, live mobile reverse-scroll probe on `http://codex-feature-pretext-feed-sizing.5chan.localhost:1355/#/all`, and matched master control probe on `http://5chan.localhost:1355/#/all`
- Blockers: Mobile `/all` is no longer catastrophically wrong, but the worktree still trails the current master control in the dev reverse-scroll probe. Latest matched sample: worktree `seenCount=23`, `scrollHeight=18209`, `maxFrameMs=133.3`, `slowFrames16=11`, `slowFrames32=9`; master `seenCount=23`, `scrollHeight=17735`, `maxFrameMs=108.3`, `slowFrames16=8`, `slowFrames32=6`.
- Next: Keep tuning the mobile board path until the reverse-scroll probe and human-eye feel are at least on par with master, likely by profiling rerender/commit churn on mobile `/all` now that the geometry bug is fixed.
## 2026-04-02 18:40
- Item: F002, F004
- Summary: Found the remaining mobile `/all` board regression in the board-level Virtuoso prop wiring rather than the Pretext formula. In `off` mode the worktree was still rendering `<Virtuoso itemSize={undefined} />`, which overrides React Virtuoso's internal default DOM measurer. That left mobile multiboard cards stuck on the `defaultItemHeight` fallback (`420px`) instead of reconciling to real DOM sizes, which explained the bad `data-known-size` values, wrong scroll height, and the remaining stutter. I changed board and catalog to only pass `itemSize` when the explicit `item-size` mode is active.
- Files: `docs/agent-runs/pretext-feed-sizing/feature-list.json`, `src/views/board/board.tsx`, `src/views/catalog/catalog.tsx`
- Verification: `corepack yarn type-check`, `corepack yarn build`, `corepack yarn lint` (existing warnings only), `corepack yarn doctor` (`86/100`, repo baseline), `corepack yarn test --run src/views/board/__tests__/board.test.tsx src/lib/utils/__tests__/pretext-height-estimates.test.ts`, fresh Playwright mobile `/all` load on `http://codex-feature-pretext-feed-sizing.5chan.localhost:1355/#/all`, and persistent-session Playwright checks on `http://codex-feature-pretext-feed-sizing.5chan.localhost:1355/#/all`
- Blockers: The biggest correctness bug is fixed, but the live mobile `/all` scroll probe in this noisy dev environment is still not a clean proof of parity versus master. The good signal is that board cards now reconcile correctly again: on the persistent RPC-backed session the first mobile `/all` card went from stale `data-known-size=420` before the fix to `data-known-size=413` after the fix, matching the real measured height.
- Next: Re-run human-eye A/B on mobile `/all` and `/biz` against master. If the feel issue is gone, the branch is much closer to merge-ready; if not, the remaining work is runtime churn rather than height reconciliation.
## 2026-04-02 18:46
- Item: F002, F004
- Summary: Re-ran matched live mobile A/B probes against `master` after the Virtuoso prop fix and confirmed the branch is no longer trailing on the board routes that mattered. On the persistent RPC-backed `/all` sessions, the worktree now renders the same visible card heights as `master` while posting slightly better reverse-scroll numbers in the probe (`maxFrameMs 83.3` vs `99.1`, `slowFrames16 6` vs `7`, `slowFrames32 2` vs `3`). On `/biz`, the branch keeps the Pretext path active (`data-pretext-height` nodes present) while matching the same visible card heights and total scroll height as `master`; probe timing is now in the same range instead of the clear regression the user saw earlier. I also added an inline comment in board/catalog so the `itemSize={undefined}` pitfall is documented in code.
- Files: `docs/agent-runs/pretext-feed-sizing/feature-list.json`, `src/views/board/board.tsx`, `src/views/catalog/catalog.tsx`
- Verification: `corepack yarn type-check`, `corepack yarn build`, `corepack yarn lint` (existing warnings only), `corepack yarn doctor` (`86/100`, repo baseline), `corepack yarn test --run`, matched Playwright mobile A/B on `http://codex-feature-pretext-feed-sizing.5chan.localhost:1355/#/all` vs `http://5chan.localhost:1355/#/all`, matched Playwright mobile A/B on `http://codex-feature-pretext-feed-sizing.5chan.localhost:1355/#/biz` vs `http://5chan.localhost:1355/#/biz`, and catalog smoke on both origins at `#/all/catalog`
- Blockers: None in the implementation itself. Remaining work before merge is review/commit/PR hygiene, plus the follow-up GitHub issue about future custom virtualization once this branch lands.
- Next: Prepare the branch for merge, keep `FeedCacheContainer`, and open the deferred “Pretext unlocks custom virtualization” issue only after the Pretext rollout is merged into `master`.
+1
View File
@@ -12,6 +12,7 @@
"@capacitor/app": "7.0.1",
"@capacitor/status-bar": "7.0.1",
"@capawesome/capacitor-android-edge-to-edge-support": "7.2.2",
"@chenglou/pretext": "0.0.3",
"@floating-ui/react": "0.26.1",
"@react-spring/web": "10.0.3",
"@types/node": "20.19.37",
@@ -45,9 +45,11 @@ type TestComment = {
const testState = vi.hoisted(() => ({
addChallengeMock: vi.fn(),
hasMoreReplies: false,
openReplyModalMock: vi.fn(),
replyComments: [] as Array<TestComment | undefined>,
setResetFunctionMock: vi.fn(),
virtuosoProps: [] as Array<{ defaultItemHeight?: number; heightEstimates?: number[]; itemSize?: unknown }>,
}));
const getMockPreloadedReplies = (comment?: TestComment, sortType?: string) => {
@@ -89,7 +91,7 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useReplies: ({ comment, sortType }: { comment?: TestComment; sortType?: string }) => {
testState.replyComments.push(comment);
return {
hasMore: false,
hasMore: testState.hasMoreReplies,
loadMore: vi.fn(),
replies: getMockPreloadedReplies(comment, sortType),
};
@@ -102,14 +104,22 @@ vi.mock('react-virtuoso', () => ({
{
components,
data = [],
defaultItemHeight,
heightEstimates,
itemSize,
itemContent,
}: {
components?: { Footer?: React.ComponentType };
data?: TestComment[];
defaultItemHeight?: number;
heightEstimates?: number[];
itemSize?: unknown;
itemContent: (index: number, item: TestComment) => React.ReactNode;
},
ref: React.ForwardedRef<{ getState: (cb: (snapshot: { ranges: number[]; scrollTop: number }) => void) => void }>,
) => {
testState.virtuosoProps.push({ defaultItemHeight, heightEstimates, itemSize });
React.useImperativeHandle(ref, () => ({
getState: (cb) => cb({ ranges: [0], scrollTop: 0 }),
}));
@@ -315,6 +325,14 @@ vi.mock('../../hooks/use-fresh-replies', () => ({
default: (replies: TestComment[]) => replies,
}));
vi.mock('../../hooks/use-reply-height-estimates', () => ({
default: ({ isMobile, replies }: { isMobile: boolean; replies: TestComment[] }) => ({
defaultItemHeight: isMobile ? 222 : 111,
heightEstimates: replies.map((_, index) => (isMobile ? 200 : 100) + index),
itemSize: vi.fn(),
}),
}));
vi.mock('../../lib/constants', () => ({
BOARD_REPLIES_PREVIEW_FETCH_SIZE: 5,
BOARD_REPLIES_PREVIEW_VISIBLE_COUNT: 3,
@@ -406,10 +424,24 @@ const makeLegacyThread = (): TestComment => ({
timestamp: 1_710_000_000,
});
const makeLegacyThreadWithoutReplies = (): TestComment => ({
...makeLegacyThread(),
replyCount: 0,
replies: {
pages: {
new: {
comments: [],
},
},
},
});
describe('post community address compatibility', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.hasMoreReplies = false;
testState.replyComments = [];
testState.virtuosoProps = [];
container = document.createElement('div');
document.body.appendChild(container);
@@ -444,4 +476,39 @@ describe('post community address compatibility', () => {
expect(container.querySelector('[data-testid="comment-media"]')).toBeTruthy();
expect(container.textContent).toContain('reply-1');
});
it('forwards Pretext-backed reply estimates into Virtuoso for desktop and mobile thread views', async () => {
testState.hasMoreReplies = true;
await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThread(), showAllReplies: true }), '/mu/thread/post-1');
expect(testState.virtuosoProps.at(-1)).toEqual({
defaultItemHeight: 111,
heightEstimates: [100],
itemSize: expect.any(Function),
});
testState.virtuosoProps = [];
await renderWithRoute(createElement(PostMobile, { post: makeLegacyThread(), showAllReplies: true }), '/mu/thread/post-1');
expect(testState.virtuosoProps.at(-1)).toEqual({
defaultItemHeight: 222,
heightEstimates: [200],
itemSize: expect.any(Function),
});
});
it('keeps board-card Pretext heights when preview replies are rendered', async () => {
await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThread() }));
expect(container.querySelector('.postDesktop')?.getAttribute('data-pretext-height')).toBeTruthy();
await renderWithRoute(createElement(PostMobile, { post: makeLegacyThread() }));
expect(container.querySelector('.postMobile')?.getAttribute('data-pretext-height')).toBeTruthy();
});
it('keeps board-card Pretext heights for simple cards without preview replies', async () => {
await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThreadWithoutReplies() }));
expect(container.querySelector('.postDesktop')?.getAttribute('data-pretext-height')).toBeTruthy();
await renderWithRoute(createElement(PostMobile, { post: makeLegacyThreadWithoutReplies() }));
expect(container.querySelector('.postMobile')?.getAttribute('data-pretext-height')).toBeTruthy();
});
});
@@ -79,8 +79,8 @@ describe('BoardPagination', () => {
buttons[1]?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.navigateMock).toHaveBeenNthCalledWith(1, '/mu');
expect(testState.navigateMock).toHaveBeenNthCalledWith(2, '/mu/3');
expect(testState.navigateMock).toHaveBeenNthCalledWith(1, { pathname: '/mu', search: '' });
expect(testState.navigateMock).toHaveBeenNthCalledWith(2, { pathname: '/mu/3', search: '' });
});
it('shows the footer pagelist, catalog links, and enables infinite scroll from the all shortcut', async () => {
@@ -104,7 +104,7 @@ describe('BoardPagination', () => {
nextButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/2');
expect(testState.navigateMock).toHaveBeenCalledWith({ pathname: '/mu/2', search: '' });
});
it('hides the footer pagelist for multiboards or when infinite scroll is already enabled', () => {
@@ -8,6 +8,7 @@ import styles from './board-pagination.module.css';
interface BoardPaginationProps {
basePath: string;
currentPage: number;
search?: string;
totalPages: number;
/** When true, renders pagelist: [All] [1] [2] ... [10] Catalog Archive + Style select */
footerStyle?: boolean;
@@ -15,14 +16,14 @@ interface BoardPaginationProps {
isMultiboard?: boolean;
}
const BoardPagination = ({ basePath, currentPage, totalPages, footerStyle = false, isMultiboard = false }: BoardPaginationProps) => {
const BoardPagination = ({ basePath, currentPage, search = '', totalPages, footerStyle = false, isMultiboard = false }: BoardPaginationProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
const setEnableInfiniteScroll = useFeedViewSettingsStore((state) => state.setEnableInfiniteScroll);
const pageHref = (page: number) => (page === 1 ? basePath : `${basePath}/${page}`);
const catalogHref = `${basePath}/catalog`;
const pageHref = (page: number) => ({ pathname: page === 1 ? basePath : `${basePath}/${page}`, search });
const catalogHref = { pathname: `${basePath}/catalog`, search };
if (totalPages <= 1 && !footerStyle) {
return null;
@@ -30,7 +31,7 @@ const BoardPagination = ({ basePath, currentPage, totalPages, footerStyle = fals
if (footerStyle) {
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1);
const archiveHref = `${basePath}/archive`;
const archiveHref = { pathname: `${basePath}/archive`, search };
return (
<div className={`${footerStyles.footerRow} ${isMultiboard ? footerStyles.footerRowRightOnly : ''}`}>
@@ -54,7 +54,6 @@ const testState = vi.hoisted(() => ({
hiddenCids: new Set<string>(),
imageSize: 'Small' as 'Large' | 'Small',
linkCount: 0,
matchedFilters: new Map<string, string>(),
mediaInfoByLink: {} as Record<string, { patternThumbnailUrl?: string; thumbnail?: string; type: string; url: string }>,
lastRepliesComment: undefined as TestComment | undefined,
replies: [] as TestComment[],
@@ -63,12 +62,6 @@ const testState = vi.hoisted(() => ({
showSnow: false,
}));
function getCatalogFiltersState() {
return {
matchedFilters: testState.matchedFilters,
};
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
@@ -156,13 +149,6 @@ vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
}));
vi.mock('../../../stores/use-catalog-filters-store', () => ({
default: <T,>(selector?: (state: ReturnType<typeof getCatalogFiltersState>) => T) => {
const state = getCatalogFiltersState();
return selector ? selector(state) : (state as T);
},
}));
vi.mock('../../../stores/use-catalog-style-store', () => ({
default: () => ({
imageSize: testState.imageSize,
@@ -232,7 +218,6 @@ describe('CatalogRow', () => {
testState.hiddenCids = new Set<string>();
testState.imageSize = 'Small';
testState.linkCount = 0;
testState.matchedFilters = new Map<string, string>();
testState.mediaInfoByLink = {};
testState.lastRepliesComment = undefined;
testState.replies = [];
@@ -253,7 +238,6 @@ describe('CatalogRow', () => {
it('renders gif frames with matched filter borders and falls back to deleted media on load errors', async () => {
testState.gifFrameStatus = 'ready';
testState.gifFrameUrl = 'https://cdn.example/frame.png';
testState.matchedFilters = new Map([['post-1', 'red']]);
await act(async () => {
root.render(
@@ -262,6 +246,7 @@ describe('CatalogRow', () => {
commentMediaInfo: { type: 'gif', url: 'https://example.com/source.gif' },
linkHeight: 200,
linkWidth: 400,
matchedFilterColor: 'red',
}),
);
});
@@ -416,7 +401,7 @@ describe('CatalogRow', () => {
expect(container.textContent).toContain('/ I: 3');
expect(container.textContent).not.toContain('/ L: 3');
expect(document.body.querySelector('a[href="/mu/thread/post-alias"]')).toBeTruthy();
expect(container.querySelector('[title=\"(R)eplies / (I)mage Replies\"]')).toBeTruthy();
expect(container.querySelector('[title="(R)eplies / (I)mage Replies"]')).toBeTruthy();
});
it('normalizes legacy board addresses before fetching hover preview replies', async () => {
@@ -495,4 +480,16 @@ describe('CatalogRow', () => {
expect(container.textContent).toContain('(hidden)');
expect(container.textContent).toContain('Text title: Plain thread body');
});
it('applies the estimated row height to the virtualization wrapper', async () => {
const post: TestComment = {
cid: 'estimated-post',
content: 'Estimated row body',
communityAddress: 'music-posting.eth',
};
await renderWithRouter(createElement(CatalogRow, { estimatedHeight: 246, row: [post] }), '/mu/catalog');
expect(container.querySelector('[data-pretext-height="246"]')).toBeTruthy();
});
});
+18 -10
View File
@@ -11,7 +11,6 @@ import { getFormattedTimeAgo } from '../../lib/utils/time-utils';
import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { findDirectoryByAddress, useDirectories } from '../../hooks/use-directories';
import { getBoardPath } from '../../lib/utils/route-utils';
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
import useEditCommentPrivileges from '../../hooks/use-author-privileges';
import { useCommentMediaInfo } from '../../hooks/use-comment-media-info';
@@ -32,9 +31,11 @@ interface CatalogPostMediaProps {
isOutOfFeed?: boolean;
linkWidth?: number;
linkHeight?: number;
matchedFilterColor?: string;
}
export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight }: CatalogPostMediaProps) => {
export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight, matchedFilterColor }: CatalogPostMediaProps) => {
void cid;
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
const iframeThumbnail = patternThumbnailUrl || thumbnail;
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
@@ -100,8 +101,6 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight
thumbnailComponent = <audio src={url} controls />;
}
const matchedFilterColor = useCatalogFiltersStore((state) => state.matchedFilters.get(cid || ''));
return (
<div
className={hasError ? '' : styles.mediaWrapper}
@@ -118,7 +117,7 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight
// Memoize CatalogPost to prevent rerenders when parent rerenders due to updatingState
const CatalogPost = memo(
({ post }: { post: Comment }) => {
({ matchedFilterColor, post }: { matchedFilterColor?: string; post: Comment }) => {
const { t } = useTranslation();
const resolvedPost = useMemo(() => withResolvedCommentCommunityAddress(post), [post]);
const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, replyCount, spoiler, communityAddress, timestamp, title, thumbnailUrl } =
@@ -260,7 +259,13 @@ const CatalogPost = memo(
{spoiler ? (
<img src='assets/spoiler.png' alt='' />
) : (
<CatalogPostMedia cid={cid} commentMediaInfo={commentMediaInfo} linkWidth={linkWidth} linkHeight={linkHeight} />
<CatalogPostMedia
cid={cid}
commentMediaInfo={commentMediaInfo}
linkWidth={linkWidth}
linkHeight={linkHeight}
matchedFilterColor={matchedFilterColor}
/>
)}
</div>
</Link>
@@ -339,21 +344,24 @@ const CatalogPost = memo(
prev?.thumbnailUrl === next?.thumbnailUrl &&
prev?.linkWidth === next?.linkWidth &&
prev?.linkHeight === next?.linkHeight &&
prevCommunityAddress === nextCommunityAddress
prevCommunityAddress === nextCommunityAddress &&
prevProps.matchedFilterColor === nextProps.matchedFilterColor
);
},
);
interface CatalogRowProps {
estimatedHeight?: number;
index?: number;
matchedFilterColors?: Map<string, string>;
row: Comment[];
}
const CatalogRow = memo(({ row }: CatalogRowProps) => {
const CatalogRow = memo(({ estimatedHeight, matchedFilterColors, row }: CatalogRowProps) => {
return (
<div className={styles.row}>
<div className={styles.row} data-pretext-height={estimatedHeight}>
{row.map((post, index) => (
<CatalogPost key={post?.cid || index} post={post} />
<CatalogPost key={post?.cid || index} matchedFilterColor={matchedFilterColors?.get(post?.cid || '')} post={post} />
))}
</div>
);
+122 -20
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
@@ -45,6 +45,7 @@ import useChallengesStore from '../../stores/use-challenges-store';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
import useReplyHeightEstimates from '../../hooks/use-reply-height-estimates';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import { usePublishCommentModeration } from '@bitsocialnet/bitsocial-react-hooks';
import useQuotedByMap from '../../hooks/use-quoted-by-map';
@@ -63,6 +64,7 @@ import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../l
import useDeleteFailedPost from '../../hooks/use-delete-failed-post';
import { getThreadPostCountsByAuthor } from '../../lib/utils/author-post-counts';
import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAuditSample } from '../../lib/utils/pretext-height-estimates';
const { addChallenge } = useChallengesStore.getState();
@@ -714,7 +716,8 @@ const Reply = ({
quotedByMap,
directRepliesByParentCid,
postsByAuthorInThread,
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]>; postsByAuthorInThread?: Map<string, number> }) => {
disableDeferredLayout,
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]>; postsByAuthorInThread?: Map<string, number>; disableDeferredLayout?: boolean }) => {
const accountReply = useSafeAccountComment({
commentIndex: typeof reply?.index === 'number' ? reply.index : undefined,
});
@@ -748,7 +751,7 @@ const Reply = ({
const failedPublishNotice = canDeleteFailedPost ? <FailedPublishNotice isDeleting={isDeletingFailedPost} onDelete={onDeleteFailedPost} /> : undefined;
return (
<div className={styles.replyDesktop}>
<div className={`${styles.replyDesktop} ${disableDeferredLayout ? styles.pretextVirtualizedReply : ''}`}>
<div className={styles.sideArrows}>{'>>'}</div>
<div className={`${styles.reply} ${isRouteLinkToReply && styles.highlight}`} data-cid={cid} data-author-address={author?.shortAddress} data-post-cid={postCid}>
<PostInfo
@@ -787,8 +790,11 @@ const Reply = ({
};
const PostDesktop = ({
feedVirtualizationModeOverride,
post,
roles,
replyPaginationOverride,
replyVirtualizationModeOverride,
showAllReplies,
showReplies = true,
targetReplyCid,
@@ -829,8 +835,9 @@ const PostDesktop = ({
const { showOmittedReplies, setShowOmittedReplies } = useShowOmittedReplies();
const shouldUsePreview = showReplies && !isModQueue && !showAllReplies;
const shouldFetchFull = showReplies && !isModQueue && (showAllReplies || showOmittedReplies[cid]);
const hasReplyPaginationOverride = !!replyPaginationOverride;
const shouldUsePreview = showReplies && !isModQueue && !showAllReplies && !hasReplyPaginationOverride;
const shouldFetchFull = showReplies && !isModQueue && !hasReplyPaginationOverride && (showAllReplies || showOmittedReplies[cid]);
const cachedPreviewRepliesResult = useReplies({
comment: shouldUsePreview ? resolvedPost : undefined,
@@ -866,15 +873,18 @@ const PostDesktop = ({
const livePreviewReplies = (previewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
? (previewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies!
: previewRepliesResult.replies || [];
const previewReplies = hasEnoughCachedPreview ? cachedPreviewReplies : livePreviewReplies;
const fullReplies = (fullRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
? (fullRepliesResult as { updatedReplies?: Comment[] }).updatedReplies!
: fullRepliesResult.replies || [];
const previewReplies = hasReplyPaginationOverride ? replyPaginationOverride.replies : hasEnoughCachedPreview ? cachedPreviewReplies : livePreviewReplies;
const fullReplies = hasReplyPaginationOverride
? replyPaginationOverride.replies
: (fullRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
? (fullRepliesResult as { updatedReplies?: Comment[] }).updatedReplies!
: fullRepliesResult.replies || [];
const { hasMore, loadMore } = fullRepliesResult;
const reset = (fullRepliesResult as { reset?: () => Promise<void> }).reset;
const hasMore = replyPaginationOverride?.hasMore ?? fullRepliesResult.hasMore;
const loadMore = replyPaginationOverride?.loadMore ?? fullRepliesResult.loadMore;
const reset = replyPaginationOverride?.reset ?? (fullRepliesResult as { reset?: () => Promise<void> }).reset;
const fullIsFetching = shouldFetchFull && fullReplies.length === 0 && fullRepliesResult.hasMore;
const fullIsFetching = shouldFetchFull && !hasReplyPaginationOverride && fullReplies.length === 0 && fullRepliesResult.hasMore;
const repliesForRender = showAllReplies
? fullReplies
@@ -924,9 +934,9 @@ const PostDesktop = ({
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
// Author-deleted replies are hidden from thread replies; moderator removals still render their placeholder.
const filteredReplies = filterRepliesForDisplay(freshRepliesForRender);
const postsByAuthorInThread = getThreadPostCountsByAuthor(resolvedPost, filteredReplies);
const directRepliesByParentCid = (() => {
const filteredReplies = useMemo(() => filterRepliesForDisplay(freshRepliesForRender), [freshRepliesForRender]);
const postsByAuthorInThread = useMemo(() => getThreadPostCountsByAuthor(resolvedPost, filteredReplies), [resolvedPost, filteredReplies]);
const directRepliesByParentCid = useMemo(() => {
const map = new Map<string, Comment[]>();
for (const reply of filteredReplies) {
const directParentCid = reply?.parentCid;
@@ -941,9 +951,92 @@ const PostDesktop = ({
}
}
return map;
})();
}, [filteredReplies]);
const quotedByMap = useQuotedByMap(filteredReplies, communityAddress);
const {
defaultItemHeight: defaultReplyItemHeight,
heightEstimates: replyHeightEstimates,
itemSize: replyItemSize,
metrics,
windowWidth,
} = useReplyHeightEstimates({
directRepliesByParentCid,
enabled: showAllReplies,
isMobile: false,
maxContentChars: showAllReplies ? 2000 : 1000,
mode: replyVirtualizationModeOverride,
quotedByMap,
replies: filteredReplies,
});
const replyVirtualizationProps = replyItemSize ? { itemSize: replyItemSize } : {};
const shouldUseFeedHeightEstimate = !showAllReplies;
const shouldUsePretextFeedHeightEstimate = shouldUseFeedHeightEstimate && feedVirtualizationModeOverride !== 'off';
const previewReplyHeightEstimates = useMemo(
() =>
!shouldUsePretextFeedHeightEstimate || filteredReplies.length === 0
? []
: getReplyHeightEstimates({
context: 'preview',
directRepliesByParentCid,
isMobile: false,
maxContentChars: 1000,
metrics,
quotedByMap,
replies: filteredReplies,
windowWidth,
}),
[directRepliesByParentCid, filteredReplies, metrics, quotedByMap, shouldUsePretextFeedHeightEstimate, windowWidth],
);
const getPreviewReplyDebugProps = useCallback(
(index: number) => {
if (!import.meta.env.DEV || !shouldUsePretextFeedHeightEstimate) {
return {};
}
const reply = filteredReplies[index];
return {
'data-pretext-reply-estimate': previewReplyHeightEstimates[index],
'data-pretext-reply-content-length': reply?.content?.length || 0,
'data-pretext-reply-has-media': reply?.link ? '1' : '0',
'data-pretext-reply-number': reply?.number,
'data-pretext-reply-title-length': reply?.title?.trim().length || 0,
};
},
[filteredReplies, previewReplyHeightEstimates, shouldUsePretextFeedHeightEstimate],
);
const feedHeightEstimate = useMemo(
() =>
!shouldUsePretextFeedHeightEstimate
? undefined
: getFeedPostHeightEstimate({
directRepliesByParentCid,
isMobile: false,
metrics,
post: resolvedPost,
previewReplies: filteredReplies,
previewReplyEstimates: previewReplyHeightEstimates,
quotedByMap,
showBoardLabel: isMultiboardView && Boolean(boardPath),
showSummary: showReplies && repliesCount > 0 && !isInPostPageView,
windowWidth,
}),
[
directRepliesByParentCid,
filteredReplies,
isInPostPageView,
metrics,
boardPath,
previewReplyHeightEstimates,
quotedByMap,
repliesCount,
resolvedPost,
shouldUsePretextFeedHeightEstimate,
showReplies,
isMultiboardView,
windowWidth,
],
);
const visibleReplies = useProgressiveRender(filteredReplies, {
batchSize: 50,
@@ -986,7 +1079,7 @@ const PostDesktop = ({
const virtuosoFooter = useCallback(() => <RepliesFooter hasMore={hasMore} loadingString={t('loading')} />, [hasMore, t]);
return (
<div className={styles.postDesktop}>
<div className={styles.postDesktop} data-pretext-height={shouldUsePretextFeedHeightEstimate ? feedHeightEstimate : undefined}>
{showReplies || isModQueue ? (
<div className={styles.hrWrapper}>
<hr />
@@ -1098,12 +1191,20 @@ const PostDesktop = ({
{/* Virtuoso infinite scroll for post page view when there's more content to paginate */}
{!isHidden && showAllReplies && !isInPendingPostView && showReplies && hasMore && !!resolvedPost?.replyCount && (
<Virtuoso
defaultItemHeight={defaultReplyItemHeight}
heightEstimates={replyHeightEstimates}
{...replyVirtualizationProps}
increaseViewportBy={{ bottom: 1200, top: 1200 }}
totalCount={filteredReplies.length}
data={filteredReplies}
itemContent={(index, reply) => (
<div className={styles.replyContainer}>
<div
className={styles.replyContainer}
data-pretext-height={replyHeightEstimates?.[index]}
ref={(element) => reportReplyHeightAuditSample(element, replyHeightEstimates?.[index], reply.cid)}
>
<Reply
disableDeferredLayout={Boolean(replyItemSize)}
reply={reply}
roles={roles}
postReplyCount={replyCount}
@@ -1147,9 +1248,10 @@ const PostDesktop = ({
!isInPendingPostView &&
freshRepliesForRender &&
showReplies &&
filteredReplies.map((reply) => (
<div key={reply.cid} className={styles.replyContainer}>
filteredReplies.map((reply, index) => (
<div key={reply.cid} className={styles.replyContainer} {...getPreviewReplyDebugProps(index)}>
<Reply
disableDeferredLayout={feedVirtualizationModeOverride === 'item-size'}
reply={reply}
roles={roles}
postReplyCount={replyCount}
+125 -27
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
@@ -43,6 +43,7 @@ import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import useQuotedByMap from '../../hooks/use-quoted-by-map';
import useProgressiveRender from '../../hooks/use-progressive-render';
import useReplyHeightEstimates from '../../hooks/use-reply-height-estimates';
import useFreshReplies from '../../hooks/use-fresh-replies';
import { BOARD_REPLIES_PREVIEW_FETCH_SIZE, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT, REPLIES_PER_PAGE } from '../../lib/constants';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
@@ -52,6 +53,7 @@ import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../l
import useDeleteFailedPost from '../../hooks/use-delete-failed-post';
import { getThreadPostCountsByAuthor } from '../../lib/utils/author-post-counts';
import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAuditSample } from '../../lib/utils/pretext-height-estimates';
const { addChallenge } = useChallengesStore.getState();
@@ -496,7 +498,8 @@ const Reply = ({
quotedByMap,
directRepliesByParentCid,
postsByAuthorInThread,
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]>; postsByAuthorInThread?: Map<string, number> }) => {
disableDeferredLayout,
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]>; postsByAuthorInThread?: Map<string, number>; disableDeferredLayout?: boolean }) => {
const accountReply = useSafeAccountComment({
commentIndex: typeof reply?.index === 'number' ? reply.index : undefined,
});
@@ -520,7 +523,7 @@ const Reply = ({
const failedPublishNotice = canDeleteFailedPost ? <FailedPublishNotice isDeleting={isDeletingFailedPost} onDelete={onDeleteFailedPost} /> : undefined;
return (
<div className={styles.replyMobile}>
<div className={`${styles.replyMobile} ${disableDeferredLayout ? styles.pretextVirtualizedReply : ''}`}>
<div className={styles.reply}>
<div
className={`${styles.replyContainer} ${isRouteLinkToReply && styles.highlight}`}
@@ -540,8 +543,11 @@ const Reply = ({
};
const PostMobile = ({
feedVirtualizationModeOverride,
post,
roles,
replyPaginationOverride,
replyVirtualizationModeOverride,
showAllReplies,
showReplies = true,
targetReplyCid,
@@ -565,7 +571,8 @@ const PostMobile = ({
const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true;
const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined;
const linksCount = useCountLinksInReplies(resolvedPost);
const shouldFetchReplies = showReplies && !isModQueue;
const hasReplyPaginationOverride = !!replyPaginationOverride;
const shouldFetchReplies = showReplies && !isModQueue && !hasReplyPaginationOverride;
const shouldUsePreview = shouldFetchReplies && !showAllReplies;
const cachedPreviewRepliesResult = useReplies({
comment: shouldUsePreview ? resolvedPost : undefined,
@@ -601,8 +608,18 @@ const PostMobile = ({
const livePreviewReplies = (previewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
? (previewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies!
: previewRepliesResult.replies || [];
const previewReplies = hasEnoughCachedPreview ? cachedPreviewReplies : livePreviewReplies;
const repliesResult = showAllReplies ? fullRepliesResult : { ...previewRepliesResult, replies: previewReplies, updatedReplies: previewReplies };
const previewReplies = hasReplyPaginationOverride ? replyPaginationOverride.replies : hasEnoughCachedPreview ? cachedPreviewReplies : livePreviewReplies;
const repliesResult = hasReplyPaginationOverride
? {
hasMore: replyPaginationOverride.hasMore ?? false,
loadMore: replyPaginationOverride.loadMore ?? (() => {}),
replies: replyPaginationOverride.replies,
reset: replyPaginationOverride.reset,
updatedReplies: replyPaginationOverride.replies,
}
: showAllReplies
? fullRepliesResult
: { ...previewRepliesResult, replies: previewReplies, updatedReplies: previewReplies };
const { replies, hasMore, loadMore } = repliesResult;
const updatedReplies = (repliesResult as { updatedReplies?: Comment[] }).updatedReplies;
const repliesForRender = updatedReplies?.length ? updatedReplies : replies || [];
@@ -635,11 +652,11 @@ const PostMobile = ({
const failedPublishNotice = canDeleteFailedPost ? <FailedPublishNotice isDeleting={isDeletingFailedPost} onDelete={onDeleteFailedPost} /> : undefined;
// Author-deleted replies are hidden from thread replies; moderator removals still render their placeholder.
const filteredReplies = filterRepliesForDisplay(freshRepliesForRender);
const postsByAuthorInThread = getThreadPostCountsByAuthor(resolvedPost, filteredReplies);
const previewDisplayReplies = getPreviewDisplayReplies(filteredReplies, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT);
const filteredReplies = useMemo(() => filterRepliesForDisplay(freshRepliesForRender), [freshRepliesForRender]);
const postsByAuthorInThread = useMemo(() => getThreadPostCountsByAuthor(resolvedPost, filteredReplies), [resolvedPost, filteredReplies]);
const previewDisplayReplies = useMemo(() => getPreviewDisplayReplies(filteredReplies, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT), [filteredReplies]);
const directRepliesByParentCid = (() => {
const directRepliesByParentCid = useMemo(() => {
const map = new Map<string, Comment[]>();
for (const reply of filteredReplies) {
const directParentCid = reply?.parentCid;
@@ -652,9 +669,62 @@ const PostMobile = ({
}
}
return map;
})();
}, [filteredReplies]);
const quotedByMap = useQuotedByMap(filteredReplies, communityAddress);
const {
defaultItemHeight: defaultReplyItemHeight,
heightEstimates: replyHeightEstimates,
itemSize: replyItemSize,
metrics,
windowWidth,
} = useReplyHeightEstimates({
directRepliesByParentCid,
enabled: showAllReplies,
isMobile: true,
maxContentChars: showAllReplies ? 2000 : 1000,
mode: replyVirtualizationModeOverride,
quotedByMap,
replies: filteredReplies,
});
const replyVirtualizationProps = replyItemSize ? { itemSize: replyItemSize } : {};
const shouldUseFeedHeightEstimate = !showAllReplies && feedVirtualizationModeOverride !== 'off';
const previewReplyHeightEstimates = useMemo(
() =>
!shouldUseFeedHeightEstimate || previewDisplayReplies.length === 0
? []
: getReplyHeightEstimates({
context: 'preview',
directRepliesByParentCid,
isMobile: true,
maxContentChars: 1000,
metrics,
quotedByMap,
replies: previewDisplayReplies,
windowWidth,
}),
[directRepliesByParentCid, metrics, previewDisplayReplies, quotedByMap, shouldUseFeedHeightEstimate, windowWidth],
);
const getPreviewReplyDebugProps = useCallback(
(index: number) => (import.meta.env.DEV && shouldUseFeedHeightEstimate ? { 'data-pretext-reply-estimate': previewReplyHeightEstimates[index] } : {}),
[previewReplyHeightEstimates, shouldUseFeedHeightEstimate],
);
const feedHeightEstimate = useMemo(
() =>
!shouldUseFeedHeightEstimate
? undefined
: getFeedPostHeightEstimate({
directRepliesByParentCid,
isMobile: true,
metrics,
post: resolvedPost,
previewReplies: previewDisplayReplies,
previewReplyEstimates: previewReplyHeightEstimates,
quotedByMap,
windowWidth,
}),
[directRepliesByParentCid, metrics, previewDisplayReplies, previewReplyHeightEstimates, quotedByMap, resolvedPost, shouldUseFeedHeightEstimate, windowWidth],
);
const visibleReplies = useProgressiveRender(filteredReplies, {
batchSize: 50,
@@ -719,7 +789,7 @@ const PostMobile = ({
</span>
</>
) : (
<div className={styles.postMobile}>
<div className={styles.postMobile} data-pretext-height={shouldUseFeedHeightEstimate ? feedHeightEstimate : undefined}>
{(showReplies || isModQueue) && (
<div className={styles.hrWrapper}>
<hr />
@@ -796,22 +866,49 @@ const PostMobile = ({
{/* Virtuoso infinite scroll for post page view when there's more content to paginate */}
{showAllReplies && !isInPendingPostView && showReplies && hasMore && !!resolvedPost?.replyCount && (
<Virtuoso
defaultItemHeight={defaultReplyItemHeight}
heightEstimates={replyHeightEstimates}
{...replyVirtualizationProps}
increaseViewportBy={{ bottom: 1200, top: 1200 }}
totalCount={filteredReplies.length}
data={filteredReplies}
itemContent={(index, reply) => (
<div className={styles.replyContainer}>
<Reply
postReplyCount={replyCount}
reply={reply}
postsByAuthorInThread={postsByAuthorInThread}
roles={roles}
threadNumber={resolvedPost?.number}
quotedByMap={quotedByMap}
directRepliesByParentCid={directRepliesByParentCid}
/>
</div>
)}
itemContent={(index, reply) => {
const renderableBacklinks = import.meta.env.DEV
? getRenderableMobileBacklinks({
cid: reply.cid,
directRepliesByParentCid,
parentCid: reply.parentCid,
quotedByMap,
})
: undefined;
const backlinkCount = renderableBacklinks
? renderableBacklinks.directReplyBacklinks.length + renderableBacklinks.opBacklinks.length + renderableBacklinks.quotedReplyBacklinks.length
: undefined;
return (
<div
className={styles.replyContainer}
data-pretext-height={replyHeightEstimates?.[index]}
data-pretext-reply-backlink-count={backlinkCount}
data-pretext-reply-content-length={import.meta.env.DEV ? reply.content?.length || 0 : undefined}
data-pretext-reply-has-media={import.meta.env.DEV ? (reply.link ? '1' : '0') : undefined}
data-pretext-reply-number={import.meta.env.DEV ? reply.number : undefined}
data-pretext-reply-title-length={import.meta.env.DEV ? reply.title?.trim().length || 0 : undefined}
ref={(element) => reportReplyHeightAuditSample(element, replyHeightEstimates?.[index], reply.cid)}
>
<Reply
disableDeferredLayout={Boolean(replyItemSize)}
postReplyCount={replyCount}
reply={reply}
postsByAuthorInThread={postsByAuthorInThread}
roles={roles}
threadNumber={resolvedPost?.number}
quotedByMap={quotedByMap}
directRepliesByParentCid={directRepliesByParentCid}
/>
</div>
);
}}
useWindowScroll={true}
components={{ Footer: virtuosoFooter }}
endReached={loadMore}
@@ -843,9 +940,10 @@ const PostMobile = ({
!isInPendingPostView &&
freshRepliesForRender &&
showReplies &&
previewDisplayReplies.map((reply) => (
<div key={reply.cid} className={styles.replyContainer}>
previewDisplayReplies.map((reply, index) => (
<div key={reply.cid} className={styles.replyContainer} {...getPreviewReplyDebugProps(index)}>
<Reply
disableDeferredLayout={feedVirtualizationModeOverride === 'item-size'}
postReplyCount={replyCount}
reply={reply}
postsByAuthorInThread={postsByAuthorInThread}
+869
View File
@@ -0,0 +1,869 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Virtuoso, type SizeFunction } from 'react-virtuoso';
import type { Comment } from '@bitsocialnet/bitsocial-react-hooks';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import CatalogRow from '../components/catalog-row';
import { Post } from '../views/post';
import PostDesktop from '../components/post-desktop';
import PostMobile from '../components/post-mobile';
import {
getCatalogRowHeightEstimates,
getFeedPostHeightEstimate,
getPretextItemSizeFromElement,
getReplyHeightEstimates,
getTypicalCatalogRowHeight,
readReplyTypographyMetrics,
type CatalogImageSize,
type ReplyVirtualizationMode,
} from '../lib/utils/pretext-height-estimates';
import useCatalogStyleStore from '../stores/use-catalog-style-store';
type BenchmarkMode = 'dom' | 'estimates' | 'item-size';
type BenchmarkVariant = 'production' | 'synthetic';
type BenchmarkSurface = 'board' | 'catalog' | 'replies';
type SyntheticReply = Comment & {
benchmarkQuotedCids?: string[];
};
type SyntheticBoardItem = {
post: SyntheticReply;
previewReplies: SyntheticReply[];
};
type BenchmarkMetrics = {
durationMs: number;
itemSizeCalls: number;
itemCount: number;
longTasks: number;
maxFrameMs: number;
mode: BenchmarkMode;
visibleMeanAbsoluteError: number;
visibleMaxAbsoluteError: number;
rectCalls: number;
renderedItems: number;
scrollHeight: number;
slowFrames16: number;
slowFrames32: number;
surface: BenchmarkSurface;
variant: BenchmarkVariant;
viewportHeight: number;
};
type BenchmarkApi = {
getMetrics: () => BenchmarkMetrics | null;
runScenario: () => Promise<BenchmarkMetrics>;
};
declare global {
interface Window {
__PRETEXT_BENCH__?: BenchmarkApi;
__PRETEXT_BENCH_RECT_COUNTER__?: {
count: number;
reset: () => void;
};
__getReactScanReport?: () => unknown;
}
}
const THREAD_CID = 'thread-root';
const DESKTOP_MEDIA_SIZE = 125;
const MOBILE_MEDIA_SIZE = 180;
const NOOP = () => {};
const WORD_BANK = [
'anon',
'feed',
'thread',
'archive',
'reply',
'catalog',
'signal',
'latency',
'render',
'virtualization',
'scroll',
'layout',
'poster',
'board',
'quote',
'merkle',
'overlay',
'cursor',
'client',
'browser',
'canvas',
'bundle',
'observer',
'window',
'pretext',
'measurement',
'comment',
'media',
'thumbnail',
'protocol',
];
const parseIntegerSearchParam = (params: URLSearchParams, key: string, fallbackValue: number) => {
const parsedValue = Number.parseInt(params.get(key) || '', 10);
return Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : fallbackValue;
};
const resolveBenchmarkMode = (params: URLSearchParams): BenchmarkMode => {
const mode = params.get('mode');
return mode === 'estimates' || mode === 'item-size' ? mode : 'dom';
};
const resolveBenchmarkVariant = (params: URLSearchParams): BenchmarkVariant => {
return params.get('variant') === 'synthetic' ? 'synthetic' : 'production';
};
const resolveBenchmarkSurface = (params: URLSearchParams): BenchmarkSurface => {
const surface = params.get('surface');
return surface === 'board' || surface === 'catalog' ? surface : 'replies';
};
const resolveCatalogImageSize = (params: URLSearchParams): CatalogImageSize => (params.get('catalogImageSize') === 'Large' ? 'Large' : 'Small');
const resolveCatalogShowOpComment = (params: URLSearchParams): boolean => params.get('showOPComment') !== '0';
const createRandom = (seed: number) => {
let currentSeed = seed >>> 0;
return () => {
currentSeed = (currentSeed * 1664525 + 1013904223) >>> 0;
return currentSeed / 4294967296;
};
};
const waitForFrames = async (frameCount: number) =>
new Promise<void>((resolve) => {
let remainingFrames = frameCount;
const handleFrame = () => {
remainingFrames -= 1;
if (remainingFrames <= 0) {
resolve();
return;
}
window.requestAnimationFrame(handleFrame);
};
window.requestAnimationFrame(handleFrame);
});
const installRectCounter = () => {
if (window.__PRETEXT_BENCH_RECT_COUNTER__) {
return window.__PRETEXT_BENCH_RECT_COUNTER__;
}
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
let count = 0;
Element.prototype.getBoundingClientRect = function getBoundingClientRectPatched(...args) {
count += 1;
return originalGetBoundingClientRect.apply(this, args as []);
};
window.__PRETEXT_BENCH_RECT_COUNTER__ = {
get count() {
return count;
},
reset() {
count = 0;
},
};
return window.__PRETEXT_BENCH_RECT_COUNTER__;
};
const buildText = (random: () => number, replyIndex: number) => {
const sentenceCount = 2 + Math.floor(random() * 14);
const sentences: string[] = [];
for (let sentenceIndex = 0; sentenceIndex < sentenceCount; sentenceIndex += 1) {
const wordCount = 8 + Math.floor(random() * 36);
const words = Array.from({ length: wordCount }, () => WORD_BANK[Math.floor(random() * WORD_BANK.length)]);
const sentence = `${words.join(' ')} ${replyIndex + 1}-${sentenceIndex + 1}.`;
sentences.push(sentence);
}
if (random() < 0.2) {
sentences.splice(Math.max(1, Math.floor(sentences.length / 2)), 0, '');
}
return sentences.join('\n');
};
const buildReplies = (count: number, seed: number): SyntheticReply[] => {
const random = createRandom(seed);
const replies: SyntheticReply[] = [];
for (let index = 0; index < count; index += 1) {
const quotedTargets: string[] = [];
const quoteTargetCount = replies.length === 0 ? 0 : Math.floor(random() * 3);
for (let quoteIndex = 0; quoteIndex < quoteTargetCount; quoteIndex += 1) {
const targetReply = replies[Math.floor(random() * replies.length)];
if (targetReply?.cid && !quotedTargets.includes(targetReply.cid)) {
quotedTargets.push(targetReply.cid);
}
}
const directParent = replies.length > 12 && random() < 0.16 ? replies[Math.floor(random() * Math.min(replies.length, 40))]?.cid || THREAD_CID : THREAD_CID;
const hasMedia = random() < 0.35;
const landscapeBias = random() < 0.5;
const linkWidth = hasMedia ? (landscapeBias ? 720 + Math.floor(random() * 960) : 320 + Math.floor(random() * 480)) : undefined;
const linkHeight = hasMedia ? (landscapeBias ? 360 + Math.floor(random() * 640) : 680 + Math.floor(random() * 1120)) : undefined;
replies.push({
author: {
address: `0x${(index + 1).toString(16).padStart(4, '0')}`,
shortAddress: `${(index + 1).toString(16).padStart(4, '0')}`,
},
benchmarkQuotedCids: quotedTargets,
cid: `reply-${index + 1}`,
content: `${quotedTargets.map((cid) => `>>${cid.split('-').at(-1)}`).join(' ')} ${buildText(random, index)}`.trim(),
communityAddress: 'music-posting.eth',
link: hasMedia ? `https://example.com/media-${index + 1}.jpg` : undefined,
linkHeight,
linkWidth,
number: index + 1,
parentCid: directParent,
postCid: THREAD_CID,
reason: undefined,
spoiler: hasMedia ? random() < 0.08 : false,
state: 'succeeded',
timestamp: 1700000000 + index * 60,
thumbnailUrl: hasMedia ? `https://example.com/thumb-${index + 1}.jpg` : undefined,
title: random() < 0.18 ? `Synthetic reply ${index + 1}` : undefined,
} as SyntheticReply);
}
return replies;
};
const buildBacklinkMaps = (replies: SyntheticReply[]) => {
const directRepliesByParentCid = new Map<string, SyntheticReply[]>();
const quotedByMap = new Map<string, SyntheticReply[]>();
for (const reply of replies) {
if (reply.parentCid && reply.parentCid !== THREAD_CID) {
const directReplies = directRepliesByParentCid.get(reply.parentCid) || [];
directReplies.push(reply);
directRepliesByParentCid.set(reply.parentCid, directReplies);
}
for (const quotedCid of reply.benchmarkQuotedCids || []) {
const quotedReplies = quotedByMap.get(quotedCid) || [];
quotedReplies.push(reply);
quotedByMap.set(quotedCid, quotedReplies);
}
}
return { directRepliesByParentCid, quotedByMap };
};
const getMediaBox = (reply: SyntheticReply, isMobile: boolean) => {
if (!reply.link) {
return null;
}
const maxSize = isMobile ? MOBILE_MEDIA_SIZE : DESKTOP_MEDIA_SIZE;
const width = reply.spoiler ? Math.min(maxSize, 100) : reply.linkWidth || maxSize;
const height = reply.spoiler ? Math.min(maxSize, 100) : reply.linkHeight || maxSize;
const scale = Math.min(1, maxSize / Math.max(width, height));
return {
height: Math.max(40, Math.round(height * scale)),
width: Math.max(56, Math.round(width * scale)),
};
};
const getBacklinkLabels = (reply: SyntheticReply, directRepliesByParentCid: Map<string, SyntheticReply[]>, quotedByMap: Map<string, SyntheticReply[]>) => {
const directReplies = directRepliesByParentCid.get(reply.cid || '') || [];
const quotedReplies = quotedByMap.get(reply.cid || '') || [];
return [...directReplies, ...quotedReplies].map((candidateReply) => `>>${candidateReply.number}`);
};
const summarizeRenderReport = () => {
const report = window.__getReactScanReport?.();
if (!report || typeof report !== 'object') {
return null;
}
const reportValues = Array.isArray(report) ? report : Object.values(report as Record<string, unknown>);
return {
componentCount: reportValues.length,
};
};
const buildThreadRoot = (replies: SyntheticReply[]): SyntheticReply =>
({
author: {
address: '0xop',
shortAddress: '0xop',
},
cid: THREAD_CID,
communityAddress: 'music-posting.eth',
content: 'Synthetic OP body for the production-path benchmark.',
number: 1,
replyCount: replies.length,
state: 'succeeded',
timestamp: 1699999000,
title: 'Synthetic benchmark thread',
}) as SyntheticReply;
const buildBoardItems = (count: number, seed: number): SyntheticBoardItem[] => {
const random = createRandom(seed ^ 0x5f3759df);
return Array.from({ length: count }, (_, index) => {
const cid = `post-${index + 1}`;
const hasMedia = random() < 0.4;
const landscapeBias = random() < 0.5;
const linkWidth = hasMedia ? (landscapeBias ? 960 + Math.floor(random() * 960) : 360 + Math.floor(random() * 480)) : undefined;
const linkHeight = hasMedia ? (landscapeBias ? 540 + Math.floor(random() * 540) : 900 + Math.floor(random() * 900)) : undefined;
const previewReplyCount = Math.floor(random() * 4);
const omittedReplyCount = Math.floor(random() * 9);
const previewReplies: SyntheticReply[] = [];
for (let replyIndex = 0; replyIndex < previewReplyCount; replyIndex += 1) {
const quotedCids = random() < 0.45 ? [cid] : [];
const priorReply = replyIndex > 0 && random() < 0.3 ? previewReplies[Math.floor(random() * replyIndex)] : undefined;
if (priorReply?.cid && !quotedCids.includes(priorReply.cid)) {
quotedCids.push(priorReply.cid);
}
const replyText = `${quotedCids.map((quotedCid) => `>>${quotedCid.split('-').at(-1)}`).join(' ')} ${buildText(random, replyIndex)}`.trim();
previewReplies.push({
author: {
address: `0xfeed${index.toString(16).padStart(3, '0')}${replyIndex.toString(16).padStart(2, '0')}`,
shortAddress: `${index.toString(16).padStart(3, '0')}${replyIndex.toString(16).padStart(2, '0')}`,
},
benchmarkQuotedCids: quotedCids,
cid: `${cid}-reply-${replyIndex + 1}`,
communityAddress: 'music-posting.eth',
content: replyText.slice(0, 1200),
number: replyIndex + 2,
parentCid: priorReply?.cid || cid,
postCid: cid,
state: 'succeeded',
timestamp: 1700000000 + index * 360 + replyIndex * 60,
title: replyIndex === 0 && random() < 0.25 ? `Preview ${replyIndex + 1}` : undefined,
} as SyntheticReply);
}
return {
post: {
author: {
address: `0xpost${(index + 1).toString(16).padStart(4, '0')}`,
shortAddress: `${(index + 1).toString(16).padStart(4, '0')}`,
},
cid,
communityAddress: 'music-posting.eth',
content: buildText(random, index).slice(0, 2400),
link: hasMedia ? `https://example.com/feed-${index + 1}.jpg` : undefined,
linkHeight,
linkWidth,
number: index + 1,
postCid: cid,
replyCount: previewReplyCount + omittedReplyCount,
spoiler: hasMedia ? random() < 0.08 : false,
state: 'succeeded',
timestamp: 1699999000 + index * 300,
thumbnailUrl: hasMedia ? `https://example.com/feed-thumb-${index + 1}.jpg` : undefined,
title: random() < 0.35 ? `Synthetic thread ${index + 1}` : undefined,
} as SyntheticReply,
previewReplies,
};
});
};
const BenchmarkReplyRow = ({
directRepliesByParentCid,
estimate,
isMobile,
quotedByMap,
reply,
}: {
directRepliesByParentCid: Map<string, SyntheticReply[]>;
estimate?: number;
isMobile: boolean;
quotedByMap: Map<string, SyntheticReply[]>;
reply: SyntheticReply;
}) => {
const metrics = readReplyTypographyMetrics();
const mediaBox = getMediaBox(reply, isMobile);
const backlinks = getBacklinkLabels(reply, directRepliesByParentCid, quotedByMap);
const contentFontSize = isMobile ? metrics.mobileContentFontSizePx : metrics.bodyFontSizePx;
const headerStyle = { color: '#789922', fontSize: 12, marginBottom: 6 };
const mediaStyle = mediaBox
? isMobile
? {
alignItems: 'center',
background: '#ddd',
border: '1px solid #bbb',
color: '#555',
display: 'flex',
height: mediaBox.height,
justifyContent: 'center',
marginBottom: 10,
width: `min(100%, ${mediaBox.width}px)`,
}
: {
alignItems: 'center',
background: '#ddd',
border: '1px solid #bbb',
color: '#555',
display: 'flex',
float: 'left' as const,
height: mediaBox.height,
justifyContent: 'center',
margin: '0 16px 8px 0',
width: mediaBox.width,
}
: undefined;
return (
<article
data-bench-backlink-count={backlinks.length}
data-bench-content-length={reply.content.length}
data-bench-has-media={mediaBox ? '1' : '0'}
data-bench-number={reply.number}
data-bench-title-length={reply.title?.length || 0}
data-bench-reply-row=''
data-pretext-height={estimate}
style={{
background: '#fff',
borderBottom: '1px solid #d6d6d6',
color: '#111',
fontFamily: 'Arial, Helvetica, sans-serif',
minHeight: 72,
padding: isMobile ? '12px 12px 14px' : '10px 12px 12px',
}}
>
<div style={headerStyle}>Anonymous 03/30/26 No.{reply.number}</div>
{mediaStyle && <div style={mediaStyle}>{reply.spoiler ? 'Spoiler' : 'Media'}</div>}
{reply.title ? <div style={{ fontSize: contentFontSize, fontWeight: 700, marginBottom: 6 }}>{reply.title}</div> : null}
<div style={{ fontSize: contentFontSize, lineHeight: 1.35, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{reply.content}</div>
{backlinks.length > 0 ? (
<div style={{ clear: mediaStyle && !isMobile ? 'both' : undefined, color: '#789922', fontSize: 12, marginTop: 8 }}>{backlinks.join(' ')}</div>
) : mediaStyle && !isMobile ? (
<div style={{ clear: 'both' }} />
) : null}
</article>
);
};
const Harness = () => {
const searchParams = useMemo(() => new URLSearchParams(window.location.search), []);
const [latestMetrics, setLatestMetrics] = useState<BenchmarkMetrics | null>(null);
const isMobile = typeof window !== 'undefined' ? window.innerWidth < 768 : false;
const mode = resolveBenchmarkMode(searchParams);
const variant = resolveBenchmarkVariant(searchParams);
const surface = resolveBenchmarkSurface(searchParams);
const catalogImageSize = resolveCatalogImageSize(searchParams);
const showCatalogOpComment = resolveCatalogShowOpComment(searchParams);
const effectiveVariant: BenchmarkVariant = surface === 'replies' ? variant : 'production';
const replyCount = parseIntegerSearchParam(searchParams, 'count', 1200);
const seed = parseIntegerSearchParam(searchParams, 'seed', 42);
const itemSizeCallCountRef = useRef(0);
const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 1280;
const replies = useMemo(() => buildReplies(replyCount, seed), [replyCount, seed]);
const boardItems = useMemo(() => buildBoardItems(replyCount, seed), [replyCount, seed]);
const threadRoot = useMemo(() => buildThreadRoot(replies), [replies]);
const { directRepliesByParentCid, quotedByMap } = useMemo(() => buildBacklinkMaps(replies), [replies]);
const metrics = useMemo(() => readReplyTypographyMetrics(), []);
const heightEstimates = useMemo(
() =>
getReplyHeightEstimates({
directRepliesByParentCid,
isMobile,
metrics,
quotedByMap,
replies,
windowWidth,
}),
[directRepliesByParentCid, isMobile, metrics, quotedByMap, replies, windowWidth],
);
const boardReplyPaginationOverrides = useMemo(
() =>
boardItems.map(({ previewReplies }) => ({
hasMore: false,
loadMore: NOOP,
replies: previewReplies,
})),
[boardItems],
);
const boardHeightEstimates = useMemo(
() =>
boardItems.map(({ post, previewReplies }) => {
const { directRepliesByParentCid: previewDirectRepliesByParentCid, quotedByMap: previewQuotedByMap } = buildBacklinkMaps(previewReplies);
const previewReplyEstimates = getReplyHeightEstimates({
directRepliesByParentCid: previewDirectRepliesByParentCid,
isMobile,
maxContentChars: 1000,
metrics,
quotedByMap: previewQuotedByMap,
replies: previewReplies,
windowWidth,
});
return getFeedPostHeightEstimate({
directRepliesByParentCid: previewDirectRepliesByParentCid,
isMobile,
metrics,
post,
previewReplies,
previewReplyEstimates,
quotedByMap: previewQuotedByMap,
showSummary: post.replyCount !== undefined && post.replyCount > previewReplies.length,
windowWidth,
});
}),
[boardItems, isMobile, metrics, windowWidth],
);
const catalogPosts = useMemo(() => boardItems.map(({ post }) => post), [boardItems]);
const catalogRows = useMemo(() => {
const cardWidth = catalogImageSize === 'Large' ? 270 : 180;
const columnCount = Math.max(1, Math.floor(windowWidth / cardWidth));
const nextRows: SyntheticReply[][] = [];
for (let index = 0; index < catalogPosts.length; index += columnCount) {
nextRows.push(catalogPosts.slice(index, index + columnCount));
}
return nextRows;
}, [catalogImageSize, catalogPosts, windowWidth]);
const catalogRowHeightEstimates = useMemo(
() =>
getCatalogRowHeightEstimates({
imageSize: catalogImageSize,
metrics,
rows: catalogRows,
showOPComment: showCatalogOpComment,
}),
[catalogImageSize, catalogRows, metrics, showCatalogOpComment],
);
const currentHeightEstimates = surface === 'board' ? boardHeightEstimates : surface === 'catalog' ? catalogRowHeightEstimates : heightEstimates;
const itemCount = surface === 'board' ? boardItems.length : surface === 'catalog' ? catalogRows.length : replies.length;
const defaultItemHeight = useMemo(() => {
if (surface === 'catalog') {
return getTypicalCatalogRowHeight(catalogRowHeightEstimates, catalogImageSize);
}
if (currentHeightEstimates.length === 0) {
return undefined;
}
const sortedEstimates = [...currentHeightEstimates].sort((leftValue, rightValue) => leftValue - rightValue);
return sortedEstimates[Math.floor(sortedEstimates.length / 2)];
}, [catalogImageSize, catalogRowHeightEstimates, currentHeightEstimates, surface]);
const itemSize = useMemo<SizeFunction | undefined>(() => {
if (mode !== 'item-size') {
return undefined;
}
return (element, field) => {
itemSizeCallCountRef.current += 1;
return getPretextItemSizeFromElement(element, field);
};
}, [mode]);
const virtuosoSizingProps = useMemo(() => {
const sizingProps: { defaultItemHeight?: number; heightEstimates?: number[]; itemSize?: SizeFunction } = {};
if (mode !== 'dom') {
sizingProps.defaultItemHeight = defaultItemHeight;
sizingProps.heightEstimates = currentHeightEstimates;
}
if (itemSize) {
sizingProps.itemSize = itemSize;
}
return sizingProps;
}, [currentHeightEstimates, defaultItemHeight, itemSize, mode]);
useEffect(() => {
window.scrollTo(0, 0);
}, [mode, surface, effectiveVariant]);
useEffect(() => {
const previousBodyClassName = document.body.className;
if (!previousBodyClassName.trim()) {
document.body.className = 'yotsuba';
}
return () => {
document.body.className = previousBodyClassName;
};
}, []);
useEffect(() => {
const previousCatalogStyle = useCatalogStyleStore.getState();
useCatalogStyleStore.setState({
imageSize: catalogImageSize,
showOPComment: showCatalogOpComment,
});
return () => {
useCatalogStyleStore.setState({
imageSize: previousCatalogStyle.imageSize,
showOPComment: previousCatalogStyle.showOPComment,
});
};
}, [catalogImageSize, showCatalogOpComment]);
useEffect(() => {
const rectCounter = installRectCounter();
const runScenario = async (): Promise<BenchmarkMetrics> => {
rectCounter.reset();
itemSizeCallCountRef.current = 0;
await waitForFrames(3);
let longTasks = 0;
let frameCount = 0;
let slowFrames16 = 0;
let slowFrames32 = 0;
let maxFrameMs = 0;
let lastFrameTime = performance.now();
let animationFrameId = 0;
const performanceObserver =
typeof PerformanceObserver === 'undefined'
? null
: new PerformanceObserver((entryList) => {
longTasks += entryList.getEntries().length;
});
try {
performanceObserver?.observe({ entryTypes: ['longtask'] });
} catch {
performanceObserver?.disconnect();
}
const trackFrame = (timestamp: number) => {
const delta = timestamp - lastFrameTime;
frameCount += 1;
maxFrameMs = Math.max(maxFrameMs, delta);
if (delta > 16.7) {
slowFrames16 += 1;
}
if (delta > 32) {
slowFrames32 += 1;
}
lastFrameTime = timestamp;
animationFrameId = window.requestAnimationFrame(trackFrame);
};
animationFrameId = window.requestAnimationFrame(trackFrame);
const scrollTargets: number[] = [];
const totalScrollableHeight = Math.max(document.documentElement.scrollHeight - window.innerHeight, 0);
const scrollStep = Math.max(window.innerHeight * 3, 1200);
for (let offset = 0; offset <= totalScrollableHeight; offset += scrollStep) {
scrollTargets.push(offset);
}
if (scrollTargets.at(-1) !== totalScrollableHeight) {
scrollTargets.push(totalScrollableHeight);
}
scrollTargets.push(...[...scrollTargets].reverse());
const scenarioStartTime = performance.now();
for (const targetScrollTop of scrollTargets) {
window.scrollTo({ top: targetScrollTop });
await waitForFrames(2);
}
const durationMs = performance.now() - scenarioStartTime;
window.cancelAnimationFrame(animationFrameId);
performanceObserver?.disconnect();
const visibleRowMetrics = Array.from(document.querySelectorAll<HTMLElement>('[data-pretext-height]')).map((element) => {
const estimatedHeight = Number.parseFloat(element.dataset.pretextHeight || '');
const actualHeight = element.offsetHeight;
return Number.isFinite(estimatedHeight) ? Math.abs(actualHeight - estimatedHeight) : 0;
});
const totalVisibleError = visibleRowMetrics.reduce((sum, value) => sum + value, 0);
const visibleMeanAbsoluteError = visibleRowMetrics.length === 0 ? 0 : totalVisibleError / visibleRowMetrics.length;
const visibleMaxAbsoluteError = visibleRowMetrics.length === 0 ? 0 : Math.max(...visibleRowMetrics);
const nextMetrics = {
durationMs: Math.round(durationMs),
itemSizeCalls: itemSizeCallCountRef.current,
itemCount,
longTasks,
maxFrameMs: Math.round(maxFrameMs * 10) / 10,
mode,
visibleMeanAbsoluteError: Math.round(visibleMeanAbsoluteError * 10) / 10,
visibleMaxAbsoluteError: Math.round(visibleMaxAbsoluteError * 10) / 10,
rectCalls: rectCounter.count,
renderedItems: document.querySelectorAll('[data-pretext-height]').length,
scrollHeight: document.documentElement.scrollHeight,
slowFrames16,
slowFrames32,
surface,
variant: effectiveVariant,
viewportHeight: window.innerHeight,
} satisfies BenchmarkMetrics;
summarizeRenderReport();
setLatestMetrics(nextMetrics);
return nextMetrics;
};
window.__PRETEXT_BENCH__ = {
getMetrics: () => latestMetrics,
runScenario,
};
return () => {
delete window.__PRETEXT_BENCH__;
};
}, [effectiveVariant, itemCount, latestMetrics, mode, surface]);
const virtualizationMode: ReplyVirtualizationMode = mode === 'dom' ? 'off' : mode;
const buildBenchmarkHref = ({
nextCount = replyCount,
nextMode = mode,
nextSeed = seed,
nextSurface = surface,
nextVariant = effectiveVariant,
}: {
nextCount?: number;
nextMode?: BenchmarkMode;
nextSeed?: number;
nextSurface?: BenchmarkSurface;
nextVariant?: BenchmarkVariant;
}) => `?e2e=pretext-benchmark&surface=${nextSurface}&variant=${nextVariant}&mode=${nextMode}&count=${nextCount}&seed=${nextSeed}`;
return (
<main style={{ background: '#f2f2f2', minHeight: '100vh' }}>
<section style={{ margin: '0 auto', maxWidth: 960, padding: '24px 16px 12px' }}>
<h1 style={{ fontFamily: 'Arial, Helvetica, sans-serif', fontSize: 28, margin: 0 }}>Pretext Virtualization Benchmark</h1>
<p style={{ color: '#444', fontFamily: 'Arial, Helvetica, sans-serif', margin: '8px 0 16px' }}>
Deterministic 5chan-style corpora for benchmarking the real Virtuoso paths before wiring them into production views.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginBottom: 16 }}>
{(['replies', 'board', 'catalog'] as BenchmarkSurface[]).map((candidateSurface) => (
<a
key={candidateSurface}
className='button'
href={buildBenchmarkHref({
nextSurface: candidateSurface,
nextVariant: candidateSurface === 'replies' ? effectiveVariant : 'production',
})}
style={{ opacity: candidateSurface === surface ? 1 : 0.7 }}
>
{candidateSurface}
</a>
))}
{(['dom', 'estimates', 'item-size'] as BenchmarkMode[]).map((candidateMode) => (
<a key={candidateMode} className='button' href={buildBenchmarkHref({ nextMode: candidateMode })} style={{ opacity: candidateMode === mode ? 1 : 0.7 }}>
{candidateMode}
</a>
))}
{surface === 'replies' &&
(['production', 'synthetic'] as BenchmarkVariant[]).map((candidateVariant) => (
<a
key={candidateVariant}
className='button'
href={buildBenchmarkHref({ nextVariant: candidateVariant })}
style={{ opacity: candidateVariant === effectiveVariant ? 1 : 0.7 }}
>
{candidateVariant}
</a>
))}
<button
className='button'
onClick={() => {
void window.__PRETEXT_BENCH__?.runScenario();
}}
>
Run scroll scenario
</button>
</div>
<div data-testid='bench-summary' style={{ color: '#333', display: 'grid', fontFamily: 'monospace', gap: 4 }}>
<div>surface: {surface}</div>
<div>variant: {effectiveVariant}</div>
<div>mode: {mode}</div>
<div>item count: {itemCount}</div>
{surface === 'catalog' ? (
<div>
catalog: {catalogImageSize} / showOPComment={showCatalogOpComment ? '1' : '0'}
</div>
) : null}
<div>sizing mode: {virtualizationMode}</div>
<div>last run: {latestMetrics ? JSON.stringify(latestMetrics) : 'not run yet'}</div>
</div>
</section>
{surface === 'replies' ? (
effectiveVariant === 'production' ? (
<MemoryRouter initialEntries={['/mu/thread/thread-root']}>
<Routes>
<Route
path='/:boardIdentifier/thread/:commentCid'
element={
isMobile ? (
<PostMobile
post={threadRoot}
replyPaginationOverride={{ hasMore: true, loadMore: NOOP, replies }}
replyVirtualizationModeOverride={virtualizationMode}
roles={{} as never}
showAllReplies={true}
showReplies={true}
/>
) : (
<PostDesktop
post={threadRoot}
replyPaginationOverride={{ hasMore: true, loadMore: NOOP, replies }}
replyVirtualizationModeOverride={virtualizationMode}
roles={{} as never}
showAllReplies={true}
showReplies={true}
/>
)
}
/>
</Routes>
</MemoryRouter>
) : (
<Virtuoso
{...virtuosoSizingProps}
data={replies}
increaseViewportBy={{ bottom: 1200, top: 1200 }}
itemContent={(index, reply) => (
<BenchmarkReplyRow
directRepliesByParentCid={directRepliesByParentCid}
estimate={heightEstimates[index]}
isMobile={isMobile}
quotedByMap={quotedByMap}
reply={reply}
/>
)}
totalCount={replies.length}
useWindowScroll={true}
/>
)
) : surface === 'board' ? (
<MemoryRouter initialEntries={['/mu']}>
<Virtuoso
{...virtuosoSizingProps}
computeItemKey={(index, item) => item.post.cid || `post-${index}`}
data={boardItems}
increaseViewportBy={{ bottom: 1200, top: 1200 }}
itemContent={(index, item) => <Post post={item.post} replyPaginationOverride={boardReplyPaginationOverrides[index]} replyVirtualizationModeOverride='off' />}
totalCount={boardItems.length}
useWindowScroll={true}
/>
</MemoryRouter>
) : (
<MemoryRouter initialEntries={['/mu/catalog']}>
<Virtuoso
{...virtuosoSizingProps}
computeItemKey={(index, row) => row[0]?.cid || `catalog-row-${index}`}
data={catalogRows}
increaseViewportBy={{ bottom: 1200, top: 1200 }}
itemContent={(index, row) => <CatalogRow estimatedHeight={catalogRowHeightEstimates[index]} row={row} />}
totalCount={catalogRows.length}
useWindowScroll={true}
/>
</MemoryRouter>
)}
</main>
);
};
export default Harness;
+57
View File
@@ -0,0 +1,57 @@
import { useMemo } from 'react';
import type { Comment } from '@bitsocialnet/bitsocial-react-hooks';
import type { SizeFunction } from 'react-virtuoso';
import { useLocation } from 'react-router-dom';
import useWindowWidth from './use-window-width';
import {
getReplyHeightEstimates,
getReplyItemSizeFromElement,
getTypicalReplyHeight,
readReplyTypographyMetrics,
resolveReplyVirtualizationMode,
type ReplyVirtualizationMode,
} from '../lib/utils/pretext-height-estimates';
interface UseReplyHeightEstimatesOptions {
directRepliesByParentCid?: Map<string, Comment[]>;
enabled?: boolean;
isMobile: boolean;
maxContentChars?: number;
mode?: ReplyVirtualizationMode;
quotedByMap?: Map<string, Comment[]>;
replies: Comment[];
}
const useReplyHeightEstimates = ({ directRepliesByParentCid, enabled = true, isMobile, maxContentChars, mode, quotedByMap, replies }: UseReplyHeightEstimatesOptions) => {
const location = useLocation();
const windowWidth = useWindowWidth();
const themeKey = typeof document !== 'undefined' ? document.body.className : '';
const effectiveMode = useMemo(() => mode ?? resolveReplyVirtualizationMode(location.search), [location.search, mode]);
const metrics = useMemo(() => readReplyTypographyMetrics(), [themeKey, windowWidth]);
const rawHeightEstimates = useMemo(
() =>
!enabled
? []
: getReplyHeightEstimates({
context: 'thread',
directRepliesByParentCid,
isMobile,
maxContentChars,
metrics,
quotedByMap,
replies,
windowWidth,
}),
[directRepliesByParentCid, enabled, isMobile, maxContentChars, metrics, quotedByMap, replies, windowWidth],
);
const heightEstimates = effectiveMode === 'off' ? undefined : rawHeightEstimates;
const defaultItemHeight = useMemo(() => getTypicalReplyHeight(rawHeightEstimates, isMobile), [rawHeightEstimates, isMobile]);
const itemSize = useMemo<SizeFunction | undefined>(() => (effectiveMode === 'item-size' ? getReplyItemSizeFromElement : undefined), [effectiveMode]);
return { defaultItemHeight, heightEstimates, itemSize, metrics, mode: effectiveMode, windowWidth };
};
export default useReplyHeightEstimates;
+8 -7
View File
@@ -16,8 +16,7 @@ import { Analytics } from '@vercel/analytics/react';
const isVercelDeployment =
typeof window !== 'undefined' && (window.location.hostname === '5chan.app' || window.location.hostname === 'www.5chan.app') && !window.isElectron;
const e2eStartHash = import.meta.env.VITE_E2E_START_HASH?.trim();
const shouldRenderThreadAutoUpdateHarness =
import.meta.env.DEV && typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('e2e') === 'thread-auto-update';
const requestedE2EHarness = import.meta.env.DEV && typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('e2e') : null;
if (typeof window !== 'undefined' && e2eStartHash && window.location.hash.length === 0) {
window.location.hash = e2eStartHash.startsWith('#') ? e2eStartHash : `#${e2eStartHash}`;
@@ -25,15 +24,17 @@ if (typeof window !== 'undefined' && e2eStartHash && window.location.hash.length
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
const renderRoot = async () => {
let threadAutoUpdateHarness: React.ComponentType | null = null;
if (shouldRenderThreadAutoUpdateHarness) {
threadAutoUpdateHarness = (await import('./e2e/thread-auto-update-harness')).default;
let e2eHarness: React.ComponentType | null = null;
if (requestedE2EHarness === 'thread-auto-update') {
e2eHarness = (await import('./e2e/thread-auto-update-harness')).default;
} else if (requestedE2EHarness === 'pretext-benchmark') {
e2eHarness = (await import('./e2e/pretext-benchmark-harness')).default;
}
root.render(
<React.StrictMode>
{threadAutoUpdateHarness ? (
React.createElement(threadAutoUpdateHarness)
{e2eHarness ? (
React.createElement(e2eHarness)
) : (
<Router>
<AppUpdateRegistration />
@@ -0,0 +1,258 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
getFeedPostHeightEstimate,
getReplyHeightEstimates,
getReplyItemSizeFromElement,
resolveCatalogVirtualizationMode,
resolveFeedVirtualizationMode,
resolveReplyVirtualizationMode,
} from '../pretext-height-estimates';
describe('pretext-height-estimates', () => {
beforeEach(() => {
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
configurable: true,
value: vi.fn(() => ({
font: '',
measureText: vi.fn((text: string) => ({
actualBoundingBoxAscent: 10,
actualBoundingBoxDescent: 3,
fontBoundingBoxAscent: 10,
fontBoundingBoxDescent: 3,
width: text.length * 7,
})),
})),
writable: true,
});
});
it('parses reply virtualization mode from search params', () => {
expect(resolveReplyVirtualizationMode('?pretextReplies=item-size')).toBe('item-size');
expect(resolveReplyVirtualizationMode(new URLSearchParams('pretextReplies=off'))).toBe('off');
expect(resolveReplyVirtualizationMode('?pretextReplies=unknown')).toBe('item-size');
});
it('parses feed virtualization mode from search params', () => {
expect(resolveFeedVirtualizationMode('?pretextFeed=item-size')).toBe('item-size');
expect(resolveFeedVirtualizationMode(new URLSearchParams('pretextFeed=off'))).toBe('off');
expect(resolveFeedVirtualizationMode('?pretextFeed=unknown')).toBe('off');
});
it('parses catalog virtualization mode from search params', () => {
expect(resolveCatalogVirtualizationMode('?pretextCatalog=item-size')).toBe('item-size');
expect(resolveCatalogVirtualizationMode(new URLSearchParams('pretextCatalog=off'))).toBe('off');
expect(resolveCatalogVirtualizationMode('?pretextCatalog=unknown')).toBe('off');
});
it('falls back to hash-fragment query params for hash-routed URLs', () => {
const previousHref = window.location.href;
window.history.replaceState({}, '', '/#/mu?pretextFeed=item-size&pretextReplies=off');
expect(resolveFeedVirtualizationMode(window.location.search)).toBe('item-size');
expect(resolveReplyVirtualizationMode(window.location.search)).toBe('off');
expect(resolveCatalogVirtualizationMode(window.location.search)).toBe('off');
window.history.replaceState({}, '', previousHref);
});
it('prefers cached Pretext height when itemSize reads a reply row', () => {
const element = document.createElement('div');
Object.defineProperty(element, 'offsetHeight', { configurable: true, value: 321 });
Object.defineProperty(element, 'offsetWidth', { configurable: true, value: 654 });
element.dataset.pretextHeight = '123';
expect(getReplyItemSizeFromElement(element, 'offsetHeight')).toBe(123);
expect(getReplyItemSizeFromElement(element, 'offsetWidth')).toBe(654);
delete element.dataset.pretextHeight;
expect(getReplyItemSizeFromElement(element, 'offsetHeight')).toBe(321);
});
it('falls back to a nested Pretext height when the wrapper has none', () => {
const element = document.createElement('div');
const child = document.createElement('div');
Object.defineProperty(element, 'offsetHeight', { configurable: true, value: 321 });
Object.defineProperty(element, 'offsetWidth', { configurable: true, value: 654 });
child.dataset.pretextHeight = '222';
element.appendChild(child);
expect(getReplyItemSizeFromElement(element, 'offsetHeight')).toBe(222);
});
it('adds desktop board-label height for multiboard text posts without media', () => {
const metrics = {
abbrFontSizePx: 13,
bodyFontFamily: 'Arial, Helvetica, sans-serif',
bodyFontSizePx: 13,
mobileContentFontSizePx: 15,
};
const post = {
cid: 'post-1',
communityAddress: 'music-posting.eth',
content: 'A short desktop board post',
postCid: 'post-1',
state: 'succeeded',
};
const withoutBoardLabel = getFeedPostHeightEstimate({
isMobile: false,
metrics,
post,
previewReplies: [],
windowWidth: 1280,
});
const withBoardLabel = getFeedPostHeightEstimate({
isMobile: false,
metrics,
post,
previewReplies: [],
showBoardLabel: true,
windowWidth: 1280,
});
expect(withBoardLabel).toBeGreaterThan(withoutBoardLabel);
});
it('accounts for the board-view long-comment notice on truncated desktop posts', () => {
const metrics = {
abbrFontSizePx: 13,
bodyFontFamily: 'Arial, Helvetica, sans-serif',
bodyFontSizePx: 13,
mobileContentFontSizePx: 15,
};
const visiblePrefix = 'word '.repeat(200);
const shortPost = {
cid: 'post-short',
communityAddress: 'music-posting.eth',
content: visiblePrefix,
postCid: 'post-short',
state: 'succeeded',
};
const truncatedPost = {
...shortPost,
cid: 'post-long',
content: `${visiblePrefix}${'overflow '.repeat(40)}`,
postCid: 'post-long',
};
const shortEstimate = getFeedPostHeightEstimate({
isMobile: false,
metrics,
post: shortPost,
previewReplies: [],
windowWidth: 1280,
});
const truncatedEstimate = getFeedPostHeightEstimate({
isMobile: false,
metrics,
post: truncatedPost,
previewReplies: [],
windowWidth: 1280,
});
expect(truncatedEstimate).toBeGreaterThan(shortEstimate);
});
it('adds desktop preview reply estimates without subtracting a per-reply calibration', () => {
const metrics = {
abbrFontSizePx: 13,
bodyFontFamily: 'Arial, Helvetica, sans-serif',
bodyFontSizePx: 13,
mobileContentFontSizePx: 15,
};
const post = {
cid: 'post-preview-parent',
communityAddress: 'music-posting.eth',
content: 'A longer desktop board post '.repeat(24),
postCid: 'post-preview-parent',
state: 'succeeded',
};
const previewReply = {
cid: 'post-preview-reply',
communityAddress: 'music-posting.eth',
content: 'A preview reply',
parentCid: 'post-preview-parent',
postCid: 'post-preview-parent',
state: 'succeeded',
};
const previewEstimate = getReplyHeightEstimates({
isMobile: false,
metrics,
replies: [previewReply],
windowWidth: 1280,
})[0];
const withoutPreview = getFeedPostHeightEstimate({
isMobile: false,
metrics,
post,
previewReplies: [],
windowWidth: 1280,
});
const withPreview = getFeedPostHeightEstimate({
isMobile: false,
metrics,
post,
previewReplies: [previewReply],
previewReplyEstimates: [previewEstimate],
windowWidth: 1280,
});
expect(withPreview - withoutPreview).toBe(previewEstimate);
});
it('keeps most mobile preview-reply height on board cards', () => {
const metrics = {
abbrFontSizePx: 13,
bodyFontFamily: 'Arial, Helvetica, sans-serif',
bodyFontSizePx: 13,
mobileContentFontSizePx: 15,
};
const post = {
cid: 'post-mobile-preview-parent',
communityAddress: 'music-posting.eth',
content: 'A mobile board post with preview replies',
link: 'https://example.com/image.jpg',
linkHeight: 720,
linkWidth: 1280,
postCid: 'post-mobile-preview-parent',
state: 'succeeded',
};
const previewReplies = Array.from({ length: 5 }, (_, index) => ({
cid: `post-mobile-preview-reply-${index}`,
communityAddress: 'music-posting.eth',
content: `Preview reply ${index} ${'text '.repeat(index + 1)}`,
parentCid: 'post-mobile-preview-parent',
postCid: 'post-mobile-preview-parent',
state: 'succeeded',
}));
const previewEstimates = getReplyHeightEstimates({
isMobile: true,
metrics,
replies: previewReplies,
windowWidth: 393,
});
const withoutPreview = getFeedPostHeightEstimate({
isMobile: true,
metrics,
post,
previewReplies: [],
windowWidth: 393,
});
const withPreview = getFeedPostHeightEstimate({
isMobile: true,
metrics,
post,
previewReplies,
previewReplyEstimates: previewEstimates,
windowWidth: 393,
});
expect(withPreview).toBeGreaterThan(withoutPreview + 300);
expect(withPreview - withoutPreview).toBeGreaterThan(previewEstimates.reduce((sum, value) => sum + value, 0) - 220);
});
});
+841
View File
@@ -0,0 +1,841 @@
import type { Comment } from '@bitsocialnet/bitsocial-react-hooks';
import { layout, layoutNextLine, prepare, prepareWithSegments } from '@chenglou/pretext';
import { getCommentMediaInfo, getHasThumbnail } from './media-utils';
import { removeMarkdown } from './post-utils';
import { getRenderableMobileBacklinks } from './reply-backlink-utils';
export type ReplyVirtualizationMode = 'off' | 'estimates' | 'item-size';
export type CatalogImageSize = 'Small' | 'Large';
type ReplyEstimateContext = 'preview' | 'thread';
type ReplyHeightAuditSample = {
actual: number;
cid: string;
diff: number;
estimate: number;
};
type ReplyHeightAuditStore = {
getSummary: () => { maxAbsoluteError: number; meanAbsoluteError: number; sampleCount: number };
record: (sample: ReplyHeightAuditSample) => void;
reset: () => void;
samples: Map<string, ReplyHeightAuditSample>;
};
const PT_TO_PX = 96 / 72;
const DESKTOP_REPLY_BASE_HEIGHT = 46;
const DESKTOP_REPLY_FILE_TEXT_HEIGHT = 18;
const DESKTOP_REPLY_FLOAT_MARGIN_WIDTH = 40;
const DESKTOP_REPLY_FLOAT_MARGIN_HEIGHT = 8;
const DESKTOP_REPLY_SIDE_ARROWS_WIDTH = 24;
const DESKTOP_REPLY_CONTENT_PADDING_X = 80;
const DESKTOP_FEED_CARD_BASE_HEIGHT = 56;
const DESKTOP_FEED_CARD_HR_HEIGHT = 14;
const DESKTOP_FEED_CARD_BOARD_LABEL_HEIGHT = 18;
const DESKTOP_FEED_CARD_SUMMARY_HEIGHT = 25;
const DESKTOP_FEED_CARD_BASE_CALIBRATION = -59;
const DESKTOP_POST_MESSAGE_VERTICAL_PADDING = 26;
const DESKTOP_PREVIEW_REPLY_TEXT_CALIBRATION = 6;
const DESKTOP_PREVIEW_REPLY_MEDIA_CALIBRATION = -17;
const MOBILE_REPLY_BASE_HEIGHT = 64;
const MOBILE_REPLY_MEDIA_EXTRA_HEIGHT = 26;
const MOBILE_REPLY_CONTENT_PADDING_X = 20;
const MOBILE_FEED_CARD_BASE_HEIGHT = 84;
const MOBILE_FEED_CARD_OUTER_GAP_HEIGHT = 60;
const MOBILE_FEED_CARD_LINK_BAR_HEIGHT = 36;
const MOBILE_FEED_CARD_PREVIEW_COUNT_CALIBRATIONS = [105, 85, 95, 120, 150, 180] as const;
const DESKTOP_THREAD_REPLY_CALIBRATION = 0;
const CATALOG_ROW_PADDING_TOP = 20;
const CATALOG_CARD_MARGIN_Y = 4;
const CATALOG_CARD_PADDING_TOP = 10;
const CATALOG_CARD_PADDING_BOTTOM = 3;
const CATALOG_CARD_MEDIA_GAP_HEIGHT = 5;
const CATALOG_CARD_META_HEIGHT = 14;
const CATALOG_CARD_MAX_HEIGHT = 320;
const CATALOG_CARD_TEXT_PADDING_X = 30;
const SMALL_CATALOG_CARD_WIDTH = 180;
const LARGE_CATALOG_CARD_WIDTH = 270;
const SMALL_CATALOG_MEDIA_SIZE = 150;
const LARGE_CATALOG_MEDIA_SIZE = 250;
const DEFAULT_DESKTOP_HEIGHT = 140;
const DEFAULT_MOBILE_HEIGHT = 180;
const DEFAULT_CATALOG_ROW_HEIGHT = 220;
const MIN_ESTIMATE_HEIGHT = 72;
const MIN_TEXT_WIDTH = 48;
const MOBILE_BACKLINK_FULL_COST_COUNT = 16;
const MOBILE_BACKLINK_TAIL_COST = 14;
const COMMENT_TOO_LONG_NOTICE = 'Comment too long. Click here to view the full text.';
const DEFAULT_REPLY_VIRTUALIZATION_MODE: ReplyVirtualizationMode = 'item-size';
const REPLY_VIRTUALIZATION_MODES: ReplyVirtualizationMode[] = ['off', 'estimates', 'item-size'];
export const REPLY_HEIGHT_DATA_ATTRIBUTE = 'data-pretext-height';
type PreparedText = ReturnType<typeof prepare>;
type PreparedTextWithSegments = ReturnType<typeof prepareWithSegments>;
interface ReplyTypographyMetrics {
abbrFontSizePx: number;
bodyFontFamily: string;
bodyFontSizePx: number;
mobileContentFontSizePx: number;
}
interface MediaMetrics {
blockHeight: number;
floatHeight: number;
floatWidth: number;
}
interface ReplyBacklinkMaps {
directRepliesByParentCid?: Map<string, Comment[]>;
quotedByMap?: Map<string, Comment[]>;
}
interface ReplyHeightEstimateOptions extends ReplyBacklinkMaps {
context?: ReplyEstimateContext;
isMobile: boolean;
maxContentChars?: number;
metrics: ReplyTypographyMetrics;
replies: Comment[];
windowWidth: number;
}
interface FeedPostHeightEstimateOptions extends ReplyBacklinkMaps {
isMobile: boolean;
metrics: ReplyTypographyMetrics;
post: Comment | undefined;
previewReplies: Comment[];
previewReplyEstimates?: number[];
showBoardLabel?: boolean;
showSummary?: boolean;
windowWidth: number;
}
interface CatalogPostHeightEstimateOptions {
imageSize: CatalogImageSize;
metrics: ReplyTypographyMetrics;
post: Comment | undefined;
showOPComment: boolean;
}
interface CatalogSingleRowHeightEstimateOptions {
imageSize: CatalogImageSize;
metrics: ReplyTypographyMetrics;
row: Comment[];
showOPComment: boolean;
}
interface CatalogRowHeightEstimateOptions {
imageSize: CatalogImageSize;
metrics: ReplyTypographyMetrics;
rows: Comment[][];
showOPComment: boolean;
}
const preparedTextCache = new Map<string, PreparedText>();
const preparedSegmentCache = new Map<string, PreparedTextWithSegments>();
const paragraphHeightCache = new WeakMap<PreparedText, Map<string, number>>();
const paragraphFloatHeightCache = new WeakMap<PreparedTextWithSegments, Map<string, number>>();
const nestedPretextElementCache = new WeakMap<HTMLElement, HTMLElement | null>();
let pretextSupport: boolean | undefined;
const parseCssLengthToPx = (value: string | null | undefined, fallbackPx: number): number => {
const trimmedValue = value?.trim();
if (!trimmedValue) {
return fallbackPx;
}
if (trimmedValue.endsWith('px')) {
const parsedValue = parseFloat(trimmedValue);
return Number.isFinite(parsedValue) ? parsedValue : fallbackPx;
}
if (trimmedValue.endsWith('pt')) {
const parsedValue = parseFloat(trimmedValue);
return Number.isFinite(parsedValue) ? parsedValue * PT_TO_PX : fallbackPx;
}
const parsedValue = parseFloat(trimmedValue);
return Number.isFinite(parsedValue) ? parsedValue : fallbackPx;
};
const getPreparedText = (text: string, font: string, usePreWrap: boolean): PreparedText => {
const cacheKey = `${font}\u0000${usePreWrap ? 'pre-wrap' : 'normal'}\u0000${text}`;
const cachedPreparedText = preparedTextCache.get(cacheKey);
if (cachedPreparedText) {
return cachedPreparedText;
}
const preparedText = prepare(text, font, usePreWrap ? { whiteSpace: 'pre-wrap' } : undefined);
preparedTextCache.set(cacheKey, preparedText);
return preparedText;
};
const getPreparedSegmentText = (text: string, font: string, usePreWrap: boolean): PreparedTextWithSegments => {
const cacheKey = `${font}\u0000${usePreWrap ? 'pre-wrap' : 'normal'}\u0000${text}`;
const cachedPreparedText = preparedSegmentCache.get(cacheKey);
if (cachedPreparedText) {
return cachedPreparedText;
}
const preparedText = prepareWithSegments(text, font, usePreWrap ? { whiteSpace: 'pre-wrap' } : undefined);
preparedSegmentCache.set(cacheKey, preparedText);
return preparedText;
};
const getCachedParagraphHeight = (preparedText: PreparedText, cacheKey: string, compute: () => number): number => {
const cachedByMeasurement = paragraphHeightCache.get(preparedText);
const cachedHeight = cachedByMeasurement?.get(cacheKey);
if (cachedHeight !== undefined) {
return cachedHeight;
}
const measuredHeight = compute();
const nextCache = cachedByMeasurement ?? new Map<string, number>();
nextCache.set(cacheKey, measuredHeight);
if (!cachedByMeasurement) {
paragraphHeightCache.set(preparedText, nextCache);
}
return measuredHeight;
};
const getCachedParagraphFloatHeight = (preparedText: PreparedTextWithSegments, cacheKey: string, compute: () => number): number => {
const cachedByMeasurement = paragraphFloatHeightCache.get(preparedText);
const cachedHeight = cachedByMeasurement?.get(cacheKey);
if (cachedHeight !== undefined) {
return cachedHeight;
}
const measuredHeight = compute();
const nextCache = cachedByMeasurement ?? new Map<string, number>();
nextCache.set(cacheKey, measuredHeight);
if (!cachedByMeasurement) {
paragraphFloatHeightCache.set(preparedText, nextCache);
}
return measuredHeight;
};
const canUsePretext = (): boolean => {
if (typeof pretextSupport === 'boolean') {
return pretextSupport;
}
if (typeof document === 'undefined' || typeof Intl === 'undefined' || typeof Intl.Segmenter === 'undefined') {
pretextSupport = false;
return pretextSupport;
}
try {
const canvas = document.createElement('canvas');
pretextSupport = Boolean(canvas.getContext?.('2d'));
} catch {
pretextSupport = false;
}
return pretextSupport;
};
const getLineHeight = (fontSizePx: number): number => Math.ceil(fontSizePx * 1.35);
const getCatalogCardWidth = (imageSize: CatalogImageSize): number => (imageSize === 'Large' ? LARGE_CATALOG_CARD_WIDTH : SMALL_CATALOG_CARD_WIDTH);
const getCatalogMediaMaxSize = (imageSize: CatalogImageSize): number => (imageSize === 'Large' ? LARGE_CATALOG_MEDIA_SIZE : SMALL_CATALOG_MEDIA_SIZE);
const clampEstimateHeight = (value: number): number => Math.max(MIN_ESTIMATE_HEIGHT, Math.ceil(value));
const getMedianEstimate = (estimates: number[], fallback: number): number => {
if (estimates.length === 0) {
return fallback;
}
const sortedEstimates = [...estimates].sort((leftValue, rightValue) => leftValue - rightValue);
return sortedEstimates[Math.floor(sortedEstimates.length / 2)];
};
const isReplyHeightAuditEnabled = (): boolean =>
import.meta.env.DEV && typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('pretextReplyAudit') === '1';
const getReplyHeightAuditStore = (): ReplyHeightAuditStore | null => {
if (!isReplyHeightAuditEnabled() || typeof window === 'undefined') {
return null;
}
const auditWindow = window as Window & { __PRETEXT_REPLY_AUDIT__?: ReplyHeightAuditStore };
if (auditWindow.__PRETEXT_REPLY_AUDIT__) {
return auditWindow.__PRETEXT_REPLY_AUDIT__;
}
const samples = new Map<string, ReplyHeightAuditSample>();
auditWindow.__PRETEXT_REPLY_AUDIT__ = {
getSummary: () => {
const allSamples = [...samples.values()];
const totalAbsoluteError = allSamples.reduce((sum, sample) => sum + Math.abs(sample.diff), 0);
return {
maxAbsoluteError: allSamples.length === 0 ? 0 : Math.max(...allSamples.map((sample) => Math.abs(sample.diff))),
meanAbsoluteError: allSamples.length === 0 ? 0 : Math.round((totalAbsoluteError / allSamples.length) * 10) / 10,
sampleCount: allSamples.length,
};
},
record: (sample) => {
samples.set(sample.cid, sample);
},
reset: () => {
samples.clear();
},
samples,
};
return auditWindow.__PRETEXT_REPLY_AUDIT__;
};
const isReplyVirtualizationMode = (value: string | null | undefined): value is ReplyVirtualizationMode =>
value !== null && value !== undefined && REPLY_VIRTUALIZATION_MODES.includes(value as ReplyVirtualizationMode);
const resolveSearchParams = (searchParams: URLSearchParams | string | null | undefined): URLSearchParams | undefined => {
if (searchParams instanceof URLSearchParams) {
if ([...searchParams.keys()].length > 0) {
return searchParams;
}
} else if (typeof searchParams === 'string') {
const trimmedSearch = searchParams.trim();
if (trimmedSearch && trimmedSearch !== '?') {
return new URLSearchParams(trimmedSearch.startsWith('?') ? trimmedSearch : `?${trimmedSearch}`);
}
} else if (searchParams) {
return searchParams;
}
if (typeof window === 'undefined') {
return undefined;
}
const hashQueryIndex = window.location.hash.indexOf('?');
if (hashQueryIndex === -1) {
return undefined;
}
return new URLSearchParams(window.location.hash.slice(hashQueryIndex));
};
const getMediaBoxDimensions = (comment: Comment | undefined, maxThumbnailSize: number): { height: number; width: number } | null => {
if (!comment?.link) {
return null;
}
const mediaInfo = getCommentMediaInfo(comment.link, comment.thumbnailUrl, comment.linkWidth, comment.linkHeight);
if (!mediaInfo) {
return null;
}
let mediaWidth = maxThumbnailSize;
let mediaHeight = maxThumbnailSize;
if (comment.spoiler) {
mediaWidth = 100;
mediaHeight = 100;
} else if (mediaInfo.type === 'audio') {
mediaHeight = 54;
} else if (mediaInfo.linkWidth && mediaInfo.linkHeight) {
const scale = Math.min(1, maxThumbnailSize / Math.max(mediaInfo.linkWidth, mediaInfo.linkHeight));
mediaWidth = Math.max(MIN_TEXT_WIDTH, Math.round(mediaInfo.linkWidth * scale));
mediaHeight = Math.max(40, Math.round(mediaInfo.linkHeight * scale));
}
return { height: mediaHeight, width: mediaWidth };
};
const getReplyMediaMetrics = (reply: Comment | undefined): MediaMetrics | null => {
const mediaBox = getMediaBoxDimensions(reply, 125);
if (!mediaBox) {
return null;
}
return {
blockHeight: mediaBox.height + MOBILE_REPLY_MEDIA_EXTRA_HEIGHT,
floatHeight: mediaBox.height + DESKTOP_REPLY_FLOAT_MARGIN_HEIGHT,
floatWidth: mediaBox.width + DESKTOP_REPLY_FLOAT_MARGIN_WIDTH,
};
};
const getFeedPostMediaMetrics = (post: Comment | undefined, isMobile: boolean): MediaMetrics | null => {
const mediaBox = getMediaBoxDimensions(post, isMobile ? 125 : 250);
if (!mediaBox) {
return null;
}
return {
blockHeight: mediaBox.height + MOBILE_REPLY_MEDIA_EXTRA_HEIGHT,
floatHeight: mediaBox.height + DESKTOP_REPLY_FLOAT_MARGIN_HEIGHT,
floatWidth: mediaBox.width + DESKTOP_REPLY_FLOAT_MARGIN_WIDTH,
};
};
const getCatalogPostMediaHeight = (post: Comment | undefined, imageSize: CatalogImageSize): number => {
if (!post?.link) {
return 0;
}
const mediaInfo = getCommentMediaInfo(post.link, post.thumbnailUrl, post.linkWidth, post.linkHeight);
if (!getHasThumbnail(mediaInfo, post.link)) {
return 0;
}
const mediaBox = getMediaBoxDimensions(post, getCatalogMediaMaxSize(imageSize));
return mediaBox ? mediaBox.height + CATALOG_CARD_MEDIA_GAP_HEIGHT : 0;
};
const normalizeRenderedCommentText = (rawContent: string): string =>
removeMarkdown(rawContent)
.replace(/\n&nbsp;\n/g, '\n\n')
.replace(/\n{3,}/g, '\n\n')
.split('\n')
.map((line) => line.replace(/\s{2,}/g, ' ').trimEnd())
.join('\n')
.trim();
const getVisibleCommentBodyText = (comment: Comment | undefined, maxContentChars: number): string => {
if (!comment) {
return '';
}
const purged = comment.commentModeration?.purged;
const deleted = comment.deleted;
const removed = comment.removed;
const reason = comment.reason?.trim();
const rawContent = comment.content || '';
const content = normalizeRenderedCommentText(rawContent.slice(0, maxContentChars));
if (purged) {
return 'This post was purged';
}
if (removed) {
return reason ? `This post was removed. Reason: ${reason}` : 'This post was removed.';
}
if (deleted) {
return reason ? `User deleted this post. Reason: ${reason}` : 'User deleted this post';
}
if (rawContent.length > maxContentChars && content) {
return `${content}\n\n${COMMENT_TOO_LONG_NOTICE}`;
}
return content;
};
const getVisibleCommentText = (comment: Comment | undefined, maxContentChars: number): string => {
if (!comment) {
return '';
}
const title = comment.title?.trim();
const content = getVisibleCommentBodyText(comment, maxContentChars);
if (title && content) {
return `${title}: ${content}`;
}
return title || content;
};
const getVisibleReplyText = (reply: Comment | undefined, maxContentChars: number = 2000): string => getVisibleCommentText(reply, maxContentChars);
const getVisibleCatalogText = (post: Comment | undefined, showOPComment: boolean, hasThumbnail: boolean): string => {
if (!post || !(showOPComment || !hasThumbnail)) {
return '';
}
return getVisibleCommentText(post, 1600);
};
const getMobileBacklinkCount = (reply: Comment, backlinkMaps: ReplyBacklinkMaps): number => {
const backlinkCount = getRenderableMobileBacklinks({
cid: reply.cid,
parentCid: reply.parentCid,
directRepliesByParentCid: backlinkMaps.directRepliesByParentCid,
quotedByMap: backlinkMaps.quotedByMap,
});
return backlinkCount.directReplyBacklinks.length + backlinkCount.opBacklinks.length + backlinkCount.quotedReplyBacklinks.length;
};
const getMobileBacklinksHeight = (totalBacklinkCount: number, metrics: ReplyTypographyMetrics): number => {
const backlinkLineHeight = Math.ceil(metrics.abbrFontSizePx * 2);
const fullCostCount = Math.min(totalBacklinkCount, MOBILE_BACKLINK_FULL_COST_COUNT);
const tailCount = Math.max(totalBacklinkCount - MOBILE_BACKLINK_FULL_COST_COUNT, 0);
return fullCostCount * backlinkLineHeight + tailCount * MOBILE_BACKLINK_TAIL_COST;
};
const getMobileThreadReplyCalibration = (hasMedia: boolean, totalBacklinkCount: number): number => {
let calibration = hasMedia ? -200 : -145;
if (totalBacklinkCount >= 4) {
calibration -= hasMedia ? 80 : 140;
}
return calibration;
};
const measureParagraphHeight = (text: string, font: string, width: number, lineHeight: number): number => {
if (!text.trim()) {
return 0;
}
const safeWidth = Math.max(MIN_TEXT_WIDTH, Math.floor(width));
const usePreWrap = text.includes('\n');
const preparedText = getPreparedText(text, font, usePreWrap);
return getCachedParagraphHeight(preparedText, `${safeWidth}:${lineHeight}`, () => layout(preparedText, safeWidth, lineHeight).height);
};
const measureParagraphWithFloatHeight = (text: string, font: string, width: number, floatWidth: number, floatHeight: number, lineHeight: number): number => {
if (!text.trim()) {
return 0;
}
const safeWidth = Math.max(MIN_TEXT_WIDTH, Math.floor(width));
const safeFloatWidth = Math.max(0, Math.floor(floatWidth));
const safeFloatHeight = Math.max(0, Math.floor(floatHeight));
const usePreWrap = text.includes('\n');
const preparedText = getPreparedSegmentText(text, font, usePreWrap);
return getCachedParagraphFloatHeight(preparedText, `${safeWidth}:${safeFloatWidth}:${safeFloatHeight}:${lineHeight}`, () => {
let cursor = { graphemeIndex: 0, segmentIndex: 0 };
let currentHeight = 0;
while (true) {
const currentLineWidth = currentHeight < safeFloatHeight ? Math.max(MIN_TEXT_WIDTH, safeWidth - safeFloatWidth) : safeWidth;
const line = layoutNextLine(preparedText, cursor, currentLineWidth);
if (line === null) {
break;
}
cursor = line.end;
currentHeight += lineHeight;
}
return currentHeight;
});
};
const estimateDesktopReplyHeight = (
reply: Comment,
metrics: ReplyTypographyMetrics,
windowWidth: number,
backlinkMaps: ReplyBacklinkMaps,
maxContentChars: number,
context: ReplyEstimateContext,
): number => {
const fontSizePx = metrics.bodyFontSizePx;
const font = `${fontSizePx}px ${metrics.bodyFontFamily}`;
const lineHeight = getLineHeight(fontSizePx);
const replyText = getVisibleReplyText(reply, maxContentChars);
const mediaMetrics = getReplyMediaMetrics(reply);
const contentWidth = Math.max(MIN_TEXT_WIDTH, windowWidth - DESKTOP_REPLY_SIDE_ARROWS_WIDTH - DESKTOP_REPLY_CONTENT_PADDING_X);
const textHeight = mediaMetrics
? measureParagraphWithFloatHeight(replyText, font, contentWidth, mediaMetrics.floatWidth, mediaMetrics.floatHeight, lineHeight)
: measureParagraphHeight(replyText, font, contentWidth, lineHeight);
const mediaHeight = mediaMetrics ? mediaMetrics.floatHeight + DESKTOP_REPLY_FILE_TEXT_HEIGHT : 0;
const previewCalibration = mediaMetrics ? DESKTOP_PREVIEW_REPLY_MEDIA_CALIBRATION : DESKTOP_PREVIEW_REPLY_TEXT_CALIBRATION;
return clampEstimateHeight(
DESKTOP_REPLY_BASE_HEIGHT + Math.max(textHeight, mediaHeight) + (context === 'thread' ? DESKTOP_THREAD_REPLY_CALIBRATION : previewCalibration),
);
};
const estimateMobileReplyHeight = (
reply: Comment,
metrics: ReplyTypographyMetrics,
windowWidth: number,
backlinkMaps: ReplyBacklinkMaps,
maxContentChars: number,
context: ReplyEstimateContext,
): number => {
const fontSizePx = metrics.mobileContentFontSizePx;
const font = `${fontSizePx}px ${metrics.bodyFontFamily}`;
const lineHeight = getLineHeight(fontSizePx);
const replyText = getVisibleReplyText(reply, maxContentChars);
const mediaMetrics = getReplyMediaMetrics(reply);
const contentWidth = Math.max(MIN_TEXT_WIDTH, windowWidth - MOBILE_REPLY_CONTENT_PADDING_X);
const textHeight = measureParagraphHeight(replyText, font, contentWidth, lineHeight);
const totalBacklinkCount = getMobileBacklinkCount(reply, backlinkMaps);
const backlinksHeight = getMobileBacklinksHeight(totalBacklinkCount, metrics);
const threadCalibration = context === 'thread' ? getMobileThreadReplyCalibration(Boolean(mediaMetrics), totalBacklinkCount) : 0;
return clampEstimateHeight(MOBILE_REPLY_BASE_HEIGHT + (mediaMetrics?.blockHeight || 0) + textHeight + backlinksHeight + threadCalibration);
};
export const getTypicalReplyHeight = (estimates: number[], isMobile: boolean): number => {
return getMedianEstimate(estimates, isMobile ? DEFAULT_MOBILE_HEIGHT : DEFAULT_DESKTOP_HEIGHT);
};
export const getReplyHeightEstimates = ({
context = 'preview',
directRepliesByParentCid,
isMobile,
maxContentChars,
metrics,
quotedByMap,
replies,
windowWidth,
}: ReplyHeightEstimateOptions): number[] => {
if (!canUsePretext() || replies.length === 0) {
return [];
}
const backlinkMaps = { directRepliesByParentCid, quotedByMap };
const effectiveMaxContentChars = maxContentChars ?? (isMobile ? 1000 : 2000);
return replies.map((reply) =>
isMobile
? estimateMobileReplyHeight(reply, metrics, windowWidth, backlinkMaps, effectiveMaxContentChars, context)
: estimateDesktopReplyHeight(reply, metrics, windowWidth, backlinkMaps, effectiveMaxContentChars, context),
);
};
const getDesktopOpBacklinkLabels = (post: Comment | undefined, quotedByMap?: Map<string, Comment[]>): string[] => {
const cid = post?.cid;
if (!cid) {
return [];
}
return (quotedByMap?.get(cid) || [])
.filter((candidateReply) => candidateReply?.cid && typeof candidateReply.number === 'number' && !(candidateReply.deleted || candidateReply.removed))
.map((candidateReply) => `>>${candidateReply.number}`);
};
const getMobileFeedCardCalibration = (previewReplyCount: number): number => {
const calibrationIndex = Math.max(0, Math.min(previewReplyCount, MOBILE_FEED_CARD_PREVIEW_COUNT_CALIBRATIONS.length - 1));
return MOBILE_FEED_CARD_PREVIEW_COUNT_CALIBRATIONS[calibrationIndex];
};
export const getFeedPostHeightEstimate = ({
directRepliesByParentCid,
isMobile,
metrics,
post,
previewReplies,
previewReplyEstimates,
quotedByMap,
showBoardLabel = false,
showSummary = false,
windowWidth,
}: FeedPostHeightEstimateOptions): number => {
if (!canUsePretext() || !post) {
return isMobile ? DEFAULT_MOBILE_HEIGHT : DEFAULT_DESKTOP_HEIGHT;
}
const previewHeights =
previewReplyEstimates && previewReplyEstimates.length === previewReplies.length
? previewReplyEstimates
: getReplyHeightEstimates({
directRepliesByParentCid,
isMobile,
maxContentChars: 1000,
metrics,
quotedByMap,
replies: previewReplies,
windowWidth,
});
const previewRepliesHeight = previewHeights.reduce((sum, value) => sum + value, 0);
const previewReplyCount = previewReplies.length;
if (isMobile) {
const fontSizePx = metrics.mobileContentFontSizePx;
const font = `${fontSizePx}px ${metrics.bodyFontFamily}`;
const lineHeight = getLineHeight(fontSizePx);
const postText = getVisibleCommentText(post, 1000);
const mediaMetrics = getFeedPostMediaMetrics(post, true);
const contentWidth = Math.max(MIN_TEXT_WIDTH, windowWidth - MOBILE_REPLY_CONTENT_PADDING_X);
const textHeight = measureParagraphHeight(postText, font, contentWidth, lineHeight);
const backlinksHeight = getMobileBacklinksHeight(getMobileBacklinkCount(post, { directRepliesByParentCid, quotedByMap }), metrics);
const rawEstimate =
MOBILE_FEED_CARD_OUTER_GAP_HEIGHT +
MOBILE_FEED_CARD_BASE_HEIGHT +
MOBILE_FEED_CARD_LINK_BAR_HEIGHT +
(mediaMetrics?.blockHeight || 0) +
textHeight +
backlinksHeight +
previewRepliesHeight;
// Mobile board cards render preview replies more compactly than the thread-reply estimator assumes.
const mobileCalibration = getMobileFeedCardCalibration(previewReplyCount);
return clampEstimateHeight(rawEstimate - mobileCalibration);
}
const fontSizePx = metrics.bodyFontSizePx;
const font = `${fontSizePx}px ${metrics.bodyFontFamily}`;
const lineHeight = getLineHeight(fontSizePx);
const postText = getVisibleCommentBodyText(post, 1000);
const mediaMetrics = getFeedPostMediaMetrics(post, false);
const contentWidth = Math.max(MIN_TEXT_WIDTH, windowWidth - DESKTOP_REPLY_CONTENT_PADDING_X);
const textHeight =
(mediaMetrics
? measureParagraphWithFloatHeight(postText, font, contentWidth, mediaMetrics.floatWidth, mediaMetrics.floatHeight, lineHeight)
: measureParagraphHeight(postText, font, contentWidth, lineHeight)) + (postText.trim().length > 0 ? DESKTOP_POST_MESSAGE_VERTICAL_PADDING : 0);
const mediaHeight = mediaMetrics ? mediaMetrics.floatHeight + DESKTOP_REPLY_FILE_TEXT_HEIGHT : 0;
const opBacklinks = getDesktopOpBacklinkLabels(post, quotedByMap);
const backlinkFontSizePx = metrics.abbrFontSizePx;
const backlinkLineHeight = getLineHeight(backlinkFontSizePx);
const backlinksHeight = Math.max(
0,
measureParagraphHeight(opBacklinks.join(' '), `${backlinkFontSizePx}px ${metrics.bodyFontFamily}`, contentWidth, backlinkLineHeight) - backlinkLineHeight,
);
const boardLabelHeight = showBoardLabel && !post.link && !post.parentCid && post.communityAddress ? DESKTOP_FEED_CARD_BOARD_LABEL_HEIGHT : 0;
const rawEstimate =
DESKTOP_FEED_CARD_HR_HEIGHT +
DESKTOP_FEED_CARD_BASE_HEIGHT +
Math.max(textHeight, mediaHeight) +
backlinksHeight +
boardLabelHeight +
(showSummary ? DESKTOP_FEED_CARD_SUMMARY_HEIGHT : 0) +
previewRepliesHeight;
return clampEstimateHeight(rawEstimate + DESKTOP_FEED_CARD_BASE_CALIBRATION);
};
export const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPComment }: CatalogPostHeightEstimateOptions): number => {
if (!canUsePretext() || !post) {
return DEFAULT_CATALOG_ROW_HEIGHT - CATALOG_ROW_PADDING_TOP;
}
const cardWidth = getCatalogCardWidth(imageSize);
const fontSizePx = metrics.bodyFontSizePx;
const font = `${fontSizePx}px ${metrics.bodyFontFamily}`;
const lineHeight = getLineHeight(fontSizePx);
const mediaInfo = post.link ? getCommentMediaInfo(post.link, post.thumbnailUrl, post.linkWidth, post.linkHeight) : undefined;
const hasThumbnail = getHasThumbnail(mediaInfo, post.link);
const visibleCatalogText = getVisibleCatalogText(post, showOPComment, hasThumbnail);
const textHeight =
visibleCatalogText.trim().length > 0
? measureParagraphHeight(visibleCatalogText, font, Math.max(MIN_TEXT_WIDTH, cardWidth - CATALOG_CARD_TEXT_PADDING_X), lineHeight)
: 0;
const mediaHeight = getCatalogPostMediaHeight(post, imageSize);
const rawHeight = Math.min(
CATALOG_CARD_MAX_HEIGHT,
CATALOG_CARD_MARGIN_Y + CATALOG_CARD_PADDING_TOP + CATALOG_CARD_PADDING_BOTTOM + mediaHeight + CATALOG_CARD_META_HEIGHT + textHeight,
);
return Math.max(MIN_ESTIMATE_HEIGHT, Math.ceil(rawHeight));
};
export const getCatalogRowHeightEstimate = ({ imageSize, metrics, row, showOPComment }: CatalogSingleRowHeightEstimateOptions): number => {
const tallestCardHeight = row.reduce((maxHeight, post) => {
return Math.max(maxHeight, getCatalogPostHeightEstimate({ imageSize, metrics, post, showOPComment }));
}, DEFAULT_CATALOG_ROW_HEIGHT - CATALOG_ROW_PADDING_TOP);
return clampEstimateHeight(CATALOG_ROW_PADDING_TOP + tallestCardHeight);
};
export const getCatalogRowHeightEstimates = ({ imageSize, metrics, rows, showOPComment }: CatalogRowHeightEstimateOptions): number[] => {
if (!canUsePretext() || rows.length === 0) {
return [];
}
return rows.map((row) => getCatalogRowHeightEstimate({ imageSize, metrics, row, showOPComment }));
};
export const getTypicalCatalogRowHeight = (estimates: number[], imageSize: CatalogImageSize): number =>
getMedianEstimate(estimates, imageSize === 'Large' ? 340 : DEFAULT_CATALOG_ROW_HEIGHT);
export const resolveReplyVirtualizationMode = (
searchParams: URLSearchParams | string | null | undefined,
fallback: ReplyVirtualizationMode = DEFAULT_REPLY_VIRTUALIZATION_MODE,
): ReplyVirtualizationMode => {
const params = resolveSearchParams(searchParams);
const requestedMode = params?.get('pretextReplies');
return isReplyVirtualizationMode(requestedMode) ? requestedMode : fallback;
};
export const resolveFeedVirtualizationMode = (
searchParams: URLSearchParams | string | null | undefined,
fallback: ReplyVirtualizationMode = 'off',
): ReplyVirtualizationMode => {
const params = resolveSearchParams(searchParams);
const requestedMode = params?.get('pretextFeed');
return isReplyVirtualizationMode(requestedMode) ? requestedMode : fallback;
};
export const resolveCatalogVirtualizationMode = (
searchParams: URLSearchParams | string | null | undefined,
fallback: ReplyVirtualizationMode = 'off',
): ReplyVirtualizationMode => {
const params = resolveSearchParams(searchParams);
const requestedMode = params?.get('pretextCatalog');
return isReplyVirtualizationMode(requestedMode) ? requestedMode : fallback;
};
export const getPretextItemSizeFromElement = (element: HTMLElement, field: 'offsetHeight' | 'offsetWidth'): number => {
if (field === 'offsetWidth') {
return element.offsetWidth;
}
const ownEstimatedHeight = Number.parseFloat(element.dataset.pretextHeight || '');
if (Number.isFinite(ownEstimatedHeight) && ownEstimatedHeight > 0) {
return ownEstimatedHeight;
}
let nestedElement = nestedPretextElementCache.get(element);
if (nestedElement && !element.contains(nestedElement)) {
nestedPretextElementCache.delete(element);
nestedElement = undefined;
}
if (nestedElement === undefined) {
nestedElement = element.querySelector<HTMLElement>(`[${REPLY_HEIGHT_DATA_ATTRIBUTE}]`) ?? null;
nestedPretextElementCache.set(element, nestedElement);
}
const nestedEstimatedHeight = Number.parseFloat(nestedElement?.dataset.pretextHeight || '');
return Number.isFinite(nestedEstimatedHeight) && nestedEstimatedHeight > 0 ? nestedEstimatedHeight : element.offsetHeight;
};
export const getReplyItemSizeFromElement = (element: HTMLElement, field: 'offsetHeight' | 'offsetWidth'): number => getPretextItemSizeFromElement(element, field);
export const reportReplyHeightAuditSample = (element: HTMLElement | null, estimate: number | undefined, cid: string | undefined) => {
if (!element || !cid || !Number.isFinite(estimate)) {
return;
}
const auditStore = getReplyHeightAuditStore();
if (!auditStore) {
return;
}
window.requestAnimationFrame(() => {
if (!document.contains(element)) {
return;
}
const actualHeight = element.offsetHeight;
const numericEstimate = Math.round(estimate as number);
auditStore.record({
actual: actualHeight,
cid,
diff: actualHeight - numericEstimate,
estimate: numericEstimate,
});
});
};
export const readReplyTypographyMetrics = (): ReplyTypographyMetrics => {
if (typeof document === 'undefined') {
return {
abbrFontSizePx: 13,
bodyFontFamily: 'Arial, Helvetica, sans-serif',
bodyFontSizePx: 13,
mobileContentFontSizePx: 15,
};
}
const bodyStyles = window.getComputedStyle(document.body);
const bodyFontFamily = bodyStyles.getPropertyValue('--body-font-family').trim() || 'Arial, Helvetica, sans-serif';
const bodyFontSizePx = parseCssLengthToPx(bodyStyles.getPropertyValue('--body-font-size'), 13);
return {
abbrFontSizePx: parseCssLengthToPx(bodyStyles.getPropertyValue('--post-mobile-abbr-font-size'), 13),
bodyFontFamily,
bodyFontSizePx,
mobileContentFontSizePx: parseCssLengthToPx(bodyStyles.getPropertyValue('--post-mobile-content-font-size'), 15),
};
};
+32 -12
View File
@@ -16,15 +16,16 @@ import useFeedResetStore from '../../stores/use-feed-reset-store';
import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store';
import usePostNumberStore from '../../stores/use-post-number-store';
import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
import useIsMobile from '../../hooks/use-is-mobile';
import { getPageSlice } from '../../lib/utils/board-feed-pagination';
import { getPageFromFeedPath, getSubplebbitAddress, isDirectoryBoard, normalizeMultiboardFeedPath, stripPageFromFeedPath } from '../../lib/utils/route-utils';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { getPretextItemSizeFromElement, resolveFeedVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
import ErrorDisplay from '../../components/error-display/error-display';
import LoadingEllipsis from '../../components/loading-ellipsis';
import BoardPagination from '../../components/board-pagination';
import { CatalogButton } from '../../components/board-buttons/board-buttons';
import { PageFooterDesktop, PageFooterMobile } from '../../components/footer';
import useIsMobile from '../../hooks/use-is-mobile';
import { Post } from '../post';
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
@@ -234,24 +235,34 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
);
const navigate = useNavigate();
const isMultiboardView = isInAllView || isInSubscriptionsView || isInModView;
const defaultFeedVirtualizationMode = isMobile && isMultiboardView ? 'off' : 'item-size';
const feedVirtualizationMode = useMemo(
() => resolveFeedVirtualizationMode(location.search, defaultFeedVirtualizationMode),
[defaultFeedVirtualizationMode, location.search],
);
const defaultBoardItemHeight = feedVirtualizationMode === 'item-size' ? (isMobile ? 420 : 480) : isMobile ? 420 : 300;
// Omit the prop entirely in fallback mode. Passing `itemSize={undefined}` overrides
// Virtuoso's internal DOM measurer and leaves multiboard items stuck on the default height.
const boardSizingProps = useMemo(() => (feedVirtualizationMode === 'item-size' ? { itemSize: getPretextItemSizeFromElement } : {}), [feedVirtualizationMode]);
// Redirect multiboard paths with page-number segments to normalized path (infinite-scroll only)
useEffect(() => {
if (!isVisible || !isForcedInfiniteScroll) return;
const normalized = normalizeMultiboardFeedPath(location.pathname);
if (normalized !== location.pathname) {
navigate(normalized, { replace: true });
navigate({ pathname: normalized, search: location.search }, { replace: true });
}
}, [isVisible, isForcedInfiniteScroll, location.pathname, navigate]);
}, [isVisible, isForcedInfiniteScroll, location.pathname, location.search, navigate]);
useEffect(() => {
if (!isVisible) return;
if (!effectiveInfiniteScroll && currentPage > totalPages && totalPages > 0) {
const targetPage = totalPages;
const targetPath = targetPage === 1 ? paginationBasePath : `${paginationBasePath}/${targetPage}`;
navigate(targetPath, { replace: true });
navigate({ pathname: targetPath, search: location.search }, { replace: true });
}
}, [isVisible, effectiveInfiniteScroll, currentPage, totalPages, paginationBasePath, navigate]);
}, [isVisible, effectiveInfiniteScroll, currentPage, totalPages, paginationBasePath, location.search, navigate]);
// Scroll to top instantly when page changes in pagination mode
useEffect(() => {
@@ -301,7 +312,14 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
/>
<PageFooterDesktop
firstRow={
<BoardPagination basePath={paginationBasePath} currentPage={currentPage} totalPages={totalPages} footerStyle isMultiboard={isForcedInfiniteScroll} />
<BoardPagination
basePath={paginationBasePath}
currentPage={currentPage}
search={location.search}
totalPages={totalPages}
footerStyle
isMultiboard={isForcedInfiniteScroll}
/>
}
/>
<PageFooterMobile>
@@ -329,7 +347,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
<span key={page}>
[
<Link
to={page === 1 ? paginationBasePath : `${paginationBasePath}/${page}`}
to={{ pathname: page === 1 ? paginationBasePath : `${paginationBasePath}/${page}`, search: location.search }}
className={page === currentPage ? mobileFooterStyles.mobileFooterPaginationCurrent : undefined}
>
{page}
@@ -373,6 +391,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
totalPages,
setEnableInfiniteScroll,
reset,
location.search,
t,
],
);
@@ -380,12 +399,13 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${location.pathname}-${BOARD_SORT_TYPE}`;
const navigationType = useNavigationType();
const isMultiboardView = isInAllView || isInSubscriptionsView || isInModView;
const defaultBoardItemHeight = isMobile ? 420 : 300;
const boardViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 1400, top: 2400 } : { bottom: 600, top: 600 }) : { bottom: 1200, top: 1200 };
const boardMinOverscanItemCount = isMultiboardView && isMobile ? { bottom: 4, top: 8 } : undefined;
const boardItemContent = useCallback((index: number, post: Comment | undefined) => <Post index={index} post={post} />, []);
const boardItemContent = useCallback(
(index: number, post: Comment | undefined) => <Post feedVirtualizationModeOverride={feedVirtualizationMode} index={index} post={post} />,
[feedVirtualizationMode],
);
const hasBeenVisibleRef = useRef(false);
useEffect(() => {
@@ -401,8 +421,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
if (!isVisible) return;
const currentKey = virtuosoStateKey;
// Saving the snapshot on every scroll tick makes board scrolling do extra work
// in the hottest path. Capturing on teardown still preserves POP restores.
// Avoid state snapshot work on every scroll tick in the hottest board path.
const saveVirtuosoState = () => {
virtuosoRef.current?.getState((snapshot: StateSnapshot) => {
if (snapshot?.ranges?.length) {
@@ -454,6 +473,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
{effectiveInfiniteScroll ? (
<Virtuoso
defaultItemHeight={defaultBoardItemHeight}
{...boardSizingProps}
increaseViewportBy={boardViewportBuffer}
minOverscanItemCount={boardMinOverscanItemCount}
totalCount={displayFeed.length}
+9 -12
View File
@@ -35,7 +35,6 @@ const testState = vi.hoisted(() => ({
account: { subscriptions: [] as string[] },
accountComments: [] as TestComment[],
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
clearMatchedFiltersMock: vi.fn(),
directoryByAddress: {
'music-posting.eth': {
address: 'music-posting.eth',
@@ -47,6 +46,7 @@ const testState = vi.hoisted(() => ({
filterItems: [] as FilterItem[],
filteredDirectoryAddresses: ['music-posting.eth'] as string[],
hasMore: false,
imageSize: 'Small' as 'Large' | 'Small',
incrementFilterCountMock: vi.fn(),
loadMoreMock: vi.fn(),
pageSizes: {
@@ -58,8 +58,8 @@ const testState = vi.hoisted(() => ({
resolvedCommunityAddress: 'music-posting.eth' as string | undefined,
searchText: '',
setCurrentCommunityAddressMock: vi.fn(),
setMatchedFilterMock: vi.fn(),
setResetFunctionMock: vi.fn(),
showOPComment: true,
sortType: 'new' as 'active' | 'new',
windowWidth: 900,
community: {
@@ -72,12 +72,10 @@ const testState = vi.hoisted(() => ({
function getCatalogFiltersState() {
return {
clearMatchedFilters: testState.clearMatchedFiltersMock,
filterItems: testState.filterItems,
incrementFilterCount: testState.incrementFilterCountMock,
searchText: testState.searchText,
setCurrentSubplebbitAddress: testState.setCurrentCommunityAddressMock,
setMatchedFilter: testState.setMatchedFilterMock,
};
}
@@ -191,7 +189,8 @@ vi.mock('../../../hooks/use-window-width', () => ({
vi.mock('../../../stores/use-catalog-style-store', () => ({
default: () => ({
imageSize: 'Small',
imageSize: testState.imageSize,
showOPComment: testState.showOPComment,
}),
}));
@@ -213,7 +212,8 @@ vi.mock('../../../stores/use-catalog-filters-store', () => ({
}));
vi.mock('../../../components/catalog-row', () => ({
default: ({ row }: { row: TestComment[] }) => createElement('div', { 'data-testid': 'catalog-row' }, `row:${row.map((comment) => comment.cid).join(',')}`),
default: ({ estimatedHeight, row }: { estimatedHeight?: number; row: TestComment[] }) =>
createElement('div', { 'data-pretext-height': estimatedHeight, 'data-testid': 'catalog-row' }, `row:${row.map((comment) => comment.cid).join(',')}`),
}));
vi.mock('../../../components/footer', () => ({
@@ -308,6 +308,7 @@ describe('Catalog', () => {
testState.filterItems = [];
testState.filteredDirectoryAddresses = ['music-posting.eth'];
testState.hasMore = false;
testState.imageSize = 'Small';
testState.pageSizes = {
guiPostsPerPage: 2,
maxGuiPages: 3,
@@ -315,6 +316,7 @@ describe('Catalog', () => {
};
testState.resolvedCommunityAddress = 'music-posting.eth';
testState.searchText = '';
testState.showOPComment = true;
testState.sortType = 'new';
testState.windowWidth = 900;
testState.community = {
@@ -323,12 +325,10 @@ describe('Catalog', () => {
state: 'ready',
title: '/mu/ - Music',
};
testState.clearMatchedFiltersMock.mockReset();
testState.incrementFilterCountMock.mockReset();
testState.loadMoreMock.mockReset();
testState.resetMock.mockReset();
testState.setCurrentCommunityAddressMock.mockReset();
testState.setMatchedFilterMock.mockReset();
testState.setResetFunctionMock.mockReset();
document.title = 'before';
@@ -342,7 +342,7 @@ describe('Catalog', () => {
container.remove();
});
it('applies catalog filters, promotes top matches, and clears board filter state on unmount', async () => {
it('applies catalog filters and promotes top matches', async () => {
testState.feed = [
{ cid: 'boring-post', title: 'plain talk', content: 'nothing special', communityAddress: 'music-posting.eth' },
{ cid: 'hidden-post', title: 'cats and spoilers', content: 'spoiler content', communityAddress: 'music-posting.eth' },
@@ -357,16 +357,13 @@ describe('Catalog', () => {
expect(document.title).toBe('/mu/ - catalog - 5chan');
expect(testState.setCurrentCommunityAddressMock).toHaveBeenCalledWith('music-posting.eth');
expect(testState.clearMatchedFiltersMock).toHaveBeenCalled();
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:top-post,boring-post']);
expect(testState.incrementFilterCountMock).toHaveBeenCalledWith(0, 'hidden-post', 'music-posting.eth');
expect(testState.incrementFilterCountMock).toHaveBeenCalledWith(1, 'top-post', 'music-posting.eth');
expect(testState.setMatchedFilterMock).toHaveBeenCalledWith('top-post', 'red');
act(() => root.unmount());
expect(testState.setCurrentCommunityAddressMock).toHaveBeenLastCalledWith(null);
expect(testState.clearMatchedFiltersMock).toHaveBeenCalledTimes(3);
root = createRoot(container);
});
+65 -65
View File
@@ -8,6 +8,7 @@ import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-directory-addresses';
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
import { useFeedStateString } from '../../hooks/use-state-string';
import useIsMobile from '../../hooks/use-is-mobile';
import useWindowWidth from '../../hooks/use-window-width';
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
import useFeedResetStore from '../../stores/use-feed-reset-store';
@@ -24,6 +25,13 @@ import styles from './catalog.module.css';
import { commentMatchesPattern } from '../../lib/utils/pattern-utils';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { sortCatalogFeedForDisplay } from '../../lib/utils/catalog-sort';
import {
getCatalogRowHeightEstimates,
getPretextItemSizeFromElement,
getTypicalCatalogRowHeight,
readReplyTypographyMetrics,
resolveCatalogVirtualizationMode,
} from '../../lib/utils/pretext-height-estimates';
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
const RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS = 60 * 60;
@@ -34,9 +42,6 @@ interface CatalogFooterProps {
communityAddresses: string[];
hasMore: boolean;
combinedFeedLength: number;
isInAllView: boolean;
isInSubscriptionsView: boolean;
isInModView: boolean;
/** When false, suppress the loading ellipsis (e.g. non-infinite mode) */
showLoadingEllipsis?: boolean;
}
@@ -44,15 +49,7 @@ interface CatalogFooterProps {
// Defined outside Catalog to preserve component identity across renders (Virtuoso optimization)
// The useFeedStateString hook is called here instead of in Catalog to isolate re-renders
// caused by backend IPFS state changes to just this footer component
const CatalogFooter = ({
communityAddresses,
hasMore,
combinedFeedLength,
isInAllView,
isInSubscriptionsView,
isInModView,
showLoadingEllipsis = true,
}: CatalogFooterProps) => {
const CatalogFooter = ({ communityAddresses, hasMore, combinedFeedLength, showLoadingEllipsis = true }: CatalogFooterProps) => {
const { t } = useTranslation();
const loadingStateString = useFeedStateString(communityAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts'));
@@ -141,11 +138,6 @@ const createContentFilter = (
// Fallback to the store method if no callback provided
useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, comment.cid, communityAddress);
}
// If the filter has a color, track it in the matchedFilters map
if (item.color) {
useCatalogFiltersStore.getState().setMatchedFilter(comment.cid, item.color);
}
}
// If this filter is set to hide, filter out the comment
@@ -222,7 +214,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
return resolvedAddressFromUrl;
}, [boardIdentifierProp, directories, resolvedAddressFromUrl]);
const { filterItems, searchText, clearMatchedFilters } = useCatalogFiltersStore();
const { filterItems, searchText } = useCatalogFiltersStore();
const account = useAccount();
const subscriptions = account?.subscriptions;
@@ -239,11 +231,11 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
return communityAddress ? [communityAddress] : [];
}, [isInAllView, isInSubscriptionsView, communityAddress, filteredDirectoryAddresses, subscriptions]);
const { imageSize } = useCatalogStyleStore();
const { imageSize, showOPComment } = useCatalogStyleStore();
const columnWidth = imageSize === 'Large' ? 270 : 180;
const columnCount = Math.floor(useWindowWidth() / columnWidth);
const postsPerPage = columnCount <= 2 ? 10 : columnCount === 3 ? 15 : columnCount === 4 ? 20 : 25;
const windowWidth = useWindowWidth();
const isMobile = useIsMobile();
const columnCount = Math.floor(windowWidth / columnWidth);
const communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress);
const { guiPostsPerPage: boardPostsPerPage, maxGuiPages, paginationFeedPostsPerPage } = useBoardFeedPageSize(communityDirectory);
@@ -259,6 +251,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const { sortType } = useSortingStore();
const feedSortType = sortType === 'new' ? 'new' : 'active';
const catalogVirtualizationMode = useMemo(() => resolveCatalogVirtualizationMode(location.search, 'item-size'), [location.search]);
const themeKey = typeof document !== 'undefined' ? document.body.className : '';
// Create a stable callback for filter matching
const handleFilterMatch = useCallback((filterIndex: number, cid: string, communityAddress: string) => {
@@ -372,15 +366,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
() => ({
Footer: () => (
<>
<CatalogFooter
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={cappedFeed.length}
isInAllView={isInAllView}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
showLoadingEllipsis={effectiveInfiniteScroll}
/>
<CatalogFooter communityAddresses={communityAddresses} hasMore={hasMore} combinedFeedLength={cappedFeed.length} showLoadingEllipsis={effectiveInfiniteScroll} />
<PageFooterDesktop
firstRow={
<CatalogFooterFirstRow
@@ -440,6 +426,29 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
return [...topPosts, ...regularPosts];
}, [sortedFeed, filterItems]);
const matchedFilterColors = useMemo(() => {
const nextMatchedFilterColors = new Map<string, string>();
const activeColoredFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== '' && item.color);
if (activeColoredFilters.length === 0) {
return nextMatchedFilterColors;
}
for (const comment of processedFeed) {
const cid = comment?.cid;
if (!cid) {
continue;
}
const firstMatch = activeColoredFilters.find((item) => commentMatchesPattern(comment, item.text));
if (firstMatch?.color) {
nextMatchedFilterColors.set(cid, firstMatch.color);
}
}
return nextMatchedFilterColors;
}, [filterItems, processedFeed]);
const rows = useMemo(() => {
if (!isFeedLoaded) {
return [];
@@ -453,6 +462,26 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
return nextRows;
}, [columnCount, isFeedLoaded, processedFeed]);
const catalogMetrics = useMemo(() => readReplyTypographyMetrics(), [themeKey, windowWidth]);
const rowHeightEstimates = useMemo(
() =>
catalogVirtualizationMode === 'off'
? []
: getCatalogRowHeightEstimates({
imageSize,
metrics: catalogMetrics,
rows,
showOPComment,
}),
[catalogMetrics, catalogVirtualizationMode, imageSize, rows, showOPComment],
);
const defaultCatalogRowHeight = useMemo(() => getTypicalCatalogRowHeight(rowHeightEstimates, imageSize), [imageSize, rowHeightEstimates]);
// Omit the prop entirely in fallback mode. Passing `itemSize={undefined}` overrides
// Virtuoso's default DOM measurement path and leaves rows on the fallback height.
const catalogSizingProps = useMemo(() => (catalogVirtualizationMode === 'item-size' ? { itemSize: getPretextItemSizeFromElement } : {}), [catalogVirtualizationMode]);
const isMultiboardView = isInAllView || isInSubscriptionsView || isInModView;
const catalogViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 2400, top: 1200 } : { bottom: 1200, top: 900 }) : { bottom: 1200, top: 1200 };
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}-${sortType}-catalog`;
const navigationType = useNavigationType();
@@ -501,37 +530,6 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
document.title = documentTitle + ` - ${t('catalog')} - 5chan`;
}, [title, shortAddress, communityAddress, isInAllView, isInSubscriptionsView, t, isVisible, params.boardIdentifier, boardIdentifierProp, directories]);
// Clear matched filters when component mounts or when community changes
useEffect(() => {
clearMatchedFilters();
return () => {
clearMatchedFilters();
};
}, [clearMatchedFilters, communityAddress]);
// Memoize filter color application to avoid redundant iterations
useMemo(() => {
if (cappedFeed.length > 0 && filterItems.length > 0) {
// Clear existing matched filters
clearMatchedFilters();
// Apply colors to posts that match filters
cappedFeed.forEach((comment) => {
if (!comment?.cid) return;
// Check each filter
for (const item of filterItems) {
if (item.enabled && item.text.trim() !== '' && item.color) {
if (commentMatchesPattern(comment, item.text)) {
useCatalogFiltersStore.getState().setMatchedFilter(comment.cid, item.color);
break; // Use the first matching filter's color
}
}
}
});
}
}, [cappedFeed, filterItems, clearMatchedFilters]);
return (
<div className={styles.content}>
<hr />
@@ -539,11 +537,13 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
{processedFeed?.length !== 0 ? (
<>
<Virtuoso
defaultItemHeight={imageSize === 'Large' ? 320 : 200}
increaseViewportBy={isInAllView || isInSubscriptionsView || isInModView ? { bottom: 600, top: 600 } : { bottom: 1200, top: 1200 }}
defaultItemHeight={defaultCatalogRowHeight}
heightEstimates={catalogVirtualizationMode === 'off' ? undefined : rowHeightEstimates}
increaseViewportBy={catalogViewportBuffer}
{...catalogSizingProps}
totalCount={rows?.length || 0}
data={rows}
itemContent={(index, row) => <CatalogRow index={index} row={row} />}
itemContent={(index, row) => <CatalogRow estimatedHeight={rowHeightEstimates[index]} index={index} matchedFilterColors={matchedFilterColors} row={row} />}
useWindowScroll={true}
components={footerComponents}
endReached={effectiveInfiniteScroll && hasMore ? loadMore : undefined}
+6
View File
@@ -507,6 +507,12 @@
contain-intrinsic-size: auto 120px;
}
.replyDesktop.pretextVirtualizedReply,
.replyMobile.pretextVirtualizedReply {
content-visibility: visible;
contain-intrinsic-size: auto;
}
.replyMobile .replyContainer {
background-color: var(--post-mobile-background-color);
border-bottom: var(--post-mobile-border-bottom);
+35 -1
View File
@@ -17,6 +17,7 @@ import PostDesktop from '../../components/post-desktop';
import PostMobile from '../../components/post-mobile';
import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import type { ReplyVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
import styles from './post.module.css';
type CommentWithRefresh = Comment & {
@@ -26,6 +27,13 @@ type CommentWithRefresh = Comment & {
errors?: Error[];
};
export interface ReplyPaginationOverride {
hasMore?: boolean;
loadMore?: () => void;
replies: Comment[];
reset?: () => Promise<void>;
}
// useComment may not return cached feed data immediately due to its updatedAt comparison logic.
// This hook falls back to the communities pages store (populated by useFeed) so content
// from the catalog appears instantly instead of going through a loading phase.
@@ -46,12 +54,15 @@ const useCommentWithFeedCache = (options: { commentCid: string | undefined; auto
};
export interface PostProps {
feedVirtualizationModeOverride?: ReplyVirtualizationMode;
index?: number;
isHidden?: boolean;
hasThumbnail?: boolean;
post?: any;
postReplyCount?: number;
reply?: any;
replyPaginationOverride?: ReplyPaginationOverride;
replyVirtualizationModeOverride?: ReplyVirtualizationMode;
roles?: Role[];
showAllReplies?: boolean;
showReplies?: boolean;
@@ -67,7 +78,21 @@ export interface PostProps {
}
export const Post = memo(
({ post, showAllReplies = false, showReplies = true, targetReplyCid, isModQueue, modQueueStatus, modQueueError, isPublishing, onApprove, onReject }: PostProps) => {
({
post,
showAllReplies = false,
showReplies = true,
targetReplyCid,
isModQueue,
modQueueStatus,
modQueueError,
isPublishing,
onApprove,
onReject,
feedVirtualizationModeOverride,
replyPaginationOverride,
replyVirtualizationModeOverride,
}: PostProps) => {
// Only subscribe to roles field to avoid rerenders from updatingState changes
const communityAddress = post?.communityAddress || post?.subplebbitAddress;
const roles = useCommunityField(communityAddress, (community) => community?.roles);
@@ -86,7 +111,10 @@ export const Post = memo(
<div className={styles.postContainer}>
{isMobile ? (
<PostMobile
feedVirtualizationModeOverride={feedVirtualizationModeOverride}
post={comment}
replyPaginationOverride={replyPaginationOverride}
replyVirtualizationModeOverride={replyVirtualizationModeOverride}
roles={roles}
showAllReplies={showAllReplies}
showReplies={showReplies}
@@ -100,7 +128,10 @@ export const Post = memo(
/>
) : (
<PostDesktop
feedVirtualizationModeOverride={feedVirtualizationModeOverride}
post={comment}
replyPaginationOverride={replyPaginationOverride}
replyVirtualizationModeOverride={replyVirtualizationModeOverride}
roles={roles}
showAllReplies={showAllReplies}
showReplies={showReplies}
@@ -133,6 +164,9 @@ export const Post = memo(
prevProps.showAllReplies === nextProps.showAllReplies &&
prevProps.showReplies === nextProps.showReplies &&
prevProps.targetReplyCid === nextProps.targetReplyCid &&
prevProps.feedVirtualizationModeOverride === nextProps.feedVirtualizationModeOverride &&
prevProps.replyPaginationOverride === nextProps.replyPaginationOverride &&
prevProps.replyVirtualizationModeOverride === nextProps.replyVirtualizationModeOverride &&
prevProps.isModQueue === nextProps.isModQueue &&
prevProps.modQueueStatus === nextProps.modQueueStatus &&
prevProps.modQueueError === nextProps.modQueueError &&
+8
View File
@@ -16,6 +16,7 @@ __metadata:
"@capacitor/core": "npm:7.4.5"
"@capacitor/status-bar": "npm:7.0.1"
"@capawesome/capacitor-android-edge-to-edge-support": "npm:7.2.2"
"@chenglou/pretext": "npm:0.0.3"
"@electron-forge/cli": "npm:7.8.0"
"@electron-forge/maker-dmg": "npm:7.8.0"
"@electron-forge/maker-squirrel": "npm:7.8.0"
@@ -1735,6 +1736,13 @@ __metadata:
languageName: node
linkType: hard
"@chenglou/pretext@npm:0.0.3":
version: 0.0.3
resolution: "@chenglou/pretext@npm:0.0.3"
checksum: 10c0/0822bfb99fa6e052b5eefc66adffb51c8c9a4de494879ec7584f533161b0718d6e6f8bdde88ba0897686751c3f96f0f0f255be8b45cebc46c7cf5f705e1cb464
languageName: node
linkType: hard
"@commitlint/config-validator@npm:^20.0.0":
version: 20.0.0
resolution: "@commitlint/config-validator@npm:20.0.0"