---
title: 'The Browser and the Web Platform'
source: 'https://academia.sh/en/courses/browser-platform'
language: en
updated: '2026-08-17T04:40:45+00:00'
license: 'CC BY-SA 4.0'
lessons: 24
outcomes:
    - 'Manages the document tree and the event model programmatically'
    - 'Uses browser storage and observer APIs in the right places'
    - 'Builds offline behavior with a service worker'
---

# The Browser and the Web Platform

Programmatic management of the document tree, the three-phase event model, storage and observer APIs, web components with offline behavior, and diagnosis with browser tools.

## The Document and Events

The API that reads and modifies the node tree, an event's capture-target-bubble journey, managing many elements with a single listener, and intervening in the browser's default behavior.

### The DOM API

Source: https://academia.sh/en/courses/browser-platform/dom-api

The interface that reads and modifies the document tree; the node-versus-element distinction, the difference between a live collection and a static list, the attribute-property duality, and the cost of batch changes.

The Web Fundamentals and HTML course built a document: the North Slope Measurement
Station page, a sectioned structure, a measurement table, and an accessible submission
form. The Visual Presentation with CSS course gave that document style. Across both
courses, the page was **static**: it stood exactly as it arrived from the server, and
nothing the user did changed it.

This course adds behavior to the page. Behavior's first condition is being able to read
and modify the document from inside a program. The Markup Language Concept lesson showed
how the tree is built from the source text; in this lesson, the structure built there
becomes **a data structure with an interface**.

#### The Tree Is an Object Graph

Every node the parser produces is an object the program can reach. Objects are linked to
each other through parent, child, and sibling bonds; every point in the tree can be
reached through these bonds.

The first trap is hidden in what these bonds carry. An element's children are not only
elements: the whitespace between tags also enters the tree as a text node. For this
reason, the API offers two separate ways to walk it — one that sees every node, another
that sees only element nodes.

```js
// nodes.mjs — the difference between child nodes and child elements
const text = (value) => ({ type: "text", value, children: [] });
const element = (name, attrs = {}, ...content) => {
  const node = { type: "element", name, attrs, children: [], parent: null };
  for (const part of content) {
    const child = typeof part === "string" ? text(part) : part;
    child.parent = node;
    node.children.push(child);
  }
  return node;
};

// tree equivalent of the measurement list from K01:
// <ul id="measurements">
//   <li class="measurement">Temperature</li>
//   <li class="measurement">Relative humidity</li>
// </ul>
const list = element("ul", { id: "measurements" },
  "\n  ",
  element("li", { class: "measurement" }, "Temperature"),
  "\n  ",
  element("li", { class: "measurement" }, "Relative humidity"),
  "\n");

const childNodes = (d) => d.children;
const childElements = (d) => d.children.filter((c) => c.type === "element");

console.log("child node count   :", childNodes(list).length);
console.log("child element count:", childElements(list).length);
console.log("first child node   :", JSON.stringify(childNodes(list)[0].value));
console.log("first child element:", childElements(list)[0].name);
```

```
child node count   : 5
child element count: 2
first child node   : "\n  "
first child element: li
```

There are three text nodes between the two `li` elements, and all three consist only of a
line break and indentation. In the browser API, this distinction is met by the pairs
`childNodes` versus `children`, `firstChild` versus `firstElementChild`, `nextSibling`
versus `nextElementSibling`. The first column of each pair sees every node, the second
sees only elements.

Code that walks a document starting from the first node can break when indentation in the
markup changes. This is the source of the fragility, and the reason for the
selector-based access covered in the next section.

#### Selecting a Node

Instead of walking the tree by hand, the node being looked for is described with a
selector. The syntax defined in the Selectors lesson of the Visual Presentation with CSS
course applies here exactly as it is: the language that determines which elements style
rules apply to also determines which elements a program can reach.

There are two basic operations. One returns the **first** matching element, the other
**all** of them. Both start from a root: a search can be run over the whole document, or
over a specific element's subtree. Searching a subtree excludes the same selector's
matches elsewhere on the page, which is why it is the form preferred by code that runs
inside a component's boundaries.

There is also access by id. Because an id is unique within a document, this access is
read directly from an index rather than scanning the tree; but it rests on the uniqueness
assumption. If the same id is written twice, the first is returned and no error is
raised.

#### Live Collection and Static List

What a selection operation returns can be one of two different kinds, and the difference
determines what the code sees after the tree changes.

```js
// collection.mjs — the difference between a live collection and a static list
const element = (name, attrs = {}, ...content) => {
  const node = { type: "element", name, attrs, children: [], parent: null };
  for (const part of content) {
    const child = typeof part === "string" ? { type: "text", value: part, children: [] } : part;
    child.parent = node;
    node.children.push(child);
  }
  return node;
};

// Walks every element in the tree in pre-order.
function* elements(root) {
  if (root.type === "element") yield root;
  for (const child of root.children) yield* elements(child);
}

const matches = (o, selector) => {
  if (selector.startsWith(".")) return (o.attrs.class ?? "").split(/\s+/).includes(selector.slice(1));
  if (selector.startsWith("#")) return o.attrs.id === selector.slice(1);
  return o.name === selector;
};

// Static list: copies the matches at call time into an array.
const selectAll = (root, selector) => [...elements(root)].filter((o) => matches(o, selector));

// Live collection: rescans the tree on every read.
const liveCollection = (root, selector) => ({
  get length() { return selectAll(root, selector).length; },
});

const list = element("ul", { id: "measurements" },
  element("li", { class: "measurement" }, "Temperature"),
  element("li", { class: "measurement" }, "Relative humidity"));

const staticList = selectAll(list, "li");
const live = liveCollection(list, "li");
console.log("before insertion — static:", staticList.length, "live:", live.length);

const item = element("li", { class: "measurement" }, "Wind speed");
item.parent = list;
list.children.push(item);

console.log("after insertion  — static:", staticList.length, "live:", live.length);
```

```
before insertion — static: 2 live: 2
after insertion  — static: 2 live: 3
```

A static list is a copy of the matches at the moment it was called; even if the tree
changes afterward, the list does not change. A live collection, on the other hand, is the
query itself: every time it is read, it gives the matches at that moment.

In the browser API, `querySelectorAll` returns a static result, `getElementsByTagName` and
`getElementsByClassName` a live one. The distinction matters in two situations. First,
adding or removing an element inside the body of a loop iterating over a live collection
changes the collection's length while the loop is running; an infinite loop originates
here. Second, holding onto a static list for a long time keeps nodes removed from the tree
in memory — this is this course's counterpart to the accessibility problem defined in the
Memory Leak Diagnosis lesson of the Asynchronous JavaScript and Runtime course.

#### Attribute and Property

An element's `href="..."` declaration written in the markup and its `href` value on the
program side are not the same thing. The first is called the **attribute**, the second
the **property**. The attribute is the string the parser reads from the source text; the
property is a value derived from that string, one that can change at runtime.

```js
// attribute.mjs — the distinction between the attribute string and the property value
const BASE = "https://data.example.test/station/north-slope/";

// Attributes are always strings; properties are resolved, live values.
const PROPERTY = {
  a: (o) => ({ href: new URL(o.attrs.href, BASE).href }),
  "input[text]": (o) => ({
    defaultValue: o.attrs.value,           // read from the attribute
    value: o.enteredValue ?? o.attrs.value, // the value the user has entered
  }),
  "input[checkbox]": (o) => ({
    defaultChecked: "checked" in o.attrs,
    checked: o.marked ?? "checked" in o.attrs,
  }),
};

const elements = [
  ["a", { attrs: { href: "../log?type=temperature" } }],
  ["input[text]", { attrs: { name: "value", value: "-4.2" }, enteredValue: "-6.8" }],
  ["input[checkbox]", { attrs: { name: "verified", checked: "" }, marked: false }],
];

for (const [type, o] of elements) {
  console.log(type);
  console.log("  attribute:", JSON.stringify(o.attrs));
  console.log("  property :", JSON.stringify(PROPERTY[type](o)));
}
```

```
a
  attribute: {"href":"../log?type=temperature"}
  property : {"href":"https://data.example.test/station/log?type=temperature"}
input[text]
  attribute: {"name":"value","value":"-4.2"}
  property : {"defaultValue":"-4.2","value":"-6.8"}
input[checkbox]
  attribute: {"name":"verified","checked":""}
  property : {"defaultChecked":true,"checked":false}
```

Three forms of divergence are visible. On the link, the attribute is a relative address,
while the property is the absolute address resolved against the base address defined in
the Links lesson. On the text field, the attribute carries the **starting** value and
does not change as the user types; the current value lives only in the property. On the
checkbox, the attribute only announces the checked state at page load; when the user
unchecks the box, the attribute stays as it was, the property becomes false.

A practical rule follows from this: **state the user can change is read from the
property, the starting state the document declares from the attribute.** Code that reads
a form field's current value from the attribute will never see what the user typed.

The `data-` attributes defined in the Data Attributes lesson sit outside this duality: the `dataset`
property reflects them as strings in both directions and produces none of the divergence seen with `value`
or `checked`.

#### Creating and Placing Nodes

New content can be produced two ways. The first is building the node piece by piece: an
element is created, its attributes are written, a text node is added, then it is placed
into its spot in the tree. The second is giving a markup string and having the parser
build the tree.

The second way is shorter to write and has a cost: if a value coming from the user gets
mixed into the string, the `<` character in that value is interpreted as a tag. When the
escaping rule defined in the Tags, Attributes, and Entities lesson is not enforced here,
data turns into code. For this reason, content carrying user data is added as a text
node; a `<` character written into a text node never becomes a tag under any condition.

Placement operations share a common property: a node can have only one place in the tree.
When a node that is already connected is added somewhere else, it is not copied, it is
**moved**; it comes out of its old place on its own.

#### The Cost of Batch Changes

Changing a node connected to the tree invalidates the relevant part of the style and
layout calculation. Adding a hundred-item list one at a time triggers this invalidation a
hundred times. The solution is preparing the nodes in a container not connected to the
document and connecting them all at once. This container is called a **document
fragment**.

```js
// fragment.mjs — the difference between inserting one by one and inserting via a document fragment
const element = (name, attrs = {}) => ({ type: "element", name, attrs, children: [], parent: null });

let invalidations = 0; // every change made in a tree attached to the document increments this

function isConnected(node) {
  let ancestor = node;
  while (ancestor.parent) ancestor = ancestor.parent;
  return ancestor.type === "document";
}

// One call = one tree change.
function append(target, ...nodes) {
  for (const d of nodes) { d.parent = target; target.children.push(d); }
  if (isConnected(target)) invalidations += 1;
}

const document_ = { type: "document", name: "#document", children: [], parent: null };
const list = element("ul", { id: "measurements" });
append(document_, list);

const records = ["Temperature", "Relative humidity", "Wind speed", "Precipitation", "Pressure"];

// First way: every item is added to the connected tree with a separate call.
invalidations = 0;
for (const name of records) append(list, element("li", { "data-measurement": name }));
console.log("direct insertion:", invalidations, "invalidations,", list.children.length, "children");

// Second way: items are first collected in an unconnected fragment, then added with one call.
list.children.length = 0;
invalidations = 0;
const fragment = { type: "fragment", name: "#fragment", children: [], parent: null };
for (const name of records) append(fragment, element("li", { "data-measurement": name }));
append(list, ...fragment.children.splice(0));
console.log("fragment insertion:", invalidations, "invalidations,", list.children.length, "children");
console.log("remaining in fragment:", fragment.children.length, "children");
```

```
direct insertion: 5 invalidations, 5 children
fragment insertion: 1 invalidations, 5 children
remaining in fragment: 0 children
```

Two properties of the document fragment appear in the output. First, because additions
made to the fragment are not connected to the document, they produce no invalidation.
Second, when the fragment is added to the tree, it does not enter the tree itself; it only
hands off its children and empties out. The zero on the last line shows this.

The number here is not a performance measurement, it is a **call count**. How large the
real cost is depends on the document and the style; measuring it is the subject of the
Performance Logging lesson. What is constant is that the number of computations triggered
drops from five to one.

#### Summary

- The document tree is an object graph linked by parent-child-sibling bonds; part of
  these bonds sees every node, part sees only elements.
- Indentation and line breaks between tags enter the tree as text nodes; code that walks
  node bonds is sensitive to how the markup is formatted.
- A selection operation returns either a static list or a live collection; a live
  collection re-queries the tree every time it is read.
- The attribute is the string in the source text, the property is a live value derived
  from it; user input only ever appears in the property.
- Every change to a connected node invalidates the style and layout calculation; batch
  insertion is prepared in an unconnected document fragment.

#### Next Step

This lesson established reading and modifying the document, but left open what triggers a
change. A page does not change on its own; the moment the user clicks, types, or scrolls
produces a notification, and the program responds to that notification. The path these
notifications follow on the tree is defined and is not a single node: an event passes
through its ancestors before reaching its target, and returns by the same path after the
target. The next lesson examines this three-phase journey and the order listeners are
called in.

### The Event Model

Source: https://academia.sh/en/courses/browser-platform/event-model

The path an event follows from root to target and back from target to root; capture, target, and bubble phases, listener order, the two forms of stopping propagation, and listener identity.

The previous lesson established reading and modifying the document. What triggers a
change was left open: a page does not change on its own, it changes in response to a
notification the user or the browser produces. This notification is called an **event**.

Which node an event belongs to is clear at first glance — the button clicked, the field
typed into. The real question is: does the event get announced only to that node? The
answer is no, and this lesson's subject is the defined path an event follows on the tree.

#### An Event Is an Object

An event is an object the browser produces and hands to the listener as an argument.
Three fields are present in every event.

**Type** is the event's name: `click`, `input`, `submit`, `keydown`. A listener registers
for a type; events of a different type do not call it.

**Target** is the node the event originated from. In a click event, this is the deepest
element under the pointer — even if it looks like the row itself was clicked, the target
is the button inside that row.

**Current target** is the node the listener is attached to. The two are the same only
when the listener is attached to the target itself; in every other case they diverge, and
this divergence is the basis for the whole of the next lesson.

When the listener runs depends on the model defined in the Event Loop lesson of the
Asynchronous JavaScript and Runtime course: when an event is produced, the listener is
not called immediately, it is queued as a task and run once the call stack is empty. A
long-running listener therefore does not only delay itself, it delays every event in the
queue.

#### The Propagation Path

When an event is produced, the browser first computes the **propagation path**: the chain
of ancestors from the root node to the target. This path is fixed before the event starts
propagating and does not change even if the tree changes during propagation.

Every node along the path is visited twice: once descending from root to target, once
ascending from target to root. These two passes produce three phases.

The **capture phase** descends from root to the target's parent. Listeners bound to
ancestors and registered in capture mode are called here.

The **target phase** is the target itself.

The **bubble phase** ascends from the target's parent to root. Listeners bound to
ancestors and registered in bubble mode are called here.

```js
// propagation.mjs — a small model of three-phase event propagation
const element = (name, ...children) => {
  const d = { name, children, parent: null, listeners: [] };
  for (const c of children) c.parent = d;
  return d;
};

const listen = (node, type, label, capturing = false) =>
  node.listeners.push({ type, label, capturing });

// Walks from target to root, then reverses: the path always runs root to target.
function path(target) {
  const chain = [];
  for (let d = target; d; d = d.parent) chain.push(d);
  return chain.reverse();
}

const PHASE_NAME = { 1: "capture", 2: "target", 3: "bubble" };

function dispatch(target, type) {
  const chain = path(target);
  const calls = [];

  const run = (node, phase, capturing) => {
    for (const d of node.listeners) {
      if (d.type !== type || d.capturing !== capturing) continue;
      calls.push({ phase, node: node.name, listener: d.label });
    }
  };

  for (const node of chain.slice(0, -1)) run(node, 1, true);   // capture
  run(target, 2, true);                                         // target
  run(target, 2, false);                                        // target
  for (const node of chain.slice(0, -1).reverse()) run(node, 3, false); // bubble

  return calls;
}

const button = element("button.save");
const row = element("tr.measurement", button);
const table = element("table#log", row);
const main = element("main", table);
const body = element("body", main);

listen(body, "click", "body-capture", true);
listen(main, "click", "main-capture", true);
listen(table, "click", "table-bubble");
listen(row, "click", "row-capture", true);
listen(row, "click", "row-bubble");
listen(button, "click", "button-1");
listen(button, "click", "button-2", true);

console.log("path:", path(button).map((d) => d.name).join(" > "));
console.log();
for (const c of dispatch(button, "click")) {
  console.log(String(c.phase).padEnd(2), PHASE_NAME[c.phase].padEnd(9), c.node.padEnd(14), c.listener);
}
```

```
path: body > main > table#log > tr.measurement > button.save

1  capture   body           body-capture
1  capture   main           main-capture
1  capture   tr.measurement row-capture
2  target    button.save    button-2
2  target    button.save    button-1
3  bubble    tr.measurement row-bubble
3  bubble    table#log      table-bubble
```

The output shows what happens when the save button in a row of the station page's
measurement table is clicked. Seven listeners are registered, and all seven are called;
the calling order does not depend on registration order but on the node's position on the
path and the listener's phase.

The `table-bubble` listener bound to the `table#log` node is third in registration order
but last in calling order. The reason is that the bubble phase walks the path in the
reverse direction: the ancestor closest to the target is called first, the farthest
ancestor last.

#### Order at the Target

The output's fourth and fifth lines carry a detail: `button-2` was called before
`button-1` even though it was registered last. The only difference between them is that
`button-2` was registered in capture mode.

The reason is that the path is walked twice. The target node is visited on both passes:
listeners in capture mode are called on the descending pass, ones in bubble mode on the
ascending pass. The phase name is reported as "target" in both, but the order comes from
the pass.

This behavior can be surprising in cases where a listener is bound to the same node in
both capture and bubble mode. The rule is simple: **capture-mode listeners on the same
node always run first**, independent of registration order. Listeners of the same mode
keep their registration order among themselves.

#### Stopping Propagation

A listener can cut short the event's journey along the path. There are two separate
operations, and their effects differ.

```js
// stopping.mjs — two forms of stopping propagation, and a non-bubbling event
const element = (name, ...children) => {
  const d = { name, children, parent: null, listeners: [] };
  for (const c of children) c.parent = d;
  return d;
};
const listen = (node, label, options = {}) =>
  node.listeners.push({ label, capturing: options.capturing ?? false, action: options.action });

function path(target) {
  const chain = [];
  for (let d = target; d; d = d.parent) chain.push(d);
  return chain.reverse();
}

function dispatch(target, { bubbles = true } = {}) {
  const chain = path(target);
  const event = { stopped: false, stoppedImmediately: false, called: [] };

  const run = (node, capturing) => {
    for (const d of node.listeners) {
      if (d.capturing !== capturing) continue;
      if (event.stoppedImmediately) return;      // also skips the rest at the same node
      event.called.push(node.name + "/" + d.label);
      d.action?.(event);
    }
  };

  for (const d of chain.slice(0, -1)) {
    if (event.stopped) break;                    // does not move to the next node
    run(d, true);
  }
  if (!event.stopped) run(target, true);
  if (!event.stopped) run(target, false);
  if (bubbles) {
    for (const d of chain.slice(0, -1).reverse()) {
      if (event.stopped) break;
      run(d, false);
    }
  }
  return event.called;
}

function buildTree(rowAction) {
  const button = element("button");
  const row = element("tr", button);
  const table = element("table", row);
  const body = element("body", table);
  listen(body, "body-capture", { capturing: true });
  listen(row, "row-capture", { capturing: true, action: rowAction });
  listen(button, "button-1");
  listen(button, "button-2");
  listen(row, "row-bubble");
  listen(table, "table-bubble");
  return button;
}

console.log("unimpeded         :", dispatch(buildTree(undefined)).join(" → "));
console.log("stopPropagation   :", dispatch(buildTree((e) => { e.stopped = true; })).join(" → "));
console.log("non-bubbling event:", dispatch(buildTree(undefined), { bubbles: false }).join(" → "));

// stopImmediatePropagation also blocks the next listener at the same node.
const button = element("button");
const body2 = element("body", button);
listen(body2, "body-bubble");
listen(button, "button-1", { action: (e) => { e.stoppedImmediately = true; e.stopped = true; } });
listen(button, "button-2");
console.log("stopImmediate...  :", dispatch(button).join(" → "));
```

```
unimpeded         : body/body-capture → tr/row-capture → button/button-1 → button/button-2 → tr/row-bubble → table/table-bubble
stopPropagation   : body/body-capture → tr/row-capture
non-bubbling event: body/body-capture → tr/row-capture → button/button-1 → button/button-2
stopImmediate...  : button/button-1
```

`stopPropagation` prevents the event from moving on to the **next node**. Other listeners
bound to the same node are still called. In the second line, the event stopped at the row
during the capture phase never reaches the target — the button's own listeners never ran.

`stopImmediatePropagation` also skips the **remaining listeners on the same node**. In the
last line, `button-2` was not called.

The choice between the two is a design decision. Code calling `stopPropagation` cancels
the event without knowing what is above it in the tree. A listener bound elsewhere on the
page to the entire document — one that closes an open menu, or saves a measurement — goes
silently inactive. For this reason, stopping propagation creates a dependency where two
pieces of code affect each other without knowing it, and it is used as a last resort.

#### Bubbling and Non-Bubbling Events

Not every event bubbles. The output's third line shows this: in a non-bubbling event, the
capture and target phases run as usual, only the bubble phase is skipped.

This distinction is often misunderstood. A non-bubbling event does not mean "only reaches
the target." Listeners bound to ancestors in capture mode do see it. Only ancestor
listeners in bubble mode do not see it.

Examples of non-bubbling events follow a certain logic: declarations specific to a single
element, with no meaning to ancestor nodes, do not bubble. Focus-gain and focus-loss
events do not bubble; a separate, bubbling event type carrying the same information is
defined. An image loading does not bubble. Events arising from user input — click, key
press, input, submission — bubble.

Whether an event bubbles can be read from the event object's `bubbles` field; this is
more reliable than guessing by looking at the document.

#### Listener Identity

A listener's registration is identified by three pieces of information: the event type,
the function reference, and the capture mode. Registering a second time with the same
triple does not add a new listener, it is ignored. Registering the same function with a
different mode, on the other hand, creates two separate listeners.

Removal is done with the same triple. A common mistake follows from this: a function
defined inline at registration time, when written again at removal time, **is a different
function** and cannot be matched. A listener's function that needs to be removable must
be bound to a name.

Two registration options solve this problem at the source. The `once` option removes the
listener on its own after the first call. The `signal` option accepts the cancellation
token defined in the Cancellation and Timeouts lesson of the Asynchronous JavaScript and
Runtime course; once the token is cancelled, every listener bound to it is removed in one
move. A component registering many listeners has its cleanup reduced to a single line
this way.

#### Summary

- When an event is produced, its propagation path is computed first: the chain of
  ancestors from root to target. The path is fixed before propagation starts.
- The path is walked twice and produces three phases: capture (root to target), target,
  bubble (target to root).
- On the same node, capture-mode listeners run before bubble-mode ones; within the same
  mode, registration order is preserved.
- `stopPropagation` prevents the event from moving to the next node; `stopImmediatePropagation`
  also blocks the remaining listeners on the same node.
- Non-bubbling events skip only the bubble phase; capture and target phases stay
  unchanged.
- Listener identity is the triple of type, function reference, and capture mode; removal
  requires the same triple, and the `once` and `signal` options remove that requirement.

#### Next Step

This lesson showed that an event passes through its ancestors both before and after
reaching the target. This journey is not a burden, it is an opportunity: a single
listener bound to the target's ancestor can see the events of every element beneath that
ancestor. Binding a single listener to the measurement table, instead of a separate
listener to every row, removes both the count of registrations and the problem of
later-added rows staying without a listener. The next lesson establishes this technique,
together with the rule for telling the target apart from the ancestor node.

### Event Delegation

Source: https://academia.sh/en/courses/browser-platform/event-delegation

Serving many elements' events with a single listener bound to an ancestor; telling the target apart from the current target, finding the closest matching ancestor, carrying identity in the markup, and delegation's limits.

The previous lesson showed that an event passes through its ancestors both before and
after reaching the target. This journey is not a burden, it is an opportunity: an
ancestor's listener sees the events of every element beneath that ancestor. Instead of
binding a separate listener to every row of the measurement table, a single listener can
be bound to the table.

This technique is called **event delegation**. Delegation working rests on a single
distinction: the listener is bound to an ancestor node, but the element producing the
event is the target. The listener has to resolve which descendant element the event it
received came from.

#### Two Problems

Direct binding produces two separate problems, and the second is more insidious.

The first is **registration count**. A listener per row in a hundred-row table means a
hundred registrations. Every registration holds an entry in memory, and every
registration creates a bond that has to be cleaned up when the element is removed from
the tree.

The second is **lifetime**. A listener is bound to the element that exists at the moment
it is registered. A row added after the page has loaded stays without a listener, because
it did not exist at registration time. This is the behavioral counterpart of the live
collection versus static list distinction from the DOM API lesson: a bond established at
a static moment does not cover nodes that arrive later.

Delegation solves both problems at once. The listener is bound to an ancestor that
outlives the rows — the table. Rows come and go, but the table stays put.

#### Resolving the Target

A delegated listener's first job is deciding whether the incoming event came from an
element it cares about. Two operations are commonly confused here.

**Match testing** says whether a single element satisfies a given selector; it looks only
at that element. **Closest-ancestor search** starts from the target, walks the ancestor
chain upward, and returns the first node matching the selector.

The difference shows up in where the click lands. If a delete button contains an icon
element, when the user presses the button, the target is not the button but the icon.
Code that tests only the target misses this click.

```js
// closest.mjs — matching search that walks upward from the target to a boundary
const element = (name, attrs = {}, ...children) => {
  const d = { name, attrs, children, parent: null };
  for (const c of children) c.parent = d;
  return d;
};

const label = (d) =>
  d.name + (d.attrs.id ? "#" + d.attrs.id : "") + (d.attrs.class ? "." + d.attrs.class : "");

const matches = (d, selector) => {
  if (selector.startsWith(".")) return (d.attrs.class ?? "").split(/\s+/).includes(selector.slice(1));
  if (selector.startsWith("#")) return d.attrs.id === selector.slice(1);
  return d.name === selector;
};

// Starts at the target, walks upward to the boundary; the boundary is also tried, nothing past it.
const closest = (target, selector, boundary) => {
  for (let d = target; d; d = d.parent) {
    if (matches(d, selector)) return d;
    if (d === boundary) break;
  }
  return null;
};

const icon = element("span", { class: "icon" });
const button = element("button", { class: "delete" }, icon);
const row = element("tr", { class: "measurement", "data-code": "T-04" }, element("td", {}), element("td", {}, button));
const table = element("table", { id: "log" }, row);

console.log("target        :", label(icon));
console.log("current target:", label(table));
console.log("matches(target, '.delete')  :", matches(icon, ".delete"));
console.log("closest('.delete')          :", label(closest(icon, ".delete", table)));
console.log("closest('tr')                :", label(closest(icon, "tr", table)), closest(icon, "tr", table).attrs["data-code"]);
console.log("closest('body')              :", closest(icon, "body", table));
```

```
target        : span.icon
current target: table#log
matches(target, '.delete')  : false
closest('.delete')          : button.delete
closest('tr')                : tr.measurement T-04
closest('body')              : null
```

The third line shows the problem: the target itself is not the delete button. The fourth
line shows the fix: the upward search finds the button. The fifth line goes one step
further and finds the row the button belongs to; the measurement's code is read from
there.

The last line shows the **boundary** concept. The search stops at the current target.
Without this boundary, the search would climb all the way to the document's root and
could return a node outside the listener's scope, leading to an operation being performed
on another component's element. In the browser API, these operations are named `matches`
and `closest`; because `closest` takes no boundary, whether the result stays under the
current target is checked separately.

#### Setting Up Delegation

The behavioral difference between the two approaches shows up on a row added later.

```js
// delegation.mjs — the difference between binding to every element and delegating with one listener
const element = (name, attrs = {}, ...children) => {
  const d = { name, attrs, children, parent: null, listeners: [] };
  for (const c of children) c.parent = d;
  return d;
};
const matches = (d, s) =>
  s.startsWith(".") ? (d.attrs.class ?? "").split(/\s+/).includes(s.slice(1)) : d.name === s;
const closest = (target, s, boundary) => {
  for (let d = target; d; d = d.parent) { if (matches(d, s)) return d; if (d === boundary) break; }
  return null;
};
// Bubble phase: every node's listeners are called from target to root.
const click = (target) => {
  for (let d = target; d; d = d.parent)
    for (const listener of d.listeners) listener({ target, currentTarget: d });
};
const listenerCount = (root) =>
  root.listeners.length + root.children.reduce((t, c) => t + listenerCount(c), 0);

const buildRow = (code) =>
  element("tr", { class: "measurement", "data-code": code }, element("td", {}), element("td", {}, element("button", { class: "delete" })));
const buildTable = () =>
  element("table", { id: "log" },
    element("tr", { class: "heading" }, element("th", {})),
    buildRow("T-01"), buildRow("T-02"), buildRow("T-03"));

const buttons = (root) =>
  [root, ...root.children.flatMap((c) => buttons(c))].filter((d) => matches(d, ".delete"));

let deleted = [];
const deleteRow = (row) => deleted.push(row.attrs["data-code"]);

// First way: a separate listener on every delete button.
const a = buildTable();
for (const b of buttons(a)) b.listeners.push(({ currentTarget }) => deleteRow(currentTarget.parent.parent));
console.log("direct binding, listeners:", listenerCount(a));
a.children.push(Object.assign(buildRow("T-04"), { parent: a })); // row added later
deleted = []; click(buttons(a)[0]); console.log("  T-01 button:", deleted.length ? deleted : "no action");
deleted = []; click(buttons(a)[3]); console.log("  T-04 button:", deleted.length ? deleted : "no action");

// Second way: a single listener on the table; the target is re-resolved on every call.
const b = buildTable();
b.listeners.push(({ target, currentTarget }) => {
  const button = closest(target, ".delete", currentTarget);
  if (button === null) return;
  deleteRow(closest(button, "tr", currentTarget));
});
console.log("delegation, listeners     :", listenerCount(b));
b.children.push(Object.assign(buildRow("T-04"), { parent: b }));
deleted = []; click(buttons(b)[0]); console.log("  T-01 button:", deleted.length ? deleted : "no action");
deleted = []; click(buttons(b)[3]); console.log("  T-04 button:", deleted.length ? deleted : "no action");
deleted = []; click(b.children[0].children[0]); console.log("  heading cell:", deleted.length ? deleted : "no action");
```

```
direct binding, listeners: 3
  T-01 button: [ 'T-01' ]
  T-04 button: no action
delegation, listeners     : 1
  T-01 button: [ 'T-01' ]
  T-04 button: [ 'T-04' ]
  heading cell: no action
```

In direct binding, the listener count grows along with the row count, and a row added
later stays silent. In delegation, there is a single registration, and a row added later
works exactly like the first row. The source of the difference is timing: in direct
binding, the match is established **at registration time**; in delegation, **at event
time**.

The last line shows delegation's second requirement. The listener bound to the table sees
every click beneath the table — the heading cell, an empty cell, the gap between rows.
When no match is found, the listener returns without doing anything. This early exit is
the first line of every delegated listener.

#### Carrying Identity in the Markup

A delegated listener has to know which record the event belongs to. Keeping this
information as a sequence number in an array is fragile: when rows are sorted or
filtered, the sequence number shifts.

