diff --git a/common/changes/@visactor/react-vtable/test-issue-4836-link-button-stage_2026-09-21-16-30.json b/common/changes/@visactor/react-vtable/test-issue-4836-link-button-stage_2026-09-21-16-30.json new file mode 100644 index 000000000..0ee1950a4 --- /dev/null +++ b/common/changes/@visactor/react-vtable/test-issue-4836-link-button-stage_2026-09-21-16-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/react-vtable", + "comment": "test: add regression coverage and a reproduction demo for custom-layout Link and Button mounting before stage attachment (GitHub #4836)", + "type": "none" + } + ], + "packageName": "@visactor/react-vtable", + "email": "892739385@qq.com" +} diff --git a/packages/react-vtable/__tests__/custom-layout-components.test.tsx b/packages/react-vtable/__tests__/custom-layout-components.test.tsx new file mode 100644 index 000000000..64091d47d --- /dev/null +++ b/packages/react-vtable/__tests__/custom-layout-components.test.tsx @@ -0,0 +1,56 @@ +/* eslint-env jest */ +/* global document */ +import React from 'react'; +import { Group } from '@visactor/vtable/es/vrender'; +import { createStageFromVRenderApp } from '@visactor/vtable/es/vrender-app'; +import { Button, Link } from '../src/components'; +import { createReconcilerContainer, reconcilor } from '../src/table-components/custom/reconciler'; + +type TestReconciler = typeof reconcilor & { + flushSyncWork?: () => unknown; + flushPassiveEffects?: () => unknown; +}; + +describe('custom layout components', () => { + test.each([ + ['Link', React.createElement(Link, null, 'View')], + ['Button', React.createElement(Button, null, 'View')] + ])('%s can mount before its graphic is attached to a stage', (_, component) => { + const detachedGroup = new Group({}); + const container = createReconcilerContainer(detachedGroup); + const testReconciler = reconcilor as TestReconciler; + const canvas = document.createElement('canvas'); + const { stage, releaseAppRef } = createStageFromVRenderApp( + { + canvas, + width: 200, + height: 80 + }, + { mode: 'browser', scope: 'react-custom-layout-components' } + ); + + try { + expect(() => { + testReconciler.updateContainer(component, container, null); + testReconciler.flushSyncWork?.(); + testReconciler.flushPassiveEffects?.(); + }).not.toThrow(); + + const graphic = detachedGroup.firstChild; + expect(graphic).toBeTruthy(); + expect(graphic.stage).toBeFalsy(); + + stage.defaultLayer.add(detachedGroup); + stage.render(); + + expect(detachedGroup.firstChild).toBe(graphic); + expect(graphic.stage).toBe(stage); + } finally { + testReconciler.updateContainer(null, container, null); + testReconciler.flushSyncWork?.(); + testReconciler.flushPassiveEffects?.(); + stage.release(); + releaseAppRef(); + } + }); +}); diff --git a/packages/react-vtable/demo/src/App.tsx b/packages/react-vtable/demo/src/App.tsx index d4c9a4369..4e250152b 100644 --- a/packages/react-vtable/demo/src/App.tsx +++ b/packages/react-vtable/demo/src/App.tsx @@ -1,5 +1,6 @@ import listTable from './list-table/list-table'; import issue5203ViteReact19 from './list-table/issue-5203-vite-react19'; +import issue4836LinkButtonStage from './list-table/issue-4836-link-button-stage'; import listOptionRecord from './list-table/list-option-records'; import listComponent from './list-table/list-component'; import listCustomLayout from './list-table/list-custom-layout'; @@ -33,6 +34,7 @@ import { Component, useEffect, useMemo, useState } from 'react'; declare const globalThis: any; const demoList = [ + { key: 'issue4836LinkButtonStage', Comp: issue4836LinkButtonStage }, { key: 'issue5203ViteReact19', Comp: issue5203ViteReact19 }, { key: 'listTable', Comp: listTable }, { key: 'listEditor', Comp: listEditor }, diff --git a/packages/react-vtable/demo/src/list-table/issue-4836-link-button-stage.tsx b/packages/react-vtable/demo/src/list-table/issue-4836-link-button-stage.tsx new file mode 100644 index 000000000..f98c055e1 --- /dev/null +++ b/packages/react-vtable/demo/src/list-table/issue-4836-link-button-stage.tsx @@ -0,0 +1,221 @@ +/* global window */ +import type { Tag } from '@visactor/vtable/es/vrender'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { CustomLayoutFunctionArg } from '../../../src'; +import { Button, Group, Link, ListColumn, ListTable } from '../../../src'; + +declare global { + interface Window { + __issue_4836_ready__?: boolean; + __issue_4836_error__?: string; + } +} + +type ActionCellProps = CustomLayoutFunctionArg & { + kind: 'link' | 'button'; +}; + +const stagedControls = new Map(); +const observedKinds = new Set(); +const scheduledAnimationFrames = new Set(); +let readinessRun = 0; + +const records = Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + name: `Record ${index + 1}` +})); + +function scheduleAnimationFrame(callback: () => void) { + const frameId = window.requestAnimationFrame(() => { + scheduledAnimationFrames.delete(frameId); + callback(); + }); + scheduledAnimationFrames.add(frameId); +} + +function cancelScheduledAnimationFrames() { + scheduledAnimationFrames.forEach(frameId => window.cancelAnimationFrame(frameId)); + scheduledAnimationFrames.clear(); +} + +function observeStageAttachment(kind: ActionCellProps['kind'], control: Tag) { + if (observedKinds.has(kind)) { + return; + } + observedKinds.add(kind); + const run = readinessRun; + + const checkStage = () => { + if (run !== readinessRun || window.__issue_4836_error__) { + return; + } + if (!control.stage) { + scheduleAnimationFrame(checkStage); + return; + } + + stagedControls.set(kind, control); + if (stagedControls.size !== 2) { + return; + } + + stagedControls.forEach(item => item.stage?.renderNextFrame?.()); + scheduleAnimationFrame(() => { + if ( + run === readinessRun && + !window.__issue_4836_error__ && + Array.from(stagedControls.values()).every(item => item.stage) + ) { + window.__issue_4836_ready__ = true; + } + }); + }; + + scheduleAnimationFrame(checkStage); +} + +function ActionCell(props: ActionCellProps) { + const { table, row, col, rect, kind } = props; + const controlRef = useRef(null); + + useEffect(() => { + if (controlRef.current) { + observeStageAttachment(kind, controlRef.current); + } + }, [kind]); + + if (!table || row === undefined || col === undefined) { + return null; + } + + const { width, height } = rect || table.getCellRect(col, row); + const content = + kind === 'link' ? ( + + View + + ) : ( + + ); + + return ( + + + + {content} + + + + ); +} + +function App() { + const [activeRun, setActiveRun] = useState(null); + + useLayoutEffect(() => { + readinessRun += 1; + const run = readinessRun; + setActiveRun(run); + stagedControls.clear(); + observedKinds.clear(); + window.__issue_4836_ready__ = false; + delete window.__issue_4836_error__; + let remainingFrames = 120; + + const checkReadyDeadline = () => { + if (run !== readinessRun || window.__issue_4836_ready__ || window.__issue_4836_error__) { + return; + } + remainingFrames -= 1; + if (remainingFrames === 0) { + const missingKinds = (['link', 'button'] as const).filter(kind => !stagedControls.has(kind)); + window.__issue_4836_error__ = missingKinds.length + ? `${missingKinds.join(' and ')} control${ + missingKinds.length > 1 ? 's were' : ' was' + } not attached to a stage` + : 'Link and Button controls did not reach the ready state'; + return; + } + scheduleAnimationFrame(checkReadyDeadline); + }; + + scheduleAnimationFrame(checkReadyDeadline); + + return () => { + if (readinessRun !== run) { + return; + } + readinessRun += 1; + cancelScheduledAnimationFrames(); + stagedControls.clear(); + observedKinds.clear(); + window.__issue_4836_ready__ = false; + delete window.__issue_4836_error__; + }; + }, []); + + if (activeRun === null) { + return null; + } + + return ( + { + if (activeRun !== readinessRun) { + return; + } + window.__issue_4836_ready__ = false; + window.__issue_4836_error__ = error instanceof Error ? error.message : String(error); + }} + > + + + {Array.from({ length: 6 }, (_, index) => { + const kind = index % 2 === 0 ? 'link' : 'button'; + return ( + + + + ); + })} + + ); +} + +export default App;