fix(mermaid): render diagrams on web via JS-interop (#851) #860

Merged
brenno merged 2 commits from fix/851-mermaid-web-render into main 2026-07-25 11:50:12 +00:00
Owner

Sluit #851.

De bug

Een beslisboom-slide (vrije Markdown met een mermaid-codeblok) bleef op web een leeg vlak. De oorzaak zat een laag dieper dan de web-CSP: de renderer tekent de diagrammen in een verborgen WebView en leest de SVG terug met runJavaScriptReturningResult — en webview_flutter_web implementeert die methode niet (erft de UnimplementedError uit PlatformWebViewController). Elke render gaf daardoor stil null en de preview viel terug op een leeg kader. Op desktop, met een echte WebView, werkte het wél.

De fix

Op web draait mermaid nu rechtstreeks in de app-pagina in plaats van in een (niet-functionele) WebView:

  • de gebundelde mermaid.min.js wordt als eigen-origin <script src> geladen — dat mag onder de strikte app-CSP (script-src 'self'), en mermaid heeft geen eval nodig (de WebView-bootstrap draaide het al onder een CSP zonder unsafe-eval);
  • mermaid.render gaat via dart:js_interop (+ package:web);
  • een conditional import houdt de WebView-renderer op desktop/mobiel;
  • de mermaid-instellingen staan gedeeld in mermaid_config.dart (kMermaidInitConfig), gebruikt door beide paden (JSON in de WebView-pagina, .jsify() op web), zodat securityLevel: 'strict' en de SVG-opschoning niet uiteen kunnen lopen.

Verificatie

De echte web-render is onder flutter test niet uit te voeren (kIsWeb is er altijd false; dart:js_interop compileert niet op de VM). Daarom:

  • CSP-rooktest: de gebundelde mermaid, geladen via <script src> onder de exacte app-CSP, rendert een geldige SVG (13.896 tekens) zonder console-/CSP-fouten;
  • In de echte webbouw (make build-web, geserveerd): de app boot onder de productie-CSP, en mermaid.render levert een geldige SVG in de Flutter-web-DOM (bodyHasFlutter=true), geladen van de asset-URL die de renderer gebruikt;
  • Regressiepoort mermaid_web_render_test: bewaakt de structuur waar de bug in zat (gedeelde config is strikt, de web-script-URL klopt met wat pubspec bundelt, de service kiest achter kIsWeb het web-pad, beide paden gebruiken kMermaidInitConfig). De io-stub wordt rechtstreeks gedekt.

Security-review

Beoordeeld door de security-architect (rolblik): akkoord. De vertrouwensgrens verschuift (geïsoleerde WebView → in-page), maar de app-CSP is op script-uitvoering zelfs strikter dan de oude WebView (die script-src 'unsafe-inline' toestond); vier lagen dekken de transiënte DOM-insertie (securityLevel strict + DOMPurify, htmlLabels uit, de app-CSP, en sanitizeMermaidSvg + render via flutter_svg zodat de SVG nooit als HTML de app-DOM in komt). Geen nieuw netwerkverkeer (zelfde-origin asset), fail-closed. Twee bevindingen weggewerkt: (1) de CSP-comment in web/index.html schreef de frame-src blob:/data:-ontheffing nog aan de mermaid-WebView toe — die dient op web nu de video-embed; (2) een mislukte scriptlading wordt niet meer permanent onthouden.

Poorten

make check groen (na rebase op de nieuwste main), make check-secrets schoon, make sast 0 findings. DAST n.v.t. (geen serveroppervlak geraakt).

Bewaker

Geen wijziging aan bestandsformaat, opslag of afhankelijkheid, en geen nieuwe vertrouwde partij (de mermaid-bundel was al gebundeld en in gebruik). De trust-boundary-verschuiving is door de security-architect gewogen; een aparte bewakersronde was niet nodig.

Sluit #851. ## De bug Een beslisboom-slide (vrije Markdown met een mermaid-codeblok) bleef op web een leeg vlak. De oorzaak zat een laag dieper dan de web-CSP: de renderer tekent de diagrammen in een verborgen WebView en leest de SVG terug met `runJavaScriptReturningResult` — en `webview_flutter_web` **implementeert die methode niet** (erft de `UnimplementedError` uit `PlatformWebViewController`). Elke render gaf daardoor stil `null` en de preview viel terug op een leeg kader. Op desktop, met een echte WebView, werkte het wél. ## De fix Op web draait mermaid nu rechtstreeks in de app-pagina in plaats van in een (niet-functionele) WebView: - de gebundelde `mermaid.min.js` wordt als **eigen-origin** `<script src>` geladen — dat mag onder de strikte app-CSP (`script-src 'self'`), en mermaid heeft geen `eval` nodig (de WebView-bootstrap draaide het al onder een CSP zonder `unsafe-eval`); - `mermaid.render` gaat via `dart:js_interop` (+ `package:web`); - een **conditional import** houdt de WebView-renderer op desktop/mobiel; - de mermaid-instellingen staan gedeeld in `mermaid_config.dart` (`kMermaidInitConfig`), gebruikt door **beide** paden (JSON in de WebView-pagina, `.jsify()` op web), zodat `securityLevel: 'strict'` en de SVG-opschoning niet uiteen kunnen lopen. ## Verificatie De echte web-render is onder `flutter test` niet uit te voeren (`kIsWeb` is er altijd false; `dart:js_interop` compileert niet op de VM). Daarom: - **CSP-rooktest**: de gebundelde mermaid, geladen via `<script src>` onder de exacte app-CSP, rendert een geldige SVG (13.896 tekens) zonder console-/CSP-fouten; - **In de echte webbouw** (`make build-web`, geserveerd): de app boot onder de productie-CSP, en `mermaid.render` levert een geldige SVG in de Flutter-web-DOM (`bodyHasFlutter=true`), geladen van de asset-URL die de renderer gebruikt; - **Regressiepoort** `mermaid_web_render_test`: bewaakt de structuur waar de bug in zat (gedeelde config is strikt, de web-script-URL klopt met wat pubspec bundelt, de service kiest achter `kIsWeb` het web-pad, beide paden gebruiken `kMermaidInitConfig`). De io-stub wordt rechtstreeks gedekt. ## Security-review Beoordeeld door de security-architect (rolblik): **akkoord**. De vertrouwensgrens verschuift (geïsoleerde WebView → in-page), maar de app-CSP is op script-uitvoering zelfs strikter dan de oude WebView (die `script-src 'unsafe-inline'` toestond); vier lagen dekken de transiënte DOM-insertie (securityLevel strict + DOMPurify, htmlLabels uit, de app-CSP, en `sanitizeMermaidSvg` + render via flutter_svg zodat de SVG nooit als HTML de app-DOM in komt). Geen nieuw netwerkverkeer (zelfde-origin asset), fail-closed. Twee bevindingen weggewerkt: (1) de CSP-comment in `web/index.html` schreef de `frame-src blob:/data:`-ontheffing nog aan de mermaid-WebView toe — die dient op web nu de video-embed; (2) een mislukte scriptlading wordt niet meer permanent onthouden. ## Poorten `make check` groen (na rebase op de nieuwste main), `make check-secrets` schoon, `make sast` 0 findings. DAST n.v.t. (geen serveroppervlak geraakt). ## Bewaker Geen wijziging aan bestandsformaat, opslag of afhankelijkheid, en geen nieuwe vertrouwde partij (de mermaid-bundel was al gebundeld en in gebruik). De trust-boundary-verschuiving is door de security-architect gewogen; een aparte bewakersronde was niet nodig.
Een beslisboom-slide bleef op web een leeg vlak. De oorzaak zat dieper dan de
CSP: de renderer tekent in een verborgen WebView en leest de SVG terug met
runJavaScriptReturningResult — en webview_flutter_web implementeert die methode
niet (erft de UnimplementedError uit de basisklasse). Elke render gaf stil null
en de preview viel terug op een leeg kader. Op desktop, met een echte WebView,
werkte het wél.

Op web draait mermaid nu rechtstreeks in de app-pagina: de gebundelde
mermaid.min.js wordt als eigen-origin <script src> geladen (mag onder de strikte
app-CSP script-src 'self'; mermaid heeft geen eval nodig) en mermaid.render gaat
via dart:js_interop. Een conditional import houdt de WebView op desktop/mobiel.
De instellingen staan gedeeld in mermaid_config.dart (kMermaidInitConfig),
gebruikt door beide paden, zodat securityLevel 'strict' en de SVG-opschoning niet
uiteen kunnen lopen. Een mislukte scriptlading wordt niet permanent onthouden.

web/index.html: de CSP-toelichting bij de frame-src/child-src blob:/data:-
ontheffing klopte niet meer — op web mount die WebView niet meer; de ontheffing
dient nu de video-embed (data:-URI-iframe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test(mermaid): guard the web render path and register it (#851)
All checks were successful
scans / scans (pull_request) Successful in 3m18s
e8bb147d95
De echte web-render is onder flutter test niet uit te voeren (kIsWeb is er altijd
false, dart:js_interop compileert niet op de VM), dus mermaid_web_render_test
bewaakt de structuur waar de bug in zat: de gedeelde config is strikt, de
web-script-URL klopt met wat pubspec bundelt, de service kiest achter kIsWeb het
web-pad, en beide paden gebruiken kMermaidInitConfig. De io-stub wordt
rechtstreeks gedekt. Het echte renderen is visueel + via een JS-interop-proef in
de echte webbouw geverifieerd (mermaid.render levert geldige SVG in de Flutter-
DOM onder de productie-CSP).

mermaid_render_pipeline_test: de WebView-bootstrap zet de config nu als JSON
(jsonEncode(kMermaidInitConfig)) in plaats van als inline JS-literal; de assertie
volgt dat. coverage_summary: mermaid_config.dart + mermaid_web_renderer.dart in
uncoveredBaseline (const-only resp. web-platformhelft). SOURCE_MAP + CHANGELOG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
brenno merged commit 4cca3aeb2b into main 2026-07-25 11:50:12 +00:00
Sign in to join this conversation.
No description provided.