@samitouri / QOS-React / commits / c492f97541

[Fiber] Support Suspense boundaries anywhere (excluding hydration) (#32163)

This is a follow up to https://github.com/facebook/react/pull/32069 In the prior change I updated Fizz to allow you to render Suspense boundaries at any level within a react-dom application by treating the document body as the default render scope. This change updates Fiber to provide similar semantics. Note that this update still does not deliver hydration so unifying the Fizz and Fiber implementations in a single App is not possible yet. The implementation required a rework of the getHostSibling and getHostParent algorithms. Now most HostSingletons are invisible from a host positioning perspective. Head is special in that it is a valid host scope so when you have Placements inside of it, it will act as the parent. But body, and html, will not directly participate in host positioning. Additionally to support flipping to a fallback html, head, and body tag in a Suspense fallback I updated the offscreen hiding/unhide logic to pierce through singletons when lookin for matching hidable nod boundaries anywhere (excluding hydration)

Josh Story committed Jan 28, 2025 at 22:42 UTC c492f97541486458ce21653d2669d53d380f0538
8 files changed +654 -175
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+77 -58
@@ -799,24 +799,37 @@ export function appendChildToContainer(
799 container: Container,
800 child: Instance | TextInstance,
801 ): void {
802 - let parentNode;
803 - if (container.nodeType === COMMENT_NODE) {
804 - parentNode = (container.parentNode: any);
805 - if (supportsMoveBefore) {
806 - // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore.
807 - parentNode.moveBefore(child, container);
808 - } else {
809 - parentNode.insertBefore(child, container);
802 + let parentNode: Document | Element;
803 + switch (container.nodeType) {
804 + case COMMENT_NODE: {
805 + parentNode = (container.parentNode: any);
806 + if (supportsMoveBefore) {
807 + // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore.
808 + parentNode.moveBefore(child, container);
809 + } else {
810 + parentNode.insertBefore(child, container);
811 + }
812 + return;
813 }
811 - } else {
812 - parentNode = container;
813 - if (supportsMoveBefore) {
814 - // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore.
815 - parentNode.moveBefore(child, null);
816 - } else {
817 - parentNode.appendChild(child);
814 + case DOCUMENT_NODE: {
815 + parentNode = (container: any).body;
816 + break;
817 }
818 + default: {
819 + if (container.nodeName === 'HTML') {
820 + parentNode = (container.ownerDocument.body: any);
821 + } else {
822 + parentNode = (container: any);
823 + }
824 + }
825 + }
826 + if (supportsMoveBefore) {
827 + // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore.
828 + parentNode.moveBefore(child, null);
829 + } else {
830 + parentNode.appendChild(child);
831 }
832 +
833 // This container might be used for a portal.
834 // If something inside a portal is clicked, that click should bubble
835 // through the React tree. However, on Mobile Safari the click would
@@ -853,21 +866,35 @@ export function insertInContainerBefore(
866 child: Instance | TextInstance,
867 beforeChild: Instance | TextInstance | SuspenseInstance,
868 ): void {
856 - if (container.nodeType === COMMENT_NODE) {
857 - if (supportsMoveBefore) {
858 - // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore.
859 - (container.parentNode: any).moveBefore(child, beforeChild);
860 - } else {
861 - (container.parentNode: any).insertBefore(child, beforeChild);
869 + let parentNode: Document | Element;
870 + switch (container.nodeType) {
871 + case COMMENT_NODE: {
872 + parentNode = (container.parentNode: any);
873 + break;
874 }
863 - } else {
864 - if (supportsMoveBefore) {
865 - // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore.
866 - container.moveBefore(child, beforeChild);
867 - } else {
868 - container.insertBefore(child, beforeChild);
875 + case DOCUMENT_NODE: {
876 + const ownerDocument: Document = (container: any);
877 + parentNode = (ownerDocument.body: any);
878 + break;
879 + }
880 + default: {
881 + if (container.nodeName === 'HTML') {
882 + parentNode = (container.ownerDocument.body: any);
883 + } else {
884 + parentNode = (container: any);
885 + }
886 }
887 }
888 + if (supportsMoveBefore) {
889 + // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore.
890 + parentNode.moveBefore(child, beforeChild);
891 + } else {
892 + parentNode.insertBefore(child, beforeChild);
893 + }
894 +}
895 +
896 +export function isSingletonScope(type: string): boolean {
897 + return type === 'head';
898 }
899
900 function createEvent(type: DOMEventName, bubbles: boolean): Event {
@@ -913,11 +940,22 @@ export function removeChildFromContainer(
940 container: Container,
941 child: Instance | TextInstance | SuspenseInstance,
942 ): void {
916 - if (container.nodeType === COMMENT_NODE) {
917 - (container.parentNode: any).removeChild(child);
918 - } else {
919 - container.removeChild(child);
943 + let parentNode: Document | Element;
944 + switch (container.nodeType) {
945 + case COMMENT_NODE:
946 + parentNode = (container.parentNode: any);
947 + break;
948 + case DOCUMENT_NODE:
949 + parentNode = (container: any).body;
950 + break;
951 + default:
952 + if (container.nodeName === 'HTML') {
953 + parentNode = (container.ownerDocument.body: any);
954 + } else {
955 + parentNode = (container: any);
956 + }
957 }
958 + parentNode.removeChild(child);
959 }
960
961 export function clearSuspenseBoundary(
@@ -965,10 +1003,15 @@ export function clearSuspenseBoundaryFromContainer(
1003 ): void {
1004 if (container.nodeType === COMMENT_NODE) {
1005 clearSuspenseBoundary((container.parentNode: any), suspenseInstance);
968 - } else if (container.nodeType === ELEMENT_NODE) {
969 - clearSuspenseBoundary((container: any), suspenseInstance);
1006 + } else if (container.nodeType === DOCUMENT_NODE) {
1007 + clearSuspenseBoundary((container: any).body, suspenseInstance);
1008 + } else if (container.nodeName === 'HTML') {
1009 + clearSuspenseBoundary(
1010 + (container.ownerDocument.body: any),
1011 + suspenseInstance,
1012 + );
1013 } else {
971 - // Document nodes should never contain suspense boundaries.
1014 + clearSuspenseBoundary((container: any), suspenseInstance);
1015 }
1016 // Retry if any event replaying was blocked on this.
1017 retryIfBlockedOn(container);
@@ -2299,30 +2342,6 @@ export function releaseSingletonInstance(instance: Instance): void {
2342 detachDeletedInstance(instance);
2343 }
2344
2302 -export function clearSingleton(instance: Instance): void {
2303 - const element: Element = (instance: any);
2304 - let node = element.firstChild;
2305 - while (node) {
2306 - const nextNode = node.nextSibling;
2307 - const nodeName = node.nodeName;
2308 - if (
2309 - isMarkedHoistable(node) ||
2310 - nodeName === 'HEAD' ||
2311 - nodeName === 'BODY' ||
2312 - nodeName === 'SCRIPT' ||
2313 - nodeName === 'STYLE' ||
2314 - (nodeName === 'LINK' &&
2315 - ((node: any): HTMLLinkElement).rel.toLowerCase() === 'stylesheet')
2316 - ) {
2317 - // retain these nodes
2318 - } else {
2319 - element.removeChild(node);
2320 - }
2321 - node = nextNode;
2322 - }
2323 - return;
2324 -}
2325 -
2345 // -------------------
2346 // Resources
2347 // -------------------
packages/react-dom/src/__tests__/ReactDOM-test.js
+408
@@ -566,4 +566,412 @@ describe('ReactDOM', () => {
566 ' in App (at **)',
567 ]);
568 });
569 +
570 + it('should render root host components into body scope when the container is a Document', async () => {
571 + function App({phase}) {
572 + return (
573 + <>
574 + {phase < 1 ? null : <div>..before</div>}
575 + {phase < 3 ? <div>before</div> : null}
576 + {phase < 2 ? null : <div>before..</div>}
577 + <html lang="en">
578 + <head data-h="">
579 + {phase < 1 ? null : <meta itemProp="" content="..head" />}
580 + {phase < 3 ? <meta itemProp="" content="head" /> : null}
581 + {phase < 2 ? null : <meta itemProp="" content="head.." />}
582 + </head>
583 + <body data-b="">
584 + {phase < 1 ? null : <div>..inside</div>}
585 + {phase < 3 ? <div>inside</div> : null}
586 + {phase < 2 ? null : <div>inside..</div>}
587 + </body>
588 + </html>
589 + {phase < 1 ? null : <div>..after</div>}
590 + {phase < 3 ? <div>after</div> : null}
591 + {phase < 2 ? null : <div>after..</div>}
592 + </>
593 + );
594 + }
595 +
596 + const root = ReactDOMClient.createRoot(document);
597 + await act(() => {
598 + root.render(<App phase={0} />);
599 + });
600 + expect(document.documentElement.outerHTML).toBe(
601 + '<html lang="en"><head data-h=""><meta itemprop="" content="head"></head><body data-b=""><div>before</div><div>inside</div><div>after</div></body></html>',
602 + );
603 +
604 + // @TODO remove this warning check when we loosen the tag nesting restrictions to allow arbitrary tags at the
605 + // root of the application
606 + assertConsoleErrorDev(['In HTML, <div> cannot be a child of <#document>']);
607 +
608 + await act(() => {
609 + root.render(<App phase={1} />);
610 + });
611 + expect(document.documentElement.outerHTML).toBe(
612 + '<html lang="en"><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"></head><body data-b=""><div>..before</div><div>before</div><div>..inside</div><div>inside</div><div>..after</div><div>after</div></body></html>',
613 + );
614 +
615 + await act(() => {
616 + root.render(<App phase={2} />);
617 + });
618 + expect(document.documentElement.outerHTML).toBe(
619 + '<html lang="en"><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"><meta itemprop="" content="head.."></head><body data-b=""><div>..before</div><div>before</div><div>before..</div><div>..inside</div><div>inside</div><div>inside..</div><div>..after</div><div>after</div><div>after..</div></body></html>',
620 + );
621 +
622 + await act(() => {
623 + root.render(<App phase={3} />);
624 + });
625 + expect(document.documentElement.outerHTML).toBe(
626 + '<html lang="en"><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head.."></head><body data-b=""><div>..before</div><div>before..</div><div>..inside</div><div>inside..</div><div>..after</div><div>after..</div></body></html>',
627 + );
628 +
629 + await act(() => {
630 + root.unmount();
631 + });
632 + expect(document.documentElement.outerHTML).toBe(
633 + '<html><head></head><body></body></html>',
634 + );
635 + });
636 +
637 + it('should render root host components into body scope when the container is a the <html> tag', async () => {
638 + function App({phase}) {
639 + return (
640 + <>
641 + {phase < 1 ? null : <div>..before</div>}
642 + {phase < 3 ? <div>before</div> : null}
643 + {phase < 2 ? null : <div>before..</div>}
644 + <head data-h="">
645 + {phase < 1 ? null : <meta itemProp="" content="..head" />}
646 + {phase < 3 ? <meta itemProp="" content="head" /> : null}
647 + {phase < 2 ? null : <meta itemProp="" content="head.." />}
648 + </head>
649 + <body data-b="">
650 + {phase < 1 ? null : <div>..inside</div>}
651 + {phase < 3 ? <div>inside</div> : null}
652 + {phase < 2 ? null : <div>inside..</div>}
653 + </body>
654 + {phase < 1 ? null : <div>..after</div>}
655 + {phase < 3 ? <div>after</div> : null}
656 + {phase < 2 ? null : <div>after..</div>}
657 + </>
658 + );
659 + }
660 +
661 + const root = ReactDOMClient.createRoot(document.documentElement);
662 + await act(() => {
663 + root.render(<App phase={0} />);
664 + });
665 + expect(document.documentElement.outerHTML).toBe(
666 + '<html><head data-h=""><meta itemprop="" content="head"></head><body data-b=""><div>before</div><div>inside</div><div>after</div></body></html>',
667 + );
668 +
669 + // @TODO remove this warning check when we loosen the tag nesting restrictions to allow arbitrary tags at the
670 + // root of the application
671 + assertConsoleErrorDev(['In HTML, <div> cannot be a child of <html>']);
672 +
673 + await act(() => {
674 + root.render(<App phase={1} />);
675 + });
676 + expect(document.documentElement.outerHTML).toBe(
677 + '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"></head><body data-b=""><div>..before</div><div>before</div><div>..inside</div><div>inside</div><div>..after</div><div>after</div></body></html>',
678 + );
679 +
680 + await act(() => {
681 + root.render(<App phase={2} />);
682 + });
683 + expect(document.documentElement.outerHTML).toBe(
684 + '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"><meta itemprop="" content="head.."></head><body data-b=""><div>..before</div><div>before</div><div>before..</div><div>..inside</div><div>inside</div><div>inside..</div><div>..after</div><div>after</div><div>after..</div></body></html>',
685 + );
686 +
687 + await act(() => {
688 + root.render(<App phase={3} />);
689 + });
690 + expect(document.documentElement.outerHTML).toBe(
691 + '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head.."></head><body data-b=""><div>..before</div><div>before..</div><div>..inside</div><div>inside..</div><div>..after</div><div>after..</div></body></html>',
692 + );
693 +
694 + await act(() => {
695 + root.unmount();
696 + });
697 + expect(document.documentElement.outerHTML).toBe(
698 + '<html><head></head><body></body></html>',
699 + );
700 + });
701 +
702 + it('should render root host components into body scope when the container is a the <body> tag', async () => {
703 + function App({phase}) {
704 + return (
705 + <>
706 + {phase < 1 ? null : <div>..before</div>}
707 + {phase < 3 ? <div>before</div> : null}
708 + {phase < 2 ? null : <div>before..</div>}
709 + <head data-h="">
710 + {phase < 1 ? null : <meta itemProp="" content="..head" />}
711 + {phase < 3 ? <meta itemProp="" content="head" /> : null}
712 + {phase < 2 ? null : <meta itemProp="" content="head.." />}
713 + </head>
714 + {phase < 1 ? null : <div>..inside</div>}
715 + {phase < 3 ? <div>inside</div> : null}
716 + {phase < 2 ? null : <div>inside..</div>}
717 + {phase < 1 ? null : <div>..after</div>}
718 + {phase < 3 ? <div>after</div> : null}
719 + {phase < 2 ? null : <div>after..</div>}
720 + </>
721 + );
722 + }
723 +
724 + const root = ReactDOMClient.createRoot(document.body);
725 + await act(() => {
726 + root.render(<App phase={0} />);
727 + });
728 + expect(document.documentElement.outerHTML).toBe(
729 + '<html><head data-h=""><meta itemprop="" content="head"></head><body><div>before</div><div>inside</div><div>after</div></body></html>',
730 + );
731 +
732 + // @TODO remove this warning check when we loosen the tag nesting restrictions to allow arbitrary tags at the
733 + // root of the application
734 + assertConsoleErrorDev(['In HTML, <head> cannot be a child of <body>']);
735 +
736 + await act(() => {
737 + root.render(<App phase={1} />);
738 + });
739 + expect(document.documentElement.outerHTML).toBe(
740 + '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"></head><body><div>..before</div><div>before</div><div>..inside</div><div>inside</div><div>..after</div><div>after</div></body></html>',
741 + );
742 +
743 + await act(() => {
744 + root.render(<App phase={2} />);
745 + });
746 + expect(document.documentElement.outerHTML).toBe(
747 + '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"><meta itemprop="" content="head.."></head><body><div>..before</div><div>before</div><div>before..</div><div>..inside</div><div>inside</div><div>inside..</div><div>..after</div><div>after</div><div>after..</div></body></html>',
748 + );
749 +
750 + await act(() => {
751 + root.render(<App phase={3} />);
752 + });
753 + expect(document.documentElement.outerHTML).toBe(
754 + '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head.."></head><body><div>..before</div><div>before..</div><div>..inside</div><div>inside..</div><div>..after</div><div>after..</div></body></html>',
755 + );
756 +
757 + await act(() => {
758 + root.unmount();
759 + });
760 + expect(document.documentElement.outerHTML).toBe(
761 + '<html><head></head><body></body></html>',
762 + );
763 + });
764 +
765 + it('should render children of <head> into the document head even when the container is inside the document body', async () => {
766 + function App({phase}) {
767 + return (
768 + <>
769 + <div>before</div>
770 + <head data-h="">
771 + {phase < 1 ? null : <meta itemProp="" content="..head" />}
772 + {phase < 3 ? <meta itemProp="" content="head" /> : null}
773 + {phase < 2 ? null : <meta itemProp="" content="head.." />}
774 + </head>
775 + <div>after</div>
776 + </>
777 + );
778 + }
779 +
780 + const container = document.createElement('main');
781 + document.body.append(container);
782 + const root = ReactDOMClient.createRoot(container);
783 + await act(() => {
784 + root.render(<App phase={0} />);
785 + });
786 + expect(document.documentElement.outerHTML).toBe(
787 + '<html><head data-h=""><meta itemprop="" content="head"></head><body><main><div>before</div><div>after</div></main></body></html>',
788 + );
789 +
790 + // @TODO remove this warning check when we loosen the tag nesting restrictions to allow arbitrary tags at the
791 + // root of the application
792 + assertConsoleErrorDev(['In HTML, <head> cannot be a child of <main>']);
793 +
794 + await act(() => {
795 + root.render(<App phase={1} />);
796 + });
797 + expect(document.documentElement.outerHTML).toBe(
798 + '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"></head><body><main><div>before</div><div>after</div></main></body></html>',
799 + );
800 +
801 + await act(() => {
802 + root.render(<App phase={2} />);
803 + });
804 + expect(document.documentElement.outerHTML).toBe(
805 + '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"><meta itemprop="" content="head.."></head><body><main><div>before</div><div>after</div></main></body></html>',
806 + );
807 +
808 + await act(() => {
809 + root.render(<App phase={3} />);
810 + });
811 + expect(document.documentElement.outerHTML).toBe(
812 + '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head.."></head><body><main><div>before</div><div>after</div></main></body></html>',
813 + );
814 +
815 + await act(() => {
816 + root.unmount();
817 + });
818 + expect(document.documentElement.outerHTML).toBe(
819 + '<html><head></head><body><main></main></body></html>',
820 + );
821 + });
822 +
823 + it('can render a Suspense boundary above the <html> tag', async () => {
824 + let suspendOnNewPromise;
825 + let resolveCurrentPromise;
826 + let currentPromise;
827 + function createNewPromise() {
828 + currentPromise = new Promise(r => {
829 + resolveCurrentPromise = r;
830 + });
831 + return currentPromise;
832 + }
833 + createNewPromise();
834 + function Comp() {
835 + const [promise, setPromise] = React.useState(currentPromise);
836 + suspendOnNewPromise = () => {
837 + setPromise(createNewPromise());
838 + };
839 + React.use(promise);
840 + return null;
841 + }
842 +
843 + const fallback = (
844 + <html data-fallback="">
845 + <body data-fallback="">
846 + <div>fallback</div>
847 + </body>
848 + </html>
849 + );
850 +
851 + const main = (
852 + <html lang="en">
853 + <head>
854 + <meta itemProp="" content="primary" />
855 + </head>
856 + <body>
857 + <div>
858 + <Message />
859 + </div>
860 + </body>
861 + </html>
862 + );
863 +
864 + let suspendOnNewMessage;
865 + let currentMessage;
866 + let resolveCurrentMessage;
867 + function createNewMessage() {
868 + currentMessage = new Promise(r => {
869 + resolveCurrentMessage = r;
870 + });
871 + return currentMessage;
872 + }
873 + createNewMessage();
874 + resolveCurrentMessage('hello world');
875 + function Message() {
876 + const [pendingMessage, setPendingMessage] =
877 + React.useState(currentMessage);
878 + suspendOnNewMessage = () => {
879 + setPendingMessage(createNewMessage());
880 + };
881 + return React.use(pendingMessage);
882 + }
883 +
884 + function App() {
885 + return (
886 + <React.Suspense fallback={fallback}>
887 + <Comp />
888 + {main}
889 + </React.Suspense>
890 + );
891 + }
892 +
893 + const root = ReactDOMClient.createRoot(document);
894 + await act(() => {
895 + root.render(<App />);
896 + });
897 + // The initial render is blocked by promiseA so we see the fallback Document
898 + expect(document.documentElement.outerHTML).toBe(
899 + '<html data-fallback=""><head></head><body data-fallback=""><div>fallback</div></body></html>',
900 + );
901 +
902 + await act(() => {
903 + resolveCurrentPromise();
904 + });
905 + // When promiseA resolves we see the primary Document
906 + expect(document.documentElement.outerHTML).toBe(
907 + '<html lang="en"><head><meta itemprop="" content="primary"></head><body><div>hello world</div></body></html>',
908 + );
909 +
910 + await act(() => {
911 + suspendOnNewPromise();
912 + });
913 + // When we switch to rendering ComponentB synchronously we have to put the Document back into fallback
914 + // The primary content remains hidden until promiseB resolves
915 + expect(document.documentElement.outerHTML).toBe(
916 + '<html data-fallback=""><head><meta itemprop="" content="primary" style="display: none;"></head><body data-fallback=""><div style="display: none;">hello world</div><div>fallback</div></body></html>',
917 + );
918 +
919 + await act(() => {
920 + resolveCurrentPromise();
921 + });
922 + // When promiseB resolves we see the new primary content inside the primary Document
923 + // style attributes stick around after being unhidden by the Suspense boundary
924 + expect(document.documentElement.outerHTML).toBe(
925 + '<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">hello world</div></body></html>',
926 + );
927 +
928 + await act(() => {
929 + React.startTransition(() => {
930 + suspendOnNewPromise();
931 + });
932 + });
933 + expect(document.documentElement.outerHTML).toBe(
934 + '<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">hello world</div></body></html>',
935 + );
936 +
937 + await act(() => {
938 + resolveCurrentPromise();
939 + });
940 + expect(document.documentElement.outerHTML).toBe(
941 + '<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">hello world</div></body></html>',
942 + );
943 +
944 + await act(() => {
945 + suspendOnNewMessage();
946 + });
947 + // When we update the message itself we will be causing updates on the primary content of the Suspense boundary.
948 + // The reason we also test for this is to make sure we don't double acquire the document singletons while
949 + // disappearing and reappearing layout effects
950 + expect(document.documentElement.outerHTML).toBe(
951 + '<html data-fallback=""><head><meta itemprop="" content="primary" style="display: none;"></head><body data-fallback=""><div style="display: none;">hello world</div><div>fallback</div></body></html>',
952 + );
953 +
954 + await act(() => {
955 + resolveCurrentMessage('hello you!');
956 + });
957 + expect(document.documentElement.outerHTML).toBe(
958 + '<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">hello you!</div></body></html>',
959 + );
960 +
961 + await act(() => {
962 + React.startTransition(() => {
963 + suspendOnNewMessage();
964 + });
965 + });
966 + expect(document.documentElement.outerHTML).toBe(
967 + '<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">hello you!</div></body></html>',
968 + );
969 +
970 + await act(() => {
971 + resolveCurrentMessage('goodbye!');
972 + });
973 + expect(document.documentElement.outerHTML).toBe(
974 + '<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">goodbye!</div></body></html>',
975 + );
976 + });
977 });
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+11 -26
@@ -31,7 +31,6 @@ let hasErrored = false;
31 let fatalError = undefined;
32 let renderOptions;
33 let waitForAll;
34 -let waitForThrow;
34 let assertLog;
35 let Scheduler;
36 let clientAct;
@@ -76,7 +75,6 @@ describe('ReactDOMFloat', () => {
75
76 const InternalTestUtils = require('internal-test-utils');
77 waitForAll = InternalTestUtils.waitForAll;
79 - waitForThrow = InternalTestUtils.waitForThrow;
78 assertLog = InternalTestUtils.assertLog;
79 clientAct = InternalTestUtils.act;
80 assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
@@ -507,14 +505,7 @@ describe('ReactDOMFloat', () => {
505 </html>
506 </>,
507 );
510 - let aggregateError = await waitForThrow();
511 - expect(aggregateError.errors.length).toBe(2);
512 - expect(aggregateError.errors[0].message).toContain(
513 - 'Invalid insertion of NOSCRIPT',
514 - );
515 - expect(aggregateError.errors[1].message).toContain(
516 - 'The node to be removed is not a child of this node',
517 - );
508 + await waitForAll([]);
509 assertConsoleErrorDev([
510 [
511 'Cannot render <noscript> outside the main document. Try moving it into the root <head> tag.',
@@ -579,14 +570,7 @@ describe('ReactDOMFloat', () => {
570 <link rel="stylesheet" href="foo" />
571 </>,
572 );
582 - aggregateError = await waitForThrow();
583 - expect(aggregateError.errors.length).toBe(2);
584 - expect(aggregateError.errors[0].message).toContain(
585 - 'Invalid insertion of LINK',
586 - );
587 - expect(aggregateError.errors[1].message).toContain(
588 - 'The node to be removed is not a child of this node',
589 - );
573 + await waitForAll([]);
574 assertConsoleErrorDev([
575 [
576 'Cannot render a <link rel="stylesheet" /> outside the main document without knowing its precedence. ' +
@@ -644,14 +628,7 @@ describe('ReactDOMFloat', () => {
628 </html>
629 </>,
630 );
647 - aggregateError = await waitForThrow();
648 - expect(aggregateError.errors.length).toBe(2);
649 - expect(aggregateError.errors[0].message).toContain(
650 - 'Invalid insertion of LINK',
651 - );
652 - expect(aggregateError.errors[1].message).toContain(
653 - 'The node to be removed is not a child of this node',
654 - );
631 + await waitForAll([]);
632 assertConsoleErrorDev(
633 [
634 'Cannot render a <link> with onLoad or onError listeners outside the main document. ' +
@@ -660,6 +637,7 @@ describe('ReactDOMFloat', () => {
637 ],
638 {withoutStack: true},
639 );
640 + return;
641 });
642
643 it('can acquire a resource after releasing it in the same commit', async () => {
@@ -1257,6 +1235,13 @@ body {
1235 pipe(writable);
1236 });
1237
1238 + expect(getMeaningfulChildren(document)).toEqual(
1239 + <html>
1240 + <head />
1241 + <body>loading...</body>
1242 + </html>,
1243 + );
1244 +
1245 await act(() => {
1246 resolveText('unblock');
1247 });
packages/react-reconciler/src/ReactFiberBeginWork.js
+7 -14
@@ -97,6 +97,7 @@ import {
97 Passive,
98 DidDefer,
99 ViewTransitionNamedStatic,
100 + LayoutStatic,
101 } from './ReactFiberFlags';
102 import {
103 disableLegacyContext,
@@ -1703,21 +1704,13 @@ function updateHostSingleton(
1704 }
1705
1706 const nextChildren = workInProgress.pendingProps.children;
1706 -
1707 - if (current === null && !getIsHydrating()) {
1708 - // Similar to Portals we append Singleton children in the commit phase. So we
1709 - // Track insertions even on mount.
1710 - // TODO: Consider unifying this with how the root works.
1711 - workInProgress.child = reconcileChildFibers(
1712 - workInProgress,
1713 - null,
1714 - nextChildren,
1715 - renderLanes,
1716 - );
1717 - } else {
1718 - reconcileChildren(current, workInProgress, nextChildren, renderLanes);
1719 - }
1707 + reconcileChildren(current, workInProgress, nextChildren, renderLanes);
1708 markRef(current, workInProgress);
1709 + if (current === null) {
1710 + // We mark Singletons with a static flag to more efficiently manage their
1711 + // ownership of the singleton host instance when in offscreen trees including Suspense
1712 + workInProgress.flags |= LayoutStatic;
1713 + }
1714 return workInProgress.child;
1715 }
1716
packages/react-reconciler/src/ReactFiberCommitHostEffects.js
+72 -42
@@ -47,8 +47,9 @@ import {
47 commitHydratedSuspenseInstance,
48 removeChildFromContainer,
49 removeChild,
50 - clearSingleton,
50 acquireSingletonInstance,
51 + releaseSingletonInstance,
52 + isSingletonScope,
53 } from './ReactFiberConfig';
54 import {captureCommitPhaseError} from './ReactFiberWorkLoop';
55 import {trackHostMutation} from './ReactFiberMutationTracking';
@@ -218,7 +219,9 @@ function isHostParent(fiber: Fiber): boolean {
219 fiber.tag === HostComponent ||
220 fiber.tag === HostRoot ||
221 (supportsResources ? fiber.tag === HostHoistable : false) ||
221 - (supportsSingletons ? fiber.tag === HostSingleton : false) ||
222 + (supportsSingletons
223 + ? fiber.tag === HostSingleton && isSingletonScope(fiber.type)
224 + : false) ||
225 fiber.tag === HostPortal
226 );
227 }
@@ -245,9 +248,19 @@ function getHostSibling(fiber: Fiber): ?Instance {
248 while (
249 node.tag !== HostComponent &&
250 node.tag !== HostText &&
248 - (!supportsSingletons ? true : node.tag !== HostSingleton) &&
251 node.tag !== DehydratedFragment
252 ) {
253 + // If this is a host singleton we go deeper if it's not a special
254 + // singleton scope. If it is a singleton scope we skip over it because
255 + // you only insert against this scope when you are already inside of it
256 + if (
257 + supportsSingletons &&
258 + node.tag === HostSingleton &&
259 + isSingletonScope(node.type)
260 + ) {
261 + continue siblings;
262 + }
263 +
264 // If it is not host node and, we might have a host node inside it.
265 // Try to search down until we find one.
266 if (node.flags & Placement) {
@@ -286,23 +299,30 @@ function insertOrAppendPlacementNodeIntoContainer(
299 appendChildToContainer(parent, stateNode);
300 }
301 trackHostMutation();
289 - } else if (
290 - tag === HostPortal ||
291 - (supportsSingletons ? tag === HostSingleton : false)
292 - ) {
302 + return;
303 + } else if (tag === HostPortal) {
304 // If the insertion itself is a portal, then we don't want to traverse
305 // down its children. Instead, we'll get insertions from each child in
306 // the portal directly.
296 - // If the insertion is a HostSingleton then it will be placed independently
297 - } else {
298 - const child = node.child;
299 - if (child !== null) {
300 - insertOrAppendPlacementNodeIntoContainer(child, before, parent);
301 - let sibling = child.sibling;
302 - while (sibling !== null) {
303 - insertOrAppendPlacementNodeIntoContainer(sibling, before, parent);
304 - sibling = sibling.sibling;
305 - }
307 + return;
308 + }
309 +
310 + if (
311 + (supportsSingletons ? tag === HostSingleton : false) &&
312 + isSingletonScope(node.type)
313 + ) {
314 + // This singleton is the parent of deeper nodes and needs to become
315 + // the parent for child insertions and appends
316 + parent = node.stateNode;
317 + }
318 +
319 + const child = node.child;
320 + if (child !== null) {
321 + insertOrAppendPlacementNodeIntoContainer(child, before, parent);
322 + let sibling = child.sibling;
323 + while (sibling !== null) {
324 + insertOrAppendPlacementNodeIntoContainer(sibling, before, parent);
325 + sibling = sibling.sibling;
326 }
327 }
328 }
@@ -322,23 +342,30 @@ function insertOrAppendPlacementNode(
342 appendChild(parent, stateNode);
343 }
344 trackHostMutation();
325 - } else if (
326 - tag === HostPortal ||
327 - (supportsSingletons ? tag === HostSingleton : false)
328 - ) {
345 + return;
346 + } else if (tag === HostPortal) {
347 // If the insertion itself is a portal, then we don't want to traverse
348 // down its children. Instead, we'll get insertions from each child in
349 // the portal directly.
332 - // If the insertion is a HostSingleton then it will be placed independently
333 - } else {
334 - const child = node.child;
335 - if (child !== null) {
336 - insertOrAppendPlacementNode(child, before, parent);
337 - let sibling = child.sibling;
338 - while (sibling !== null) {
339 - insertOrAppendPlacementNode(sibling, before, parent);
340 - sibling = sibling.sibling;
341 - }
350 + return;
351 + }
352 +
353 + if (
354 + (supportsSingletons ? tag === HostSingleton : false) &&
355 + isSingletonScope(node.type)
356 + ) {
357 + // This singleton is the parent of deeper nodes and needs to become
358 + // the parent for child insertions and appends
359 + parent = node.stateNode;
360 + }
361 +
362 + const child = node.child;
363 + if (child !== null) {
364 + insertOrAppendPlacementNode(child, before, parent);
365 + let sibling = child.sibling;
366 + while (sibling !== null) {
367 + insertOrAppendPlacementNode(sibling, before, parent);
368 + sibling = sibling.sibling;
369 }
370 }
371 }
@@ -348,14 +375,6 @@ function commitPlacement(finishedWork: Fiber): void {
375 return;
376 }
377
351 - if (supportsSingletons) {
352 - if (finishedWork.tag === HostSingleton) {
353 - // Singletons are already in the Host and don't need to be placed
354 - // Since they operate somewhat like Portals though their children will
355 - // have Placement and will get placed inside them
356 - return;
357 - }
358 - }
378 // Recursively insert all host nodes into the parent.
379 const parentFiber = getHostParentFiber(finishedWork);
380
@@ -546,13 +565,12 @@ export function commitHostHydratedSuspense(
565 }
566 }
567
549 -export function commitHostSingleton(finishedWork: Fiber) {
568 +export function commitHostSingletonAcquisition(finishedWork: Fiber) {
569 const singleton = finishedWork.stateNode;
570 const props = finishedWork.memoizedProps;
571
572 try {
554 - // This was a new mount, we need to clear and set initial properties
555 - clearSingleton(singleton);
573 + // This was a new mount, acquire the DOM instance and set initial properties
574 if (__DEV__) {
575 runWithFiberInDEV(
576 finishedWork,
@@ -574,3 +592,15 @@ export function commitHostSingleton(finishedWork: Fiber) {
592 captureCommitPhaseError(finishedWork, finishedWork.return, error);
593 }
594 }
595 +
596 +export function commitHostSingletonRelease(releasingWork: Fiber) {
597 + if (__DEV__) {
598 + runWithFiberInDEV(
599 + releasingWork,
600 + releaseSingletonInstance,
601 + releasingWork.stateNode,
602 + );
603 + } else {
604 + releaseSingletonInstance(releasingWork.stateNode);
605 + }
606 +}
packages/react-reconciler/src/ReactFiberCommitWork.js
+77 -33
@@ -152,7 +152,6 @@ import {
152 prepareForCommit,
153 beforeActiveInstanceBlur,
154 detachDeletedInstance,
155 - releaseSingletonInstance,
155 getHoistableRoot,
156 acquireResource,
157 releaseResource,
@@ -173,6 +172,7 @@ import {
172 hasInstanceChanged,
173 hasInstanceAffectedParent,
174 wasInstanceInViewport,
175 + isSingletonScope,
176 } from './ReactFiberConfig';
177 import {
178 captureCommitPhaseError,
@@ -246,7 +246,8 @@ import {
246 commitHostHydratedSuspense,
247 commitHostRemoveChildFromContainer,
248 commitHostRemoveChild,
249 - commitHostSingleton,
249 + commitHostSingletonAcquisition,
250 + commitHostSingletonRelease,
251 } from './ReactFiberCommitHostEffects';
252 import {
253 viewTransitionMutationContext,
@@ -1359,22 +1360,24 @@ function commitLayoutEffectOnFiber(
1360 }
1361 break;
1362 }
1362 - case HostHoistable: {
1363 - if (supportsResources) {
1364 - recursivelyTraverseLayoutEffects(
1365 - finishedRoot,
1366 - finishedWork,
1367 - committedLanes,
1368 - );
1369 -
1370 - if (flags & Ref) {
1371 - safelyAttachRef(finishedWork, finishedWork.return);
1363 + case HostSingleton: {
1364 + if (supportsSingletons) {
1365 + // We acquire the singleton instance first so it has appropriate
1366 + // styles before other layout effects run. This isn't perfect because
1367 + // an early sibling of the singleton may have an effect that can
1368 + // observe the singleton before it is acquired.
1369 + // @TODO move this to the mutation phase. The reason it isn't there yet
1370 + // is it seemingly requires an extra traversal because we need to move the
1371 + // disappear effect into a phase before the appear phase
1372 + if (current === null && flags & Update) {
1373 + // Unlike in the reappear path we only acquire on new mount
1374 + commitHostSingletonAcquisition(finishedWork);
1375 }
1373 - break;
1376 + // We fall through to the HostComponent case below.
1377 }
1375 - // Fall through
1378 + // Fallthrough
1379 }
1377 - case HostSingleton:
1380 + case HostHoistable:
1381 case HostComponent: {
1382 recursivelyTraverseLayoutEffects(
1383 finishedRoot,
@@ -1840,8 +1843,7 @@ function hideOrUnhideAllChildren(finishedWork: Fiber, isHidden: boolean) {
1843 while (true) {
1844 if (
1845 node.tag === HostComponent ||
1843 - (supportsResources ? node.tag === HostHoistable : false) ||
1844 - (supportsSingletons ? node.tag === HostSingleton : false)
1846 + (supportsResources ? node.tag === HostHoistable : false)
1847 ) {
1848 if (hostSubtreeRoot === null) {
1849 hostSubtreeRoot = node;
@@ -1994,7 +1996,17 @@ function commitDeletionEffects(
1996 let parent: null | Fiber = returnFiber;
1997 findParent: while (parent !== null) {
1998 switch (parent.tag) {
1997 - case HostSingleton:
1999 + case HostSingleton: {
2000 + if (supportsSingletons) {
2001 + if (isSingletonScope(parent.type)) {
2002 + hostParent = parent.stateNode;
2003 + hostParentIsContainer = false;
2004 + break findParent;
2005 + }
2006 + break;
2007 + }
2008 + // Expected fallthrough when supportsSingletons is false
2009 + }
2010 case HostComponent: {
2011 hostParent = parent.stateNode;
2012 hostParentIsContainer = false;
@@ -2083,7 +2095,10 @@ function commitDeletionEffectsOnFiber(
2095
2096 const prevHostParent = hostParent;
2097 const prevHostParentIsContainer = hostParentIsContainer;
2086 - hostParent = deletedFiber.stateNode;
2098 + if (isSingletonScope(deletedFiber.type)) {
2099 + hostParent = deletedFiber.stateNode;
2100 + hostParentIsContainer = false;
2101 + }
2102 recursivelyTraverseDeletionEffects(
2103 finishedRoot,
2104 nearestMountedAncestor,
@@ -2095,7 +2110,7 @@ function commitDeletionEffectsOnFiber(
2110 // a different fiber. To increase our chances of avoiding this, specifically
2111 // if you keyed a HostSingleton so there will be a delete followed by a Placement
2112 // we treat detach eagerly here
2098 - releaseSingletonInstance(deletedFiber.stateNode);
2113 + commitHostSingletonRelease(deletedFiber);
2114
2115 hostParent = prevHostParent;
2116 hostParentIsContainer = prevHostParentIsContainer;
@@ -2684,12 +2699,19 @@ function commitMutationEffectsOnFiber(
2699 }
2700 case HostSingleton: {
2701 if (supportsSingletons) {
2687 - if (flags & Update) {
2688 - const previousWork = finishedWork.alternate;
2689 - if (previousWork === null) {
2690 - commitHostSingleton(finishedWork);
2702 + recursivelyTraverseMutationEffects(root, finishedWork, lanes);
2703 + commitReconciliationEffects(finishedWork, lanes);
2704 + if (flags & Ref) {
2705 + if (!offscreenSubtreeWasHidden && current !== null) {
2706 + safelyDetachRef(current, current.return);
2707 }
2708 }
2709 + if (current !== null && flags & Update) {
2710 + const newProps = finishedWork.memoizedProps;
2711 + const oldProps = current.memoizedProps;
2712 + commitHostUpdate(finishedWork, newProps, oldProps);
2713 + }
2714 + break;
2715 }
2716 // Fall through
2717 }
@@ -2960,15 +2982,18 @@ function commitMutationEffectsOnFiber(
2982 offscreenInstance._visibility |= OffscreenVisible;
2983 }
2984
2985 + const isUpdate = current !== null;
2986 if (isHidden) {
2964 - const isUpdate = current !== null;
2965 - const wasHiddenByAncestorOffscreen =
2966 - offscreenSubtreeIsHidden || offscreenSubtreeWasHidden;
2967 - // Only trigger disapper layout effects if:
2987 + // Only trigger disappear layout effects if:
2988 // - This is an update, not first mount.
2989 // - This Offscreen was not hidden before.
2970 - // - Ancestor Offscreen was not hidden in previous commit.
2971 - if (isUpdate && !wasHidden && !wasHiddenByAncestorOffscreen) {
2990 + // - Ancestor Offscreen was not hidden in previous commit or in this commit
2991 + if (
2992 + isUpdate &&
2993 + !wasHidden &&
2994 + !offscreenSubtreeIsHidden &&
2995 + !offscreenSubtreeWasHidden
2996 + ) {
2997 if (
2998 disableLegacyMode ||
2999 (finishedWork.mode & ConcurrentMode) !== NoMode
@@ -3371,8 +3396,14 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
3396 recursivelyTraverseDisappearLayoutEffects(finishedWork);
3397 break;
3398 }
3399 + case HostSingleton: {
3400 + if (supportsSingletons) {
3401 + // TODO (Offscreen) Check: flags & RefStatic
3402 + commitHostSingletonRelease(finishedWork);
3403 + }
3404 + // Expected fallthrough to HostComponent
3405 + }
3406 case HostHoistable:
3375 - case HostSingleton:
3407 case HostComponent: {
3408 // TODO (Offscreen) Check: flags & RefStatic
3409 safelyDetachRef(finishedWork, finishedWork.return);
@@ -3428,7 +3459,7 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
3459 }
3460
3461 function recursivelyTraverseDisappearLayoutEffects(parentFiber: Fiber) {
3431 - // TODO (Offscreen) Check: flags & (RefStatic | LayoutStatic)
3462 + // TODO (Offscreen) Check: subtreeflags & (RefStatic | LayoutStatic)
3463 let child = parentFiber.child;
3464 while (child !== null) {
3465 disappearLayoutEffects(child);
@@ -3488,8 +3519,21 @@ export function reappearLayoutEffects(
3519 // case HostRoot: {
3520 // ...
3521 // }
3522 + case HostSingleton: {
3523 + if (supportsSingletons) {
3524 + // We acquire the singleton instance first so it has appropriate
3525 + // styles before other layout effects run. This isn't perfect because
3526 + // an early sibling of the singleton may have an effect that can
3527 + // observe the singleton before it is acquired.
3528 + // @TODO move this to the mutation phase. The reason it isn't there yet
3529 + // is it seemingly requires an extra traversal because we need to move the
3530 + // disappear effect into a phase before the appear phase
3531 + commitHostSingletonAcquisition(finishedWork);
3532 + // We fall through to the HostComponent case below.
3533 + }
3534 + // Fallthrough
3535 + }
3536 case HostHoistable:
3492 - case HostSingleton:
3537 case HostComponent: {
3538 recursivelyTraverseReappearLayoutEffects(
3539 finishedRoot,
packages/react-reconciler/src/ReactFiberConfigWithNoSingletons.js
+1 -1
@@ -21,7 +21,7 @@ function shim(...args: any): any {
21 // Resources (when unsupported)
22 export const supportsSingletons = false;
23 export const resolveSingletonInstance = shim;
24 -export const clearSingleton = shim;
24 export const acquireSingletonInstance = shim;
25 export const releaseSingletonInstance = shim;
26 export const isHostSingletonType = shim;
27 +export const isSingletonScope = shim;
packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
+1 -1
@@ -232,7 +232,7 @@ export const suspendResource = $$$config.suspendResource;
232 // -------------------
233 export const supportsSingletons = $$$config.supportsSingletons;
234 export const resolveSingletonInstance = $$$config.resolveSingletonInstance;
235 -export const clearSingleton = $$$config.clearSingleton;
235 export const acquireSingletonInstance = $$$config.acquireSingletonInstance;
236 export const releaseSingletonInstance = $$$config.releaseSingletonInstance;
237 export const isHostSingletonType = $$$config.isHostSingletonType;
238 +export const isSingletonScope = $$$config.isSingletonScope;