• Activity
  • Votes
  • Comments
  • New
  • All activity
  • Showing only topics with the tag "development". Back to normal view
    1. 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 handlePagination from 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 there

      If 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
    2. 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 implement

      I 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
    3. 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
    4. 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
    5. 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
    6. 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-CLUSTER or MISS-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.txt
      

      However, 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-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.

      We care about the 5th value only. So let's do: cut -d" " -f5 to get that. We will also sort the 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.txt
      

      Now we can start doing some neat stuff. wc (wordcount) is an awesome utility, it lets you count characters, words or lines very easily. wc -l counts 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 total
      

      220779 / 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. uniq tool 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 with uniq < hits-sorted.txt | wc -l; uniq < misses-sorted.txt | wc -l. We get 49778 and 201178, 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 -c to uniq in order to get a duplicate count in front of our URLs. To get the top ones at the top, we can then use sort, in reverse sort mode (-r), and it also needs to be numeric sort, not alphabetic (-n). head lets 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?d720146f1999
      

      And 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-3 for 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 /de
      
      cut -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 /it
      

      This gives us hit-miss ratios by prefix. Neat, huh?

      13 votes