The solid way is carrying the identity on the element itself. The `data-` attributes
defined in the Data Attributes lesson of the Web Fundamentals and HTML course exist for
this job: the value stays in the markup, and even if the element moves in the tree, it
moves along with its value. In the example above, a row's measurement code is read this
way.

The same attributes can also carry the action type. If a row has delete, edit, and mark
buttons, instead of testing a separate selector for each, the action name is kept in a
shared attribute on the buttons; the listener does a single lookup and calls the action
from a mapping. This makes the listener's length independent of both the row count and
the action count.

#### Delegation's Limits

Delegation does not work for every event type and has two limits.

**Non-bubbling events cannot be delegated.** As defined in the previous lesson,
focus-gain and focus-loss events do not bubble; a bubble-mode listener bound to an
ancestor does not see them. These events' bubbling counterparts are defined as separate
types, and delegation is built with those. The second route is registering the listener
on the ancestor in **capture mode**: because the capture phase also runs for non-bubbling
events, the ancestor node does see the event. The choice between the two routes depends
on whether the event needs to be handled before or after it reaches the target.

**An intermediate listener that stops propagation silently breaks delegation.** A listener
delegated to the table is never called if a listener inside the row calls
`stopPropagation`. The previous lesson said stopping propagation is a last resort; in a
tree with delegation set up, this turns into a remote-acting interruption.

A third point is not a limit but a design decision: the higher up the tree the ancestor a
listener is bound to, the wider its scope. A listener bound to the entire document runs on
every click on the page. Delegation is set up on the elements of interest' **common
closest ancestor**; the table's events are delegated to the table, the form's events to
the form.

#### Summary

- Event delegation is binding a single listener to an ancestor of the elements of
  interest and resolving the target at event time; it makes the listener count
  independent of the element count.
- Delegation also covers elements that do not exist at registration time; because the
  match is established at event time rather than registration time, later-added nodes do
  not stay without a listener.
- The target resolves to the closest matching ancestor; code that tests only the target
  misses clicks coming from nested elements.
- The search stops at the current target; an unbounded search can return a node outside
  the listener's scope.
- Registration identity is carried on the element itself with `data-` attributes; mapping
  based on a sequence number breaks after sorting and filtering.
- Non-bubbling events cannot be delegated in bubble mode; capture mode or the bubbling
  counterpart type is used. An intermediate listener stopping propagation silently
  cancels delegation.

#### Next Step

Up to this lesson, listeners only **added**: an event arrived, code ran, a behavior was
attached to the page. But the browser has its own behaviors too, and these work without a
listener. Clicking a link starts navigation, a submit button submits the form, a checkbox
toggles its checked state, the space bar scrolls the page. When a listener wants to put
its own behavior in place of these, it has to cancel the work the browser was going to do.
The next lesson covers how this cancellation is requested, which events it is possible
for, and why it should not be confused with stopping propagation.

### Default Behavior and Cancellation

Source: https://academia.sh/en/courses/browser-platform/default-behavior-and-cancellation

The browser's built-in response to events and the request to cancel it; the cancelability condition, why cancellation is not the same as stopping propagation, the passive listener's constraint, and when cancellation is legitimate.

In previous lessons, listeners only added: an event arrived, code ran, a behavior was
attached to the page. But the browser has its own behaviors too, and they work with no
listener at all. Clicking a link starts navigation, a submit button submits the form, a
checkbox toggles its checked state, the space bar scrolls the page.

When a listener wants to put its own behavior in place of these, it has to cancel the
work the browser was going to do. This lesson establishes how cancellation is requested,
under what conditions it is accepted, and why it is not the same thing as stopping
propagation.

#### The Default Action

An event's **default action** is the browser's built-in response to that event. It is
written neither in the document nor in the style; it comes from the meaning of the
markup. Because a link element means "leads to another resource," navigation starts when
it is clicked. Because a submit button means "submits the form," the entry list is
prepared when it is clicked.

Two properties of the default action are critical.

The first is its **order**. The default action runs after the event's propagation
finishes. Any listener along the propagation path — as much the target's as the
ancestors' — can cancel the action. An ancestor node's listener in the bubble phase can
stop a navigation that started at the target.

The second is **chainability**. Some default actions produce a new event. Clicking a
label produces a click event on the form field it is bound to; that click has its own
default action too. A single user gesture can therefore produce more than one event and
more than one default action; which link of the chain the cancellation happens at
determines the result.

#### The Cancellation Request

A listener requests cancellation through the event object. The request is not always
accepted: the event has to be **cancelable**. Some events, like a scroll notification, are
produced after the browser has already done the work, and cannot be cancelled; the
request is silently ignored.

```js
// default.mjs — propagation, cancellation, and the order of the default action
const element = (name, ...children) => {
  const d = { name, children, parent: null, listeners: [] };
  for (const c of children) c.parent = d;
  return d;
};
const listen = (node, label, action, passive = false) =>
  node.listeners.push({ label, action, passive });

function dispatch(target, { type, cancelable, defaultAction }) {
  const chain = [];
  for (let d = target; d; d = d.parent) chain.push(d);

  const event = { type, cancelable, defaultPrevented: false, stopped: false };
  const log = [];
  event.preventDefault = function () {
    if (this.inPassiveListener) return log.push("  [ignored] passive listener");
    if (event.cancelable === false) return log.push("  [ignored] non-cancelable event");
    event.defaultPrevented = true;
  };

  for (const node of chain) {          // bubble phase: target to root
    if (event.stopped) break;
    for (const d of node.listeners) {
      event.inPassiveListener = d.passive;
      log.push(`  ${node.name}/${d.label} (prevented=${event.defaultPrevented})`);
      d.action?.(event);
    }
  }
  log.push(event.defaultPrevented ? `  default action: cancelled` : `  default action: ${defaultAction}`);
  return log;
}

function tree(setUpListeners) {
  const link = element("a");
  const nav = element("nav", link);
  element("main", nav);
  setUpListeners(link, nav);
  return link;
}
const CLICK = { type: "click", cancelable: true, defaultAction: "navigation" };

const scenarios = [
  ["unimpeded", CLICK, (link, nav) => { listen(link, "link"); listen(nav, "nav"); }],
  ["preventDefault", CLICK, (link, nav) => {
    listen(link, "link", (o) => o.preventDefault()); listen(nav, "nav");
  }],
  ["stopPropagation", CLICK, (link, nav) => {
    listen(link, "link", (o) => { o.stopped = true; }); listen(nav, "nav");
  }],
  ["non-cancelable event", { type: "scroll", cancelable: false, defaultAction: "scrolling" },
    (link) => { listen(link, "link", (o) => o.preventDefault()); }],
  ["passive listener", { type: "wheel", cancelable: true, defaultAction: "scrolling" },
    (link) => { listen(link, "link", (o) => o.preventDefault(), true); }],
];

for (const [name, eventDef, setUp] of scenarios) {
  console.log(name);
  for (const line of dispatch(tree(setUp), eventDef)) console.log(line);
}
```

```
unimpeded
  a/link (prevented=false)
  nav/nav (prevented=false)
  default action: navigation
preventDefault
  a/link (prevented=false)
  nav/nav (prevented=true)
  default action: cancelled
stopPropagation
  a/link (prevented=false)
  default action: navigation
non-cancelable event
  a/link (prevented=false)
  [ignored] non-cancelable event
  default action: scrolling
passive listener
  a/link (prevented=false)
  [ignored] passive listener
  default action: scrolling
```

In the second scenario, the listener at the target requests cancellation; the event
continues on its path, and the listener at the ancestor node reads `prevented=true`. In
the browser API, this flag's name is `defaultPrevented`. Propagation continuing is not a
defect, it is the design itself: later listeners on the path can see the decision and
adjust their own behavior to it.

#### Separating Cancellation from Stopping

The third scenario shows this lesson's most commonly confused point. Propagation was
stopped — the ancestor listener was never called — but navigation still happened.

The two operations are **independent of each other**:

- Stopping propagation prevents the event from reaching **other listeners**. It does not
  touch the browser's own behavior.
- Requesting cancellation prevents **the browser's behavior**. It does not prevent the
  event from continuing on its path.

Two mistakes follow from this. First, code that wants to stop navigation but stops
propagation instead does not stop the navigation. Second, code that only wants to do its
own work but stops propagation along with cancellation silently disables delegated
listeners elsewhere on the page. The two operations should be requested separately, and
in most cases only one of them is needed.

#### The Passive Listener

The last scenario shows a different constraint. In scroll-related events, the browser
faces a dilemma: it cannot start scrolling without knowing whether the listener will
cancel it or not. For this reason, it waits for the listener to finish; a long-running
listener holds up the page under the user's finger.

A **passive listener** removes this uncertainty. With an option given at registration
time, the listener announces "I will not request cancellation"; the browser starts
scrolling without waiting for the listener. If cancellation is requested contrary to the
announcement, the request is ignored.

A practical rule follows from this: listeners that observe scrolling but do not change it
are registered as passive. A listener that puts its own behavior in place of scrolling
cannot be passive and accepts the cost of being waited for.

#### The Fine Points of Cancellation

**In some events, cancellation means permission.** In a drag-and-drop operation, the
default action of the event produced while passing over an element is "refuse the drop";
the drop does not happen unless it is cancelled. Here, requesting cancellation does not
block the behavior, it opens it instead. This is why the assumption "cancellation always
blocks" is wrong; what an event's default action is needs to be known separately for each
one.

**Cancellation may not undo state.** When a checkbox is clicked, the checked-state change
happens before the event propagates; cancellation reverts the change. The current value
read inside the listener may therefore no longer be valid after cancellation.

**Something needs to be put in place of a cancelled action.** A page that cancels a
form's submission and sends the data itself in its own code behaves correctly. A page
that cancels submission and puts nothing in its place ends the user's task with no
visible reason. The same principle applies to links: code that cancels navigation also
takes away the user's ability to open it in a new tab with the middle button, copy the
address, or see where the link goes.

**Keyboard and accessibility behaviors are not cancelled.** A page that cancels the tab
key's focus-moving behavior becomes unusable by keyboard. Likewise, cancelling text
selection, the right-click menu, and zooming takes away the user's control. These
behaviors are cancelled only when an equivalent path is put in their place; the limits of
this are set in the Focus and Keyboard Interaction lesson.

#### Cancellation's Irreversibility

A flag being readable does not mean it is changeable. There is no operation that undoes a
cancellation. The first cancellation request on the path settles the decision; later
listeners only see it.

The design consequence of this is: the decision to cancel should be made by the listener
that **owns** the behavior. Code that will cancel the measurement form's submission is the
form's own listener; a general listener looking at the form from the outside makes the
decision without seeing the form's own logic.

The same certainty holds in the reverse direction too. If a listener requests
unconditional cancellation at an early point in propagation, it is not possible for later
listeners to bring the behavior back; the only remedy is moving the condition inside that
listener. As long as the condition stays outside, cancellation turns into a switch that
silently and permanently shuts the behavior off.

Leaving the page is the most visible example of this rule. A page with unsaved changes
can cancel the event produced before leaving and have the browser ask for confirmation.
The page does not determine the confirmation's text or form; the browser does, and it
usually requires the user to have interacted with the page beforehand. The rule is the
same: a cancellation request is a suggestion, and it finds a response only within the
browser's acceptance conditions.

#### Summary

- The default action is the browser's built-in response to an event, coming from the
  meaning of the markup; it runs after propagation finishes.
- Every listener on the propagation path can request cancellation; the request is
  accepted only when the event is cancelable, otherwise it is silently ignored.
- Cancellation and stopping propagation are independent operations: one blocks the
  browser's behavior, the other blocks the event from reaching other listeners.
- The event continues on its path even after being cancelled; later listeners can read
  the decision through a flag but cannot undo it.
- A passive listener announces ahead of time that it will not request cancellation; the
  browser starts scrolling without waiting, and a contrary request is ignored.
- In some events, cancellation means permission, not blocking; every cancelled behavior
  needs an equivalent path put in its place.

#### Next Step

This lesson set the keyboard apart among behaviors that should not be cancelled and left
the reason for later. The reason is this: not all interaction with the page is built with
a pointing device. The user moves between elements with the tab key, presses a button with
space, moves between options with the arrow keys; a screen reader follows the same path.
At the center of this movement is a single concept: at any moment, at most one element in
the document receives input. The next lesson covers how this element is determined, what
the order is built from, and what information keyboard events carry.

### Focus and Keyboard Interaction

Source: https://academia.sh/en/courses/browser-platform/focus-and-keyboard-interaction

Determining the single element that receives input; focusability conditions, computing tab order, the bubbling behavior of focus events, the information key events carry, and a navigation layout leaving a single stop within a group.

The previous lesson said keyboard behaviors are among the behaviors that should not be
cancelled. The reason is this: not all interaction with the page is built with a pointing
device. The user moves between elements with the tab key, presses a button with space,
moves between options with the arrow keys; a screen reader follows the same path.

At the center of this movement is a single concept: at any moment, at most one element in
the document receives input. That element is called the **active element**, and it is the
target of events coming from the keyboard. This lesson establishes how the active element
is determined and what information keyboard events carry.

#### Focusability

**Focus** is the authority to receive input. Not every element can take focus. Whether an
element can take focus depends on three conditions.

The first is **natural focusability**. Links, buttons, form fields, and controls take
focus; this requires no additional declaration. A section carrying text or a list only
gets focus when it is explicitly requested.

The second is **visibility**. An element hidden from the document cannot take focus; code
that tries to focus a hidden element silently fails. This is the source of focus getting
lost the moment an open panel closes.

The third is **being enabled**. A disabled control does not take focus; a keyboard user
never sees it at all. A design consequence follows from this: disabling a button can also
make its associated error message unreachable. The reason is not being able to stand on
the element.

An element that takes focus is required to have a visible marker. WCAG criterion 2.4.7
requires a focused element to have a visible indicator. Code that removes the focus
indicator from the style produces a situation similar to working with a cursor no one can
see.

#### Tab Order

The tab key moves focus according to document order. This order can be changed with the
`tabindex` attribute, and the sign of the value produces three different behaviors.

```js
// tab.mjs — deriving tab order from document order and tabindex value
const elements = [
  { name: "a.skip-link",       tabindex: null, naturallyFocusable: true,  hidden: false, disabled: false },
  { name: "input#filter",      tabindex: null, naturallyFocusable: true,  hidden: false, disabled: false },
  { name: "button#applyFilter",tabindex: null, naturallyFocusable: true,  hidden: false, disabled: false },
  { name: "button#export",     tabindex: 1,    naturallyFocusable: true,  hidden: false, disabled: false },
  { name: "div#summary",       tabindex: -1,   naturallyFocusable: false, hidden: false, disabled: false },
  { name: "span.badge",        tabindex: null, naturallyFocusable: false, hidden: false, disabled: false },
  { name: "button.delete[T-01]", tabindex: null, naturallyFocusable: true, hidden: false, disabled: false },
  { name: "button.delete[T-02]", tabindex: null, naturallyFocusable: true, hidden: false, disabled: true  },
  { name: "button#submit",     tabindex: null, naturallyFocusable: true,  hidden: true,  disabled: false },
  { name: "div.row-container", tabindex: 0,    naturallyFocusable: false, hidden: false, disabled: false },
];

const focusable = (o) =>
  !o.hidden && !o.disabled && (o.naturallyFocusable || o.tabindex !== null);
const inTabOrder = (o) => focusable(o) && (o.tabindex === null ? true : o.tabindex >= 0);

const tabOrder = (list) => {
  const documentOrder = list.map((o, i) => ({ ...o, i })).filter(inTabOrder);
  const positive = documentOrder.filter((o) => (o.tabindex ?? 0) > 0)
    .sort((a, b) => a.tabindex - b.tabindex || a.i - b.i);
  const zero = documentOrder.filter((o) => (o.tabindex ?? 0) === 0);
  return [...positive, ...zero];
};

console.log("tab order:");
for (const [n, o] of tabOrder(elements).entries())
  console.log(`  ${n + 1}. ${o.name.padEnd(20)} tabindex=${o.tabindex ?? "none"}`);

console.log("not in order:");
for (const o of elements.filter((o) => !inTabOrder(o))) {
  const reason = o.hidden ? "hidden" : o.disabled ? "disabled"
    : o.tabindex === -1 ? "tabindex=-1 (only focusable via script)" : "not focusable";
  console.log(`  ${o.name.padEnd(20)} ${reason}`);
}
```

```
tab order:
  1. button#export        tabindex=1
  2. a.skip-link          tabindex=none
  3. input#filter         tabindex=none
  4. button#applyFilter   tabindex=none
  5. button.delete[T-01]  tabindex=none
  6. div.row-container    tabindex=0
not in order:
  div#summary          tabindex=-1 (only focusable via script)
  span.badge           not focusable
  button.delete[T-02]  disabled
  button#submit        hidden
```

Three rules are visible in the output.

**A value of zero places the element in the order without changing its position.** A
container that does not naturally take focus becomes a tab stop this way and keeps its
place in document order.

**A negative value takes the element out of the order but leaves it focusable.** The tab
key does not pass over it; a program can still move focus there. This is what makes it
possible to give focus to an error summary or an open panel.

**Positive values break the order.** The output's first line shows this: the export button
is fourth in the document, first in tab order. The page's skip link drops to second place.
Positive values come before every other stop in the document; every component added to
the page later has to rethink this ordering. The meaningful order WCAG criterion 2.4.3
asks for is achieved by getting the markup's own order right — not with positive values.

#### Focus Events and Programmatic Focus

Focus change produces four events, defined as two pairs. As noted in the Event Model
lesson, focus-gain and focus-loss events **do not bubble**; a bubbling pair carrying the
same information is defined separately. Code that wants to watch focus change across
every field of a form with a single listener has to either use the bubbling pair or
register in capture mode; the rule set in the Event Delegation lesson applies here.

Focus events report both the element **giving up** and the element **receiving** focus.
When a field is left, where focus went is read from this information, and two cases are
distinguished: focus has moved to another field within the form, or the form has been left
entirely. Running field validation only in the second case spares the user from being met
with an error message at every field transition.

Two responsibilities arise when a program moves focus. First, the element focus moves to
needs to be visible; the browser scrolls the focused element into the visible area, and
this scroll can be perceived as a page jump. Second, **focus must not be lost**: when a
focused element is removed from the tree, focus drops to the document's root and the tab
key starts its cycle over from the beginning. Code that deletes a row has to decide where
focus should go before deleting — to the neighboring row, or to the list itself.

A third case requires **containing** focus. An open dialog blocks tabbing through to the
page behind it; focus circles inside the dialog. WCAG criterion 2.1.2 places a bound while
this arrangement is being built: a user who entered by keyboard must be able to leave by
keyboard. A focus trap with no way out is an accessibility bug.

#### Key Events

A key press produces two events: when the key is pressed and when it is released. A key
held down produces new press events at the operating system's repeat interval; the repeat
flag in the event object tells these events apart from the first press. A keyboard
shortcut that increments a counter reads this flag depending on whether it wants to count
the repeats.

The event object reports the key with two separate fields, and the distinction between
them is critical.

**The meaning field** gives the value the key produces: `a`, `Enter`, `ArrowDown`,
`Escape`. It changes according to the user's keyboard layout and held modifiers; uppercase
and lowercase are different values.

**The position field** gives the key's physical place on the keyboard. It stays the same
even if the layout changes.

The selection rule is: if a shortcut relies on a letter's meaning, the meaning field is
read; if it relies on the key's place under the hand, the position field is read. Code
relying on the meaning field lands on different keys on different layouts; code relying on
the position field may not match the letter the user sees.

Deriving text input from key events is a separate mistake. In the **composition**
mechanism used for Chinese, Japanese, and Korean input, the user presses more than one key
to produce a single character; the field's content does not match the keys during the
intermediate stage. Likewise, pasting, voice input, and autofill produce no key event. The
only reliable way to learn a field's content is listening to input events; this is the
subject of the Form Events lesson.

#### Group-Internal Navigation

If every row of the measurement table has a button, the tab key walks through every row
one by one; in a hundred-row table, leaving the table takes a hundred key presses. The
established solution is making the group a single tab stop and navigating within the
group with the arrow keys. This layout is called **roving tabindex**: at any moment, a
single element in the group has a `tabindex` value of zero, the rest are negative one; the
arrow key carries both the focus and the zero value.

```js
// roving.mjs — focus management that leaves a single tab stop within a group
const rows = ["T-01", "T-02", "T-03", "T-04"];
let active = 0;          // the group's single tab stop
let focus = "outside group"; // the element currently holding focus

const stops = () => rows.map((name, i) => `${name}:${i === active ? 0 : -1}`).join(" ");

function key(name) {
  const result = { key: name, prevented: false };
  if (focus === "outside group") {
    if (name === "Tab") focus = rows[active];   // entering the group: the browser's own behavior
    return result;
  }
  switch (name) {
    case "ArrowDown": active = (active + 1) % rows.length; break;
    case "ArrowUp":   active = (active - 1 + rows.length) % rows.length; break;
    case "Home":      active = 0; break;
    case "End":       active = rows.length - 1; break;
    case "Tab":       focus = "outside group"; return result;   // default behavior is kept
    default:          return result;                             // no work done, no cancellation requested
  }
  focus = rows[active];
  result.prevented = true;   // the arrow keys' scrolling behavior is cancelled
  return result;
}

console.log("start:", focus.padEnd(14), "|", stops());
for (const name of ["Tab", "ArrowDown", "ArrowDown", "End", "Home", "ArrowUp", "x", "Tab"]) {
  const result = key(name);
  console.log(
    result.key.padEnd(9),
    "focus:", focus.padEnd(14),
    "| prevented:", String(result.prevented).padEnd(5),
    "|", stops(),
  );
}
```

```
start: outside group  | T-01:0 T-02:-1 T-03:-1 T-04:-1
Tab       focus: T-01           | prevented: false | T-01:0 T-02:-1 T-03:-1 T-04:-1
ArrowDown focus: T-02           | prevented: true  | T-01:-1 T-02:0 T-03:-1 T-04:-1
ArrowDown focus: T-03           | prevented: true  | T-01:-1 T-02:-1 T-03:0 T-04:-1
End       focus: T-04           | prevented: true  | T-01:-1 T-02:-1 T-03:-1 T-04:0
Home      focus: T-01           | prevented: true  | T-01:0 T-02:-1 T-03:-1 T-04:-1
ArrowUp   focus: T-04           | prevented: true  | T-01:-1 T-02:-1 T-03:-1 T-04:0
x         focus: T-04           | prevented: false | T-01:-1 T-02:-1 T-03:-1 T-04:0
Tab       focus: outside group  | prevented: false | T-01:-1 T-02:-1 T-03:-1 T-04:0
```

Four behaviors read from the output. Entering the group is the browser's own tab
behavior, and it is not cancelled. The arrow keys carry focus, and cancellation is
requested only for these keys; the arrow keys' page-scrolling behavior has been replaced
with group-internal navigation. The `x` line before the last shows that cancellation is
not requested for keys that are not handled — typing, shortcuts, and browser behaviors are
unaffected. The last tab key leaves the group, and the group's stop stays on the
most-recently-used row; the user picks up where they left off when they return.

#### Summary

- At any moment, at most one element in the document receives input; focusability depends
  on natural focus-taking, visibility, and being enabled.
- A `tabindex` of zero places an element in order at its document position, a negative
  value takes it out of order but leaves it open to programmatic focus, positive values
  cut ahead of the entire order and break the layout.
- Focus events' non-bubbling pair is only seen at the target; delegation uses the
  bubbling pair or capture mode. Events report both the element giving up and the one
  receiving focus.
- If a focused element is removed from the tree, focus starts over from the root; delete
  and close operations need to decide ahead of time where focus should go.
- Key events report meaning and physical position in separate fields; text input cannot
  be derived from key events.
- In the roving tabindex layout, there is a single stop in the group, the arrow keys carry
  both focus and the stop together, and the default behavior is kept for unhandled keys.

#### Next Step

This lesson said the only reliable way to learn text input is not key events, and left the
real way for later. The form on the page does not only collect text: a measurement value
is a number, a date comes from a calendar control, a station code is chosen from a list.
Every control reports its value at different moments — some at every keystroke, some when
the field is left. On top of this there is submission: the browser collects the fields,
tests the constraints, and builds the request. The next lesson covers this flow, which
event reports which moment, and where validation belongs.

### Form Events

Source: https://academia.sh/en/courses/browser-platform/form-events

The flow from value change to submission; the distinction between input and change events, building the entry list, the submitter's contribution, the states of constraint validation, and accessible reporting of an error.

The previous lesson said the reliable way to learn a field's content is not key events.
The form on the page does not only collect text: a measurement value is a number, a date
comes from a calendar control, a station code is chosen from a list. Every control reports
its value at different moments.

On top of this there is submission. The browser collects the fields, tests the
constraints, and builds the request. This lesson covers the flow in two parts: what is
reported when a value changes, and what happens when submission is requested.

#### Events That Report Change

Three event types report a value's change, and the three mark different moments.

The **before-input event** is produced before the change is applied to the field. It
reports what is about to happen and is cancelable; rejecting a specific input outright is
possible with this event. Which operation the user performed — typing, pasting, undoing —
is also read from this event.

The **input event** is produced after the change has been applied. It runs on every
character typed, every paste, every undo. Code that wants to continuously track the value
listens to this. The composition mechanism mentioned in the previous lesson, voice input,
and autofill also produce this event; the cases key events miss are seen here.

The **change event** reports the moment the value **settles**. On a text field, this is when the field
loses focus — which also happens on submission, because clicking the submit button blurs the field first;
on a checkbox, a radio button, and a dropdown list, it is the moment a selection is made. So on text
fields the change event is much rarer than the input event, while on selection controls it is produced
together with it.

The selection rule depends on the nature of the work. If the page's filter field narrows
the result as it is typed, the input event is needed. If the field's value is only going
to be written to a record, the change event is enough and does less.

Code listening to the input event carries an added responsibility: a handler that runs on
every keystroke should not produce a request on every keystroke. The established solution
is **debouncing**: when the event arrives, the work is not done immediately, it is
deferred to shortly after, and if a new event arrives within that time, the deferral
restarts. A single run remains once the user stops typing. Measuring and cancelling this
duration is the subject of the Timing and the Rendering Loop lesson.

#### Submission and the Entry List

Submission is the browser gathering the form's controls and building a request body. The
**entry list** defined in the Form Structure lesson of the Web Fundamentals and HTML
course is the result of this gathering. Which control enters the list is governed by
rules.

```js
// entrylist.mjs — which controls enter the entry list on submission
const controls = [
  { name: "station",  type: "text",     value: "North Slope" },
  { name: "measurement", type: "select", value: "temperature" },
  { name: "value",    type: "number",   value: "-4.2" },
  { name: "unit",     type: "radio",    value: "C",     checked: true },
  { name: "unit",     type: "radio",    value: "F",     checked: false },
  { name: "verified", type: "checkbox", value: "yes",   checked: false },
  { name: "note",     type: "textarea", value: "" },
  { name: "",         type: "text",     value: "unnamed field" },
  { name: "hiddenCode", type: "text",   value: "K-9", disabled: true },
  { name: "action",   type: "submit",   value: "save" },
  { name: "action",   type: "submit",   value: "delete" },
];

// The button that starts submission: only it enters the list, the others do not.
const submitter = controls.find((d) => d.type === "submit" && d.value === "save");

function entryList(controls, submitter) {
  const entries = [];
  for (const d of controls) {
    if (d.name === "" || d.disabled) continue;
    if ((d.type === "radio" || d.type === "checkbox") && d.checked !== true) continue;
    if (d.type === "submit" && d !== submitter) continue;
    entries.push([d.name, d.value]);
  }
  return entries;
}

const entries = entryList(controls, submitter);
for (const [name, value] of entries) console.log(`  ${name.padEnd(11)} = ${JSON.stringify(value)}`);
console.log("body:", new URLSearchParams(entries).toString());
```

```
  station     = "North Slope"
  measurement = "temperature"
  value       = "-4.2"
  unit        = "C"
  note        = ""
  action      = "save"
body: station=North+Slope&measurement=temperature&value=-4.2&unit=C&note=&action=save
```

Four rules read from the output. **A control with no name does not enter the list**; the
name is the field's identity as seen by the server. **A disabled control does not enter
the list**; this is the data-side counterpart of the previous lesson's "disabling makes
invisible" rule. **An unchecked checkbox and an unselected radio button do not enter the
list**; the receiving side has to interpret this field's absence as "no." **An empty text
field does enter the list**, because an empty value is a value too.

The submitter button makes a separate contribution. If a form has more than one submit
button, only the clicked button's name and value enter the list; the receiving side learns
which action was requested from this. The submit event reports the submitter through the
event object.

The submit event is cancelable. The rule from the Default Behavior and Cancellation lesson
applies here: code that cancels submission has to send the data itself. There is a
program-side counterpart of the entry list too, and it can be handed to the request as it
is; this way the server side sees the same body on both paths.

#### Constraint Validation

Before the submit event is produced, the browser runs **constraint validation**.
Constraints are declared in the markup; their results are read as a set of states.

```js
// validity.mjs — deriving validity state from constraint attributes
const CONSTRAINT = { required: true, min: -50, max: 60, step: 0.1, base: 0 };

// The step check is scaled to an integer to avoid binary-fraction error.
const scale = 1000;
const stepMismatch = (n, k) =>
  Math.round((n - k.base) * scale) % Math.round(k.step * scale) !== 0;

function validity(raw, k = CONSTRAINT, disabled = false) {
  const state = {
    valueMissing: false, typeMismatch: false, rangeUnderflow: false,
    rangeOverflow: false, stepMismatch: false,
  };
  if (disabled) return { willValidate: false, state, valid: true };

  if (k.required && raw === "") state.valueMissing = true;
  else if (raw !== "") {
    const n = Number(raw);
    if (Number.isNaN(n)) state.typeMismatch = true;
    else {
      if (n < k.min) state.rangeUnderflow = true;
      if (n > k.max) state.rangeOverflow = true;
      if (stepMismatch(n, k)) state.stepMismatch = true;
    }
  }
  const valid = Object.values(state).every((v) => v === false);
  return { willValidate: true, state, valid };
}

const samples = [
  ["", false], ["-4.2", false], ["-80", false], ["61.5", false],
  ["12.34", false], ["too cold", false], ["999", true],
];

for (const [raw, disabled] of samples) {
  const { willValidate, state, valid } = validity(raw, CONSTRAINT, disabled);
  const broken = Object.entries(state).filter(([, v]) => v).map(([name]) => name);
  console.log(
    JSON.stringify(raw).padEnd(12),
    "willValidate:", String(willValidate).padEnd(5),
    "valid:", String(valid).padEnd(5),
    broken.join(", "),
  );
}
```

```
""           willValidate: true  valid: false valueMissing
"-4.2"       willValidate: true  valid: true  
"-80"        willValidate: true  valid: false rangeUnderflow
"61.5"       willValidate: true  valid: false rangeOverflow
"12.34"      willValidate: true  valid: false stepMismatch
"too cold"   willValidate: true  valid: false typeMismatch
"999"        willValidate: false valid: true  
```

The model shows three things. First, validity is made up not of a single true-or-false but
of **separately readable states**; the error message can be written according to which
constraint was broken. Second, the step constraint cannot be tested by direct division on
decimal values; the binary-fraction problem from the How Computers Work course shows up
here, and the check is done by scaling to an integer. Third, a disabled control is
**never validated at all**; the out-of-range value on the last line counts as valid,
because that control does not take part in validation.

The browser's own testing can also correct the field's raw value. When a non-numeric
string is typed into a field expecting a number, the value can be cleared; in that case
the reported state is not "type mismatch" but "value missing." For this reason, the error
message is chosen by looking at the reported state, not at the value the user sees.

