Add additional fixtures for FragmentInstance text node support (#35631)

Stacked on https://github.com/facebook/react/pull/35630

- Adds test case for compareDocumentPosition, missing before and also
extending to text nodes
- Adds event handling fixture case for text
- Adds getRootNode fixture case for text
This commit is contained in:
Jack Pope
2026-01-28 14:55:07 -05:00
committed by GitHub
parent 875b06489f
commit 90b2dd442c
7 changed files with 669 additions and 48 deletions

View File

@@ -0,0 +1,53 @@
import TestCase from '../../TestCase';
import Fixture from '../../Fixture';
import CompareDocumentPositionFragmentContainer from './CompareDocumentPositionFragmentContainer';
const React = window.React;
export default function CompareDocumentPositionCase() {
return (
<TestCase title="compareDocumentPosition">
<TestCase.Steps>
<li>Click the "Compare All Positions" button</li>
</TestCase.Steps>
<TestCase.ExpectedResult>
The compareDocumentPosition method compares the position of the fragment
relative to other elements in the DOM. The "Before Element" should be
PRECEDING the fragment, and the "After Element" should be FOLLOWING.
Elements inside the fragment should be CONTAINED_BY.
</TestCase.ExpectedResult>
<Fixture>
<Fixture.Controls>
<CompareDocumentPositionFragmentContainer>
<div
style={{
padding: '10px',
backgroundColor: 'lightblue',
borderRadius: '4px',
marginBottom: '8px',
}}>
First child element
</div>
<div
style={{
padding: '10px',
backgroundColor: 'lightgreen',
borderRadius: '4px',
marginBottom: '8px',
}}>
Second child element
</div>
<div
style={{
padding: '10px',
backgroundColor: 'lightpink',
borderRadius: '4px',
}}>
Third child element
</div>
</CompareDocumentPositionFragmentContainer>
</Fixture.Controls>
</Fixture>
</TestCase>
);
}

View File

@@ -0,0 +1,246 @@
const React = window.React;
const {Fragment, useRef, useState} = React;
const POSITION_FLAGS = {
DISCONNECTED: 0x01,
PRECEDING: 0x02,
FOLLOWING: 0x04,
CONTAINS: 0x08,
CONTAINED_BY: 0x10,
IMPLEMENTATION_SPECIFIC: 0x20,
};
function getPositionDescription(bitmask) {
const flags = [];
if (bitmask & POSITION_FLAGS.DISCONNECTED) flags.push('DISCONNECTED');
if (bitmask & POSITION_FLAGS.PRECEDING) flags.push('PRECEDING');
if (bitmask & POSITION_FLAGS.FOLLOWING) flags.push('FOLLOWING');
if (bitmask & POSITION_FLAGS.CONTAINS) flags.push('CONTAINS');
if (bitmask & POSITION_FLAGS.CONTAINED_BY) flags.push('CONTAINED_BY');
if (bitmask & POSITION_FLAGS.IMPLEMENTATION_SPECIFIC)
flags.push('IMPLEMENTATION_SPECIFIC');
return flags.length > 0 ? flags.join(' | ') : 'SAME';
}
function ResultRow({label, result, color}) {
if (!result) return null;
return (
<div
style={{
padding: '10px 14px',
marginBottom: '8px',
backgroundColor: '#f8f9fa',
borderLeft: `4px solid ${color}`,
borderRadius: '4px',
}}>
<div
style={{
fontWeight: 'bold',
marginBottom: '6px',
color: '#333',
}}>
{label}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gap: '4px 12px',
fontSize: '13px',
fontFamily: 'monospace',
}}>
<span style={{color: '#666'}}>Raw value:</span>
<span style={{color: '#333'}}>{result.raw}</span>
<span style={{color: '#666'}}>Flags:</span>
<span style={{color: color, fontWeight: 500}}>
{getPositionDescription(result.raw)}
</span>
</div>
</div>
);
}
export default function CompareDocumentPositionFragmentContainer({children}) {
const fragmentRef = useRef(null);
const beforeRef = useRef(null);
const afterRef = useRef(null);
const insideRef = useRef(null);
const [results, setResults] = useState(null);
const compareAll = () => {
const fragment = fragmentRef.current;
const beforePos = fragment.compareDocumentPosition(beforeRef.current);
const afterPos = fragment.compareDocumentPosition(afterRef.current);
const insidePos = insideRef.current
? fragment.compareDocumentPosition(insideRef.current)
: null;
setResults({
before: {raw: beforePos},
after: {raw: afterPos},
inside: insidePos !== null ? {raw: insidePos} : null,
});
};
return (
<Fragment>
<div style={{marginBottom: '16px'}}>
<button
onClick={compareAll}
style={{
padding: '8px 16px',
fontSize: '14px',
fontWeight: 'bold',
cursor: 'pointer',
}}>
Compare All Positions
</button>
{results && (
<span style={{marginLeft: '12px', color: '#666'}}>
Comparison complete
</span>
)}
</div>
<div style={{display: 'flex', gap: '24px'}}>
<div style={{flex: '0 0 300px'}}>
<div
style={{
padding: '16px',
backgroundColor: '#f0f0f0',
borderRadius: '8px',
}}>
<div
ref={beforeRef}
style={{
padding: '12px',
backgroundColor: '#d4edda',
border: '2px solid #28a745',
borderRadius: '4px',
marginBottom: '12px',
textAlign: 'center',
fontWeight: 'bold',
color: '#155724',
}}>
Before Element
</div>
<div
style={{
padding: '12px',
backgroundColor: '#fff3cd',
border: '2px dashed #ffc107',
borderRadius: '4px',
marginBottom: '12px',
}}>
<div
style={{
fontSize: '11px',
color: '#856404',
marginBottom: '8px',
fontWeight: 'bold',
}}>
FRAGMENT
</div>
<div ref={insideRef}>
<Fragment ref={fragmentRef}>{children}</Fragment>
</div>
</div>
<div
ref={afterRef}
style={{
padding: '12px',
backgroundColor: '#f8d7da',
border: '2px solid #dc3545',
borderRadius: '4px',
textAlign: 'center',
fontWeight: 'bold',
color: '#721c24',
}}>
After Element
</div>
</div>
</div>
<div style={{flex: 1}}>
<div
style={{
fontSize: '14px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#333',
}}>
Comparison Results
</div>
{!results && (
<div
style={{
padding: '20px',
backgroundColor: '#f8f9fa',
borderRadius: '4px',
color: '#666',
textAlign: 'center',
}}>
Click "Compare All Positions" to see results
</div>
)}
{results && (
<Fragment>
<ResultRow
label='vs "Before Element"'
result={results.before}
color="#28a745"
/>
<ResultRow
label='vs "After Element"'
result={results.after}
color="#dc3545"
/>
{results.inside && (
<ResultRow
label='vs "Inside Element"'
result={results.inside}
color="#ffc107"
/>
)}
<div
style={{
marginTop: '16px',
padding: '12px',
backgroundColor: '#e7f3ff',
borderRadius: '4px',
fontSize: '12px',
color: '#0c5460',
}}>
<strong>Flag Reference:</strong>
<div
style={{
marginTop: '8px',
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gap: '2px 12px',
}}>
<code>0x01</code>
<span>DISCONNECTED</span>
<code>0x02</code>
<span>PRECEDING (other is before fragment)</span>
<code>0x04</code>
<span>FOLLOWING (other is after fragment)</span>
<code>0x08</code>
<span>CONTAINS (other contains fragment)</span>
<code>0x10</code>
<span>CONTAINED_BY (other is inside fragment)</span>
</div>
</div>
</Fragment>
)}
</div>
</div>
</Fragment>
);
}

