Lesson #1454

← Back to Knowledge Board
AJAX-Fragment-Swap statt Full-Page-Reload für public-Tab/Nav-Wechsel
ID
1454
Author
ai
Agent
agent-claude
Reviewed
✓ Yes
Source authority
75 / 100
Source
Yoga/cmoments Pattern — wenn ein Klick auf einen Tab/Pagination-Link nur einen Box-Inhalt austauscht, muss er via AJAX-Fragment laden, nicht full-page reload. Helper `helpers/fragment_swap.py`.
Source issue
Created at
2026-05-12T10:00:22.958610+00:00
Valid until
Deprecated at
Supersedes
Obsidian path
/root/.claude/projects/-nvmetank1-projects/memory/feedback_fragment_swap_pattern.md
Obsidian hash
9cea426fdd75176bad1fa3e778f2c81e
Tags
claude-memory,feedback

Content

Public-Page Tab- oder Pagination-Wechsel (z.B. Magic-Link↔PIN↔Passkey, Kalender prev/next-Monat) MÜSSEN als AJAX-Fragment-Swap implementiert werden, nicht als Full-Page-Reload. Sonst flackert der ganze Frame (Hero, Nav, Footer) und es springt.

**Helper**: `helpers/fragment_swap.py`
- `wants_fragment()` — True wenn fetch() das Fragment-only HTML will (Header `X-Requested-With: fetch` + Query `?fragment=1`)
- `fragment_swap_script(box_sel, link_sel, history_key, bound_flag)` — emittiert das `<script>`, das alle `<a>`-Klicks auf `link_sel` abfängt, das HTML via fetch() lädt und mit DOMParser sicher in `box_sel` einsetzt + `history.pushState`. Bei Errors transparenter Fallback auf full-page-Reload.

**Implementierungs-Pattern** (Vorlage für neue Tab-/Pagination-Pages):
```python
from helpers.fragment_swap import wants_fragment, fragment_swap_script

# Modul-level: Script einmalig generieren mit eindeutigem history_key/bound_flag.
_MY_SWAP_JS = fragment_swap_script(
    box_sel=".my-swappable-box",
    link_sel=".my-swappable-box a.my-nav-btn",
    history_key="myFeature",
    bound_flag="myFeatureBound",
)

# In der Render-Funktion:
fragment_html = f'<div class="my-swappable-box">...nav-links + content...</div>{_MY_SWAP_JS}'

if wants_fragment():
    # Page-Router-Routes: Response statt str zurueckgeben, damit
    # _wrap_or_return das Layout NICHT umlegt.
    from flask import make_response
    resp = make_response(fragment_html, 200)
    resp.headers["Content-Type"] = "text/html; charset=utf-8"
    return resp

return _layout("Title", body=full_page_with_fragment, hero=hero)
```

**Why:**
User 2026-05-06: "und wieso fullpage load und nicht einfach nur das div laden?" + "richtig, speichere dieses pattern und schaue was du modularisieren kannst." Examples deployed: `/anmelden` Tab-Wechsel zwischen Magic-Link/PIN/Passkey + `/kurse/kalender` Monats-Nav.

**How to apply:**
- Bei JEDEM neuen Public-Page-Feature mit Tab-Switch oder Pagination zuerst prüfen: kann fragment-swap statt full-reload? Default: ja.
- Box-Selektor stabil halten (kein Hash-Suffix oder dyn. ID), sonst kann DOMParser nicht swap'en.
- Bei page-router-Routes (`_rp(...)`) Response-Object zurückgeben für fragment-mode (nicht str), damit der layout-wrap übersprungen wird.
- bound_flag pro Feature unique sein lassen (window-namespace), damit zwei verschiedene Swap-Loops auf der gleichen Seite koexistieren können.
- DOMParser-Pattern nutzen (XSS-safe), nicht `innerHTML =` — Security-Hook blockt letzteres.

**KRITISCH — capture-phase + stopImmediatePropagation:**
Public-Layout bindet auf JEDEM `<a href>` einen direkten Click-Handler via
`document.startViewTransition(() => window.location.href = href)`. Das ist
ein Full-Page-Reload mit View-Transition-Animation. Direkt-Handler auf
`<a>` feuern in TARGET-Phase BEVOR document-bubble-Handler. Heißt: ein
naiver `document.addEventListener('click', ...)` im Bubble kommt zu spät —
die Seite ist schon am Navigieren.

**Fix** (im Helper bereits implementiert): Capture-Phase + stopImmediatePropagation:
```js
document.addEventListener('click', function(e){
  var a = e.target.closest(LINK_SEL);
  if (!a) return;
  e.preventDefault();
  e.stopImmediatePropagation();  // <- killt den ViewTransition-Hijacker
  swap(a.getAttribute('href')).catch(...);
}, true);  // <- capture phase
```

Symptom wenn vergessen: "Seite springt nach oben beim vor/zurück-Schalten" —
weil sie tatsächlich neu lädt. User 2026-05-06 hat das dreimal hintereinander
gemeldet (scrollRestoration + savedScroll + popstate-fix halfen alle nichts),
bevor wir den ViewTransition-Hijacker als Ursache identifiziert haben.

**Why:**
User 2026-05-06: "und wieso fullpage load und nicht einfach nur das div laden?" + "richtig, speichere dieses pattern und schaue was du modularisieren kannst." Examples deployed: `/anmelden` Tab-Wechsel zwischen Magic-Link/PIN/Passkey + `/kurse/kalender` Monats-Nav. Capture-Phase-Bug 2026-05-06 nach drei "springt immer noch"-Iterationen entdeckt.