Three operations are defined on the program side. One **tests** validity and returns the
result; the second tests and, if invalid, **shows** the browser's own message; the third
**writes** a field-specific error message. The third exists to carry application rules: a
rule that cannot be declared in markup — such as a measurement date not being in the
future — is tied to the built-in mechanism this way. Writing an empty message makes the
field valid again.

There is also a declaration that turns off validation for the whole form. This declaration
removes the browser's own messages; constraints are still read. Pages that build their own
error presentation follow this route. Under no circumstance does client-side validation
stand in for server-side validation: a request can be built without the page ever being
used at all.

#### Reporting an Error

An event is produced for every invalid field, and this event does not bubble; code that
wants to collect a form's errors in one place uses capture mode. The rule from the Event
Delegation lesson comes up a second time here.

An error's presentation is made of three parts. The **relevant message** stands next to
the field and is attached to it with an extra description bond; the field is marked with
an invalidity notice. An **error summary** sits at the top of the form, and every item
links to its field. After a submission attempt, **focus moves to the first invalid field**
or to the summary itself; the programmatic focus rule from the Focus and Keyboard
Interaction lesson applies here.

The moment error messages are written is also a design decision. Showing an error on
every keystroke while the user is filling in the field declares an input that is not yet
finished to be wrong. The common layout is this: the first message is written when the
field is left or submission is attempted; once a field has been found invalid once, it is
updated on the input event, so the message disappears the moment it is corrected.

Resetting the form produces a separate event and is cancelable. Reset returns fields to
their starting values declared in the markup — the direct consequence of the
attribute-property distinction from the DOM API lesson. Code that does not clear its own
held state on this event ends up with a form empty on screen but full in memory.

#### Summary

- The before-input event is produced before the change is applied and is cancelable; the
  input event is produced on every change, the change event at the moment the value
  settles.
- Work bound to the input event is thinned out with debouncing; not every keystroke
  produces a request.
- The entry list includes controls that have a name, are not disabled, and are checked in
  the case of selection controls; among several submit buttons, only the clicked one
  contributes.
- Constraint validation produces separately readable states rather than a single
  true-or-false; disabled controls do not take part in validation at all.
- A custom error message writer ties application rules that cannot be declared in markup
  to the built-in validation; client-side validation does not stand in for server-side
  validation.
- The invalidity event does not bubble; error presentation is built from a message, a
  summary, and moving focus.

#### Next Step

This lesson assumed a form ends with a submission. But most forms are left half-finished:
the user enters a measurement value, closes the tab, comes back the next day. Everything
held in memory is erased when the page reloads — the criterion typed into the filter
field, and the half-finished form alike. Storing these requires writing to a place that
outlives the page. The next lesson covers these places — the one bound to the tab's
lifetime, the one that persists for the resource's lifetime, and the one that holds
structured data — along with their distinctions.

## Browser APIs

Storage layers, history and address management, observers, frame-aligned updates, formatting, and worker threads that offload the main thread.

### Storage APIs

Source: https://academia.sh/en/courses/browser-platform/storage-apis

Layers that hold data outliving the page; the distinction between session and origin lifetime, the string store's type and serialization losses, quota and eviction, cross-tab notification, schema migration, and data that should not get stored.

The previous lesson assumed a form ends with a submission. Most forms
stay unfinished: the user enters a measurement value, closes the tab,
comes back the next day. Once the page reloads, everything in memory
has been erased — both the criterion written into the filter field
and the half-finished form.

Saving these requires writing to a place in the browser that outlives
the page. There are several such places, and the difference between
them is not size but **lifetime, access style, and visibility to the
server**.

#### Layers

**Session storage** is limited to the tab's lifetime. When the same
address gets opened in two tabs, two separate stores get seen; the
content gets erased once the tab closes. A half-finished form draft,
a multi-step flow's intermediate state get written here.

**Local storage** persists for the origin's lifetime. Whether the tab
closes or the browser closes, the content stays; every tab of the
same origin shares the same store. User preferences, the filter
criterion, the last-viewed measurement get written here.

**Structured storage (IndexedDB)** holds more than a key-value pair:
indexed records, transactions, range queries. Its access is
asynchronous. A measurement log meant to work offline stays in this
layer.

**A cookie** serves a different purpose than the others: as defined
in the How the Internet Works curriculum, it goes to the server with
every request. This is the wanted behavior for a session identifier;
it is an unwanted cost for interface state. Data meant to stay
client-only does not get kept in a cookie.

All four share the same boundary: **origin**. If the triple formed by
scheme, host, and port is different, the store is different too; two
different origins hosted on the same server cannot see each other's
data. A cookie's boundary can also get narrowed with domain and path
components.

#### The String Store's Behavior

The first two layers hold only strings. This single sentence produces
three separate consequences.

```js
// store.mjs — three behaviors of a string-based key-value store
class Store {
  constructor(quota) { this.quota = quota; this.records = new Map(); }
  get usage() {
    let t = 0;
    for (const [k, v] of this.records) t += k.length + v.length;
    return t;
  }
  write(key, value) {
    const text = String(value);              // every value gets converted to a string
    const previous = this.records.get(key)?.length ?? 0;
    if (this.usage - previous + key.length + text.length > this.quota)
      throw new Error("QuotaExceeded");
    this.records.set(key, text);
  }
  read(key) { return this.records.get(key) ?? null; }
}

const store = new Store(100);  // quota is measured in code units

// 1. Type loss: the written number is the read string.
store.write("threshold", 12.5);
console.log("read:", JSON.stringify(store.read("threshold")), "type:", typeof store.read("threshold"));
console.log("missing key:", store.read("missing"));

// 2. Serialization loss: JSON is not a type-preserving format.
const measurement = { code: "T-01", at: new Date("2024-02-11T06:00:00Z"), note: undefined,
                tags: new Set(["night"]) };
store.write("measurement", JSON.stringify(measurement));
const restored = JSON.parse(store.read("measurement"));
console.log("written:", { at: measurement.at.constructor.name, note: measurement.note, tags: measurement.tags });
console.log("read   :", restored);

// 3. Quota: the write throws, it does not fail silently.
try {
  store.write("log", "x".repeat(200));
} catch (error) {
  console.log("error:", error.message, "| usage:", store.usage, "/", store.quota);
}
console.log("'threshold' after quota exceeded:", JSON.stringify(store.read("threshold")));
```

```
read: "12.5" type: string
missing key: null
written: { at: 'Date', note: undefined, tags: Set(1) { 'night' } }
read   : { code: 'T-01', at: '2024-02-11T06:00:00.000Z', tags: {} }
error: QuotaExceeded | usage: 81 / 100
'threshold' after quota exceeded: "12.5"
```

**Type loss.** The number written comes back as a string when read.
Code that compares without converting silently misbehaves. A key
that does not exist returns null, not an empty string; the two need
distinguishing, because the user may have deliberately left a field
empty.

**Serialization loss.** JSON is not a type-preserving format. A date
object turns into a string, an undefined field drops entirely, a set
turns into an empty object. The reading side cannot settle for just
calling `JSON.parse`; a layer that converts the record back to the
expected types is needed. This is also why the browser uses a
separate copying mechanism instead of JSON when moving data between
threads; that mechanism gets taken up in the Web Workers lesson.

**Quota.** The store is not unlimited, and the write **throws an
error** once the limit gets exceeded. The quota's size is
implementation-dependent; code has to adapt by catching the error,
not by assuming a number for it. The output's last line shows a
second rule: a failed write does not corrupt the previous record. The
write path therefore always stays inside a catch block and produces a
behavior visible to the user on failure.

Data can get lost without exceeding the quota, too. The browser can
**evict** an origin's storage when space gets tight or when data has
gone unused for a long time. The user can also clear the storage.
Storage therefore gets treated like a cache: a page that also works
without its content is correctly designed.

#### Cross-Tab Notification

Because local storage gets shared by every tab of the same origin, a
write in one tab concerns the others. The browser produces an event
for this; the event carries the old value, the new value, and the
key. This way, a filter criterion changed in one tab can also get
applied in another.

This event has two surprising properties. First, it **does not get
produced in the writing tab**; only the other tabs get the
notification. Updating its own interface is the writing tab's own
responsibility. Second, this notification is meaningless for session
storage, because the store is already tab-specific.

#### The Cost of Synchronous Access

The first two layers' access is **synchronous**: the read and the
write complete the moment they get called, and the main thread cannot
do other work during that time. This is invisible for small values.
Code that writes a large record on every keystroke, though, makes the
user interface wait; the queue delay mentioned in the Event Model
lesson applies here too.

Three rules limit this cost. Writes get throttled — the delayed
triggering from the Form Events lesson applies here too. Large data
gets moved to structured storage; its asynchronous interface does not
make the main thread wait. Reading gets done once, at page open, and
kept in memory; it does not get re-read on every access.

#### Schema Change

Stored data outlives the code that wrote it. A record written in a
six-month-old format can still sit in the user's browser. This is why
every record carries a **version** field, and the read path contains
a chain that carries an old version to the current format.

```js
// migrate.mjs — versioning a stored record and the forward-migration chain
const CURRENT = 3;
const MIGRATIONS = {
  1: (record) => ({ ...record, unit: "C" }),                                   // 1 -> 2: unit added
  2: ({ value, ...record }) => ({ ...record, temperature: Number(value) }),    // 2 -> 3: field name and type
};

function read(raw) {
  let record;
  try { record = JSON.parse(raw); }
  catch { return { result: "unparseable", record: null }; }

  const version = record.version ?? 1;
  if (version > CURRENT) return { result: "future version, ignored", record: null };

  let v = version;
  while (v < CURRENT) { record = { ...MIGRATIONS[v](record), version: v + 1 }; v += 1; }
  return { result: `version ${version} -> ${CURRENT}`, record };
}

const samples = [
  '{"code":"T-01","value":"-4.2"}',                                  // no version field: counted as 1
  '{"version":2,"code":"T-02","value":"3.5","unit":"C"}',
  '{"version":3,"code":"T-03","temperature":11.8,"unit":"C"}',
  '{"version":9,"code":"T-04"}',
  '{code:"T-05"}',
];

for (const raw of samples) {
  const { result, record } = read(raw);
  console.log(result.padEnd(26), JSON.stringify(record));
}
```

```
version 1 -> 3             {"code":"T-01","unit":"C","version":3,"temperature":-4.2}
version 2 -> 3             {"version":3,"code":"T-02","unit":"C","temperature":3.5}
version 3 -> 3             {"version":3,"code":"T-03","temperature":11.8,"unit":"C"}
future version, ignored    null
unparseable                null
```

The chain handles four cases at once. The oldest record, with no
version field, counts as version one and gets carried to the current
format in two steps. An intermediate version skips the remaining
step. A current record passes through untouched.

The last two rows are the defensive side. A record **from the
future** — one the user synced from another device, or one written
with a newer version — cannot get carried backward; it does not get
read and gets ignored. **Unparseable** content gets handled the same
way. Everything read from storage is external input: it can be
corrupted, hand-edited, or never written at all. Parsing always
happens inside a catch block.

#### What Does Not Get Stored

Storage is a space that sits on the user's device and under the
user's control. Three rules follow from this.

Every piece of code running on the page can read the same store; this
is why **identity tokens, passwords, and personal data** do not get
written there. A script injection accesses everything in the store.

The store is **not the source of the record**. It is secondary to the
data on the server; in a conflict, which one wins gets decided
explicitly.

The user can clear the store at any moment, and the browser can
evict it. This is why stored data has to be **reproducible**; its
loss should not permanently break a function.

#### Summary

- Session storage is limited to the tab's lifetime, local storage to
  the origin's lifetime; structured storage holds indexed records
  with an asynchronous interface, and a cookie goes to the server
  with every request.
- String stores convert the written value to a string, and JSON does
  not preserve types; the reading side needs a layer that converts
  the record back to the expected types.
- Quota overflow throws an error and does not corrupt the previous
  record; the quota's size does not get assumed, the error gets
  caught.
- A change in local storage gets notified to other tabs with an
  event; the writing tab does not see this event and updates its own
  interface itself.
- Synchronous access makes the main thread wait; writes get
  throttled, large data gets moved to structured storage, reading
  gets done once and kept in memory.
- Records carry a version field and go through a migration chain on
  the read path; content with a future version or unparseable content
  gets ignored.

#### Next Step

This lesson saved state but did not touch state's relationship to the
**address**. When the filter criterion gets kept in storage, it comes
back once the page reopens; but that state has no address. The user
cannot share the filtered list, cannot bookmark it, cannot return to
the previous filter with the back button. These are problems of
navigation, not storage, and get solved through the browser's address
bar and history stack. The next lesson examines the address's parts,
how the history stack gets changed, and at which point client-side
navigation has to come to terms with the server.

### History and Location

Source: https://academia.sh/en/courses/browser-platform/history-and-location

Writing page state into the address and managing the history stack programmatically; the address's parts, which state belongs to the path, the query, or the fragment, the distinction between pushing and replacing, the navigation event, and the server side's share.

The previous lesson saved state but did not touch state's
relationship to the address. When the filter criterion gets kept in
storage, it comes back once the page reopens; but that state has no
address. The user cannot share the filtered list, cannot bookmark it,
cannot return to the previous filter with the back button.

These are problems of **navigation**, not storage. The browser's
address bar and history stack can get changed without the page
reloading. This lesson establishes the address's parts, how the stack
gets managed, and what client-side navigation requires from the
server.

#### The Address's Parts

The Links lesson in the Web Fundamentals and HTML curriculum
introduced the address as a string. On the program side, the address
is an object split into its parts, and each part can get read and
written separately.

```js
// address.mjs — the address's parts and editing the query string
const address = new URL("https://example.test/station/north-slope/log?measurement=temperature&threshold=-5&page=3#summary");

for (const field of ["protocol", "host", "pathname", "search", "hash"])
  console.log(field.padEnd(9), JSON.stringify(address[field]));

// When the filter changes, only the relevant parameters get written, the rest are preserved.
const next = new URL(address);
next.searchParams.set("measurement", "relative humidity");
next.searchParams.delete("page");            // pagination resets when the filter changes
console.log("next      ", next.pathname + next.search + next.hash);

// More than one value can attach to the same name; reading is two separate operations.
next.searchParams.append("tag", "night");
next.searchParams.append("tag", "snow");
console.log("tag single", JSON.stringify(next.searchParams.get("tag")));
console.log("tag all   ", JSON.stringify(next.searchParams.getAll("tag")));

// A relative address gets resolved against the base address.
console.log("relative  ", new URL("../summary?measurement=temperature", address).href);
```

```
protocol  "https:"
host      "example.test"
pathname  "/station/north-slope/log"
search    "?measurement=temperature&threshold=-5&page=3"
hash      "#summary"
next       /station/north-slope/log?measurement=relative+humidity&threshold=-5#summary
tag single "night"
tag all    ["night","snow"]
relative   https://example.test/station/summary?measurement=temperature
```

Three gains of editing the query string with the provided interface,
rather than concatenating it by hand, show up in the output. Escaping
happens automatically: the value with a space is encoded. Irrelevant
parameters get preserved; the threshold value stayed in place while
the filter changed. Multiple values attached to the same name are
supported, and reading a single value and reading all values are
separate operations.

Deleting the pagination parameter is an example of a decision, not
code: the old page number loses its meaning once the filter changes.
Because the address carries state, dependencies of this kind between
states also get enforced on the address.

#### Which State Gets Written to the Address

Each part of the address corresponds to a different question.

**The path** is the resource's identity: which station's log is
being viewed. If the resource changes, the path changes.

**The query string** says which view of the same resource is wanted:
filter, sort, page number. The measurement list's filtered state gets
written here, and this is what makes it shareable.

**The fragment** shows a position within the document and never gets
sent to the server. In-page links and temporary view settings the
server does not need to know about fit here.

There is also state that **should not get written** to the address:
hovering over a button, an animation's progress, an open tooltip. The
test is this: does the user expect to see the same screen when they
open this address in a new tab? If the answer is no, the state does
not enter the address.

There is also a privacy criterion. The address gets written into
logs, can travel to external servers through referrers, and gets
shared by the user; personal data and tokens do not get put in the
address.

#### The History Stack

The browser's history is not a stack, but a sequence of entries moved
through forward and backward. The program affects this sequence with
three operations.

```js
// history.mjs — the history stack's three operations and which one produces an event
class History {
  constructor(initial) { this.entries = [initial]; this.index = 0; }
  get current() { return this.entries[this.index]; }
  push(entry) {                       // forward entries get discarded
    this.entries.length = this.index + 1;
    this.entries.push(entry);
    this.index += 1;
    return null;                      // no event gets produced
  }
  replace(entry) {
    this.entries[this.index] = entry;
    return null;                      // no event gets produced
  }
  go(delta) {
    const target = this.index + delta;
    if (target < 0 || target >= this.entries.length) return "out of bounds, no-op";
    this.index = target;
    return this.current.state;        // event gets produced, the state object gets carried
  }
}

const hist = new History({ name: "list", state: { measurement: "all" } });
const log = (op, event) =>
  console.log(
    op.padEnd(20),
    "index:", hist.index,
    "| stack:", hist.entries.map((x) => x.name).join(" "),
    "| event:", event === null ? "none" : JSON.stringify(event),
  );

log("start", null);
log("push(temperature)", hist.push({ name: "temperature", state: { measurement: "temperature" } }));
log("push(humidity)", hist.push({ name: "humidity", state: { measurement: "humidity" } }));
log("replace(humidity*)", hist.replace({ name: "humidity*", state: { measurement: "humidity", page: 2 } }));
log("go(-1)", hist.go(-1));
log("go(-1)", hist.go(-1));
log("go(-1)", hist.go(-1));
log("push(wind)", hist.push({ name: "wind", state: { measurement: "wind" } }));
log("go(+1)", hist.go(1));
```

```
start                index: 0 | stack: list | event: none
push(temperature)    index: 1 | stack: list temperature | event: none
push(humidity)       index: 2 | stack: list temperature humidity | event: none
replace(humidity*)   index: 2 | stack: list temperature humidity* | event: none
go(-1)               index: 1 | stack: list temperature humidity* | event: {"measurement":"temperature"}
go(-1)               index: 0 | stack: list temperature humidity* | event: {"measurement":"all"}
go(-1)               index: 0 | stack: list temperature humidity* | event: "out of bounds, no-op"
push(wind)           index: 1 | stack: list wind | event: none
go(+1)               index: 1 | stack: list wind | event: "out of bounds, no-op"
```

Four rules can get read from this output.

**A push opens a new entry and discards the forward entries.** In the
last rows, the push made after going back deleted the two entries
ahead; the forward button no longer works. This is the same in the
browser's own navigation.

**A replace does not increase the entry count.** A page that pushes
for every character typed into the filter field makes the user's
back button unusable: every press undoes one letter. States the user
would want to return to get pushed; intermediate states get replaced.

**The event only gets produced while traversing.** Pushing and
replacing do not produce an event; the code that does them updates
the interface itself. This is the same design as the local storage
event not getting produced in the writing tab, from the previous
lesson.

**Every entry has its own state object.** The event carries this
object; which view gets built when the page goes back gets read from
it. Because the state object gets serialized to get stored, the
Storage APIs lesson's limit applies here too: no function, node, or
live object, and size restricted by an implementation-dependent
limit. The sound approach puts only small information not derivable
from the address into the state object — a scroll position, an open
panel's ID.

One point of the model diverges from the browser: here, moving past
the boundary is a no-op. In the browser, history
belongs to the tab and also contains entries from before the page;
going past the first entry leaves the site. Code therefore cannot
assume "there is an entry to go back to."

#### The Server Side's Share

Client-side navigation changes the address without the page
reloading. This has one condition, and when it gets forgotten, the
error shows up only on a second visit: **the server has to give a
valid response when the user opens that address directly.**

If the filtered list's address is visible in the address bar, the
user can copy it into a new tab, bookmark it, or
arrive via search. In these requests, the browser requests
a new document. If the server does not recognize that path, the
response is not found. The status codes defined in the How the
Internet Works curriculum land directly on the user here.

There are two classes of solution. The server produces a real
document for every path; or it maps defined path patterns to a
single document and the client builds the view. The point to watch in
the second is that genuinely nonexistent addresses still get a
not-found response; a server that gives a successful response to
every address gives search engines and link checkers wrong
information.

Writing to the location object is a separate operation and **always
starts a real navigation**. This navigation has two forms: one pushes
an entry to history, one replaces the current entry; the second gets
used for intermediate addresses the user would get redirected from
again if they went back.

#### Two Responsibilities During Navigation

Navigation done without a page reload takes over two jobs the browser
otherwise does on its own.

The first is **scroll position**. In a real navigation, the browser
restores the returned-to page's scroll position. On a page where the
document stays put and only content changes, this can fire at the
wrong time: jumping to the old position before new content settles.
The browser therefore offers a manual scroll-restoration setting; the
page keeps the position in its own state object and applies it once
the content has settled.

The second is **requests that do not get canceled**. When data gets
requested for the new view, the user can move to another link without
waiting. If the old request's response arrives later, it
overwrites the view on screen with stale data. The cancellation
signal defined in the Cancellation and Timeout lesson in the
Asynchronous JavaScript and Runtime curriculum is the solution to
this race: every navigation opens its own signal, and the next
navigation cancels the previous one. An uncanceled request's
response, once it arrives, gets checked for which navigation it
belongs to and gets ignored.

#### Summary

- The address is an object split into parts; when the query string
  gets edited with the provided interface, escaping happens
  automatically and irrelevant parameters get preserved.
- The path carries the resource's identity, the query the same
  resource's view, the fragment a position within the document; state
  not expected to get rebuilt when shared does not get written to the
  address.
- Pushing to history discards the forward entries; replacing the
  current entry for intermediate states preserves the user's back
  button.
- The navigation event only gets produced when traversing history;
  code that pushes and replaces updates the interface itself.
- Every history entry has a serializable state object; its size is
  limited, and information derivable from the address does not get
  put there.
- Every address client-side navigation produces has to get a valid
  response from the server when requested directly, and nonexistent
  addresses still have to give a not-found response; restoring
  scroll position and canceling stale requests also become the
  page's responsibility.

#### Next Step

The address and history define the view the user **wants**. But how
much of that view shows on screen, how far its container has
widened, and when the tree changed cannot get read from this
information. Loading images only once they near the viewport in a
long measurement list, redrawing a chart once its container narrows,
logging newly added rows to a log — all three require continuous
measurement. Doing this by hand on every frame is both expensive and
forces layout by the measurement itself. The next lesson takes up the
family of observers that answer these three questions in a batched,
asynchronous way.

### Observers

Source: https://academia.sh/en/courses/browser-platform/observers

The observer family that reports visibility, size, and tree changes in a batched, asynchronous way; the intersection ratio's calculation, root margin and threshold, the content-versus-border-box distinction, batched delivery of records, and the observer's contribution to layout cost.

The address and history define the view the user wants. But how much
of that view shows on screen, how far a container has widened, and
when the tree changed cannot get read from this information. Loading
images only once they near the viewport in a long measurement list,
redrawing a chart once its container narrows, processing newly added
rows — all three demand continuous measurement.

Doing this by hand on every frame is wrong for two reasons.
Measurement is expensive, and the measurement itself forces the
browser to do a layout calculation. The browser answers these three
questions with a family of interfaces that respond at the moment it
has already done its own calculation.

#### Common Form

All three observers follow the same pattern, and once the pattern
gets learned, all three get used the same way.

An **observer** object gets constructed with a callback function. It
observes nothing when constructed; which targets get watched gets
declared separately. The same observer can watch multiple targets,
and separate options can get given for each target.

The callback gets called not with individual events, but with an
**array of records**. Every change accumulated in the time between
calls gets delivered in a single call.

Watching ends with two operations: stopping the observation of a single target or cutting off observation
of all targets. The intersection and mutation observers additionally allow manually taking records not yet
delivered; the resize observer has no such operation. A component has to disconnect its observer once it
gets removed from the tree; an observer that does not get disconnected holds a reference to its target,
and becomes the source of the leak defined in the Asynchronous JavaScript and Runtime curriculum.

The reason this pattern gets chosen over an event is that the event
model offers no frequency control. A scroll event gets produced on
every scroll step, and its listener has to measure every single time.
An observer, though, takes the measurement from the browser's own
calculation and reports only once the result changes.

#### Intersection Observer

**The intersection observer** reports how much a target intersects
with a root rectangle. The root is the viewport by default; a
scrollable container can also get given as the root.

Two settings determine the behavior. **Root margin** grows or shrinks
the root rectangle; growing it makes it possible to get a
notification before the target is even visible. **Threshold** says at
which intersection ratios the notification gets produced.

```js
// intersection.mjs — calculating the intersection ratio and threshold crossing
const rect = (top, bottom, left = 0, right = 360) => ({ top, bottom, left, right });
const area = (r) => Math.max(0, r.bottom - r.top) * Math.max(0, r.right - r.left);

const intersect = (a, b) => rect(
  Math.max(a.top, b.top), Math.min(a.bottom, b.bottom),
  Math.max(a.left, b.left), Math.min(a.right, b.right),
);

// The margin expands or shrinks the root rectangle in every direction.
const rootWithMargin = (root, margin) => rect(
  root.top - margin.top, root.bottom + margin.bottom, root.left - margin.left, root.right + margin.right,
);

const ratio = (target, root) => area(intersect(target, root)) / area(target);

const viewport = rect(0, 800);
const preload = { top: 0, bottom: 300, left: 0, right: 0 };

const rows = [
  ["T-01", rect(-40, 60)],    // overflows past the top edge
  ["T-02", rect(300, 400)],   // fully inside
  ["T-03", rect(760, 860)],   // at the bottom edge
  ["T-04", rect(900, 1000)],  // outside the viewport, inside the margin
  ["T-05", rect(1200, 1300)], // outside the margin
];

const THRESHOLD = 0.25;
console.log("row    ratio(no margin)  ratio(margin: bottom 300)  threshold 0.25");
for (const [name, target] of rows) {
  const a = ratio(target, viewport);
  const b = ratio(target, rootWithMargin(viewport, preload));
  console.log(
    name,
    a.toFixed(2).padStart(12),
    b.toFixed(2).padStart(18),
    (a >= THRESHOLD ? "  passed" : "  failed").padEnd(9),
    b >= THRESHOLD ? "| passed with margin" : "| failed with margin too",
  );
}
console.log("expanded root:", JSON.stringify(rootWithMargin(viewport, preload)));
```

```
row    ratio(no margin)  ratio(margin: bottom 300)  threshold 0.25
T-01         0.60               0.60   passed  | passed with margin
T-02         1.00               1.00   passed  | passed with margin
T-03         0.40               1.00   passed  | passed with margin
T-04         0.00               1.00   failed  | passed with margin
T-05         0.00               0.00   failed  | failed with margin too
expanded root: {"top":0,"bottom":1100,"left":0,"right":360}
```

The ratio gets calculated against the target's **own area**, not the
root's area. This is why an element larger than the viewport does
not reach a ratio of one, even if it completely covers the screen.

The margin's effect shows up in the fourth row. A row sitting 100
pixels below the screen does not intersect at all in the margin-free
calculation; once 300 pixels get added to the bottom margin, it
counts as fully intersecting. If image loading gets started by this
notification, the image is ready by the time the user reaches the
row. The margin's size is a trade-off: a small margin produces empty
boxes, a large margin produces unnecessary downloads.

Threshold values can get given as a list; a notification gets
produced at every threshold crossing. For just the question "did it
appear or disappear," a single zero threshold is enough. Measuring
how long and at what ratio an ad or a piece of content got seen
requires multiple thresholds.

Two behaviors frequently surprise. The observer reports **the initial
state too** once a target starts getting watched; a call gets
produced for elements already visible on screen. The observer also
answers the question "is it intersecting," not "is it visible": a
target with zero opacity or sitting underneath another element still
counts as visible if it intersects.

#### Resize Observer

**The resize observer** reports when an element's size changes. This
is where it diverges from the window resize event: a container's
size can change without the window changing — when a side panel
opens, when a font loads, when a sibling element grows.

The notification gives the boxes defined in the Box Model lesson in
the Visual Presentation with CSS curriculum separately: the content
box and the border box are different numbers, and which one gets
read depends on the calculation. A chart's drawing area is the
content box; the space the container takes up is the border box.

This observer has a trap of its own. Code inside the callback that
changes the observed element's size produces a new notification; the
notification changes the size again, and a loop forms. The browser
detects this loop, reports an error, and continues drawing for that
frame. The fix is making a change in the callback that does not
depend on the measurement, or limiting the new measurement with a
condition.

#### Mutation Observer

**The mutation observer** reports changes in the document tree:
adding and removing children, attribute changes, a text node's
content changing. Which types get watched gets declared with
options, and the entire subtree can get brought into scope.

```js
// records.mjs — batching mutation records and delivering them together
class ChangeObserver {
  constructor(callback) { this.callback = callback; this.queue = []; this.targets = []; }
  observe(target, options) { this.targets.push({ target, options }); }
  record(entry) {
    const match = this.targets.find(
      (t) => t.target === entry.target && t.options[entry.type] === true,
    );
    if (match === undefined) return;                 // an unobserved type does not get recorded
    if (entry.type === "attribute" && match.options.oldValue !== true) entry = { ...entry, old: undefined };
    this.queue.push(entry);
  }
  flush() {                                            // runs at the microtask boundary
    if (this.queue.length === 0) return;
    const entries = this.queue;
    this.queue = [];
    this.callback(entries);
  }
}

let callCount = 0;
const watcher = new ChangeObserver((entries) => {
  callCount += 1;
  console.log(`callback ${callCount}: ${entries.length} entr${entries.length === 1 ? "y" : "ies"}`);
  for (const entry of entries) console.log("   ", JSON.stringify(entry));
});

const list = { name: "ul#measurements" };
const heading = { name: "h2#heading" };
watcher.observe(list, { childList: true, attribute: true, oldValue: true });
watcher.observe(heading, { childList: true });        // attribute not observed

// Within a single task, three rows get added and one attribute gets changed.
for (const code of ["T-05", "T-06", "T-07"])
  watcher.record({ type: "childList", target: list, added: code });
watcher.record({ type: "attribute", target: list, name: "data-filter", old: "all" });
watcher.record({ type: "attribute", target: heading, name: "class", old: "open" });
console.log("queue before flush:", watcher.queue.length);
watcher.flush();

// In the next task, a single change: a separate callback.
watcher.record({ type: "childList", target: list, added: "T-08" });
watcher.flush();
watcher.flush();                                      // an empty queue produces no callback
console.log("total callbacks:", callCount);
```

```
queue before flush: 4
callback 1: 4 entries
    {"type":"childList","target":{"name":"ul#measurements"},"added":"T-05"}
    {"type":"childList","target":{"name":"ul#measurements"},"added":"T-06"}
    {"type":"childList","target":{"name":"ul#measurements"},"added":"T-07"}
    {"type":"attribute","target":{"name":"ul#measurements"},"name":"data-filter","old":"all"}
callback 2: 1 entry
    {"type":"childList","target":{"name":"ul#measurements"},"added":"T-08"}
total callbacks: 2
```

Four behaviors can get read from the output. Records get
**accumulated**; four changes got delivered in a single call.
Delivery happens not instantly, like events, but after the running
code finishes — the microtask boundary from the Asynchronous
JavaScript and Runtime curriculum applies here. An unwatched type
never gets recorded: the heading element's attribute change never
entered the queue. An empty queue produces no call.

Having the old value in the record has to get requested separately.
When it does not get requested, the record only says "this attribute
changed"; the previous value is lost. This distinction exists for
memory cost, and is off by default.

