Webpack Module Code Splitting (dynamic import()) — Dynamische <script>-Interception + Ausführung im isolierten Kontext #35

Closed
opened 2026-06-17 19:47:01 +00:00 by Artur · 1 comment
Owner

Problembeschreibung

Unser ScriptLoader (src/js/script-loader.ts) handhabt aktuell alle statischen <script>-Tags, die vom IncrementalParser.preloadScripts() per Regex-Präscan erfasst werden: inline, classic-blocking, defer, async, module.

Webpack-Code-Splitting (und jede andere Form dynamischer Skriptlade-Mechanismen) erzeugt jedoch <script>-Elemente zur Runtime über JS — nicht statisch im HTML:

// Webpack 5 __webpack_require__.e() → createScriptTag()
script = document.createElement('script');
script.src = '/chunks/123.async.js';
script.onload = resolve;
document.head.appendChild(script);

Diese dynamisch erzeugten Skripte werden vom Präscan NICHT erfasst → sie werden nie von unserem ScriptLoader.execute() mit dem var-decl-Wrapper ausgeführt → der Chunk-Code läuft entweder im falschen Kontext (Happy DOM) oder gar nicht.

Konsequenz: Jede Webpack-App mit import()-Code-Splitting bricht beim Laden der asynchronen Chunks.

Architektur-Analyse

Aktuelle Architektur

parser.ts: preloadScripts(html)  →  regex scan nach <script>-Tags
            document.write(html)  →  Happy DOM baut DOM (evaluiert Skripte SELBST)
            onScriptTag()         →  ScriptLoader.execute() mit var-decl IIFE
                                     (2. Evaluation im isolierten Kontext)

Happy DOMs Built-In Script Evaluation

Happy DOMs HTMLScriptElement.connectedToDocument() (Zeile 348-384) evaluiert bei jedem appendChild/insertBefore:

  • Inline-Skripte: #evaluateScript()JavaScriptCompiler.compile()compiled.execute() im Happy-DOM-Window
  • Externe Skripte: #loadScript(url)ResourceFetch.fetch()JavaScriptCompiler → eval im Happy-DOM-Window
  • Module: #loadModule() / #evaluateModule() — gleiches Problem

Dies verursacht:

  1. Doppelte Evaluation statischer Skripte (prägescannt UND Happy DOM nativ)
  2. Falscher Kontextwindow, document, fetch zeigen auf Happy DOM, nicht auf unsere isolierten Proxies
  3. Keine Interception dynamischer Skripte#loadScript wird zwar aufgerufen, aber der Chunk-Code sieht nicht unsere Isolationsschicht

Lösungsansätze

Option A (empfohlen): HTMLScriptElement-Prototyp-Patching + ScriptLoader-Erweiterung

Kernidee: Happy DOMs native Skript-Evaluation deaktivieren → alle Skripte (statische + dynamische) durch unseren ScriptLoader jagen → var-decl IIFE garantiert Isolation.

Teil 1: HTMLScriptElement evaluiert NICHTS mehr selbst

Patch HTMLScriptElement.prototype[PropertySymbol.connectedToDocument]:

  • disableEvaluation = true setzen (existierende Property)
  • connectedToDocument überschreiben: nur noch super.connectedToDocument() → KEIN #evaluateScript(), #loadScript() etc.

Teil 2: Dynamic Script Hook

Neue Datei: src/js/dynamic-scripts.ts

export function installDynamicScriptHandler(scriptLoader: ScriptLoader, happyWindow: Window): void {
  // MutationObserver auf document.head + document.body
  // filtert addedNodes: HTMLScriptElement mit src
  // Wenn ein dynamisches <script> erkannt wird:
  //   - Content fetchen
  //   - document.currentScript setzen
  //   - Im isolierten Kontext ausführen (var-decl IIFE)
  //   - currentScript zurücksetzen
  //   - load-Event feuern
}

Teil 3: ScriptLoader erweitern

// executeRaw(code, url) — führt Code im isolierten Kontext mit var-decl IIFE
async executeRaw(code: string, url?: string): Promise<void>

// fetchContent(url) → public machen (bisher private)
async fetchContent(url: string): Promise<string>

Option B: Nur MutationObserver

Einfacher, aber MutationObserver ist asynchron → Webpack feuert onload bevor Observer feuert → Race Condition.

Option C: Proxy auf document.createElement('script')

document.createElement durch Proxy ersetzen → erfasst createElement('script') und intercepted src-Setter + appendChild.
Webpack kann aber auch über ownerDocument.createElement oder createElementNS gehen.

Entscheidung: Option A (empfohlen)

