Fix large GEDCOM files failing to load or freezing the browser (#296)

* fix(upload): bypass history.pushState size limit with in-memory GEDCOM store

Firefox caps history.pushState state at 640KB; Safari at ~512KB. The upload
handler was passing the raw GEDCOM string through navigate(..., {state}),
which calls history.pushState. A 10MB GEDCOM caused an immediate SecurityError
in Firefox and Safari, preventing the chart from ever loading.

Add a bounded in-memory store (gedcom_store.ts) capped to 1 entry — uploading
a new file evicts the previous one, preventing unbounded memory growth during
a session. navigate() now carries only a file fingerprint hash in the URL;
loadGedcom() retrieves the GEDCOM from the store by hash.

Update embedded.ts to store its injected GEDCOM under the key 'embedded'
before calling loadGedcom(), keeping the data path consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(fingerprint): replace 500ms full-file md5 with fast metadata + 4KB sample hash

The upload handler called md5() over the full GEDCOM string before navigating
— ~240ms for a 10MB file, blocking the main thread before the user saw
any feedback.

Replace with a hash over file.name|file.size|file.lastModified plus the first
and last 4KB of content. Runtime drops to ~1ms. The 4KB sample captures the
GEDCOM header (software name, export date, submitter info) which differs
between any two real exports, providing strong collision resistance for a local
session cache without a cryptographic guarantee.

Extract fileFingerprint to src/util/file_fingerprint.ts so it can be unit
tested independently of the React component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(parse): async GEDCOM conversion with event-loop yields and onProgress callback

convertGedcom was synchronous, blocking the main thread for the full parse
duration (~1.2s parseGedcom + ~700ms gedcomEntriesToJson in Safari). The
browser spinner could not update and GC could not run between steps.

Make convertGedcom async and insert await setTimeout(0) yields between the
four major steps (parse, convert, normalize, index). This breaks the work
into chunks, allows incremental GC, and keeps the spinner responsive.

Add onProgress?(status: string) => void to convertGedcom, prepareData,
loadGedcom, loadFromUrl, loadAndPrepareFile, and the DataSource interface.
All data source implementations (UploadedDataSource, GedcomUrlDataSource,
GoogleDriveDataSource) forward the callback down the load pipeline.
WikiTree and Embedded accept the parameter but do not use it.

Also skip the sessionStorage JSON.stringify for GEDCOM files over 512KB.
The serialized TopolaData (chartData 7.85MB + raw parse-gedcom tree 25MB
= 33MB) always exceeded the 5MB sessionStorage limit, wasting 10-20s in
Safari on a write that was guaranteed to fail.

Simplify loadGedcom to (hash, onProgress?) now that the GEDCOM string is
always retrieved from the in-memory store rather than passed as a parameter.
Remove console.log timing instrumentation and window.__topolaDebug globals
that were left in from debugging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ui): skip redundant chart re-renders; persist progress overlay through D3 layout

Two related issues caused the chart to freeze or appear unresponsive:

1. The Chart useEffect has no dependency array, so it ran after every React
   render. Each call re-created a JsonDataProvider from all 23K individuals,
   re-ran the ancestor/descendant layout, and called getComputedTextLength for
   every visible text element (~500ms per call in Chrome, longer in Safari).
   For unrelated state changes this froze the browser 3-5 extra times per load.
   Fix: return early in renderChart() when neither initialRender nor
   resetPosition is set — the SVG is already correct.

2. The "Loading..." progress pill was mounted only in AppState.LOADING. When
   data finished parsing, React transitioned to SHOWING_CHART and unmounted the
   pill before the D3 layout (the slow part) had run. Users saw a blank screen
   with no feedback for several seconds.
   Fix: replace the state-gated pill with a persistent fixed overlay driven by
   a loadingStatus string, visible through both LOADING and the initial chart
   render. Add onFirstRender() callback to Chart; App clears loadingStatus when
   the first D3 layout completes.

Wire the onProgress callback from the load pipeline into setLoadingStatus so
users see step-by-step progress ("Step 1/4: parsing GEDCOM..." through
"Rendering chart (23,909 people)...").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(search): pre-build Lunr index during browser idle time

Building the full-text search index over 23K individuals takes ~600ms
synchronously. This was deferred to the first keystroke, freezing the input
field for up to 3 seconds in Safari with no user feedback.

Schedule the build via requestIdleCallback (5s timeout) after data loads,
with a 200ms setTimeout fallback for browsers without requestIdleCallback.
The index is typically warm before the user types. getOrBuildIndex() still
builds synchronously on the first keystroke if idle scheduling has not yet
fired (e.g. user types immediately after load).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add coverage for gedcom_store, loadGedcom store path, and fileFingerprint

