Pagefold

Components that clean up

Components with timers and listeners, set up when their page arrives and cleaned up when it leaves, with a log of every step.

When Pagefold swaps the HTML, the JavaScript you attached to it stays behind: listeners, timers and instances of libraries. pagefold.mount creates a component for every element that matches a selector, and runs its cleanup when the element leaves.

/examples/components/ Open in a new tab Source on GitHub
main.js
const pagefold = new Pagefold()

const log = document.querySelector('#log')
const lines = document.querySelector('#lines')
const cleanupOn = document.querySelector('#cleanup')
let alive = 0
let timers = 0
let tabsCount = 0

const write = (text, kind) => {
  const li = document.createElement('li')
  li.className = kind
  li.textContent = text
  lines.prepend(li)
}
const show = () => {
  document.querySelector('#alive').textContent = alive
  document.querySelector('#timers').textContent = timers
  log.classList.toggle('is-leaking', !cleanupOn.checked)
}
cleanupOn.addEventListener('change', show)
document.querySelector('#reset').addEventListener('click', () => location.reload())

// The function a component returns runs when its element leaves the page.
// With the switch off it does nothing, as if you had forgotten to write it.
const whenLeaving = (name, cleanup) => () => {
  if (!cleanupOn.checked) return write(`NOT cleaned  ${name}: it is still alive`, 'leak')
  cleanup()
  alive--
  show()
  write(`cleaned      ${name}`, 'down')
}

pagefold.mount('[data-counter]', (el) => {
  const button = el.querySelector('button')
  const number = el.querySelector('b')
  let n = 0
  const onClick = () => (number.textContent = ++n)
  button.addEventListener('click', onClick)
  alive++
  show()
  write('set up       counter', 'up')
  return whenLeaving('counter', () => button.removeEventListener('click', onClick))
})

pagefold.mount('[data-clock]', (el) => {
  const time = el.querySelector('.time')
  const tick = () => (time.textContent = new Date().toLocaleTimeString([], { hour12: false }))
  tick()
  const timer = setInterval(tick, 1000)
  alive++
  timers++
  show()
  write('set up       clock, timer started', 'up')
  return whenLeaving('clock (its timer keeps running)', () => {
    clearInterval(timer)
    timers--
  })
})

pagefold.mount('[data-tabs]', (el) => {
  const buttons = [...el.querySelectorAll('.tablist button')]
  const panel = el.querySelector('.panel')
  const id = ++tabsCount
  const onClick = (event) => {
    buttons.forEach((b) => b.setAttribute('aria-selected', String(b === event.currentTarget)))
    panel.textContent = `Panel ${event.currentTarget.textContent.toLowerCase()}`
  }
  // A listener on the document outlives the element, unless somebody removes it
  const onKey = (event) => write(`key "${event.key}" handled by tabs #${id}`, 'up')
  buttons.forEach((b) => b.addEventListener('click', onClick))
  document.addEventListener('keydown', onKey)
  alive++
  show()
  write(`set up       tabs #${id}`, 'up')
  return whenLeaving(`tabs #${id} (it still listens to the keyboard)`, () => {
    buttons.forEach((b) => b.removeEventListener('click', onClick))
    document.removeEventListener('keydown', onKey)
  })
})
index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Counter · Components</title>
  <meta name="description" content="A counter keeps its own number while you are here.">
  <link rel="stylesheet" href="../_lib/fade.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <header class="top">
    <strong>Components</strong>
    <nav>
      <a href="index.html">Counter</a>
      <a href="clock.html">Clock</a>
      <a href="tabs.html">Tabs</a>
    </nav>
  </header>
  <main id="pagefold" class="transition-fade">
    <h1>Counter</h1>
    <p class="lead">A counter keeps its own number while you are here.</p>
    <div class="widget" data-counter><button type="button">Clicked <b>0</b> times</button></div>
  </main>
  <section id="log" aria-label="What pagefold.mount did">
    <header>
      <label class="switch"><input type="checkbox" id="cleanup" checked> Clean up when leaving</label>
      <span class="stats">alive <b id="alive">0</b> · timers <b id="timers">0</b></span>
      <button type="button" id="reset">Start over</button>
    </header>
    <ol id="lines"></ol>
  </section>
  <script src="../_lib/pagefold.js"></script>
  <script src="main.js"></script>