Kombination aus:

  1. disableEvaluation setzen auf allen Script-Elementen nach DOM-Aufbau
  2. src-Setter-Hook auf HTMLScriptElement.prototype für script.src = url-Interception
  3. MutationObserver als Backup
  4. Optionale Node.prototype.appendChild-Hook für synchrones Intercept

Akzeptanzkriterien

  • document.createElement('script') + src + appendChild → Inhalt wird gefetcht und ausgeführt
  • Ausführung erfolgt im isolierten Kontext (var-decl IIFE mit unserem window/document/fetch)
  • document.currentScript zeigt während der Ausführung auf das Script-Element
  • load-Event wird nach erfolgreicher Ausführung auf dem Script-Element gefeuert
  • error-Event bei fehlgeschlagenem Fetch
  • Webpack __webpack_require__.e() + script.onload-Promise resolved korrekt
  • self['webpackChunk_...'] global funktioniert mit dynamischen Chunks
  • Keine doppelte Evaluation (Happy DOM evaluiert nicht zusätzlich)
  • 100% Code Coverage auf neuen/geänderten Dateien
  • Keine Regression bei bestehenden 813 Tests
  • Integrationstest mit echtem Webpack-Bundle (mit import() dynamischem Import)

Betroffene Dateien

Datei Änderung
src/js/script-loader.ts + executeRaw(code, url?) + fetchContent(url) public + buildVarDecls()
src/js/dynamic-scripts.ts NEU — DynamicScriptHandler + installDynamicScriptHandler()
src/runtime-isolation.ts + dynamic-scripts Patches nach Context-Erstellung
src/pages/page.ts + ScriptLoader an createIsolatedContext weiterreichen
tests/unit/dynamic-scripts.test.ts NEU — alle Dynamic-Script-Szenarien
tests/integration/webpack-code-split.test.ts NEU — Webpack-Bundle mit import()
tests/fixtures/webpack-split/ NEU — Webpack-Testprojekt (minimaler Build)

Dependencies

  • Issue #12 (Script Loader Basis) — vorhanden und funktionsfähig
  • Issue #2 (Runtime Isolation) — var-decl IIFE + window-Proxies
  • Keine externen Dependencies (esbuild ist bereits in package.json)

Querverweise

  • #12 Script Loader: Basis für diese Erweiterung
  • #35 (React UMD/CDN) — var-decl-Wrapper bricht Reacts UMD-Build
  • #34 React Integration Suite — profitiert von korrektem Code-Splitting
  • evaluate.ts — var-injection für page.evaluate()
  • parser.ts — Präscan-Logik für statische Skripte

Technische Risiken

  1. Happy DOM Upgrade: PropertySymbol.connectedToDocument ist intern. Upgrade könnte Struktur ändern. Lösung: Feature-Detection.
  2. Webpack Versionen: v1-4 (JSONP) vs v5 (global-array-push). Lösung: var-decl-Wrapper macht self/window automatisch zu unserem isolierten Window.
  3. Non-Webpack Loader: Vite/Rollup. Lösung: DynamicScriptHandler ist framework-agnostisch.

Performance-Impact

  • MutationObserver auf head + body: 2 Observer (minimal)
  • Prototyp-Patch: einmalig zur Context-Erstellung
  • Fetch dynamischer Skripte: gleicher Mechanismus wie statische
  • Erwartet: < 5% Einbuße bei Seiten ohne dynamische Skripte

Testplan

Unit-Tests (25+)

  1. DynamicScriptHandler initialisiert korrekt
  2. document.createElement('script') wird erkannt
  3. script.src = url triggert Content-Fetch
  4. appendChild(script) triggert Ausführung mit var-decl IIFE
  5. document.currentScript ist gesetzt während Ausführung
  6. document.currentScript ist null nach Ausführung
  7. load-Event wird gefeuert
  8. error-Event bei Fetch-Fehler
  9. disableEvaluation wird gesetzt
  10. self['webpackChunk_...'] ist auf isoliertem Window

Integration-Tests (5+)

  1. Webpack-5-Bundle mit import('./module') → Chunk wird geladen
  2. Webpack 5 multiple Chunks korrekt
  3. Webpack 4 JSONP-Chunk via globalem Callback
  4. onload/onerror Weiterleitung
  5. Chunk-Zugriff auf webpack_require aus Bootstrap

E2E-Test (1)

Echter Webpack-Build + Static-Server + Page.goto() → alle Chunks laden