The mutation observer gets used as a last resort. Code that changes a
tree it wrote itself already knows what it changed; notifying at the
site of the change is both cheaper and clearer than observing the
tree. The observer makes sense when a change produced by code outside
your control needs watching.

#### The Cost of Measurement

The observers' common rationale comes together here. Directly
querying an element's size or position forces the browser to do
every pending style and layout calculation **right then**. Code that
measures and changes in a loop, in sequence, triggers this forcing on
every iteration.

Observers eliminate this forcing: the measurement gets taken at the
moment the browser does its own calculation, and the result arrives
ready in the callback. Splitting measurement and change into separate
phases, though, is the next lesson's subject.

#### Summary

- The three observers follow a common pattern: constructed with a
  callback, targets get declared separately, delivery happens as an
  array of records rather than one by one, and observation gets
  disconnected explicitly.
- The intersection ratio gets calculated against the target's own
  area; root margin pulls the notification earlier, and the
  threshold list determines at which ratios the notification gets
  produced.
- The intersection observer reports the initial state too when
  watching starts, and measures intersection, not visibility.
- The resize observer reports the content and border boxes
  separately; code that changes the size inside the callback produces
  a loop.
- Mutation records get accumulated and delivered in a batch at the
  microtask boundary; an unwatched type does not get recorded, and
  the old value does not get carried unless it gets requested
  separately.
- Because observers take the measurement from the browser's own
  calculation, they eliminate the layout forcing that direct querying
  creates.

#### Next Step

Observers solve when the measurement gets taken, but leave open when
the change gets applied. Code that updates a heading's position while
the measurement list scrolls, grows a bar, or advances a counter runs
against the browser's drawing schedule if it does this at a random
moment: a frame gets dropped, motion turns choppy. The order in which
the browser prepares every frame is defined, and there is a way to
fit into that order. The next lesson takes up the concept of the
frame, why timers are not frame-aligned, and why reading and writing
get split into separate phases.

### Timing and the Paint Cycle

Source: https://academia.sh/en/courses/browser-platform/timing-and-paint-cycle

The browser's frame-preparation order and fitting an update into that order; the frame budget, the alignment drift a fixed-delay timer causes, the frame callback's rules, separating reads from writes, and splitting long work.

Observers solve when the measurement gets taken, leave open when the
change gets applied. Code that updates a heading's position while the
measurement list scrolls, or grows a bar, runs against the browser's
drawing schedule if it does this at a random moment.

The browser refreshes the screen not continuously, but **frame by
frame**. It follows a specific order in every frame, and that order
is defined. Fitting the update into this order is the only way for
motion to look smooth.

#### Anatomy of a Frame

A frame goes through these stages: accumulated input events get
processed, expired timers run, frame callbacks get called, style gets
calculated, layout gets done, painting gets done, and layers get
composited.

Two consequences follow from this order. First, frame callbacks run
**before style and layout**; a change made there shows on screen in
the same frame. Second, the sum of all work done in a frame must not
exceed the frame's duration.

A frame's duration depends on the screen's refresh rate and is not
the same on every device. Assuming 60 frames per second, a frame
lasts 16.67 milliseconds; this shortens on faster screens. This is
why code should rely on the timestamp the browser gives, rather than
assuming a fixed duration.

#### Timer Versus Frame Alignment

A common approach is tying the update to a fixed-delay timer. This
approach's problem is that the delay gets counted **from when the
work finishes**: a drift accumulates every round, equal to the work's
duration.

```js
// frame.mjs — comparing a fixed-delay timer's callback to a frame-aligned one
const FRAME = 1000 / 60;              // frame duration assuming 60 frames per second (ms)
const WORK = 4;                       // duration each update takes (ms)
const STEPS = 8;

const frameNumber = (t) => Math.floor(t / FRAME);

// Timer: every round gets rescheduled 16 ms after the work finishes.
let t = 0;
const timer = [];
for (let i = 0; i < STEPS; i += 1) {
  timer.push(t);
  t += WORK + 16;
}

// Frame-aligned: the callback runs at the start of the frame, work duration does not shift the next target.
const frameAligned = Array.from({ length: STEPS }, (_, i) => i * FRAME);

const tally = (moments) => {
  const bins = new Map();
  for (const moment of moments) bins.set(frameNumber(moment), (bins.get(frameNumber(moment)) ?? 0) + 1);
  return bins;
};

console.log("step  timer(ms)        frame  frame-aligned(ms)  frame");
for (let i = 0; i < STEPS; i += 1)
  console.log(
    String(i + 1).padStart(4),
    timer[i].toFixed(2).padStart(15), String(frameNumber(timer[i])).padStart(6),
    frameAligned[i].toFixed(2).padStart(17), String(frameNumber(frameAligned[i])).padStart(6),
  );

const gaps = (moments) => {
  const bins = tally(moments);
  const last = frameNumber(moments[moments.length - 1]);
  let n = 0;
  for (let k = 0; k <= last; k += 1) if ((bins.get(k) ?? 0) === 0) n += 1;
  return { last, empty: n };
};
for (const [label, moments] of [["timer", timer], ["frame-aligned", frameAligned]]) {
  const { last, empty: n } = gaps(moments);
  console.log(`${label.padEnd(13)}: ${STEPS} updates, spread across frames 0..${last}, empty frames: ${n}`);
}
console.log("frame budget:", FRAME.toFixed(2), "ms | work:", WORK, "ms | remaining:", (FRAME - WORK).toFixed(2), "ms");
```

```
step  timer(ms)        frame  frame-aligned(ms)  frame
   1            0.00      0              0.00      0
   2           20.00      1             16.67      1
   3           40.00      2             33.33      2
   4           60.00      3             50.00      3
   5           80.00      4             66.67      4
   6          100.00      6             83.33      5
   7          120.00      7            100.00      6
   8          140.00      8            116.67      7
timer        : 8 updates, spread across frames 0..8, empty frames: 1
frame-aligned: 8 updates, spread across frames 0..7, empty frames: 0
frame budget: 16.67 ms | work: 4 ms | remaining: 12.67 ms
```

The drift becomes visible at the sixth step: the fifth frame gets no
update, the sixth frame gets the next one. What the user sees is not
a freeze but irregularity — the motion pauses for a moment. When the
same number of updates gets done frame-aligned, no frame stays empty.

The fixed-delay timer's second problem is that it also runs at
moments with no frame. When a tab goes to the background, the screen
does not refresh; the timer still fires, and frames that will never
be seen get calculated. The frame callback does not get called in
that case, and the work stops on its own.

#### The Frame Callback's Rules

The frame callback is **one-shot**: it has to get re-registered for
the next frame. An ongoing motion re-registers itself from inside the
callback; stopping it means not renewing the registration, or
canceling a pending one.

The callback takes the frame's timestamp as an argument. The motion's
progress gets calculated from this timestamp: "this many pixels based
on elapsed time," instead of "one pixel per call." The first form
changes the motion's speed when the frame rate changes; the second
form is device-independent. The same timestamp gets given to every
callback in that frame, so elements updated in the same frame stay
consistent with each other.

The user's motion preference is a separate rule. If reduced motion
has been requested at the operating-system level, this preference can
get queried on the style side and also read on the program side; if
the preference is set, motion gets removed or reduced to an instant
transition.

#### Separating Reads From Writes

A second rule is needed to keep the work done inside a frame cheap.
Querying an element's size forces every pending write to get
processed right then; this is the forcing mentioned in the Observers
lesson. Doing measurement and writing in sequence triggers this
forcing on every round.

```js
// read-write.mjs — the layout count forced by interleaving reads and writes
function setup() {
  return {
    bars: [40, 55, 30, 72, 61].map((w, i) => ({ name: `T-0${i + 1}`, width: w })),
    dirty: false,          // is a write pending
    layouts: 0,            // forced layout calculation count
  };
}
const read = (state, bar) => {
  if (state.dirty) { state.layouts += 1; state.dirty = false; }   // measuring forces any pending writes
  return bar.width;
};
const write = (state, bar, value) => { bar.width = value; state.dirty = true; };

// First approach: read then write for each bar.
const a = setup();
for (const bar of a.bars) write(a, bar, read(a, bar) + 10);
console.log("interleaved  :", a.layouts, "forced layouts |",
  a.bars.map((c) => c.width).join(" "));

// Second approach: all measurements first, then all writes.
const b = setup();
const measured = b.bars.map((bar) => read(b, bar));
b.bars.forEach((bar, i) => write(b, bar, measured[i] + 10));
console.log("separated    :", b.layouts, "forced layouts |",
  b.bars.map((c) => c.width).join(" "));
console.log("results match:", JSON.stringify(a.bars) === JSON.stringify(b.bars));
```

```
interleaved  : 4 forced layouts | 50 65 40 82 71
separated    : 0 forced layouts | 50 65 40 82 71
results match: true
```

The two approaches produce the same result; their costs differ. In
the separated order, measurements get taken in one pass, writes
accumulate, and layout happens once, in the frame's own stage.

The number here is again not a performance measurement, but a **call
count**; the real cost depends on the document and the style sheet.
What stays fixed is the number of triggered calculations dropping
from four to zero. The rule stands out even more as the element count
grows: the separated order produces a fixed number of calculations,
the interleaved order one proportional to the element count.

#### Splitting Long Work

A single piece of work that exceeds the frame budget makes everything
else in that frame wait: input does not get processed, motion does
not advance. A function that filters a thousand measurements in one
go does exactly this.

The solution is splitting the work. A chunk gets processed, control
gets handed back to the runtime, the next chunk gets processed on the
next round. This way, input events get processed in the gaps
between, and the interface stays responsive. The measure of splitting
is not a fixed chunk count, but elapsed time: duration gets measured
every round, and the round gets ended once the budget fills up.

Some work does not have to happen inside a frame — writing to a log,
clearing a cache, preparing the next page. For these, the browser
offers a callback of the **idle** class: once the frame finishes its
work, it gets called if time remains, and reports how much time is
left. Work given to the idle callback gets bounded with a timeout,
against the possibility that it never runs at all.

Another solution for heavy computation is taking the work out of the
main thread entirely; this is the Web Workers lesson's subject.

#### Summary

- The browser refreshes the screen frame by frame; because frame
  callbacks run before style and layout, a change made there shows up
  in the same frame.
- A fixed-delay timer accumulates drift because it counts the delay
  from when the work finishes, and some frames get no update; the
  frame callback preserves alignment.
- The frame callback is one-shot, takes a timestamp, and does not get
  called in a tab whose screen does not refresh; motion gets
  calculated by elapsed time.
- Doing measurement and writing in sequence forces a layout
  calculation on every round; this forcing disappears once all
  measurements happen first and all writes happen after.
- Work that exceeds the frame budget gets split into chunks, and
  control gets handed back to the runtime between chunks; deferrable
  work gets left to the idle callback.

#### Next Step

Part of the work done inside a frame is producing text: writing a
unit next to a measurement value, converting a timestamp into a
readable date, sorting the list by name. None of these operations
are universal. The decimal separator is not the same character
everywhere, the order of date fields varies by language, letter
sorting depends on the alphabet, and in Turkish the dotted and
dotless letter are separate letters. Hand-written formatting code
cannot carry these differences. The next lesson takes up the
browser's interface that does this work according to locale, and
that interface's setup cost.

### Internationalization API

Source: https://academia.sh/en/courses/browser-platform/internationalization-api

Doing number, date, and sorting according to locale; the locale tag's structure, splitting formatted output into parts, the time zone's role, sorting's dependence on the alphabet, and the formatter's setup cost.

Part of the work done inside a frame is producing text: writing a
unit next to a measurement value, converting a timestamp into a
readable date, sorting the list by name. None of these operations are
universal.

The decimal separator is not the same character everywhere, the
order of date fields varies by language, letter sorting depends on
the alphabet, and in Turkish the dotted and dotless letter are
separate letters. Hand-written formatting code cannot carry these
differences. For this, the browser offers a family of interfaces
that carry their own language data.

This lesson's outputs got produced on one runtime. The formatted
text's **exact character sequence** depends on the language data the
runtime carries, and can change between versions; what does not
change is the operation itself. This distinction turns into a rule at
the end of the lesson.

#### The Locale Tag

All these interfaces take a **locale** tag. The tag consists of
hyphen-separated subtags: language, an optional script, an optional
region. `tr` declares only the language, `tr-TR` the language and the
region, `sr-Latn-RS` all three at once.

The region subtag chooses **rules**, not the language: currency,
measurement system, and date order can diverge between two regions
of the same language. Extensions added at the end of the tag can also
request a calendar, a numbering system, and a sort order.

The user's preference gets read from the browser and arrives as a
**list**: if the first preference is not supported, the second gets
tried. A list can also get given to the interfaces instead of a
single tag; the choice gets made by intersecting with the provided
data. Which setting got chosen and which options are in effect can
get asked back from the formatter.

#### Number Formatting

```js
// number.mjs — formatting the same number according to locale
const value = -1234.567;

for (const locale of ["tr-TR", "en-US", "de-DE"]) {
  const format = new Intl.NumberFormat(locale, { maximumFractionDigits: 2 });
  console.log(locale, "->", format.format(value));
}

const temperature = new Intl.NumberFormat("tr-TR", {
  style: "unit", unit: "celsius", unitDisplay: "short", maximumFractionDigits: 1,
});
const ratio = new Intl.NumberFormat("tr-TR", { style: "percent", maximumFractionDigits: 1 });
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact" });
const compactText = compact.format(48210);

console.log("temperature:", temperature.format(-4.23));
console.log("ratio      :", ratio.format(0.0731));
console.log("compact    :", compactText, "| code points:",
  [...compactText].map((c) => "U+" + c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")).join(" "));

// Splitting into parts makes it possible to pick a field out of the formatted text.
console.log(JSON.stringify(temperature.formatToParts(-4.23)));
```

```
tr-TR -> -1.234,57
en-US -> -1,234.57
de-DE -> -1.234,57
temperature: -4,2°C
ratio      : %7,3
compact    : 48 B | code points: U+0034 U+0038 U+00A0 U+0042
[{"type":"minusSign","value":"-"},{"type":"integer","value":"4"},{"type":"decimal","value":","},{"type":"fraction","value":"2"},{"type":"unit","value":"°C"}]
```

The first three lines show the decimal and thousands separators
swapping places; the same number appears in three writings. Code that
concatenates a number with a period and writes it to the screen can
run into a period the user takes for a thousands separator.

The percent line carries a second difference: in Turkish, the sign
comes **before** the number. Where the unit and the sign get placed
also comes from the language data; concatenating by hand loses this
information.

The compact notation's code points carry a third warning. The
character between the number and the abbreviation is not an
ordinary space, it is the **non-breaking space** defined in the Text
Labels lesson; it shows up as `U+00A0` in the code point list. Code
that tries to recover the number by splitting the text on whitespace
runs into this character. This is concrete proof that formatted text
should not get parsed.

The last line shows the most useful operation. The formatted text can
get split into parts, and each part's type gets declared. This way,
the integer part can get written large, the decimal part small, or
the unit can get put into a separate element; none of this requires
trying to split the text.

The formatting's input also needs attention. The number formatter
expects a number, not a string; the type loss mentioned in the
Storage APIs lesson becomes visible here.

#### Date, Time, and Time Zone

```js
// date.mjs — formatting the same instant by locale and time zone
const instant = new Date("2024-02-11T06:05:00Z");   // a fixed instant in universal time

for (const [locale, zone] of [["tr-TR", "Europe/Istanbul"], ["en-US", "America/New_York"]]) {
  const format = new Intl.DateTimeFormat(locale, {
    dateStyle: "long", timeStyle: "short", timeZone: zone,
  });
  console.log(locale.padEnd(6), zone.padEnd(17), format.format(instant));
}

// Requesting fields separately and splitting into parts
const fielded = new Intl.DateTimeFormat("tr-TR", {
  weekday: "long", day: "numeric", month: "long", timeZone: "Europe/Istanbul",
});
console.log("fielded :", fielded.format(instant));
console.log("parts   :", fielded.formatToParts(instant).map((p) => `${p.type}=${p.value}`).join(" "));

// Relative time: unit and number get given, the text comes from the locale.
const relative = new Intl.RelativeTimeFormat("tr-TR", { numeric: "auto" });
for (const [n, unit] of [[-1, "day"], [-3, "hour"], [0, "day"], [2, "week"]])
  console.log("relative:", relative.format(n, unit));
```

```
tr-TR  Europe/Istanbul   11 Şubat 2024 09:05
en-US  America/New_York  February 11, 2024 at 1:05 AM
fielded : 11 Şubat Pazar
parts   : day=11 literal=  month=Şubat literal=  weekday=Pazar
relative: dün
relative: 3 saat önce
relative: bugün
relative: 2 hafta sonra
```

The first two lines show the same instant. What gets stored in the
measurement record is a single instant in universal time; what gets
shown to the user is that instant's counterpart in the user's time
zone. Giving the time zone explicitly to the formatter leaves no
ambiguity about which zone the record gets read in. When it does not
get given, the device's zone gets used, and two users see the same
record with different times.

The field order also comes from the language: in Turkish, day, month,
weekday name; a completely different order in another language. Even
the separators in the parts list are part of the language data; this
shows why concatenating a date by hand does not work.

The relative time formatter solves a separate problem. Calculating
"how many units ago" is the program's job; turning that calculation
into text is the language data's. The automatic mode uses a word
instead of a number wherever one exists — "dün" (yesterday) and
"bugün" (today) in the output are the result of this.

#### Sorting and Comparison

The default form of string comparison compares code points. This
order, defined in the Character Encodings lesson, does not
correspond to any language's alphabet.

```js
// sort.mjs — code point order versus locale-aware sorting
const stations = ["Iğdır", "İzmir", "Isparta", "Şanlıurfa", "Sivas", "Çorum", "Ordu"];

console.log("code point :", stations.toSorted().join(" "));
console.log("tr-TR      :", stations.toSorted(new Intl.Collator("tr").compare).join(" "));
console.log("en-US      :", stations.toSorted(new Intl.Collator("en").compare).join(" "));

// Sensitivity: whether case and accent differences count as equal
const loose = new Intl.Collator("tr", { sensitivity: "base" });
console.log("'Izmir' and 'İZMİR' equal:", loose.compare("Izmir", "İZMİR") === 0);

// Numeric sorting: numbers inside the string get compared as numbers
const codes = ["T-2", "T-10", "T-1", "T-21"];
console.log("as string  :", codes.toSorted(new Intl.Collator("tr").compare).join(" "));
console.log("as number  :", codes.toSorted(new Intl.Collator("tr", { numeric: true }).compare).join(" "));

// Plural rules: which category a number falls into gets asked, the text comes from the locale
const plural = new Intl.PluralRules("tr");
console.log("plural tr  :", [0, 1, 2, 11].map((n) => `${n}=${plural.select(n)}`).join(" "));
console.log("plural en  :", [0, 1, 2, 11].map((n) => `${n}=${new Intl.PluralRules("en").select(n)}`).join(" "));
```

```
code point : Isparta Iğdır Ordu Sivas Çorum İzmir Şanlıurfa
tr-TR      : Çorum Iğdır Isparta İzmir Ordu Sivas Şanlıurfa
en-US      : Çorum Iğdır Isparta İzmir Ordu Şanlıurfa Sivas
'Izmir' and 'İZMİR' equal: false
as string  : T-1 T-10 T-2 T-21
as number  : T-1 T-2 T-10 T-21
plural tr  : 0=other 1=one 2=other 11=other
plural en  : 0=other 1=one 2=other 11=other
```

The first line is the result of code point order: accented letters
get thrown outside the alphabet, `Ç` and `Ş` fall to the end of the
list. The second line follows the Turkish alphabet.

The third line carries a subtle distinction. Turkish and English
sorting diverge only at the letter `Ş`: in Turkish, `Ş` is a separate
letter and comes after `S`; in English sorting, it counts as a
variant of `S`, and `Şanlıurfa` comes before `Sivas`. The same data
gets sorted differently depending on the user's language.

The fourth line shows Turkish's well-known trap. Even at the loosest
sensitivity, the dotted and dotless letter **do not count as equal**,
because in Turkish these are not two forms of the same letter, but
two separate letters. Code doing case-insensitive search has to
account for this distinction.

The fifth and sixth lines show numeric sorting: when measurement
codes get sorted as strings, `T-10` comes before `T-2`. The numeric
option compares number groups inside a string as numbers.

The last two lines show plural rules. When writing interface text,
which categories the "1 measurement / 2 measurements" distinction
gets made by depends on the language; the category name gets asked,
and the application supplies the matching text. Turkish's and
English's categories overlap in this example; other languages have
more than two categories, and a two-branch condition is not enough.

#### The Formatter's Cost and Stability

**Constructing** the formatter object **is expensive**; language
data gets resolved and options get reconciled. The formatting
operation itself, though, is cheap. A single rule follows from this:
the formatter gets constructed once and reused. Code that constructs
a new formatter in every cell while drawing a thousand-row table can
consume the previous lesson's frame budget all by itself.

The second rule concerns the output's stability. The produced text
**is meant to get shown to the user**; not to get parsed, compared,
or sent to the server. Language data can change between versions; the
space character, a separator, or an abbreviation can take a
different form. Data always gets stored and carried in its raw form;
formatting only happens at the presentation layer. In tests, too, the
whole of the formatted text does not get compared against a
constant; this is why the splitting-into-parts operation exists.

#### Summary

- The locale tag consists of language, script, and region subtags;
  the region chooses formatting rules, not the language, and the
  preference gets given as a list.
- The decimal separator, the thousands separator, and the sign's
  position come from the language data; code that concatenates a
  number by hand loses this information.
- Formatted text can get split into parts; styling fields separately
  does not require splitting the text.
- The measurement instant gets stored in universal time, and the
  time zone gets given explicitly at display time; when it does not
  get given, the device's zone gets used.
- Code point order does not correspond to any alphabet; locale-aware
  sorting follows the alphabet, and in Turkish the dotted and
  dotless letter are separate letters even at the loosest
  sensitivity.
- The formatter gets constructed once and reused; the text it
  produces is for presentation, data always gets stored in its raw
  form, and does not get used as parsing input.

#### Next Step

Locale, time zone, and language preference get read from the user's
device **without asking permission**; these are information the
page already knows. There are other things the measurement station
page will want to ask for: the coordinates of the point a
measurement got taken at, a photo to attach to a fault report, a
voice note. None of these get given without asking. The next lesson
takes up the capabilities that require permission, permission's
states, its dependence on user gesture, and how the page keeps
working if the request gets denied.

### Media and Device APIs

Source: https://academia.sh/en/courses/browser-platform/media-and-device-apis

Access to capabilities that require permission; permission's states and preconditions, the secure-context and user-gesture requirements, feature detection and graceful degradation, media tracks' lifecycle, and the privacy balance of location access.

Locale, time zone, and language preference get read from the user's
device without asking permission; these are information the page
already knows. There are other things the measurement station page
will want to ask for: the coordinates of the point a measurement got
taken at, a photo to attach to a fault report, a voice note.

None of these get given without asking. What they have in common is
that they **carry information out of the user's device**. The
browser puts these capabilities behind a common permission mechanism
that, once learned, applies to all of them.

#### Capabilities That Require Permission

Capabilities split into three sets.

**Those that require no permission** are the device's general
properties that do not identify the user: viewport dimensions,
language preference, time zone, whether the connection is online.
These get read without asking.

**Those that require explicit permission** are the ones that collect
data from the user's surroundings: camera, microphone, location,
screen sharing, motion and orientation sensors, reading the
clipboard, sending notifications.

**Those limited by user gesture** are capabilities that do not ask
permission but cannot get triggered on their own: going fullscreen,
prompting a file selection, keeping the screen awake, writing to the
clipboard. These only run while an event arising from a user action
gets processed.

A capability can also be closed from the very start. The
permissions-policy declaration defined in the Embedded Content lesson
determines which capabilities get delegated to a frame; an
undelegated capability does not work even with user consent.

#### Permission's States

Permission sits in one of three states: not yet asked, granted,
denied. When a request gets made, the browser checks the
preconditions first, then looks at the state.

```js
// permission.mjs — the permission state machine and a request's preconditions
function request(state, env) {
  if (env.secureContext === false)
    return { state, result: "error: no secure context, not asked" };
  if (state === "granted") return { state, result: "granted directly, not asked" };
  if (state === "denied") return { state, result: "denied, not asked again" };
  if (env.userGesture === false)
    return { state, result: "error: no user gesture, not asked" };
  return { state: env.response, result: `asked the user -> ${env.response}` };
}

const scenarios = [
  ["insecure context", { secureContext: false, userGesture: true, response: "granted" }],
  ["gestureless call", { secureContext: true, userGesture: false, response: "granted" }],
  ["user dismissed", { secureContext: true, userGesture: true, response: "not-asked" }],
  ["user granted", { secureContext: true, userGesture: true, response: "granted" }],
  ["second request", { secureContext: true, userGesture: true, response: "granted" }],
];

let state = "not-asked";
console.log("initial state:", state);
for (const [label, env] of scenarios) {
  const result = request(state, env);
  state = result.state;
  console.log(label.padEnd(18), "->", result.result.padEnd(38), "| state:", state);
}

// When the user revokes permission from settings, the state changes; the page only learns this by asking.
state = "denied";
console.log("permission revoked ->", request(state, { secureContext: true, userGesture: true, response: "granted" }).result);
```

```
initial state: not-asked
insecure context   -> error: no secure context, not asked    | state: not-asked
gestureless call   -> error: no user gesture, not asked      | state: not-asked
user dismissed     -> asked the user -> not-asked            | state: not-asked
user granted       -> asked the user -> granted              | state: granted
second request     -> granted directly, not asked            | state: granted
permission revoked -> denied, not asked again
```

Five rules come together in this output.

**A secure context is required.** A page served unencrypted cannot
request these capabilities; without the privacy
guarantee defined in the Role of HTTPS lesson, data coming from the
camera would travel exposed over the network. The local development
address is a defined exception to this rule.

**A user gesture is required.** Screen sharing and reading the clipboard reject outright a request that
does not start from a user gesture; camera and microphone do not carry that condition, but code that asks
for permission the moment the page loads can still get rejected by the browser. The request gets made
while processing the event started by the user pressing a button.

**Dismissing is not denying.** When the user closes the prompt
without answering it, the state stays "not asked"; the next request
can ask again. This also opens the door to insistent asking, which
the browser blocks on its own once abused.

**Granted permission does not get asked again.** The second request
resolves without bothering the user.

**Denied permission does not get asked again either.** The request
fails directly; what gets shown to the user is not a question, but
an explanation of how to enable it in the browser's settings.

Permission state can also get **queried**: what the state is can get
learned without making a request, and a notification can get
received when the state changes. This makes it possible to update
the interface when the user revokes permission from settings.

#### Feature Detection and Graceful Degradation

A capability being absent and being denied are separate situations
and get handled separately.

**Feature detection** is testing at runtime whether the relevant
interface exists. Code that checks the browser's name or version is
the wrong path: testing the name does not test the capability. The
test checks for the presence of the object or method that will get
used; if negative, the interface hides that option entirely.

These three situations have different responses. If the capability
is absent, the option does not get shown. If it exists but permission
got denied, the option shows with an explanation of how to enable
it. If the capability exists, permission exists, but
the operation failed, an error gets reported and can get retried.

The common rule in every case is this: the page has to do its job
even without the capability. If it cannot read location, it asks the
user to type the coordinate; if it cannot open the camera, it
prompts a file selection. A design with only a capability-dependent
path leaves a declining user outside the page.

#### Media Tracks and Releasing the Device

A camera or microphone request returns a **stream**. A stream
consists of **tracks**, each tied to a single source, and releasing
the source depends on stopping the tracks.

```js
// stream.mjs — media track lifecycle and releasing the device
const devices = { camera: 0, microphone: 0 };   // how many tracks are using the device

const track = (kind, device, settings) => {
  devices[device] += 1;
  return {
    kind, device, settings, live: true,
    stop() { if (this.live) { this.live = false; devices[device] -= 1; } },
  };
};

function getStream(constraints) {
  const tracks = [];
  if (constraints.video) tracks.push(track("video", "camera", { width: 1280, height: 720 }));
  if (constraints.audio) tracks.push(track("audio", "microphone", { echoCancellation: true }));
  return { tracks, stop() { for (const t of this.tracks) t.stop(); } };
}

const log = (label) =>
  console.log(label.padEnd(24), "camera:", devices.camera, "| microphone:", devices.microphone);

log("start");
const stream = getStream({ video: true, audio: true });
log("stream acquired");
console.log("tracks:", stream.tracks.map((t) => `${t.kind}(${JSON.stringify(t.settings)})`).join(" "));

stream.tracks[0].stop();                        // only the video track gets stopped
log("video track stopped");
stream.tracks[0].stop();                        // a second stop has no effect
log("same track stopped again");
stream.stop();
log("stream stopped");
console.log("track states:", stream.tracks.map((t) => `${t.kind}=${t.live ? "live" : "ended"}`).join(" "));
```

```
start                    camera: 0 | microphone: 0
stream acquired          camera: 1 | microphone: 1
tracks: video({"width":1280,"height":720}) audio({"echoCancellation":true})
video track stopped      camera: 0 | microphone: 1
same track stopped again camera: 0 | microphone: 1
stream stopped           camera: 0 | microphone: 0
track states: video=ended audio=ended
```

The fourth line shows the most common mistake. Clearing a video
element's source or removing the element from the tree does not stop
the track; the recording indicator keeps glowing, and the user
thinks the page is still listening. The device only gets released
once **every track gets stopped individually**. Stopping is
idempotent: a second call does not break the count.

The **constraints** given during the request declare settings like
the requested resolution and audio processing. These are a wish, not
a guarantee: the actual settings the device provides get read back
from the track and may differ. Giving a
constraint in mandatory form means the request fails entirely if it
cannot get met.

#### Location and Sensors

Location access has two forms: a one-time read and continuous
watching. Watching produces a notification as the location changes
and continues until it gets explicitly released; watching that does
not get released drains the battery.

A **high-accuracy** request declared in the request can bring
satellite-based positioning into play; this is slower and more
expensive. The accuracy needed to know which valley a point is in
differs from the accuracy needed to know which side of a sidewalk
someone stands on; accuracy that is not needed does not get
requested.

The incoming location has an **accuracy radius**, and this value has
to get reflected in the interface; showing a single point claims a
precision that is not available. Timeout and cached-reading age also
get declared in the request: whether a stale reading is acceptable,
and how long to wait.

Motion and orientation sensors sit within a similar framework, and
also require permission on some platforms. A use like recording the
device's tilt while taking a measurement has to get weighed against
sensor data's potential to identify the user.

#### The Privacy Contract

These capabilities' shared contract has four articles. Permission
gets requested **in context**: the moment the user understands why,
when they press the relevant button. The data collected is **minimal**: if location gets attached
to a measurement, the station name may be enough instead of the
exact coordinate. Access is **visible**: the interface shows it
while the capability is in use. And access gets **released**: once
the job is done, tracks get stopped and watches get ended.

#### Summary

- Capabilities split into sets that require no permission, require
  explicit permission, and get limited by user gesture; a capability
  not delegated to frames does not open even with permission.
- A permission request depends on the secure-context and
  user-gesture preconditions; granted and denied permissions do not
  get asked again, while a dismissed prompt does not change the
  state.
- Feature detection checks for the interface's presence, not the
  browser's name; capability absence, permission denial, and
  operation failure each require a different response.
- The page has to do its job even without the capability; a design
  that leaves only a path dependent on the capability leaves a
  declining user outside.
