diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..06d69c5
Binary files /dev/null and b/.DS_Store differ
diff --git a/package.json b/package.json
index f7480d8..bda8b78 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@kne/example-driver",
- "version": "0.1.19",
+ "version": "0.1.20",
"description": "用于在线展示和编辑React组件",
"syntax": {
"esmodules": true
diff --git a/src/components/LiveCode.js b/src/components/LiveCode.js
index 789421e..79925df 100644
--- a/src/components/LiveCode.js
+++ b/src/components/LiveCode.js
@@ -1,4 +1,4 @@
-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';
@@ -6,10 +6,14 @@ 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 || '');
@@ -17,7 +21,6 @@ const LiveCodeInner = ({code = '', scope = [], title, description, contextCompon
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);
@@ -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 {
@@ -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 <>
-
+
setCodeOpen(!codeOpen)}/>
{codeOpen && }
diff --git a/src/hooks/__tests__/heightStability.test.js b/src/hooks/__tests__/heightStability.test.js
index b41aec4..5ea71a9 100644
--- a/src/hooks/__tests__/heightStability.test.js
+++ b/src/hooks/__tests__/heightStability.test.js
@@ -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}) => (
@@ -37,6 +36,7 @@ describe('Height stability integration test', () => {
let originalGBCR;
beforeEach(() => {
+ __resetSharedObserverForTests();
observerInstances = [];
originalGBCR = window.HTMLElement.prototype.getBoundingClientRect;
@@ -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
}]);
});
@@ -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();
@@ -92,38 +97,28 @@ describe('Height stability integration test', () => {
const {container} = render(
);
- // 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');
@@ -137,20 +132,18 @@ describe('Height stability integration test', () => {
const {container} = render();
- 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();
}
@@ -166,16 +159,14 @@ describe('Height stability integration test', () => {
const {container} = render();
- 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');
@@ -183,4 +174,50 @@ describe('Height stability integration test', () => {
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 ;
+ };
+
+ const {container} = render(
);
+
+ 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(
);
+
+ 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);
+ });
});
diff --git a/src/hooks/__tests__/useInView.test.js b/src/hooks/__tests__/useInView.test.js
index b34566e..1f81502 100644
--- a/src/hooks/__tests__/useInView.test.js
+++ b/src/hooks/__tests__/useInView.test.js
@@ -1,6 +1,10 @@
import React, {useRef} from 'react';
import {render, act} from '@testing-library/react';
-import useInView from '../useInView';
+import useInView, {__resetSharedObserverForTests} from '../useInView';
+
+const IN_ZONE_RECT = {top: 100, bottom: 200, left: 0, right: 300, height: 100, width: 300};
+const OUT_ZONE_RECT = {top: -500, bottom: -400, left: 0, right: 300, height: 100, width: 300};
+const UNMOUNT_DELAY = 300;
const TestComponent = ({onStateChange, style}) => {
const ref = useRef(null);
@@ -15,7 +19,9 @@ describe('useInView', () => {
let observerInstances = [];
beforeEach(() => {
+ __resetSharedObserverForTests();
observerInstances = [];
+ jest.useFakeTimers();
window.IntersectionObserver = class {
constructor(callback, options) {
this.callback = callback;
@@ -28,72 +34,81 @@ describe('useInView', () => {
};
});
- it('should set shouldRender to true when element is intersecting', () => {
- let state;
- render( state = s}/>);
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+ const triggerVisibility = (boundingClientRect) => {
const observer = observerInstances[0];
act(() => {
observer.callback([{
- isIntersecting: true,
- intersectionRatio: 0.1,
+ boundingClientRect,
target: observer.element
}]);
});
+ };
+ it('should set shouldRender to true when element is in preload zone', () => {
+ let state;
+ render( state = s}/>);
+
+ triggerVisibility(IN_ZONE_RECT);
expect(state.shouldRender).toBe(true);
});
- it('should set shouldRender to false when fully out of view (intersectionRatio === 0)', () => {
+ it('should set shouldRender to false after leaving preload zone and delay elapses', () => {
let state;
render( state = s}/>);
- const observer = observerInstances[0];
+ triggerVisibility(IN_ZONE_RECT);
+ expect(state.shouldRender).toBe(true);
- act(() => {
- observer.callback([{
- isIntersecting: true,
- intersectionRatio: 0.5,
- target: observer.element
- }]);
- });
+ triggerVisibility(OUT_ZONE_RECT);
expect(state.shouldRender).toBe(true);
act(() => {
- observer.callback([{
- isIntersecting: false,
- intersectionRatio: 0,
- target: observer.element
- }]);
+ jest.advanceTimersByTime(UNMOUNT_DELAY);
});
expect(state.shouldRender).toBe(false);
});
- it('should not set shouldRender to false when partially out of view (intersectionRatio > 0)', () => {
+ it('should cancel pending unmount when re-entering preload zone before delay', () => {
let state;
render( state = s}/>);
- const observer = observerInstances[0];
+ triggerVisibility(IN_ZONE_RECT);
+ triggerVisibility(OUT_ZONE_RECT);
act(() => {
- observer.callback([{
- isIntersecting: true,
- intersectionRatio: 0.5,
- target: observer.element
- }]);
+ jest.advanceTimersByTime(100);
});
- // Partially out of view
+ triggerVisibility(IN_ZONE_RECT);
+
act(() => {
- observer.callback([{
- isIntersecting: false,
- intersectionRatio: 0.3,
- target: observer.element
- }]);
+ jest.advanceTimersByTime(UNMOUNT_DELAY);
});
- // shouldRender stays true since intersectionRatio > 0
+ expect(state.shouldRender).toBe(true);
+ });
+
+ it('should stay rendered when element is in preload buffer below viewport', () => {
+ let state;
+ render( state = s}/>);
+
+ triggerVisibility(IN_ZONE_RECT);
+ expect(state.shouldRender).toBe(true);
+
+ const belowViewportBuffer = {
+ top: window.innerHeight + 50,
+ bottom: window.innerHeight + 150,
+ left: 0,
+ right: 300,
+ height: 100,
+ width: 300
+ };
+ triggerVisibility(belowViewportBuffer);
expect(state.shouldRender).toBe(true);
});
@@ -109,4 +124,9 @@ describe('useInView', () => {
expect(state.heightRef).toBeDefined();
expect(state.heightRef.current).toBe(0);
});
+
+ it('should use rootMargin 0 observer options', () => {
+ render();
+ expect(observerInstances[0].options.rootMargin).toBe('0px');
+ });
});
diff --git a/src/hooks/__tests__/useReactRoot.test.js b/src/hooks/__tests__/useReactRoot.test.js
index 917ce4c..5cff62b 100644
--- a/src/hooks/__tests__/useReactRoot.test.js
+++ b/src/hooks/__tests__/useReactRoot.test.js
@@ -222,13 +222,61 @@ describe('useReactRoot', () => {
const {unmount} = render();
- // Wait for double rAF to record height
await act(async () => {
await new Promise(resolve => requestAnimationFrame(resolve));
await new Promise(resolve => requestAnimationFrame(resolve));
});
- // heightRef should have been recorded
expect(heightRefValue.current).toBe(fixedHeight);
});
+
+ it('should create default placeholder when shouldRender is true but JSX is not ready', async () => {
+ const containerRef = {current: null};
+ const heightRefValue = {current: 0};
+
+ const PendingComponent = () => {
+ const ref = useRef(null);
+ containerRef.current = ref.current;
+ useReactRoot(ref, true, null, heightRefValue);
+ return ;
+ };
+
+ const {container} = render(
);
+
+ await waitFor(() => {
+ const placeholder = container.querySelector('.example-driver-placeholder');
+ expect(placeholder).toBeInTheDocument();
+ expect(placeholder.style.height).toBe('120px');
+ });
+ });
+
+ it('should keep minHeight lock on remount until content paints', async () => {
+ const fixedHeight = 220;
+ mockGetBoundingClientRect(fixedHeight);
+
+ const {rerender, container} = render(
+
+ );
+
+ await act(async () => {
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ });
+
+ rerender();
+ await waitFor(() => {
+ expect(container.querySelector('.example-driver-placeholder')).toBeInTheDocument();
+ });
+
+ const heightBeforeRemount = container.querySelector('[data-testid="container"]')
+ .getBoundingClientRect().height;
+
+ rerender();
+
+ const runner = container.querySelector('.example-driver-runner');
+ expect(runner).toBeInTheDocument();
+ expect(runner.style.minHeight).toBe(fixedHeight + 'px');
+ expect(container.querySelector('[data-testid="container"]').getBoundingClientRect().height)
+ .toBe(heightBeforeRemount);
+ });
});
diff --git a/src/hooks/useInView.js b/src/hooks/useInView.js
index 623329d..ea2a39b 100644
--- a/src/hooks/useInView.js
+++ b/src/hooks/useInView.js
@@ -4,16 +4,25 @@ let sharedObserver = null;
let sharedObserverCtor = null;
const elementCallbacks = new Map();
+const PRELOAD_MARGIN = 200;
+
const OBSERVER_OPTIONS = {
threshold: [0],
- // preload a bit outside viewport to reduce frequent mount/unmount near boundary
- rootMargin: '200px 0px'
+ rootMargin: '0px'
};
// Delay before unmounting to avoid rapid mount/unmount cycles during scrolling.
-// If the element re-enters the viewport before the delay expires, the unmount is cancelled.
const UNMOUNT_DELAY = 300;
+const isInPreloadZone = (rect) => {
+ if (!rect || typeof rect.top !== 'number' || typeof rect.bottom !== 'number') {
+ return false;
+ }
+ if (typeof window === 'undefined') return true;
+ const vh = window.innerHeight || document.documentElement.clientHeight || 0;
+ return rect.bottom > -PRELOAD_MARGIN && rect.top < vh + PRELOAD_MARGIN;
+};
+
const getSharedObserver = () => {
if (!sharedObserver || sharedObserverCtor !== window.IntersectionObserver) {
sharedObserverCtor = window.IntersectionObserver;
@@ -46,8 +55,10 @@ const useInView = (ref, options) => {
const observer = getSharedObserver();
const cb = (entry) => {
- if (entry.isIntersecting) {
- // Cancel pending unmount if element re-enters viewport
+ const rect = entry.boundingClientRect;
+ const inZone = isInPreloadZone(rect);
+
+ if (inZone) {
if (unmountTimerRef.current) {
clearTimeout(unmountTimerRef.current);
unmountTimerRef.current = null;
@@ -55,16 +66,15 @@ const useInView = (ref, options) => {
setShouldRender(true);
return;
}
- if (!entry.isIntersecting && entry.intersectionRatio === 0) {
- // Delay unmount to avoid rapid mount/unmount cycles during scrolling
- if (unmountTimerRef.current) {
- clearTimeout(unmountTimerRef.current);
- }
- unmountTimerRef.current = setTimeout(() => {
- unmountTimerRef.current = null;
- setShouldRender(false);
- }, UNMOUNT_DELAY);
+
+ // Fully outside preload zone — schedule unmount with delay
+ if (unmountTimerRef.current) {
+ clearTimeout(unmountTimerRef.current);
}
+ unmountTimerRef.current = setTimeout(() => {
+ unmountTimerRef.current = null;
+ setShouldRender(false);
+ }, UNMOUNT_DELAY);
};
let callbacks = elementCallbacks.get(container);
@@ -96,3 +106,9 @@ const useInView = (ref, options) => {
};
export default useInView;
+
+export const __resetSharedObserverForTests = () => {
+ sharedObserver = null;
+ sharedObserverCtor = null;
+ elementCallbacks.clear();
+};
diff --git a/src/hooks/useReactRoot.js b/src/hooks/useReactRoot.js
index 8678da0..af2ec13 100644
--- a/src/hooks/useReactRoot.js
+++ b/src/hooks/useReactRoot.js
@@ -1,11 +1,36 @@
import {useEffect, useRef, useCallback} from 'react';
import {createRoot} from 'react-dom/client';
-const useReactRoot = (containerRef, shouldRender, renderJsx, heightRef) => {
+const DEFAULT_PLACEHOLDER_HEIGHT = 120;
+
+const useReactRoot = (containerRef, shouldRender, renderJsx, heightRef, options) => {
+ const onHeightRecord = options && options.onHeightRecord;
const reactRootRef = useRef(null);
const runnerRef = useRef(null);
const mountedRef = useRef(false);
const resizeObserverRef = useRef(null);
+ const heightLockReleasedRef = useRef(false);
+ const resizeRafRef = useRef(null);
+
+ const notifyHeight = useCallback((h) => {
+ if (h > 0) {
+ heightRef.current = h;
+ if (typeof onHeightRecord === 'function') {
+ onHeightRecord(h);
+ }
+ }
+ }, [heightRef, onHeightRecord]);
+
+ const disconnectResizeObserver = useCallback(() => {
+ if (resizeRafRef.current) {
+ cancelAnimationFrame(resizeRafRef.current);
+ resizeRafRef.current = null;
+ }
+ if (resizeObserverRef.current) {
+ resizeObserverRef.current.disconnect();
+ resizeObserverRef.current = null;
+ }
+ }, []);
const getRunner = useCallback(() => {
const container = containerRef.current;
@@ -42,26 +67,46 @@ const useReactRoot = (containerRef, shouldRender, renderJsx, heightRef) => {
const recordHeight = useCallback((runner) => {
if (!runner) return 0;
const h = runner.getBoundingClientRect().height;
- if (h > 0) {
- heightRef.current = h;
- }
+ notifyHeight(h);
return h;
+ }, [notifyHeight]);
+
+ const applyPlaceholderHeight = useCallback((runner) => {
+ const savedHeight = heightRef.current > 0 ? heightRef.current : DEFAULT_PLACEHOLDER_HEIGHT;
+ runner.className = 'example-driver-placeholder';
+ runner.style.height = savedHeight + 'px';
+ runner.style.minHeight = savedHeight + 'px';
}, [heightRef]);
+ const releaseHeightLock = useCallback((runner) => {
+ if (!runner || heightLockReleasedRef.current) return;
+ heightLockReleasedRef.current = true;
+ runner.style.height = '';
+ runner.style.minHeight = '';
+ }, []);
+
const ensureResizeObserver = useCallback((runner) => {
if (!runner) return;
if (resizeObserverRef.current) return;
if (typeof window === 'undefined' || typeof window.ResizeObserver !== 'function') return;
const ro = new window.ResizeObserver(() => {
- const h = runner.getBoundingClientRect().height;
- if (h > 0) {
- heightRef.current = h;
+ // Never mutate DOM or trigger React setState inside RO callback — defer to rAF
+ if (resizeRafRef.current) {
+ cancelAnimationFrame(resizeRafRef.current);
}
+ resizeRafRef.current = requestAnimationFrame(() => {
+ resizeRafRef.current = null;
+ if (!mountedRef.current || !runnerRef.current) return;
+ const h = runnerRef.current.getBoundingClientRect().height;
+ if (h > 0) {
+ notifyHeight(h);
+ }
+ });
});
ro.observe(runner);
resizeObserverRef.current = ro;
- }, [heightRef]);
+ }, [notifyHeight]);
useEffect(() => {
const container = containerRef.current;
@@ -69,15 +114,20 @@ const useReactRoot = (containerRef, shouldRender, renderJsx, heightRef) => {
let runner = getRunner();
- // shouldRender=true but JSX not ready yet: keep current DOM (typically placeholder with last height)
if (shouldRender && !renderJsx) {
+ disconnectResizeObserver();
+ if (!runner) {
+ runner = createRunner('example-driver-placeholder');
+ }
+ applyPlaceholderHeight(runner);
return;
}
if (!shouldRender) {
mountedRef.current = false;
+ heightLockReleasedRef.current = false;
+ disconnectResizeObserver();
- // If we never rendered and there's no recorded height, do nothing (avoid creating empty placeholder nodes)
if (!runner && !(heightRef.current > 0)) {
return;
}
@@ -86,14 +136,8 @@ const useReactRoot = (containerRef, shouldRender, renderJsx, heightRef) => {
runner = createRunner('example-driver-placeholder');
}
- // record last rendered height, then keep it by setting runner height
recordHeight(runner);
- const savedHeight = heightRef.current;
-
- runner.className = 'example-driver-placeholder';
- if (savedHeight > 0) {
- runner.style.height = savedHeight + 'px';
- }
+ applyPlaceholderHeight(runner);
if (reactRootRef.current) {
reactRootRef.current.render(null);
@@ -101,13 +145,17 @@ const useReactRoot = (containerRef, shouldRender, renderJsx, heightRef) => {
return;
}
- // Mount / update
+ heightLockReleasedRef.current = false;
+
if (!runner) {
runner = createRunner('example-driver-runner');
}
runner.className = 'example-driver-runner';
- runner.style.height = '';
+ const lockedHeight = heightRef.current > 0 ? heightRef.current : 0;
+ if (lockedHeight > 0) {
+ runner.style.minHeight = lockedHeight + 'px';
+ }
const root = ensureRoot(runner);
ensureResizeObserver(runner);
@@ -115,30 +163,26 @@ const useReactRoot = (containerRef, shouldRender, renderJsx, heightRef) => {
mountedRef.current = true;
root.render(renderJsx);
- // Fallback: without ResizeObserver, record height after paint
- if (typeof window === 'undefined' || typeof window.ResizeObserver !== 'function') {
+ requestAnimationFrame(() => {
requestAnimationFrame(() => {
- if (mountedRef.current) {
+ if (mountedRef.current && runner) {
recordHeight(runner);
+ releaseHeightLock(runner);
}
});
- }
+ });
return () => {
mountedRef.current = false;
};
- }, [containerRef, shouldRender, renderJsx, heightRef, getRunner, createRunner, ensureRoot, ensureResizeObserver, recordHeight]);
+ }, [containerRef, shouldRender, renderJsx, heightRef, getRunner, createRunner, ensureRoot, ensureResizeObserver, recordHeight, applyPlaceholderHeight, releaseHeightLock, disconnectResizeObserver]);
useEffect(() => {
return () => {
- if (resizeObserverRef.current) {
- resizeObserverRef.current.disconnect();
- resizeObserverRef.current = null;
- }
+ disconnectResizeObserver();
if (reactRootRef.current) {
const root = reactRootRef.current;
reactRootRef.current = null;
- // Defer unmount to avoid "unmount while React is rendering" warnings during React Testing Library cleanup.
setTimeout(() => {
try {
root.unmount();
@@ -149,7 +193,7 @@ const useReactRoot = (containerRef, shouldRender, renderJsx, heightRef) => {
}
runnerRef.current = null;
};
- }, []);
+ }, [disconnectResizeObserver]);
};
export default useReactRoot;