gedcom_store.spec.ts
  - Returns undefined for unknown hash
  - Stores and retrieves GEDCOM by hash
  - Evicts the previous entry when a new file is stored (bounded-to-1 behavior)
  - Returns undefined for a hash that was evicted

load_data_store.spec.ts
  - loadGedcom throws ERROR_LOADING_UPLOADED_FILE when hash is absent from store
  - loadGedcom successfully parses GEDCOM retrieved from the in-memory store
  - onProgress callback receives all four step messages in the correct order

upload_menu.spec.ts (via src/util/file_fingerprint.ts)
  - Returns a valid 32-character hex hash
  - Produces identical hashes for identical inputs
  - Differs for different filename, file size, content beyond 4KB boundary,
    and image file list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ui): clear loading pill for all chart types including Donatso

The progress pill persisted indefinitely on the Donatso chart because
DonatsoChart did not call onFirstRender — only the D3-based Chart did.

Add onFirstRender? to DonatsoChartProps and call it from the existing
useEffect on the first render, consistent with how Chart handles it.
Pass onFirstRender={() => setLoadingStatus('')} from renderChart in app.tsx.

Update visual regression snapshots for macOS (darwin) to reflect the new
progress pill and its correct dismissal across all chart types. The 12
non-Donatso failures were pre-existing snapshot staleness; chart-donatso
was the one newly introduced by this PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix thread onProgress through EmbeddedDataSource.onMessage to loadGedcom

The _onProgress parameter accepted by loadData was never forwarded to the
onMessage event handler, silently dropping all step-progress callbacks for
embedded GEDCOM loads. The progress pill stayed at 'Loading…' for the full
parse duration instead of showing the four-step breakdown.

Thread onProgress through the event-listener closure by adding it as a
parameter to onMessage and passing it to loadGedcom.

* Make gedcom_store tests independent of execution order

Jest caches module instances, so the module-level let variables persisted
across tests. Running with --randomize or in parallel could cause test 1
('returns undefined for unknown hash') to see a hash left by a previous test.

Export clearStoredGedcom() for testing and call it in beforeEach so each test
starts from a clean slate regardless of execution order.

* Document intentional pre-animation timing of onFirstRender in Chart

onFirstRender fires when the chart SVG is in the DOM, before the D3
fade-in animation completes (~400ms). A reviewer noted this as a potential
issue, but moving it into animationPromise.then() would keep the progress
pill visible for 400ms after the chart is already visible and interactive,
creating a confusing overlap. The current timing is correct.

Add a comment explaining the design intent.

* Pass onProgress to WikiTree and Embedded data sources at call site

Both call sites in the loadData callback omitted the onProgress parameter
that all other sources (UPLOADED, GEDCOM_URL, GOOGLE_DRIVE) already forward.
WikiTree's implementation ignores it today (_onProgress), but wiring it here
ensures parity and makes future progress reporting possible without touching
app.tsx again. Embedded now receives it and threads it to loadGedcom.

* Use const-scoped handles in search useEffect to eliminate type casts

The shared 'let handle' variable was typed as a union of setTimeout and
requestIdleCallback return types, requiring unsafe casts (handle as number,
handle as ReturnType<typeof setTimeout>) in the cleanup closures. Each
cleanup closure captured the shared variable by reference rather than value.

Use a const declaration in each branch so TypeScript infers the correct
type automatically and no casts are needed. The early-return form also
makes the two branches structurally symmetric.

* Remove window event listener in EmbeddedDataSource after GEDCOM received

The listener registered in loadData was never removed, causing it to
accumulate on every loadData call. In a multi-file workflow the second
load would have two listeners firing on the same message, the first
with a stale resolve/reject pair.

Wrap resolve and reject so the listener removes itself on first
settlement, making the registration effectively once-per-load.

* Minor code fixes. Moved test to a different file

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Przemek Więch <pwiech@gmail.com>
This commit is contained in:
Rob Ekl
2026-06-04 17:14:58 -05:00
committed by GitHub
parent 059c9d9413
commit 621d581d87
29 changed files with 420 additions and 86 deletions

View File

@@ -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<UploadLocationState>): 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<UploadLocationState>): 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>(AppState.INITIAL);
/** Progress message shown during LOADING and initial chart render. */
const [loadingStatus, setLoadingStatus] = useState('Loading…');
/** Loaded data. */
const [data, setData] = useState<TopolaData>();
/** 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) ? (
<div
style={{
position: 'fixed',
bottom: 16,
left: 16,
background: 'rgba(0,0,0,0.7)',
color: '#fff',
padding: '6px 12px',
borderRadius: 4,
fontFamily: 'monospace',
fontSize: 12,
zIndex: 9999,
pointerEvents: 'none',
}}
>
{loadingStatus}
</div>
) : null;
return (
<>
{progressPill}
<TopBar
data={data?.chartData}
allowAllRelativesChart={sourceSpec?.source !== DataSourceEnum.WIKITREE}

View File

@@ -328,6 +328,8 @@ export interface ChartProps {
hideSex?: Sex;
placeDisplay?: PlaceDisplay;
placeCount?: number;
/** Called once after the initial D3 layout and SVG render completes. */
onFirstRender?: () => 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?.();
}
});

