Skip to content

Commit c8ba0b3

Browse files
authored
perf(feeds): improve scrolling, media loading, and navigation (#1206)
Reduce avoidable React work during feed scrolling and navigation, restore React Compiler integration, update Virtuoso, and deduplicate media and immutable comment lookups. Preserve cached feed state, scroll restoration, and GIF thumbnail ownership across Activity hide/show. Validated with focused regressions, production and throttled browser measurements, all three browser engines, and passing final CI across web, Android, Linux, macOS, and Windows. Correct the preexisting Windows native-binary assertion to its current package path.
1 parent 1973222 commit c8ba0b3

32 files changed

Lines changed: 1210 additions & 327 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,7 @@ jobs:
495495
APP_ASAR=$(find out -path '*/resources/app.asar' | head -n 1)
496496
KUBO_EXE=$(find out -path '*/resources/app.asar.unpacked/bin/win/ipfs.exe' | head -n 1)
497497
SQLITE_MODULE=$(find out -path '*/resources/app.asar.unpacked/node_modules/better-sqlite3/build/Release/better_sqlite3.node' | head -n 1)
498-
DATACHANNEL_MODULE=$(find out -path '*/resources/app.asar.unpacked/node_modules/node-datachannel/build/Release/node_datachannel.node' | head -n 1)
498+
DATACHANNEL_MODULE=$(find out -path '*/resources/app.asar.unpacked/node_modules/@node-datachannel/win32-x64-msvc/node_datachannel.node' | head -n 1)
499499
echo "Found ASAR: $APP_ASAR"
500500
echo "Found Kubo: $KUBO_EXE"
501501
echo "Found SQLite module: $SQLITE_MODULE"

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"react-i18next": "16.6.6",
4646
"react-router-dom": "7.18.2",
4747
"react-router-hash-link": "2.4.3",
48-
"react-virtuoso": "4.18.5",
48+
"react-virtuoso": "4.18.13",
4949
"tcp-port-used": "1.0.2",
5050
"typescript": "7.0.2",
5151
"workbox-core": "7.4.0",
@@ -139,6 +139,7 @@
139139
]
140140
},
141141
"devDependencies": {
142+
"@babel/core": "7.29.6",
142143
"@capacitor/android": "7.4.5",
143144
"@capacitor/cli": "7.4.5",
144145
"@capacitor/core": "7.4.5",
@@ -149,6 +150,8 @@
149150
"@electron-forge/plugin-auto-unpack-natives": "7.8.0",
150151
"@electron/rebuild": "3.7.2",
151152
"@reforged/maker-appimage": "5.1.1",
153+
"@rolldown/plugin-babel": "0.1.8",
154+
"@types/babel__core": "7.20.5",
152155
"@types/lodash": "4.17.24",
153156
"@types/memoizee": "0.4.9",
154157
"@vitejs/plugin-react": "6.0.0",
@@ -179,6 +182,7 @@
179182
"react-doctor": "0.8.1",
180183
"react-grab": "0.1.48",
181184
"react-scan": "0.5.3",
185+
"rolldown": "1.0.3",
182186
"smol-toml": "1.6.1",
183187
"stream-browserify": "3.0.0",
184188
"vite": "8.0.16",
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import assert from 'node:assert/strict';
2+
import { mkdtempSync, rmSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { dirname, join } from 'node:path';
5+
import test from 'node:test';
6+
import { fileURLToPath } from 'node:url';
7+
import { createServer, loadConfigFromFile } from 'vite';
8+
import { parseSync, traverse } from '@babel/core';
9+
10+
test('the app Vite configuration preserves React Compiler behavior', { timeout: 30_000 }, async (t) => {
11+
const configFile = fileURLToPath(new URL('../../vite.config.js', import.meta.url));
12+
const root = dirname(configFile);
13+
const cacheDir = mkdtempSync(join(tmpdir(), '5chan-react-compiler-'));
14+
const environment = new Map(['VITE_APP_VERSION', 'VITE_COMMIT_REF', 'VITE_LATEST_RELEASE_COMMIT_REF'].map((key) => [key, process.env[key]]));
15+
let server;
16+
t.after(async () => {
17+
await server?.close();
18+
rmSync(cacheDir, { recursive: true, force: true });
19+
for (const [key, value] of environment) {
20+
if (value === undefined) delete process.env[key];
21+
else process.env[key] = value;
22+
}
23+
});
24+
25+
// Keep metadata resolution offline, including shallow CI checkouts without release tags.
26+
process.env.VITE_COMMIT_REF = 'compiler-test';
27+
process.env.VITE_LATEST_RELEASE_COMMIT_REF = 'compiler-test';
28+
const loaded = await loadConfigFromFile({ command: 'serve', mode: 'development' }, configFile, root);
29+
assert.ok(loaded, `Could not load ${configFile}`);
30+
server = await createServer({
31+
...loaded.config,
32+
configFile: false,
33+
root,
34+
cacheDir,
35+
server: { ...loaded.config.server, middlewareMode: true, hmr: false, watch: null },
36+
optimizeDeps: { noDiscovery: true, include: [] },
37+
});
38+
39+
const sourcePath = '/src/components/loading-ellipsis/loading-ellipsis.tsx';
40+
t.diagnostic(`Transforming ${sourcePath} with ${configFile}`);
41+
const transformed = await server.transformRequest(sourcePath);
42+
assert.ok(transformed, `Vite did not transform ${sourcePath}`);
43+
assert.match(transformed.code, /compiler-runtime/, 'React Compiler must inject its runtime');
44+
assert.match(transformed.code, /react\.memo_cache_sentinel/, 'React Compiler must emit memo caches for the component');
45+
46+
// Vitest normally runs without React Compiler. Check the actual app transform:
47+
// memoizing a bound store hook would skip React hooks on subsequent renders.
48+
for (const [path, expectedCalls] of [
49+
['/src/hooks/use-prune-hidden-catalog-threads.ts', 2],
50+
['/src/hooks/use-state-string.ts', 1],
51+
['/src/hooks/use-communities-stats.ts', 2],
52+
['/src/views/board/board.tsx', 1],
53+
]) {
54+
await t.test(`store subscriptions stay unconditional in ${path}`, async () => {
55+
const result = await server.transformRequest(path);
56+
assert.ok(result, `Vite did not transform ${path}`);
57+
const ast = parseSync(result.code, { configFile: false, babelrc: false });
58+
const storeHooks = new Set();
59+
traverse(ast, {
60+
ImportDeclaration(importPath) {
61+
if (importPath.node.source.value.includes('bitsocial-internals/stores')) {
62+
for (const specifier of importPath.node.specifiers) {
63+
storeHooks.add(specifier.local.name);
64+
}
65+
}
66+
},
67+
});
68+
let calls = 0;
69+
traverse(ast, {
70+
CallExpression(callPath) {
71+
if (callPath.node.callee.type !== 'Identifier' || !storeHooks.has(callPath.node.callee.name)) return;
72+
calls++;
73+
const statement = callPath.getStatementParent();
74+
assert.ok(
75+
statement.parentPath.isBlockStatement() && statement.parentPath.parentPath.isFunction(),
76+
`${callPath.node.callee.name} must run unconditionally in the compiled component or hook`,
77+
);
78+
},
79+
});
80+
assert.equal(calls, expectedCalls, 'Check every expected bound store hook call');
81+
});
82+
}
83+
});

src/components/__tests__/post-community-address-compat.test.tsx

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as React from 'react';
22
import { createElement } from 'react';
33
import { createRoot, type Root } from 'react-dom/client';
4-
import { MemoryRouter } from 'react-router-dom';
4+
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
66
import PostDesktop from '../post-desktop';
77
import PostMobile from '../post-mobile';
@@ -52,12 +52,16 @@ const testState = vi.hoisted(() => ({
5252
accountCommentsByCid: {} as Record<string, TestComment | undefined>,
5353
directoryEntryByAddress: {} as Record<string, { address: string; directoryCode?: string; features?: Record<string, unknown>; title?: string } | undefined>,
5454
hasMoreReplies: false,
55+
freshReplyInputs: [] as TestComment[][],
5556
openReplyModalMock: vi.fn(),
5657
pseudonymityMode: 'none',
5758
replyComments: [] as Array<TestComment | undefined>,
5859
setResetFunctionMock: vi.fn(),
5960
stateString: undefined as string | undefined,
6061
virtuosoProps: [] as Array<{ defaultItemHeight?: number; heightEstimates?: number[]; itemSize?: unknown }>,
62+
getVirtuosoStateMock: vi.fn(),
63+
virtuosoSnapshot: { ranges: [0], scrollTop: 0 },
64+
restoredVirtuosoStates: [] as Array<{ initialScrollTop?: number; restoreStateFrom?: { ranges: number[]; scrollTop: number } }>,
6165
}));
6266

6367
const getMockPreloadedReplies = (comment?: TestComment, sortType?: string) => {
@@ -120,20 +124,28 @@ vi.mock('react-virtuoso', () => ({
120124
heightEstimates,
121125
itemSize,
122126
itemContent,
127+
initialScrollTop,
128+
restoreStateFrom,
123129
}: {
124130
components?: { Footer?: React.ComponentType };
125131
data?: TestComment[];
126132
defaultItemHeight?: number;
127133
heightEstimates?: number[];
128134
itemSize?: unknown;
129135
itemContent: (index: number, item: TestComment) => React.ReactNode;
136+
initialScrollTop?: number;
137+
restoreStateFrom?: { ranges: number[]; scrollTop: number };
130138
},
131139
ref: React.ForwardedRef<{ getState: (cb: (snapshot: { ranges: number[]; scrollTop: number }) => void) => void }>,
132140
) => {
133141
testState.virtuosoProps.push({ defaultItemHeight, heightEstimates, itemSize });
142+
testState.restoredVirtuosoStates.push({ initialScrollTop, restoreStateFrom });
134143

135144
React.useImperativeHandle(ref, () => ({
136-
getState: (cb) => cb({ ranges: [0], scrollTop: 0 }),
145+
getState: (cb) => {
146+
testState.getVirtuosoStateMock();
147+
cb(testState.virtuosoSnapshot);
148+
},
137149
}));
138150

139151
return createElement(
@@ -358,7 +370,10 @@ vi.mock('../../hooks/use-progressive-render', () => ({
358370
}));
359371

360372
vi.mock('../../hooks/use-fresh-replies', () => ({
361-
default: (replies: TestComment[]) => replies,
373+
default: (replies: TestComment[]) => {
374+
testState.freshReplyInputs.push(replies);
375+
return replies;
376+
},
362377
}));
363378

364379
vi.mock('../../hooks/use-reply-height-estimates', () => ({
@@ -378,7 +393,7 @@ vi.mock('../../lib/constants', () => ({
378393
vi.mock('../../lib/utils/replies-preview-utils', () => ({
379394
computeOmittedCount: () => 0,
380395
filterRepliesForDisplay: (replies: TestComment[]) => replies,
381-
getPreviewDisplayReplies: (replies: TestComment[]) => replies,
396+
getPreviewDisplayReplies: (replies: TestComment[]) => [...replies],
382397
getTotalReplyCount: ({ replyCount }: { replyCount?: number }) => replyCount ?? 0,
383398
hasEnoughPreviewReplies: ({ replyCount, loadedCount, visibleCount }: { replyCount?: number; loadedCount: number; visibleCount: number }) =>
384399
loadedCount >= Math.min(visibleCount, replyCount ?? visibleCount),
@@ -483,10 +498,13 @@ describe('post community address compatibility', () => {
483498
'music-posting.eth': { address: 'music-posting.eth', features: {} },
484499
};
485500
testState.hasMoreReplies = false;
501+
testState.freshReplyInputs = [];
486502
testState.pseudonymityMode = 'none';
487503
testState.replyComments = [];
488504
testState.stateString = undefined;
489505
testState.virtuosoProps = [];
506+
testState.virtuosoSnapshot = { ranges: [0], scrollTop: 0 };
507+
testState.restoredVirtuosoStates = [];
490508

491509
container = document.createElement('div');
492510
document.body.appendChild(container);
@@ -733,6 +751,80 @@ describe('post community address compatibility', () => {
733751
});
734752
});
735753

754+
it.each([
755+
['desktop', PostDesktop],
756+
['mobile', PostMobile],
757+
] as const)('saves %s reply sizes on departure and restores them on back without snapshotting scroll ticks', async (mode, PostComponent) => {
758+
const post = { ...makeLegacyThread(), cid: `snapshot-${mode}` };
759+
const NavigationHarness = () => {
760+
const location = useLocation();
761+
const navigate = useNavigate();
762+
return createElement(
763+
React.Fragment,
764+
{},
765+
createElement('button', { 'data-testid': 'leave-thread', onClick: () => navigate('/') }, 'home'),
766+
createElement('button', { 'data-testid': 'back-to-thread', onClick: () => navigate(-1) }, 'back'),
767+
location.pathname.includes('/thread/') ? createElement(PostComponent, { post, showAllReplies: true }) : null,
768+
);
769+
};
770+
771+
await renderWithRoute(createElement(NavigationHarness), `/mu/thread/${post.cid}`);
772+
expect(container.querySelector('[data-testid="virtuoso"]')).toBeNull();
773+
774+
// Replies may become virtualized only after loading their first page.
775+
testState.hasMoreReplies = true;
776+
await renderWithRoute(createElement(NavigationHarness), `/mu/thread/${post.cid}`);
777+
expect(container.querySelector('[data-testid="virtuoso"]')).toBeTruthy();
778+
779+
await act(async () => {
780+
for (let index = 0; index < 100; index += 1) window.dispatchEvent(new Event('scroll'));
781+
});
782+
expect(testState.getVirtuosoStateMock).not.toHaveBeenCalled();
783+
784+
// Saving after a resize captures the latest item sizes too.
785+
testState.virtuosoSnapshot = { ranges: [1, 4], scrollTop: 480 };
786+
await act(async () => {
787+
window.dispatchEvent(new Event('resize'));
788+
window.dispatchEvent(new Event('pagehide'));
789+
});
790+
expect(testState.getVirtuosoStateMock).toHaveBeenCalledTimes(1);
791+
792+
testState.virtuosoSnapshot = { ranges: [2, 5], scrollTop: 1024 };
793+
await act(async () => container.querySelector<HTMLButtonElement>('[data-testid="leave-thread"]')?.click());
794+
expect(testState.getVirtuosoStateMock).toHaveBeenCalledTimes(2);
795+
expect(container.querySelector('[data-testid="virtuoso"]')).toBeNull();
796+
797+
window.dispatchEvent(new Event('pagehide'));
798+
expect(testState.getVirtuosoStateMock).toHaveBeenCalledTimes(2);
799+
800+
await act(async () => container.querySelector<HTMLButtonElement>('[data-testid="back-to-thread"]')?.click());
801+
expect(testState.restoredVirtuosoStates.at(-1)).toEqual({
802+
initialScrollTop: 1024,
803+
restoreStateFrom: { ranges: [2, 5], scrollTop: 1024 },
804+
});
805+
});
806+
807+
it('preserves unchanged desktop preview inputs and updates them when replies change', async () => {
808+
const post = makeLegacyThread();
809+
const reply = post.replies!.pages!.new.comments![0];
810+
const replyPaginationOverride = { replies: [reply] };
811+
812+
await renderWithRoute(createElement(PostDesktop, { post, replyPaginationOverride }));
813+
const firstPreview = testState.freshReplyInputs.at(-1);
814+
expect(firstPreview).toEqual([reply]);
815+
testState.freshReplyInputs = [];
816+
817+
await renderWithRoute(createElement(PostDesktop, { post, replyPaginationOverride }));
818+
expect(testState.freshReplyInputs.length).toBeGreaterThan(0);
819+
expect(testState.freshReplyInputs.every((replies) => replies === firstPreview)).toBe(true);
820+
821+
const updatedReply = { ...reply, cid: 'updated-reply', content: 'Updated preview' };
822+
await renderWithRoute(createElement(PostDesktop, { post, replyPaginationOverride: { replies: [updatedReply] } }));
823+
expect(testState.freshReplyInputs.at(-1)).toEqual([updatedReply]);
824+
expect(testState.freshReplyInputs.at(-1)).not.toBe(firstPreview);
825+
expect(container.textContent).toContain('updated-reply');
826+
});
827+
736828
it('keeps board-card Pretext heights when preview replies are rendered', async () => {
737829
await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThread() }));
738830
expect(container.querySelector('.postDesktop')?.getAttribute('data-pretext-height')).toBeTruthy();

src/components/comment-media/__tests__/comment-media.test.tsx

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const testState = vi.hoisted(() => ({
1313
getHasThumbnailResult: true,
1414
gifFrameStatus: 'idle' as 'failed' | 'idle' | 'loading' | 'ready',
1515
gifFrameUrl: null as string | null,
16+
gifFrameRequests: vi.fn(),
1617
hostname: 'example.com',
1718
isMobile: false,
1819
unmuteExpandedVideoSound: false,
@@ -65,10 +66,13 @@ vi.mock('../../../stores/use-expanded-media-store', () => {
6566
});
6667

6768
vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({
68-
default: () => ({
69-
frameUrl: testState.gifFrameUrl,
70-
status: testState.gifFrameStatus,
71-
}),
69+
default: (url?: string) => {
70+
testState.gifFrameRequests(url);
71+
return {
72+
frameUrl: testState.gifFrameUrl,
73+
status: testState.gifFrameStatus,
74+
};
75+
},
7276
}));
7377

7478
vi.mock('../../../hooks/use-is-mobile', () => ({
@@ -285,6 +289,70 @@ describe('CommentMedia', () => {
285289
expect(container.querySelector('img[src="https://cdn.example.com/frame.png"]')).toBeTruthy();
286290
});
287291

292+
it('opens an inline GIF without fetching or mounting an unused still thumbnail', async () => {
293+
const url = 'https://cdn.example.com/inline.gif';
294+
await renderMedia({
295+
commentMediaInfo: { type: 'gif', url },
296+
disableToggle: true,
297+
setShowThumbnail: setShowThumbnailMock,
298+
showThumbnail: false,
299+
});
300+
301+
expect(testState.gifFrameRequests).toHaveBeenLastCalledWith(undefined);
302+
expect(container.querySelectorAll('img')).toHaveLength(1);
303+
expect(container.querySelector('img')?.getAttribute('src')).toBe(url);
304+
expect(container.querySelector('[aria-label="Open GIF"]')).toBeNull();
305+
});
306+
307+
it('opens an inline video with one media element and keeps normal collapse thumbnails available', async () => {
308+
const commentMediaInfo = { type: 'video', url: 'https://cdn.example.com/inline.mp4' };
309+
await renderMedia({ commentMediaInfo, disableToggle: true, setShowThumbnail: setShowThumbnailMock, showThumbnail: false });
310+
311+
expect(container.querySelectorAll('video')).toHaveLength(1);
312+
expect(container.querySelector('video')?.getAttribute('src')).toBe(commentMediaInfo.url);
313+
314+
await renderMedia({ commentMediaInfo, setShowThumbnail: setShowThumbnailMock, showThumbnail: true });
315+
const thumbnail = container.querySelector('video[aria-label="Video thumbnail"]');
316+
expect(thumbnail?.getAttribute('src')).toBe(`${commentMediaInfo.url}#t=0.001`);
317+
await renderMedia({ commentMediaInfo, setShowThumbnail: setShowThumbnailMock, showThumbnail: false });
318+
expect(container.querySelector('video[aria-label="Video thumbnail"]')).toBe(thumbnail);
319+
await renderMedia({ commentMediaInfo, setShowThumbnail: setShowThumbnailMock, showThumbnail: true });
320+
expect(container.querySelector('video[aria-label="Video thumbnail"]')).toBe(thumbnail);
321+
expect(container.querySelectorAll('video')).toHaveLength(1);
322+
});
323+
324+
it('does not load an unused YouTube thumbnail for an expanded inline embed', async () => {
325+
await renderMedia({
326+
commentMediaInfo: { type: 'iframe', url: 'https://youtu.be/inline', patternThumbnailUrl: 'https://img.youtube.com/vi/inline/maxresdefault.jpg' },
327+
disableToggle: true,
328+
setShowThumbnail: setShowThumbnailMock,
329+
showThumbnail: false,
330+
});
331+
332+
expect(container.querySelector('[data-testid="embed"]')).toBeTruthy();
333+
expect(container.querySelector('img')).toBeNull();
334+
});
335+
336+
it('keeps floating GIF thumbnails and audio preview players available', async () => {
337+
await renderMedia({
338+
commentMediaInfo: { type: 'gif', url: 'https://cdn.example.com/hover.gif' },
339+
disableToggle: true,
340+
isFloatingEmbed: true,
341+
setShowThumbnail: setShowThumbnailMock,
342+
showThumbnail: true,
343+
});
344+
expect(testState.gifFrameRequests).toHaveBeenLastCalledWith('https://cdn.example.com/hover.gif');
345+
expect(container.querySelector('[aria-label="Open GIF"]')).toBeTruthy();
346+
347+
await renderMedia({
348+
commentMediaInfo: { type: 'audio', url: 'https://cdn.example.com/audio.mp3' },
349+
disableToggle: true,
350+
setShowThumbnail: setShowThumbnailMock,
351+
showThumbnail: false,
352+
});
353+
expect(container.querySelector('audio[aria-label="Audio preview"]')).toBeTruthy();
354+
});
355+
288356
it('renders fallback embedded webpage links when there is no thumbnail', async () => {
289357
testState.canEmbed = true;
290358
testState.getHasThumbnailResult = false;

0 commit comments

Comments
 (0)