Files
HMDesign/packages/org-chart-react/src/OrgChart.tsx
T

122 lines
4.9 KiB
TypeScript

"use client";
import { useLayoutEffect, useRef } from 'react';
import { createOrgChart } from '@hm-design/org-chart';
import type { OrgChartDataSource, OrgChartHandle } from '@hm-design/org-chart';
import type { OrgChartProps } from './types.js';
interface Instance {
readonly handle: OrgChartHandle;
readonly generation: number;
source: OrgChartDataSource;
operation: number;
}
/** A React host for the exact same Core used by Vanilla HTML. */
export function OrgChart({
dataSource,
aggregationPolicy,
stylesheetUrls,
displayMode,
organizationLinks,
onEmployeeClick,
className,
style,
onStateChange,
onLifecycleError,
}: OrgChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<Instance | null>(null);
const generationRef = useRef(0);
const committedRef = useRef({ dataSource, onStateChange, onLifecycleError, onEmployeeClick });
// Publish committed values before Core effects run, without letting callback
// identity changes or abandoned concurrent renders alter Core ownership.
useLayoutEffect(() => {
committedRef.current = { dataSource, onStateChange, onLifecycleError, onEmployeeClick };
}, [dataSource, onStateChange, onLifecycleError, onEmployeeClick]);
const engineStylesheetUrl = stylesheetUrls.engine;
const coreStylesheetUrl = stylesheetUrls.core;
const hasEmployeeCallback = typeof onEmployeeClick === 'function';
// Ownership changes are synchronous with the commit: an old request cannot
// notify between a committed unmount/source change and a passive cleanup.
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const generation = ++generationRef.current;
let handle: OrgChartHandle | undefined;
let unsubscribe: (() => void) | undefined;
const reportError = (error: unknown) => {
if (generationRef.current !== generation) return;
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Consumer errors must not leave rejected lifecycle promises. */ }
};
try {
const source = committedRef.current.dataSource;
handle = createOrgChart({
dataSource: source,
aggregationPolicy,
stylesheetUrls: { engine: engineStylesheetUrl, core: coreStylesheetUrl },
displayMode,
organizationLinks,
onEmployeeClick: hasEmployeeCallback ? (employee, context) => {
if (generationRef.current === generation) committedRef.current.onEmployeeClick?.(employee, context);
} : undefined,
});
const instance: Instance = { handle, source, generation, operation: 1 };
instanceRef.current = instance;
unsubscribe = handle.subscribe(state => {
if (instanceRef.current === instance && generationRef.current === generation) {
committedRef.current.onStateChange?.(state);
}
});
// Core mount performs the first load. The source effect below compares
// identity against this record and does not load the initial source again.
const operation = instance.operation;
void handle.mount(container).catch(error => {
if (instanceRef.current === instance && instance.operation === operation) reportError(error);
});
} catch (error) {
reportError(error);
}
return () => {
// Invalidate callbacks and promise continuations before terminal disposal.
if (generationRef.current === generation) ++generationRef.current;
if (instanceRef.current?.generation === generation) instanceRef.current = null;
try { unsubscribe?.(); }
catch (error) {
try { committedRef.current.onLifecycleError?.(error); }
catch { /* A consumer callback cannot prevent terminal Core disposal. */ }
}
finally {
try { handle?.destroy(); }
catch (error) {
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Cleanup still completes when the consumer callback throws. */ }
}
}
};
}, [aggregationPolicy, engineStylesheetUrl, coreStylesheetUrl, displayMode, organizationLinks, hasEmployeeCallback]);
useLayoutEffect(() => {
const instance = instanceRef.current;
if (!instance || instance.source === dataSource) return;
instance.source = dataSource;
const operation = ++instance.operation;
// Core reload synchronously starts a new revision, aborting/ignoring earlier
// source requests even when their Promise or engine import is still pending.
void instance.handle.reload(dataSource).catch(error => {
if (instanceRef.current !== instance || generationRef.current !== instance.generation || instance.operation !== operation) return;
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Consumer errors must not become unhandled Promise rejections. */ }
});
}, [dataSource]);
return <div ref={containerRef} className={className} style={style} />;
}