- The device only gets released once every track in the stream gets
  stopped; removing the element from the tree does not stop the
  track.
- Requesting accuracy for location has a cost, and the incoming
  reading's accuracy radius gets reflected in the interface; watching
  gets ended explicitly.

#### Next Step

Most of this lesson's capabilities produce heavy data: frames coming
from the camera, sensor readings, thousands of rows in a selected
measurement file. Processing these on the main thread exhausts the
Timing and the Paint Cycle lesson's frame budget in a single call,
and the interface stops responding.
Splitting the work into chunks spreads out the delay but does not
shorten the total time. The next lesson takes up web workers, which
move the computation outside the main thread, message passing
between them, and the cost of this separation.

### Web Workers

Source: https://academia.sh/en/courses/browser-platform/web-workers

Moving computation outside the main thread; the non-shared memory model, message passing, structured cloning's scope and cost, transferable objects, worker types, and when the separation pays off.

Most of the previous lesson's capabilities produce heavy data: frames
coming from the camera, sensor readings, thousands of rows in a
selected measurement file. Processing these on the main thread
exhausts the frame budget in a single call, and the interface stops
responding.

Splitting the work into chunks spreads out the delay but does not
shorten the total time; the computation still happens on the same
thread. The second solution is taking the computation out of there
entirely. For this, the browser offers a runtime that runs in
separate threads.

#### Non-Shared Memory

**A worker** is a runtime that runs on a separate thread and is not
a copy of the page. It has its own global object, its own heap, and
its own event loop. Three consequences follow from this.

**It cannot access the document tree.** Selecting a node, reading
style, adding an element is not the worker's job. The only place
that updates the interface is the main thread; the worker only
produces data.

**It shares no variables.** An object on the main thread is
invisible to the worker. The only link between them is **message
passing**. This means the single-threaded model from the
Asynchronous JavaScript and Runtime curriculum stays unbroken: both
sides are single-threaded within themselves, there is no shared
state between them, and classic race conditions do not arise.

**It consumes a separate resource.** Every worker carries its own
memory and its own runtime structure; its count does not scale up
without limit.

#### Running a Worker

The example below runs with `node:worker_threads` and opens a real
thread. Its browser counterpart follows the same model: a worker
gets constructed with a script address, and both sides send and
receive messages. Naming details differ — on the browser side,
initial data gets sent as a separate message — but the flow is the
same.

```js
// worker.mjs — moving computation to a separate thread (node:worker_threads)
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";

// The same file runs both on the main thread and in the worker; the branch splits on isMainThread.
function summarize(measurements) {
  let total = 0;
  let max = -Infinity;
  let aboveThreshold = 0;
  for (const v of measurements) {
    total += v;
    if (v > max) max = v;
    if (v > 5) aboveThreshold += 1;
  }
  return { count: measurements.length, average: total / measurements.length, max, aboveThreshold };
}

if (isMainThread) {
  // Deterministic data: the same array gets produced on every run.
  const measurements = Array.from({ length: 200000 }, (_, i) => Math.sin(i / 7) * 10);
  const worker = new Worker(new URL(import.meta.url), { workerData: measurements });

  worker.on("message", (result) => {
    console.log("result :", {
      count: result.count,
      average: Number(result.average.toFixed(6)),
      max: Number(result.max.toFixed(6)),
      aboveThreshold: result.aboveThreshold,
    });
  });
  worker.on("error", (error) => console.log("error  :", error.message));
  worker.on("exit", (code) => console.log("exit   :", code));
  console.log("main thread set up the worker and continued without waiting");
} else {
  parentPort.postMessage(summarize(workerData));
}
```

```
main thread set up the worker and continued without waiting
result : { count: 200000, average: 0.000399, max: 10, aboveThreshold: 66672 }
exit   : 0
```

The output's order is the model's summary. The main thread
constructs the worker and continues **without waiting**; the
callback runs once the result arrives. Throughout the loop over two
hundred thousand elements, the main thread is free and keeps
processing events.

The largest value in the result comes from how the sample data got
produced: the sine function's largest value approaches one, and
multiplied by ten it is ten. The above-threshold count also derives
from the data; because the data is deterministic, it comes out the
same on every run. An exit code of zero declares that the worker
closed without error.

#### The Cost of Message Passing

Message passing is not free. The value sent does not get shared, it
gets **cloned**. The cloning rule is wider than JSON's and has its
own name.

```js
// clone.mjs — structured cloning and transferable objects
const record = {
  code: "T-01",
  at: new Date("2024-02-11T06:00:00Z"),
  tags: new Map([["night", true]]),
  sample: new Uint8Array([3, 1, 4]),
};
const clone = structuredClone(record);
console.log("date preserved:", clone.at instanceof Date, "| map preserved:", clone.tags instanceof Map);
console.log("array preserved:", clone.sample instanceof Uint8Array, "| same object:", clone.sample === record.sample);

// What JSON cannot do: a circular reference
const node = { name: "station" };
node.itself = node;
const nodeClone = structuredClone(node);
console.log("cycle preserved:", nodeClone.itself === nodeClone);

// Values that cannot get cloned produce an error.
for (const [name, value] of [["function", { f: () => 1 }], ["symbol", { s: Symbol("x") }]]) {
  try {
    structuredClone(value);
    console.log(name.padEnd(14), ": cloned");
  } catch (error) {
    console.log(name.padEnd(14), ":", error.name);
  }
}

// Transfer: the buffer does not get copied, ownership gets handed over.
const buffer = new ArrayBuffer(1024);
const transferred = structuredClone(buffer, { transfer: [buffer] });
console.log("after transfer source:", buffer.byteLength, "| target:", transferred.byteLength);
```

```
date preserved: true | map preserved: true
array preserved: true | same object: false
cycle preserved: true
function       : DataCloneError
symbol         : DataCloneError
after transfer source: 0 | target: 1024
```

**Structured cloning** does not suffer the JSON losses seen in the
Storage APIs lesson: a date stays a date, a map stays a map, a
circular reference gets preserved. In return, the copy is a genuine
copy; the objects on the two sides are separate objects, and a
change made on one does not reflect on the other.

**There are values that cannot get cloned.** A function and a symbol
cannot get cloned and produce an error. The same rule applies to
nodes; sending an element to a worker is not possible. This is the
message side's counterpart to not being able to access the document
tree.

**Transferable objects** make it possible to escape the cloning
cost. A buffer put in the transfer list does not get copied; its
ownership passes to the other side and it becomes unusable on the
sender's side — the source's length is zero in the output. Large
binary data gets moved this way at almost no cost, but the sending
side has to be certain it will never use it again.

There is also genuinely **shared memory**: two threads seeing the
same buffer. This path depends on conditions that require the page
to be isolated from other sources, and access to shared memory has
to get queued with a mechanism that makes operations atomic. It is
the path resorted to last, because it brings race conditions back.

#### Types and Lifetime

There are three types of worker thread. **A dedicated worker**
belongs to the document that constructed it; it ends once the
document closes. **A shared worker** serves multiple documents of
the same origin and speaks through ports; it is useful for doing a
shared computation once across tabs. **A service worker**, though,
serves a separate purpose — it mediates network requests and can run
even while the document is closed; it is the Service Workers
lesson's subject.

A worker can get terminated explicitly. Termination is abrupt: the
worker's work gets left unfinished. Terminating an ongoing
computation once the user moves to another view is this context's
counterpart to the cancellation rule from previous lessons. A worker
that does not get terminated stays in memory and keeps consuming
resources.

Error handling is separate too. An uncaught error inside the worker
gets reported to the main thread with an event; a worker crashing
does not crash the page. This isolation is a gain: an unreliable
computation gets kept separate from the main thread.

#### When It Is Worth It

The separation has a setup cost: opening the thread, loading the
script, cloning the data, cloning the result back. For a small job,
this cost outweighs the gain.

The test is this: is the work **pure computation**, is it **long**,
is it **independent of the document tree**. If all three conditions
hold at once, a worker pays off — parsing a large measurement file,
image processing, compression, building a search index. If one
condition fails, the previous lesson's splitting method fits better.

The data transfer itself also enters the test. If the data to get
cloned is more expensive than the computation, the gain disappears;
in that case, sending the raw data to the worker once and then
working with only small messages afterward, or using a transferable
object, is needed.

#### Summary

- A worker thread has its own memory and event loop; it cannot
  access the document tree and speaks with the main thread only
  through message passing.
- The main thread does not wait after constructing the worker; the
  callback runs once the result arrives.
- In message passing, values get carried by structured cloning; the
  types and circular references JSON loses get preserved, and values
  like functions and symbols produce an error.
- Transferable objects do not get cloned, ownership gets handed
  over, and they become unusable on the sending side.
- A dedicated worker serves a document, a shared worker serves
  documents of the same origin; a service worker forms a separate
  type for network mediation.
- A worker pays off when the work is pure computation, long, and
  independent of the document tree; setup and cloning cost swallows
  the gain when these conditions are not met.

#### Next Step

Throughout this topic, behavior got added to the page: events got
delegated, default behaviors got intervened on, state got stored and
written to the address, measurements got observed, computation got
taken out of the main thread. Every capability is usable on its own;
but all of them still sit scattered across the page's general code.
A measurement row's own structure, its own style, and its own
behavior are not together.

The next step in distributing the load is the interface piece
pulling itself together: an element defining its own tag, its own
lifecycle, and its own behavior in a form the browser recognizes.
The next topic begins with this packaging; its first lesson, Custom
Elements, takes up how an element with its own name gets introduced
to the browser, and which hooks get called as it enters and leaves
the tree.

## Web Components and Offline

Custom elements' lifecycle, shadow tree encapsulation, templates and slots, intervening in requests, and the cache freshness trade-off.

### Custom Elements

Source: https://academia.sh/en/courses/browser-platform/custom-elements

Introducing a new tag name into the browser's element registry; the naming rule, the order of lifecycle callbacks, upgrading an element that entered the tree before its definition, and attribute-property reflection.

The previous lesson spread the load on the main thread out to worker threads: computation in
one place, interface in another. Once the load is spread out, what remains is the interface's
own problem. The measurement badge on the station page — the small piece that shows a number
with its value, unit, and threshold color — repeats five times on the page, fifteen times on
the dashboard. Each repeat writes the same markup by hand, gives the same classes by hand,
wires up the same listeners by hand.

The way to gather this repetition into one place is to introduce a new tag name to the
browser: an element whose structure, behavior, and lifecycle are defined within itself and
written in the document with a single line. The mechanism built in this lesson is called a
**custom element**.

#### The Naming Rule and the Element Registry

The browser keeps tag names in a table: `p`, `section`, `input`, and the rest. A new name is
added to this table from a program; a class is given for the name, and the browser associates
every element with that name with an instance of that class. This table is called the
**custom element registry**.

The added name has one formatting rule: it must **contain at least one hyphen** and start with
a lowercase letter. `measurement-badge` is valid, `badge` is not. The reasoning behind the rule
is not restriction but protection. A tag name added to HTML later never contains a hyphen; the
hyphenated name space is thereby reserved for the document author. Without this separation, a
`badge` element defined today would collide with a `badge` tag that enters the standard later,
and the page would silently start doing something else.

A name can be defined only once. Trying to define the same name a second time produces an
error; this is a check that catches two separate versions of the same component entering the
same page early.

There are two kinds of custom elements. The first is an autonomous element that extends the
base element class and is written with its own tag name. The second extends an existing tag
and inherits that tag's behavior; it is written in the document with the `is` attribute.
Support for the second kind is conditional and tested with feature detection; this lesson is
built on the first kind.

#### Lifecycle Callbacks

A custom element's class is called by the browser at four points. These are called
**lifecycle callbacks**.

The **constructor** runs when the element object is first created. The **connected callback**
runs when the element enters a tree connected to the document. The **disconnected callback**
runs when the element is removed from that tree. The **attribute changed callback** runs when
the value of a previously declared attribute changes.

The last callback has one condition: the class must declare, with a static list, which
attributes to watch. A change to an attribute not on the list produces no call. This is a
deliberate limit that keeps every attribute write from triggering a callback.

The order differs from a hand-written setup routine and cannot be learned by guessing. The
model below makes the call order visible.

```js
// lifecycle.mjs — a small model of custom element lifecycle and upgrade
const calls = [];

const doc = { name: "#document", attrs: {}, children: [], parent: null, instance: null };
const isConnected = (n) => { let cur = n; while (cur.parent) cur = cur.parent; return cur === doc; };
function* allNodes(root) { yield root; for (const c of root.children) yield* allNodes(c); }

const registry = new Map();

// Upgrade order is fixed: constructor -> observed attributes -> connectedCallback if connected.
function upgrade(node) {
  const Class = registry.get(node.name);
  if (!Class || node.instance) return;
  node.instance = new Class(node);
  for (const name of Class.observedAttributes ?? [])
    if (name in node.attrs) node.instance.attributeChangedCallback(name, null, node.attrs[name]);
  if (isConnected(node)) node.instance.connectedCallback();
}

// When an element is created, if a definition exists upgrade happens immediately; otherwise it stays undefined.
function element(name, attrs = {}) {
  const node = { name, attrs, children: [], parent: null, instance: null };
  upgrade(node);
  return node;
}

function define(name, Class) {
  if (!name.includes("-")) throw new Error(`invalid name: ${name}`);
  if (registry.has(name)) throw new Error(`already defined: ${name}`);
  registry.set(name, Class);
  for (const n of allNodes(doc)) if (n.name === name) upgrade(n); // in document order
}

function remove(node) {
  const siblings = node.parent.children;
  const wasConnected = isConnected(node);
  siblings.splice(siblings.indexOf(node), 1);
  node.parent = null;
  if (wasConnected) node.instance?.disconnectedCallback();
}

function append(parent, child) {
  if (child.parent) remove(child);
  child.parent = parent;
  parent.children.push(child);
  if (!child.instance) upgrade(child);
  else if (isConnected(child)) child.instance.connectedCallback();
}

function setAttribute(node, name, value) {
  const oldValue = node.attrs[name] ?? null;
  node.attrs[name] = value;
  const observed = registry.get(node.name)?.observedAttributes ?? [];
  if (node.instance && observed.includes(name)) node.instance.attributeChangedCallback(name, oldValue, value);
}

let counter = 0;
class MeasurementBadge {
  static observedAttributes = ["value", "unit"];
  constructor(node) {
    this.label = `badge#${++counter}`;
    calls.push(`${this.label} constructor`);
  }
  attributeChangedCallback(name, oldValue, newValue) {
    calls.push(`${this.label} attributeChangedCallback(${name}, ${oldValue}, ${newValue})`);
  }
  connectedCallback() { calls.push(`${this.label} connectedCallback`); }
  disconnectedCallback() { calls.push(`${this.label} disconnectedCallback`); }
}

// A. Two elements that entered the tree BEFORE the definition (the state a parser produces).
const dashboard = element("section", { id: "dashboard" });
append(doc, dashboard);
append(dashboard, element("measurement-badge", { value: "-4.2", unit: "C" }));
append(dashboard, element("measurement-badge", { value: "72" }));
calls.push("--- define('measurement-badge') ---");
define("measurement-badge", MeasurementBadge);

// B. An element created AFTER the definition.
calls.push("--- element created after definition ---");
const fresh = element("measurement-badge");
setAttribute(fresh, "value", "3.4");
append(dashboard, fresh);

// C. A connected element is moved to another parent.
calls.push("--- element moved to another parent ---");
const sidebar = element("aside", { id: "sidebar" });
append(doc, sidebar);
append(sidebar, fresh);

// D. A name without a hyphen.
calls.push("--- name without a hyphen ---");
try { define("badge", MeasurementBadge); } catch (e) { calls.push(e.message); }

console.log(calls.join("\n"));
```

```
--- define('measurement-badge') ---
badge#1 constructor
badge#1 attributeChangedCallback(value, null, -4.2)
badge#1 attributeChangedCallback(unit, null, C)
badge#1 connectedCallback
badge#2 constructor
badge#2 attributeChangedCallback(value, null, 72)
badge#2 connectedCallback
--- element created after definition ---
badge#3 constructor
badge#3 attributeChangedCallback(value, null, 3.4)
badge#3 connectedCallback
--- element moved to another parent ---
badge#3 disconnectedCallback
badge#3 connectedCallback
--- name without a hyphen ---
invalid name: badge
```

There are four things in the output worth reading.

First, the first seven lines show that **no callback runs until the definition is called**.
The elements were in the tree, their attributes were in place; what was pending was the
definition.

Second, the order is the same for every element: constructor, then one attribute-changed call
for each observed attribute, then connection. Connection always comes last. This is why it is
safe for a component to do its first render in the connected callback: attribute values have
been read by that point.

Third, the second badge produced no call for the `unit` attribute, since it was not written in
the document; no default call is made for a missing attribute. Setting up a default value is
the class's job.

Fourth, for the moved element, **disconnection and connection ran back to back**. Moving
something in the tree is removing it and re-adding it. A binding rule follows from this: the
connected callback can run more than once over an element's lifetime, so it must be written to
tolerate repetition. A connected callback that attaches a listener must, as its counterpart,
remove that listener in the disconnected callback; otherwise a listener accumulates on every
move.

#### What Cannot Be Done in the Constructor

The constructor is the element's earliest stop, and this earliness brings a restriction. At
the moment the constructor runs, the element may not yet be in the tree, its attributes may
not have been read, its children may not have been parsed. The parser builds the instance the
moment it sees a tag; it has not reached the closing tag yet.

Three rules follow from this. The constructor **does not read or write attributes**. The
constructor **does not add or read child nodes**. The constructor **does not access the
document**. The constructor's only job is to set the element's own fields to their initial
values; everything concerning the document is left to the connected callback.

Parsing order has one more side effect: when the connected callback of a custom element in the
middle of the document runs, that element's children may not yet be parsed. A component that
needs to read its children does this either by waiting for parsing to finish or by observing
the child list; it cannot do it by counting at the moment of connection.

#### Upgrade and the Delay of Definition

Associating an element that entered the tree before its definition with the class once the
definition arrives is called **upgrade**. Upgrade does not produce a new object: the object in
the tree stays as it was, it gains the class's behavior.

This means the page's state before the definition is not a bug, it is an intermediate state.
When the script carrying the definition loads deferred, the user briefly sees the document's
raw elements. This intermediate state is managed with style: there is a pseudo-class that
selects undefined elements, and these elements can be hidden or shown in placeholder form until
the definition arrives. The balance to strike is between showing empty space and showing
unstyled content; the right choice for the measurement badge is to show the number in its raw
form and withhold only the threshold color until the definition arrives — readable content is
never hidden.

A waiting point is needed on the program side too: a promise that waits for a name to be
defined and resolves once the definition arrives. Code that accesses an element's properties
before upgrade should await this promise; code that does not await it falls into the next
section's trap.

#### Reflection Between Attribute and Property

The distinction built in the DOM API lesson turns into a design decision here: an attribute is
the text written in the document, a property is the living value on the program side. A
custom element offers both and sets up a **reflection** between them: a value written to the
property is written to the attribute, a value written to the attribute updates the property.

This two-way bond feeds itself if set up carelessly. The property setter writes the attribute,
the attribute-changed callback writes the property, which writes the attribute again. The rule
that breaks the loop is simple: **before writing in either direction, check that the value has
actually changed.** A step rewriting the same value must cut the chain from continuing.

A second trap is sneakier and comes from the delay of upgrade.

```js
// upgrade-trap.mjs — a property assigned before the definition shadows the accessor
// Upgrade means replacing the element's prototype with the class prototype; the element object itself is unchanged.
class Badge {
  get value() { return this.valueField ?? null; }
  set value(v) {
    this.valueField = Number(v);
    this.renderCount = (this.renderCount ?? 0) + 1; // on a real element, this would be a re-render
  }
}

function upgrade(el, { rescue }) {
  Object.setPrototypeOf(el, Badge.prototype);
  if (rescue && Object.hasOwn(el, "value")) {
    const pending = el.value; // the element's own property shadowing the accessor
    delete el.value;          // remove the shadow
    el.value = pending;       // assignment now goes to the accessor
  }
}

console.log("calling code assigns a value to the property before the definition loads:");
for (const rescue of [false, true]) {
  const el = {};    // element whose definition has not loaded yet
  el.value = "5";   // no accessor: this becomes the element's own property
  upgrade(el, { rescue });
  console.log(
    `  rescue ${rescue ? "yes" : "no"} -> value: ${JSON.stringify(el.value)},`,
    `type: ${typeof el.value}, renders: ${el.renderCount ?? 0}`,
  );
}
```

```
calling code assigns a value to the property before the definition loads:
  rescue no -> value: "5", type: string, renders: 0
  rescue yes -> value: 5, type: number, renders: 1
```

An assignment made to the property before the definition loads creates the element's **own
property**. Upgrade changes the prototype, but the accessor on the prototype stays underneath
the element's own property and is never called. In the first line, the value stays a string,
no conversion happens, no render is triggered. The failure is silent: there is no error
message, only a component that fails to work.

The rescue is a single pattern and is applied in the constructor: for every property the class
defines, if the element has its own property of the same name, its value is taken, the
property is deleted, and reassigned. Deleting exposes the accessor on the prototype;
reassigning runs it. In the second line, the value has become a number and a render has
triggered once.

#### Summary

- A custom element is adding a tag name and a class pair to the browser's element registry;
  the name must contain at least one hyphen and be defined only once.
- The hyphen requirement separates the name space to prevent collision with tag names that
  enter the standard later.
- Lifecycle calls proceed in a fixed order: constructor, change calls for observed
  attributes, then connection; disconnection happens on removal from the tree.
- The connected callback can run more than once over an element's lifetime; everything it
  sets up must be undone by the disconnected callback.
- The constructor does not read attributes, add children, or access the document; document
  work belongs to the connected callback.
- Elements present in the tree before the definition get upgraded; a value written to the
  property before the definition shadows the accessor and is rescued in the constructor with
  a delete-and-reassign pattern.

#### Next Step

This lesson gave the element a name and a lifecycle, but left its inside open. The badge's
markup still sits in the document's shared tree: a style rule written anywhere on the page can
leak into the badge, and the badge's own rule can leak out; a selector on the page finds the
badge's internal nodes and touches them. What a reusable piece needs is exactly the opposite:
its inside should not be visible from outside, its outside should not affect the inside. The
next lesson examines the mechanism that opens a separate tree inside the document tree and
builds this two-way isolation.

### Shadow DOM

Source: https://academia.sh/en/courses/browser-platform/shadow-dom

A separate document tree opened inside an element; where the selector stops, style's two-way isolation, inheritance and custom properties crossing the boundary, open versus closed mode, and how an event's target gets retargeted.

The previous lesson gave the element a name and a lifecycle; it left its inside open. The
measurement badge's markup still sits in the document's shared tree. A rule written anywhere
on the page, `.value { font-weight: 700 }`, also catches the value inside the badge; the
badge's own rule leaks into another `.value` element on the page; a selector bound to the
document finds the badge's internal nodes and touches them.

What a reusable piece needs is the opposite. This lesson builds the mechanism that opens a
separate tree inside the document tree and sets up two-way isolation.

#### Shadow Root, Host, and Light Tree

An element can acquire a second tree bound to it. This tree's root is called the **shadow
root**, the tree itself the **shadow tree**, and the element carrying the tree the **host**.

The shadow tree is not part of the document tree; it is a separate tree with its own root. The
host is the single point where the two trees meet: an element in the document tree, a root
owner in the shadow tree.

The host's own children written in the document do not disappear; they stay where they are.
These are called the **light DOM**. What is painted on an element with an attached shadow tree
is not the light tree, it is the shadow tree; how the light tree's content is recovered is the
next lesson's topic.

Attaching a shadow tree to an element is not possible for every tag. Elements with their own
internal structure — form controls, an image, an input field — are closed to this; they
already have an internal structure managed by the browser.

#### Where the Selector Stops

Isolation's first face is the selector. A query made in the document tree does not descend
into the shadow tree; a query made in the shadow tree does not climb out.

```js
// shadow-boundary.mjs — the selector stops at the boundary, inheritance passes through it
const element = (name, attrs = {}, ...children) => {
  const node = { name, attrs, children, parent: null, shadow: null };
  for (const c of children) c.parent = node;
  return node;
};

// Attaches a shadow root to the host.
function attachShadow(host, ...children) {
  const root = { name: "#shadow-root", attrs: {}, children, parent: null, host, shadow: null };
  for (const c of children) c.parent = root;
  host.shadow = root;
  return root;
}

// Walks a tree; does NOT enter shadow trees it encounters.
function* walk(root) {
  yield root;
  for (const c of root.children) yield* walk(c);
}

const hasClass = (n, cls) => (n.attrs.class ?? "").split(/\s+/).includes(cls);
const format = (n) => `${n.name}#${n.attrs.id ?? "-"}`;
const query = (root, cls) => [...walk(root)].filter((n) => hasClass(n, cls)).map(format);

// Station page: two badges; the first has a shadow tree, the second is plain markup.
const badgeA = element("measurement-badge", { class: "badge", id: "temperature" });
attachShadow(badgeA,
  element("div", { class: "box", id: "shadow-box" },
    element("span", { class: "value", id: "shadow-value" }),
    element("span", { class: "unit", id: "shadow-unit" })));

const badgeB = element("p", { class: "badge", id: "humidity" },
  element("span", { class: "value", id: "light-value" }));

const doc = element("#document", { id: "document" }, element("section", { id: "dashboard" }, badgeA, badgeB));

console.log("from the document tree, .badge :", query(doc, "badge"));
console.log("from the document tree, .value :", query(doc, "value"));
console.log("from the shadow tree, .value   :", query(badgeA.shadow, "value"));
console.log("from the shadow tree, .badge   :", query(badgeA.shadow, "badge"));

// Inherited properties pass through the boundary; selector-based rules do not.
const INHERITED = new Set(["color", "font-size", "line-height"]);
const documentRule = { class: "value", declaration: { "font-weight": "700" } };
const fromHost = { color: "#b23", "font-weight": "400" };

function computeStyle(node) {
  const result = Object.fromEntries(
    Object.entries(fromHost).filter(([name]) => INHERITED.has(name)));
  const inDocumentTree = [...walk(doc)].includes(node);
  if (inDocumentTree && hasClass(node, documentRule.class)) Object.assign(result, documentRule.declaration);
  return result;
}

const shadowSpan = [...walk(badgeA.shadow)].find((n) => n.attrs.id === "shadow-value");
const documentSpan = [...walk(doc)].find((n) => n.attrs.id === "light-value");

console.log("computed span#light-value:", computeStyle(documentSpan));
console.log("computed span#shadow-value:", computeStyle(shadowSpan));
```

```
from the document tree, .badge : [ 'measurement-badge#temperature', 'p#humidity' ]
from the document tree, .value : [ 'span#light-value' ]
from the shadow tree, .value   : [ 'span#shadow-value' ]
from the shadow tree, .badge   : []
computed span#light-value: { color: '#b23', 'font-weight': '700' }
computed span#shadow-value: { color: '#b23' }
```

The first four lines show the boundary is symmetric. A query looking for `.value` in the
document finds only one of two matches; `span#shadow-value` in the shadow tree is invisible.
The reverse holds too: a query looking for `.badge` in the shadow tree returns empty even
though the host itself has that class — the host is outside the shadow tree.

This has two practical consequences. First, ids in the shadow tree do not collide with
document ids; every shadow tree has its own id space. Using the same component ten times on
the page produces ten identical ids without any collision. Second, outside code that wants to
touch the inside of a component can only do so by crossing from the host into the shadow
root — that is, by a deliberate and visible step.

#### Style's Two-Way Isolation

Isolation's second face is style, and it follows from the same rule as the selector. The
matching defined in the Cascade and Specificity lesson is a rule applying to elements in its
own tree. A rule written in the document has a selector that does not match elements in the
shadow tree; a rule written in the shadow tree does not match the document tree.

The output's last two lines show this distinction. The document rule's `font-weight: 700` was
applied to `span#light-value` in the document tree, and was not applied to
`span#shadow-value` in the shadow tree.

There are **two defined crossings** on style's side of the boundary, and both are deliberate.

The first is **inheritance**. Inherited properties do not recognize the tree boundary: the
text color, line height, and font size applied to the host carry over into the shadow tree.
This is why both elements in the output got their `color` value from the host. Shadow-tree
style is therefore not a complete reset; the component adapts to typography coming from
outside, and this is usually the wanted behavior.

The second is **custom properties**. Since they are defined as an inherited value, they cross
the boundary and give the component an externally adjustable surface. The component reads a
custom property with a fallback value in its own rules; the page changes the component's
appearance by defining that property on the host, without ever touching its internal
structure. This mechanism, built in the Custom Properties lesson, becomes a component's
**style contract** together with the shadow tree.

Two selectors are defined for rules written from inside the shadow tree. One targets the host itself and
styles the component's outer box; it lets conditional style be given based on the host's own attribute;
looking at the host's context in the document is a separate selector's job. The other targets internal
parts the component exposes outward: the component marks an internal element by giving it a name, and
outside style uses that name to reach only the marked parts. This is not piercing encapsulation, it is
opening a window defined within it — the component itself decides which of its parts are open to being
styled.

#### Open and Closed Mode

One of two modes is chosen while attaching a shadow tree. In **open mode**, the host's shadow
root can be read by a program; outside code can reach into the component's inside. In
**closed mode**, the shadow root cannot be reached through the host; the reference is kept
only in the component's own code.

Closed mode is not a security boundary. The component's code runs in the same environment as
the page; determined code can reach the shadow root by other means. Closed mode is a
**statement of intent**: the component's internal structure is not part of its interface, and
code relying on it will break.

It has a cost too. A closed tree makes the page's accessibility checks, testing tools, and
debugging harder. The common choice is open mode; encapsulation is protected by contract, not
by an access barrier.

#### Events Crossing the Boundary

The Event Model lesson defined the propagation path as the ancestor chain running from root to
target. The shadow tree adds one thing to this chain: the path does not stop at the shadow
root, it jumps to the host and continues up to the document. This is called the **composed
path**.

A problem arises when the path spills outward. If a listener bound to the document sees a node
inside the shadow tree as the event's target, encapsulation has been pierced. This is why the
browser **retargets** the target: every listener sees, as the event's target, the nearest
counterpart in its own tree.

```js
// retargeting.mjs — how the target changes as an event crosses the shadow boundary
const element = (name, ...children) => {
  const node = { name, children, parent: null, host: null, shadow: null };
  for (const c of children) c.parent = node;
  return node;
};

function attachShadow(host, ...children) {
  const root = { name: "#shadow-root", children, parent: null, host, shadow: null };
  for (const c of children) c.parent = root;
  host.shadow = root;
  return root;
}

// The root of the tree a node belongs to: either the document or a shadow root.
const treeRoot = (n) => { let a = n; while (a.parent) a = a.parent; return a; };

// Composed path: starts from the target, jumps to the host at tree roots, climbs to the document.
function composedPath(target) {
  const path = [];
  for (let n = target; n; n = n.parent ?? n.host) path.push(n);
  return path;
}

// Target for a listener: the first node in the composed path that is in the SAME tree as the listener.
function retargetedTarget(target, listener) {
  const listenerTree = treeRoot(listener);
  return composedPath(target).find((n) => treeRoot(n) === listenerTree);
}

const button = element("button.save");
const box = element("div.box", button);
const badge = element("measurement-badge");
const shadowRoot = attachShadow(badge, box);
const dashboard = element("section#dashboard", badge);
const doc = element("#document", dashboard);

console.log("composed path:", composedPath(button).map((n) => n.name).join(" -> "));
console.log();
console.log("listener's node        target it sees");
for (const n of [button, box, shadowRoot, badge, dashboard, doc])
  console.log(n.name.padEnd(22), retargetedTarget(button, n).name);

// An event with composed: false does not climb past the shadow root.
const propagationPath = (target, composed) => {
  const path = composedPath(target);
  if (composed) return path;
  const boundary = path.findIndex((n) => n.name === "#shadow-root");
  return boundary === -1 ? path : path.slice(0, boundary + 1);
};

console.log();
console.log("composed: true  ->", propagationPath(button, true).map((n) => n.name).join(" -> "));
console.log("composed: false ->", propagationPath(button, false).map((n) => n.name).join(" -> "));
```