View File

@@ -28,5 +28,8 @@ export interface DataSource<SourceSpecT> {
data?: TopolaData,
): boolean;
/** Loads data from the data source. */
loadData(spec: SourceSelection<SourceSpecT>): Promise<TopolaData>;
loadData(
spec: SourceSelection<SourceSpecT>,
onProgress?: (status: string) => void,
): Promise<TopolaData>;
}

View File

@@ -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<EmbeddedSourceSpec> {
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<EmbeddedSourceSpec> {
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<EmbeddedSourceSpec> {
async loadData(
_source: SourceSelection<EmbeddedSourceSpec>,
onProgress?: (status: string) => void,
): Promise<TopolaData> {
// Notify the parent window that we are ready.
return new Promise<TopolaData>((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);
});
}
}

View File

@@ -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<string, string>();
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'));
});
});

View File

@@ -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<string, string>} | null = null;
export function storeGedcom(
hash: string,
gedcom: string,
images: Map<string, string>,
): void {
currentHash = hash;
currentEntry = {gedcom, images};
}
export function getStoredGedcom(
hash: string,
): {gedcom: string; images: Map<string, string>} | 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;
}

View File

@@ -35,6 +35,7 @@ export class GoogleDriveDataSource implements DataSource<GoogleDriveSourceSpec>
async loadData(
source: SourceSelection<GoogleDriveSourceSpec>,
onProgress?: (status: string) => void,
): Promise<TopolaData> {
const fileId = source.spec.fileId;
const cacheKey = `${GOOGLE_DRIVE_CACHE_KEY_PREFIX}${fileId}`;
@@ -88,6 +89,6 @@ export class GoogleDriveDataSource implements DataSource<GoogleDriveSourceSpec>
// 5. Parse and prepare the file content
const blob = await response.blob();
return loadAndPrepareFile(blob, cacheKey);
return loadAndPrepareFile(blob, cacheKey, onProgress);
}
}

View File

@@ -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<string, string>,
): TopolaData {
const data = convertGedcom(gedcom, images || new Map());
onProgress?: (status: string) => void,
): Promise<TopolaData> {
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<TopolaData> {
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<TopolaData> {
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<string, string>,
onProgress?: (status: string) => void,
): Promise<TopolaData> {
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<string, string>;
}
export interface UploadLocationState {
data: string;
images: Map<string, string>;
}
/** Files opened from the local computer. */
export class UploadedDataSource implements DataSource<UploadSourceSpec> {
// isNewData(args: Arguments, state: State): boolean {
isNewData(
newSource: SourceSelection<UploadSourceSpec>,
oldSource: SourceSelection<UploadSourceSpec>,
@@ -222,18 +228,12 @@ export class UploadedDataSource implements DataSource<UploadSourceSpec> {
async loadData(
source: SourceSelection<UploadSourceSpec>,
onProgress?: (status: string) => void,
): Promise<TopolaData> {
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<UrlSourceSpec> {
return newSource.spec.url !== oldSource.spec.url;
}
async loadData(source: SourceSelection<UrlSourceSpec>): Promise<TopolaData> {
async loadData(
source: SourceSelection<UrlSourceSpec>,
onProgress?: (status: string) => void,
): Promise<TopolaData> {
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;

View File

@@ -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…',
]);
});
});

View File

@@ -87,6 +87,7 @@ export class WikiTreeDataSource implements DataSource<WikiTreeSourceSpec> {
async loadData(
source: SourceSelection<WikiTreeSourceSpec>,
_onProgress?: (status: string) => void,
): Promise<TopolaData> {
if (!source.selection) {
throw new TopolaError(

View File

@@ -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);
}

View File

@@ -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 (

View File

@@ -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},
);
}

View File

@@ -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<File, 'name' | 'size' | 'lastModified'> => ({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);
});
});

View File

@@ -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<File, 'name' | 'size' | 'lastModified'>,
gedcom: string,
imageFileNames: string,
): string {
const sample = gedcom.slice(0, 4096) + gedcom.slice(-4096);
return md5(
`${file.name}|${file.size}|${file.lastModified}|${sample}|${imageFileNames}`,
);
}

View File

@@ -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<void> {
if (status) onProgress?.(status);
return new Promise((resolve) => setTimeout(resolve, 0));
}
export async function convertGedcom(
gedcom: string,
images: Map<string, string>,
): TopolaData {
onProgress?: (status: string) => void,
): Promise<TopolaData> {
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,
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB