122 lines
4.9 KiB
TypeScript
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} />;
|
|
}
|