-
11 votes
-
Making infinite scrollable lists for web without a constantly expanding DOM
A common theme in web development, and the crux of the so-called "Web 2.0" is scrolling through dynamic lists of content. Tildes is such an example: you can scroll through about 50 topics on the...
A common theme in web development, and the crux of the so-called "Web 2.0" is scrolling through dynamic lists of content. Tildes is such an example: you can scroll through about 50 topics on the front page before you reach a "next" button if you want to keep looking.
There's a certain beauty in the simplicity of the next/previous page. When done right it's fast, it's easy, and fits neatly into a server-side rendered model. However, it does cause that small bit of friction where you need to hit the next button to go forward -- taking you out of the "flow", so-to-speak. It's slick, but it could be slicker. Perhaps more importantly, it's an interesting problem to solve.
A step up from the next/previous button is to load the next page of content when you reach the end of the list, inserting it below. If the load is pretty fast, this will hardly interrupt your flow at all! The ever-so-popular reddit enhancement suite does precisely that for reddit: instead of a next button, when you reach the bottom, the next page of items simply plops into place. If the loading isn't fast enough, perhaps instead of loading when they reach the last item, you might choose to load when they hit the fifth from last item, etc.
To try to keep this post more concrete, and more helpful, here's how this type of pagination would work in practice, in typescript and using the Intersection Observer API but otherwise framework agnostic:
/** * Allows the user to scroll forever through the given list by calling the given loadMore() * function whenever the bottom element (by default) becomes visible. This assumes that * loadMore is the only thing that modifies the list, and that the list is done being modified * once the promise returned from loadMore resolves * * @param list The element which contains the individual items * @param loadMore A function which can be called to insert more items into the list. Can return * a rejected promise to indicate that there are no more items to load * @param triggerLoadAt The index of the child in the list which triggers the load. Negative numbers * are interpreted as offsets from the end of the list. */ function handlePagination(list: Element, loadMore: () => Promise<void>, triggerLoadAt: number = -1) { manageIntersection(); return; function handleIntersection(ele: Element, handler: () => void): () => void { let active = true; const observer = new IntersectionObserver((entries) => { if (active && entries[0].isIntersecting) { handler() } }, { root: null, threshold: 0.5 }); observer.observe(ele); return () => { if (active) { active = false; observer.disconnect(); } } } function manageIntersection() { const index = triggerLoadAt < 0 ? list.children.length + triggerLoadAt : triggerLoadAt; if (index < 0 || index >= list.children.length) { throw new Error(`index=${index} is not valid for a list of ${list.children.length} items`); } const child = list.children[index]; const removeIntersectionHandler = handleIntersection(child, () => { removeIntersectionHandler(); loadMore().then(() => { manageIntersection(); }).catch((e) => {}); }); } }If you're sane, this probably suffices for you. However, there is still one problem: as you scroll,
the number of elements on the DOM get longer and longer. This means they necessarily take up
some amount of memory, and browsers probably have to do some amount of work to keep
track of them. Thus, in theory, if you were to scroll long enough, the page would get slower and
slower! How long "long enough" is would depend mostly on how complicated each item is: if each one
is a unique 20k element svg, it'll get slow pretty quickly.The trick to avoid this, and to get a constant overhead, is that when adding new items below, remove the same number of items above! Of course, if the user scrolls back up they'll be expecting those items to be there, but no worries, the
handlePaginationfrom before works just as well for loading items before the first item.However, this simple change is where a key problem arises: inserting elements below doesn't cause any layout shift, but inserting an item above ought to--right?
The answer is: it depends on the browser! Back in 2017 chrome realized that it's often convenient to be able to insert items into the dom above the viewport, and implemented scroll anchoring, which basically ensures that if you insert an item 50px tall above the viewport, then scroll 50px down so that there's no visual layout shift. Firefox followed suite in 2019, and edge got support in 2020. But alas, safari both on mac and ios does not support scroll anchoring (though they expressed interest in it since 2017)
Now, there's two responses to this:
- Surely Safari support is coming soon, they've posted on that bug as recently as April! Just use simpler pagination for now
- Pshhhh, just implement scroll anchoring ourself!
Of course, I've gone and done #2, and it almost perfectly works. Here's the idea:
- Right before
loadMore, find the first item in the list which is inside the viewport. This is the item whose position we don't want to move. Use getBoundingClientRect to find it's top position. - Perform the DOM manipulation as desired
- Use getBoundingClientRect again to find the new top of that item.
- Insert (or remove) the appropriate amount of blank space at the top of the list to offset the change in client rect (note that if there's scroll anchoring support in the browser this should always be zero, which means this effectively works as progressive enhancement)
Now, the function to do this is a tad too long for this post. I implemented it in React, however, and combined it with some stronger preloading object (we don't need all the items we've fetched from the API on the DOM, so we can use before, onTheDom, after lists to avoid getting a bunch of api requests just from scrolling down and up within the same small number of items).
What's interesting is that it still works perfectly on chrome even with scroll-anchoring disabled (via
overflow-anchor: none), but on Safari there is still, sometimes, 1 frame where it renders the wrong scroll position before immediately adjusting. Because I implemented it in react, however, my current hypothesis is I have a mistake somewhere which causes the javascript to yield to the renderer before all the manipulations are done, and it only shows up on Safari because of the generally higher framerates thereIf it's interesting to people, I could extract the infinite list component outside of this project: I certainly like it, and in my case I do expect people to want to quickly scroll through hundreds to thousands of items, so the lighter DOM feels worth it (though perhaps it wouldn't if I had known, when starting, how painful getting it to work on Safari would be!).
What do you think of this type of "true" infinite scrolling for web? Good thing, neutral thing, bad thing? Would you use it, if the component were available? Would you remove it, if you saw someone doing this? Are there other questions about how this was accomplished? Is this an appropriate post for Tildes?
11 votes -
Apple WWDC 2023 megathread (link goes to Apple event page)
23 votes -
Apple Vision Pro was just announced. It's Apple's first foray into AR/VR headsets.
61 votes -
This week in KDE: For fevelopers
5 votes -
App Store developers generated $1.1 trillion in total billings and sales in the App Store ecosystem in 2022
9 votes -
Naughty Dog's game design is outdated
6 votes -
core-js: So, what's next?
15 votes -
Returnal, the PS5 hit, is a master class in porting a console game to PC
4 votes -
FOSDEM 2023: Glad to be back
3 votes -
The marketing buzzwords that developers hate
5 votes -
How a single developer dropped Amazon Web Services costs by 90%, then disappeared
16 votes -
Twitter turns its back on open-source development
9 votes -
Looking for smallish feature suggestions for an open source project
I'm thinking about increasing the level of my open source contributions a bit. Instead of searching blindly until I stumble upon an issue that: A) Piques my interest B) I feel somewhat qualified...
I'm thinking about increasing the level of my open source contributions a bit. Instead of searching blindly until I stumble upon an issue that:
A) Piques my interest
B) I feel somewhat qualified to implementI figured I'd check with the tildes community. Is there any Open Source software that you use that is missing a feature/capability? Can you give a brief description of it (bonus points for links to an issue tracker with an open ticket :))?
Can't of course promise anything will come of it, but if I do pick up your suggestion at least I'll give you a ping if I make any progress!
7 votes -
What is the Fourth Dimension really? 4D Golf Devlog #2
5 votes -
Exclusive: Final Fantasy 16’s developers open up about Game of Thrones comparisons, sidequests, and representation
4 votes -
Standard patterns in choice-based games
11 votes -
Breath of the Wild fixed stamina, it's perfect now, we did it
5 votes -
The problem with mini-maps
9 votes -
Architecture in video games: Designing for impact
7 votes -
How Townscaper works: A story, four games in the making
8 votes -
Bloodborne PSX: Recreating Bloodborne as a PlayStation One game
4 votes -
The Google engineer who thinks the company’s AI has come to life
17 votes -
Allegations of sexism, bullying, and burnout: Inside the Microsoft studio behind State Of Decay 3
4 votes -
Trying to become a junior developer in Brazil is an uphill battle
They ask for years of experience, skills that no Jr would know since, well, it is a Jr and the process to apply for jobs are surreal. Thousands of tests, interviews that goes nowhere and lots of...
They ask for years of experience, skills that no Jr would know since, well, it is a Jr and the process to apply for jobs are surreal. Thousands of tests, interviews that goes nowhere and lots of ghosting. And the pay is not that good. No wonder after 2 or 3 years of experience a lot of develpers starts working for companies outside of Brazil.
Last one to contact me sent me a test to do it in 1 week. I went above and beyond and learned a lot of things. Before this, i had some small projects in Go and Python. Now i needed to learn Docker, tests, github actions, Postgresql and other things. Not everything was mandatory, but i did my best and did it all. I finished in 5 days since i have a day job.
Here is the result: https://github.com/crdpa/conservice
Showing the data in the browser was not necessary, but i think it was a nice touch and well made. If this does not land me a job as a junior developer i don't know what else could.
I'm glad i already have a job in another area, but me and my SO are separated by a 4 hour drive and i'm tired. I want to work from home to be near her and our dog. Paying rent in two places is becoming a burden.
I would be happy if you guys could test the application i made. It only needs docker.
And do you guys have any tips from now on?
7 votes -
Good web dev communities?
Hey folks. May someone recommend a good web dev community out there for quality discussions? Right now I'm using Vue for a project and I'm wrestling with architectural decisions. I'd love for a...
Hey folks.
May someone recommend a good web dev community out there for quality discussions?
Right now I'm using Vue for a project and I'm wrestling with architectural decisions. I'd love for a place where I can discuss different approaches' trade-offs and merits.
Many thanks. :)
11 votes -
Life of Reddit Enhancement Suite
23 votes -
Where/how should I acquire a .com domain for three years in advance?
So I wanna purchase a domain for my personal website (just a WordPress thing), and I wanna pay for three years in advance (I have my reasons). Which domain sellers are reasonably priced,...
So I wanna purchase a domain for my personal website (just a WordPress thing), and I wanna pay for three years in advance (I have my reasons). Which domain sellers are reasonably priced, trustworthy, and more likely to assist a less technical, non-developer user like myself?
Thanks!
13 votes -
Developer nukes his extensively used JS libraries to protest corporate use without compensation
17 votes -
Hyperbolica devlog #7 (probably final): Dialogue system
3 votes -
The battle for Bungie's soul: Inside the studio's struggle for a better work culture
11 votes -
What’s something you’re trying to be better about?
Big or small, significant or insignificant, meaningful or mundane — what are you trying to be better about, and why?
11 votes -
The Legend of Bounce Back - My playable tribute to thirty-five years of Zelda
13 votes -
The importance of button prompts
3 votes -
The anatomy of Portal - How Portal's puzzles trick you into being smart
5 votes -
Apple will not reinstate Epic’s Fortnite developer account
11 votes -
Hyperbolica devlog #6: Pushing Unity to its limits
5 votes -
Crash Bandicoot dev on optimizing for the PlayStation | War Stories
12 votes -
Activision Blizzard hires union-busting firm as workers start to come together
25 votes -
GDC Talks for Unreal Engine 2021
6 votes -
Unity able to build and port to ChromeOS
5 votes -
A few easy linux commands, and a real-world example on how to use them in a pinch
This below is a summary of some real-world performance investigation I recently went through. The tools I used are installed on all linux systems, but I know some people don't know them and would...
This below is a summary of some real-world performance investigation I recently went through. The tools I used are installed on all linux systems, but I know some people don't know them and would straight up jump to heavyweight log analysis services and what not, or writing their own solution.
Let's say you have request log sampling in a bunch of log files that contain lines like these:
127.0.0.1 [2021-05-27 23:28:34.460] "GET /static/images/flags/2/54@3x.webp HTTP/2" 200 1806 TLSv1.3 HIT-CLUSTER SessionID:(null) Cache:max-age=31536000
127.0.0.1 [2021-05-27 23:51:22.019] "GET /pl/player/123456/changelog/ HTTP/1.1" 200 16524 TLSv1.2 MISS-CLUSTER SessionID:(null) Cache:You might recognize Fastly logs there (IP anonymized). Now, there's a lot you might care about in this log file, but in my case, I wanted to get a breakdown of hits vs misses by URL.
So, first step, let's concatenate all the log files with
cat *.log > all.txt, so we can work off a single file.Then, let's split the file in two: hits and misses. There are a few different values for them, the majority are covered by either
HIT-CLUSTERorMISS-CLUSTER. We can do this by just grepping for them like so:grep HIT-CLUSTER all.txt > hits.txt; grep MISS-CLUSTER all.txt > misses.txtHowever, we only care about url and whether it's a hit or a miss. So let's clean up those hits and misses with
cut. The way cut works, it takes a delimiter (-d) and cuts the input based on that; you then give it a range of "fields" (-f) that you want.In our case, if we cut based on spaces, we end up with for example:
127.0.0.1[2021-05-2723:28:34.460]"GET/static/images/flags/2/54@3x.webpHTTP/2"2001806TLSv1.3HIT-CLUSTERSessionID:(null)Cache:max-age=31536000.We care about the 5th value only. So let's do:
cut -d" " -f5to get that. We will alsosortthe result, because future operations will require us to work on a sorted list of values.cut -d" " -f5 hits.txt | sort > hits-sorted.txt; cut -d" " -f5 misses.txt | sort > misses-sorted.txtNow we can start doing some neat stuff.
wc(wordcount) is an awesome utility, it lets you count characters, words or lines very easily.wc -lcounts lines in an input, since we're operating with one value per line we can easily count our hits and misses already:$ wc -l hits-sorted.txt misses-sorted.txt 132523 hits-sorted.txt 220779 misses-sorted.txt 353302 total220779 / 132523 is a 1:1.66 ratio of hits to misses. That's not great…
Alright, now I'm also interested in how many unique URLs are hit versus missed.
uniqtool deduplicates immediate sequences, so the input has to be sorted in order to deduplicate our entire file. We already did that. We can now count our urls withuniq < hits-sorted.txt | wc -l; uniq < misses-sorted.txt | wc -l. We get49778and201178, respectively. It's to be expected that most of our cache misses would be in "rarer" urls; this gives us a 1:4 ratio of cached to uncached URL.Let's say we want to dig down further into which URLs are most often hitting the cache, specifically. We can add
-ctouniqin order to get a duplicate count in front of our URLs. To get the top ones at the top, we can then usesort, in reverse sort mode (-r), and it also needs to be numeric sort, not alphabetic (-n).headlets us get the top 10.$ uniq -c < hits-sorted.txt | sort -nr | head 815 /static/app/webfonts/fa-solid-900.woff2?d720146f1999 793 /static/app/images/1.png 786 /static/app/fonts/nunito-v9-latin-ext_latin-regular.woff2?d720146f1999 760 /static/CACHE/js/output.cee5c4089626.js 758 /static/images/crest/3/light/notfound.png 757 /static/CACHE/css/output.4f2b59394c83.css 756 /static/app/webfonts/fa-regular-400.woff2?d720146f1999 754 /static/app/css/images/loading.gif?d720146f1999 750 /static/app/css/images/prev.png?d720146f1999 745 /static/app/css/images/next.png?d720146f1999And same for misses:
$ uniq -c < misses-sorted.txt | sort -nr | head 56 / 14 /player/237678/ 13 /players/ 12 /teams/ 11 /players/top/ <snip>So far this tells us static files are most often hit, and for misses it also tells us… something, but we can't quite track it down yet (and we won't, not in this post). We're not adjusting for how often the page is hit as a whole, this is still just high-level analysis.
One last thing I want to show you! Let's take everything we learned and analyze those URLs by prefix instead. We can cut our URLs again by slash with
cut -d"/". If we want the first prefix, we can do-f1-2, or-f1-3for the first two prefixes. Let's look!cut -d'/' -f1-2 < hits-sorted.txt | uniq -c | sort -nr | head 100189 /static 5948 /es 3069 /player 2480 /fr 2476 /es-mx 2295 /pt-br 2094 /tr 1939 /it 1692 /ru 1626 /decut -d'/' -f1-2 < misses-sorted.txt | uniq -c | sort -nr | head 66132 /static 18578 /es 17448 /player 17064 /tr 11379 /fr 9624 /pt-br 8730 /es-mx 7993 /ru 7689 /zh-hant 7441 /itThis gives us hit-miss ratios by prefix. Neat, huh?
13 votes -
Return of the Obra Dinn: Development log by Lucas Pope
15 votes -
Microsoft enables Linux GUI apps on Windows 10 for developers
24 votes -
The SPACE of Developer Productivity
3 votes -
The Digital Antiquarian: System Shock
7 votes -
Audi abandons combustion engine development
19 votes -
Star Citizen developers fed up after being expected to work during devastating Texas snowstorm
14 votes -
Vampire: The Masquerade - Bloodlines 2 delayed indefinitely
15 votes -
Let's build a JPEG Decoder (4-part series)
5 votes