```
composed path: button.save -> div.box -> #shadow-root -> measurement-badge -> section#dashboard -> #document

listener's node        target it sees
button.save            button.save
div.box                button.save
#shadow-root           button.save
measurement-badge      measurement-badge
section#dashboard      measurement-badge
#document              measurement-badge

composed: true  -> button.save -> div.box -> #shadow-root -> measurement-badge -> section#dashboard -> #document
composed: false -> button.save -> div.box -> #shadow-root
```

The three listeners inside the shadow tree see the real target; the three outside it see the
host. Event-delegation code bound to the document cannot learn which button was clicked inside
the component; it only gets the information "the measurement badge was clicked." The component
itself decides what to announce outward, and it usually does this with an event type it defines
itself.

The last two lines show the second distinction. Whether an event crosses the shadow boundary
at all is set by a field on the event object. An event that does not cross the boundary stops
at the shadow root and never reaches outside. Built-in events arising from user input cross the
boundary; events a component defines itself do not cross by default, and if crossing outward
is wanted, this is declared explicitly.

#### Summary

- A shadow tree is a separate document tree bound to a host element; the host's own children
  in the document stay in place as the light tree.
- The selector does not cross the boundary in either direction: a document query cannot see
  the shadow tree, a shadow query cannot see the document tree. Id spaces are separate too.
- Style matching follows the same boundary as the selector; inherited properties and custom
  properties cross the boundary and form the component's style contract.
- A component opens its internal parts to outside styling by giving them a name;
  encapsulation is preserved outside these windows.
- Closed mode makes access harder but is not a security boundary; what it carries is that the
  internal structure is not part of the interface.
- The event path jumps from the shadow root to the host and continues; every listener sees the
  target as its counterpart in its own tree, and events that do not cross the boundary stop at
  the shadow root.

#### Next Step

This lesson showed how the shadow tree is isolated, but not where its content comes from. Two
questions were left open. First, if the component's internal structure is built node by node
for every instance, fifteen badges run the same setup code fifteen times; markup needs a form
that sits unrendered and can be cheaply duplicated. Second, the host's light tree written in
the document is nowhere visible right now; the component needs to place the content it
receives from outside at a point the shadow tree determines. The next lesson builds these two
mechanisms together — an unrendered markup container, and the points where content gets
placed.

### Templates and Slots

Source: https://academia.sh/en/courses/browser-platform/templates-and-slots

The markup container that stays unrendered and the rules for copying it; the slot assignment algorithm, fallback content, the flattened tree, and which tree placed content still belongs to.

The previous lesson showed how the shadow tree is isolated, not where its content comes from.
Two open questions remained. First, if the component's internal structure is built node by
node for every instance, fifteen badges run the same setup code fifteen times. Second, the
host's light tree written in the document is nowhere visible: the label and unit written
inside the badge disappear.

This lesson builds two mechanisms together: a markup container that stays unrendered, and the
points in the shadow tree where content from the light tree gets placed.

#### An Unrendered Markup Container

There is an element in HTML whose content is parsed but does not enter the document: the
**template**. The parser turns markup inside the template into nodes, but places them not in
the document tree but in a separate document fragment belonging to the template.

The result is that the content is **inert**. An image inside it does not download, a script
inside it does not run, style rules inside it are not applied, its elements take no part in
layout computation. The markup sits written on the page but takes part in none of the page's
behavior.

This is the right container for a component's internal structure. The markup sits readably in
the document; when needed, the component copies the container's content into the shadow tree.
The content is not reparsed from text every time — parsing happened once, everything after is
tree copying.

Reaching a template's content by looking at the element's children gives no result; the
content sits in a separate fragment and is read through it. This surprises on first
encounter: a template element's child node count is zero.

#### Cloning and What a Clone Does Not Carry

The copy has to be deep: an operation copying not just the root but the entire subtree. A
shallow copy gives the template's root, not its content.

What the copy does not carry is more instructive than what it does.

**Listeners are not copied.** Even if a listener is attached to a node in the template, the
copy does not get it; a listener is attached after the copied tree has been placed. This is not
a gap but a necessity: a listener is a function reference and is not part of markup.

**Ids are copied.** If an id is written in the template, the same id results in every copy. In
the document tree, this breaks id uniqueness. In the shadow tree, no problem arises: as seen
in the previous lesson, every shadow tree has its own id space. This is why using ids in
templates is tied to the shadow tree.

**Attributes are copied, user state is not.** The distinction from the DOM API lesson applies
here too: the copy gets the initial state the attributes declare; a value the user changed on
the source element does not carry over to the copy.

Copying produces nodes in a fragment not bound to the document. The rule built in the DOM API
lesson works here on its own: no layout computation is triggered while the copy is being
prepared; the computation happens only once, when the fragment is added to the shadow tree.

#### Slots and the Assignment Rule

The template gives the component's **own** structure. Content the component receives from
outside comes from somewhere else: the host's light tree. The shadow tree marks the points
where this content gets placed with **slot** elements.

The rule comes down to three points. Only the host's **direct children** can be assigned;
grandchildren are not. A child carrying a `slot` attribute goes to the same-named slot, one
without it goes to the unnamed default slot. A child matching no slot's name is assigned
nowhere and is not rendered.

```js
// slot-assignment.mjs — the slot assignment algorithm and the flattened tree
const element = (name, attrs = {}, ...children) => ({ name, attrs, children, text: null });
const text = (value) => ({ name: "#text", attrs: {}, children: [], text: value });
const slot = (name, ...fallback) => element("slot", name ? { name } : {}, ...fallback);

function* walk(root) { yield root; for (const c of root.children) yield* walk(c); }

const label = (n) =>
  n.text !== null ? `"${n.text}"`
  : n.name === "slot" ? `slot[${n.attrs.name ?? "default"}]`
  : n.name + (n.attrs.slot ? `[slot=${n.attrs.slot}]` : "");

// Assignment: only the host's DIRECT children are assigned; those without a slot attribute
// go to the default slot, those with one go to the same-named slot. Order is the light tree's order.
function assignments(host, shadowRoot) {
  const map = new Map();
  for (const n of walk(shadowRoot)) if (n.name === "slot") map.set(n.attrs.name ?? "", []);
  for (const child of host.children) {
    const wanted = child.attrs.slot ?? "";
    if (map.has(wanted)) map.get(wanted).push(child);
  }
  return map;
}

// Flattened tree: each slot is replaced by its assigned nodes, or by fallback content if unassigned.
function flatten(node, map, depth = 0, lines = []) {
  if (node.name === "slot") {
    const assigned = map.get(node.attrs.name ?? "") ?? [];
    const visible = assigned.length ? assigned : node.children;
    const source = assigned.length ? "light" : "fallback";
    for (const item of visible) {
      lines.push(`${"  ".repeat(depth)}${label(item)}   (${source})`);
      for (const c of item.children) flatten(c, map, depth + 1, lines);
    }
    return lines;
  }
  lines.push(`${"  ".repeat(depth)}${label(node)}`);
  for (const c of node.children) flatten(c, map, depth + 1, lines);
  return lines;
}

// The host's children as written in the document (the light tree)
const host = element("measurement-badge", { id: "temperature" },
  element("span", { slot: "label" }, text("Temperature")),
  element("strong", {}, text("-4.2")),
  element("span", { slot: "unit" }, text("°C")),
  element("span", { slot: "source" }, text("station-3")));

// Shadow tree
const shadowRoot = element("#shadow-root", {},
  element("div", { class: "box" },
    slot("label", text("Measurement")),
    element("output", {}, slot(null, text("—"))),
    slot("unit", text("unitless"))));

const map = assignments(host, shadowRoot);
console.log("assignments:");
for (const [name, nodes] of map)
  console.log(`  slot[${name || "default"}] <-`, nodes.map(label).join(", ") || "(empty -> fallback content)");

const unassigned = host.children.filter((c) => !map.has(c.attrs.slot ?? ""));
console.log("unassigned light nodes:", unassigned.map(label).join(", ") || "(none)");

console.log("\nflattened tree:");
console.log(flatten(shadowRoot, map).join("\n"));

// A light tree that is an empty host, with the same shadow tree: every slot shows its fallback content.
const emptyHost = element("measurement-badge", { id: "humidity" });
console.log("\nlight tree, empty host:");
console.log(flatten(shadowRoot, assignments(emptyHost, shadowRoot)).join("\n"));

// If the light tree changes, assignment is recomputed.
host.children.push(element("em", { slot: "unit" }, text("°F")));
const updated = assignments(host, shadowRoot);
console.log("\nafter adding em[slot=unit] to the light tree:");
for (const [name, nodes] of updated) {
  const before = map.get(name).map(label).join(",");
  const now = nodes.map(label).join(",");
  console.log(`  slot[${name || "default"}] ${before === now ? "unchanged" : "changed -> " + (now || "(empty)")}`);
}
```

```
assignments:
  slot[label] <- span[slot=label]
  slot[default] <- strong
  slot[unit] <- span[slot=unit]
unassigned light nodes: span[slot=source]

flattened tree:
#shadow-root
  div
    span[slot=label]   (light)
      "Temperature"
    output
      strong   (light)
        "-4.2"
    span[slot=unit]   (light)
      "°C"

light tree, empty host:
#shadow-root
  div
    "Measurement"   (fallback)
    output
      "—"   (fallback)
    "unitless"   (fallback)

after adding em[slot=unit] to the light tree:
  slot[label] unchanged
  slot[default] unchanged
  slot[unit] changed -> span[slot=unit],em[slot=unit]
```

The line that stands out in the output's first section is the last one: the child carrying
`slot="source"` matched no slot and so was not assigned, and therefore not rendered. It has not
been deleted from the tree, it still sits in the document; it just never entered the flattened
tree. This means a component user's spelling mistakes disappear silently, and it shows the
component's dependency on documented slot names.

#### The Flattened Tree

What the browser actually paints is neither the document tree nor the shadow tree; it is the
shadow tree with every slot replaced by the nodes assigned to it. This is called the
**flattened tree**, and layout and paint are done over this tree.

The output's second section shows this. `slot` elements do not appear in the flattened tree;
nodes from the light tree sit in their place. The `strong` element is a direct child of the
host in the document tree, but sits inside the `output` element when painted. The node's place
in the tree and its place in painting have come apart.

The third section shows the role of **fallback content**. A slot's own children are painted
when nothing is assigned to it. This gives the component a default appearance: an empty badge
in the light tree does not look unit-less and value-less, it shows its placeholders instead.
Fallback content is entirely disabled the moment an assignment exists; the two never mix.

The last section shows that a light-tree change recomputes assignment. Slot elements emit an
event when their assignments change; a component whose computation depends on its content
listens for this event. The Timing and the Rendering Loop lesson's model gives this event's
place in the rendering loop: it is announced after the code that made the change.

#### Who Owns Placed Content

Placement does not change a node's tree. The `strong` element assigned to a slot stays in the
document tree; it is only moved to a point in the shadow tree while being painted. Because
ownership does not change, three behaviors follow from this.

**Style comes from the document.** A placed node is styled by the document tree's rule; shadow
tree rules do not generally match it. The component can give placed nodes a limited style: a
special selector targets nodes assigned to a slot, matching only the assigned node
itself — not its children. This limit is deliberate: the component cannot style the internal
structure of content its user gave it, only align its outer box. On conflict the document's
rule wins; the component's style for placed content is only a suggestion.

**Events do not get retargeted.** A placed node's ancestors are in the document tree; an
event's target there is not retargeted, because the event never crossed a shadow boundary. The
component cannot and should not see listeners bound to content in the light tree.

**The accessibility tree derives from the flattened tree.** The structure presented to
assistive technology is the order the user sees. Slots' order in the shadow tree determines
reading order, not the write order in the light tree. When designing a component, slot order
is not a visual preference, it is a decision that carries meaning.

#### Summary

- A template element's content is parsed but does not enter the document; it is inert, loads
  no resource, and takes no part in painting.
- Template content is duplicated by deep cloning; the copy carries attributes and ids, not
  listeners or user state.
- The slot assignment rule applies only to the host's direct children; a name matching nothing
  is assigned nowhere and is not rendered.
- The rendered structure is the flattened tree: every slot in the shadow tree replaced by its
  assigned nodes, or by fallback content if there is no assignment.
- A placed node still belongs to the light tree: it is styled by the document's style, its
  events do not cross the shadow boundary, and what style the component can give it is
  limited.

#### Next Step

Everything built up to here holds once the page has loaded: the element gets defined, the
shadow tree gets attached, slots get filled. Behind all of it sits a single assumption — the
component's code, template, and data came from the network. When the network connection
weakens or drops entirely, this assumption fails, and the page, however well encapsulated,
lands on a blank error screen. Where the station page is used does not favor this: the person
taking the measurement is usually at the edge of coverage. The next lesson builds a layer that
can meet the page's requests before they reach the network, and that lives independently of the
page.

### Service Workers

Source: https://academia.sh/en/courses/browser-platform/service-workers

A layer that lives independent of the page and can intervene to answer requests; the scope rule, the install-wait-activate cycle, the request-handling contract, and how a bad deployment is rolled back.

Everything built up to here holds once the page has loaded. Behind all of it sits a single
assumption: the component's code, template, and data came from the network. When the
connection weakens, this assumption fails, and the page, however well encapsulated, lands on
an error screen. Where the North Slope Measurement Station page is used does not favor this:
the person taking the measurement is usually at the edge of coverage.

This lesson builds a layer that can see the page's requests before they reach the network and
lives independently of the page: the **service worker**.

#### A Context Independent of the Page

A service worker is a kind of the worker thread introduced in the Web Workers lesson, and it
carries all of that thread's restrictions: no access to the document tree, cannot see the
page's variables, talks to the page only by passing messages. It adds its own restrictions on
top.

**It is not bound to a page.** The registration stays in place even if the registering page
closes; when another page of the same origin opens, the same worker takes over. This makes it
not a page component but a service belonging to the origin.

**It does not run continuously.** The browser wakes the worker when an event arrives and
terminates it once the work is done. The time in between is not guaranteed. A binding rule
follows from this: **variables in the worker's global scope cannot hold persistent state.**
They can be lost between two events. Anything that needs to persist is written to the storage
layer.

**It runs only in a secure context.** Registration requires an **origin** served over HTTPS;
the loopback address is kept as an exception for development convenience. The reason is
direct: a layer that intervenes between the page and the network can permanently alter the
entire page once it falls into the hands of someone listening on the network. The integrity
guarantee from the Role of HTTPS lesson is a precondition for this authority.

#### Scope and Control

A registration governs not the whole origin but a path prefix. This prefix is called
**scope**, and two separate rules determine it.

The first is the declaration's validity: a worker cannot declare a scope wider than the
directory its script sits in. A script under `/assets/js/` cannot govern the whole origin.
This limit can be lifted, but only by the server granting the permission with a header — that
is, by the decision of whoever configures the server, not whoever uploads the file.

The second is mapping: a page is controlled by the registration whose scope is a prefix of
that page's path and is the **longest** among those that match. This is the same logic as the
longest-prefix matching in the IP Address Concept lesson.

```js
// scope.mjs — matching registration scope with the longest-prefix rule
// Scope cannot be wider than the directory the script sits in; widening it needs a
// permission header from the server. The two functions below test these rules separately.

const directory = (path) => path.slice(0, path.lastIndexOf("/") + 1);

function isScopeValid(scriptPath, requestedScope, allowedParent = null) {
  const widest = allowedParent ?? directory(scriptPath);
  return requestedScope.startsWith(widest);
}

// Which registration controls a page: the one whose scope is a prefix of the page's
// path and is the LONGEST among those that match.
function controllingRegistration(registrations, pagePath) {
  const matching = registrations.filter((r) => pagePath.startsWith(r.scope));
  if (matching.length === 0) return null;
  return matching.reduce((a, b) => (b.scope.length > a.scope.length ? b : a));
}

const trials = [
  { script: "/sw.js", scope: "/" },
  { script: "/station/sw.js", scope: "/station/" },
  { script: "/station/sw.js", scope: "/" },
  { script: "/station/sw.js", scope: "/", permission: "/" },
  { script: "/assets/js/sw.js", scope: "/station/" },
];

console.log("is the scope declaration valid?");
for (const t of trials) {
  const result = isScopeValid(t.script, t.scope, t.permission);
  console.log(
    `  script ${t.script.padEnd(20)} scope ${t.scope.padEnd(12)}` +
    `${t.permission ? "permission " + t.permission : "no permission"} -> ${result ? "valid" : "rejected"}`);
}

const registrations = [
  { name: "root", scope: "/" },
  { name: "station", scope: "/station/" },
  { name: "north-slope", scope: "/station/north-slope/" },
];

console.log("\nwhich registration controls the page?");
for (const page of [
  "/",
  "/about",
  "/station/",
  "/station/log",
  "/station/north-slope/measurements",
]) console.log(`  ${page.padEnd(36)} -> ${controllingRegistration(registrations, page)?.name ?? "(uncontrolled)"}`);
```

```
is the scope declaration valid?
  script /sw.js               scope /           no permission -> valid
  script /station/sw.js       scope /station/   no permission -> valid
  script /station/sw.js       scope /           no permission -> rejected
  script /station/sw.js       scope /           permission / -> valid
  script /assets/js/sw.js     scope /station/   no permission -> rejected

which registration controls the page?
  /                                    -> root
  /about                               -> root
  /station/                            -> station
  /station/log                         -> station
  /station/north-slope/measurements    -> north-slope
```

The difference between the third and fourth lines shows that script placement is a deployment
decision. Putting a service worker script in the assets directory looks natural for gathering a
bundler's output in one place, and it locks the scope to that directory. The script sitting at
the root is not a technical requirement, it is where the widest scope declaration comes free.

One consequence of scope mapping is that a page can stay uncontrolled: a page outside every
scope is seen by no worker and goes straight to the network. Out-of-scope pages have no
offline behavior.

#### Lifecycle and the Waiting State

A service worker does not start work the moment it is registered. The states in between exist
to keep two different versions from mixing on the same page.

**Installing** is the new version's first chance: it gathers the resources it will need ahead
of time. If installation fails, the version never activates and the old version stays in
place — this is the first safety lock keeping a half-finished deployment from breaking the
page.

**Waiting** is the finished version waiting until the pages the old version controls close.
The reasoning is that a page talks to a single version throughout its life: if a page was
loaded against the old version's cache and its requests get answered by the new version, that
produces version mixing.

**Activating** is the right place to clean up what the old version left behind; at that point
the old version controls no page.

```js
// worker-lifecycle.mjs — model of the installing, waiting, and activating states
const trace = [];

function registration() {
  return { active: null, waiting: null, installing: null, controlled: new Set() };
}

// A new version is registered: it installs, then either waits or activates directly.
function register(reg, version, { skipWaiting = false } = {}) {
  reg.installing = version;
  trace.push(`${version}: installing`);
  reg.installing = null;
  const hasControlled = reg.controlled.size > 0;
  if (reg.active && hasControlled && !skipWaiting) {
    reg.waiting = version;
    trace.push(`${version}: installed -> waiting (${reg.active} still controls ${reg.controlled.size} page(s))`);
  } else {
    activate(reg, version);
  }
}

function activate(reg, version) {
  if (reg.active) trace.push(`${reg.active}: now obsolete`);
  reg.active = version;
  reg.waiting = null;
  trace.push(`${version}: activating -> active`);
}

// Control is only established at navigation time; the claim call does not wait for it.
function openPage(reg, page) {
  reg.controlled.add(page);
  trace.push(`${page} opened -> controller: ${reg.active ?? "(none)"}`);
}

function closePage(reg, page) {
  reg.controlled.delete(page);
  trace.push(`${page} closed -> controlled pages: ${reg.controlled.size}`);
  if (reg.waiting && reg.controlled.size === 0) activate(reg, reg.waiting);
}

console.log("A. Waiting state: the old version waits until pages let go");
const a = registration();
register(a, "v1");
openPage(a, "measurements");
register(a, "v2");
openPage(a, "log");
closePage(a, "measurements");
closePage(a, "log");
console.log(trace.map((s) => "  " + s).join("\n"));

trace.length = 0;
console.log("\nB. Skipping the wait: the new version takes over open pages");
const b = registration();
register(b, "v1");
openPage(b, "measurements");
register(b, "v2", { skipWaiting: true });
console.log(trace.map((s) => "  " + s).join("\n"));
console.log(`  -> the 'measurements' page loaded with v1, its requests are now served by ${b.active}`);
```

```
A. Waiting state: the old version waits until pages let go
  v1: installing
  v1: activating -> active
  measurements opened -> controller: v1
  v2: installing
  v2: installed -> waiting (v1 still controls 1 page(s))
  log opened -> controller: v1
  measurements closed -> controlled pages: 1
  log closed -> controlled pages: 0
  v1: now obsolete
  v2: activating -> active

B. Skipping the wait: the new version takes over open pages
  v1: installing
  v1: activating -> active
  measurements opened -> controller: v1
  v2: installing
  v1: now obsolete
  v2: activating -> active
  -> the 'measurements' page loaded with v1, its requests are now served by v2
```

The first section's sixth line is a commonly missed behavior: a new page opened while `v2`
waits is still controlled by `v1`. What ends the waiting state is not reloading a tab, it is
**every** page belonging to the origin closing. Reloading a tab usually does not end the wait,
because it opens the new page before closing the old one.

The second section shows what skipping the wait does and what it risks. The new version
activates immediately and takes over open pages. The gain is that deployment spreads instantly;
the cost is that a page loaded under the old version has its requests answered by the new one.
This produces a break when the page's code and the worker's expectations diverge. Skipping the
wait is safe in deployments where cross-version compatibility is known to hold; it is not a
default habit applied to every deployment.

Updating the registration is a separate check: the browser re-requests the worker script at
intervals, and if the content differs from the previous one **at the byte level**, it counts as
a new version. This is why the worker script itself must not be cached long-lived; otherwise
the update check reads the old copy and the deployment is never seen.

#### Answering Requests by Intervening

An active worker receives an event for every network request its controlled pages produce:
page navigations, scripts, stylesheets, images, data requests. The event object carries the
request; the worker does one of two things.

**If it does not respond**, the request continues on its usual path and goes to the network.
This means doing nothing is the safe default.

**If it responds**, the network may never be used at all. The response is declared as a
promise; the request waits until the promise resolves. The contract has a strict condition:
the decision to respond must be announced **synchronously** while the event callback is
running. If the decision is made after awaiting a promise, it comes too late; the browser has
already sent the request to the network in the meantime. Producing the response can be
asynchronous, announcing that a response will be given cannot.

Two details are commonly missed. First, the response body is **read once**; if it needs to be
both written to a cache and given to the page, it must be cloned. Second, a response coming
from another origin whose headers are closed to sharing is **opaque**: its status code cannot
be read, its content cannot be inspected, its size is unknown. Since whether such a response
succeeded cannot be determined, caching it can make an error page permanent.

There are requests the worker never sees too: requests from pages outside its scope, requests
arising from pages of another origin, and the update request for the worker's own script.

#### The Irreversibility of Deployment

A service worker is a proxy deployed not to the server but to the user's device. A broken
version does not go away by being deleted from the server; it stays active on the user's
device and pins the page to the version it knows. A fix on the server does not reach the user
until the worker performs its update check and accepts the new script.

The counterpart to this is two habits. First, short-lived caching of the worker script — so
the update check genuinely reaches the network. Second, keeping a **minimal version** ready
that clears the registration and empties the caches: the only way to retire a worker whose
behavior is uncertain is to deploy a worker that removes itself in its place.

#### Summary

- A service worker is bound to the origin, not the page; it can be terminated between events
  and cannot hold persistent state in its global scope.
- Scope is set by two rules: it cannot be wider than the script's directory, and a page is
  controlled by whichever matching registration has the longest scope.
- If installation fails, the version never activates; the waiting state lets a page talk to a
  single version throughout its life, and lasts until every page of the origin closes.
- Skipping the wait spreads deployment instantly, but pairs an old page with the new version;
  it is not done without knowing version compatibility.
- The decision to answer a request must be announced synchronously; the response body is read
  once, and an opaque response's success cannot be checked.
- The worker is a proxy deployed to the user's device; the way to roll back a broken version is
  to deploy a version that removes itself.

#### Next Step

This lesson built the authority to intervene but left open what to do with it. There is no
single correct way to answer a request: the station's logo and the measurement list cannot be
served by the same rule. One never changes and asking the network is unnecessary; the other
changes every minute and an old copy is misleading. The choice between them is a deliberate
trade-off between speed and freshness, made separately for each kind of resource. The next
lesson takes up this decision together with how a cache key is produced and when a copy counts
as fresh.

### Caching Strategies

Source: https://academia.sh/en/courses/browser-platform/caching-strategies

The division of labor between the browser's two cache layers, the freshness decision given with headers and conditional requests, cache key production, the result four strategies give in the same states, and versioning with cleanup.

The previous lesson built the authority to intervene and left open what to do with it. There
is no single correct way to answer a request: the station's logo and the measurement list
cannot be served by the same rule. One never changes and asking the network is unnecessary; the
other changes every minute and an old copy is misleading.

This lesson reduces that per-resource decision to two questions: where is a copy found, and
when is it counted as fresh.

#### Two Separate Cache Layers

There are two separate caches in the browser, and confusing them leads to wrong diagnoses.

The **HTTP cache** is the layer the browser manages itself, invisible to a program. What gets
stored and for how long is decided by headers the server sends; page code has no say. There is
no program-side way to delete an entry or read its content.

The **program-controlled cache** is a name-record store the service worker explicitly writes
to and deletes from. What gets stored, how long it is kept, and when it is served is decided
by the code that writes to it; headers do nothing on their own in this layer.

The two sit in a chain. When the service worker goes out to the network, the request passes
through the HTTP cache. This produces double caching: the same resource can be stored in both
layers, and the copy written to the program layer can be the HTTP layer's stale copy. The way
to avoid this is to set up the request to bypass the HTTP layer when caching critical
resources.

#### Where the Freshness Decision Is Made

The HTTP layer's decision rests on three headers: storage permission, lifetime, and a
validator. These can be read in a response's headers.

```js
// server.mjs — a local server serving three resources with three separate freshness policies
import { createServer } from "node:http";

const RESOURCES = {
  "/logo.svg": {
    type: "image/svg+xml",
    etag: '"logo-3f2a"',
    control: "public, max-age=31536000, immutable",
    body: '<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"></svg>',
  },
  "/measurements.json": {
    type: "application/json",
    etag: '"measurement-7"',
    control: "no-cache",
    body: '{"temperature":-4.2,"humidity":72}',
  },
  "/session": {
    type: "text/plain; charset=utf-8",
    etag: null,
    control: "no-store",
    body: "personal content",
  },
};

createServer((request, response) => {
  response.sendDate = false; // to keep the output deterministic: no Date header is written
  const resource = RESOURCES[request.url];
  if (!resource) { response.writeHead(404).end(); return; }

  response.setHeader("Cache-Control", resource.control);
  if (resource.etag) response.setHeader("ETag", resource.etag);

  // Conditional request: if the client's etag matches the server's, no body is sent.
  if (resource.etag && request.headers["if-none-match"] === resource.etag) {
    response.writeHead(304).end();
    return;
  }
  response.setHeader("Content-Type", resource.type);
  response.setHeader("Content-Length", Buffer.byteLength(resource.body));
  response.writeHead(200).end(resource.body);
}).listen(8137, "127.0.0.1", () => console.log("listening: 127.0.0.1:8137"));
```

The script below is run in the same directory as this file. Port 8137 is chosen arbitrarily
and must be free; if it is in use, change it in both files.

```bash
#!/usr/bin/env bash
# Starts server.mjs, reads the three resources' response headers, then stops it.
node server.mjs > /dev/null &
server=$!
sleep 1

echo "--- /logo.svg (versioned asset) ---"
curl -sS -D - -o /dev/null http://127.0.0.1:8137/logo.svg

echo "--- /measurements.json (revalidated on every use) ---"
curl -sS -D - -o /dev/null http://127.0.0.1:8137/measurements.json

echo '--- /measurements.json + If-None-Match: "measurement-7" ---'
curl -sS -D - -o /dev/null -H 'If-None-Match: "measurement-7"' http://127.0.0.1:8137/measurements.json

echo "--- /session (never stored) ---"
curl -sS -D - -o /dev/null http://127.0.0.1:8137/session

kill "$server"
```

```
--- /logo.svg (versioned asset) ---
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
ETag: "logo-3f2a"
Content-Type: image/svg+xml
Content-Length: 67
Connection: keep-alive
Keep-Alive: timeout=5

--- /measurements.json (revalidated on every use) ---
HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "measurement-7"
Content-Type: application/json
Content-Length: 34
Connection: keep-alive
Keep-Alive: timeout=5

--- /measurements.json + If-None-Match: "measurement-7" ---
HTTP/1.1 304 Not Modified
Cache-Control: no-cache
ETag: "measurement-7"
Connection: keep-alive
Keep-Alive: timeout=5

--- /session (never stored) ---
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: text/plain; charset=utf-8
Content-Length: 16
Connection: keep-alive
Keep-Alive: timeout=5
```

Two headers concern the connection and have nothing to do with freshness; the `Date` header is
deliberately turned off so the output is the same on every run, and would be present on a real
server.

The three policies represent three separate resource classes. An asset with a versioned name
gets a long lifetime and declares its content will never change; for such a resource even a
validation request is unnecessary. Frequently changing data can be stored but **must be
validated on every use** — the header's name is misleading; it forbids not storage but use
without validation. Personal content is stored in neither layer.

The third request's response shows the gain of validation: the client sends the validator it
has, the server announces with a bodyless response that the content has not changed. A network
round trip has been made but no body was transferred. This does not remove latency, it saves
bandwidth; on a dropped connection, validation cannot happen either, so it does not by itself
provide offline behavior.

#### The Cache Key

In the program-controlled cache, an entry's key is a request, and by default the mapping is
done over the **entire URL**. This has three consequences.

First, the query string is part of the key. `/measurements?type=temperature` and
`/measurements?type=humidity` are two separate entries. If there are query parameters known to
point to the same resource, ignoring the query in the mapping can be wanted; this keeps the
same page from being stored dozens of times because of tracking parameters.

Second, requests to the same URL with different methods can be distinguished; by default the
cache is suited to storing only safe methods. Storing the response of a request with a body
under the URL key means different bodies fall into the same entry.

Third, the server can announce which request header a response varies by. For an endpoint that
gives a different response based on language preference or accepted content format, if this
announcement is not made, the response one user got is served to another. Whether this
announcement is taken into account during mapping can also be chosen.

A worker that takes key production into its own hands normalizes the URL before writing it:
drops tracking parameters, orders the remaining parameters, strips the fragment. Having the
same content land under a single key improves both space and hit rate.

#### Four Strategies and the Promise They Make

There are four basic ways to answer a request. The difference between them becomes visible
through what they serve in the same three states and when they use the network.

