Skip to content

SCC-5375: Multiselect lazy loading#2007

Open
KaseyW31 wants to merge 16 commits into
developmentfrom
SCC-5375/multiselect-lazy-loading
Open

SCC-5375: Multiselect lazy loading#2007
KaseyW31 wants to merge 16 commits into
developmentfrom
SCC-5375/multiselect-lazy-loading

Conversation

@KaseyW31

@KaseyW31 KaseyW31 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Fixes JIRA ticket SCC-5375

This PR does the following:

  • Adds the lazy-load option to the listOverflow prop of the MultiSelect component

How has this been tested?

  • Local build of storybook - "Lazy loading items" story

Accessibility concerns or updates

Accessibility Checklist

  • Checked Storybook's "Accessibility" tab for color contrast and other issues.
  • The feature works with keyboard inputs including tabbing back and forward and pressing space, enter, arrow, and esc keys.
  • For hidden text or when aria-live is used, a screenreader was used to verify the text is read.
  • For features that involve UI updates and focusing on DOM refs, focus management was reviewed.
  • The feature works when the page is zoomed in to 200% and 400%.

Open Questions

Checklist:

  • I have updated the Storybook documentation and changelog accordingly.
  • I have added relevant accessibility documentation for this pull request.
  • All new and existing tests passed.

Front End Review:

  • Review the Vercel preview deployment once it is ready.

@vercel

vercel Bot commented Jul 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nypl-design-system Ready Ready Preview, Comment Jul 21, 2026 3:15pm

Request Review

@7emansell 7emansell left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall looks good to me, and it's working great in Storybook, just a couple notes/questions on the implementation.
I do have to ask my favorite question though... have you tried this in RC advanced search and if so is it performant

}, 1); // Ensure focus logic runs after state update
};

const onItemsListScroll = () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this chunk can be replaced by setting rootMargin on the IntersectionObserver– i'm new to that API but it seems like if it's set (to 200px or something) it would trigger the same anticipatory load?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This chunk allows for a more gradual and scaled loading - without it, scrolling down just a bit loads a lot of items (even with rootMargin at 0px). But that would work too, any thoughts on which is preferred?
I'm also quite new to the API so I could definitely be missing something else, and I can continue to explore it more

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No i see what you're saying here! I think it's worth the workaround to have a better scrolling experience

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the scrolling-loading could use some polishing. I'd love to have a chat to learn more about the API and the config.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

I put these console.logs right before the loadMoreLazyItems() call in the observer and right before loadMoreLazyItems() call in the onItemsListScroll function.

The scroll handler keeps moving the sentinel away before the observer can reach it. So the observer fires only once, while the scroll handler takes over and it's running a lot.

I tried removing onItemsListScroll and I personally did not observe much of a difference, so would love to see the difference you're seeing.

Also with regards to, "scrolling down just a bit loads a lot of items" -- isn't the number of items related to the setState in loadMoreLazyItems, rather than anything to do with the rootMargin?

setLazyItemsVisible((previousVisibleItems) =>
          Math.min(
            previousVisibleItems +
              defaultItemsVisible +
              previousVisibleItems / defaultItemsVisible, // Scales with further scrolling
            itemsList.length
          )
        );

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@oliviawongnyc I didn't realize that, thanks for pointing that out! When I was testing in research catalog using just the observer, scrolling fast would cause stuttering. After scrolling all the way to the bottom, it took some time to load more items, and by then the user would be scrolling past the panel entirely. So I think I overcorrected for that and messed up the observer in the process without realizing it.

As for the difference, I misinterpreted the comment and thought the chunk was referring to the scaling part of it. Removing the scroll handler changes the behavior slightly but doesn't make the difference I mentioned, sorry for the confusion.

I'll take a look at reducing stuttering while only using the IntersectionObserver.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the scroll handler and tweaked some of the scaling. I tried calculating the rootMargin using pixel heights in the scroll panel but that didn't work out great, so the numbers I have now are from trial and error. Any thoughts on that or the scrolling experience now @7emansell @oliviawongnyc ?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes looking good to me!

As for the scaling, what happens with a fixed root margin? rootMargin: "0px 0px 100px 0px", for example? I would think we just want the observer to trigger loading before someone reaches the bottom of the visible list and not need to scale, but lmk your thoughts.

Same question about the batching. Does the increment size need to scale?

);
});

