Virtual DOM I: Serialize
advancedTurn real DOM into a plain-object tree — the first half of understanding how React represents UI.
Implement a BrowserHistory class in JavaScript — the index-and-stack model, the truncation-on-visit rule, real History API differences, and how routers build on it.
Implement
BrowserHistory:visit(url)navigates somewhere new,back()andforward()move through history,current()reports where you are — with the exact semantics your browser's back button has.
This is a pure data-structure question (it's also LeetCode 1472), and its entire difficulty is one rule everybody knows as a user and forgets as an implementer: visiting a new page while you're back in history destroys the forward stack. Go back three pages, click a link — the three "forward" pages are gone forever. Model that correctly and the rest is bookkeeping.
The model: an array plus an index — not two stacks (though two stacks also work — see follow-ups). The index is "where you are"; entries after the index are your forward history.
class BrowserHistory {
#entries;
#index;
constructor(homepage) {
this.#entries = [homepage];
this.#index = 0;
}
visit(url) {
// THE rule: a new visit truncates everything after the current position…
this.#entries.length = this.#index + 1; // (truncate in place)
// …then appends and moves onto the new entry
this.#entries.push(url);
this.#index += 1;
}
back(steps = 1) {
// clamp, don't underflow — mirroring the real back button's behavior
this.#index = Math.max(0, this.#index - steps);
return this.#entries[this.#index];
}
forward(steps = 1) {
this.#index = Math.min(this.#entries.length - 1, this.#index + steps);
return this.#entries[this.#index];
}
current() {
return this.#entries[this.#index];
}
get canGoBack() { return this.#index > 0; }
get canGoForward() { return this.#index < this.#entries.length - 1; }
}const h = new BrowserHistory("home");
h.visit("a"); h.visit("b"); h.visit("c");
// entries: [home, a, b, c] index: 3
h.back(2); // "a" entries unchanged, index: 1
h.forward(1); // "b" index: 2 — forward history still intact
h.visit("x"); // ← the moment that matters
// entries: [home, a, b, x] index: 3 — "c" is GONE
h.forward(5); // "x" — nothing to go forward to; clamped
h.back(99); // "home" — clamped at the start, no crashIf an interviewer gives you exactly one test, it's that visit-after-back sequence. The two clamp behaviors (back(99), forward(5)) are the second test — real browsers clamp silently, and so should you.
canGoBack/canGoForward are trivial comparisons, and the state is inspectable in one glance. Two stacks (back-stack, forward-stack) also pass — the migration between them on each step is the cost.entries.length = index + 1 — truncating an array by assigning length is idiomatic and in-place; slice also works but allocates. Either is fine; forgetting truncation entirely is the fail.The class is the mental model; window.history is the same model with the browser holding the state — and three differences interviewers probe:
history exposes length and the current entry's state — never the URLs of other entries (privacy: a page could sniff where you've been). Your class is the debuggable version of a deliberately opaque API.pushState(state, "", url) is visit without navigation — it rewrites the URL bar and pushes an entry with no page load; that single capability is what makes SPAs possible. replaceState is visit that overwrites the current entry instead of pushing (redirects, normalizing URLs — worth knowing it exists).popstate fires only for browser-initiated moves (back/forward buttons, history.back()) — not for your own pushState. So a router = your class's model + pushState on link clicks + a popstate listener to catch the user's button presses. Miss the listener and the back button breaks — the #1 hand-rolled-router bug.// The 15-line SPA router this question is secretly about:
function navigate(url) {
history.pushState({}, "", url);
render(url); // pushState does NOT fire popstate
}
window.addEventListener("popstate", () => render(location.pathname));A common pitfall in "sync a custom class to window.history" hybrids: calling history.back() and decrementing your own index, then also handling popstate — the index moves twice. Keep one source of truth: either the class owns the state (pure model) or the browser does (read it in popstate); mirrored state with two writers is the bug factory.
visit after back — the truncation rule; the question's core.back then back to index 0, then forward all the way — round-trip integrity; a quick invariant test (0 <= index < entries.length always).visit("a"); visit("a") creates two entries — correct! The real browser does the same (that's why you sometimes click back twice). Deduplicating is a router-level policy (replaceState), not a history-level one.{ url, state, scrollPosition } — which is how browsers restore your scroll on back. Extending the entry shape is the natural "productionize" follow-up.visit — forward history survives a new navigation; the model is simply wrong.undefined on over-stepped back/forward instead of clamping.back() returns the page you're already on).window.history implementations with double-updating indexes (one state, two writers).pushState triggers popstate.visit clears the forward-stack (same rule, different clothes). Good test that you understand the semantics, not one encoding.maxSize cap." — evict from the front when over capacity (oldest history goes first, index shifts down) — a bounded buffer; browsers really do cap (50 entries in some engines).state in pushState live and what survives refresh?" — serialized by the browser (structured-clone rules — the same algorithm), survives refresh and back/forward; great trivia bridging two catalog questions.pushState + popstate + a render callback, plus route matching on top. The event-emitter pattern is how they broadcast location changes to subscribers.#/path needs no server config (hashchange event, fragment never sent to the server) vs clean URLs needing a server fallback to index.html; one sentence each and the trade-off is complete.Turn real DOM into a plain-object tree — the first half of understanding how React represents UI.
Implement delegate(root, selector, handler) with closest() — one listener for a thousand rows, and why frameworks did exactly this.
Rebuild real DOM from the virtual tree, completing the render pipeline.
Implement on, off, once, and emit — the pub/sub pattern behind Node streams and countless libraries.