</body>
</html>
style.css
*{box-sizing:border-box}
html,body{min-height:100%}
body{margin:0;font:16px/1.6 system-ui,sans-serif;background:#0a0a0a;color:#fafaf8}
/* Links: the accent color with a quiet underline that gets stronger on hover */
a{color:#5af0ae;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:.25em;text-decoration-color:rgba(90,240,174,.5);transition:color .15s,text-decoration-color .15s}
a:hover{color:#00ff9c;text-decoration-color:currentColor}
a:focus-visible{outline:2px solid #5af0ae;outline-offset:3px;border-radius:2px}
.top{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.8rem 1.5rem;border-bottom:1px solid #2c2c2c}
.top strong{font:700 .95rem ui-monospace,Menlo,monospace}
.top nav{display:flex;gap:1.25rem;flex-wrap:wrap}
main{padding:2rem 1.5rem 3rem;max-width:46rem}
h1{margin:0 0 .5rem;font-size:2.1rem;line-height:1.15}
p{margin:0 0 1rem;max-width:36rem;color:#c9c9c4}
.lead{color:#a3a39e;font-size:1.05rem}
.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:.75rem;margin-top:1.5rem}
.card{border:1px solid #2c2c2c;background:#141414;border-radius:12px;padding:.9rem}
.card i{display:block;height:3.2rem;border-radius:8px;margin-bottom:.6rem}
.card b{display:block;font-size:.95rem}
.card span{font-size:.82rem;color:#a3a39e}
@media (max-width:34rem){.cards{grid-template-columns:1fr}main{padding:1.25rem}}
.widget{margin:1.25rem 0;padding:1.1rem;border:1px solid #2c2c2c;border-radius:12px;background:#141414}
.widget button{font:inherit;color:#fafaf8;background:#1a1a1a;border:1px solid #2c2c2c;border-radius:8px;padding:.45rem .9rem;cursor:pointer}
.widget button:hover{border-color:#00ff9c}
.widget b{color:#00ff9c;font-variant-numeric:tabular-nums}
.time{font:700 2rem ui-monospace,Menlo,monospace;font-variant-numeric:tabular-nums}
.tablist{display:flex;gap:.4rem;margin-bottom:.75rem}
.tablist [aria-selected=true]{border-color:#00ff9c;background:#10261d}
.panel{margin:0}
main{padding-bottom:1rem}
#log{margin:0 1.5rem 1.5rem;border:1px solid #2c2c2c;border-radius:12px;background:#101010;max-width:46rem;font:.8rem ui-monospace,Menlo,monospace}
#log header{display:flex;flex-wrap:wrap;align-items:center;gap:.6rem 1.25rem;padding:.5rem .8rem;border-bottom:1px solid #2c2c2c;color:#a3a39e}
#log .switch{display:flex;align-items:center;gap:.45rem;color:#fafaf8;cursor:pointer}
#log .switch input{accent-color:#00ff9c;width:1rem;height:1rem;cursor:pointer}
#log .stats{margin-left:auto}
#log .stats b{color:#00ff9c;font-variant-numeric:tabular-nums}
#log.is-leaking .stats b{color:#ff9f5c}
#log button{font:inherit;color:#fafaf8;background:#1a1a1a;border:1px solid #2c2c2c;border-radius:8px;padding:.2rem .6rem;cursor:pointer}
#log button:hover{border-color:#00ff9c}
#lines{list-style:none;margin:0;padding:.4rem .8rem;max-height:9rem;overflow:auto}
#lines li{padding:.12rem 0;color:#a3a39e}
#lines .up{color:#00ff9c}
#lines .down{color:#7cc4ff}
#lines .leak{color:#ff9f5c}

What to try

With the switch on (the default):

  • On Counter, click the button a few times. Go to Clock: the log says the counter was cleaned.
  • On Clock, timers is 1. Go to another page and it is 0: the timer was stopped.
  • Go back and forth. alive is always the number of components that are on the page.

Now turn “Clean up when leaving” off and do the same:

  • Go to Clock and away, three times. timers goes to 3. Each visit left one more timer running, on an element that is no longer on the page.
  • Go to Tabs and away a few times, then press any key. The log shows the same key handled by several tabs: tabs #2, tabs #3, tabs #4.
  • alive only goes up. The log says NOT cleaned at every step.
  • Press Start over to begin again.

Why not cleaning up is bad

The switch does exactly what forgetting the cleanup does: the component is set up again on every visit, and the old one is never undone.

  • Timers and animation loops keep running. Each one wakes the browser, burns CPU and battery, and works on elements that nobody can see. They add up with every visit.
  • Listeners on document, window or a timer fire several times. The key press above is handled once per visit you made. Real examples are a tracking event sent three times, a modal that opens twice or a request that is made once per old page.
  • Memory grows. A listener or a timer holds on to its element, its data and everything its closure can reach, so the old page cannot be freed. On a site where visitors browse for an hour, this is a slow leak that nobody notices until it hurts.
  • The bugs are hard to find. Everything works the first time and gets worse with use, so it is easy to miss while developing, where you rarely visit more than a few pages.

The cure is the one in the code: return a function that undoes what setup did, and pagefold.mount runs it when the element leaves. Anything you start (a timer, an observer, a listener outside the element, a third-party instance) needs an undo.

How it works

pagefold.mount('[data-clock]', (el) => {
  const timer = setInterval(tick, 1000)
  return () => clearInterval(timer)
})
  • Each element is set up once, and cleaned up once, however many visits come and go.
  • Return the cleanup. Without it, every visit to the clock would leave one more timer running.
  • The code is loaded once, in the first page. mount takes care of setting up and cleaning up, so no script has to run again.

More in Initializing components and Gia.