diff --git a/src/app.tsx b/src/app.tsx index ca0f580..4057ff4 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -36,7 +36,6 @@ import { getSelection, revokeObjectUrls, UploadedDataSource, - UploadLocationState, UploadSourceSpec, UrlSourceSpec, } from './datasource/load_data'; @@ -164,7 +163,7 @@ function getParamFromSearch(name: string, search: queryString.ParsedQuery) { * Retrieve arguments passed into the application through the URL and uploaded * data. */ -function getArguments(location: H.Location): Arguments { +function getArguments(location: H.Location): Arguments { const search = queryString.parse(location.search); const getParam = (name: string) => getParamFromSearch(name, search); @@ -204,8 +203,6 @@ function getArguments(location: H.Location): Arguments { sourceSpec = { source: DataSourceEnum.UPLOADED, hash, - gedcom: location.state && location.state.data, - images: location.state && location.state.images, }; } else if (url) { sourceSpec = { @@ -261,6 +258,8 @@ const googleDriveDataSource = new GoogleDriveDataSource(); export function App() { /** State of the application. */ const [state, setState] = useState(AppState.INITIAL); + /** Progress message shown during LOADING and initial chart render. */ + const [loadingStatus, setLoadingStatus] = useState('Loading…'); /** Loaded data. */ const [data, setData] = useState(); /** Selected individual. */ @@ -412,28 +411,32 @@ export function App() { ); const loadData = useCallback( - (newSourceSpec: DataSourceSpec, newSelection?: IndiInfo) => { + ( + newSourceSpec: DataSourceSpec, + newSelection?: IndiInfo, + onProgress?: (status: string) => void, + ) => { switch (newSourceSpec.source) { case DataSourceEnum.UPLOADED: - return uploadedDataSource.loadData({ - spec: newSourceSpec, - selection: newSelection, - }); + return uploadedDataSource.loadData( + {spec: newSourceSpec, selection: newSelection}, + onProgress, + ); case DataSourceEnum.GEDCOM_URL: - return gedcomUrlDataSource.loadData({ - spec: newSourceSpec, - selection: newSelection, - }); + return gedcomUrlDataSource.loadData( + {spec: newSourceSpec, selection: newSelection}, + onProgress, + ); case DataSourceEnum.WIKITREE: - return wikiTreeDataSource.loadData({ - spec: newSourceSpec, - selection: newSelection, - }); + return wikiTreeDataSource.loadData( + {spec: newSourceSpec, selection: newSelection}, + onProgress, + ); case DataSourceEnum.EMBEDDED: - return embeddedDataSource.loadData({ - spec: newSourceSpec, - selection: newSelection, - }); + return embeddedDataSource.loadData( + {spec: newSourceSpec, selection: newSelection}, + onProgress, + ); case DataSourceEnum.GOOGLE_DRIVE: if (!isGoogleDriveConfigured()) { throw new TopolaError( @@ -441,10 +444,13 @@ export function App() { 'Google Drive integration is not configured.', ); } - return googleDriveDataSource.loadData({ - spec: newSourceSpec as GoogleDriveSourceSpec, - selection: newSelection, - }); + return googleDriveDataSource.loadData( + { + spec: newSourceSpec as GoogleDriveSourceSpec, + selection: newSelection, + }, + onProgress, + ); } }, [wikiTreeDataSource], @@ -531,12 +537,23 @@ export function App() { setFreezeAnimation(args.freezeAnimation); setConfig(args.config); const currentFetchId = ++fetchIdRef.current; + setLoadingStatus('Loading…'); try { - const data = await loadData(args.sourceSpec, args.selection); + const data = await loadData( + args.sourceSpec, + args.selection, + (status) => { + if (isMountedRef.current) setLoadingStatus(status); + }, + ); if (!isMountedRef.current || fetchIdRef.current !== currentFetchId) { return; } - // Set state with data. + // Show "Rendering chart…" while the initial D3 layout runs (which + // happens in the Chart useEffect after SHOWING_CHART is set). + setLoadingStatus( + `Rendering chart (${data.chartData.indis.length.toLocaleString()} people)…`, + ); setData(data); updateChartWithConfig(args.config, data); setShowSidePanel(args.showSidePanel); @@ -740,6 +757,7 @@ export function App() { data={data.chartData} selection={selection} onSelection={onSelection} + onFirstRender={() => setLoadingStatus('')} /> ); } @@ -756,6 +774,7 @@ export function App() { hideSex={config.sex} placeDisplay={config.place} placeCount={config.placeCount} + onFirstRender={() => setLoadingStatus('')} /> ); } @@ -806,8 +825,31 @@ export function App() { } } + const progressPill = + loadingStatus && + (state === AppState.LOADING || state === AppState.SHOWING_CHART) ? ( +
+ {loadingStatus} +
+ ) : null; + return ( <> + {progressPill} void; } class ChartWrapper { @@ -365,6 +367,13 @@ class ChartWrapper { resetPosition: false, }, ) { + // Nothing changed — the SVG is already correct. Skip re-render. + // This prevents repeated full D3 layout passes (500ms+ each) that happen + // when React re-renders for unrelated state changes. + if (!args.initialRender && !args.resetPosition) { + return; + } + // Wait for animation to finish if animation is in progress. if (!args.initialRender && this.animating) { this.rerenderRequired = true; @@ -561,6 +570,12 @@ export function Chart(props: ChartProps) { initialRender: true, resetPosition: true, }); + // Clear the loading pill now that the chart SVG is in the DOM. + // This fires before the D3 fade-in animation completes (~400ms), which + // is intentional: the chart is visible and interactive at this point, + // and keeping the pill visible during the animation would create a + // confusing overlap. + props.onFirstRender?.(); } }); diff --git a/src/datasource/data_source.ts b/src/datasource/data_source.ts index beb8641..dd24fc1 100644 --- a/src/datasource/data_source.ts +++ b/src/datasource/data_source.ts @@ -28,5 +28,8 @@ export interface DataSource { data?: TopolaData, ): boolean; /** Loads data from the data source. */ - loadData(spec: SourceSelection): Promise; + loadData( + spec: SourceSelection, + onProgress?: (status: string) => void, + ): Promise; } diff --git a/src/datasource/embedded.ts b/src/datasource/embedded.ts index 37eca69..c93f2db 100644 --- a/src/datasource/embedded.ts +++ b/src/datasource/embedded.ts @@ -1,6 +1,7 @@ import {analyticsEvent} from '../util/analytics'; import {getSoftware, TopolaData} from '../util/gedcom_util'; import {DataSource, DataSourceEnum, SourceSelection} from './data_source'; +import {storeGedcom} from './gedcom_store'; import {loadGedcom} from './load_data'; /** @@ -45,6 +46,7 @@ export class EmbeddedDataSource implements DataSource { message: EmbeddedMessage, resolve: (value: TopolaData) => void, reject: (reason: unknown) => void, + onProgress?: (status: string) => void, ) { if (message.message === EmbeddedMessageType.PARENT_READY) { // Parent didn't receive the first 'ready' message, so we need to send it again. @@ -55,7 +57,9 @@ export class EmbeddedDataSource implements DataSource { return; } try { - const data = await loadGedcom('', gedcom); + const embeddedHash = 'embedded'; + storeGedcom(embeddedHash, gedcom, new Map()); + const data = await loadGedcom(embeddedHash, onProgress); const software = getSoftware(data.gedcom.head); analyticsEvent('embedded_file_loaded', { event_label: software, @@ -70,13 +74,24 @@ export class EmbeddedDataSource implements DataSource { async loadData( _source: SourceSelection, + onProgress?: (status: string) => void, ): Promise { // Notify the parent window that we are ready. return new Promise((resolve, reject) => { + // Remove the listener once the GEDCOM arrives (resolve) or an error + // occurs (reject) to prevent it from accumulating across loadData calls. + const wrappedResolve = (value: TopolaData) => { + window.removeEventListener('message', listener); + resolve(value); + }; + const wrappedReject = (reason: unknown) => { + window.removeEventListener('message', listener); + reject(reason); + }; + const listener = (event: MessageEvent) => + this.onMessage(event.data, wrappedResolve, wrappedReject, onProgress); window.parent.postMessage({message: EmbeddedMessageType.READY}, '*'); - window.addEventListener('message', (data) => - this.onMessage(data.data, resolve, reject), - ); + window.addEventListener('message', listener); }); } } diff --git a/src/datasource/gedcom_store.spec.ts b/src/datasource/gedcom_store.spec.ts new file mode 100644 index 0000000..9d5fcfe --- /dev/null +++ b/src/datasource/gedcom_store.spec.ts @@ -0,0 +1,43 @@ +import {beforeEach, describe, expect, it} from '@jest/globals'; +import {clearStoredGedcom, getStoredGedcom, storeGedcom} from './gedcom_store'; + +describe('gedcom_store', () => { + const images = new Map(); + + beforeEach(() => { + // Jest caches module instances, so module-level state persists across + // tests. Reset explicitly to keep tests independent of execution order. + clearStoredGedcom(); + }); + + it('returns undefined for an unknown hash', () => { + expect(getStoredGedcom('unknown')).toBeUndefined(); + }); + + it('stores and retrieves a GEDCOM by hash', () => { + storeGedcom('hash-a', '0 HEAD', images); + const result = getStoredGedcom('hash-a'); + expect(result?.gedcom).toBe('0 HEAD'); + expect(result?.images).toBe(images); + }); + + it('evicts the previous entry when a new one is stored', () => { + storeGedcom('hash-b', 'file b content', images); + storeGedcom('hash-c', 'file c content', images); + // Old hash is gone. + expect(getStoredGedcom('hash-b')).toBeUndefined(); + // New hash is retrievable. + expect(getStoredGedcom('hash-c')?.gedcom).toBe('file c content'); + }); + + it('returns undefined for a hash that was evicted', () => { + storeGedcom('hash-d', 'first', images); + storeGedcom('hash-e', 'second', images); + expect(getStoredGedcom('hash-d')).toBeUndefined(); + }); + + it('returns the same entry on repeated gets', () => { + storeGedcom('hash-f', 'data', images); + expect(getStoredGedcom('hash-f')).toBe(getStoredGedcom('hash-f')); + }); +}); diff --git a/src/datasource/gedcom_store.ts b/src/datasource/gedcom_store.ts new file mode 100644 index 0000000..bb9a7ce --- /dev/null +++ b/src/datasource/gedcom_store.ts @@ -0,0 +1,37 @@ +/** + * In-memory store for uploaded GEDCOM content, keyed by file fingerprint. + * + * Avoids passing the raw GEDCOM string through history.pushState (640KB limit + * in Firefox, ~512KB in Safari) while keeping the content available for + * within-tab navigation. + * + * Bounded to 1 entry: uploading a new file evicts the previous one. + * A typical session involves one active file; bounding prevents unbounded + * memory growth when users upload multiple large files in one session. + */ +let currentHash: string | null = null; +let currentEntry: {gedcom: string; images: Map} | null = null; + +export function storeGedcom( + hash: string, + gedcom: string, + images: Map, +): void { + currentHash = hash; + currentEntry = {gedcom, images}; +} + +export function getStoredGedcom( + hash: string, +): {gedcom: string; images: Map} | undefined { + return currentHash === hash && currentEntry ? currentEntry : undefined; +} + +/** + * Resets store state to empty. Use in test beforeEach to prevent module-level + * state from bleeding between test cases (Jest caches module instances). + */ +export function clearStoredGedcom(): void { + currentHash = null; + currentEntry = null; +} diff --git a/src/datasource/google_drive.ts b/src/datasource/google_drive.ts index be3a07e..e5dca47 100644 --- a/src/datasource/google_drive.ts +++ b/src/datasource/google_drive.ts @@ -35,6 +35,7 @@ export class GoogleDriveDataSource implements DataSource async loadData( source: SourceSelection, + onProgress?: (status: string) => void, ): Promise { const fileId = source.spec.fileId; const cacheKey = `${GOOGLE_DRIVE_CACHE_KEY_PREFIX}${fileId}`; @@ -88,6 +89,6 @@ export class GoogleDriveDataSource implements DataSource // 5. Parse and prepare the file content const blob = await response.blob(); - return loadAndPrepareFile(blob, cacheKey); + return loadAndPrepareFile(blob, cacheKey, onProgress); } } diff --git a/src/datasource/load_data.ts b/src/datasource/load_data.ts index eaebeb5..1efb31a 100644 --- a/src/datasource/load_data.ts +++ b/src/datasource/load_data.ts @@ -5,6 +5,7 @@ import {analyticsEvent} from '../util/analytics'; import {TopolaError} from '../util/error'; import {convertGedcom, getSoftware, TopolaData} from '../util/gedcom_util'; import {DataSource, DataSourceEnum, SourceSelection} from './data_source'; +import {getStoredGedcom} from './gedcom_store'; /** * Returns a valid IndiInfo object, either with the given indi and generation @@ -23,20 +24,29 @@ export function getSelection( return {id, generation: selection?.generation || 0}; } -function prepareData( +// sessionStorage limit is ~5MB in all browsers. The serialized output for +// typical files includes both chartData (~7.85MB for 24K individuals) and the +// raw gedcom entry tree (~25MB), so attempting JSON.stringify for large files +// wastes 10-20s in Safari and always fails. Skip caching above this threshold. +const SESSION_CACHE_GEDCOM_LIMIT = 512 * 1024; // 512KB raw GEDCOM → safe to try + +async function prepareData( gedcom: string, cacheId: string, images?: Map, -): TopolaData { - const data = convertGedcom(gedcom, images || new Map()); + onProgress?: (status: string) => void, +): Promise { + const data = await convertGedcom(gedcom, images || new Map(), onProgress); if (!images || images.size === 0) { - const dataToSerialize = {...data}; - delete dataToSerialize.images; - const serializedData = JSON.stringify(dataToSerialize); - try { - sessionStorage.setItem(cacheId, serializedData); - } catch (e) { - console.warn('Failed to store data in session storage: ' + e); + if (gedcom.length <= SESSION_CACHE_GEDCOM_LIMIT) { + const dataToSerialize = {...data}; + delete dataToSerialize.images; + const serializedData = JSON.stringify(dataToSerialize); + try { + sessionStorage.setItem(cacheId, serializedData); + } catch (e) { + console.warn('Failed to store data in session storage: ' + e); + } } } return data; @@ -121,10 +131,11 @@ export async function loadFile( export async function loadAndPrepareFile( blob: Blob, cacheId: string, + onProgress?: (status: string) => void, ): Promise { const {gedcom, images} = await loadFile(blob); try { - return prepareData(gedcom, cacheId, images); + return await prepareData(gedcom, cacheId, images, onProgress); } catch (error) { revokeObjectUrls(images); throw error; @@ -135,6 +146,7 @@ export async function loadAndPrepareFile( export async function loadFromUrl( url: string, handleCors: boolean, + onProgress?: (status: string) => void, ): Promise { try { const cachedData = sessionStorage.getItem(url); @@ -165,14 +177,13 @@ export async function loadFromUrl( throw new Error(response.statusText); } - return loadAndPrepareFile(await response.blob(), url); + return loadAndPrepareFile(await response.blob(), url, onProgress); } -/** Loads data from the given GEDCOM file contents. */ +/** Loads GEDCOM data from the cache or the in-memory store by its hash. */ export async function loadGedcom( hash: string, - gedcom?: string, - images?: Map, + onProgress?: (status: string) => void, ): Promise { try { const cachedData = sessionStorage.getItem(hash); @@ -182,36 +193,31 @@ export async function loadGedcom( } catch (e) { console.warn('Failed to load data from session storage: ' + e); } - if (!gedcom) { + // Retrieve from the in-memory store (survives within-tab navigation even + // when the GEDCOM is too large for history.pushState or sessionStorage). + const stored = getStoredGedcom(hash); + if (!stored) { throw new TopolaError( 'ERROR_LOADING_UPLOADED_FILE', 'Error loading data. Please upload your file again.', ); } try { - return prepareData(gedcom, hash, images); + return await prepareData(stored.gedcom, hash, stored.images, onProgress); } catch (error) { - revokeObjectUrls(images); + revokeObjectUrls(stored.images); throw error; } } export interface UploadSourceSpec { source: DataSourceEnum.UPLOADED; - gedcom?: string; - /** Hash of the GEDCOM contents. */ + /** Fingerprint of the uploaded file, used as cache key and store lookup. */ hash: string; - images?: Map; -} - -export interface UploadLocationState { - data: string; - images: Map; } /** Files opened from the local computer. */ export class UploadedDataSource implements DataSource { - // isNewData(args: Arguments, state: State): boolean { isNewData( newSource: SourceSelection, oldSource: SourceSelection, @@ -222,18 +228,12 @@ export class UploadedDataSource implements DataSource { async loadData( source: SourceSelection, + onProgress?: (status: string) => void, ): Promise { try { - const data = await loadGedcom( - source.spec.hash, - source.spec.gedcom, - source.spec.images, - ); + const data = await loadGedcom(source.spec.hash, onProgress); const software = getSoftware(data.gedcom.head); - analyticsEvent('upload_file_loaded', { - event_label: software, - event_value: (source.spec.images && source.spec.images.size) || 0, - }); + analyticsEvent('upload_file_loaded', {event_label: software}); return data; } catch (error) { analyticsEvent('upload_file_error'); @@ -259,9 +259,16 @@ export class GedcomUrlDataSource implements DataSource { return newSource.spec.url !== oldSource.spec.url; } - async loadData(source: SourceSelection): Promise { + async loadData( + source: SourceSelection, + onProgress?: (status: string) => void, + ): Promise { try { - const data = await loadFromUrl(source.spec.url, source.spec.handleCors); + const data = await loadFromUrl( + source.spec.url, + source.spec.handleCors, + onProgress, + ); const software = getSoftware(data.gedcom.head); analyticsEvent('upload_file_loaded', {event_label: software}); return data; diff --git a/src/datasource/load_data_store.spec.ts b/src/datasource/load_data_store.spec.ts new file mode 100644 index 0000000..45eee03 --- /dev/null +++ b/src/datasource/load_data_store.spec.ts @@ -0,0 +1,45 @@ +import {describe, expect, it} from '@jest/globals'; +import {storeGedcom} from './gedcom_store'; +import {loadGedcom} from './load_data'; + +// Minimal GEDCOM with 1 individual and 1 family so convertGedcom succeeds. +const MINIMAL_GEDCOM = [ + '0 HEAD', + '1 CHAR UTF-8', + '0 @I1@ INDI', + '1 NAME Test /Person/', + '1 SEX M', + '1 FAMS @F1@', + '0 @F1@ FAM', + '1 HUSB @I1@', + '0 TRLR', +].join('\n'); + +describe('loadGedcom()', () => { + it('throws ERROR_LOADING_UPLOADED_FILE when hash is not in store', async () => { + await expect(loadGedcom('nonexistent-hash')).rejects.toMatchObject({ + code: 'ERROR_LOADING_UPLOADED_FILE', + }); + }); + + it('loads GEDCOM from the in-memory store', async () => { + const hash = 'test-store-hash'; + storeGedcom(hash, MINIMAL_GEDCOM, new Map()); + const data = await loadGedcom(hash); + expect(data.chartData.indis).toHaveLength(1); + expect(data.chartData.fams).toHaveLength(1); + }); + + it('calls onProgress at each parse step', async () => { + const hash = 'test-progress-hash'; + storeGedcom(hash, MINIMAL_GEDCOM, new Map()); + const steps: string[] = []; + await loadGedcom(hash, (status) => steps.push(status)); + expect(steps).toEqual([ + 'Step 1/4: parsing GEDCOM…', + 'Step 2/4: building family graph…', + 'Step 3/4: sorting & normalizing…', + 'Step 4/4: indexing records…', + ]); + }); +}); diff --git a/src/datasource/wikitree.ts b/src/datasource/wikitree.ts index 809f9f2..d52574c 100644 --- a/src/datasource/wikitree.ts +++ b/src/datasource/wikitree.ts @@ -87,6 +87,7 @@ export class WikiTreeDataSource implements DataSource { async loadData( source: SourceSelection, + _onProgress?: (status: string) => void, ): Promise { if (!source.selection) { throw new TopolaError( diff --git a/src/donatso-chart.tsx b/src/donatso-chart.tsx index 7885f05..4ebc886 100644 --- a/src/donatso-chart.tsx +++ b/src/donatso-chart.tsx @@ -17,6 +17,8 @@ export interface DonatsoChartProps { data: JsonGedcomData; selection: IndiInfo; onSelection: (indiInfo: IndiInfo) => void; + /** Called once after the initial chart render completes. */ + onFirstRender?: () => void; } function getOtherSpouse(fam: JsonFam, indi: string) { @@ -114,6 +116,7 @@ export function DonatsoChart(props: DonatsoChartProps) { useEffect(() => { if (!prevProps) { chartWrapper.current.initializeChart(props, intl); + props.onFirstRender?.(); } else { chartWrapper.current.updateChart(props, intl); } diff --git a/src/menu/search.tsx b/src/menu/search.tsx index 8f7299a..077d564 100644 --- a/src/menu/search.tsx +++ b/src/menu/search.tsx @@ -51,12 +51,20 @@ export function SearchBar(props: Props) { } as SearchResultProps; } + /** Returns the index, building it on first use to avoid blocking the initial render. */ + function getOrBuildIndex(): SearchIndex { + if (!searchIndex.current) { + searchIndex.current = buildSearchIndex(props.data); + } + return searchIndex.current; + } + /** On search input change. */ function handleSearch(input: string | undefined) { - if (!input || !searchIndex.current) { + if (!input) { return; } - const results = searchIndex.current + const results = getOrBuildIndex() .search(input) .map((result) => displaySearchResult(result)); setSearchResults(results); @@ -76,9 +84,20 @@ export function SearchBar(props: Props) { setSearchString(value || ''); } - // Initialize the search index. + // When data changes, reset the index and schedule a background rebuild so + // the first keystroke doesn't block the UI. Falls back to a 200ms timeout + // if requestIdleCallback is unavailable (e.g. Firefox 115, Safari 16). useEffect(() => { - searchIndex.current = buildSearchIndex(props.data); + searchIndex.current = undefined; + const build = () => { + searchIndex.current = buildSearchIndex(props.data); + }; + if (typeof requestIdleCallback !== 'undefined') { + const handle = requestIdleCallback(build, {timeout: 5000}); + return () => cancelIdleCallback(handle); + } + const handle = setTimeout(build, 200); + return () => clearTimeout(handle); }, [props.data]); return ( diff --git a/src/menu/upload_menu.tsx b/src/menu/upload_menu.tsx index 41e552e..bab7b32 100644 --- a/src/menu/upload_menu.tsx +++ b/src/menu/upload_menu.tsx @@ -1,11 +1,12 @@ -import md5 from 'md5'; import queryString from 'query-string'; import {SyntheticEvent} from 'react'; import {FormattedMessage} from 'react-intl'; import {useLocation, useNavigate} from 'react-router'; import {Dropdown, Icon, Menu} from 'semantic-ui-react'; +import {storeGedcom} from '../datasource/gedcom_store'; import {loadFile} from '../datasource/load_data'; import {analyticsEvent} from '../util/analytics'; +import {fileFingerprint} from '../util/file_fingerprint'; import {isImageFile} from '../util/gedcom_util'; import {MenuType} from './menu_item'; @@ -51,9 +52,16 @@ export function UploadMenu(props: Props) { images.set(file.name.toLowerCase(), URL.createObjectURL(file)), ); - // Hash GEDCOM contents with uploaded image file names. + // Fingerprint the file for cache keying. A content sample + metadata is + // fast (~1ms vs ~500ms for md5 over the full 10MB file) and collision- + // resistant enough for a local session cache. const imageFileNames = Array.from(images.keys()).sort().join('|'); - const hash = md5(md5(gedcom) + imageFileNames); + const hash = fileFingerprint(gedcomFile, gedcom, imageFileNames); + + // Keep GEDCOM in memory instead of history.pushState — browsers cap + // history state at 640KB (Firefox) or 512KB (Safari), well under the + // typical 10MB+ GEDCOM file size. + storeGedcom(hash, gedcom, images); // Use history.replace() when reuploading the same file and history.push() when loading // a new file. @@ -65,10 +73,7 @@ export function UploadMenu(props: Props) { pathname: '/view', search: queryString.stringify({file: hash}), }, - { - replace, - state: {data: gedcom, images}, - }, + {replace}, ); } diff --git a/src/util/file_fingerprint.spec.ts b/src/util/file_fingerprint.spec.ts new file mode 100644 index 0000000..f06e5d1 --- /dev/null +++ b/src/util/file_fingerprint.spec.ts @@ -0,0 +1,52 @@ +import {describe, expect, it} from '@jest/globals'; +import {fileFingerprint} from './file_fingerprint'; + +const makeFile = ( + name: string, + size: number, + lastModified: number, +): Pick => ({name, size, lastModified}); + +describe('fileFingerprint()', () => { + const file = makeFile('family.ged', 10_000, 1_700_000_000_000); + + it('returns a non-empty string', () => { + expect(fileFingerprint(file, '0 HEAD\n0 TRLR', '')).toMatch( + /^[a-f0-9]{32}$/, + ); + }); + + it('produces the same hash for the same inputs', () => { + const a = fileFingerprint(file, '0 HEAD\n0 TRLR', ''); + const b = fileFingerprint(file, '0 HEAD\n0 TRLR', ''); + expect(a).toBe(b); + }); + + it('differs when file name changes', () => { + const other = makeFile('other.ged', 10_000, 1_700_000_000_000); + expect(fileFingerprint(file, '0 HEAD\n0 TRLR', '')).not.toBe( + fileFingerprint(other, '0 HEAD\n0 TRLR', ''), + ); + }); + + it('differs when file size changes', () => { + const bigger = makeFile('family.ged', 20_000, 1_700_000_000_000); + expect(fileFingerprint(file, '0 HEAD\n0 TRLR', '')).not.toBe( + fileFingerprint(bigger, '0 HEAD\n0 TRLR', ''), + ); + }); + + it('differs when content differs beyond the 4KB sample boundary', () => { + const base = '0 HEAD\n' + 'X'.repeat(8192) + '\n0 TRLR'; + const diff = '0 HEAD\n' + 'Y'.repeat(8192) + '\n0 TRLR'; + expect(fileFingerprint(file, base, '')).not.toBe( + fileFingerprint(file, diff, ''), + ); + }); + + it('differs when image file names change', () => { + const a = fileFingerprint(file, '0 HEAD\n0 TRLR', 'photo.jpg'); + const b = fileFingerprint(file, '0 HEAD\n0 TRLR', ''); + expect(a).not.toBe(b); + }); +}); diff --git a/src/util/file_fingerprint.ts b/src/util/file_fingerprint.ts new file mode 100644 index 0000000..c755eae --- /dev/null +++ b/src/util/file_fingerprint.ts @@ -0,0 +1,19 @@ +import md5 from 'md5'; + +/** + * Returns a cache key for an uploaded file. Uses file metadata + a content + * sample instead of md5-ing the full file (~500ms for 10MB). + * + * 4KB from each end captures the unique GEDCOM header (software, export date, + * submitter) without the cost of hashing megabytes. + */ +export function fileFingerprint( + file: Pick, + gedcom: string, + imageFileNames: string, +): string { + const sample = gedcom.slice(0, 4096) + gedcom.slice(-4096); + return md5( + `${file.name}|${file.size}|${file.lastModified}|${sample}|${imageFileNames}`, + ); +} diff --git a/src/util/gedcom_util.ts b/src/util/gedcom_util.ts index 436527d..5abbe24 100644 --- a/src/util/gedcom_util.ts +++ b/src/util/gedcom_util.ts @@ -278,12 +278,31 @@ function filterImages( * @param images Map from file name to image URL. This is used to pass in * uploaded images. */ -export function convertGedcom( +/** + * Yields to the browser event loop, allowing incremental GC and UI updates. + * Calls onProgress if provided; does not touch the DOM directly. + */ +function yieldToEventLoop( + onProgress?: (status: string) => void, + status?: string, +): Promise { + if (status) onProgress?.(status); + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +export async function convertGedcom( gedcom: string, images: Map, -): TopolaData { + onProgress?: (status: string) => void, +): Promise { + await yieldToEventLoop(onProgress, 'Step 1/4: parsing GEDCOM…'); + const entries = parseGedcom(gedcom); + + await yieldToEventLoop(onProgress, 'Step 2/4: building family graph…'); + const json = gedcomEntriesToJson(entries); + if ( !json || !json.indis || @@ -294,9 +313,17 @@ export function convertGedcom( throw new TopolaError('GEDCOM_READ_FAILED', 'Failed to read GEDCOM file'); } + await yieldToEventLoop(onProgress, 'Step 3/4: sorting & normalizing…'); + + const chartData = filterImages(normalizeGedcom(json), images); + + await yieldToEventLoop(onProgress, 'Step 4/4: indexing records…'); + + const gedcomData = prepareGedcom(entries); + return { - chartData: filterImages(normalizeGedcom(json), images), - gedcom: prepareGedcom(entries), + chartData, + gedcom: gedcomData, images, }; } diff --git a/tests/charts_visual.spec.ts-snapshots/chart-donatso-visual-darwin.png b/tests/charts_visual.spec.ts-snapshots/chart-donatso-visual-darwin.png new file mode 100644 index 0000000..edff05d Binary files /dev/null and b/tests/charts_visual.spec.ts-snapshots/chart-donatso-visual-darwin.png differ diff --git a/tests/charts_visual.spec.ts-snapshots/chart-fancy-visual-darwin.png b/tests/charts_visual.spec.ts-snapshots/chart-fancy-visual-darwin.png new file mode 100644 index 0000000..7993209 Binary files /dev/null and b/tests/charts_visual.spec.ts-snapshots/chart-fancy-visual-darwin.png differ diff --git a/tests/charts_visual.spec.ts-snapshots/chart-hourglass-visual-darwin.png b/tests/charts_visual.spec.ts-snapshots/chart-hourglass-visual-darwin.png new file mode 100644 index 0000000..bd8b485 Binary files /dev/null and b/tests/charts_visual.spec.ts-snapshots/chart-hourglass-visual-darwin.png differ diff --git a/tests/charts_visual.spec.ts-snapshots/chart-relatives-visual-darwin.png b/tests/charts_visual.spec.ts-snapshots/chart-relatives-visual-darwin.png new file mode 100644 index 0000000..2185a20 Binary files /dev/null and b/tests/charts_visual.spec.ts-snapshots/chart-relatives-visual-darwin.png differ diff --git a/tests/config_visual.spec.ts-snapshots/config-state-default-visual-darwin.png b/tests/config_visual.spec.ts-snapshots/config-state-default-visual-darwin.png new file mode 100644 index 0000000..97a8a16 Binary files /dev/null and b/tests/config_visual.spec.ts-snapshots/config-state-default-visual-darwin.png differ diff --git a/tests/config_visual.spec.ts-snapshots/config-state-gender-no-ids-visual-darwin.png b/tests/config_visual.spec.ts-snapshots/config-state-gender-no-ids-visual-darwin.png new file mode 100644 index 0000000..91b50cf Binary files /dev/null and b/tests/config_visual.spec.ts-snapshots/config-state-gender-no-ids-visual-darwin.png differ diff --git a/tests/config_visual.spec.ts-snapshots/config-state-minimalist-visual-darwin.png b/tests/config_visual.spec.ts-snapshots/config-state-minimalist-visual-darwin.png new file mode 100644 index 0000000..6026c52 Binary files /dev/null and b/tests/config_visual.spec.ts-snapshots/config-state-minimalist-visual-darwin.png differ diff --git a/tests/details_visual.spec.ts-snapshots/details-complex-name-visual-darwin.png b/tests/details_visual.spec.ts-snapshots/details-complex-name-visual-darwin.png new file mode 100644 index 0000000..22ed786 Binary files /dev/null and b/tests/details_visual.spec.ts-snapshots/details-complex-name-visual-darwin.png differ diff --git a/tests/details_visual.spec.ts-snapshots/details-events-sources-visual-darwin.png b/tests/details_visual.spec.ts-snapshots/details-events-sources-visual-darwin.png new file mode 100644 index 0000000..d17b4b4 Binary files /dev/null and b/tests/details_visual.spec.ts-snapshots/details-events-sources-visual-darwin.png differ diff --git a/tests/details_visual.spec.ts-snapshots/details-immediate-family-visual-darwin.png b/tests/details_visual.spec.ts-snapshots/details-immediate-family-visual-darwin.png new file mode 100644 index 0000000..0d0200b Binary files /dev/null and b/tests/details_visual.spec.ts-snapshots/details-immediate-family-visual-darwin.png differ diff --git a/tests/details_visual.spec.ts-snapshots/details-media-fallback-visual-darwin.png b/tests/details_visual.spec.ts-snapshots/details-media-fallback-visual-darwin.png new file mode 100644 index 0000000..354d2dd Binary files /dev/null and b/tests/details_visual.spec.ts-snapshots/details-media-fallback-visual-darwin.png differ diff --git a/tests/details_visual.spec.ts-snapshots/details-photo-render-visual-darwin.png b/tests/details_visual.spec.ts-snapshots/details-photo-render-visual-darwin.png new file mode 100644 index 0000000..0270750 Binary files /dev/null and b/tests/details_visual.spec.ts-snapshots/details-photo-render-visual-darwin.png differ diff --git a/tests/intro_visual.spec.ts-snapshots/intro-page-visual-darwin.png b/tests/intro_visual.spec.ts-snapshots/intro-page-visual-darwin.png new file mode 100644 index 0000000..cd34c70 Binary files /dev/null and b/tests/intro_visual.spec.ts-snapshots/intro-page-visual-darwin.png differ