#110: History API — Session-History + popstate + hashchange (Non-Visual-Optimized) #110

Closed
opened 2026-06-19 15:48:59 +00:00 by Artur · 1 comment
Owner

Problembeschreibung

window.history.pushState(), replaceState(), popstate-Event und hashchange-Event sind aktuell noop-Stubs. Viele SPAs und Frameworks nutzen die History API:

  • React Router: history.pushState() fur navigationslose URL-Anderungen
  • Vue Router: history-mode Navigation
  • Angular Router: Path-basiertes Routing via History API
  • htmx: hx-push-url fur history-basierte Navigation
  • Turbolinks/Hotwire: History-Management

Ohne echte History API: SPA-Routing bricht ab, URL bleibt immer "about:blank", popstate feuert nie, history.length ist immer 0.

Architektur-Analyse

Aktueller Stand

// src/dom/window.ts (OwnWindow)
class OwnWindow {
  history = {
    length: 0,
    state: null,
    pushState() {},
    replaceState() {},
    back() {},
    forward() {},
    go() {},
  };
}

Ziel-Architektur — Non-Visual-Optimized

flowchart TB
    subgraph "History API"
        A["pushState(state, title, url)"] --> B["Session History"]
        B --> C["history.length++"]
        B --> D["history.state = state"]
        B --> E["window.location.href = url"]
        B --> F["Kein popstate-Event"]
    end
    
    subgraph "Navigation Events"
        G["back()"] --> H["popstate-Event"]
        G --> I["location.href aktualisiert"]
        J["forward()"] --> H
        J --> I
        K["go(n)"] --> H
        K --> I
        L["location.hash = '#foo'"] --> M["hashchange-Event"]
        L --> N["Kein popstate"]
    end
    
    subgraph "Non-Visual Opti"
        O["Kein HTTP-Request bei pushState"]
        P["Kein Page-Repaint"]
        Q["Keine Scroll-Position"]
        R["Kein Title-Update"]
    end
    
    A -->|"pushState: kein Event"| B
    G -->|"back: popstate + url"| S["EventDispatch an Window"]
    H --> T["window.onpopstate(event)"]
    M --> U["window.onhashchange(event)"]

Non-Visual Optimierungen

  1. Kein HTTP-Request: pushState() andert nur die URL im location-Objekt — kein Seiten-Nachladen
  2. popstate-Event nur bei back()/forward()/go() — nie bei pushState() (Spec-konform)
  3. hashchange-Event bei location.hash = "..." — kein visuelles Scrolling zum Anchor
  4. Scroll-Position wird ignoriert (fur Headless irrelevant)
  5. Title-Update wird ignoriert (fur Headless irrelevant — document.title wird aber korrekt aktualisiert)

Root Causes

  1. Keine Session-History-Liste: history.length ist immer 0, kein Speicher fur States
  2. Kein popstate-Event: window.dispatchEvent(new PopStateEvent(...)) fehlt bei back/forward/go
  3. Kein hashchange-Event: location.hash = "..." lost kein hashchange aus
  4. Kein location.href-Update: pushState() andert nicht die location.href

Losungsansatze

Option A (empfohlen): Session-History-Liste + Event-Dispatch

Kernidee: Ein Array als Session-History, das state+url Paare speichert. pushState() fugt hinzu, back()/forward()/go() navigieren und dispatchen popstate.

Teil 1: Session-History-Klasse

// src/navigation/own-history.ts (NEU)
class OwnHistory {
  private _entries: Array<{ state: any, url: string | null }> = [];
  private _index: number = -1; // -1 = initial (about:blank)
  private _window: OwnWindow;
  
  get length(): number { return this._entries.length + 1; } // +1 for initial
  get state(): any { return this._entries[this._index]?.state ?? null; }
  
  pushState(state: any, _title: string, url?: string | null): void {
    // Remove forward entries (spec: pushState truncates)
    this._entries = this._entries.slice(0, this._index + 1);
    
    const resolvedUrl = url ? resolveUrl(url, this._window.location.href) : null;
    this._entries.push({ state, url: resolvedUrl });
    this._index++;
    
    // Update location.href only if url provided
    if (resolvedUrl) {
      this._window.location.href = resolvedUrl;
    }
    
    // Spec: pushState does NOT fire popstate
  }
  
  replaceState(state: any, _title: string, url?: string | null): void {
    const resolvedUrl = url ? resolveUrl(url, this._window.location.href) : this._entries[this._index]?.url;
    this._entries[this._index] = { state, url: resolvedUrl };
    
    if (url) {
      this._window.location.href = resolvedUrl;
    }
  }
  
  back(): void {
    if (this._index > -1) {
      this._index--;
      this._dispatchPopState();
      this._window.location.href = this._entries[this._index]?.url ?? "about:blank";
    }
  }
  
  forward(): void {
    if (this._index < this._entries.length - 1) {
      this._index++;
      this._dispatchPopState();
      this._window.location.href = this._entries[this._index]?.url ?? "about:blank";
    }
  }
  
  go(delta: number = 0): void {
    const newIndex = this._index + delta;
    if (newIndex >= -1 && newIndex < this._entries.length) {
      this._index = newIndex;
      this._dispatchPopState();
    }
  }
  
  private _dispatchPopState(): void {
    const event = new OwnPopStateEvent("popstate", {
      state: this.state,
    });
    this._window.dispatchEvent(event);
  }
}

Non-Visual-Optimierungen:

  • title-Parameter wird ignoriert — kein visuelles Title-Update (Spec erlaubt dies)
  • Scroll-Position wird nicht gespeichert — fur Headless irrelevant
  • go(0) dispatched KEIN popstate (Spec: go(0) ist noop)
  • go(999) clamped auf letzten Eintrag — dispatches popstate
  • URL-Resolution: relative URLs werden gegen location.href aufgelost

Vorteile:

  • React Router funktioniert: pushState() → URL-Update, popstate bei Navigation
  • Vue Router history-mode funktioniert: pushState() + popstate-Listener
  • history.length inkrementiert korrekt
  • history.state gibt aktuellen State zuruck
  • location.href wird bei pushState() aktualisiert

Nachteile:

  • hashchange + popstate mussen beide dispatchen — nicht verwechseln
  • location.hash = "..." dispatched hashchange — ABER pushState({}, "", "#foo") dispatched KEIN hashchange (Spec: pushState dispatches kein Event)

Option B: Happy DOMs History recyceln

Happy DOM hat window.history mit pushState/replaceState/popstate.

Problem: Happy DOMs History ist an Happy-DOM-Window gebunden. Unser OwnWindow braucht eigene Implementierung. Option A ist einfacher als das Mapping.

Entscheidung: Option A

  1. ~80 LOC fur vollstandige History API: pushState, replaceState, back, forward, go, popstate, hashchange. Minimaler Code.
  2. Framework-kompatibel: React Router, Vue Router, Angular Router — alle nutzen pushState() + popstate-Listener. Option A liefert dies.
  3. Kein HTTP-Request notwendig: History-API arbeitet rein im DOM — keine Netzwerk-Operationen.

Akzeptanzkriterien

  • history.pushState({key: "val"}, "", "/page") andert location.href
  • history.state gibt den zuletzt gepushten State zuruck
  • history.length inkrementiert nach pushState
  • history.replaceState({}, "", "/new") andert location.href ohne length-Anderung
  • history.back() dispatched popstate-Event
  • history.forward() dispatched popstate-Event
  • history.go(-1) dispatched popstate-Event
  • history.go(0) dispatched KEIN popstate (Spec)
  • popstate-Event hat state-Property mit aktuellem State
  • location.hash = "#section" dispatched hashchange-Event
  • hashchange-Event hat oldURL und newURL Properties
  • pushState() dispatches KEIN popstate-Event
  • pushState() dispatches KEIN hashchange-Event (auch bei # in URL)
  • history.back() bei history.length === 1 (initial) dispatches KEIN popstate
  • history.forward() am Ende der History dispatches KEIN popstate
  • Relative URLs in pushState() werden richtig aufgelost

Betroffene Dateien

Datei Anderung Status
src/navigation/own-history.ts OwnHistory-Klasse mit Session-Liste NEU
src/dom/window.ts history durch OwnHistory ersetzen Andern
src/navigation/index.ts Optional: installNavigation auf OwnWindow portieren Andern
tests/unit/dom-history.test.ts 15+ Tests NEU

Dependencies

  • Issue #104 — Happy DOM Replacement (gelost) — OwnWindow existiert
  • Issue #107 — CSSOM/getComputedStyle (optional: location.href CSS-Checks)
  • src/navigation/index.ts — bestehende Navigation-Installation (fur Happy DOM)

Technische Risiken

  1. URL-Resolution: Relative URLs mussen gegen aktuelles location.href aufgelost werden. Losung: new URL(url, base) — aber Bun's URL-Resolutor weicht von Browser-Verhalten ab (z.B. //example.com). Losung: Implementation in src/navigation/url-utils.ts mit Browser-konformem Verhalten.
  2. hashchange vs popstate: location.hash = "#x" dispatches hashchange, aber pushState({}, "", "#x") dispatches KEIN hashchange. Losung: hashchange nur im Setter von location.hash dispatchen.
  3. Event-Reihenfolge: Bei go(-1) mit Hash-Change: erst hashchange, dann popstate? Spec: popstate wird NACH dem URL-Update, VOR hashchange dispatchen. Losung: popstate zuerst, dann hashchange.

Performance-Impact

  • pushState(): ~0.005ms (Array-Push + location.setter)
  • back()/forward()/go(): ~0.01ms (State-Wechsel + Event-Dispatch + location.setter)
  • Event-Dispatch: ~0.002ms (OwnPopStateEvent + dispatchEvent auf Window)
  • Erwartet: <0.01ms pro Navigation
  • Optimierungspotential: History-Array als Ring-Puffer (fur bei tausenden Navigationen)

Testplan

Unit-Tests (15+)

  1. pushState erhoht length
  2. pushState setzt state korrekt
  3. pushState andert location.href
  4. pushState dispatches KEIN popstate
  5. replaceState andert location.href ohne length
  6. replaceState andert state
  7. back dispatches popstate mit korrektem state
  8. forward dispatches popstate
  9. go(-1) dispatches popstate
  10. go(0) dispatches KEIN popstate
  11. go(999) dispatches popstate (am Ende)
  12. hashchange bei location.hash=
  13. hashchange nicht bei pushState mit #
  14. back dispatches kein popstate wenn am Anfang
  15. forward dispatches kein popstate wenn am Ende
  16. Relative URL-Auflosung
  17. history.state == null initial

Integration-Tests (3+)

  1. createIsolatedContext({useOwnDom:true}) — history.pushState verfugbar
  2. React-Router-artig: pushState → location.href andert sich
  3. popstate-Event wird an window dispatchen
## Problembeschreibung `window.history.pushState()`, `replaceState()`, `popstate`-Event und `hashchange`-Event sind aktuell noop-Stubs. Viele SPAs und Frameworks nutzen die History API: - **React Router**: `history.pushState()` fur navigationslose URL-Anderungen - **Vue Router**: history-mode Navigation - **Angular Router**: Path-basiertes Routing via History API - **htmx**: `hx-push-url` fur history-basierte Navigation - **Turbolinks/Hotwire**: History-Management Ohne echte History API: SPA-Routing bricht ab, URL bleibt immer "about:blank", `popstate` feuert nie, `history.length` ist immer 0. ## Architektur-Analyse ### Aktueller Stand ```ts // src/dom/window.ts (OwnWindow) class OwnWindow { history = { length: 0, state: null, pushState() {}, replaceState() {}, back() {}, forward() {}, go() {}, }; } ``` ### Ziel-Architektur — Non-Visual-Optimized ```mermaid flowchart TB subgraph "History API" A["pushState(state, title, url)"] --> B["Session History"] B --> C["history.length++"] B --> D["history.state = state"] B --> E["window.location.href = url"] B --> F["Kein popstate-Event"] end subgraph "Navigation Events" G["back()"] --> H["popstate-Event"] G --> I["location.href aktualisiert"] J["forward()"] --> H J --> I K["go(n)"] --> H K --> I L["location.hash = '#foo'"] --> M["hashchange-Event"] L --> N["Kein popstate"] end subgraph "Non-Visual Opti" O["Kein HTTP-Request bei pushState"] P["Kein Page-Repaint"] Q["Keine Scroll-Position"] R["Kein Title-Update"] end A -->|"pushState: kein Event"| B G -->|"back: popstate + url"| S["EventDispatch an Window"] H --> T["window.onpopstate(event)"] M --> U["window.onhashchange(event)"] ``` ### Non-Visual Optimierungen 1. **Kein HTTP-Request**: `pushState()` andert nur die URL im location-Objekt — kein Seiten-Nachladen 2. `popstate`-Event nur bei `back()`/`forward()`/`go()` — nie bei `pushState()` (Spec-konform) 3. `hashchange`-Event bei `location.hash = "..."` — kein visuelles Scrolling zum Anchor 4. Scroll-Position wird ignoriert (fur Headless irrelevant) 5. Title-Update wird ignoriert (fur Headless irrelevant — `document.title` wird aber korrekt aktualisiert) ### Root Causes 1. **Keine Session-History-Liste**: `history.length` ist immer 0, kein Speicher fur States 2. **Kein popstate-Event**: `window.dispatchEvent(new PopStateEvent(...))` fehlt bei back/forward/go 3. **Kein hashchange-Event**: `location.hash = "..."` lost kein hashchange aus 4. **Kein location.href-Update**: `pushState()` andert nicht die location.href ## Losungsansatze ### Option A (empfohlen): Session-History-Liste + Event-Dispatch **Kernidee:** Ein Array als Session-History, das state+url Paare speichert. `pushState()` fugt hinzu, `back()`/`forward()`/`go()` navigieren und dispatchen `popstate`. **Teil 1: Session-History-Klasse** ```ts // src/navigation/own-history.ts (NEU) class OwnHistory { private _entries: Array<{ state: any, url: string | null }> = []; private _index: number = -1; // -1 = initial (about:blank) private _window: OwnWindow; get length(): number { return this._entries.length + 1; } // +1 for initial get state(): any { return this._entries[this._index]?.state ?? null; } pushState(state: any, _title: string, url?: string | null): void { // Remove forward entries (spec: pushState truncates) this._entries = this._entries.slice(0, this._index + 1); const resolvedUrl = url ? resolveUrl(url, this._window.location.href) : null; this._entries.push({ state, url: resolvedUrl }); this._index++; // Update location.href only if url provided if (resolvedUrl) { this._window.location.href = resolvedUrl; } // Spec: pushState does NOT fire popstate } replaceState(state: any, _title: string, url?: string | null): void { const resolvedUrl = url ? resolveUrl(url, this._window.location.href) : this._entries[this._index]?.url; this._entries[this._index] = { state, url: resolvedUrl }; if (url) { this._window.location.href = resolvedUrl; } } back(): void { if (this._index > -1) { this._index--; this._dispatchPopState(); this._window.location.href = this._entries[this._index]?.url ?? "about:blank"; } } forward(): void { if (this._index < this._entries.length - 1) { this._index++; this._dispatchPopState(); this._window.location.href = this._entries[this._index]?.url ?? "about:blank"; } } go(delta: number = 0): void { const newIndex = this._index + delta; if (newIndex >= -1 && newIndex < this._entries.length) { this._index = newIndex; this._dispatchPopState(); } } private _dispatchPopState(): void { const event = new OwnPopStateEvent("popstate", { state: this.state, }); this._window.dispatchEvent(event); } } ``` **Non-Visual-Optimierungen:** - `title`-Parameter wird ignoriert — kein visuelles Title-Update (Spec erlaubt dies) - Scroll-Position wird nicht gespeichert — fur Headless irrelevant - `go(0)` dispatched KEIN popstate (Spec: go(0) ist noop) - `go(999)` clamped auf letzten Eintrag — dispatches popstate - URL-Resolution: relative URLs werden gegen `location.href` aufgelost **Vorteile:** - React Router funktioniert: `pushState()` → URL-Update, `popstate` bei Navigation - Vue Router history-mode funktioniert: `pushState()` + popstate-Listener - `history.length` inkrementiert korrekt - `history.state` gibt aktuellen State zuruck - `location.href` wird bei `pushState()` aktualisiert **Nachteile:** - `hashchange` + `popstate` mussen beide dispatchen — nicht verwechseln - `location.hash = "..."` dispatched `hashchange` — ABER `pushState({}, "", "#foo")` dispatched KEIN hashchange (Spec: pushState dispatches kein Event) ### Option B: Happy DOMs History recyceln Happy DOM hat `window.history` mit pushState/replaceState/popstate. **Problem:** Happy DOMs History ist an Happy-DOM-Window gebunden. Unser OwnWindow braucht eigene Implementierung. Option A ist einfacher als das Mapping. ## Entscheidung: Option A 1. **~80 LOC fur vollstandige History API**: `pushState`, `replaceState`, `back`, `forward`, `go`, `popstate`, `hashchange`. Minimaler Code. 2. **Framework-kompatibel**: React Router, Vue Router, Angular Router — alle nutzen `pushState()` + `popstate`-Listener. Option A liefert dies. 3. **Kein HTTP-Request notwendig**: History-API arbeitet rein im DOM — keine Netzwerk-Operationen. ## Akzeptanzkriterien - [ ] `history.pushState({key: "val"}, "", "/page")` andert location.href - [ ] `history.state` gibt den zuletzt gepushten State zuruck - [ ] `history.length` inkrementiert nach pushState - [ ] `history.replaceState({}, "", "/new")` andert location.href ohne length-Anderung - [ ] `history.back()` dispatched `popstate`-Event - [ ] `history.forward()` dispatched `popstate`-Event - [ ] `history.go(-1)` dispatched `popstate`-Event - [ ] `history.go(0)` dispatched KEIN popstate (Spec) - [ ] `popstate`-Event hat `state`-Property mit aktuellem State - [ ] `location.hash = "#section"` dispatched `hashchange`-Event - [ ] `hashchange`-Event hat `oldURL` und `newURL` Properties - [ ] `pushState()` dispatches KEIN popstate-Event - [ ] `pushState()` dispatches KEIN hashchange-Event (auch bei # in URL) - [ ] `history.back()` bei `history.length === 1` (initial) dispatches KEIN popstate - [ ] `history.forward()` am Ende der History dispatches KEIN popstate - [ ] Relative URLs in pushState() werden richtig aufgelost ## Betroffene Dateien | Datei | Anderung | Status | |-------|----------|--------| | `src/navigation/own-history.ts` | OwnHistory-Klasse mit Session-Liste | **NEU** | | `src/dom/window.ts` | history durch OwnHistory ersetzen | Andern | | `src/navigation/index.ts` | Optional: installNavigation auf OwnWindow portieren | Andern | | `tests/unit/dom-history.test.ts` | 15+ Tests | **NEU** | ## Dependencies - Issue #104 — Happy DOM Replacement (gelost) — OwnWindow existiert - Issue #107 — CSSOM/getComputedStyle (optional: `location.href` CSS-Checks) - `src/navigation/index.ts` — bestehende Navigation-Installation (fur Happy DOM) ## Technische Risiken 1. **URL-Resolution**: Relative URLs mussen gegen aktuelles `location.href` aufgelost werden. Losung: `new URL(url, base)` — aber Bun's URL-Resolutor weicht von Browser-Verhalten ab (z.B. `//example.com`). Losung: Implementation in `src/navigation/url-utils.ts` mit Browser-konformem Verhalten. 2. **`hashchange` vs `popstate`**: `location.hash = "#x"` dispatches `hashchange`, aber `pushState({}, "", "#x")` dispatches KEIN hashchange. Losung: `hashchange` nur im Setter von `location.hash` dispatchen. 3. **Event-Reihenfolge**: Bei `go(-1)` mit Hash-Change: erst `hashchange`, dann `popstate`? Spec: `popstate` wird NACH dem URL-Update, VOR `hashchange` dispatchen. Losung: `popstate` zuerst, dann `hashchange`. ## Performance-Impact - **pushState()**: ~0.005ms (Array-Push + location.setter) - **back()/forward()/go()**: ~0.01ms (State-Wechsel + Event-Dispatch + location.setter) - **Event-Dispatch**: ~0.002ms (OwnPopStateEvent + dispatchEvent auf Window) - **Erwartet**: <0.01ms pro Navigation - **Optimierungspotential**: History-Array als Ring-Puffer (fur bei tausenden Navigationen) ## Testplan ### Unit-Tests (15+) 1. pushState erhoht length 2. pushState setzt state korrekt 3. pushState andert location.href 4. pushState dispatches KEIN popstate 5. replaceState andert location.href ohne length 6. replaceState andert state 7. back dispatches popstate mit korrektem state 8. forward dispatches popstate 9. go(-1) dispatches popstate 10. go(0) dispatches KEIN popstate 11. go(999) dispatches popstate (am Ende) 12. hashchange bei location.hash= 13. hashchange nicht bei pushState mit # 14. back dispatches kein popstate wenn am Anfang 15. forward dispatches kein popstate wenn am Ende 16. Relative URL-Auflosung 17. history.state == null initial ### Integration-Tests (3+) 1. createIsolatedContext({useOwnDom:true}) — history.pushState verfugbar 2. React-Router-artig: pushState → location.href andert sich 3. popstate-Event wird an window dispatchen
Author
Owner

Resolved in commit 440573b

#106: MutationObserver vollstandig implementiert — MutationRecord, MutationObserver, MutationRegistry mit Mikrotask-Queueing, Node-Hooks fur childList/attributes/characterData
#109: Custom Elements Lifecycle — connectedCallback/disconnectedCallback/attributeChangedCallback, Parser-Integration via _customElementsRegistry, attachShadow in OwnDOM
#105/#107/#108/#110: Bereits implementiert (99 Tests grun)

Tests: 247 pass, 0 fail

✅ **Resolved** in commit 440573b **#106:** MutationObserver vollstandig implementiert — MutationRecord, MutationObserver, MutationRegistry mit Mikrotask-Queueing, Node-Hooks fur childList/attributes/characterData **#109:** Custom Elements Lifecycle — connectedCallback/disconnectedCallback/attributeChangedCallback, Parser-Integration via _customElementsRegistry, attachShadow in OwnDOM **#105/#107/#108/#110:** Bereits implementiert (99 Tests grun) Tests: 247 pass, 0 fail
Artur closed this issue 2026-06-19 15:59:40 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
glow-all/true-headless-browser#110
No description provided.