## Problembeschreibung Unser **ScriptLoader** (src/js/script-loader.ts) handhabt aktuell alle **statischen** `<script>`-Tags, die vom `IncrementalParser.preloadScripts()` per Regex-Präscan erfasst werden: inline, classic-blocking, defer, async, module. **Webpack-Code-Splitting** (und jede andere Form dynamischer Skriptlade-Mechanismen) erzeugt jedoch `<script>`-Elemente **zur Runtime über JS** — nicht statisch im HTML: ```js // Webpack 5 __webpack_require__.e() → createScriptTag() script = document.createElement('script'); script.src = '/chunks/123.async.js'; script.onload = resolve; document.head.appendChild(script); ``` Diese dynamisch erzeugten Skripte werden vom Präscan NICHT erfasst → sie werden nie von unserem `ScriptLoader.execute()` mit dem var-decl-Wrapper ausgeführt → der Chunk-Code läuft entweder im falschen Kontext (Happy DOM) oder gar nicht. **Konsequenz:** Jede Webpack-App mit `import()`-Code-Splitting bricht beim Laden der asynchronen Chunks. ## Architektur-Analyse ### Aktuelle Architektur ``` parser.ts: preloadScripts(html) → regex scan nach <script>-Tags document.write(html) → Happy DOM baut DOM (evaluiert Skripte SELBST) onScriptTag() → ScriptLoader.execute() mit var-decl IIFE (2. Evaluation im isolierten Kontext) ``` ### Happy DOMs Built-In Script Evaluation Happy DOMs `HTMLScriptElement.connectedToDocument()` (Zeile 348-384) evaluiert bei jedem `appendChild`/`insertBefore`: - **Inline-Skripte:** `#evaluateScript()` → `JavaScriptCompiler.compile()` → `compiled.execute()` im Happy-DOM-Window - **Externe Skripte:** `#loadScript(url)` → `ResourceFetch.fetch()` → `JavaScriptCompiler` → eval im Happy-DOM-Window - **Module:** `#loadModule()` / `#evaluateModule()` — gleiches Problem Dies verursacht: 1. **Doppelte Evaluation** statischer Skripte (prägescannt UND Happy DOM nativ) 2. **Falscher Kontext** — `window`, `document`, `fetch` zeigen auf Happy DOM, nicht auf unsere isolierten Proxies 3. **Keine Interception dynamischer Skripte** — `#loadScript` wird zwar aufgerufen, aber der Chunk-Code sieht nicht unsere Isolationsschicht ## Lösungsansätze ### Option A (empfohlen): HTMLScriptElement-Prototyp-Patching + ScriptLoader-Erweiterung **Kernidee:** Happy DOMs native Skript-Evaluation deaktivieren → alle Skripte (statische + dynamische) durch unseren ScriptLoader jagen → var-decl IIFE garantiert Isolation. **Teil 1: HTMLScriptElement evaluiert NICHTS mehr selbst** Patch `HTMLScriptElement.prototype[PropertySymbol.connectedToDocument]`: - `disableEvaluation = true` setzen (existierende Property) - `connectedToDocument` überschreiben: nur noch `super.connectedToDocument()` → KEIN `#evaluateScript()`, `#loadScript()` etc. **Teil 2: Dynamic Script Hook** Neue Datei: `src/js/dynamic-scripts.ts` ```ts export function installDynamicScriptHandler(scriptLoader: ScriptLoader, happyWindow: Window): void { // MutationObserver auf document.head + document.body // filtert addedNodes: HTMLScriptElement mit src // Wenn ein dynamisches <script> erkannt wird: // - Content fetchen // - document.currentScript setzen // - Im isolierten Kontext ausführen (var-decl IIFE) // - currentScript zurücksetzen // - load-Event feuern } ``` **Teil 3: ScriptLoader erweitern** ```ts // executeRaw(code, url) — führt Code im isolierten Kontext mit var-decl IIFE async executeRaw(code: string, url?: string): Promise<void> // fetchContent(url) → public machen (bisher private) async fetchContent(url: string): Promise<string> ``` ### Option B: Nur MutationObserver Einfacher, aber MutationObserver ist asynchron → Webpack feuert `onload` bevor Observer feuert → Race Condition. ### Option C: Proxy auf document.createElement('script') `document.createElement` durch Proxy ersetzen → erfasst `createElement('script')` und intercepted `src`-Setter + `appendChild`. Webpack kann aber auch über `ownerDocument.createElement` oder `createElementNS` gehen. ## Entscheidung: Option A (empfohlen) Kombination aus: 1. **disableEvaluation setzen** auf allen Script-Elementen nach DOM-Aufbau 2. **src-Setter-Hook** auf HTMLScriptElement.prototype für `script.src = url`-Interception 3. **MutationObserver** als Backup 4. **Optionale Node.prototype.appendChild-Hook** für synchrones Intercept ## Akzeptanzkriterien - [ ] `document.createElement('script')` + `src` + `appendChild` → Inhalt wird gefetcht und ausgeführt - [ ] Ausführung erfolgt im isolierten Kontext (var-decl IIFE mit unserem window/document/fetch) - [ ] `document.currentScript` zeigt während der Ausführung auf das Script-Element - [ ] `load`-Event wird nach erfolgreicher Ausführung auf dem Script-Element gefeuert - [ ] `error`-Event bei fehlgeschlagenem Fetch - [ ] Webpack `__webpack_require__.e()` + `script.onload`-Promise resolved korrekt - [ ] `self['webpackChunk_...']` global funktioniert mit dynamischen Chunks - [ ] Keine doppelte Evaluation (Happy DOM evaluiert nicht zusätzlich) - [ ] 100% Code Coverage auf neuen/geänderten Dateien - [ ] Keine Regression bei bestehenden 813 Tests - [ ] Integrationstest mit echtem Webpack-Bundle (mit `import()` dynamischem Import) ## Betroffene Dateien | Datei | Änderung | |-------|----------| | `src/js/script-loader.ts` | + `executeRaw(code, url?)` + `fetchContent(url)` public + `buildVarDecls()` | | `src/js/dynamic-scripts.ts` | **NEU** — DynamicScriptHandler + installDynamicScriptHandler() | | `src/runtime-isolation.ts` | + dynamic-scripts Patches nach Context-Erstellung | | `src/pages/page.ts` | + ScriptLoader an createIsolatedContext weiterreichen | | `tests/unit/dynamic-scripts.test.ts` | **NEU** — alle Dynamic-Script-Szenarien | | `tests/integration/webpack-code-split.test.ts` | **NEU** — Webpack-Bundle mit import() | | `tests/fixtures/webpack-split/` | **NEU** — Webpack-Testprojekt (minimaler Build) | ## Dependencies - Issue #12 (Script Loader Basis) — vorhanden und funktionsfähig - Issue #2 (Runtime Isolation) — var-decl IIFE + window-Proxies - Keine externen Dependencies (esbuild ist bereits in package.json) ## Querverweise - #12 Script Loader: Basis für diese Erweiterung - #35 (React UMD/CDN) — var-decl-Wrapper bricht Reacts UMD-Build - #34 React Integration Suite — profitiert von korrektem Code-Splitting - `evaluate.ts` — var-injection für page.evaluate() - `parser.ts` — Präscan-Logik für statische Skripte ## Technische Risiken 1. **Happy DOM Upgrade:** `PropertySymbol.connectedToDocument` ist intern. Upgrade könnte Struktur ändern. Lösung: Feature-Detection. 2. **Webpack Versionen:** v1-4 (JSONP) vs v5 (global-array-push). Lösung: var-decl-Wrapper macht `self`/`window` automatisch zu unserem isolierten Window. 3. **Non-Webpack Loader:** Vite/Rollup. Lösung: DynamicScriptHandler ist framework-agnostisch. ## Performance-Impact - MutationObserver auf head + body: 2 Observer (minimal) - Prototyp-Patch: einmalig zur Context-Erstellung - Fetch dynamischer Skripte: gleicher Mechanismus wie statische - Erwartet: < 5% Einbuße bei Seiten ohne dynamische Skripte ## Testplan ### Unit-Tests (25+) 1. DynamicScriptHandler initialisiert korrekt 2. document.createElement('script') wird erkannt 3. script.src = url triggert Content-Fetch 4. appendChild(script) triggert Ausführung mit var-decl IIFE 5. document.currentScript ist gesetzt während Ausführung 6. document.currentScript ist null nach Ausführung 7. load-Event wird gefeuert 8. error-Event bei Fetch-Fehler 9. disableEvaluation wird gesetzt 10. self['webpackChunk_...'] ist auf isoliertem Window ### Integration-Tests (5+) 1. Webpack-5-Bundle mit import('./module') → Chunk wird geladen 2. Webpack 5 multiple Chunks korrekt 3. Webpack 4 JSONP-Chunk via globalem Callback 4. onload/onerror Weiterleitung 5. Chunk-Zugriff auf __webpack_require__ aus Bootstrap ### E2E-Test (1) Echter Webpack-Build + Static-Server + Page.goto() → alle Chunks laden
Author
Owner

Webpack Module Code Splitting (dynamic import()). Implementiert via DynamicScriptHandler + executeRaw. Überschneidet mit #36 (geschlossen).

Webpack Module Code Splitting (dynamic import()). ✅ Implementiert via DynamicScriptHandler + executeRaw. Überschneidet mit #36 (geschlossen).
Artur closed this issue 2026-06-18 06:28:05 +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#35
No description provided.