View File

@@ -0,0 +1,112 @@
const React = window.React;
const {Fragment, useRef, useState} = React;
export default function EventFragmentContainer({children}) {
const fragmentRef = useRef(null);
const [eventLog, setEventLog] = useState([]);
const [listenerAdded, setListenerAdded] = useState(false);
const [bubblesState, setBubblesState] = useState(true);
const logEvent = message => {
setEventLog(prev => [...prev, message]);
};
const fragmentClickHandler = () => {
logEvent('Fragment event listener fired');
};
const addListener = () => {
fragmentRef.current.addEventListener('click', fragmentClickHandler);
setListenerAdded(true);
logEvent('Added click listener to fragment');
};
const removeListener = () => {
fragmentRef.current.removeEventListener('click', fragmentClickHandler);
setListenerAdded(false);
logEvent('Removed click listener from fragment');
};
const dispatchClick = () => {
fragmentRef.current.dispatchEvent(
new MouseEvent('click', {bubbles: bubblesState})
);
logEvent(`Dispatched click event (bubbles: ${bubblesState})`);
};
const clearLog = () => {
setEventLog([]);
};
return (
<Fragment>
<div
style={{
marginBottom: '16px',
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
alignItems: 'center',
}}>
<select
value={bubblesState ? 'true' : 'false'}
onChange={e => setBubblesState(e.target.value === 'true')}
style={{padding: '6px 10px'}}>
<option value="true">Bubbles: true</option>
<option value="false">Bubbles: false</option>
</select>
<button onClick={dispatchClick} style={{padding: '6px 12px'}}>
Dispatch click event
</button>
<button
onClick={addListener}
disabled={listenerAdded}
style={{padding: '6px 12px'}}>
Add event listener
</button>
<button
onClick={removeListener}
disabled={!listenerAdded}
style={{padding: '6px 12px'}}>
Remove event listener
</button>
<button onClick={clearLog} style={{padding: '6px 12px'}}>
Clear log
</button>
</div>
<div
onClick={() => logEvent('Parent div clicked')}
style={{
padding: '12px',
border: '1px dashed #ccc',
borderRadius: '4px',
backgroundColor: '#fff',
}}>
<Fragment ref={fragmentRef}>{children}</Fragment>
</div>
{eventLog.length > 0 && (
<div
style={{
marginTop: '12px',
padding: '10px',
backgroundColor: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: '4px',
maxHeight: '150px',
overflow: 'auto',
fontFamily: 'monospace',
fontSize: '13px',
}}>
<strong>Event Log:</strong>
<ul style={{margin: '5px 0', paddingLeft: '20px'}}>
{eventLog.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</div>
)}
</Fragment>
);
}

View File

@@ -1,46 +1,35 @@
import TestCase from '../../TestCase';
import Fixture from '../../Fixture';
import EventFragmentContainer from './EventFragmentContainer';
const React = window.React;
const {Fragment, useEffect, useRef, useState} = React;
const {useState} = React;
function WrapperComponent(props) {
return props.children;
}
function handler(e) {
const text = e.currentTarget.innerText;
alert('You clicked: ' + text);
}
export default function EventListenerCase() {
const fragmentRef = useRef(null);
const [extraChildCount, setExtraChildCount] = useState(0);
useEffect(() => {
fragmentRef.current.addEventListener('click', handler);
const lastFragmentRefValue = fragmentRef.current;
return () => {
lastFragmentRefValue.removeEventListener('click', handler);
};
});
return (
<TestCase title="Event Registration">
<TestCase.Steps>
<li>Click one of the children, observe the alert</li>
<li>Add a new child, click it, observe the alert</li>
<li>Remove the event listeners, click a child, observe no alert</li>
<li>Add the event listeners back, click a child, observe the alert</li>
<li>
Click "Add event listener" to attach a click handler to the fragment
</li>
<li>Click "Dispatch click event" to dispatch a click event</li>
<li>Observe the event log showing the event fired</li>
<li>Add a new child, dispatch again to see it still works</li>
<li>
Click "Remove event listener" and dispatch again to see no event fires
</li>
</TestCase.Steps>
<TestCase.ExpectedResult>
<p>
Fragment refs can manage event listeners on the first level of host
children. This page loads with an effect that sets up click event
hanndlers on each child card. Clicking on a card will show an alert
with the card's text.
children. The event log shows when events are dispatched and handled.
</p>
<p>
New child nodes will also have event listeners applied. Removed nodes
@@ -50,28 +39,17 @@ export default function EventListenerCase() {
<Fixture>
<Fixture.Controls>
<div>Target count: {extraChildCount + 3}</div>
<button
onClick={() => {
setExtraChildCount(prev => prev + 1);
}}>
Add Child
</button>
<button
onClick={() => {
fragmentRef.current.addEventListener('click', handler);
}}>
Add click event listeners
</button>
<button
onClick={() => {
fragmentRef.current.removeEventListener('click', handler);
}}>
Remove click event listeners
</button>
</Fixture.Controls>
<div className="card-container">
<Fragment ref={fragmentRef}>
<div style={{marginBottom: '10px'}}>
Target count: {extraChildCount + 3}
<button
onClick={() => {
setExtraChildCount(prev => prev + 1);
}}
style={{marginLeft: '10px'}}>
Add Child
</button>
</div>
<EventFragmentContainer>
<div className="card" id="child-a">
Child A
</div>
@@ -88,8 +66,8 @@ export default function EventListenerCase() {
</div>
))}
</WrapperComponent>
</Fragment>
</div>
</EventFragmentContainer>
</Fixture.Controls>
</Fixture>
</TestCase>
);

View File

@@ -0,0 +1,79 @@
const React = window.React;
const {Fragment, useRef, useState} = React;
export default function GetRootNodeFragmentContainer({children}) {
const fragmentRef = useRef(null);
const [rootNodeInfo, setRootNodeInfo] = useState(null);
const getRootNodeInfo = () => {
const rootNode = fragmentRef.current.getRootNode();
setRootNodeInfo({
nodeName: rootNode.nodeName,
nodeType: rootNode.nodeType,
nodeTypeLabel: getNodeTypeLabel(rootNode.nodeType),
isDocument: rootNode === document,
});
};
const getNodeTypeLabel = nodeType => {
const types = {
1: 'ELEMENT_NODE',
3: 'TEXT_NODE',
9: 'DOCUMENT_NODE',
11: 'DOCUMENT_FRAGMENT_NODE',
};
return types[nodeType] || `UNKNOWN (${nodeType})`;
};
return (
<Fragment>
<div style={{marginBottom: '16px'}}>
<button
onClick={getRootNodeInfo}
style={{
padding: '8px 16px',
fontSize: '14px',
fontWeight: 'bold',
cursor: 'pointer',
}}>
Get Root Node
</button>
</div>
{rootNodeInfo && (
<div
style={{
marginBottom: '16px',
padding: '12px',
backgroundColor: '#e8f4e8',
border: '1px solid #9c9',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '13px',
}}>
<div style={{marginBottom: '4px'}}>
<strong>Node Name:</strong> {rootNodeInfo.nodeName}
</div>
<div style={{marginBottom: '4px'}}>
<strong>Node Type:</strong> {rootNodeInfo.nodeType} (
{rootNodeInfo.nodeTypeLabel})
</div>
<div>
<strong>Is Document:</strong>{' '}
{rootNodeInfo.isDocument ? 'Yes' : 'No'}
</div>
</div>
)}
<div
style={{
padding: '12px',
border: '1px dashed #ccc',
borderRadius: '4px',
backgroundColor: '#fff',
}}>
<Fragment ref={fragmentRef}>{children}</Fragment>
</div>
</Fragment>
);
}

View File

@@ -1,6 +1,9 @@
import TestCase from '../../TestCase';
import Fixture from '../../Fixture';
import PrintRectsFragmentContainer from './PrintRectsFragmentContainer';
import CompareDocumentPositionFragmentContainer from './CompareDocumentPositionFragmentContainer';
import EventFragmentContainer from './EventFragmentContainer';
import GetRootNodeFragmentContainer from './GetRootNodeFragmentContainer';
const React = window.React;
const {Fragment, useRef, useState} = React;
@@ -242,6 +245,28 @@ function ScrollIntoViewMixed() {
);
}
function CompareDocumentPositionTextNodes() {
return (
<TestCase title="compareDocumentPosition - Text Only">
<TestCase.Steps>
<li>Click the "Compare All Positions" button</li>
</TestCase.Steps>
<TestCase.ExpectedResult>
compareDocumentPosition should work correctly even when the fragment
contains only text nodes. The "Before" element should be PRECEDING the
fragment, and the "After" element should be FOLLOWING.
</TestCase.ExpectedResult>
<Fixture>
<Fixture.Controls>
<CompareDocumentPositionFragmentContainer>
This is text-only content inside the fragment.
</CompareDocumentPositionFragmentContainer>
</Fixture.Controls>
</Fixture>
</TestCase>
);
}
function ObserveTextOnlyWarning() {
const fragmentRef = useRef(null);
const [message, setMessage] = useState('');
@@ -287,6 +312,126 @@ function ObserveTextOnlyWarning() {
);
}
function EventTextOnly() {
return (
<TestCase title="Event Operations - Text Only">
<TestCase.Steps>
<li>
Click "Add event listener" to attach a click handler to the fragment
</li>
<li>Click "Dispatch click event" to dispatch a click event</li>
<li>Observe that the fragment's event listener fires</li>
<li>Click "Remove event listener" and dispatch again</li>
</TestCase.Steps>
<TestCase.ExpectedResult>
Event operations (addEventListener, removeEventListener, dispatchEvent)
work on fragments with text-only content. The event is dispatched on the
fragment's parent element since text nodes cannot be event targets.
</TestCase.ExpectedResult>
<Fixture>
<Fixture.Controls>
<EventFragmentContainer>
This fragment contains only text. Events are handled via the parent.
</EventFragmentContainer>
</Fixture.Controls>
</Fixture>
</TestCase>
);
}
function EventMixed() {
return (
<TestCase title="Event Operations - Mixed Content">
<TestCase.Steps>
<li>
Click "Add event listener" to attach a click handler to the fragment
</li>
<li>Click "Dispatch click event" to dispatch a click event</li>
<li>Observe that the fragment's event listener fires</li>
<li>Click directly on the element or text content to see bubbling</li>
</TestCase.Steps>
<TestCase.ExpectedResult>
Event operations work on fragments with mixed text and element content.
dispatchEvent forwards to the parent element. Clicks on child elements
or text bubble up through the DOM as normal.
</TestCase.ExpectedResult>
<Fixture>
<Fixture.Controls>
<EventFragmentContainer>
Text node before element.
<span
style={{
display: 'inline-block',
padding: '5px 10px',
margin: '0 5px',
backgroundColor: 'lightblue',
border: '1px solid blue',
}}>
Element
</span>
Text node after element.
</EventFragmentContainer>
</Fixture.Controls>
</Fixture>
</TestCase>
);
}
function GetRootNodeTextOnly() {
return (
<TestCase title="getRootNode - Text Only">
<TestCase.Steps>
<li>Click the "Get Root Node" button</li>
</TestCase.Steps>
<TestCase.ExpectedResult>
getRootNode should return the root of the DOM tree containing the
fragment's text content. For a fragment in the main document, this
should return the Document node.
</TestCase.ExpectedResult>
<Fixture>
<Fixture.Controls>
<GetRootNodeFragmentContainer>
This fragment contains only text. getRootNode returns the document.
</GetRootNodeFragmentContainer>
</Fixture.Controls>
</Fixture>
</TestCase>
);
}
function GetRootNodeMixed() {
return (
<TestCase title="getRootNode - Mixed Content">
<TestCase.Steps>
<li>Click the "Get Root Node" button</li>
</TestCase.Steps>
<TestCase.ExpectedResult>
getRootNode should return the root of the DOM tree for fragments with
mixed text and element content. The result is the same whether checking
from text nodes or element nodes within the fragment.
</TestCase.ExpectedResult>
<Fixture>
<Fixture.Controls>
<GetRootNodeFragmentContainer>
Text before element.
<span
style={{
display: 'inline-block',
padding: '5px 10px',
margin: '0 5px',
backgroundColor: 'lightyellow',
border: '1px solid #cc0',
}}>
Element
</span>
Text after element.
</GetRootNodeFragmentContainer>
</Fixture.Controls>
</Fixture>
</TestCase>
);
}
export default function TextNodesCase() {
return (
<TestCase title="Text Node Support">
@@ -297,7 +442,8 @@ export default function TextNodesCase() {
</p>
<p>
<strong>Supported:</strong> getClientRects, compareDocumentPosition,
scrollIntoView
scrollIntoView, getRootNode, addEventListener, removeEventListener,
dispatchEvent
</p>
<p>
<strong>No-op (silent):</strong> focus, focusLast (text nodes cannot
@@ -310,10 +456,15 @@ export default function TextNodesCase() {
</TestCase.ExpectedResult>
<GetClientRectsTextOnly />
<GetClientRectsMixed />
<CompareDocumentPositionTextNodes />
<FocusTextOnlyNoop />
<ScrollIntoViewTextOnly />
<ScrollIntoViewMixed />
<ObserveTextOnlyWarning />
<EventTextOnly />
<EventMixed />
<GetRootNodeTextOnly />
<GetRootNodeMixed />
</TestCase>
);
}

View File

@@ -5,6 +5,7 @@ import IntersectionObserverCase from './IntersectionObserverCase';
import ResizeObserverCase from './ResizeObserverCase';
import FocusCase from './FocusCase';
import GetClientRectsCase from './GetClientRectsCase';
import CompareDocumentPositionCase from './CompareDocumentPositionCase';
import ScrollIntoViewCase from './ScrollIntoViewCase';
import TextNodesCase from './TextNodesCase';
@@ -19,6 +20,7 @@ export default function FragmentRefsPage() {
<ResizeObserverCase />
<FocusCase />
<GetClientRectsCase />
<CompareDocumentPositionCase />
<ScrollIntoViewCase />
<TextNodesCase />
</FixtureSet>