it("should lazily load more list items while scrolling in lazy load mode", async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clever test!! also wondering: did you try a more involved mock of the intersection observer and it got too messy ? It seems like it would be difficult to mock

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not 🤔 I could try it though if that would be preferred

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries just wondering

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the good test and good mock.

export type MultiSelectWidths = typeof multiSelectWidthsArray[number];
export const multiSelectListOverflowArray = ["scroll", "expand"] as const;
export const multiSelectListOverflowArray = [
"scroll",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interested in marty/edwin thoughts but maybe lazy-load should be a flag you can set if you've picked scroll, not a standalone type

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah that does make sense

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me review before you make this update. Without having looked too closely at the PR, Emma's comment sounds reasonable. Perhaps it can be an independent flag that works for both scroll and expand?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this shouldn't be possible on expand right?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be wrong to always have lazy load be enabled for the scroll option? It works well and it doesn't seem like it has a big overhead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, agreed. lazy-load should not be an option for listOverflow. It should be a boolean flag.

Here's another idea. Although, I am not sure if the timing of this is possible. Can lazy load be automatically enabled if the total options is greater than x? Otherwise, scrolling functions as is always has?

Do we know the threshold when performance takes a hit?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about the threshold. I'm open to logging performance/render times compared to the number of items, but I imagine that performance could also vary based on how it's used in the consuming app?

Right now 20 + defaultItemsVisible are loaded initially per @bigfishdesign13 's suggestion (although defaultItemsVisible is only used for expand since the default appears to be ~7 for scrolling, but that might be a separate concern). Maybe this could be the threshold for when lazy loading is enabled?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, let's keep it at 20 + defaultItemVisible as the threshold for now and we can gauge its performance once it's live.

@KaseyW31

KaseyW31 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

have you tried this in RC advanced search and if so is it performant

I did, without any other changes to RC it doesn't seem to make a much of a difference in prod, but it does in dev mode... I haven't tried it with my changes in this PR yet, but I imagine it would make less of a difference since it wouldn't be rerendered as often
Do you have any thoughts on whether this is worth trying?

Actually on second look there is a slight performance improvement in the language checkbox and input fields (if I squint). Previously I was still on DS 4.3.0 which might have slowed it down a bit

@KaseyW31
KaseyW31 requested a review from 7emansell July 10, 2026 20:00
@7emansell

Copy link
Copy Markdown
Collaborator

have you tried this in RC advanced search and if so is it performant

I did, without any other changes to RC it doesn't seem to make a much of a difference in prod, but it does in dev mode... I haven't tried it with my changes in this PR yet, but I imagine it would make less of a difference since it wouldn't be rerendered as often Do you have any thoughts on whether this is worth trying?

Actually on second look there is a slight performance improvement in the language checkbox and input fields (if I squint). Previously I was still on DS 4.3.0 which might have slowed it down a bit

if it makes a difference in dev mode that's awesome (thinking along the lines of our unit test duration..) Let me know if you have a branch that has this + 4.2.0 + no other changes in advanced search, i'd be interested to see. I like the idea of adding this as a non-breaking change, totally optional, to the Multiselect on the DS level and then handling the useMemo on the consuming client level, that seems like a fair separation of concerns to me

@KaseyW31

Copy link
Copy Markdown
Contributor Author

Let me know if you have a branch that has this + 4.2.0 + no other changes in advanced search, i'd be interested to see.

Yes I pushed my changes to the no-ref/test-multiselect-lazy branch! I do think the unit tests run slightly faster, now around 40-50s

@EdwinGuzman EdwinGuzman left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments. Before I review the main file, we should decide on the approach and whether the lazy loading should always be enabled. If so, it might change the code.

Comment thread src/components/MultiSelect/MultiSelect.stories.tsx Outdated
);
});

it("should lazily load more list items while scrolling in lazy load mode", async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the good test and good mock.

observe: () => null,
disconnect: () => null,
});
window.IntersectionObserver = jest

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Emma already asked if you tested the rc npm release in Research Catalog. Did you also run tests in RC?

I noticed that whenever we mock global APIs in the DS, unit tests in consuming apps need to be updated as well. It's not bad, but we would just have to let teams know about the dependency.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the IntersectionObserver mock is needed when lazy-load is selected. Would this be a factor at all in deciding whether lazy loading should be always enabled for scroll?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a big factor but just something to call out when devs import the release version. Did you have to add it in a global pre-test jest file in RC?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like it's only needed if the number of items in the MultiSelect > defaultItemsVisible + 20, when lazy loading is triggered. Otherwise putting this block of code

