Skip to content

Commit bcc12ad

Browse files
authored
fix: Support React Suspense in collections (#7912)
* fix: Support React Suspense in collections * Fix SSR
1 parent 74cac94 commit bcc12ad

File tree

3 files changed

+299
-103
lines changed

3 files changed

+299
-103
lines changed

packages/@react-aria/collections/src/Document.ts

Lines changed: 128 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ export class BaseNode<T> {
118118
}
119119

120120
appendChild(child: ElementNode<T>): void {
121-
this.ownerDocument.startTransaction();
122121
if (child.parentNode) {
123122
child.parentNode.removeChild(child);
124123
}
@@ -141,13 +140,6 @@ export class BaseNode<T> {
141140
this.lastChild = child;
142141

143142
this.ownerDocument.markDirty(this);
144-
if (child.hasSetProps) {
145-
// Only add the node to the collection if we already received props for it.
146-
// Otherwise wait until then so we have the correct id for the node.
147-
this.ownerDocument.addNode(child);
148-
}
149-
150-
this.ownerDocument.endTransaction();
151143
this.ownerDocument.queueUpdate();
152144
}
153145

@@ -156,7 +148,6 @@ export class BaseNode<T> {
156148
return this.appendChild(newNode);
157149
}
158150

159-
this.ownerDocument.startTransaction();
160151
if (newNode.parentNode) {
161152
newNode.parentNode.removeChild(newNode);
162153
}
@@ -175,21 +166,13 @@ export class BaseNode<T> {
175166
newNode.parentNode = referenceNode.parentNode;
176167

177168
this.invalidateChildIndices(referenceNode);
178-
179-
if (newNode.hasSetProps) {
180-
this.ownerDocument.addNode(newNode);
181-
}
182-
183-
this.ownerDocument.endTransaction();
184169
this.ownerDocument.queueUpdate();
185170
}
186171

187172
removeChild(child: ElementNode<T>): void {
188173
if (child.parentNode !== this || !this.ownerDocument.isMounted) {
189174
return;
190175
}
191-
192-
this.ownerDocument.startTransaction();
193176

194177
if (child.nextSibling) {
195178
this.invalidateChildIndices(child.nextSibling);
@@ -213,13 +196,44 @@ export class BaseNode<T> {
213196
child.previousSibling = null;
214197
child.index = 0;
215198

216-
this.ownerDocument.removeNode(child);
217-
this.ownerDocument.endTransaction();
199+
this.ownerDocument.markDirty(child);
218200
this.ownerDocument.queueUpdate();
219201
}
220202

221203
addEventListener(): void {}
222204
removeEventListener(): void {}
205+
206+
get previousVisibleSibling(): ElementNode<T> | null {
207+
let node = this.previousSibling;
208+
while (node && node.isHidden) {
209+
node = node.previousSibling;
210+
}
211+
return node;
212+
}
213+
214+
get nextVisibleSibling(): ElementNode<T> | null {
215+
let node = this.nextSibling;
216+
while (node && node.isHidden) {
217+
node = node.nextSibling;
218+
}
219+
return node;
220+
}
221+
222+
get firstVisibleChild(): ElementNode<T> | null {
223+
let node = this.firstChild;
224+
while (node && node.isHidden) {
225+
node = node.nextSibling;
226+
}
227+
return node;
228+
}
229+
230+
get lastVisibleChild(): ElementNode<T> | null {
231+
let node = this.lastChild;
232+
while (node && node.isHidden) {
233+
node = node.previousSibling;
234+
}
235+
return node;
236+
}
223237
}
224238

225239
/**
@@ -229,16 +243,14 @@ export class BaseNode<T> {
229243
export class ElementNode<T> extends BaseNode<T> {
230244
nodeType = 8; // COMMENT_NODE (we'd use ELEMENT_NODE but React DevTools will fail to get its dimensions)
231245
node: CollectionNode<T>;
246+
isMutated = true;
232247
private _index: number = 0;
233248
hasSetProps = false;
249+
isHidden = false;
234250

235251
constructor(type: string, ownerDocument: Document<T, any>) {
236252
super(ownerDocument);
237253
this.node = new CollectionNode(type, `react-aria-${++ownerDocument.nodeId}`);
238-
// Start a transaction so that no updates are emitted from the collection
239-
// until the props for this node are set. We don't know the real id for the
240-
// node until then, so we need to avoid emitting collections in an inconsistent state.
241-
this.ownerDocument.startTransaction();
242254
}
243255

244256
get index(): number {
@@ -258,30 +270,45 @@ export class ElementNode<T> extends BaseNode<T> {
258270
return 0;
259271
}
260272

273+
/**
274+
* Lazily gets a mutable instance of a Node. If the node has already
275+
* been cloned during this update cycle, it just returns the existing one.
276+
*/
277+
private getMutableNode(): Mutable<CollectionNode<T>> {
278+
if (!this.isMutated) {
279+
this.node = this.node.clone();
280+
this.isMutated = true;
281+
}
282+
283+
this.ownerDocument.markDirty(this);
284+
return this.node;
285+
}
286+
261287
updateNode(): void {
262-
let node = this.ownerDocument.getMutableNode(this);
288+
let nextSibling = this.nextVisibleSibling;
289+
let node = this.getMutableNode();
263290
node.index = this.index;
264291
node.level = this.level;
265292
node.parentKey = this.parentNode instanceof ElementNode ? this.parentNode.node.key : null;
266-
node.prevKey = this.previousSibling?.node.key ?? null;
267-
node.nextKey = this.nextSibling?.node.key ?? null;
293+
node.prevKey = this.previousVisibleSibling?.node.key ?? null;
294+
node.nextKey = nextSibling?.node.key ?? null;
268295
node.hasChildNodes = !!this.firstChild;
269-
node.firstChildKey = this.firstChild?.node.key ?? null;
270-
node.lastChildKey = this.lastChild?.node.key ?? null;
296+
node.firstChildKey = this.firstVisibleChild?.node.key ?? null;
297+
node.lastChildKey = this.lastVisibleChild?.node.key ?? null;
271298

272299
// Update the colIndex of sibling nodes if this node has a colSpan.
273-
if ((node.colSpan != null || node.colIndex != null) && this.nextSibling) {
300+
if ((node.colSpan != null || node.colIndex != null) && nextSibling) {
274301
// This queues the next sibling for update, which means this happens recursively.
275302
let nextColIndex = (node.colIndex ?? node.index) + (node.colSpan ?? 1);
276-
if (nextColIndex !== this.nextSibling.node.colIndex) {
277-
let siblingNode = this.ownerDocument.getMutableNode(this.nextSibling);
303+
if (nextColIndex !== nextSibling.node.colIndex) {
304+
let siblingNode = nextSibling.getMutableNode();
278305
siblingNode.colIndex = nextColIndex;
279306
}
280307
}
281308
}
282309

283310
setProps<E extends Element>(obj: {[key: string]: any}, ref: ForwardedRef<E>, rendered?: ReactNode, render?: (node: Node<T>) => ReactElement): void {
284-
let node = this.ownerDocument.getMutableNode(this);
311+
let node = this.getMutableNode();
285312
let {value, textValue, id, ...props} = obj;
286313
props.ref = ref;
287314
node.props = props;
@@ -300,19 +327,44 @@ export class ElementNode<T> extends BaseNode<T> {
300327
node.colSpan = props.colSpan;
301328
}
302329

303-
// If this is the first time props have been set, end the transaction started in the constructor
304-
// so this node can be emitted.
305-
if (!this.hasSetProps) {
306-
this.ownerDocument.addNode(this);
307-
this.ownerDocument.endTransaction();
308-
this.hasSetProps = true;
309-
}
310-
330+
this.hasSetProps = true;
311331
this.ownerDocument.queueUpdate();
312332
}
313333

314334
get style(): CSSProperties {
315-
return {};
335+
// React sets display: none to hide elements during Suspense.
336+
// We'll handle this by setting the element to hidden and invalidating
337+
// its siblings/parent. Hidden elements remain in the Document, but
338+
// are removed from the Collection.
339+
let element = this;
340+
return {
341+
get display() {
342+
return element.isHidden ? 'none' : '';
343+
},
344+
set display(value) {
345+
let isHidden = value === 'none';
346+
if (element.isHidden !== isHidden) {
347+
// Mark parent node dirty if this element is currently the first or last visible child.
348+
if (element.parentNode?.firstVisibleChild === element || element.parentNode?.lastVisibleChild === element) {
349+
element.ownerDocument.markDirty(element.parentNode);
350+
}
351+
352+
// Mark sibling visible elements dirty.
353+
let prev = element.previousVisibleSibling;
354+
let next = element.nextVisibleSibling;
355+
if (prev) {
356+
element.ownerDocument.markDirty(prev);
357+
}
358+
if (next) {
359+
element.ownerDocument.markDirty(next);
360+
}
361+
362+
// Mark self dirty.
363+
element.isHidden = isHidden;
364+
element.ownerDocument.markDirty(element);
365+
}
366+
}
367+
};
316368
}
317369

318370
hasAttribute(): void {}
@@ -334,18 +386,16 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
334386
nodesByProps = new WeakMap<object, ElementNode<T>>();
335387
isMounted = true;
336388
private collection: C;
337-
private collectionMutated: boolean;
338-
private mutatedNodes: Set<ElementNode<T>> = new Set();
389+
private nextCollection: C | null = null;
339390
private subscriptions: Set<() => void> = new Set();
340-
private transactionCount = 0;
341391
private queuedRender = false;
342392
private inSubscription = false;
343393

344394
constructor(collection: C) {
345395
// @ts-ignore
346396
super(null);
347397
this.collection = collection;
348-
this.collectionMutated = true;
398+
this.nextCollection = collection;
349399
}
350400

351401
get isConnected(): boolean {
@@ -356,78 +406,56 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
356406
return new ElementNode(type, this);
357407
}
358408

359-
/**
360-
* Lazily gets a mutable instance of a Node. If the node has already
361-
* been cloned during this update cycle, it just returns the existing one.
362-
*/
363-
getMutableNode(element: ElementNode<T>): Mutable<CollectionNode<T>> {
364-
let node = element.node;
365-
if (!this.mutatedNodes.has(element)) {
366-
node = element.node.clone();
367-
this.mutatedNodes.add(element);
368-
element.node = node;
369-
}
370-
this.markDirty(element);
371-
return node;
372-
}
373-
374409
private getMutableCollection() {
375-
if (!this.isSSR && !this.collectionMutated) {
376-
this.collection = this.collection.clone();
377-
this.collectionMutated = true;
410+
if (!this.nextCollection) {
411+
this.nextCollection = this.collection.clone();
378412
}
379413

380-
return this.collection;
414+
return this.nextCollection;
381415
}
382416

383417
markDirty(node: BaseNode<T>): void {
384418
this.dirtyNodes.add(node);
385419
}
386420

387-
startTransaction(): void {
388-
this.transactionCount++;
389-
}
390-
391-
endTransaction(): void {
392-
this.transactionCount--;
393-
}
421+
private addNode(element: ElementNode<T>): void {
422+
if (element.isHidden) {
423+
return;
424+
}
394425

395-
addNode(element: ElementNode<T>): void {
396426
let collection = this.getMutableCollection();
397427
if (!collection.getItem(element.node.key)) {
398-
collection.addNode(element.node);
399-
400428
for (let child of element) {
401429
this.addNode(child);
402430
}
403431
}
404432

405-
this.markDirty(element);
433+
collection.addNode(element.node);
406434
}
407435

408-
removeNode(node: ElementNode<T>): void {
436+
private removeNode(node: ElementNode<T>): void {
409437
for (let child of node) {
410438
this.removeNode(child);
411439
}
412440

413441
let collection = this.getMutableCollection();
414442
collection.removeNode(node.node.key);
415-
this.markDirty(node);
416443
}
417444

418445
/** Finalizes the collection update, updating all nodes and freezing the collection. */
419446
getCollection(): C {
420-
if (this.transactionCount > 0) {
421-
return this.collection;
447+
// If in a subscription update, return a clone of the existing collection.
448+
// This ensures React will queue a render. React will call getCollection again
449+
// during render, at which point all the updates will be complete and we can return
450+
// the new collection.
451+
if (this.inSubscription) {
452+
return this.collection.clone();
422453
}
423454

424-
this.updateCollection();
425-
426455
// Reset queuedRender to false when getCollection is called during render.
427-
if (!this.inSubscription) {
428-
this.queuedRender = false;
429-
}
456+
this.queuedRender = false;
430457

458+
this.updateCollection();
431459
return this.collection;
432460
}
433461

@@ -439,36 +467,35 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
439467

440468
// Next, update dirty collection nodes.
441469
for (let element of this.dirtyNodes) {
442-
if (element instanceof ElementNode && element.isConnected) {
443-
element.updateNode();
470+
if (element instanceof ElementNode) {
471+
if (element.isConnected && !element.isHidden) {
472+
element.updateNode();
473+
this.addNode(element);
474+
} else {
475+
this.removeNode(element);
476+
}
477+
478+
element.isMutated = false;
444479
}
445480
}
446481

447482
this.dirtyNodes.clear();
448483

449484
// Finally, update the collection.
450-
if (this.mutatedNodes.size || this.collectionMutated) {
451-
let collection = this.getMutableCollection();
452-
for (let element of this.mutatedNodes) {
453-
if (element.isConnected) {
454-
collection.addNode(element.node);
455-
}
485+
if (this.nextCollection) {
486+
this.nextCollection.commit(this.firstVisibleChild?.node.key ?? null, this.lastVisibleChild?.node.key ?? null, this.isSSR);
487+
if (!this.isSSR) {
488+
this.collection = this.nextCollection;
489+
this.nextCollection = null;
456490
}
457-
458-
this.mutatedNodes.clear();
459-
collection.commit(this.firstChild?.node.key ?? null, this.lastChild?.node.key ?? null, this.isSSR);
460491
}
461-
462-
this.collectionMutated = false;
463492
}
464493

465494
queueUpdate(): void {
466-
// Don't emit any updates if there is a transaction in progress.
467-
// queueUpdate should be called again after the transaction.
468-
if (this.dirtyNodes.size === 0 || this.transactionCount > 0 || this.queuedRender) {
495+
if (this.dirtyNodes.size === 0 || this.queuedRender) {
469496
return;
470497
}
471-
498+
472499
// Only trigger subscriptions once during an update, when the first item changes.
473500
// React's useSyncExternalStore will call getCollection immediately, to check whether the snapshot changed.
474501
// If so, React will queue a render to happen after the current commit to our fake DOM finishes.

0 commit comments

Comments
 (0)