loading...
+ ,
+ );
+
+ // unblock css loading
+ await act(() => {
+ resolveText('foo');
+ });
+
+ // bar is still blocking the whole list
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'loading...'}
+
+
+ ,
+ );
+
+ // unblock inner loading states
+ await act(() => {
+ resolveText('bar');
+ });
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'loading foo...'}
+ {'loading bar...'}
+ {'loading baz...'}
+
+
+ ,
+ );
+
+ // resolve the last boundary
+ await act(() => {
+ resolveText('baz');
+ });
+
+ // still blocked on the css of the first row
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'loading foo...'}
+ {'loading bar...'}
+ {'loading baz...'}
+
+
+ ,
+ );
+
+ await act(() => {
+ loadStylesheets();
+ });
+ await assertLog(['load stylesheet: foo']);
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'foo'}
+ {'bar'}
+ {'baz'}
+
+
+ ,
+ );
+ });
+
+ // @gate enableSuspenseList
+ it('delays "together" SuspenseList rows until the css of previous rows have completed', async () => {
+ await act(() => {
+ renderToPipeableStream(
+
+
+
+
+
+
+ foo
+
+
+ bar
+
+
+ ,
+ ).pipe(writable);
+ });
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+ {'loading foo...'}
+ {'loading bar...'}
+
+ ,
+ );
+
+ await act(() => {
+ resolveText('foo');
+ });
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'loading foo...'}
+ {'loading bar...'}
+
+
+ ,
+ );
+
+ await act(() => {
+ loadStylesheets();
+ });
+ await assertLog(['load stylesheet: foo']);
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'foo'}
+ {'bar'}
+
+
+ ,
+ );
+ });
+
describe('ReactDOM.preconnect(href, { crossOrigin })', () => {
it('creates a preconnect resource when called', async () => {
function App({url}) {
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index 70e61f42c6e..7f47e8e4d5c 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -236,6 +236,9 @@ type LegacyContext = {
type SuspenseListRow = {
pendingTasks: number, // The number of tasks, previous rows and inner suspense boundaries blocking this row.
boundaries: null | Array, // The boundaries in this row waiting to be unblocked by the previous row. (null means this row is not blocked)
+ hoistables: HoistableState, // Any dependencies that this row depends on. Future rows need to also depend on it.
+ inheritedHoistables: null | HoistableState, // Any dependencies that previous row depend on, that new boundaries of this row needs.
+ together: boolean, // All the boundaries within this row must be revealed together.
next: null | SuspenseListRow, // The next row blocked by this one.
};
@@ -789,6 +792,10 @@ function createSuspenseBoundary(
boundary.pendingTasks++;
blockedBoundaries.push(boundary);
}
+ const inheritedHoistables = row.inheritedHoistables;
+ if (inheritedHoistables !== null) {
+ hoistHoistables(boundary.contentState, inheritedHoistables);
+ }
}
return boundary;
}
@@ -1404,6 +1411,11 @@ function renderSuspenseBoundary(
}
return;
}
+ } else {
+ const boundaryRow = prevRow;
+ if (boundaryRow !== null && boundaryRow.together) {
+ tryToResolveTogetherRow(request, boundaryRow);
+ }
}
} catch (thrownValue: mixed) {
newBoundary.status = CLIENT_RENDERED;
@@ -1670,16 +1682,36 @@ function replaySuspenseBoundary(
function finishSuspenseListRow(request: Request, row: SuspenseListRow): void {
// This row finished. Now we have to unblock all the next rows that were blocked on this.
+ unblockSuspenseListRow(request, row.next, row.hoistables);
+}
+
+function unblockSuspenseListRow(
+ request: Request,
+ unblockedRow: null | SuspenseListRow,
+ inheritedHoistables: null | HoistableState,
+): void {
// We do this in a loop to avoid stack overflow for very long lists that get unblocked.
- let unblockedRow = row.next;
while (unblockedRow !== null) {
+ if (inheritedHoistables !== null) {
+ // Hoist any hoistables from the previous row into the next row so that it can be
+ // later transferred to all the rows.
+ hoistHoistables(unblockedRow.hoistables, inheritedHoistables);
+ // Mark the row itself for any newly discovered Suspense boundaries to inherit.
+ // This is different from hoistables because that also includes hoistables from
+ // all the boundaries below this row and not just previous rows.
+ unblockedRow.inheritedHoistables = inheritedHoistables;
+ }
// Unblocking the boundaries will decrement the count of this row but we keep it above
// zero so they never finish this row recursively.
const unblockedBoundaries = unblockedRow.boundaries;
if (unblockedBoundaries !== null) {
unblockedRow.boundaries = null;
for (let i = 0; i < unblockedBoundaries.length; i++) {
- finishedTask(request, unblockedBoundaries[i], null, null);
+ const unblockedBoundary = unblockedBoundaries[i];
+ if (inheritedHoistables !== null) {
+ hoistHoistables(unblockedBoundary.contentState, inheritedHoistables);
+ }
+ finishedTask(request, unblockedBoundary, null, null);
}
}
// Instead we decrement at the end to keep it all in this loop.
@@ -1688,16 +1720,48 @@ function finishSuspenseListRow(request: Request, row: SuspenseListRow): void {
// Still blocked.
break;
}
+ inheritedHoistables = unblockedRow.hoistables;
unblockedRow = unblockedRow.next;
}
}
+function tryToResolveTogetherRow(
+ request: Request,
+ togetherRow: SuspenseListRow,
+): void {
+ // If we have a "together" row and all the pendingTasks are really the boundaries themselves,
+ // and we won't outline any of them then we can unblock this row early so that we can inline
+ // all the boundaries at once.
+ const boundaries = togetherRow.boundaries;
+ if (boundaries === null || togetherRow.pendingTasks !== boundaries.length) {
+ return;
+ }
+ let allCompleteAndInlinable = true;
+ for (let i = 0; i < boundaries.length; i++) {
+ const rowBoundary = boundaries[i];
+ if (
+ rowBoundary.pendingTasks !== 1 ||
+ rowBoundary.parentFlushed ||
+ isEligibleForOutlining(request, rowBoundary)
+ ) {
+ allCompleteAndInlinable = false;
+ break;
+ }
+ }
+ if (allCompleteAndInlinable) {
+ unblockSuspenseListRow(request, togetherRow, togetherRow.hoistables);
+ }
+}
+
function createSuspenseListRow(
previousRow: null | SuspenseListRow,
): SuspenseListRow {
const newRow: SuspenseListRow = {
pendingTasks: 1, // At first the row is blocked on attempting rendering itself.
boundaries: null,
+ hoistables: createHoistableState(),
+ inheritedHoistables: null,
+ together: false,
next: null,
};
if (previousRow !== null && previousRow.pendingTasks > 0) {
@@ -1978,7 +2042,27 @@ function renderSuspenseList(
}
if (revealOrder === 'together') {
- // TODO
+ const prevKeyPath = task.keyPath;
+ const prevRow = task.row;
+ const newRow = (task.row = createSuspenseListRow(null));
+ // This will cause boundaries to block on this row, but there's nothing to
+ // unblock them. We'll use the partial flushing pass to unblock them.
+ newRow.boundaries = [];
+ newRow.together = true;
+ task.keyPath = keyPath;
+ renderNodeDestructive(request, task, children, -1);
+ if (--newRow.pendingTasks === 0) {
+ finishSuspenseListRow(request, newRow);
+ }
+ task.keyPath = prevKeyPath;
+ task.row = prevRow;
+ if (prevRow !== null && newRow.pendingTasks > 0) {
+ // If we are part of an outer SuspenseList and our row is still pending, then that blocks
+ // the parent row from completing. We can continue the chain.
+ prevRow.pendingTasks++;
+ newRow.next = prevRow;
+ }
+ return;
}
// For other reveal order modes, we just render it as a fragment.
const prevKeyPath = task.keyPath;
@@ -4308,6 +4392,14 @@ function erroredTask(
encodeErrorForBoundary(boundary, errorDigest, error, errorInfo, false);
untrackBoundary(request, boundary);
+ const boundaryRow = boundary.row;
+ if (boundaryRow !== null) {
+ // Unblock the SuspenseListRow that was blocked by this boundary.
+ if (--boundaryRow.pendingTasks === 0) {
+ finishSuspenseListRow(request, boundaryRow);
+ }
+ }
+
// Regardless of what happens next, this boundary won't be displayed,
// so we can flush it, if the parent already flushed.
if (boundary.parentFlushed) {
@@ -4460,13 +4552,6 @@ function abortTask(task: Task, request: Request, error: mixed): void {
segment.status = ABORTED;
}
- const row = task.row;
- if (row !== null) {
- if (--row.pendingTasks === 0) {
- finishSuspenseListRow(request, row);
- }
- }
-
const errorInfo = getThrownInfo(task.componentStack);
if (boundary === null) {
@@ -4489,7 +4574,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
// we just need to mark it as postponed.
logPostpone(request, postponeInstance.message, errorInfo, null);
trackPostpone(request, trackedPostpones, task, segment);
- finishedTask(request, null, row, segment);
+ finishedTask(request, null, task.row, segment);
} else {
const fatal = new Error(
'The render was aborted with postpone when the shell is incomplete. Reason: ' +
@@ -4508,7 +4593,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
// We log the error but we still resolve the prerender
logRecoverableError(request, error, errorInfo, null);
trackPostpone(request, trackedPostpones, task, segment);
- finishedTask(request, null, row, segment);
+ finishedTask(request, null, task.row, segment);
} else {
logRecoverableError(request, error, errorInfo, null);
fatalError(request, error, errorInfo, null);
@@ -4552,7 +4637,6 @@ function abortTask(task: Task, request: Request, error: mixed): void {
}
}
} else {
- boundary.pendingTasks--;
// We construct an errorInfo from the boundary's componentStack so the error in dev will indicate which
// boundary the message is referring to
const trackedPostpones = request.trackedPostpones;
@@ -4580,7 +4664,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
abortTask(fallbackTask, request, error),
);
boundary.fallbackAbortableTasks.clear();
- return finishedTask(request, boundary, row, segment);
+ return finishedTask(request, boundary, task.row, segment);
}
}
boundary.status = CLIENT_RENDERED;
@@ -4597,7 +4681,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
logPostpone(request, postponeInstance.message, errorInfo, null);
if (request.trackedPostpones !== null && segment !== null) {
trackPostpone(request, request.trackedPostpones, task, segment);
- finishedTask(request, task.blockedBoundary, row, segment);
+ finishedTask(request, task.blockedBoundary, task.row, segment);
// If this boundary was still pending then we haven't already cancelled its fallbacks.
// We'll need to abort the fallbacks, which will also error that parent boundary.
@@ -4622,6 +4706,16 @@ function abortTask(task: Task, request: Request, error: mixed): void {
}
}
+ boundary.pendingTasks--;
+
+ const boundaryRow = boundary.row;
+ if (boundaryRow !== null) {
+ // Unblock the SuspenseListRow that was blocked by this boundary.
+ if (--boundaryRow.pendingTasks === 0) {
+ finishSuspenseListRow(request, boundaryRow);
+ }
+ }
+
// If this boundary was still pending then we haven't already cancelled its fallbacks.
// We'll need to abort the fallbacks, which will also error that parent boundary.
boundary.fallbackAbortableTasks.forEach(fallbackTask =>
@@ -4630,6 +4724,13 @@ function abortTask(task: Task, request: Request, error: mixed): void {
boundary.fallbackAbortableTasks.clear();
}
+ const row = task.row;
+ if (row !== null) {
+ if (--row.pendingTasks === 0) {
+ finishSuspenseListRow(request, row);
+ }
+ }
+
request.allPendingTasks--;
if (request.allPendingTasks === 0) {
completeAll(request);
@@ -4759,6 +4860,8 @@ function finishedTask(
if (row !== null) {
if (--row.pendingTasks === 0) {
finishSuspenseListRow(request, row);
+ } else if (row.together) {
+ tryToResolveTogetherRow(request, row);
}
}
request.allPendingTasks--;
@@ -4806,10 +4909,15 @@ function finishedTask(
// If the boundary is eligible to be outlined during flushing we can't cancel the fallback
// since we might need it when it's being outlined.
if (boundary.status === COMPLETED) {
+ const boundaryRow = boundary.row;
+ if (boundaryRow !== null) {
+ // Hoist the HoistableState from the boundary to the row so that the next rows
+ // can depend on the same dependencies.
+ hoistHoistables(boundaryRow.hoistables, boundary.contentState);
+ }
if (!isEligibleForOutlining(request, boundary)) {
boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
boundary.fallbackAbortableTasks.clear();
- const boundaryRow = boundary.row;
if (boundaryRow !== null) {
// If we aren't eligible for outlining, we don't have to wait until we flush it.
if (--boundaryRow.pendingTasks === 0) {
@@ -4848,6 +4956,10 @@ function finishedTask(
}
}
}
+ const boundaryRow = boundary.row;
+ if (boundaryRow !== null && boundaryRow.together) {
+ tryToResolveTogetherRow(request, boundaryRow);
+ }
}
}
@@ -5604,6 +5716,20 @@ function flushPartialBoundary(
}
completedSegments.splice(0, i);
+ const row = boundary.row;
+ if (row !== null && row.together && boundary.pendingTasks === 1) {
+ // "together" rows are blocked on their own boundaries.
+ // We have now flushed all the boundary's segments as partials.
+ // We can now unblock it from blocking the row that will eventually
+ // unblock the boundary itself which can issue its complete instruction.
+ // TODO: Ideally the complete instruction would be in a single