```js
// strategy.mjs — the result four caching strategies give in the same three states
const PATH = "/station/measurements.json";

function setup({ cached, online }) {
  const state = { networkCalls: 0, cache: new Map() };
  if (cached) state.cache.set(PATH, "v1");
  state.network = async () => {
    state.networkCalls += 1;
    if (!online) throw new Error("no network");
    return "v2";
  };
  return state;
}

async function cacheFirst(s) {
  const cached = s.cache.get(PATH);
  if (cached) return cached;
  const response = await s.network();
  s.cache.set(PATH, response);
  return response;
}

async function networkFirst(s) {
  try {
    const response = await s.network();
    s.cache.set(PATH, response);
    return response;
  } catch {
    return s.cache.get(PATH) ?? "(fallback page)";
  }
}

async function staleWhileRevalidate(s) {
  const cached = s.cache.get(PATH);
  const refresh = s.network().then((response) => s.cache.set(PATH, response)).catch(() => {});
  if (cached) return cached;          // the old copy is served without waiting
  await refresh;
  return s.cache.get(PATH) ?? "(fallback page)";
}

async function networkOnly(s) {
  try { return await s.network(); } catch { return "(fallback page)"; }
}

const strategies = [
  ["cache-first", cacheFirst],
  ["network-first", networkFirst],
  ["stale-while-revalidate", staleWhileRevalidate],
  ["network-only", networkOnly],
];

const states = [
  ["cache empty, online", { cached: false, online: true }],
  ["v1 cached, online", { cached: true, online: true }],
  ["v1 cached, offline", { cached: true, online: false }],
];

console.log("strategy".padEnd(26) + "state".padEnd(28) + "served".padEnd(16) + "net  left in cache");
for (const [strategyName, run] of strategies) {
  for (const [stateName, options] of states) {
    const s = setup(options);
    const served = await run(s);
    await new Promise((r) => setImmediate(r)); // let the background refresh finish
    console.log(
      strategyName.padEnd(26) + stateName.padEnd(28) +
      String(served).padEnd(16) + String(s.networkCalls).padEnd(4) +
      (s.cache.get(PATH) ?? "(empty)"));
  }
}
```

```
strategy                  state                       served          net  left in cache
cache-first               cache empty, online         v2              1   v2
cache-first               v1 cached, online           v1              0   v1
cache-first               v1 cached, offline          v1              0   v1
network-first             cache empty, online         v2              1   v2
network-first             v1 cached, online           v2              1   v2
network-first             v1 cached, offline          v1              1   v1
stale-while-revalidate    cache empty, online         v2              1   v2
stale-while-revalidate    v1 cached, online           v1              1   v2
stale-while-revalidate    v1 cached, offline          v1              1   v1
network-only              cache empty, online         v2              1   (empty)
network-only              v1 cached, online           v2              1   v1
network-only              v1 cached, offline          (fallback page) 1   v1
```

The second row is **cache-first**'s defining property: while a copy exists, the network is
never used. The response arrives the fastest and is independent of the connection; the cost is
that even if the resource changes on the server, the old copy is served indefinitely. This is
right only for assets with a versioned name whose content never changes.

The fifth and sixth rows summarize **network-first**: always current while online, whatever is
on hand while offline. When the network is slow, the user waits as long as the network is slow;
in real deployments this wait is bounded by a timeout, and the copy is fallen back to once it
expires.

The eighth row shows **stale-while-revalidate**'s trade-off in a single line: served is `v1`,
left in cache is `v2`. The user gets a response without waiting, but one version behind;
current content appears on the next visit. This is the right choice for content whose
freshness can tolerate one round trip's delay, and the interface should not leave this
implicit.

The last three rows show that **network-only** never writes to the cache at all: in the third
state, `v1` sits in the cache but the fallback page is served. Requests that cannot be
queued — a measurement submission, for instance — belong to this strategy; what to do while
offline is not show old data, it is announce that the request did not go through.

#### Versioning, Cleanup, and Quota

Caches are named, and the name carries a version. A new worker version writes to its own name
while installing; while activating, it deletes every cache other than its own name. These two
steps together keep old and new assets from mixing: a version's assets either all exist or
none do.

The resource list gathered during installation is kept short. The smallest set needed for the
application to open — the document shell, base style, base script, offline fallback page —
goes into installation; the rest of the assets are written as they are used. Counting
installation as failed as a whole means a single item on a long list blocking the entire
deployment.

It is also accounted for that stored data is not unlimited. The browser applies an upper bound
per origin, and can delete an origin's data as a whole when space runs low. Two rules follow
from this: the cache is not used like a persistent database, and the possibility that an entry
read from the cache is not found is always handled.

#### Summary

- There are two cache layers in the browser: the HTTP cache managed by headers, and the
  program-controlled cache managed by code; the two sit one after the other in a chain.
- Freshness headers announce three decisions: whether it can be stored, how long it counts as
  fresh, and which validator it is checked with. Validation results in a bodyless response and
  saves bandwidth, not latency.
- The cache key is, by default, the entire URL; the query string is part of the key, and
  without normalization the same content lands in more than one entry.
- Cache-first is the fastest and most open to staleness, network-first is the freshest and
  slowest, stale-while-revalidate is fast but one version behind, network-only is the
  strategy with no offline behavior.
- Cache names carry a version; they are written at install and old names are deleted at
  activation. The install list is kept short, and storage is limited.

#### Next Step

This lesson made the page independent of the network: resources are local, decisions are
defined. What results is now more than a page, and it needs to look that way to the user too.
The person using the North Slope page in the field does not want to open it by typing an
address every time; they expect it to open in its own window, with its own icon, without a
browser interface. This needs a manifest file that introduces the page to itself, criteria for
installability, and a permission model for notifications arriving from the background. The
next lesson takes up these three pieces.

### Progressive Web Apps

Source: https://academia.sh/en/courses/browser-platform/progressive-web-apps

The pieces that turn a page into an installable app; the web app manifest's fields and internal consistency, installability criteria, the responsibilities display mode brings, the permission model, and the offline outbox.

The previous lesson made the page independent of the network: resources are local, decisions
are defined. What results is now more than a page, and it needs to look that way to the user
too. The person using the North Slope page in the field does not want to open it by typing an
address every time; they expect it to open in its own window, with its own icon.

This lesson takes up three pieces: the manifest that introduces the page to itself, the
conditions for installability, and the permission model for notifications arriving from the
background. This approach is called a **progressive web app**; the "progressive" in its name
describes the page continuing to work as an ordinary page in an unsupported environment.

#### The Web App Manifest

The page links to a JSON file that introduces itself as an app. The file carries the
information the operating system needs to list the app and open its window: a display name, a
short name, the address to go to on launch, the path scope the app is counted within, display
mode, icons, and colors.

Most fields are formally independent, but there are silent consistency conditions between
them. These are checkable.

```js
// manifest-check.mjs — tests the internal consistency of a web app manifest
const REQUIRED = ["name", "start_url", "icons", "display"];
const REQUIRED_SIZES = [192, 512];

function check(manifest) {
  const findings = [];

  for (const field of REQUIRED)
    if (!(field in manifest)) findings.push(`missing field: ${field}`);

  const scope = manifest.scope ?? "/";
  if (manifest.start_url && !manifest.start_url.startsWith(scope))
    findings.push(`start_url (${manifest.start_url}) outside the scope (${scope})`);

  const icons = manifest.icons ?? [];
  const sizes = new Set(
    icons.flatMap((i) => (i.sizes ?? "").split(" ")).map((s) => Number(s.split("x")[0])));
  for (const needed of REQUIRED_SIZES)
    if (![...sizes].some((s) => s >= needed))
      findings.push(`no icon at ${needed}x${needed} or larger`);

  if (!icons.some((i) => (i.purpose ?? "").split(" ").includes("maskable")))
    findings.push("no icon with maskable purpose: edges get clipped in cropped slots");

  if (manifest.display && !["fullscreen", "standalone", "minimal-ui", "browser"].includes(manifest.display))
    findings.push(`unrecognized display value: ${manifest.display}`);

  return findings;
}

const manifests = {
  "north-slope.webmanifest": {
    name: "North Slope Measurement Station",
    short_name: "North Slope",
    start_url: "/station/north-slope/",
    scope: "/station/north-slope/",
    display: "standalone",
    background_color: "#0f1115",
    theme_color: "#0f1115",
    icons: [
      { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
      { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
      { src: "/icon-maskable.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
    ],
  },
  "incomplete.webmanifest": {
    short_name: "North Slope",
    start_url: "/panel/",
    scope: "/station/",
    display: "app",
    icons: [{ src: "/icon-64.png", sizes: "64x64", type: "image/png" }],
  },
};

for (const [name, manifest] of Object.entries(manifests)) {
  const findings = check(manifest);
  console.log(`${name}: ${findings.length === 0 ? "consistent" : findings.length + " finding(s)"}`);
  for (const f of findings) console.log("  - " + f);
}
```

```
north-slope.webmanifest: consistent
incomplete.webmanifest: 6 finding(s)
  - missing field: name
  - start_url (/panel/) outside the scope (/station/)
  - no icon at 192x192 or larger
  - no icon at 512x512 or larger
  - no icon with maskable purpose: edges get clipped in cropped slots
  - unrecognized display value: app
```

The second finding is the sneakiest one. The scope in the manifest draws the app's boundary: a
url outside the scope opens in the browser, not the app window. If the launch url is outside
the scope, the app installs and its icon appears, but every launch exits its own window. This
must also line up with the service worker's scope; when the two scopes diverge, a url opened in
the app window can end up lacking offline support.

The maskable-icon finding looks like a visual detail, and it is not. The system crops the icon
to its own shape; an icon that does not account for cropping gets its edges cut off. An icon
marked with a separate purpose tag is one with safe space left around it.

#### Criteria for Installability

Installability does not come automatically with the manifest's presence. The common core is:
the page served from a secure origin, a valid manifest linked, an icon of sufficient size
provided, and usually a service worker registered that answers requests. Criteria beyond this
vary from browser to browser and can change over time; the only correct approach is to build
the install flow with **feature detection**.

When the install prompt is shown is not the page's decision. The browser emits an event
whenever it sees fit; the page can catch this event, defer it, and bind it to a button in its
own interface. Opening the install window without user interaction is not possible. The event
may never arrive at all: if the app is already installed, or the environment does not support
installation. The install button is therefore shown conditionally.

#### Display Mode and the Responsibility It Brings

In standalone display mode, the browser interface is not visible: no address bar, no back
button, no reload button. Every browser capability the page implicitly relied on is now the
page's own responsibility.

Three things are directly affected. **Back navigation** must be offered explicitly in the
interface; the user gets stuck on a screen they cannot leave. **A reload path** must be left in
place; when the offline fallback page is shown, the user needs a button to try again. **Sharing
the address** needs a way of its own; without an address bar, there is no other way to copy the
current screen's url.

Which mode the page opened in can be read with a media query; this lets the same document show
different navigation elements in a browser tab versus an app window. The mode declaration also
works like a fallback chain: if the requested mode is not supported, it falls back to a less
privileged mode, ending eventually at an ordinary tab.

#### Notifications and the Permission Model

The authority to send notifications depends on the user's explicit consent, and permission
sits in one of three states: not asked, granted, denied. The denied state is permanent; the
page cannot ask for the same permission again unless the user reverses it in settings.

This permanence produces a single rule: **permission is asked for at the moment the user
starts an action that needs it.** Permission asked for the instant the page opens is usually
denied, and that denial is permanent; the right to ask again when it is genuinely needed later
gets burned. The right order is to first show a step in the interface explaining what it will
be used for, then open the browser's permission window.

Remote notification also needs a server side: the subscription the page creates is registered
with the server, the server sends the message to a broker, and the broker wakes the service
worker. The subscription is not permanent; the browser can refresh it and the server's record
can go stale. For this reason, notification is never counted as the only channel of
communication; the same information should also be available in the interface when the app is
opened.

#### Offline Submission and Resilience to Repetition

Offline reading was the previous lesson's topic; offline **writing** is a separate problem. A
measurement taken in the field cannot be sent while there is no connection; to keep it from
being lost, it is written to a local queue and drained once a connection arrives. In some
environments, the browser can do this draining even while the page is closed; support is
conditional and used through detection.

The queue itself produces a new class of error. If the response gets lost on the way after the
server has written the record, the client counts the submission as failed and retries it.

```js
// queue.mjs — offline submission queue and resilience against retries
// Scenario: the record is written to the server, but the response is lost before it
// reaches the client. The client counts the submission as failed and retries it.

function server() {
  const records = [];
  const seenKeys = new Set();
  return {
    records,
    write(body, key) {
      if (key && seenKeys.has(key)) return "duplicate request, ignored";
      if (key) seenKeys.add(key);
      records.push(body);
      return "written";
    },
  };
}

// Drains the queue. If responseLoss[i] is true, the response does not reach the client on attempt i.
function drain(queue, s, responseLoss) {
  let attempt = 0;
  while (queue.length > 0 && attempt < 5) {
    const job = queue[0];
    const result = s.write(job.body, job.key);
    const lost = responseLoss[attempt] === true;
    attempt += 1;
    console.log(`  attempt ${attempt}: server "${result}"` +
      (lost ? " -> response lost, will retry" : " -> response arrived"));
    if (!lost) queue.shift();
  }
}

const measurement = { station: "north-slope", temperature: -4.2 };

for (const key of [null, "submission-9f3c"]) {
  console.log(key ? `retry-resilient key: ${key}` : "submission without a key:");
  const s = server();
  const queue = [{ body: measurement, key }];
  drain(queue, s, [true, false]); // response lost on the first attempt
  console.log(`  records on the server: ${s.records.length}\n`);
}
```

```
submission without a key:
  attempt 1: server "written" -> response lost, will retry
  attempt 2: server "written" -> response arrived
  records on the server: 2

retry-resilient key: submission-9f3c
  attempt 1: server "written" -> response lost, will retry
  attempt 2: server "duplicate request, ignored" -> response arrived
  records on the server: 1
```

The same measurement was recorded twice, and no error appeared on the client side. The
distinction is a key produced together with the submission that stays **unchanged** across
retries. The key is produced on the client when the submission is queued; if it is produced
fresh on every attempt, it serves no purpose. The server-side counterpart is ignoring a request
that sees the same key a second time and reporting success anyway.

The queue's visibility to the user is also part of the design. The count of unsent
measurements should stand in the interface and disappear once submission completes; data the
user thinks is saved silently waiting in the queue is the most costly failure of offline
support.

#### Summary

- The web app manifest is the JSON file that introduces the page to the operating system; its
  fields carry checkable consistency conditions between them.
- The launch url must be within the manifest's scope; outside it, the app exits its own window
  on every launch.
- Installability criteria include a secure origin, a valid manifest, and usually a registered
  service worker; the rest varies by environment and is handled with feature detection.
- In standalone display mode, back navigation, reload, and address sharing become the page's
  own responsibility.
- Permission denial is permanent; permission is asked for at the moment the user starts an
  action that needs that capability.
- The offline submission queue produces retries; without a key that stays unchanged per
  submission, the same record gets written more than once.

#### Next Step

The layers built up to here — the document tree, the event path, encapsulated components, the
intervening worker, caching decisions — become invisible when they work together. When
something goes wrong, which layer the problem came from cannot be understood by reading text:
the badge's color may be wrong because a style rule won unexpectedly, or a measurement may be
stale because the request was served from the cache. These two diagnoses need different
questions and different measurements. The next topic takes up the diagnostic tools the browser
offers through these questions, and its first lesson starts from the nearest one: reading which
values an element is actually painted with.

## Developer Tools

The question each of the element inspector, network panel, performance recording, memory snapshot, and breakpoint tools answers, and how it is read.

### Element and Style Inspection

Source: https://academia.sh/en/courses/browser-platform/element-and-style-inspection

The question the Elements panel answers; the difference between the live tree and the source text, the declared-computed-used value distinction, reading cascade order, tracing inheritance, and interpreting box measurements.

The layers built up to this point become invisible when they work together. When
something goes wrong, the source does not show which layer the problem comes from: a
measurement badge's color could be wrong because a style rule won unexpectedly, or its
number could be stale because the request was served from cache.

This topic covers the browser's built-in diagnostic tools by the questions they ask. Each
tool answers a specific question, reads a specific measure, and interprets it a specific
way. The first tool is closest at hand: the **Elements panel**, which shows what values
an element is actually rendered with.

#### The Tree the Panel Shows

The tree in the panel is not a picture of the text that came from the server. The
distinction from the DOM API lesson is reflected in the tool here: what is shown is the
**live tree** resulting from parsing and every script that has run up to that moment.

Four sources produce this difference. The parser's recovery rules complete missing
closing tags and move misplaced elements, so a structure not in the source appears in
the tree. Scripts add and remove nodes. Shadow trees are shown as separate subtrees, not
present in the document tree. Pseudo-elements are not nodes in the tree, but because they
render, they are marked as separate rows in the panel.

A direct diagnostic rule follows from this: **someone who cannot find the structure they
see in the panel in the source is looking at two different things.** What the server
actually sent is a separate question, and it is the subject of the Network panel.

#### Three Values: Declared, Computed, Used

The most common confusion in style inspection is the same property appearing differently
in different places. The reason: a value passes through three stages on its way to
rendering.

The **declared value** is exactly as written in the style file: `1.5rem`, `50%`,
`inherit`, `auto`. The winning declaration is listed in this form.

The **computed value** is the value after inheritance and relative units have been
resolved. A unit defined relative to text size drops here to an absolute length; keywords
are converted to their own equivalents. Inheritance carries the value at this stage.

The **used value** is the number that emerges once the layout calculation has finished.
Percentages are resolved against the container's real width, `auto` drops to a number,
and flexing and grid distribution give their result here.

The distinction is decisive in diagnosis. When an element whose width is written as a
percentage renders narrower than expected, the computed value can still show a
percentage; the problem is in the container's width and only shows up in the used value.
Conversely, if a color declaration is never applied, the used value is misleading; the
place to look is cascade order.

#### The Rule of Ordering and the Struck-Through

The style list orders every declaration matching an element from winner to loser and
strikes through the losers. Struck-through lines are not noise, they are the diagnosis
itself: why a rule was not applied is read from where the winning rule sits.

The computation producing the order rests on three criteria.

```js
// cascade.mjs — the computation producing the style list's order and the winning declaration
// Specificity triple: (id, class/attribute/pseudo-class, type/pseudo-element)
function specificity(selector) {
  const parts = selector.split(/\s+/);
  let a = 0, b = 0, c = 0;
  for (const p of parts) {
    a += (p.match(/#[\w-]+/g) ?? []).length;
    b += (p.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+/g) ?? []).length;
    c += (p.match(/::[\w-]+/g) ?? []).length;
    if (/^[a-zA-Z][\w-]*/.test(p)) c += 1;
  }
  return [a, b, c];
}

// Layer weight: the larger one wins.
const WEIGHT = { "browser": 0, "author": 1, "author!": 2, "browser!": 3 };
const weight = (b) => WEIGHT[b.source + (b.important ? "!" : "")];

function order(declarations) {
  return [...declarations].sort((x, y) => {
    if (weight(x) !== weight(y)) return weight(y) - weight(x);
    const ox = specificity(x.selector), oy = specificity(y.selector);
    for (let i = 0; i < 3; i++) if (ox[i] !== oy[i]) return oy[i] - ox[i];
    return y.order - x.order; // written later wins
  });
}

// color declarations applied to a span.value element
const declarations = [
  { order: 1, source: "browser", selector: "span", value: "canvastext", important: false },
  { order: 2, source: "author", selector: ".value", value: "#333", important: false },
  { order: 3, source: "author", selector: "#dashboard .value", value: "#0a5", important: false },
  { order: 4, source: "author", selector: "span.value", value: "#b23", important: false },
  { order: 5, source: "author", selector: ".value", value: "#07c", important: false },
  { order: 6, source: "author", selector: ".warning .value", value: "#c30", important: true },
];

console.log("color declarations (winner on top):");
for (const [i, b] of order(declarations).entries()) {
  const [a, s, t] = specificity(b.selector);
  console.log(
    `  ${i === 0 ? "→" : " "} ${b.selector.padEnd(16)} ${b.value.padEnd(12)}` +
    `specificity ${a}-${s}-${t}  ${b.source}${b.important ? " !important" : ""}` +
    `${i === 0 ? "" : "   (struck through)"}`);
}

// Inheritance: only kicks in when no declaration matches at all.
const winner = (list) => (list.length ? order(list)[0].value : null);
const parentColor = winner(declarations);
const childDeclarations = []; // no color rule matches the strong element

console.log("\nspan.value  → computed color:", parentColor);
console.log("strong      → computed color:",
  winner(childDeclarations) ?? `${parentColor} (inherited)`);
```

```
color declarations (winner on top):
  → .warning .value  #c30        specificity 0-2-0  author !important
    #dashboard .value #0a5        specificity 1-1-0  author   (struck through)
    span.value       #b23        specificity 0-1-1  author   (struck through)
    .value           #07c        specificity 0-1-0  author   (struck through)
    .value           #333        specificity 0-1-0  author   (struck through)
    span             canvastext  specificity 0-0-1  browser   (struck through)

span.value  → computed color: #c30
strong      → computed color: #c30 (inherited)
```

Three criteria are at work in the list, and which one takes effect is read by comparing
lines.

The first line shows **weight beating specificity**: a declaration with lower specificity
sits at the top only because it is marked important. Before answering the question of why
a rule won with "because it is more specific," the weight column is checked.

The second and third lines show that the **specificity comparison is lexicographic**: if
the id count is not equal, the rest is not counted at all. One id beats however many
classes are written.

The fourth and fifth lines show that **source order** is the last criterion: between two
declarations of the same selector, the one written later wins. This is where
file-loading order enters the diagnosis when the same class is defined in two files.

The last line shows the browser's own default style appears in the list and always stays
at the bottom. For a property the author has no rule for, this line wins; the source of
an unexpected margin is most often here.

#### Reading Inheritance

The output's last two lines show inheritance's condition: when no declaration matches an
element and the property is an inherited one, the value is taken from the parent's
computed value.

In the panel, this appears as a value with no source in the element's own rules, shown
next to the ancestor it belongs to. The diagnostic rule is: **changing an inherited value
requires writing a rule on the element itself; a rule written on the ancestor never
appears if another matching declaration sits in between.** Inheritance is not part of the
cascade, it is the fallback that kicks in when the cascade produces no result.

#### Reading Box Measurements

The box measurements view shows an element's content area, padding, border, and margin as
numbers. Every number here is a **used value**: percentages resolved, `auto` dropped to a
number, colliding margins merged.

Three readings come up often. Content width differing from what was written shows the
box-sizing model includes padding and border in the width. A margin coming out at half of
what was written shows collapsing with the neighboring box has occurred. Zero height
shows the element takes up no space in flow — that its children are floated or absolutely
positioned.

An element not being rendered at all is a separate case, read not in the box measurements
but in the computed values: which of display type, visibility, opacity, and clipping is
in effect is tested separately. All four remove the element from view, but three preserve
its place, one does not.

#### Forcing States

Rules applied while the pointer is over an element or it has focus mostly disappear the
moment inspection is wanted: the pointer leaves the element, focus moves to the panel.
For this reason, inspectors allow **forcing** an element's state; the relevant
pseudo-class rules are applied and appear in the list even though the element is not
actually in that state.

The same route serves accessibility auditing: whether the focus indicator is actually
visible is tested by forcing the focus state. The requirement from the Focus and
Keyboard Interaction lesson becomes measurable here.

Pseudo-elements are listed separately because they have their own rule sets and do not
appear in the parent's list. Why generated content is not showing up is mostly answered
there.

#### Summary

- The tree in the panel is the live tree: parser recovery, script changes, shadow trees,
  and pseudo-elements diverge from the source text.
- A value passes through the declared, computed, and used stages; which stage to look at
  depends on the kind of question.
- The style list orders declarations by weight, specificity, and source order;
  struck-through lines say why a rule lost.
- The specificity comparison is lexicographic: if a higher digit is not equal, the lower
  ones are not counted at all.
- The numbers in box measurements are used values; deviations from the written value are
  explained by the box model, collapsing, and flow behavior.
- Interaction states are inspected by forcing them; pseudo-element rules are listed
  separately.

#### Next Step

This lesson answered why an element looks the way it does, not why its content is what it
is. If the number on the badge is stale, the problem is not in the style: either the
request was never made, it was served from cache, the server returned stale data, or the
response arrived much later than expected. The four are separated by different measures,
all read in one place: when the request started, how long it waited at which stage, and
which headers it came back with. The next lesson covers these measures and the causal
links between them.

### Network Panel Diagnostics

Source: https://academia.sh/en/courses/browser-platform/network-panel-diagnostics

Which cause a request's timing stages point to; the distinction between transferred size and resource size, headers' role in diagnosis, reading a dependency chain in the waterfall, and making a measurement reproducible.

The previous lesson answered why an element looks the way it does, not why its content is
what it is. If the number on the badge is stale, the problem is not in the style: either
the request was never made, it was served from cache, the server returned stale data, or
the response arrived much later than expected.

The Network panel separates these four possibilities. This lesson covers the measures the
panel produces and which cause each measure points to.

#### The Questions the Panel Answers

The panel shows a list of requests, and every row carries an answer to four questions.

**Was the request actually made?** A request not in the list was never made. An expected
request never showing up says the code never ran that path, or a condition was not met —
there is no need to look at the server.

**Where was it served from?** Whether it came from the network, the browser's own cache,
or a service worker is marked separately. A cache-served request appears in the list but
never reached the network; this is the most common source of the stale-data problem.

**How long did it take, and where did it wait?** Total time alone is not a diagnosis; its
breakdown into stages is.

**What did it return?** Status code, headers, body. Most wrong-content problems show up
here.

#### The Stages of Timing

A request's total time splits into consecutive stages, and each stage taking longer
points to a different cause.

**Queued time** is the time before the request has started at all. The browser limits how
many simultaneous connections it opens to the same host; once that limit fills, new
requests wait. A long queue says something about how many requests the page starts at
once, not about the server.

**Connection setup** consists of name resolution, transport connection, and the secure
handshake. It shows up only on new connections; on a persistent connection it is near
zero. Taking longer points to network distance and certificate verification, not server
load.

**Waiting for first byte** is the time between the request being sent and the first
response byte arriving, including the server's time to produce the response. Taking
longer here moves the diagnosis to the server side; there is nothing to do on the client.

**Downloading the body** is the time from the first byte to the last. Taking longer
relates to size or bandwidth; compression and minification affect this, server speed does
not.

The distinction is measurable. The following server produces two delays separately: a
processing time up to the first byte, then a transfer time for the rest of the body.

```js
// delayed-server.mjs — waits 300 ms until the first byte, then 200 ms for the rest of the body
import { createServer } from "node:http";

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

createServer(async (request, response) => {
  response.sendDate = false;
  await wait(300);                      // the server's processing time: delays the first byte
  response.writeHead(200, { "Content-Type": "application/json" });
  response.write('{"measurements":[{"temperature":-4.2}');
  await wait(200);                      // the rest of the body: extends the download time
  response.end(',{"temperature":-4.6}]}');
}).listen(8138, "127.0.0.1", () => console.log("listening: 127.0.0.1:8138"));
```

```bash
#!/usr/bin/env bash
# Starts delayed-server.mjs, measures one request's stage timings, then stops it.
node delayed-server.mjs > /dev/null &
server=$!
sleep 1

curl -sS -o /dev/null http://127.0.0.1:8138/measurements.json \
  -w 'name lookup   : %{time_namelookup} s
connect       : %{time_connect} s
first byte    : %{time_starttransfer} s
total         : %{time_total} s
downloaded    : %{size_download} bytes
'

kill "$server"
```

{/* mask: (?<=name lookup   : 0\.00)[0-9]{4}(?= s) */}
{/* mask: (?<=connect       : 0\.00)[0-9]{4}(?= s) */}
{/* mask: (?<=first byte    : 0\.3)[0-9]{5}(?= s) */}
{/* mask: (?<=total         : 0\.5)[0-9]{5}(?= s) */}
```
name lookup   : 0.000152 s
connect       : 0.000438 s
first byte    : 0.306938 s
total         : 0.508153 s
downloaded    : 60 bytes
```

Times depend on the environment and vary in decimal places from run to run; what does not
change is the structure. Name resolution and connection setup are too short to measure,
since the request went to the loopback address; on a real server, these two values take a
noticeable share of the total. The first-byte time reflects the 300 milliseconds the
server held back. The roughly 200-millisecond gap after it is body download.

The diagnostic rule follows: **the time up to the first byte and the time after it
measure two separate problems**, and a single total number hides both.

#### Transferred Size and Resource Size

The panel shows two sizes for a request. **Transferred size** is the bytes that went over
the wire; it includes headers and, if compressed, is the compressed form. **Resource
size** is the content's size once unpacked.

Their ratio says whether compression is working. Equal sizes show compression was not
applied — for a text-type resource, a configuration gap. Transferred size near zero shows
the request was served from cache.

The distinction matters for performance too. Parsing cost is proportional to resource
size, transfer cost to transferred size. Compression lowers the second, not the first: a
large script that compresses well does not tire the network, but it does tire the main
thread.

#### Reading Headers

Response headers are the evidence for the previous topic's freshness decision. Why a
resource arrived stale is answered by reading the caching permission and lifetime
headers; if a validator exists, whether a conditional request was made and an
empty-body response came back is visible in the list.

Three more headers come up often. Content type determines how the browser interprets the
response; a wrong type causes a correctly arrived response to be processed incorrectly.
Content encoding says in which form compression was applied. The header declaring which
request header the response varies by keeps the cache from serving the same response to
different users; its absence lets one user's response reach another.

Request headers are read too: whether credentials were sent, whether a conditional
request carried a validator, and which origin made the request.

#### Reading a Chain in the Waterfall

The waterfall view shows every request as a bar on a timeline. What is read is not
individual durations, it is the bars' **position relative to each other**.

Bars starting side by side are parallel requests. If one does not start until another
finishes, there is a dependency between them, usually unwanted: a resource discovered
only after a script has downloaded and run can only be requested at that point. The
preload scan from the Resource Loading Order lesson exists to shorten this chain; a
staircase pattern says the scan could not see the resource.

Bars starting together but waiting a long while point to a connection limit. Resources
holding up first render are compared against timeline markers: a request finishing after
first contentful paint did not delay that render.

#### An Intervening Layer's Appearance in the List

When a service worker is registered, the list can double. The page's own request produces
one row; if the worker goes to the network to fulfill it, that appears as a second row.
Both rows belong to the same address, and confusing them makes the request count look
doubled.

The distinction is the diagnosis itself. If the page's row marks the request as served
from the worker with no network row beneath it, the response came from cache. If both
rows are present, the worker went to the network; the gap between their durations is its
own processing time. If the page's row appears but the worker never engages, either the
request is out of scope or the page is not yet controlled — the control rule from the
Service Workers lesson becomes observable here.

The worker's own lifecycle leaves a trace in the list too: the update request for the
worker script itself and the requests for resources collected during install show up as
rows the page never asked for.

#### Reproducible Measurement

Network measurement cannot be compared until measurement conditions are fixed. Three
conditions need to be stated explicitly.

**Cache state.** A first visit and a repeat visit are two completely different scenarios,
and both are real. Whichever the measurement is for, that state is set up; measuring with
cache disabled describes only the first visit.

**Network condition.** Tools can artificially limit bandwidth and latency. A local
measurement on a development machine never represents a field user's conditions;
throttling is the only client-side way to bring it closer to reality.

**Extensions and background activity.** Installed extensions mix their own requests into
the list and affect timing. Measurement is done in a profile with none.

