"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(null); const instanceRef = useRef(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
; }