',
+ );
+
+ // @TODO remove this warning check when we loosen the tag nesting restrictions to allow arbitrary tags at the
+ // root of the application
+ assertConsoleErrorDev(['In HTML,
cannot be a child of <#document>']);
+
+ await act(() => {
+ root.render();
+ });
+ expect(document.documentElement.outerHTML).toBe(
+ '
',
+ );
+
+ // @TODO remove this warning check when we loosen the tag nesting restrictions to allow arbitrary tags at the
+ // root of the application
+ assertConsoleErrorDev(['In HTML,
cannot be a child of ']);
+
+ await act(() => {
+ root.render();
+ });
+ expect(document.documentElement.outerHTML).toBe(
+ '
',
+ );
+
+ // @TODO remove this warning check when we loosen the tag nesting restrictions to allow arbitrary tags at the
+ // root of the application
+ assertConsoleErrorDev(['In HTML, cannot be a child of ']);
+
+ await act(() => {
+ root.render();
+ });
+ expect(document.documentElement.outerHTML).toBe(
+ '
',
+ );
+
+ await act(() => {
+ root.unmount();
+ });
+ expect(document.documentElement.outerHTML).toBe(
+ '',
+ );
+ });
+
+ it('should render children of into the document head even when the container is inside the document body', async () => {
+ function App({phase}) {
+ return (
+ <>
+
',
+ );
+
+ // @TODO remove this warning check when we loosen the tag nesting restrictions to allow arbitrary tags at the
+ // root of the application
+ assertConsoleErrorDev(['In HTML, cannot be a child of ']);
+
+ await act(() => {
+ root.render();
+ });
+ expect(document.documentElement.outerHTML).toBe(
+ '
+
+
+ );
+
+ let suspendOnNewMessage;
+ let currentMessage;
+ let resolveCurrentMessage;
+ function createNewMessage() {
+ currentMessage = new Promise(r => {
+ resolveCurrentMessage = r;
+ });
+ return currentMessage;
+ }
+ createNewMessage();
+ resolveCurrentMessage('hello world');
+ function Message() {
+ const [pendingMessage, setPendingMessage] =
+ React.useState(currentMessage);
+ suspendOnNewMessage = () => {
+ setPendingMessage(createNewMessage());
+ };
+ return React.use(pendingMessage);
+ }
+
+ function App() {
+ return (
+
+
+ {main}
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(document);
+ await act(() => {
+ root.render();
+ });
+ // The initial render is blocked by promiseA so we see the fallback Document
+ expect(document.documentElement.outerHTML).toBe(
+ '
fallback
',
+ );
+
+ await act(() => {
+ resolveCurrentPromise();
+ });
+ // When promiseA resolves we see the primary Document
+ expect(document.documentElement.outerHTML).toBe(
+ '
hello world
',
+ );
+
+ await act(() => {
+ suspendOnNewPromise();
+ });
+ // When we switch to rendering ComponentB synchronously we have to put the Document back into fallback
+ // The primary content remains hidden until promiseB resolves
+ expect(document.documentElement.outerHTML).toBe(
+ '
hello world
fallback
',
+ );
+
+ await act(() => {
+ resolveCurrentPromise();
+ });
+ // When promiseB resolves we see the new primary content inside the primary Document
+ // style attributes stick around after being unhidden by the Suspense boundary
+ expect(document.documentElement.outerHTML).toBe(
+ '
',
+ );
+
+ await act(() => {
+ suspendOnNewMessage();
+ });
+ // When we update the message itself we will be causing updates on the primary content of the Suspense boundary.
+ // The reason we also test for this is to make sure we don't double acquire the document singletons while
+ // disappearing and reappearing layout effects
+ expect(document.documentElement.outerHTML).toBe(
+ '
',
+ );
+ });
});
diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
index 5f50ceb207aba..bdd50c9d4f469 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
@@ -31,7 +31,6 @@ let hasErrored = false;
let fatalError = undefined;
let renderOptions;
let waitForAll;
-let waitForThrow;
let assertLog;
let Scheduler;
let clientAct;
@@ -76,7 +75,6 @@ describe('ReactDOMFloat', () => {
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
- waitForThrow = InternalTestUtils.waitForThrow;
assertLog = InternalTestUtils.assertLog;
clientAct = InternalTestUtils.act;
assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
@@ -507,14 +505,7 @@ describe('ReactDOMFloat', () => {
>,
);
- let aggregateError = await waitForThrow();
- expect(aggregateError.errors.length).toBe(2);
- expect(aggregateError.errors[0].message).toContain(
- 'Invalid insertion of NOSCRIPT',
- );
- expect(aggregateError.errors[1].message).toContain(
- 'The node to be removed is not a child of this node',
- );
+ await waitForAll([]);
assertConsoleErrorDev([
[
'Cannot render
+ loading...
+ ,
+ );
+
await act(() => {
resolveText('unblock');
});
diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js
index a7bb6e6df5acd..28cdd0e1ed54c 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.js
@@ -97,6 +97,7 @@ import {
Passive,
DidDefer,
ViewTransitionNamedStatic,
+ LayoutStatic,
} from './ReactFiberFlags';
import {
disableLegacyContext,
@@ -1703,21 +1704,13 @@ function updateHostSingleton(
}
const nextChildren = workInProgress.pendingProps.children;
-
- if (current === null && !getIsHydrating()) {
- // Similar to Portals we append Singleton children in the commit phase. So we
- // Track insertions even on mount.
- // TODO: Consider unifying this with how the root works.
- workInProgress.child = reconcileChildFibers(
- workInProgress,
- null,
- nextChildren,
- renderLanes,
- );
- } else {
- reconcileChildren(current, workInProgress, nextChildren, renderLanes);
- }
+ reconcileChildren(current, workInProgress, nextChildren, renderLanes);
markRef(current, workInProgress);
+ if (current === null) {
+ // We mark Singletons with a static flag to more efficiently manage their
+ // ownership of the singleton host instance when in offscreen trees including Suspense
+ workInProgress.flags |= LayoutStatic;
+ }
return workInProgress.child;
}
diff --git a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js
index 8ecc2d0de0fac..159c12bd4bcf2 100644
--- a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js
+++ b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js
@@ -47,8 +47,9 @@ import {
commitHydratedSuspenseInstance,
removeChildFromContainer,
removeChild,
- clearSingleton,
acquireSingletonInstance,
+ releaseSingletonInstance,
+ isSingletonScope,
} from './ReactFiberConfig';
import {captureCommitPhaseError} from './ReactFiberWorkLoop';
import {trackHostMutation} from './ReactFiberMutationTracking';
@@ -218,7 +219,9 @@ function isHostParent(fiber: Fiber): boolean {
fiber.tag === HostComponent ||
fiber.tag === HostRoot ||
(supportsResources ? fiber.tag === HostHoistable : false) ||
- (supportsSingletons ? fiber.tag === HostSingleton : false) ||
+ (supportsSingletons
+ ? fiber.tag === HostSingleton && isSingletonScope(fiber.type)
+ : false) ||
fiber.tag === HostPortal
);
}
@@ -245,9 +248,19 @@ function getHostSibling(fiber: Fiber): ?Instance {
while (
node.tag !== HostComponent &&
node.tag !== HostText &&
- (!supportsSingletons ? true : node.tag !== HostSingleton) &&
node.tag !== DehydratedFragment
) {
+ // If this is a host singleton we go deeper if it's not a special
+ // singleton scope. If it is a singleton scope we skip over it because
+ // you only insert against this scope when you are already inside of it
+ if (
+ supportsSingletons &&
+ node.tag === HostSingleton &&
+ isSingletonScope(node.type)
+ ) {
+ continue siblings;
+ }
+
// If it is not host node and, we might have a host node inside it.
// Try to search down until we find one.
if (node.flags & Placement) {
@@ -286,23 +299,30 @@ function insertOrAppendPlacementNodeIntoContainer(
appendChildToContainer(parent, stateNode);
}
trackHostMutation();
- } else if (
- tag === HostPortal ||
- (supportsSingletons ? tag === HostSingleton : false)
- ) {
+ return;
+ } else if (tag === HostPortal) {
// If the insertion itself is a portal, then we don't want to traverse
// down its children. Instead, we'll get insertions from each child in
// the portal directly.
- // If the insertion is a HostSingleton then it will be placed independently
- } else {
- const child = node.child;
- if (child !== null) {
- insertOrAppendPlacementNodeIntoContainer(child, before, parent);
- let sibling = child.sibling;
- while (sibling !== null) {
- insertOrAppendPlacementNodeIntoContainer(sibling, before, parent);
- sibling = sibling.sibling;
- }
+ return;
+ }
+
+ if (
+ (supportsSingletons ? tag === HostSingleton : false) &&
+ isSingletonScope(node.type)
+ ) {
+ // This singleton is the parent of deeper nodes and needs to become
+ // the parent for child insertions and appends
+ parent = node.stateNode;
+ }
+
+ const child = node.child;
+ if (child !== null) {
+ insertOrAppendPlacementNodeIntoContainer(child, before, parent);
+ let sibling = child.sibling;
+ while (sibling !== null) {
+ insertOrAppendPlacementNodeIntoContainer(sibling, before, parent);
+ sibling = sibling.sibling;
}
}
}
@@ -322,23 +342,30 @@ function insertOrAppendPlacementNode(
appendChild(parent, stateNode);
}
trackHostMutation();
- } else if (
- tag === HostPortal ||
- (supportsSingletons ? tag === HostSingleton : false)
- ) {
+ return;
+ } else if (tag === HostPortal) {
// If the insertion itself is a portal, then we don't want to traverse
// down its children. Instead, we'll get insertions from each child in
// the portal directly.
- // If the insertion is a HostSingleton then it will be placed independently
- } else {
- const child = node.child;
- if (child !== null) {
- insertOrAppendPlacementNode(child, before, parent);
- let sibling = child.sibling;
- while (sibling !== null) {
- insertOrAppendPlacementNode(sibling, before, parent);
- sibling = sibling.sibling;
- }
+ return;
+ }
+
+ if (
+ (supportsSingletons ? tag === HostSingleton : false) &&
+ isSingletonScope(node.type)
+ ) {
+ // This singleton is the parent of deeper nodes and needs to become
+ // the parent for child insertions and appends
+ parent = node.stateNode;
+ }
+
+ const child = node.child;
+ if (child !== null) {
+ insertOrAppendPlacementNode(child, before, parent);
+ let sibling = child.sibling;
+ while (sibling !== null) {
+ insertOrAppendPlacementNode(sibling, before, parent);
+ sibling = sibling.sibling;
}
}
}
@@ -348,14 +375,6 @@ function commitPlacement(finishedWork: Fiber): void {
return;
}
- if (supportsSingletons) {
- if (finishedWork.tag === HostSingleton) {
- // Singletons are already in the Host and don't need to be placed
- // Since they operate somewhat like Portals though their children will
- // have Placement and will get placed inside them
- return;
- }
- }
// Recursively insert all host nodes into the parent.
const parentFiber = getHostParentFiber(finishedWork);
@@ -546,13 +565,12 @@ export function commitHostHydratedSuspense(
}
}
-export function commitHostSingleton(finishedWork: Fiber) {
+export function commitHostSingletonAcquisition(finishedWork: Fiber) {
const singleton = finishedWork.stateNode;
const props = finishedWork.memoizedProps;
try {
- // This was a new mount, we need to clear and set initial properties
- clearSingleton(singleton);
+ // This was a new mount, acquire the DOM instance and set initial properties
if (__DEV__) {
runWithFiberInDEV(
finishedWork,
@@ -574,3 +592,15 @@ export function commitHostSingleton(finishedWork: Fiber) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
}
+
+export function commitHostSingletonRelease(releasingWork: Fiber) {
+ if (__DEV__) {
+ runWithFiberInDEV(
+ releasingWork,
+ releaseSingletonInstance,
+ releasingWork.stateNode,
+ );
+ } else {
+ releaseSingletonInstance(releasingWork.stateNode);
+ }
+}
diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js
index e57f54e6687e6..061d426c4266f 100644
--- a/packages/react-reconciler/src/ReactFiberCommitWork.js
+++ b/packages/react-reconciler/src/ReactFiberCommitWork.js
@@ -115,6 +115,7 @@ import {
ViewTransitionStatic,
AffectedParentLayout,
ViewTransitionNamedStatic,
+ MutationStatic,
} from './ReactFiberFlags';
import {
commitStartTime,
@@ -152,7 +153,6 @@ import {
prepareForCommit,
beforeActiveInstanceBlur,
detachDeletedInstance,
- releaseSingletonInstance,
getHoistableRoot,
acquireResource,
releaseResource,
@@ -173,6 +173,7 @@ import {
hasInstanceChanged,
hasInstanceAffectedParent,
wasInstanceInViewport,
+ isSingletonScope,
} from './ReactFiberConfig';
import {
captureCommitPhaseError,
@@ -246,7 +247,8 @@ import {
commitHostHydratedSuspense,
commitHostRemoveChildFromContainer,
commitHostRemoveChild,
- commitHostSingleton,
+ commitHostSingletonAcquisition,
+ commitHostSingletonRelease,
} from './ReactFiberCommitHostEffects';
import {
viewTransitionMutationContext,
@@ -1359,22 +1361,24 @@ function commitLayoutEffectOnFiber(
}
break;
}
- case HostHoistable: {
- if (supportsResources) {
- recursivelyTraverseLayoutEffects(
- finishedRoot,
- finishedWork,
- committedLanes,
- );
-
- if (flags & Ref) {
- safelyAttachRef(finishedWork, finishedWork.return);
+ case HostSingleton: {
+ if (supportsSingletons) {
+ // We acquire the singleton instance first so it has appropriate
+ // styles before other layout effects run. This isn't perfect because
+ // an early sibling of the singleton may have an effect that can
+ // observe the singleton before it is acquired.
+ // @TODO move this to the mutation phase. The reason it isn't there yet
+ // is it seemingly requires an extra traversal because we need to move the
+ // disappear effect into a phase before the appear phase
+ if (current === null && flags & Update) {
+ // Unlike in the reappear path we only acquire on new mount
+ commitHostSingletonAcquisition(finishedWork);
}
- break;
+ // We fall through to the HostComponent case below.
}
- // Fall through
+ // Fallthrough
}
- case HostSingleton:
+ case HostHoistable:
case HostComponent: {
recursivelyTraverseLayoutEffects(
finishedRoot,
@@ -1840,8 +1844,7 @@ function hideOrUnhideAllChildren(finishedWork: Fiber, isHidden: boolean) {
while (true) {
if (
node.tag === HostComponent ||
- (supportsResources ? node.tag === HostHoistable : false) ||
- (supportsSingletons ? node.tag === HostSingleton : false)
+ (supportsResources ? node.tag === HostHoistable : false)
) {
if (hostSubtreeRoot === null) {
hostSubtreeRoot = node;
@@ -1994,7 +1997,17 @@ function commitDeletionEffects(
let parent: null | Fiber = returnFiber;
findParent: while (parent !== null) {
switch (parent.tag) {
- case HostSingleton:
+ case HostSingleton: {
+ if (supportsSingletons) {
+ if (isSingletonScope(parent.type)) {
+ hostParent = parent.stateNode;
+ hostParentIsContainer = false;
+ break findParent;
+ }
+ break;
+ }
+ // Expected fallthrough when supportsSingletons is false
+ }
case HostComponent: {
hostParent = parent.stateNode;
hostParentIsContainer = false;
@@ -2083,7 +2096,10 @@ function commitDeletionEffectsOnFiber(
const prevHostParent = hostParent;
const prevHostParentIsContainer = hostParentIsContainer;
- hostParent = deletedFiber.stateNode;
+ if (isSingletonScope(deletedFiber.type)) {
+ hostParent = deletedFiber.stateNode;
+ hostParentIsContainer = false;
+ }
recursivelyTraverseDeletionEffects(
finishedRoot,
nearestMountedAncestor,
@@ -2095,7 +2111,7 @@ function commitDeletionEffectsOnFiber(
// a different fiber. To increase our chances of avoiding this, specifically
// if you keyed a HostSingleton so there will be a delete followed by a Placement
// we treat detach eagerly here
- releaseSingletonInstance(deletedFiber.stateNode);
+ commitHostSingletonRelease(deletedFiber);
hostParent = prevHostParent;
hostParentIsContainer = prevHostParentIsContainer;
@@ -2684,12 +2700,19 @@ function commitMutationEffectsOnFiber(
}
case HostSingleton: {
if (supportsSingletons) {
- if (flags & Update) {
- const previousWork = finishedWork.alternate;
- if (previousWork === null) {
- commitHostSingleton(finishedWork);
+ recursivelyTraverseMutationEffects(root, finishedWork, lanes);
+ commitReconciliationEffects(finishedWork, lanes);
+ if (flags & Ref) {
+ if (!offscreenSubtreeWasHidden && current !== null) {
+ safelyDetachRef(current, current.return);
}
}
+ if (current !== null && flags & Update) {
+ const newProps = finishedWork.memoizedProps;
+ const oldProps = current.memoizedProps;
+ commitHostUpdate(finishedWork, newProps, oldProps);
+ }
+ break;
}
// Fall through
}
@@ -2960,15 +2983,18 @@ function commitMutationEffectsOnFiber(
offscreenInstance._visibility |= OffscreenVisible;
}
+ const isUpdate = current !== null;
if (isHidden) {
- const isUpdate = current !== null;
- const wasHiddenByAncestorOffscreen =
- offscreenSubtreeIsHidden || offscreenSubtreeWasHidden;
- // Only trigger disapper layout effects if:
+ // Only trigger disappear layout effects if:
// - This is an update, not first mount.
// - This Offscreen was not hidden before.
- // - Ancestor Offscreen was not hidden in previous commit.
- if (isUpdate && !wasHidden && !wasHiddenByAncestorOffscreen) {
+ // - Ancestor Offscreen was not hidden in previous commit or in this commit
+ if (
+ isUpdate &&
+ !wasHidden &&
+ !offscreenSubtreeIsHidden &&
+ !offscreenSubtreeWasHidden
+ ) {
if (
disableLegacyMode ||
(finishedWork.mode & ConcurrentMode) !== NoMode
@@ -3371,8 +3397,15 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
recursivelyTraverseDisappearLayoutEffects(finishedWork);
break;
}
+ case HostSingleton: {
+ // TODO (Offscreen) Check: flags & (RefStatic | MutationStatic)
+ safelyDetachRef(finishedWork, finishedWork.return);
+ commitHostSingletonRelease(finishedWork);
+
+ recursivelyTraverseDisappearLayoutEffects(finishedWork);
+ break;
+ }
case HostHoistable:
- case HostSingleton:
case HostComponent: {
// TODO (Offscreen) Check: flags & RefStatic
safelyDetachRef(finishedWork, finishedWork.return);
@@ -3428,7 +3461,7 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
}
function recursivelyTraverseDisappearLayoutEffects(parentFiber: Fiber) {
- // TODO (Offscreen) Check: flags & (RefStatic | LayoutStatic)
+ // TODO (Offscreen) Check: subtreeflags & (RefStatic | LayoutStatic | MutationStatic)
let child = parentFiber.child;
while (child !== null) {
disappearLayoutEffects(child);
@@ -3488,8 +3521,21 @@ export function reappearLayoutEffects(
// case HostRoot: {
// ...
// }
+ case HostSingleton: {
+ if (supportsSingletons) {
+ // We acquire the singleton instance first so it has appropriate
+ // styles before other layout effects run. This isn't perfect because
+ // an early sibling of the singleton may have an effect that can
+ // observe the singleton before it is acquired.
+ // @TODO move this to the mutation phase. The reason it isn't there yet
+ // is it seemingly requires an extra traversal because we need to move the
+ // disappear effect into a phase before the appear phase
+ commitHostSingletonAcquisition(finishedWork);
+ // We fall through to the HostComponent case below.
+ }
+ // Fallthrough
+ }
case HostHoistable:
- case HostSingleton:
case HostComponent: {
recursivelyTraverseReappearLayoutEffects(
finishedRoot,
diff --git a/packages/react-reconciler/src/ReactFiberConfigWithNoSingletons.js b/packages/react-reconciler/src/ReactFiberConfigWithNoSingletons.js
index f592e9518591f..92d2fadd39b64 100644
--- a/packages/react-reconciler/src/ReactFiberConfigWithNoSingletons.js
+++ b/packages/react-reconciler/src/ReactFiberConfigWithNoSingletons.js
@@ -21,7 +21,7 @@ function shim(...args: any): any {
// Resources (when unsupported)
export const supportsSingletons = false;
export const resolveSingletonInstance = shim;
-export const clearSingleton = shim;
export const acquireSingletonInstance = shim;
export const releaseSingletonInstance = shim;
export const isHostSingletonType = shim;
+export const isSingletonScope = shim;
diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
index 5d6ab3224ee51..91420ca88cd95 100644
--- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
+++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
@@ -232,7 +232,7 @@ export const suspendResource = $$$config.suspendResource;
// -------------------
export const supportsSingletons = $$$config.supportsSingletons;
export const resolveSingletonInstance = $$$config.resolveSingletonInstance;
-export const clearSingleton = $$$config.clearSingleton;
export const acquireSingletonInstance = $$$config.acquireSingletonInstance;
export const releaseSingletonInstance = $$$config.releaseSingletonInstance;
export const isHostSingletonType = $$$config.isHostSingletonType;
+export const isSingletonScope = $$$config.isSingletonScope;