-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat: Async loading support for S2 ComboBox/Picker #7938
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c4ca76d
8ee20f9
c2a1cf9
765b757
7708b81
2da7d04
0bb9f21
0049153
76e1a05
68033b2
54fcbaa
4d3bb6c
f6ba68a
3fe09f6
0b06824
da166f2
0ef52ef
51580d7
76dc08a
27260f4
41a28cb
4fb8aeb
f8b1745
b272469
7a2877c
4c5bc69
5c423d4
fdf423f
711dff6
286d8b8
0938fb9
1eeb798
5fc224f
18c65b2
85b7edb
7f57506
d493fc4
a69a794
4523888
3052001
6a98ac5
6fb2c01
97b21a2
e1bd8f3
d4bec98
7f062b8
561d914
dc6e80a
0c3a3b4
2085f8c
a9b7ea6
f60cbe3
0fa17cc
95beec7
fc193c5
cbd91b5
4a62993
f664da0
85440fc
09825f4
d98691f
f34fc0c
abfcde0
5258d8b
48caf94
379fba7
160e9a0
fac1920
f87438b
7c5f1e8
63db21e
1eb421e
15bc7d1
77b4b62
f7a1e20
53f9158
4755b91
5e8b6ae
e2b8c00
a0b9e5d
be7d233
69b7db1
a2049ab
65c2aca
32c3792
1680885
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -103,7 +103,7 @@ export class BaseNode<T> { | |
} | ||
|
||
private invalidateChildIndices(child: ElementNode<T>): void { | ||
if (this._minInvalidChildIndex == null || child.index < this._minInvalidChildIndex.index) { | ||
if (this._minInvalidChildIndex == null || !this._minInvalidChildIndex.isConnected || child.index < this._minInvalidChildIndex.index) { | ||
this._minInvalidChildIndex = child; | ||
} | ||
} | ||
|
@@ -154,8 +154,11 @@ export class BaseNode<T> { | |
|
||
newNode.nextSibling = referenceNode; | ||
newNode.previousSibling = referenceNode.previousSibling; | ||
newNode.index = referenceNode.index; | ||
|
||
// Ensure that the newNode's index is less than that of the reference node so that | ||
// invalidateChildIndices will properly use the newNode as the _minInvalidChildIndex, thus making sure | ||
// we will properly update the indexes of all sibiling nodes after the newNode. The value here doesn't matter | ||
// since updateChildIndices should calculate the proper indexes. | ||
newNode.index = referenceNode.index - 1; | ||
snowystinger marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if (this.firstChild === referenceNode) { | ||
this.firstChild = newNode; | ||
} else if (referenceNode.previousSibling) { | ||
|
@@ -165,15 +168,15 @@ export class BaseNode<T> { | |
referenceNode.previousSibling = newNode; | ||
newNode.parentNode = referenceNode.parentNode; | ||
|
||
this.invalidateChildIndices(referenceNode); | ||
this.invalidateChildIndices(newNode); | ||
this.ownerDocument.queueUpdate(); | ||
} | ||
|
||
removeChild(child: ElementNode<T>): void { | ||
if (child.parentNode !== this || !this.ownerDocument.isMounted) { | ||
return; | ||
} | ||
|
||
if (child.nextSibling) { | ||
this.invalidateChildIndices(child.nextSibling); | ||
child.nextSibling.previousSibling = child.previousSibling; | ||
|
@@ -279,7 +282,7 @@ export class ElementNode<T> extends BaseNode<T> { | |
this.node = this.node.clone(); | ||
this.isMutated = true; | ||
} | ||
|
||
this.ownerDocument.markDirty(this); | ||
return this.node; | ||
} | ||
|
@@ -470,6 +473,12 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend | |
} | ||
|
||
// Next, update dirty collection nodes. | ||
// TODO: when updateCollection is called here, shouldn't we be calling this.updateChildIndicies as well? Theoretically it should only update | ||
// nodes from _minInvalidChildIndex onwards so the increase in dirtyNodes should be minimal. | ||
// Is element.updateNode supposed to handle that (it currently assumes the index stored on the node is correct already). | ||
// At the moment, without this call to updateChildIndicies, filtering an async combobox doesn't actually update the index values of the | ||
// updated collection... | ||
this.updateChildIndices(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This change along with the above were mainly to fix an issue I noticed where the collection indexes weren't updating when filtering the combobox. Above, we properly update the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. shouldn't the document be in the list of dirtyNodes then and therefore get processed by the above loop? also is the todo still relevant? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The todo is part of the comment above, basically just noting that I'm not 100% sure about the change. From what I observed the document never marks itself as dirty when performing |
||
for (let element of this.dirtyNodes) { | ||
if (element instanceof ElementNode) { | ||
if (element.isConnected && !element.isHidden) { | ||
|
@@ -497,7 +506,7 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend | |
if (this.dirtyNodes.size === 0 || this.queuedRender) { | ||
return; | ||
} | ||
|
||
// Only trigger subscriptions once during an update, when the first item changes. | ||
// React's useSyncExternalStore will call getCollection immediately, to check whether the snapshot changed. | ||
// If so, React will queue a render to happen after the current commit to our fake DOM finishes. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
/* | ||
* Copyright 2024 Adobe. All rights reserved. | ||
* This file is licensed to you under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. You may obtain a copy | ||
* of the License at http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software distributed under | ||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS | ||
* OF ANY KIND, either express or implied. See the License for the specific language | ||
* governing permissions and limitations under the License. | ||
*/ | ||
|
||
import type {AsyncLoadable, Collection, Node} from '@react-types/shared'; | ||
import {getScrollParent} from './getScrollParent'; | ||
import {RefObject, useRef} from 'react'; | ||
import {useEffectEvent} from './useEffectEvent'; | ||
import {useLayoutEffect} from './useLayoutEffect'; | ||
|
||
export interface LoadMoreSentinelProps extends Omit<AsyncLoadable, 'isLoading'> { | ||
collection: Collection<Node<unknown>>, | ||
/** | ||
* The amount of offset from the bottom of your scrollable region that should trigger load more. | ||
* Uses a percentage value relative to the scroll body's client height. Load more is then triggered | ||
* when your current scroll position's distance from the bottom of the currently loaded list of items is less than | ||
* or equal to the provided value. (e.g. 1 = 100% of the scroll region's height). | ||
* @default 1 | ||
*/ | ||
scrollOffset?: number | ||
// TODO: Maybe include a scrollRef option so the user can provide the scrollParent to compare against instead of having us look it up | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. leaning towards not adding it for now, the hook is unstable as of now so we could always add it later. Open to opinions if anyone feels strongly about the approach There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would that be different than the ref already provided? a ref to the scrollable region seems like a good idea so that eventually people can use something like body element scrolling to drive the virtualizer There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the ref that is provided is the sentinel ref so not quite the same, but the general idea would be for the case you mentioned. Figured we could omit it until we actually try that use case |
||
} | ||
|
||
export function UNSTABLE_useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: RefObject<HTMLElement | null>): void { | ||
let {collection, onLoadMore, scrollOffset = 1} = props; | ||
|
||
let sentinelObserver = useRef<IntersectionObserver>(null); | ||
|
||
let triggerLoadMore = useEffectEvent((entries: IntersectionObserverEntry[]) => { | ||
// Use "isIntersecting" over an equality check of 0 since it seems like there is cases where | ||
// a intersection ratio of 0 can be reported when isIntersecting is actually true | ||
for (let entry of entries) { | ||
// Note that this will be called if the collection changes, even if onLoadMore was already called and is being processed. | ||
// Up to user discretion as to how to handle these multiple onLoadMore calls | ||
if (entry.isIntersecting && onLoadMore) { | ||
onLoadMore(); | ||
} | ||
} | ||
}); | ||
|
||
useLayoutEffect(() => { | ||
if (ref.current) { | ||
// Tear down and set up a new IntersectionObserver when the collection changes so that we can properly trigger additional loadMores if there is room for more items | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. seems fine, certainly more readable than separating it into some other effect and has the benefit of being queued in the same way as the rest of the IntersectionObserver updates |
||
// Need to do this tear down and set up since using a large rootMargin will mean the observer's callback isn't called even when scrolling the item into view beause its visibility hasn't actually changed | ||
// https://codesandbox.io/p/sandbox/magical-swanson-dhgp89?file=%2Fsrc%2FApp.js%3A21%2C21 | ||
sentinelObserver.current = new IntersectionObserver(triggerLoadMore, {root: getScrollParent(ref?.current) as HTMLElement, rootMargin: `0px ${100 * scrollOffset}% ${100 * scrollOffset}% ${100 * scrollOffset}%`}); | ||
sentinelObserver.current.observe(ref.current); | ||
} | ||
|
||
return () => { | ||
if (sentinelObserver.current) { | ||
sentinelObserver.current.disconnect(); | ||
} | ||
}; | ||
}, [collection, triggerLoadMore, ref, scrollOffset]); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -98,7 +98,6 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement | |
// Prevent rubber band scrolling from shaking when scrolling out of bounds | ||
state.scrollTop = Math.max(0, Math.min(scrollTop, contentSize.height - state.height)); | ||
state.scrollLeft = Math.max(0, Math.min(scrollLeft, contentSize.width - state.width)); | ||
|
||
onVisibleRectChange(new Rect(state.scrollLeft, state.scrollTop, state.width, state.height)); | ||
|
||
if (!state.isScrolling) { | ||
|
@@ -199,6 +198,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement | |
|
||
// Update visible rect when the content size changes, in case scrollbars need to appear or disappear. | ||
let lastContentSize = useRef<Size | null>(null); | ||
let [update, setUpdate] = useState({}); | ||
useLayoutEffect(() => { | ||
if (!isUpdatingSize.current && (lastContentSize.current == null || !contentSize.equals(lastContentSize.current))) { | ||
// React doesn't allow flushSync inside effects, so queue a microtask. | ||
|
@@ -209,7 +209,11 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement | |
// https://github.com/reactwg/react-18/discussions/102 | ||
// @ts-ignore | ||
if (typeof IS_REACT_ACT_ENVIRONMENT === 'boolean' ? IS_REACT_ACT_ENVIRONMENT : typeof jest !== 'undefined') { | ||
updateSize(fn => fn()); | ||
// This is so we update size in a separate render but within the same act. Needs to be setState instead of refs | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why? is there some event we could hook into instead? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is to specifically fix the tests where the order in which things run (such as when the ref is connected to the ScrollView and when we perform rect measurements) vs how focus is moved by simulated clicks/presses from userEvent was causing odd behaviors (i.e. focus would move to the trigger button -> the dropdown -> back to the button specifically due to the order in which the dropdown becomes available/focusable + The only way to fix this was to make sure the size of the ScrollView updates in a separate render but within the same act, but ideally the high level flow should be that focus moves to the Picker trigger on click -> all press handling finishes -> collection is fully formed -> dropdown opens and focus moves into it. |
||
// due to strict mode. | ||
setUpdate({}); | ||
lastContentSize.current = contentSize; | ||
return; | ||
} else { | ||
queueMicrotask(() => updateSize(flushSync)); | ||
} | ||
|
@@ -218,6 +222,11 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement | |
lastContentSize.current = contentSize; | ||
}); | ||
|
||
// Will only run in tests, needs to be in separate effect so it is properly run in the next render in strict mode. | ||
useLayoutEffect(() => { | ||
updateSize(fn => fn()); | ||
}, [update]); | ||
|
||
let onResize = useCallback(() => { | ||
updateSize(flushSync); | ||
}, [updateSize]); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,6 +6,7 @@ | |
"actionbar.selectedAll": "تم تحديد الكل", | ||
"breadcrumbs.more": "المزيد من العناصر", | ||
"button.pending": "قيد الانتظار", | ||
"combobox.noResults": "لا توجد نتائج", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From S1 |
||
"contextualhelp.help": "مساعدة", | ||
"contextualhelp.info": "معلومات", | ||
"dialog.alert": "تنبيه", | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I found cases where the minInvalidChildIndex set on the Document was pointing to nodes that didn't exist in the collection any more so added this check so we'd always have an up to date one
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sounds maybe related to #8127?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah a bit, definitely solves a part of that issue