Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -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"
}
56 changes: 56 additions & 0 deletions packages/react-vtable/__tests__/custom-layout-components.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
2 changes: 2 additions & 0 deletions packages/react-vtable/demo/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ActionCellProps['kind'], Tag>();
const observedKinds = new Set<ActionCellProps['kind']>();
const scheduledAnimationFrames = new Set<number>();
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<Tag>(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' ? (
<Link ref={controlRef} maxWidth={width - 30} panelStyle={{ visible: true, boundsPadding: [6, 12] }}>
View
</Link>
) : (
<Button ref={controlRef} maxWidth={width - 30}>
Open
</Button>
);

return (
<Group
attribute={{
width,
height,
display: 'flex',
flexWrap: 'nowrap',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Group
attribute={{
width: width - 16,
height,
display: 'flex',
flexWrap: 'nowrap',
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between'
}}
>
<Group
attribute={{
width: width - 30,
height,
display: 'flex',
flexWrap: 'wrap',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
clip: true
}}
>
{content}
</Group>
</Group>
</Group>
);
}

function App() {
const [activeRun, setActiveRun] = useState<number | null>(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 (
<ListTable
records={records}
height="100%"
defaultRowHeight={44}
onError={error => {
if (activeRun !== readinessRun) {
return;
}
window.__issue_4836_ready__ = false;
window.__issue_4836_error__ = error instanceof Error ? error.message : String(error);
}}
>
<ListColumn field="id" title="ID" width={70} />
<ListColumn field="name" title="Name" width={140} />
{Array.from({ length: 6 }, (_, index) => {
const kind = index % 2 === 0 ? 'link' : 'button';
return (
<ListColumn key={index} field="name" title={`${kind} ${index + 1}`} width={140}>
<ActionCell role="custom-layout" kind={kind} />
</ListColumn>
);
})}
</ListTable>
);
}

export default App;
Loading