feat(rules page): rebuild directory rules page with vendored lists (#1147)

* feat(rules page): rebuild directory rules page with vendored lists

Mirror per-directory JSON from lists into src/data/5chan-directories, rework /rules layout to match 4chan (sidebar nav, category boxes, P2P load), and keep spoiler markup visible in rule text via parseSpoilers.

* fix(rules): keep directory defaults cache atomic

* fix(rules): derive defaults from shared directory refresh

* fix(rules): address directory refresh edge cases
This commit is contained in:
Tommaso Casaburi
2026-05-31 11:39:40 +07:00
committed by GitHub
parent e4638ac8c7
commit b442e05191
75 changed files with 2853 additions and 1333 deletions
+68 -1
View File
@@ -9,15 +9,18 @@ import {
useDirectories,
useDirectoriesMetadata,
useDirectoriesState,
useDirectoryDefaults,
useDirectoryAddresses,
useDirectoryByAddress,
type DirectoriesData,
type DirectoryDefaultsData,
} from '../use-directories';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const LOCALSTORAGE_KEY = '5chan-directories-cache';
const LOCALSTORAGE_DEFAULTS_KEY = '5chan-directory-defaults-cache';
const LOCALSTORAGE_TIMESTAMP_KEY = '5chan-directories-cache-timestamp';
type Snapshot = {
@@ -25,6 +28,7 @@ type Snapshot = {
state: ReturnType<typeof useDirectoriesState>;
addresses: ReturnType<typeof useDirectoryAddresses>;
directory: ReturnType<typeof useDirectoryByAddress>;
directoryDefaults: DirectoryDefaultsData;
metadata: ReturnType<typeof useDirectoriesMetadata>;
};
@@ -47,6 +51,7 @@ const HookHarness = ({ address = 'music-posting.eth' }: { address?: string }) =>
const state = useDirectoriesState();
const addresses = useDirectoryAddresses();
const directory = useDirectoryByAddress(address);
const directoryDefaults = useDirectoryDefaults();
const metadata = useDirectoriesMetadata();
React.useLayoutEffect(() => {
@@ -55,9 +60,10 @@ const HookHarness = ({ address = 'music-posting.eth' }: { address?: string }) =>
state,
addresses,
directory,
directoryDefaults,
metadata,
};
}, [addresses, directory, directories, metadata, state]);
}, [addresses, directory, directories, directoryDefaults, metadata, state]);
return null;
};
@@ -273,6 +279,9 @@ describe('use-directories', () => {
const persisted = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY) ?? '{}');
expect(persisted.title).toBe('5chan directories');
expect(persisted.communities.length).toBeGreaterThan(1);
const persistedDefaults = JSON.parse(localStorage.getItem(LOCALSTORAGE_DEFAULTS_KEY) ?? '{}');
expect(persistedDefaults.description).toBe('remote defaults');
});
it('does not refetch GitHub directories for each later hook mount after a successful refresh', async () => {
@@ -342,6 +351,64 @@ describe('use-directories', () => {
expect(warnSpy.mock.calls.some((call: ConsoleWarnCall) => String(call[0]).includes('Failed to fetch directory list "biz"'))).toBe(true);
});
it('does not expose refreshed defaults before the matching directory payload commits', async () => {
const pendingDefaults = createDeferred<ReturnType<typeof createFetchResponse>>();
const pendingFetches = new Map<string, Deferred<ReturnType<typeof createFetchResponse>>>();
fetchMock.mockImplementation((url: unknown) => {
if (isDefaultsUrl(url)) {
return pendingDefaults.promise;
}
const code = getDirectoryCodeFromUrl(url);
const deferred = createDeferred<ReturnType<typeof createFetchResponse>>();
pendingFetches.set(code, deferred);
return deferred.promise;
});
renderHarness();
await flushEffects();
pendingDefaults.resolve(createFetchResponse(createRemoteDefaults()));
await flushEffects();
renderHarness('business-and-finance.bso');
await flushEffects();
expect(latestSnapshot?.directoryDefaults.description).not.toBe('remote defaults');
pendingFetches.forEach((pendingFetch, code) => {
pendingFetch.resolve(createFetchResponse(createRemoteDirectoryList(code)));
});
await flushEffects(8);
expect(latestSnapshot?.directoryDefaults.description).toBe('remote defaults');
});
it('hydrates cached directory defaults with cached communities when GitHub refresh fails', async () => {
const cachedDefaults = createRemoteDefaults(REMOTE_DIRECTORY_CODES, {
mu: { title: '/mu/ - Cached Remote Music' },
});
const cachedData: DirectoriesData = {
title: 'Cached directories',
description: 'cached description',
createdAt: 1,
updatedAt: 2,
communities: [{ address: 'music-posting.bso', title: '/mu/ - Cached Music', directoryCode: 'mu', nsfw: false }],
};
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(cachedData));
localStorage.setItem(LOCALSTORAGE_DEFAULTS_KEY, JSON.stringify(cachedDefaults));
localStorage.setItem(LOCALSTORAGE_TIMESTAMP_KEY, String(Date.now()));
fetchMock.mockRejectedValueOnce(new Error('network down'));
renderHarness('music-posting.bso');
await flushEffects(8);
expect(latestSnapshot?.directories.map((community) => community.address)).toEqual(['music-posting.bso']);
expect(latestSnapshot?.directoryDefaults.description).toBe('remote defaults');
expect(latestSnapshot?.directoryDefaults.directories.mu.title).toBe('/mu/ - Cached Remote Music');
expect(warnSpy.mock.calls.some((call: ConsoleWarnCall) => String(call[0]).includes('Failed to fetch directories'))).toBe(true);
});
it('clears invalid recent cache entries and falls back to vendored data when GitHub refresh fails', async () => {
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify({ title: 'broken cache' }));
localStorage.setItem(LOCALSTORAGE_TIMESTAMP_KEY, String(Date.now()));
+47 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import directoryListsData from '../data/5chan-directory-lists.json';
import { vendoredDirectoryLists as directoryListsData, vendoredDirectoryDefaults as directoryDefaultsData } from '../data/vendored-directory-lists';
import {
directoryListToCommunity,
isRecord,
@@ -15,7 +15,7 @@ import {
} from '../lib/utils/directory-list-utils';
import { normalizeBoardAddress } from '../lib/utils/directory-list-lookup-utils';
export type { DirectoriesData, DirectoryCommunity } from '../lib/utils/directory-list-utils';
export type { DirectoriesData, DirectoryCommunity, DirectoryDefaultsData } from '../lib/utils/directory-list-utils';
export { normalizeBoardAddress };
interface DirectoriesMetadata {
@@ -34,6 +34,7 @@ interface DirectoriesState {
const GITHUB_URL_TEMPLATE = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-directories/5chan-{code}-directory.json';
const GITHUB_DEFAULTS_URL = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-directories/5chan-directories-defaults.json';
const LOCALSTORAGE_KEY = '5chan-directories-cache';
const LOCALSTORAGE_DEFAULTS_KEY = '5chan-directory-defaults-cache';
const LOCALSTORAGE_TIMESTAMP_KEY = '5chan-directories-cache-timestamp';
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
const FETCH_RETRY_DELAY_MS = 60 * 1000; // 1 minute
@@ -44,6 +45,7 @@ let cacheMetadata: DirectoriesMetadata | null = null;
let inFlightGitHubFetch: Promise<DirectoriesData> | null = null;
let lastSuccessfulGitHubFetchAt: number | null = null;
let lastGitHubFetchAttemptAt: number | null = null;
let cacheDefaults: DirectoryDefaultsData | null = null;
// Exposed for deterministic unit tests around module-level cache state.
export const __resetDirectoriesModuleStateForTests = () => {
cacheCommunities = null;
@@ -51,7 +53,9 @@ export const __resetDirectoriesModuleStateForTests = () => {
inFlightGitHubFetch = null;
lastSuccessfulGitHubFetchAt = null;
lastGitHubFetchAttemptAt = null;
cacheDefaults = null;
fallbackDirectoriesData = null;
fallbackDirectoryDefaults = null;
};
const getDirectoryIdentifiers = (community: DirectoryCommunity): string[] => [
@@ -212,6 +216,13 @@ const normalizeDirectoriesData = (value: unknown): DirectoriesData | null => {
};
let fallbackDirectoriesData: DirectoriesData | null = null;
let fallbackDirectoryDefaults: DirectoryDefaultsData | null = null;
export const getFallbackDirectoryDefaults = (): DirectoryDefaultsData => {
if (fallbackDirectoryDefaults) return fallbackDirectoryDefaults;
fallbackDirectoryDefaults = normalizeDirectoryDefaultsData(directoryDefaultsData as unknown);
return fallbackDirectoryDefaults;
};
export const getFallbackDirectoriesData = (): DirectoriesData => {
if (fallbackDirectoriesData) return fallbackDirectoriesData;
@@ -224,6 +235,26 @@ export const getFallbackDirectoriesData = (): DirectoriesData => {
return fallbackDirectoriesData;
};
const getDirectoryDefaultsFromLocalStorage = (): DirectoryDefaultsData | null => {
try {
const cached = localStorage.getItem(LOCALSTORAGE_DEFAULTS_KEY);
if (!cached) {
return null;
}
const normalized = normalizeDirectoryDefaultsData(JSON.parse(cached));
if (Object.keys(normalized.directories).length > 0) {
return normalized;
}
console.warn('Invalid directory defaults cache format, clearing stale cache');
localStorage.removeItem(LOCALSTORAGE_DEFAULTS_KEY);
} catch (e) {
console.warn('Failed to read directory defaults from localStorage:', e);
localStorage.removeItem(LOCALSTORAGE_DEFAULTS_KEY);
}
return null;
};
const getFromLocalStorage = (): DirectoriesData | null => {
try {
const cached = localStorage.getItem(LOCALSTORAGE_KEY);
@@ -234,10 +265,12 @@ const getFromLocalStorage = (): DirectoriesData | null => {
const parsed = JSON.parse(cached);
const normalized = normalizeDirectoriesData(parsed);
if (normalized) {
cacheDefaults ??= getDirectoryDefaultsFromLocalStorage();
return normalized;
}
console.warn('Invalid directories cache format, clearing stale cache');
localStorage.removeItem(LOCALSTORAGE_KEY);
localStorage.removeItem(LOCALSTORAGE_DEFAULTS_KEY);
localStorage.removeItem(LOCALSTORAGE_TIMESTAMP_KEY);
}
}
@@ -247,9 +280,12 @@ const getFromLocalStorage = (): DirectoriesData | null => {
return null;
};
const saveToLocalStorage = (data: DirectoriesData) => {
const saveToLocalStorage = (data: DirectoriesData, defaults?: DirectoryDefaultsData) => {
try {
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(data));
if (defaults) {
localStorage.setItem(LOCALSTORAGE_DEFAULTS_KEY, JSON.stringify(defaults));
}
localStorage.setItem(LOCALSTORAGE_TIMESTAMP_KEY, Date.now().toString());
} catch (e) {
console.warn('Failed to save to localStorage:', e);
@@ -356,8 +392,9 @@ const fetchDirectoriesFromGitHub = async (): Promise<DirectoriesData> => {
throw new Error('Invalid directories payload');
}
hydrateModuleCaches(data);
cacheDefaults = defaults;
lastSuccessfulGitHubFetchAt = Date.now();
saveToLocalStorage(data);
saveToLocalStorage(data, defaults);
return data;
};
@@ -463,6 +500,12 @@ export const useDirectories = () => {
return cacheCommunities || state.communities || getFallbackDirectoriesData().communities;
};
export const useDirectoryDefaults = (): DirectoryDefaultsData => {
// Subscribe to the shared directory refresh so defaults update only when the matching directory payload commits.
useDirectories();
return cacheDefaults ?? getFallbackDirectoryDefaults();
};
export const useDirectoriesState = () => {
// Use vendored data as fallback to prevent theme flash on first load
const [state, setState] = useState<DirectoriesState>({