Skip to content
academia.sh

Lesson 08 / 24

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.

Contents

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.

// 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.

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close