Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kne/example-driver",
"version": "0.1.19",
"version": "0.1.20",
"description": "用于在线展示和编辑React组件",
"syntax": {
"esmodules": true
Expand Down
25 changes: 21 additions & 4 deletions src/components/LiveCode.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
import React, {useEffect, useRef, useState, useMemo} from 'react';
import React, {useEffect, useRef, useState, useMemo, useCallback} from 'react';
import ErrorBoundary from '@kne/react-error-boundary';
import withLocale from '../withLocale';
import {useInView, useLazyCompile, useReactRoot} from '../hooks';
import DescriptionBar from './DescriptionBar';
import CodePanel from './CodePanel';
import ErrorComponent from './ErrorComponent';

// vertical padding of .example-driver-preview (42px top + 30px bottom)
const PREVIEW_VERTICAL_PADDING = 72;

const LiveCodeInner = ({code = '', scope = [], title, description, contextComponent, mounted, useInView: enableInView = true}) => {
const [_code, setCode] = useState(code);
const [codeOpen, setCodeOpen] = useState(false);
const containerRef = useRef(null);
const [previewMinHeight, setPreviewMinHeight] = useState(0);

useEffect(() => {
setCode(code || '');
}, [code]);

const useViewport = enableInView !== false && typeof mounted !== 'boolean';
const {shouldRender: inViewShouldRender, heightRef} = useInView(containerRef, {disabled: !useViewport});
// mounted has highest priority; otherwise use viewport if enabled, else always render
const shouldRender = typeof mounted === 'boolean' ? mounted : (useViewport ? inViewShouldRender : true);
const {compiledCode, error} = useLazyCompile(_code, shouldRender);

Expand All @@ -28,6 +31,12 @@ const LiveCodeInner = ({code = '', scope = [], title, description, contextCompon

const [renderJsx, setRenderJsx] = useState(null);

useEffect(() => {
if (!shouldRender) {
setRenderJsx(null);
}
}, [shouldRender]);

useEffect(() => {
if (!compiledCode || !shouldRender) return;
try {
Expand All @@ -42,10 +51,18 @@ const LiveCodeInner = ({code = '', scope = [], title, description, contextCompon
}
}, [compiledCode, currentScope, contextComponent, shouldRender]);

useReactRoot(containerRef, shouldRender, renderJsx, heightRef);
const handleHeightRecord = useCallback((h) => {
setPreviewMinHeight(prev => Math.max(prev, h));
}, []);

useReactRoot(containerRef, shouldRender, renderJsx, heightRef, {onHeightRecord: handleHeightRecord});

const previewStyle = previewMinHeight > 0
? {minHeight: previewMinHeight + PREVIEW_VERTICAL_PADDING + 'px'}
: undefined;

return <>
<div className="example-driver-preview" ref={containerRef}/>
<div className="example-driver-preview" ref={containerRef} style={previewStyle}/>
<DescriptionBar title={title} description={description} codeOpen={codeOpen}
onToggle={() => setCodeOpen(!codeOpen)}/>
{codeOpen && <CodePanel code={_code} scope={scope} error={error} editable onChange={setCode}/>}
Expand Down
109 changes: 73 additions & 36 deletions src/hooks/__tests__/heightStability.test.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import React, {useRef, useState, useEffect} from 'react';
import {render, act, waitFor} from '@testing-library/react';
import useInView from '../useInView';
import useInView, {__resetSharedObserverForTests} from '../useInView';
import useReactRoot from '../useReactRoot';

/**
* Integration test: verifies height consistency when
* useInView and useReactRoot work together.
*/
const UNMOUNT_DELAY = 300;
const IN_ZONE_RECT = {top: 100, bottom: 300, left: 0, right: 300, height: 200, width: 300};
const OUT_ZONE_RECT = {top: -500, bottom: -300, left: 0, right: 300, height: 200, width: 300};

const InnerComponent = ({height}) => (
<div data-testid="inner" style={{height: height + 'px', backgroundColor: 'lightblue'}}>
Expand Down Expand Up @@ -37,6 +36,7 @@ describe('Height stability integration test', () => {
let originalGBCR;

beforeEach(() => {
__resetSharedObserverForTests();
observerInstances = [];
originalGBCR = window.HTMLElement.prototype.getBoundingClientRect;

Expand All @@ -62,12 +62,11 @@ describe('Height stability integration test', () => {
};
};

const triggerIntersection = (isIntersecting, intersectionRatio) => {
const triggerVisibility = (boundingClientRect) => {
const observer = observerInstances[observerInstances.length - 1];
act(() => {
observer.callback([{
isIntersecting,
intersectionRatio,
boundingClientRect,
target: observer.element
}]);
});
Expand All @@ -80,6 +79,12 @@ describe('Height stability integration test', () => {
});
};

const waitForUnmount = async () => {
await act(async () => {
await new Promise(resolve => setTimeout(resolve, UNMOUNT_DELAY + 20));
});
};

const waitForPlaceholder = async (container) => {
await waitFor(() => {
expect(container.querySelector('.example-driver-placeholder')).toBeInTheDocument();
Expand All @@ -92,38 +97,28 @@ describe('Height stability integration test', () => {

const {container} = render(<TestWrapper mockHeight={mockHeight}/>);

// Step 1: Element enters viewport
triggerIntersection(true, 0.1);
triggerVisibility(IN_ZONE_RECT);
await waitForHeightRecord();

const containerDiv = container.querySelector('[data-testid="container"]');
const heightAfterRender = containerDiv.getBoundingClientRect().height;

// Step 2: Element leaves viewport
triggerIntersection(false, 0);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
});
triggerVisibility(OUT_ZONE_RECT);
await waitForUnmount();
await waitForPlaceholder(container);

const placeholder = containerDiv.querySelector('.example-driver-placeholder');
const placeholderHeight = parseInt(placeholder.style.height, 10);

// Placeholder height must match rendered height
expect(placeholderHeight).toBe(heightAfterRender);

// Step 3: Element re-enters viewport
triggerIntersection(true, 0.1);
triggerVisibility(IN_ZONE_RECT);
await waitForHeightRecord();

const heightAfterRerender = containerDiv.getBoundingClientRect().height;
expect(heightAfterRerender).toBe(heightAfterRender);

// Step 4: Leave viewport again
triggerIntersection(false, 0);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
});
triggerVisibility(OUT_ZONE_RECT);
await waitForUnmount();
await waitForPlaceholder(container);

const placeholder2 = containerDiv.querySelector('.example-driver-placeholder');
Expand All @@ -137,20 +132,18 @@ describe('Height stability integration test', () => {

const {container} = render(<TestWrapper mockHeight={mockHeight}/>);

triggerIntersection(true, 0.1);
triggerVisibility(IN_ZONE_RECT);
await waitForHeightRecord();

const initialHeight = container.querySelector('[data-testid="container"]')
.getBoundingClientRect().height;

// Multiple in/out toggles
for (let i = 0; i < 3; i++) {
triggerIntersection(false, 0);
for (let i = 0; i < 5; i++) {
triggerVisibility(OUT_ZONE_RECT);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
await new Promise(resolve => setTimeout(resolve, 50));
});

triggerIntersection(true, 0.1);
triggerVisibility(IN_ZONE_RECT);
await waitForHeightRecord();
}

Expand All @@ -166,21 +159,65 @@ describe('Height stability integration test', () => {

const {container} = render(<TestWrapper mockHeight={mockHeight}/>);

triggerIntersection(true, 0.1);
triggerVisibility(IN_ZONE_RECT);
await waitForHeightRecord();

const renderedHeight = container.querySelector('[data-testid="container"]')
.getBoundingClientRect().height;

triggerIntersection(false, 0);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
});
triggerVisibility(OUT_ZONE_RECT);
await waitForUnmount();
await waitForPlaceholder(container);

const placeholder = container.querySelector('.example-driver-placeholder');
const placeholderHeight = parseInt(placeholder.style.height, 10);

expect(placeholderHeight).toBe(renderedHeight);
});

it('should create default placeholder on first enter before JSX is ready', async () => {
const mockHeight = 200;
mockGetBoundingClientRect(mockHeight);

const PendingWrapper = () => {
const containerRef = useRef(null);
const {shouldRender, heightRef} = useInView(containerRef);
useReactRoot(containerRef, shouldRender, null, heightRef);
return <div data-testid="container" ref={containerRef}/>;
};

const {container} = render(<PendingWrapper/>);

triggerVisibility(IN_ZONE_RECT);

await waitFor(() => {
const placeholder = container.querySelector('.example-driver-placeholder');
expect(placeholder).toBeInTheDocument();
expect(parseInt(placeholder.style.height, 10)).toBe(120);
});
});

it('should keep container height stable during placeholder to remount transition', async () => {
const mockHeight = 260;
mockGetBoundingClientRect(mockHeight);

const {container} = render(<TestWrapper mockHeight={mockHeight}/>);

triggerVisibility(IN_ZONE_RECT);
await waitForHeightRecord();

const containerDiv = container.querySelector('[data-testid="container"]');
const stableHeight = containerDiv.getBoundingClientRect().height;

triggerVisibility(OUT_ZONE_RECT);
await waitForUnmount();
await waitForPlaceholder(container);
expect(containerDiv.getBoundingClientRect().height).toBe(stableHeight);

triggerVisibility(IN_ZONE_RECT);
expect(containerDiv.getBoundingClientRect().height).toBe(stableHeight);

await waitForHeightRecord();
expect(containerDiv.getBoundingClientRect().height).toBe(stableHeight);
});
});
Loading