The recording itself needs preserving too: the list clears on navigation by default, and
the record of a problem that results in navigation is lost with it. Preserving the log is
requested explicitly.

#### Summary

- The Network panel separates four questions: was the request made, where was it served
  from, how long did it wait at which stage, what did it return. Stages point to
  different causes: queueing to the page's request count, connection setup to distance,
  first byte to the server, download to size.
- With a service worker registered, the same address can produce two rows; the absence of
  the second row shows the response came from cache.
- The gap between transferred size and resource size measures compression; parsing cost is
  proportional to resource size.
- Headers are the evidence for the freshness decision, content interpretation, and cache
  separation.
- What is read in the waterfall is not durations but the bars' position relative to each
  other; a staircase pattern means a discovery chain.
- A measurement cannot be reproduced without fixing cache state, network condition, and
  browser profile.

#### Next Step

The Network panel says when a request finished, not why the page still stays frozen after
the response arrives. If the measurement list downloaded but the screen updates late, the
cause is not the network but work queued on a single thread: parsing, style calculation,
layout, painting, and every intervening script share the same queue. Where it gets stuck
is seen not from the request list but from a breakdown of work over time. The next lesson
covers the recording that produces this breakdown and how to read it.

### Performance Recording

Source: https://academia.sh/en/courses/browser-platform/performance-recording

The main thread's breakdown of work over time; the distinction sampling produces between self time and total time, the long-task and frame-budget calculation, counting layout thrashing, and traps in interpreting a recording.

The Network panel says when a request finished, not why the page still stays frozen after
the response arrives. If the measurement list downloaded but the screen updates late, the
cause is not the network but work queued on a single thread.

This lesson covers the **performance recording** that produces this queue's breakdown
over time, and how to read it.

#### What the Recording Collects

The recording is not a single list, it is several traces laid on the same time axis.

The **main thread trace** is the densest: it carries when every task started, how long it
took, and which calls it contained. The **rendering trace** shows which frames were
produced on time. The **network trace** gives requests' place on the time axis and is the
previous lesson's list aligned to time. The **mark and measure trace** carries labels
coming from the code the recording ran.

The traces sharing an axis is the recording's real value: the gap between a request
finishing and the screen changing is only visible once two traces sit side by side.

#### One Queue: The Main Thread

Most of the work the browser runs per page shares a single queue: HTML parsing, style
calculation, layout, paint preparation, and every script. The model established in the
Event Loop lesson of the Asynchronous JavaScript and Runtime course finds its visual
counterpart here; what the recording shows is that queue made real.

A single queue has two consequences. While a task runs, user input cannot be processed;
a click is recorded but the response is delayed. And while a task runs, no new frame can
be produced; animation stops.

#### Sampling and the Flame Chart

The main thread trace is mostly collected by **sampling**: the recorder captures the
current call stack at fixed intervals. The flame chart is derived from these samples.
Horizontal width is duration; **vertical depth is not time, it is call nesting.**

Two numbers are read from the chart, and confusing them leads to the wrong optimization.

```js
// sampling.mjs — self time and total time computation from sample stacks
const INTERVAL = 1; // ms: the sampler records the call stack every 1 ms

// Every row is the call stack at that moment (outermost to innermost).
const samples = [
  ["root", "drawMeasurements"],
  ["root", "drawMeasurements", "buildRow"],
  ["root", "drawMeasurements", "buildRow"],
  ["root", "drawMeasurements", "buildRow", "formatDate"],
  ["root", "drawMeasurements", "buildRow", "formatDate"],
  ["root", "drawMeasurements", "buildRow", "formatDate"],
  ["root", "drawMeasurements", "buildRow"],
  ["root", "drawMeasurements", "computeThreshold"],
  ["root", "drawMeasurements", "computeThreshold"],
  ["root", "writeToLog"],
];

const total = new Map();
const self = new Map();
const add = (map, name) => map.set(name, (map.get(name) ?? 0) + INTERVAL);

for (const stack of samples) {
  for (const name of new Set(stack)) add(total, name); // total time for every frame
  add(self, stack[stack.length - 1]);                  // self time only for the innermost
}

console.log("function".padEnd(16) + "total".padStart(8) + "self".padStart(8));
for (const [name, t] of [...total].sort((a, b) => b[1] - a[1]))
  console.log(name.padEnd(16) + `${t} ms`.padStart(8) + `${self.get(name) ?? 0} ms`.padStart(8));

// Long task threshold and frame budget
const LONG_TASK = 50;      // ms
const FRAME_BUDGET = 1000 / 60;

const tasks = [
  { name: "navigation handling", duration: 132 },
  { name: "measurement list render", duration: 68 },
  { name: "input handler", duration: 12 },
  { name: "scroll handler", duration: 23 },
];

console.log("\ntask".padEnd(24) + "duration".padStart(8) + "  long task?  dropped frames");
for (const t of tasks) {
  const dropped = Math.max(0, Math.floor(t.duration / FRAME_BUDGET));
  console.log(
    t.name.padEnd(24) + `${t.duration} ms`.padStart(8) +
    (t.duration > LONG_TASK ? "  yes" : "  no").padEnd(15) + dropped);
}
console.log(`\nframe budget: ${FRAME_BUDGET.toFixed(2)} ms (60 frames per second)`);
```

```
function           total    self
root               10 ms    0 ms
drawMeasurements    9 ms    1 ms
buildRow            6 ms    3 ms
formatDate          3 ms    3 ms
computeThreshold    2 ms    2 ms
writeToLog          1 ms    1 ms

task                   duration  long task?  dropped frames
navigation handling       132 ms  yes          7
measurement list render    68 ms  yes          4
input handler              12 ms  no           0
scroll handler             23 ms  no           1

frame budget: 16.67 ms (60 frames per second)
```

**Total time** is a function's own duration plus everything it calls. **Self time** is
only the time spent in that function's own body. The root frame's total time is the whole
recording, and its self time is zero: it does no work, it only calls.

An optimization decision is made by looking at self time. Trying to speed up a function
with a large total time is wasted effort if the time is actually spent in a sub-function
it calls. In the output, `drawMeasurements` totals nine milliseconds but spends only one
in its own body; the real cost sits in `buildRow` and the formatting function it calls.

#### Long Task and Frame Budget

The second table places two thresholds side by side.

The **long task** threshold separates tasks that occupy the main thread for more than
fifty milliseconds. Its reasoning is input response: a delay of this order becomes
noticeable when the user presses a button.

**Frame budget** is derived from the screen's refresh rate. The example assumes sixty
frames per second and brings the budget to roughly seventeen milliseconds; a screen with a
different refresh rate changes the budget, not the shape of the calculation. Any task that
does not fit the budget blocks frame production for its duration — the last column counts
this.

The two thresholds measure different problems. A fifty-millisecond task is not a long
task but still drops three frames. Scroll and animation smoothness is judged by frame
budget, click response by the long-task threshold.

#### Layout Thrashing

The most common pattern in a recording is layout computations scattered through a
script. The reason is defined: when a measurement is asked for, the browser has to
compute layout **immediately** if changes are pending. When reading a measurement and
writing a style alternate, this computation is forced again on every turn.

```js
// thrashing.mjs — how many layout computations read-write ordering produces
function engine() {
  let dirty = false;
  let forcedComputation = 0;
  return {
    get forcedComputation() { return forcedComputation; },
    write() { dirty = true; },                       // invalidates layout
    read() { if (dirty) { forcedComputation += 1; dirty = false; } }, // reading a measurement forces the computation
    frameEnd() { if (dirty) { dirty = false; return 1; } return 0; }, // normal computation
  };
}

function run(sequence) {
  const m = engine();
  for (const op of sequence) m[op]();
  const frameEndComputation = m.frameEnd();
  return { forced: m.forcedComputation, frameEnd: frameEndComputation };
}

const ROWS = 5;
const interleaved = [];
for (let i = 0; i < ROWS; i++) interleaved.push("read", "write"); // read then write for every row

const batched = [
  ...Array.from({ length: ROWS }, () => "read"),
  ...Array.from({ length: ROWS }, () => "write"),
];

for (const [name, sequence] of [["read-write interleaved", interleaved], ["all reads first", batched]]) {
  const s = run(sequence);
  console.log(
    `${name.padEnd(22)} forced layout computations: ${s.forced}` +
    `   computation at frame end: ${s.frameEnd}`);
}
```

```
read-write interleaved forced layout computations: 4   computation at frame end: 1
all reads first        forced layout computations: 0   computation at frame end: 1
```

The two orderings do the same work and produce the same result; what differs is how many
computations they trigger. The interleaved sequence forces layout on every read but the
first. The reads-first sequence forces none; a single computation happens at frame's end
on the browser's own schedule.

In a recording, this pattern shows up as layout work embedded inside a script task, and
tools flag it separately as a warning. The fix is always the same shape: **batch reads,
then batch writes.** In a five-row list the difference is trivial; in a hundred-row
measurement table it eats the entire frame budget.

#### Marking Your Own Code

Sampling does not reliably catch short, infrequent code. A function never appearing in
the recording does not mean it never ran; it may have fallen between two samples.

The remedy is having the code announce its own boundaries. A **mark** is placed at a
point; the span between two marks is named a **measure**, and these named spans appear in
their own trace in the recording. This pair, defined in the Performance Profiling lesson
of the Asynchronous JavaScript and Runtime course, ties unnamed tasks in the recording to
meaningful names.

Marks also carry a benefit of crossing asynchronous boundaries: the span between a data
request starting and the list being rendered does not pass inside a single task, so it
cannot be read from the flame chart; placed as a named span, it becomes readable.

#### Traps in Interpreting a Recording

Three traps come up often.

**Sampling is not exact.** Small numbers are affected by the sample interval; a
two-millisecond difference can be noise. Decisions should be made on magnitudes that are
clear multiples of the sample interval.

**Measuring affects the result.** A page runs slower while recording. Absolute durations
are not meaningful — the ratio between two recordings taken under the same conditions is.

**Development output is not production output.** A recording of unminified output
carrying extra checks does not represent what the user encounters. Measurement is done
with a production build; function-name readability is a separate matter, solved with
source maps.

#### Summary

- A performance recording lays several traces on the same time axis; its value is that
  gaps between traces become visible.
- The main thread is a single queue: parsing, style, layout, paint, and every script share
  it.
- In the flame chart, width is duration, depth is call nesting; optimization decisions are
  made on self time, not total time.
- The long-task threshold measures input response, frame budget measures smoothness; they
  point to different problems.
- Interleaving measurement reads and style writes forces layout computations; batching
  reads ahead of writes drops the forced count to zero.
- Sampling misses short code, recording slows down what it measures, and development
  output does not represent production behavior.

#### Next Step

Performance recording gives a breakdown of a moment: what happened in the few seconds the
recording was open. Some problems do not fit this window. If the measurement page slows
down the longer it stays open, growing heavier on every refresh of the list, no single
recording shows a long task for it; what accumulates is memory that grows over time and is
never released. Its measure is not duration but the sum of objects the program can still
reach. The next lesson covers the tool that measures this sum and how to find the leak.

### Memory Inspection

Source: https://academia.sh/en/courses/browser-platform/memory-inspection

Defining a leak through reachability; shallow size, retained size, and retainer path computation, comparing snapshots, the measured difference between strong and weak references, and browser-specific leak sources.

A performance recording gives a breakdown of a moment. Some problems do not fit this
window: if the measurement page slows down the longer it stays open, growing heavier on
every refresh of the list, no single recording shows a long task for it. What accumulates
is memory that grows over time and is never released.

This lesson covers the tool that measures that accumulation and how to find its source.

#### Defining a Leak: Reachability

The model established in the Memory Lifecycle lesson of the Asynchronous JavaScript and
Runtime course is the basis for diagnosis here: the garbage collector reclaims every
object **not reachable from the root set**. No reachable object is ever collected.

The definition of a leak follows from this. A **memory leak** is an object no longer used
staying reachable anyway. It is not the garbage collector's fault; it cannot know an
object is unused, it only knows reachability. Diagnosis is therefore done not with "which
object is large" but with **"who still refers to this object."**

#### Shallow Size, Retained Size, and Retainer Path

Memory views list two separate sizes for an object, and the difference between them is
the center of the diagnosis.

```js
// retained-size.mjs — shallow size, retained size, and retainer path computation
const SHALLOW = {
  root: 16, listener: 32, closure: 32, measurementArray: 8192,
  detachedRow: 128, cell1: 64, cell2: 64,
  dashboard: 64, badge: 48, sharedStyle: 256,
};

const EDGES = {
  root: ["listener", "dashboard"],
  listener: ["closure"],
  closure: ["detachedRow", "measurementArray"],
  detachedRow: ["cell1", "cell2"],
  measurementArray: [], cell1: [], cell2: [],
  dashboard: ["badge", "sharedStyle"],
  badge: ["sharedStyle"],
  sharedStyle: [],
};

const ROOT_SET = ["root"];

function reachable(excluding = null) {
  const seen = new Set();
  const stack = ROOT_SET.filter((d) => d !== excluding);
  while (stack.length) {
    const d = stack.pop();
    if (seen.has(d)) continue;
    seen.add(d);
    for (const neighbor of EDGES[d]) if (neighbor !== excluding) stack.push(neighbor);
  }
  return seen;
}

const size = (set) => [...set].reduce((t, d) => t + SHALLOW[d], 0);

function retainedSize(node) {
  const before = reachable();
  const after = reachable(node);
  return size(before) - size(after);
}

// Retainer path: the shortest path from the root to the node.
function retainerPath(target) {
  const queue = ROOT_SET.map((d) => [d]);
  const seen = new Set(ROOT_SET);
  while (queue.length) {
    const path = queue.shift();
    const last = path[path.length - 1];
    if (last === target) return path;
    for (const neighbor of EDGES[last])
      if (!seen.has(neighbor)) { seen.add(neighbor); queue.push([...path, neighbor]); }
  }
  return null;
}

console.log("node".padEnd(18) + "shallow".padStart(9) + "retained".padStart(10));
for (const d of Object.keys(SHALLOW))
  console.log(d.padEnd(18) + String(SHALLOW[d]).padStart(9) + String(retainedSize(d)).padStart(10));

console.log("\nretainer path (detachedRow):", retainerPath("detachedRow").join(" → "));
console.log("retainer path (sharedStyle):", retainerPath("sharedStyle").join(" → "));
```

```
node                shallow  retained
root                     16      8896
listener                 32      8512
closure                  32      8480
measurementArray       8192      8192
detachedRow             128       256
cell1                    64        64
cell2                    64        64
dashboard                64       368
badge                    48        48
sharedStyle             256       256

retainer path (detachedRow): root → listener → closure → detachedRow
retainer path (sharedStyle): root → dashboard → sharedStyle
```

**Shallow size** is the object's own field allocation. **Retained size** is the total
space that would be reclaimed if that object were freed — the sum of the object and
everything reachable only through it.

The listener row summarizes the distinction: its shallow size is thirty-two bytes, its
retained size over eight thousand. In a view sorted by shallow size, this listener draws
no attention at all; sorted by retained size, it rises to the top. **In a leak hunt, the
sort criterion is retained size.**

The `badge` versus `sharedStyle` comparison gives the second rule. The badge's retained
size is only its own shallow size, because the style object it points to is also
referenced from the dashboard; freeing the badge leaves that object reachable still.
**Shared objects enter no retainer's retained size**; this is why summing retained sizes
does not give the heap's total size.

**Retainer path** is the reference chain from the root set to the object, and it is the
answer to the leak. The detached row's path says what holds it is a closure, and what
holds the closure is a listener; the fix belongs not in memory but in the code where that
listener is never removed.

#### Measuring: Snapshot and Comparison

A single **heap snapshot** gives a state at one moment; a leak, though, is a trend. This
is why measurement is done by comparison.

The established method rests on three snapshots. The suspected operation — opening and
closing the measurement list — runs once, and the first snapshot is taken; this sets as
baseline the structures that form on first use and count as normal. The operation repeats
a few more times for the second snapshot, then once more for the third. **Objects that
keep existing between the second and third snapshot** are leak candidates; ones that grew
relative to the first but vanished in the second were temporary allocations.

For the comparison to be meaningful, garbage collection has to have run before each
snapshot is taken; tools do this automatically while taking one. Otherwise the measured
growth may just be garbage not yet collected.

#### Strong and Weak References

The most common leak source is helper structures that store objects as keys: a mapping
holding data attached to an element keeps referring to it even after the element has left
the tree. The size of the difference can be measured directly.

The following measurement calls the garbage collector by hand and, for that, must be run
with `node --expose-gc`; run without the flag, the call is undefined.

```js
// leak-measurement.mjs — run with: node --expose-gc leak-measurement.mjs
// Two registries do the same job; the difference is whether they hold the object
// they use as a key with a strong or a weak reference.
const MB = (bytes) => (bytes / 1024 / 1024).toFixed(1);

function measure() {
  globalThis.gc();                     // undefined without --expose-gc
  return process.memoryUsage().heapUsed;
}

function trial(registry, count) {
  const base = measure();
  for (let i = 0; i < count; i++) {
    const node = { id: i, measurement: new Array(64).fill(i) };
    registry.set(node, { lastUpdated: i });
    // once the loop turn ends, nothing outside holds a reference to node
  }
  return measure() - base;
}

const COUNT = 200_000;
console.log(`heap remaining after creating and dropping ${COUNT} nodes:`);
console.log("  strong-reference registry:", MB(trial(new Map(), COUNT)), "MB");
console.log("  weak-reference registry  :", MB(trial(new WeakMap(), COUNT)), "MB");
```

```
heap remaining after creating and dropping 200000 nodes:
  strong-reference registry: 127.5 MB
  weak-reference registry  : 1.0 MB
```

The numbers depend on the runtime and machine; the ratio is what stays stable. The
strong-reference registry keeps every node reachable, and none is collected. In the
weak-reference registry, the key does not keep the object alive; a node with no other
reference is collected, and its entry in the registry goes with it.

The rule: **side data attached to an object should not extend its lifetime.** A weak
reference provides this, at the cost that the registry's contents cannot be iterated —
asking which keys still exist would itself keep them alive.

#### Browser-Specific Leak Sources

Four patterns repeat in a page's context, and all four leave recognizable traces in the
retainer path.

**Detached nodes.** DOM nodes removed from the tree but still referenced by a variable, an
array, or a closure. They are marked as a separate class in memory views; a detached
node's entire subtree is retained with it. Holding a static list from the DOM API lesson
for a long time produces this result.

**Listeners not removed.** Every bound listener ties both the function and its closure to
the target node. The abort-signal option from the Event Model lesson solves this at the
source: one cancellation removes every listener bound to that signal. For a custom
element author, the counterpart is tearing down in the disconnect callback what was set
up in the connect callback.

**Timers and observers left running.** A repeating timer keeps its callback's closure
reachable indefinitely. The same holds for an observer that is not stopped even after the
element it watches leaves the tree. Both have the same remedy: an explicit stop call.

**Unboundedly growing caches.** A result mapping held inside a program grows for its whole
lifetime if given no limit and no eviction policy. The eviction-policy concept from the
Processor Cache lesson applies here: an unbounded cache is not a cache, it is a leak.

#### Summary

- A memory leak is an unused object staying reachable; diagnosis is done with "who still
  refers to this."
- Shallow size is an object's own field, retained size the total space reclaimed if
  freed; the sort criterion in a leak hunt is retained size.
- Shared objects enter no retainer's retained size, so the sum of retained sizes does not
  give the heap's size.
- Retainer path is the chain from the root set to the object and points to the line of
  code to fix.
- A leak is a trend; objects that keep existing across consecutive snapshots are
  candidates.
- Side data attached to an object should not extend its lifetime; weak-reference
  structures provide this, at the cost of not being iterable.

#### Next Step

The tools so far measured a result: which rule won, how long the request took, which
function held the main thread, which object stayed in memory. None answer "what was the
variable's value when this line ran." Why a row of the measurement list rendered missing,
which branch a recording took under which condition — these are only seen by stopping the
program at a chosen point and reading its state there. The next lesson covers this pausing
mechanism and how paused code in production output is traced back to its source.

### Source Debugging

Source: https://academia.sh/en/courses/browser-platform/source-debugging

Stopping a program at a chosen point and reading its state; the question each breakpoint type answers, stepping and reading scope, resolving a location in production output back to source with a source map, and deploying the map.

The tools so far measured a result: which rule won, how long the request took, which
function held the main thread, which object stayed in memory. None answer "what was the
variable's value when this line ran."

Why a row of the measurement list rendered missing, which branch a recording took under
which condition — this is only seen by stopping the program at a chosen point and reading
its state there. This lesson covers the pause mechanism and how paused code in production
output is traced back to its source.

#### What Pausing Means

When a breakpoint is reached, the main thread stops. What stops is not only that
function: it is the entire page. Timers do not fire, events are not processed, no frame
is produced. The state read is exactly the state at the moment that instruction was about
to run.

Two consequences follow. First, **time-dependent behavior breaks under pausing**: stopped
mid-animation and resumed, the elapsed time has actually passed, and duration-based
calculations jump. Timing problems are examined with a recording, not by pausing.

Second, **the value read at a stopped point is more reliable than a printed one.** An
object written to the log is read at the moment it is expanded in the console; that is
after the write, and the object may have changed in between. A breakpoint removes this
uncertainty.

#### Breakpoint Types and the Question They Answer

The breakpoint type is chosen according to the question being asked.

**Line breakpoint** answers "was this line reached, and what was the state then." It is
the most basic type and is used when the line's code is already known.

**Conditional breakpoint** stops on the same line only when a condition holds. It is
needed to stop only on the one row that renders missing in a hundred-row list; an
unconditional breakpoint here stops a hundred times and becomes unusable.

**Logpoint** does not stop, it only writes an expression's value to the log. It is a way
to add a log line without changing the code, and so eliminates the temporary print lines
that would otherwise leak into version control.

**Exception breakpoint** answers "where was this error thrown from" and has two modes:
stop on every exception, or only on uncaught ones. The first mode finds places where an
error is caught and swallowed.

**Subtree modification breakpoint** answers "who changed this node." It stops on attribute
change, on child addition or removal, or on removal of the node itself. It is the
shortest route to finding who is responsible for an element that unexpectedly disappears
or changes; it requires no idea of which part of the code to search.

**Event breakpoint** stops when a given event type begins being handled and answers "which
listener is handling this click." In the structure established in the Event Delegation
lesson, a listener sitting on an ancestor far from the target makes this tool necessary.

**Request breakpoint** stops when a network request is made whose address matches a given
pattern, and shows the call chain that started the request.

#### Stepping, Scope, and Call Stack

In a paused program, three views are read together.

The **call stack** says how execution got to that point. Every frame can be selected, and
the selected frame's scope is read; where a bad value came from is found by walking up the
stack. Asynchronous boundaries are added to the stack too: the relationship between code
running after a promise and the call that started the promise is shown as a separate
section. Without this, the stack starts at the event loop and says nothing.

The **scope view** lists local variables in the selected frame, values captured in a
closure, and the global scope separately. The closure section is especially valuable in
diagnosis: a variable being different from what is expected mostly comes from how a loop
variable was captured.

**Stepping** is done in three modes. Step over runs the call on the current line and stops
on the next line. Step into stops at the first line of the called function. Step out runs
until the current function returns and goes back to the caller.

Stepping's practical problem is falling into helper code: trying to step into an event
listener, intervening infrastructure frames scatter the stepping. Tools allow specific
files to be **ignored** for stepping; ignored files' frames are hidden in the stack, and
step-into skips them. Staying in your own code is achieved with this setting.

#### Stopping in Production Output: Source Maps

The deployed script is not the written script: names are shortened, lines merged, perhaps
reduced to a different syntax. A program paused in this output shows a single unreadable
line.

The structure defined in the Source Maps lesson of the Tooling, Modules, and Ecosystem
course closes this gap: a mapping table tying every position in the generated file to a
position in the original file. The table is encoded with diffs and variable-length
quantities to save space. Decoding it is a computable operation.

```js
// sourcemap.mjs — VLQ encoding/decoding and resolving a generated position to its source
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

function vlqEncode(number) {
  let value = number < 0 ? (-number << 1) | 1 : number << 1; // sign in the lowest bit
  let output = "";
  do {
    let chunk = value & 0b11111;
    value >>>= 5;
    if (value > 0) chunk |= 0b100000;                  // continuation bit
    output += ALPHABET[chunk];
  } while (value > 0);
  return output;
}

function vlqDecode(text) {
  const numbers = [];
  let value = 0, shift = 0;
  for (const char of text) {
    const chunk = ALPHABET.indexOf(char);
    value += (chunk & 0b11111) << shift;
    if (chunk & 0b100000) { shift += 5; continue; } // continuation bit: move to the next char
    numbers.push(value & 1 ? -(value >>> 1) : value >>> 1);
    value = 0; shift = 0;
  }
  return numbers;
}

// Absolute mappings: position in the generated file → position in the source
const MAPPINGS = [
  [ // generated line 0
    { generatedColumn: 0, source: 0, line: 0, column: 0, name: 0 },
    { generatedColumn: 12, source: 0, line: 4, column: 2, name: 1 },
    { generatedColumn: 31, source: 0, line: 5, column: 14, name: null },
  ],
  [ // generated line 1
    { generatedColumn: 0, source: 0, line: 9, column: 0, name: null },
    { generatedColumn: 8, source: 0, line: 11, column: 4, name: null },
  ],
];

// Turns absolute mappings into a difference sequence and encodes it: fields are stored as diffs.
function generateMappings(mappings) {
  let prevSource = 0, prevLine = 0, prevColumn = 0, prevName = 0;
  return mappings.map((lineMappings) => {
    let prevGeneratedColumn = 0;
    return lineMappings.map((e) => {
      const fields = [e.generatedColumn - prevGeneratedColumn, e.source - prevSource,
        e.line - prevLine, e.column - prevColumn];
      if (e.name !== null) fields.push(e.name - prevName);
      prevGeneratedColumn = e.generatedColumn;
      prevSource = e.source; prevLine = e.line; prevColumn = e.column;
      if (e.name !== null) prevName = e.name;
      return fields.map(vlqEncode).join("");
    }).join(",");
  }).join(";");
}

const map = {
  version: 3,
  file: "measurements.min.js",
  sources: ["measurements.js"],
  names: ["drawMeasurements", "buildRow"],
  mappings: generateMappings(MAPPINGS),
};
console.log("mappings:", map.mappings);

// Decoding: reconstructs absolute positions from the difference sequence.
function parseMappings(mappings) {
  let source = 0, line = 0, column = 0, name = 0;
  return mappings.split(";").map((lineText) => {
    let generatedColumn = 0;
    if (lineText === "") return [];
    return lineText.split(",").map((segment) => {
      const a = vlqDecode(segment);
      generatedColumn += a[0]; source += a[1]; line += a[2]; column += a[3];
      if (a.length === 5) name += a[4];
      return { generatedColumn, source, line, column, name: a.length === 5 ? name : null };
    });
  });
}

const parsed = parseMappings(map.mappings);

// From generated position to source: take the LAST mapping in the line not past the column.
function findInSource(generatedLine, generatedColumn) {
  const lineMappings = parsed[generatedLine] ?? [];
  let found = null;
  for (const e of lineMappings) if (e.generatedColumn <= generatedColumn) found = e;
  if (!found) return null;
  return {
    file: map.sources[found.source],
    line: found.line + 1,
    column: found.column + 1,
    name: found.name === null ? null : map.names[found.name],
  };
}

console.log("\ngenerated position → position in source");
for (const [s, k] of [[0, 0], [0, 20], [0, 40], [1, 3], [1, 12]]) {
  const c = findInSource(s, k);
  console.log(`  measurements.min.js ${s + 1}:${k + 1}`.padEnd(28) +
    `→ ${c.file} ${c.line}:${c.column}` + (c.name ? `  (${c.name})` : ""));
}
```

```
mappings: AAAAA,YAIEC,mBACY;AAId,QAEI

generated position → position in source
  measurements.min.js 1:1   → measurements.js 1:1  (drawMeasurements)
  measurements.min.js 1:21  → measurements.js 5:3  (buildRow)
  measurements.min.js 1:41  → measurements.js 6:15
  measurements.min.js 2:4   → measurements.js 10:1
  measurements.min.js 2:13  → measurements.js 12:5
```

Three details in the output are worth noticing.

First, **the mapping is not complete**. There is no entry for the twenty-first column;
resolution falls back to the last mapping not past that column. This is why a breakpoint
in the debugger drifts a little from the line it was written on: it holds onto the
nearest mapping.

Second, **the name field is optional**. The third mapping has none; a shortened
variable's original name can only be recovered if its mapping carries a name. This is
why single-letter names keep showing up in the scope view.

Third, **the encoding is diff-based**. Decoding a segment depends on the ones before it;
reading cannot start from the middle of the table. This keeps the file small and forces a
full decode.

#### Deploying the Map

A source map is a separate file, tied to the generated file with a comment line at its
end. The deployment decision comes down to three options.

**Serving the map publicly** is easiest and makes the source readable. For a closed-source
application, this means publishing the code.

**Generating the map but not publishing it** is the common middle path: the map is
uploaded to an error-tracking system, not served from the server. The user's browser keeps
the shortened stack trace; the developer side sees it resolved.

**Never generating it** produces no measurable gain — the map is only downloaded when
requested — and makes diagnosing production errors impossible.

Whether the map carries the source text inside itself is a separate decision too: if it
does, the file grows but the original files need no separate access; if not, the map
stays small and the sources need to be served separately.

#### Summary

- A breakpoint stops the main thread; time-dependent behavior breaks under pausing, so
  timing problems are examined with a recording instead.
- Breakpoint type is chosen by the question asked: line, condition, log, exception,
  subtree change, event, and request.
- The call stack says how execution reached that point, the scope view gives values in
  the selected frame; asynchronous boundaries are added to the stack separately.
- Stepping has three modes; ignoring helper files keeps stepping in your own code.
- A source map is a diff-encoded mapping table; resolution falls to the nearest mapping,
  and the name field is optional.
- Map deployment is a decision between code readability and the diagnosability of
  production errors.

#### Course Wrap-Up

This course treated the browser as a working environment and built it in four layers.

The **Document and Events** layer turned the page into a data structure that can be read
and modified from inside a program; the event's defined path from root to target and back
made it possible to manage many elements with a single listener, intervene in the
browser's default behavior, and build keyboard and form interaction.

The **Browser APIs** layer added persistence, navigation control, observation ability,
frame-synced updates, localized formatting, and the ability to offload the main thread to
the page.

The **Web Components and Offline** layer established two independences: an interface
piece's independence from the page — with its own name, lifecycle, encapsulated
structure, and style — and the application's independence from the network — with an
intervening worker, per-resource cache decisions, and an installable shell.

The **Developer Tools** layer put diagnosis on top of these three. Every tool's question
was separate: which rule won, where and how long a request was served from, where the
main thread got stuck, which object was never released, what the value was at that line.
The common method was the same each time — first determine the question, then read the
measure that answers it.

Everything this course built was done **by hand**: creating a node, binding a listener,
writing a change to the relevant element, undoing all of it when tearing down. The
measurement badge became a component, but the fifteen badges on the dashboard staying in
sync with the list still depends on hand-written update code. The code itself computes
which nodes should change when a record is added to the list; as this computation grows,
it turns into a source of bugs.

The next course, **M14/K05 · Component-Based Interface Development**, reverses this
computation. The interface is defined not by how to change it step by step, but by what
it **should be** given the current state; finding the difference and applying it to the
tree becomes the infrastructure's job. With platform capabilities and diagnostic tools now
in place, the turn is to the declarative interface model and the composition of
components.