const intersectionObserverMock = () => ({
  observe: () => null,
  disconnect: () => null,
  unobserve: () => null,
})
window.IntersectionObserver = jest
  .fn()
  .mockImplementation(intersectionObserverMock)

either in the test files with the MultiSelect, or the global pre-test file is needed.

export type MultiSelectWidths = typeof multiSelectWidthsArray[number];
export const multiSelectListOverflowArray = ["scroll", "expand"] as const;
export const multiSelectListOverflowArray = [
"scroll",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be wrong to always have lazy load be enabled for the scroll option? It works well and it doesn't seem like it has a big overhead.

Comment thread src/components/MultiSelect/MultiSelect.test.tsx Outdated
Comment thread src/components/MultiSelect/MultiSelect.tsx Outdated
Comment thread src/components/MultiSelect/MultiSelect.tsx Outdated

const { scrollTop, clientHeight, scrollHeight } = itemsListRef.current;
const scrollThreshold = scrollHeight / 2; // Scales with further scrolling
const isAtBottom =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about isHalfwayThroughLoadedContent rather than isAtBottom? Then someone reading this can easily see that when isHalfwayThroughLoadedContent, loadMoreLazyItems() and I think it would act like a little bit of documentation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scratch this. After looking at this longer, I don't think we should use two triggers for the loading, and think we should use the observer over this onScroll.

Math.min(
previousVisibleItems +
defaultItemsVisible +
previousVisibleItems / defaultItemsVisible, // Scales with further scrolling

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the benefits to scaling the batch?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I was experimenting with scrolling quickly, the scrolling speed can increase as I scroll further down. The scaling was to prevent too many items from loading at the beginning, while preventing the stuttering that I mentioned above if the user is scrolling quickly to the bottom (which I saw more on research catalog than storybook). Maybe I could set a constant higher amount to load, though @bigfishdesign13 suggested continuing with the scaling, which I can try adapting to the IntersectionObserver.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current configuration works well. No matter how fast I try to scroll, I never hit the bottom. I would prefer to load a constant number of options during the scrolling, but I also don't want users to hit the bottom.

The data for all of the options is always available, right? No API call to get more data from the server. So any lag that happens as the options are added is due to rendering, which could be impacted by the power of a users computer or device. If there are concerns about the scaling, let's try a static number and try to find the sweet spot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realized that a lot of the slowness issues I was having without scaling was because I was testing in research catalog dev mode earlier, my mistake. I removed the scaling and set the rootMargin to 300px which seems to work well enough, let me know if there are any issues with that

<Banner
content={
<>
<strong>IMPORTANT:</strong> This prop can only be used with{" "}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: update "with" to "when"?

@EdwinGuzman EdwinGuzman left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking great. Just have a few comments and questions.

I think what's left to do is to always enable lazy loading when scrolling is selected. @bigfishdesign13 just want to make sure we agree on this; you suggested an additional boolean flag.

return {
observe: observeMock,
disconnect: disconnectMock,
unobserve: jest.fn(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't also need to be cleared?

});
});

const triggerIntersection = () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice update. How did you catch that the mock scroll behavior was not actually working as expected?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test was failing because no more items were being loaded on scroll - seems like the scroll handler was taking over. I also didn't realize earlier that the IntersectionObserver doesn't work in the test environment because the environment doesn't have the browser layout engine.


setLazyItemsVisible((previousVisibleItems) =>
Math.min(
previousVisibleItems * 1.2 + lazyLoadIncrementNum,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you create a CONSTANT_VARIABLE for 1.2? This is to reduce magic numbers and better understand when reading this why it's set to 1.2.

{
root: itemsListRef.current,
threshold: 0,
rootMargin: `${12 * lazyItemsVisible}px`,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also create another constant var for 12?

))}
</CheckboxGroup>
{isOverflowExpand && <ExpandToggleButton />}
{isOverflowLazy &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes sense why we need this, but can you add a short comment describing why? When we revisit this component in the future, it'll be clear why this is needed in the DOM.

@bigfishdesign13

Copy link
Copy Markdown
Collaborator

I think what's left to do is to always enable lazy loading when scrolling is selected. @bigfishdesign13 just want to make sure we agree on this; you suggested an additional boolean flag.

@EdwinGuzman Yes, always enable lazy loading when scrolling is selected. Consistency. There's no need to add an additional boolean to toggle this feature on and off.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants