Navigation/History Spec — pushState, popstate, hashchange, SPA Routing #99

Closed
opened 2026-06-19 12:09:49 +00:00 by Artur · 0 comments
Owner

Issue #99 — Navigation/History Session Spec — pushState, popstate, hashchange, SPA Routing

Problembeschreibung

Die Browser-Navigation und History-API ist inkomplett. Es gibt history-patch.ts (Sprint 20), aber:

  • history.pushState() existiert im Happy DOM nicht — wird durch einen Patch ersetzt
  • history.replaceState() updated nicht korrekt location.href
  • popstate-Event wird nie gefeuert (keine Back/Forward-Navigation)
  • hashchange-Event fehlt komplett
  • location.hash Änderungen triggern kein hashchange

Warum das blockiert:

  • SPA Frameworks: React Router, Vue Router, SvelteKit nutzen pushState/replaceState
  • Qwik: Nutzt History API für Route-Transitionen
  • Amazon.de: Nutzt location.hash für Product-Tabs/Gallery
  • AWS WAF: Nutzt location.replace() nach Challenge-Erfolg

Architektur-Analyse

Aktueller Stand (src/fakes/history-patch.ts)

// Sprint 20: installHistoryPatch
installHistoryPatch(happyWindow) {
    // Definiert pushState/replaceState auf win.history
    // Mit location.href update
    // Ohne popstate-Event
    // Ohne hashchange
    // Ohne history.state persistence
}

Spec-konforme History API

interface History {
    readonly length: number;
    scrollRestoration: ScrollRestoration;
    readonly state: any;
    back(): void;
    forward(): void;
    go(delta?: number): void;
    pushState(data: any, title: string, url?: string): void;
    replaceState(data: any, title: string, url?: string): void;
}

Session-History-Entry:

{
    url: string
    state: any
    document: Document (shared or cloned)
    scrollPositions: { x, y }
}

Lösungsansätze

Option A — Full Session History (Empfohlen)

Eigene SessionHistory-Klasse mit Entry-Stack:

src/navigation/
├── session-history.ts    (History Stack + Entry Management)
├── navigation.ts          (Navigation Dispatch: popstate, hashchange, location update)
└── location-api.ts        (Location-Property-Setter mit Event-Trigger)

History Stack:

class SessionHistory {
    private entries: HistoryEntry[] = [{ url: 'about:blank', state: null }]
    private currentIndex = 0
    
    pushState(data, title, url) {
        // Remove forward entries
        this.entries = this.entries.slice(0, this.currentIndex + 1)
        // Resolve URL
        const resolved = url ? new URL(url, this.currentUrl).href : this.currentUrl
        // Push
        this.entries.push({ url: resolved, state: data, ... })
        this.currentIndex++
        // Update location
        this._updateLocation(resolved)
        // No popstate event (spec: pushState does NOT fire popstate)
    }
    
    replaceState(data, title, url) {
        const resolved = url ? new URL(url, this.currentUrl).href : this.currentUrl
        this.entries[this.currentIndex] = { url: resolved, state: data, ... }
        this._updateLocation(resolved)
    }
    
    go(delta) {
        const target = this.currentIndex + delta
        if (target < 0 || target >= this.entries.length) return
        this.currentIndex = target
        this._updateLocation(this.entries[target].url)
        // FIRE popstate event
        this._dispatchPopstate(this.entries[target].state)
    }
}

Location Integration:

// location.hash setzen feuert hashchange
Object.defineProperty(location, 'hash', {
    set(value) {
        const oldHash = this.hash
        const newHash = value.startsWith('#') ? value : '#' + value
        // Update URL intern
        this.href = this.href.replace(/#.*$/, '') + newHash
        // Fire hashchange
        if (oldHash !== newHash) {
            window.dispatchEvent(new HashChangeEvent('hashchange', {
                oldURL, newURL
            }))
        }
    }
})

Vorteile:

  • 100% nach HTML5 History Spec
  • SPA Routing funktioniert (React Router, Vue Router, SvelteKit)
  • hashchange für Tab-Navigation
  • popstate für Back/Forward
  • history.state persistence korrekt

Nachteile:

  • Braucht Integration mit Page.goto() für "echte" Navigation (popstate → load URL)
  • Scroll-Wiederherstellung ist vereinfacht

Option B — History-Patch erweitern

Bestehenden history-patch.ts erweitern:

  • popstate-Event-Dispatch bei go/back/forward
  • hashchange-Event bei location.hash Änderung
  • history.state korrekt persisted

Vorteile: Weniger Code (~200 Zeilen Änderungen)
Nachteile: Bleibt ein Patch, nie spec-konformer Entry-Stack

Entscheidung

Option A — Eigene SessionHistory. Der aktuelle Patch ist zu minimal. SPA-Routing erfordert korrekten Entry-Stack mit popstate-Event-Timing.

Akzeptanzkriterien

  • history.pushState(data, title, url) → URL updated, kein popstate, history.length inkrementiert
  • history.replaceState(data, title, url) → URL updated, history.length unverändert
  • history.back() → popstate-Event mit vorheriger state + URL
  • history.forward() → popstate-Event mit nächster state + URL
  • history.go(-1) → wie back(), history.go(1) → wie forward()
  • history.state → liefert aktuellen state (null wenn nicht gesetzt)
  • hashchange-Event bei location.hash = '...'
  • hashchange-Event bei Klick auf <a href="#section">
  • popstate.state enthält korrekte Daten vom pushState/replaceState
  • location.href, location.pathname, location.search sind nach pushState aktuell
  • Scroll-Position: Scroll wird auf 0,0 zurückgesetzt (vereinfacht)
  • Kein popstate beim initialen Page-Load

Betroffene Dateien

Datei Änderung Status
src/fakes/history-patch.ts Ersetzen durch SessionHistory Löschen
src/navigation/session-history.ts Neu Neu
src/navigation/navigation.ts Neu Neu
src/navigation/location-api.ts Neu Neu
src/runtime-isolation.ts SessionHistory installieren Ändern
src/pages/page.ts Navigation-Integration (popstate → goto) Ändern
tests/unit/session-history.test.ts Neu Neu
tests/integration/spa-routing.test.ts Neu Neu

Dependencies

  • SessionHistory benötigt keinen Parser (URL-Manipulation pur)
  • Event Loop Issue #98 für korrektes popstate-Event-Timing
  • Page.goto() für "echte" Navigation bei popstate

Technische Risiken

  • URL-Resolution: Relative URLs brauchen korrekte Base (location.href zum Zeitpunkt des pushState)
  • Scroll Restoration: scrollRestoration = 'auto'/'manual' ist nice-to-have, nicht critical
  • Frames/Iframes: Jeder Frame hat eigene Session History — erstmal Single-Page

Performance-Impact

  • ~0 Overhead (kein Network, nur State-Management)
  • popstate-Event ist O(1), kein Reflow

Testplan

Unit (20+)

  • pushState → history.length, location.href, history.state
  • replaceState → location.href unverändert, state überschrieben
  • back()/forward() → popstate-Event mit korrekten Werten
  • go(0) → kein popstate
  • go(-100) → clamp an 0
  • pushState dann back → popstate + state
  • Non-string url → relative Resolution
  • title wird ignoriert (per Spec)
  • hashchange feuert nur bei Änderung
  • Kein popstate beim Initial-Load

Integration (10+)

  • React Router: pushState → Route-Update → render
  • Vue Router: replaceState → Route-Update
  • Qwik: Router-Transition mit pushState
  • Link-Klick auf <a href="#section"> → hashchange + Scroll

E2E (2)

  • Simple SPA mit 3 Routes via pushState
  • Back/Forward Browser Buttons (simuliert)
# Issue #99 — Navigation/History Session Spec — pushState, popstate, hashchange, SPA Routing ## Problembeschreibung Die Browser-Navigation und History-API ist **inkomplett**. Es gibt `history-patch.ts` (Sprint 20), aber: - `history.pushState()` existiert im Happy DOM nicht — wird durch einen Patch ersetzt - `history.replaceState()` updated nicht korrekt `location.href` - `popstate`-Event wird nie gefeuert (keine Back/Forward-Navigation) - `hashchange`-Event fehlt komplett - `location.hash` Änderungen triggern kein `hashchange` **Warum das blockiert:** - **SPA Frameworks**: React Router, Vue Router, SvelteKit nutzen pushState/replaceState - **Qwik**: Nutzt History API für Route-Transitionen - **Amazon.de**: Nutzt `location.hash` für Product-Tabs/Gallery - **AWS WAF**: Nutzt `location.replace()` nach Challenge-Erfolg ## Architektur-Analyse ### Aktueller Stand (`src/fakes/history-patch.ts`) ```ts // Sprint 20: installHistoryPatch installHistoryPatch(happyWindow) { // Definiert pushState/replaceState auf win.history // Mit location.href update // Ohne popstate-Event // Ohne hashchange // Ohne history.state persistence } ``` ### Spec-konforme History API ``` interface History { readonly length: number; scrollRestoration: ScrollRestoration; readonly state: any; back(): void; forward(): void; go(delta?: number): void; pushState(data: any, title: string, url?: string): void; replaceState(data: any, title: string, url?: string): void; } ``` Session-History-Entry: ``` { url: string state: any document: Document (shared or cloned) scrollPositions: { x, y } } ``` ## Lösungsansätze ### Option A — Full Session History (Empfohlen) Eigene SessionHistory-Klasse mit Entry-Stack: ``` src/navigation/ ├── session-history.ts (History Stack + Entry Management) ├── navigation.ts (Navigation Dispatch: popstate, hashchange, location update) └── location-api.ts (Location-Property-Setter mit Event-Trigger) ``` **History Stack:** ```ts class SessionHistory { private entries: HistoryEntry[] = [{ url: 'about:blank', state: null }] private currentIndex = 0 pushState(data, title, url) { // Remove forward entries this.entries = this.entries.slice(0, this.currentIndex + 1) // Resolve URL const resolved = url ? new URL(url, this.currentUrl).href : this.currentUrl // Push this.entries.push({ url: resolved, state: data, ... }) this.currentIndex++ // Update location this._updateLocation(resolved) // No popstate event (spec: pushState does NOT fire popstate) } replaceState(data, title, url) { const resolved = url ? new URL(url, this.currentUrl).href : this.currentUrl this.entries[this.currentIndex] = { url: resolved, state: data, ... } this._updateLocation(resolved) } go(delta) { const target = this.currentIndex + delta if (target < 0 || target >= this.entries.length) return this.currentIndex = target this._updateLocation(this.entries[target].url) // FIRE popstate event this._dispatchPopstate(this.entries[target].state) } } ``` **Location Integration:** ```ts // location.hash setzen feuert hashchange Object.defineProperty(location, 'hash', { set(value) { const oldHash = this.hash const newHash = value.startsWith('#') ? value : '#' + value // Update URL intern this.href = this.href.replace(/#.*$/, '') + newHash // Fire hashchange if (oldHash !== newHash) { window.dispatchEvent(new HashChangeEvent('hashchange', { oldURL, newURL })) } } }) ``` **Vorteile:** - 100% nach HTML5 History Spec - SPA Routing funktioniert (React Router, Vue Router, SvelteKit) - hashchange für Tab-Navigation - popstate für Back/Forward - history.state persistence korrekt **Nachteile:** - Braucht Integration mit Page.goto() für "echte" Navigation (popstate → load URL) - Scroll-Wiederherstellung ist vereinfacht ### Option B — History-Patch erweitern Bestehenden `history-patch.ts` erweitern: - popstate-Event-Dispatch bei go/back/forward - hashchange-Event bei location.hash Änderung - history.state korrekt persisted **Vorteile:** Weniger Code (~200 Zeilen Änderungen) **Nachteile:** Bleibt ein Patch, nie spec-konformer Entry-Stack ## Entscheidung **Option A** — Eigene SessionHistory. Der aktuelle Patch ist zu minimal. SPA-Routing erfordert korrekten Entry-Stack mit popstate-Event-Timing. ## Akzeptanzkriterien - [ ] history.pushState(data, title, url) → URL updated, kein popstate, history.length inkrementiert - [ ] history.replaceState(data, title, url) → URL updated, history.length unverändert - [ ] history.back() → popstate-Event mit vorheriger state + URL - [ ] history.forward() → popstate-Event mit nächster state + URL - [ ] history.go(-1) → wie back(), history.go(1) → wie forward() - [ ] history.state → liefert aktuellen state (null wenn nicht gesetzt) - [ ] hashchange-Event bei location.hash = '...' - [ ] hashchange-Event bei Klick auf `<a href="#section">` - [ ] popstate.state enthält korrekte Daten vom pushState/replaceState - [ ] location.href, location.pathname, location.search sind nach pushState aktuell - [ ] Scroll-Position: Scroll wird auf 0,0 zurückgesetzt (vereinfacht) - [ ] Kein popstate beim initialen Page-Load ## Betroffene Dateien | Datei | Änderung | Status | |-------|----------|--------| | `src/fakes/history-patch.ts` | Ersetzen durch SessionHistory | Löschen | | `src/navigation/session-history.ts` | Neu | Neu | | `src/navigation/navigation.ts` | Neu | Neu | | `src/navigation/location-api.ts` | Neu | Neu | | `src/runtime-isolation.ts` | SessionHistory installieren | Ändern | | `src/pages/page.ts` | Navigation-Integration (popstate → goto) | Ändern | | `tests/unit/session-history.test.ts` | Neu | Neu | | `tests/integration/spa-routing.test.ts` | Neu | Neu | ## Dependencies - SessionHistory benötigt keinen Parser (URL-Manipulation pur) - Event Loop Issue #98 für korrektes popstate-Event-Timing - Page.goto() für "echte" Navigation bei popstate ## Technische Risiken - **URL-Resolution**: Relative URLs brauchen korrekte Base (location.href zum Zeitpunkt des pushState) - **Scroll Restoration**: `scrollRestoration = 'auto'`/`'manual'` ist nice-to-have, nicht critical - **Frames/Iframes**: Jeder Frame hat eigene Session History — erstmal Single-Page ## Performance-Impact - ~0 Overhead (kein Network, nur State-Management) - popstate-Event ist O(1), kein Reflow ## Testplan ### Unit (20+) - pushState → history.length, location.href, history.state - replaceState → location.href unverändert, state überschrieben - back()/forward() → popstate-Event mit korrekten Werten - go(0) → kein popstate - go(-100) → clamp an 0 - pushState dann back → popstate + state - Non-string url → relative Resolution - title wird ignoriert (per Spec) - hashchange feuert nur bei Änderung - Kein popstate beim Initial-Load ### Integration (10+) - React Router: pushState → Route-Update → render - Vue Router: replaceState → Route-Update - Qwik: Router-Transition mit pushState - Link-Klick auf `<a href="#section">` → hashchange + Scroll ### E2E (2) - Simple SPA mit 3 Routes via pushState - Back/Forward Browser Buttons (simuliert)
Artur closed this issue 2026-06-19 14:09:49 +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#99
No description provided.