From 3212b71159e07d3ad22775526a91a261234d4e72 Mon Sep 17 00:00:00 2001 From: Linzp Date: Tue, 16 Jun 2026 17:17:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8D=B8=E8=BD=BD=E6=97=B6?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 0 -> 8196 bytes package.json | 2 +- src/components/LiveCode.js | 25 ++++- src/hooks/__tests__/heightStability.test.js | 109 +++++++++++++------- src/hooks/__tests__/useInView.test.js | 88 ++++++++++------ src/hooks/__tests__/useReactRoot.test.js | 52 +++++++++- src/hooks/useInView.js | 44 +++++--- src/hooks/useReactRoot.js | 104 +++++++++++++------ 8 files changed, 303 insertions(+), 121 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..06d69c5b99b99e21547e93dfa6db2c5f8f2265a7 GIT binary patch literal 8196 zcmeHLF>4e-6n?XJ=1z=o7=*-XwUF2dQDbA_8cqufQPP{+%Y`I(=N-8m8mlcVf}N1o zG?q3&@E-^kDu{)kMM{x?AlQf!@Ov|J?3>%$pp6(Z1G{gT_r3kT{cd*m-V%|T={2T^ z#)+tmjbr5qc0&f|dCQEz&P*aI)KhbDYIeTe>SU09!aLv{@D6wfyaV2W|HT2kvw4(r z-uou&_uc{Tz=3ptpAR86j**RtiTcrjoo)dTitu> zp-egELyL=SOiVQ8q-^t{EVHs5iqh;jf2iT4A`|`IJK!D2I>5bqkKb{%u&_~(UBk3PS;;^rHB<~vL2w}D_s-=(EKFD+4z>XIEd@EXoOfRGxd zgN;iUH?@WgDpit?l6vejYXo{&Ntf=>nyaR`12s-wYB~Ax!?9~d%K#=-l8140n5Wkv z=vbx(El}*Ln0NveA}clQKEHA6#ar!%gGdz&M>zac0|C}U1N>cgJy7uYfy_zn?O&BI z#n*N{*xFV*WgeDycndY0nhM};)4Y4lw|CQw_dqaN#`u2gn< zJ!WOOSgG;enG;{_eYyueRr@D+sr!7Ku z^7qU~Nj*g0SWf|KcV%!Ly*ZU;B!6q`+(fCrj&z;m&1M}wrTLiC5AVQ!bD&@Zk8}Tj zHUInne!DEM*E`@H_zMSAu{v9wL4ob9Go{>XhuCV^crY$8QI}xH5T4iVIHGOG^;~Fk dk&TIox)e3K6!9N@2*5uB|NiG+z>kCMz;76 { 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;