fix(ui): use head= for NiiVue loading, not dynamic import in js_on_load (#24) (#28)
Browse files* fix(ui): use head= for NiiVue loading, not dynamic import in js_on_load (#24)
ROOT CAUSE (proven by A/B test in docs/specs/24-bug-hf-spaces-loading-forever.md):
Dynamic import() inside gr.HTML(js_on_load=...) blocks Gradio's Svelte
hydration on HuggingFace Spaces, causing the "Loading..." spinner forever.
The A/B test showed: disabling js_on_load makes the app load perfectly.
Everything worked EXCEPT the Interactive 3D viewer (as expected).
FIX:
1. Load NiiVue via head= parameter BEFORE Gradio hydrates
2. js_on_load just USES window.Niivue (NO dynamic imports)
3. If NiiVue fails to load, show graceful error (don't block app)
This architecture matches Gradio maintainer guidance (GitHub Issue #11649)
and aligns with our own proven diagnostic test.
Changes:
- viewer.py: Remove import() from NIIVUE_ON_LOAD_JS and NIIVUE_UPDATE_JS
- viewer.py: Remove data-niivue-url attribute (no longer needed)
- ui/app.py: Add head=get_niivue_head_html() to launch()
- app.py: Same changes for local dev entry point
- ROOT_CAUSE_ANALYSIS.md: Document first-principles analysis and evidence
All 136 tests pass. Lint and mypy clean.
* docs: add web research findings - Gradio is the issue, not HF Spaces
* docs: add Gradio + WebGL analysis - root cause is fighting Gradio architecture
- GRADIO_WEBGL_ANALYSIS.md +133 -0
- ROOT_CAUSE_ANALYSIS.md +230 -0
- app.py +13 -3
- src/stroke_deepisles_demo/ui/app.py +13 -4
- src/stroke_deepisles_demo/ui/viewer.py +37 -77
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Gradio + WebGL/NiiVue Analysis
|
| 2 |
+
|
| 3 |
+
**Date:** 2025-12-10
|
| 4 |
+
**Context:** Understanding why NiiVue (WebGL) doesn't work in Gradio on HF Spaces
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## Why Are We Using Gradio?
|
| 9 |
+
|
| 10 |
+
**What Gradio provides:**
|
| 11 |
+
- Quick ML demo UIs with Python only (no frontend code needed)
|
| 12 |
+
- Built-in components: file upload, sliders, dropdowns, image display
|
| 13 |
+
- Easy deployment to HuggingFace Spaces
|
| 14 |
+
- Handles backend/frontend communication automatically
|
| 15 |
+
|
| 16 |
+
**What Gradio does NOT provide:**
|
| 17 |
+
- Native support for NIfTI/DICOM medical imaging (closed as "not planned" - [Issue #4511](https://github.com/gradio-app/gradio/issues/4511))
|
| 18 |
+
- Native WebGL canvas component (closed as "not planned" - [Issue #7649](https://github.com/gradio-app/gradio/issues/7649))
|
| 19 |
+
- Clean way to embed custom WebGL libraries like NiiVue
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## The Root Cause: We're Fighting Gradio's Architecture
|
| 24 |
+
|
| 25 |
+
### What We're Trying To Do
|
| 26 |
+
Embed NiiVue (a WebGL2 library) into `gr.HTML` using JavaScript.
|
| 27 |
+
|
| 28 |
+
### Why It Doesn't Work
|
| 29 |
+
1. **`gr.HTML` strips `<script>` tags** - Security feature
|
| 30 |
+
2. **`js_on_load` with `import()` blocks Svelte hydration** - Our proven root cause
|
| 31 |
+
3. **`head=` parameter still uses ES module import** - May have same issue
|
| 32 |
+
|
| 33 |
+
### Gradio's Official Stance
|
| 34 |
+
From Gradio maintainer Abubakar Abid on both issues:
|
| 35 |
+
> "We are not planning to include this in the core Gradio library."
|
| 36 |
+
> "We've now made it possible for Gradio users to create their own custom components."
|
| 37 |
+
|
| 38 |
+
**The official answer is: Create a Gradio Custom Component.**
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
## The Four Options (Ranked by Effort)
|
| 43 |
+
|
| 44 |
+
### Option 1: Keep Hacking `gr.HTML` (Current Approach)
|
| 45 |
+
- **Effort:** Low
|
| 46 |
+
- **Success probability:** 30%
|
| 47 |
+
- **What we're trying:** `head=`, `demo.load(_js=...)`, `gr.Blocks(js=...)`
|
| 48 |
+
- **Problem:** Fighting Gradio's architecture
|
| 49 |
+
|
| 50 |
+
### Option 2: Create a Gradio Custom Component
|
| 51 |
+
- **Effort:** Medium (2-3 days)
|
| 52 |
+
- **Success probability:** 90%
|
| 53 |
+
- **What it is:** A proper Svelte + Python component that wraps NiiVue
|
| 54 |
+
- **Why it works:** This is the official Gradio way to add WebGL
|
| 55 |
+
- **Resources:**
|
| 56 |
+
- [Custom Components Guide](https://www.gradio.app/guides/custom-components-in-five-minutes)
|
| 57 |
+
- [gradio-litmodel3d](https://pypi.org/project/gradio-litmodel3d/) - Example WebGL custom component
|
| 58 |
+
- [Custom Components Gallery](https://www.gradio.app/custom-components/gallery)
|
| 59 |
+
|
| 60 |
+
### Option 3: Static HTML Space (No Gradio)
|
| 61 |
+
- **Effort:** High (rebuild entire UI)
|
| 62 |
+
- **Success probability:** 99%
|
| 63 |
+
- **What it is:** Pure HTML/CSS/JS app on HF Spaces
|
| 64 |
+
- **Why it works:** WebGL works perfectly (Unity, Three.js examples exist)
|
| 65 |
+
- **Downside:** Lose Gradio's nice features (file upload UX, etc.)
|
| 66 |
+
|
| 67 |
+
### Option 4: 2D Slice Fallback (Remove NiiVue Entirely)
|
| 68 |
+
- **Effort:** Low
|
| 69 |
+
- **Success probability:** 100%
|
| 70 |
+
- **What it is:** Use Matplotlib 2D slices instead of 3D WebGL viewer
|
| 71 |
+
- **Why it works:** Already works (Static Report tab)
|
| 72 |
+
- **Downside:** No interactive 3D visualization
|
| 73 |
+
|
| 74 |
+
---
|
| 75 |
+
|
| 76 |
+
## Comparison: Custom Component vs Static HTML
|
| 77 |
+
|
| 78 |
+
| Aspect | Custom Component | Static HTML |
|
| 79 |
+
|--------|------------------|-------------|
|
| 80 |
+
| Keep Gradio features | Yes | No |
|
| 81 |
+
| File upload UX | Built-in | Must build |
|
| 82 |
+
| Sliders/dropdowns | Built-in | Must build |
|
| 83 |
+
| HF Spaces deployment | Works | Works |
|
| 84 |
+
| Development time | 2-3 days | 3-5 days |
|
| 85 |
+
| Maintainability | Better (Gradio handles updates) | Worse (all custom) |
|
| 86 |
+
|
| 87 |
+
---
|
| 88 |
+
|
| 89 |
+
## Recommendation
|
| 90 |
+
|
| 91 |
+
**If current PR #28 fails:**
|
| 92 |
+
|
| 93 |
+
1. **First try:** `demo.load(_js=...)` approach (1 hour)
|
| 94 |
+
2. **If that fails:** Create a Gradio Custom Component for NiiVue (2-3 days)
|
| 95 |
+
3. **Nuclear option:** Static HTML Space or remove 3D viewer entirely
|
| 96 |
+
|
| 97 |
+
**The Custom Component approach is the "correct" solution** - it's what Gradio maintainers recommend for WebGL content. We've been trying to hack around Gradio instead of working with it.
|
| 98 |
+
|
| 99 |
+
---
|
| 100 |
+
|
| 101 |
+
## Existing Work We Can Reference
|
| 102 |
+
|
| 103 |
+
1. **[gradio-litmodel3d](https://pypi.org/project/gradio-litmodel3d/)** - WebGL Model3D with HDR support
|
| 104 |
+
2. **[Unet-nifti-gradio](https://github.com/benjaminirving/Unet-nifti-gradio)** - NIfTI + Gradio integration
|
| 105 |
+
3. **[papaya-image-viewer-gradio](https://github.com/gradio-app/gradio/issues/4511)** - Medical imaging viewer mentioned in Issue #4511
|
| 106 |
+
4. **[NiiVue docs](https://niivue.com/docs/)** - Official NiiVue integration guide
|
| 107 |
+
|
| 108 |
+
---
|
| 109 |
+
|
| 110 |
+
## Answer: "What Does Gradio Unblock?"
|
| 111 |
+
|
| 112 |
+
**Gradio unblocks:**
|
| 113 |
+
- UI/UX components (dropdowns, sliders, file upload, etc.)
|
| 114 |
+
- Backend/frontend communication
|
| 115 |
+
- Easy HF Spaces deployment
|
| 116 |
+
- Python-only development (no JS required for basic apps)
|
| 117 |
+
|
| 118 |
+
**Gradio does NOT unblock:**
|
| 119 |
+
- Custom WebGL content (you need a Custom Component)
|
| 120 |
+
- Medical imaging formats (NIfTI, DICOM)
|
| 121 |
+
- Advanced JavaScript integrations
|
| 122 |
+
|
| 123 |
+
**If we go Static HTML:** Yes, we'd have to write all the HTML/CSS/JS ourselves, including file upload handling, UI layout, etc. That's what Gradio provides "for free."
|
| 124 |
+
|
| 125 |
+
---
|
| 126 |
+
|
| 127 |
+
## Sources
|
| 128 |
+
|
| 129 |
+
- [HF Forum: Gradio HTML with JS](https://discuss.huggingface.co/t/gradio-html-component-with-javascript-code-dont-work/37316)
|
| 130 |
+
- [Gradio Issue #4511: 3D Medical Images](https://github.com/gradio-app/gradio/issues/4511)
|
| 131 |
+
- [Gradio Issue #7649: WebGL Canvas](https://github.com/gradio-app/gradio/issues/7649)
|
| 132 |
+
- [Gradio Custom Components Guide](https://www.gradio.app/guides/custom-components-in-five-minutes)
|
| 133 |
+
- [HF Unity WebGL Template](https://github.com/huggingface/Unity-WebGL-template-for-Hugging-Face-Spaces)
|
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Root Cause Analysis: HF Spaces "Loading..." Forever (Issue #24)
|
| 2 |
+
|
| 3 |
+
**Date:** 2025-12-10
|
| 4 |
+
**Status:** IN PROGRESS
|
| 5 |
+
**Branch:** `debug/niivue-head-script-loading`
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Executive Summary
|
| 10 |
+
|
| 11 |
+
The HuggingFace Spaces app hangs on "Loading..." indefinitely because **dynamic ES module `import()` inside `gr.HTML(js_on_load=...)` blocks Gradio's Svelte frontend from hydrating**.
|
| 12 |
+
|
| 13 |
+
This was proven empirically in our own A/B test documented in `docs/specs/24-bug-hf-spaces-loading-forever.md`:
|
| 14 |
+
|
| 15 |
+
> **Diagnostic test:** Disabled `js_on_load` parameter entirely.
|
| 16 |
+
> **Result:** App loads perfectly! Everything works EXCEPT Interactive 3D viewer.
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## First Principles Analysis
|
| 21 |
+
|
| 22 |
+
### How Gradio Renders
|
| 23 |
+
|
| 24 |
+
1. Server sends initial HTML with loading spinner
|
| 25 |
+
2. Gradio's Svelte app downloads and hydrates
|
| 26 |
+
3. Components mount, including `gr.HTML`
|
| 27 |
+
4. `js_on_load` executes during component mount
|
| 28 |
+
5. Loading spinner clears when hydration completes
|
| 29 |
+
|
| 30 |
+
### Why Dynamic Import Blocks Hydration
|
| 31 |
+
|
| 32 |
+
The current `js_on_load` code does this (viewer.py:497):
|
| 33 |
+
|
| 34 |
+
```javascript
|
| 35 |
+
const module = await import(niivueUrl); // <-- BLOCKS HYDRATION
|
| 36 |
+
window.Niivue = module.Niivue;
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
**Problem:** If this `import()` hangs (CSP issues, network issues, MIME type issues, etc.), the async IIFE never resolves, and Svelte's mount lifecycle stalls. HF Spaces silently blocks or delays these imports.
|
| 40 |
+
|
| 41 |
+
### Why `head=` Works
|
| 42 |
+
|
| 43 |
+
The `head=` parameter injects content into `<head>` BEFORE Gradio hydrates:
|
| 44 |
+
|
| 45 |
+
```html
|
| 46 |
+
<head>
|
| 47 |
+
<!-- Injected by head= -->
|
| 48 |
+
<script type="module">
|
| 49 |
+
const { Niivue } = await import('/gradio_api/file=.../niivue.js');
|
| 50 |
+
window.Niivue = Niivue;
|
| 51 |
+
</script>
|
| 52 |
+
</head>
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
**Key insight:** Even if this script fails, Gradio still loads because:
|
| 56 |
+
1. Script tags in `<head>` don't block Svelte hydration
|
| 57 |
+
2. They run BEFORE Gradio components mount
|
| 58 |
+
3. Failure just means `window.Niivue` is undefined (graceful degradation)
|
| 59 |
+
|
| 60 |
+
Then `js_on_load` simply USES `window.Niivue` (no imports):
|
| 61 |
+
|
| 62 |
+
```javascript
|
| 63 |
+
// No import() - just use what's already loaded
|
| 64 |
+
const Niivue = window.Niivue;
|
| 65 |
+
if (!Niivue) {
|
| 66 |
+
// Show error message, don't block
|
| 67 |
+
}
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## Evidence-Based Conclusions
|
| 73 |
+
|
| 74 |
+
| Claim | Evidence | Validated |
|
| 75 |
+
|-------|----------|-----------|
|
| 76 |
+
| Dynamic `import()` in `js_on_load` blocks HF Spaces | A/B test: disabling `js_on_load` makes app load | **YES** |
|
| 77 |
+
| Vendored NiiVue file is served correctly | Local testing shows 200 response | **YES** |
|
| 78 |
+
| `gr.set_static_paths()` is called correctly | Called before any Blocks in both entry points | **YES** |
|
| 79 |
+
| `allowed_paths` is configured correctly | Both entry points pass `allowed_paths` | **YES** |
|
| 80 |
+
| `demo.load()` doesn't block initial render | Gradio docs confirm load runs post-hydration | **YES** |
|
| 81 |
+
|
| 82 |
+
---
|
| 83 |
+
|
| 84 |
+
## The Fix
|
| 85 |
+
|
| 86 |
+
### Before (Broken)
|
| 87 |
+
|
| 88 |
+
```python
|
| 89 |
+
# viewer.py - NIIVUE_ON_LOAD_JS
|
| 90 |
+
const module = await import(niivueUrl); # Dynamic import in js_on_load
|
| 91 |
+
window.Niivue = module.Niivue;
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
```python
|
| 95 |
+
# ui/app.py - No head= parameter, relying on js_on_load to load NiiVue
|
| 96 |
+
demo.launch(...) # No head= parameter
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
### After (Fixed)
|
| 100 |
+
|
| 101 |
+
```python
|
| 102 |
+
# ui/app.py - Load NiiVue via head= BEFORE Gradio hydrates
|
| 103 |
+
from stroke_deepisles_demo.ui.viewer import get_niivue_head_html
|
| 104 |
+
|
| 105 |
+
get_demo().launch(
|
| 106 |
+
head=get_niivue_head_html(), # Inject NiiVue loader into <head>
|
| 107 |
+
...
|
| 108 |
+
)
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
```python
|
| 112 |
+
# viewer.py - NIIVUE_ON_LOAD_JS just USES window.Niivue (no import)
|
| 113 |
+
const Niivue = window.Niivue;
|
| 114 |
+
if (!Niivue) {
|
| 115 |
+
// Graceful error - don't block Gradio
|
| 116 |
+
container.innerHTML = 'NiiVue failed to load...';
|
| 117 |
+
return;
|
| 118 |
+
}
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
---
|
| 122 |
+
|
| 123 |
+
## Why Previous Attempts Failed
|
| 124 |
+
|
| 125 |
+
### Attempt 1: CDN Import
|
| 126 |
+
**Failed because:** HF Spaces CSP blocks external CDN imports
|
| 127 |
+
|
| 128 |
+
### Attempt 2: Vendor NiiVue + Dynamic Import in js_on_load
|
| 129 |
+
**Failed because:** Dynamic `import()` in js_on_load still blocks Svelte hydration, even for local files
|
| 130 |
+
|
| 131 |
+
### Attempt 3: Remove head= and make js_on_load self-sufficient
|
| 132 |
+
**Failed because:** This approach doubled down on the broken pattern (dynamic import in js_on_load)
|
| 133 |
+
|
| 134 |
+
### This Fix: head= for loading + js_on_load for init only
|
| 135 |
+
**Should work because:** Matches the architecture documented in spec 24 and proven by the A/B test
|
| 136 |
+
|
| 137 |
+
---
|
| 138 |
+
|
| 139 |
+
## Test Strategy
|
| 140 |
+
|
| 141 |
+
1. **Local sanity:** Run with fix, verify app loads and NiiVue works
|
| 142 |
+
2. **A/B comparison:** Compare behavior with/without `head=` parameter
|
| 143 |
+
3. **HF Spaces deployment:** Push to hf-personal remote and verify
|
| 144 |
+
4. **Console inspection:** Check for `[NiiVue Loader]` logs in browser console
|
| 145 |
+
|
| 146 |
+
---
|
| 147 |
+
|
| 148 |
+
## Files to Modify
|
| 149 |
+
|
| 150 |
+
| File | Change |
|
| 151 |
+
|------|--------|
|
| 152 |
+
| `src/stroke_deepisles_demo/ui/viewer.py` | Remove `import()` from js_on_load, use `window.Niivue` directly |
|
| 153 |
+
| `src/stroke_deepisles_demo/ui/app.py` | Add `head=get_niivue_head_html()` to launch() |
|
| 154 |
+
| `app.py` | Same as above for local dev |
|
| 155 |
+
|
| 156 |
+
---
|
| 157 |
+
|
| 158 |
+
## Update (2025-12-10): Web Research Findings
|
| 159 |
+
|
| 160 |
+
### Critical Discovery: The Issue is Gradio, NOT HuggingFace Spaces
|
| 161 |
+
|
| 162 |
+
**Web search confirmed:**
|
| 163 |
+
- HF Spaces DOES support JavaScript, WebGL, ES modules
|
| 164 |
+
- Working examples: Unity WebGL, Three.js games, Gaussian Splat Viewer
|
| 165 |
+
- The issue is specifically **Gradio's handling of custom JavaScript**
|
| 166 |
+
|
| 167 |
+
**Sources:**
|
| 168 |
+
- [HF Unity WebGL Template](https://github.com/huggingface/Unity-WebGL-template-for-Hugging-Face-Spaces)
|
| 169 |
+
- [WebGL Gaussian Splat Viewer on HF](https://huggingface.co/spaces/cakewalk/splat)
|
| 170 |
+
- [HF Forum: Gradio HTML with JS doesn't work](https://discuss.huggingface.co/t/gradio-html-component-with-javascript-code-dont-work/37316)
|
| 171 |
+
|
| 172 |
+
### Known Gradio Limitations
|
| 173 |
+
|
| 174 |
+
1. **`gr.HTML()` cannot load `<script>` tags** - They're stripped for security
|
| 175 |
+
2. **postMessage origin mismatch bug** (Gradio Issue #10893) - Causes SyntaxError
|
| 176 |
+
3. **`js_on_load` with dynamic `import()`** - Can block Svelte hydration
|
| 177 |
+
|
| 178 |
+
### Alternative Approaches NOT YET TRIED
|
| 179 |
+
|
| 180 |
+
#### Option 1: `demo.load(_js=...)` with globalThis
|
| 181 |
+
|
| 182 |
+
```python
|
| 183 |
+
scripts = """
|
| 184 |
+
async () => {
|
| 185 |
+
const script = document.createElement("script");
|
| 186 |
+
script.src = "/gradio_api/file=.../niivue.js";
|
| 187 |
+
script.type = "module";
|
| 188 |
+
document.head.appendChild(script);
|
| 189 |
+
await new Promise(resolve => script.onload = resolve);
|
| 190 |
+
globalThis.Niivue = window.Niivue;
|
| 191 |
+
}
|
| 192 |
+
"""
|
| 193 |
+
demo.load(None, None, None, _js=scripts)
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
Source: [HF Forum workaround](https://discuss.huggingface.co/t/gradio-html-component-with-javascript-code-dont-work/37316)
|
| 197 |
+
|
| 198 |
+
#### Option 2: `gr.Blocks(js=...)` parameter
|
| 199 |
+
|
| 200 |
+
```python
|
| 201 |
+
with gr.Blocks(js="() => { /* load NiiVue */ }") as demo:
|
| 202 |
+
...
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
Source: [Gradio Custom CSS/JS Guide](https://www.gradio.app/guides/custom-CSS-and-JS)
|
| 206 |
+
|
| 207 |
+
#### Option 3: Static HTML Space (Nuclear Option)
|
| 208 |
+
|
| 209 |
+
If all Gradio approaches fail, create a **Static HTML Space** with pure JS/HTML/CSS.
|
| 210 |
+
NiiVue would definitely work since WebGL examples exist on HF Spaces.
|
| 211 |
+
|
| 212 |
+
Would require rebuilding the UI without Gradio.
|
| 213 |
+
|
| 214 |
+
### Decision Tree
|
| 215 |
+
|
| 216 |
+
```
|
| 217 |
+
PR #28 (head= approach) works? ──YES──> Done!
|
| 218 |
+
│
|
| 219 |
+
NO
|
| 220 |
+
↓
|
| 221 |
+
Try demo.load(_js=...) works? ──YES──> Done!
|
| 222 |
+
│
|
| 223 |
+
NO
|
| 224 |
+
↓
|
| 225 |
+
Try gr.Blocks(js=...) works? ──YES──> Done!
|
| 226 |
+
│
|
| 227 |
+
NO
|
| 228 |
+
↓
|
| 229 |
+
Static HTML Space (rebuild UI without Gradio)
|
| 230 |
+
```
|
|
@@ -18,6 +18,7 @@ gr.set_static_paths(paths=[str(_ASSETS_DIR)])
|
|
| 18 |
from stroke_deepisles_demo.core.config import get_settings # noqa: E402
|
| 19 |
from stroke_deepisles_demo.core.logging import get_logger, setup_logging # noqa: E402
|
| 20 |
from stroke_deepisles_demo.ui.app import get_demo # noqa: E402
|
|
|
|
| 21 |
|
| 22 |
logger = get_logger(__name__)
|
| 23 |
|
|
@@ -36,9 +37,17 @@ if __name__ == "__main__":
|
|
| 36 |
logger.info("Assets exists: %s", _ASSETS_DIR.exists())
|
| 37 |
logger.info("=" * 60)
|
| 38 |
|
| 39 |
-
#
|
| 40 |
-
#
|
| 41 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
demo.launch(
|
| 44 |
server_name=settings.gradio_server_name,
|
|
@@ -46,5 +55,6 @@ if __name__ == "__main__":
|
|
| 46 |
share=settings.gradio_share,
|
| 47 |
theme=gr.themes.Soft(),
|
| 48 |
css="footer {visibility: hidden}",
|
|
|
|
| 49 |
allowed_paths=[str(_ASSETS_DIR)],
|
| 50 |
)
|
|
|
|
| 18 |
from stroke_deepisles_demo.core.config import get_settings # noqa: E402
|
| 19 |
from stroke_deepisles_demo.core.logging import get_logger, setup_logging # noqa: E402
|
| 20 |
from stroke_deepisles_demo.ui.app import get_demo # noqa: E402
|
| 21 |
+
from stroke_deepisles_demo.ui.viewer import get_niivue_head_html # noqa: E402
|
| 22 |
|
| 23 |
logger = get_logger(__name__)
|
| 24 |
|
|
|
|
| 37 |
logger.info("Assets exists: %s", _ASSETS_DIR.exists())
|
| 38 |
logger.info("=" * 60)
|
| 39 |
|
| 40 |
+
# CRITICAL FIX (Issue #24): Load NiiVue via head= parameter
|
| 41 |
+
#
|
| 42 |
+
# The head= parameter injects a <script type="module"> into <head> that loads
|
| 43 |
+
# NiiVue BEFORE Gradio's Svelte app hydrates. This is critical because:
|
| 44 |
+
#
|
| 45 |
+
# 1. Dynamic import() inside js_on_load blocks Svelte hydration on HF Spaces
|
| 46 |
+
# 2. head= scripts run BEFORE Gradio mounts, so failures don't block the app
|
| 47 |
+
# 3. js_on_load then just USES window.Niivue (no imports)
|
| 48 |
+
#
|
| 49 |
+
# Evidence: A/B test in docs/specs/24-bug-hf-spaces-loading-forever.md showed
|
| 50 |
+
# disabling js_on_load makes the app load. The fix is head= for loading.
|
| 51 |
|
| 52 |
demo.launch(
|
| 53 |
server_name=settings.gradio_server_name,
|
|
|
|
| 55 |
share=settings.gradio_share,
|
| 56 |
theme=gr.themes.Soft(),
|
| 57 |
css="footer {visibility: hidden}",
|
| 58 |
+
head=get_niivue_head_html(), # Load NiiVue before Gradio hydrates
|
| 59 |
allowed_paths=[str(_ASSETS_DIR)],
|
| 60 |
)
|
|
@@ -27,6 +27,7 @@ from stroke_deepisles_demo.ui.components import ( # noqa: E402
|
|
| 27 |
from stroke_deepisles_demo.ui.viewer import ( # noqa: E402
|
| 28 |
NIIVUE_UPDATE_JS,
|
| 29 |
create_niivue_html,
|
|
|
|
| 30 |
nifti_to_gradio_url,
|
| 31 |
render_3panel_view,
|
| 32 |
render_slice_comparison,
|
|
@@ -287,10 +288,17 @@ if __name__ == "__main__":
|
|
| 287 |
logger.info("Assets exists: %s", _ASSETS_DIR.exists())
|
| 288 |
logger.info("=" * 60)
|
| 289 |
|
| 290 |
-
#
|
| 291 |
-
#
|
| 292 |
-
#
|
| 293 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
get_demo().launch(
|
| 296 |
server_name=settings.gradio_server_name,
|
|
@@ -298,6 +306,7 @@ if __name__ == "__main__":
|
|
| 298 |
share=settings.gradio_share,
|
| 299 |
theme=gr.themes.Soft(),
|
| 300 |
css="footer {visibility: hidden}",
|
|
|
|
| 301 |
show_error=True, # Show full Python tracebacks in UI for debugging
|
| 302 |
allowed_paths=[str(_ASSETS_DIR)],
|
| 303 |
)
|
|
|
|
| 27 |
from stroke_deepisles_demo.ui.viewer import ( # noqa: E402
|
| 28 |
NIIVUE_UPDATE_JS,
|
| 29 |
create_niivue_html,
|
| 30 |
+
get_niivue_head_html,
|
| 31 |
nifti_to_gradio_url,
|
| 32 |
render_3panel_view,
|
| 33 |
render_slice_comparison,
|
|
|
|
| 288 |
logger.info("Assets exists: %s", _ASSETS_DIR.exists())
|
| 289 |
logger.info("=" * 60)
|
| 290 |
|
| 291 |
+
# CRITICAL FIX (Issue #24): Load NiiVue via head= parameter
|
| 292 |
+
#
|
| 293 |
+
# The head= parameter injects a <script type="module"> into <head> that loads
|
| 294 |
+
# NiiVue BEFORE Gradio's Svelte app hydrates. This is critical because:
|
| 295 |
+
#
|
| 296 |
+
# 1. Dynamic import() inside js_on_load blocks Svelte hydration on HF Spaces
|
| 297 |
+
# 2. head= scripts run BEFORE Gradio mounts, so failures don't block the app
|
| 298 |
+
# 3. js_on_load then just USES window.Niivue (no imports)
|
| 299 |
+
#
|
| 300 |
+
# Evidence: A/B test in docs/specs/24-bug-hf-spaces-loading-forever.md showed
|
| 301 |
+
# disabling js_on_load makes the app load. The fix is head= for loading.
|
| 302 |
|
| 303 |
get_demo().launch(
|
| 304 |
server_name=settings.gradio_server_name,
|
|
|
|
| 306 |
share=settings.gradio_share,
|
| 307 |
theme=gr.themes.Soft(),
|
| 308 |
css="footer {visibility: hidden}",
|
| 309 |
+
head=get_niivue_head_html(), # Load NiiVue before Gradio hydrates
|
| 310 |
show_error=True, # Show full Python tracebacks in UI for debugging
|
| 311 |
allowed_paths=[str(_ASSETS_DIR)],
|
| 312 |
)
|
|
@@ -390,15 +390,14 @@ def create_niivue_html(
|
|
| 390 |
Create HTML for NiiVue viewer (static content only).
|
| 391 |
|
| 392 |
This function generates an HTML snippet with data attributes containing
|
| 393 |
-
volume URLs
|
| 394 |
-
|
| 395 |
|
| 396 |
-
IMPORTANT
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
parameter working correctly, which has been problematic on HF Spaces.
|
| 402 |
|
| 403 |
Args:
|
| 404 |
volume_url: Gradio file URL (e.g., /gradio_api/file=/path/to/file.nii.gz)
|
|
@@ -420,16 +419,12 @@ def create_niivue_html(
|
|
| 420 |
# Using json.dumps ensures proper escaping
|
| 421 |
volume_attr = f"data-volume-url={json.dumps(volume_url)}"
|
| 422 |
mask_attr = f"data-mask-url={json.dumps(mask_url)}" if mask_url else 'data-mask-url=""'
|
| 423 |
-
# Embed NiiVue library URL so js_on_load can load it directly
|
| 424 |
-
# This removes dependency on head= script working on HF Spaces
|
| 425 |
-
niivue_url_attr = f"data-niivue-url={json.dumps(NIIVUE_JS_URL)}"
|
| 426 |
|
| 427 |
return f"""<div
|
| 428 |
id="niivue-container-{viewer_id}"
|
| 429 |
class="niivue-viewer"
|
| 430 |
{volume_attr}
|
| 431 |
{mask_attr}
|
| 432 |
-
{niivue_url_attr}
|
| 433 |
style="width:100%; height:{height}px; background:#000; border-radius:8px; position:relative;"
|
| 434 |
>
|
| 435 |
<canvas style="width:100%; height:100%;"></canvas>
|
|
@@ -443,12 +438,14 @@ def create_niivue_html(
|
|
| 443 |
# This runs when the gr.HTML component FIRST loads (mounts)
|
| 444 |
# Variables available: element, props, trigger
|
| 445 |
#
|
| 446 |
-
# CRITICAL FIX (Issue #24): This code
|
| 447 |
-
#
|
| 448 |
-
#
|
|
|
|
|
|
|
| 449 |
#
|
| 450 |
-
#
|
| 451 |
-
#
|
| 452 |
NIIVUE_ON_LOAD_JS = """
|
| 453 |
(async () => {
|
| 454 |
const container = element.querySelector('.niivue-viewer') || element;
|
|
@@ -458,7 +455,6 @@ NIIVUE_ON_LOAD_JS = """
|
|
| 458 |
// Get URLs from data attributes
|
| 459 |
const volumeUrl = container.dataset.volumeUrl;
|
| 460 |
const maskUrl = container.dataset.maskUrl;
|
| 461 |
-
const niivueUrl = container.dataset.niivueUrl;
|
| 462 |
|
| 463 |
// Skip if no volume URL (initial empty state)
|
| 464 |
if (!volumeUrl) {
|
|
@@ -476,36 +472,19 @@ NIIVUE_ON_LOAD_JS = """
|
|
| 476 |
return;
|
| 477 |
}
|
| 478 |
|
| 479 |
-
if (status) status.innerText = '
|
| 480 |
-
|
| 481 |
-
//
|
| 482 |
-
//
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
throw new Error('No NiiVue URL provided in data-niivue-url attribute');
|
| 493 |
-
}
|
| 494 |
-
|
| 495 |
-
console.log('[NiiVue] Loading from:', niivueUrl);
|
| 496 |
-
try {
|
| 497 |
-
const module = await import(niivueUrl);
|
| 498 |
-
window.Niivue = module.Niivue;
|
| 499 |
-
console.log('[NiiVue] Successfully loaded and cached');
|
| 500 |
-
return module.Niivue;
|
| 501 |
-
} catch (e) {
|
| 502 |
-
// Provide detailed error for debugging
|
| 503 |
-
console.error('[NiiVue] Import failed:', e);
|
| 504 |
-
throw new Error('Failed to load NiiVue from ' + niivueUrl + ': ' + e.message);
|
| 505 |
-
}
|
| 506 |
-
};
|
| 507 |
-
|
| 508 |
-
const Niivue = await loadNiivue();
|
| 509 |
|
| 510 |
// Initialize NiiVue
|
| 511 |
const nv = new Niivue({
|
|
@@ -563,8 +542,7 @@ NIIVUE_ON_LOAD_JS = """
|
|
| 563 |
# This runs after Python updates the HTML value.
|
| 564 |
# ⚠️ CRITICAL: 'element' is NOT available here! Must use document.querySelector
|
| 565 |
#
|
| 566 |
-
# CRITICAL FIX (Issue #24):
|
| 567 |
-
# from the data-niivue-url attribute. Same pattern as NIIVUE_ON_LOAD_JS.
|
| 568 |
NIIVUE_UPDATE_JS = """
|
| 569 |
(async () => {
|
| 570 |
// We must find the container globally since 'element' is not available in event handlers
|
|
@@ -581,7 +559,6 @@ NIIVUE_UPDATE_JS = """
|
|
| 581 |
// Get URLs from data attributes
|
| 582 |
const volumeUrl = container.dataset.volumeUrl;
|
| 583 |
const maskUrl = container.dataset.maskUrl;
|
| 584 |
-
const niivueUrl = container.dataset.niivueUrl;
|
| 585 |
|
| 586 |
// Skip if no volume URL
|
| 587 |
if (!volumeUrl) {
|
|
@@ -601,32 +578,15 @@ NIIVUE_UPDATE_JS = """
|
|
| 601 |
return;
|
| 602 |
}
|
| 603 |
|
| 604 |
-
//
|
| 605 |
-
const
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
if (!niivueUrl) {
|
| 614 |
-
throw new Error('No NiiVue URL provided in data-niivue-url attribute');
|
| 615 |
-
}
|
| 616 |
-
|
| 617 |
-
console.log('[NiiVue] Loading from:', niivueUrl);
|
| 618 |
-
try {
|
| 619 |
-
const module = await import(niivueUrl);
|
| 620 |
-
window.Niivue = module.Niivue;
|
| 621 |
-
console.log('[NiiVue] Successfully loaded and cached');
|
| 622 |
-
return module.Niivue;
|
| 623 |
-
} catch (e) {
|
| 624 |
-
console.error('[NiiVue] Import failed:', e);
|
| 625 |
-
throw new Error('Failed to load NiiVue from ' + niivueUrl + ': ' + e.message);
|
| 626 |
-
}
|
| 627 |
-
};
|
| 628 |
-
|
| 629 |
-
const Niivue = await loadNiivue();
|
| 630 |
|
| 631 |
// Initialize NiiVue
|
| 632 |
const nv = new Niivue({
|
|
|
|
| 390 |
Create HTML for NiiVue viewer (static content only).
|
| 391 |
|
| 392 |
This function generates an HTML snippet with data attributes containing
|
| 393 |
+
volume URLs. The actual NiiVue initialization is handled by js_on_load
|
| 394 |
+
in the gr.HTML component (see NIIVUE_ON_LOAD_JS).
|
| 395 |
|
| 396 |
+
IMPORTANT (Issue #24):
|
| 397 |
+
- Gradio's gr.HTML strips <script> tags for security
|
| 398 |
+
- NiiVue library is loaded via `head=` parameter (see get_niivue_head_html())
|
| 399 |
+
- js_on_load just USES window.Niivue - NO dynamic imports
|
| 400 |
+
- This prevents Gradio's Svelte hydration from being blocked on HF Spaces
|
|
|
|
| 401 |
|
| 402 |
Args:
|
| 403 |
volume_url: Gradio file URL (e.g., /gradio_api/file=/path/to/file.nii.gz)
|
|
|
|
| 419 |
# Using json.dumps ensures proper escaping
|
| 420 |
volume_attr = f"data-volume-url={json.dumps(volume_url)}"
|
| 421 |
mask_attr = f"data-mask-url={json.dumps(mask_url)}" if mask_url else 'data-mask-url=""'
|
|
|
|
|
|
|
|
|
|
| 422 |
|
| 423 |
return f"""<div
|
| 424 |
id="niivue-container-{viewer_id}"
|
| 425 |
class="niivue-viewer"
|
| 426 |
{volume_attr}
|
| 427 |
{mask_attr}
|
|
|
|
| 428 |
style="width:100%; height:{height}px; background:#000; border-radius:8px; position:relative;"
|
| 429 |
>
|
| 430 |
<canvas style="width:100%; height:100%;"></canvas>
|
|
|
|
| 438 |
# This runs when the gr.HTML component FIRST loads (mounts)
|
| 439 |
# Variables available: element, props, trigger
|
| 440 |
#
|
| 441 |
+
# CRITICAL FIX (Issue #24): This code MUST NOT use dynamic import()!
|
| 442 |
+
# Dynamic import() inside js_on_load blocks Gradio's Svelte hydration on HF Spaces.
|
| 443 |
+
#
|
| 444 |
+
# Solution: NiiVue is loaded via `head=` parameter (see get_niivue_head_html()).
|
| 445 |
+
# This js_on_load handler just USES window.Niivue - no imports.
|
| 446 |
#
|
| 447 |
+
# Evidence: A/B test in docs/specs/24-bug-hf-spaces-loading-forever.md proved
|
| 448 |
+
# that disabling js_on_load makes the app load. The issue is import() in js_on_load.
|
| 449 |
NIIVUE_ON_LOAD_JS = """
|
| 450 |
(async () => {
|
| 451 |
const container = element.querySelector('.niivue-viewer') || element;
|
|
|
|
| 455 |
// Get URLs from data attributes
|
| 456 |
const volumeUrl = container.dataset.volumeUrl;
|
| 457 |
const maskUrl = container.dataset.maskUrl;
|
|
|
|
| 458 |
|
| 459 |
// Skip if no volume URL (initial empty state)
|
| 460 |
if (!volumeUrl) {
|
|
|
|
| 472 |
return;
|
| 473 |
}
|
| 474 |
|
| 475 |
+
if (status) status.innerText = 'Initializing NiiVue...';
|
| 476 |
+
|
| 477 |
+
// Use window.Niivue loaded by head= script (NO dynamic import!)
|
| 478 |
+
// The head= parameter in launch() loads NiiVue BEFORE Gradio hydrates.
|
| 479 |
+
// This is critical for HF Spaces compatibility (Issue #24).
|
| 480 |
+
const Niivue = window.Niivue;
|
| 481 |
+
if (!Niivue) {
|
| 482 |
+
console.error('[NiiVue] window.Niivue not found - head= script may have failed');
|
| 483 |
+
container.innerHTML = '<div style="color:#f66;padding:20px;text-align:center;">NiiVue library failed to load. Check browser console for errors.</div>';
|
| 484 |
+
return;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
console.log('[NiiVue] Using window.Niivue from head= script');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
|
| 489 |
// Initialize NiiVue
|
| 490 |
const nv = new Niivue({
|
|
|
|
| 542 |
# This runs after Python updates the HTML value.
|
| 543 |
# ⚠️ CRITICAL: 'element' is NOT available here! Must use document.querySelector
|
| 544 |
#
|
| 545 |
+
# CRITICAL FIX (Issue #24): NO dynamic import()! Use window.Niivue from head= script.
|
|
|
|
| 546 |
NIIVUE_UPDATE_JS = """
|
| 547 |
(async () => {
|
| 548 |
// We must find the container globally since 'element' is not available in event handlers
|
|
|
|
| 559 |
// Get URLs from data attributes
|
| 560 |
const volumeUrl = container.dataset.volumeUrl;
|
| 561 |
const maskUrl = container.dataset.maskUrl;
|
|
|
|
| 562 |
|
| 563 |
// Skip if no volume URL
|
| 564 |
if (!volumeUrl) {
|
|
|
|
| 578 |
return;
|
| 579 |
}
|
| 580 |
|
| 581 |
+
// Use window.Niivue loaded by head= script (NO dynamic import!)
|
| 582 |
+
const Niivue = window.Niivue;
|
| 583 |
+
if (!Niivue) {
|
| 584 |
+
console.error('[NiiVue] window.Niivue not found - head= script may have failed');
|
| 585 |
+
container.innerHTML = '<div style="color:#f66;padding:20px;text-align:center;">NiiVue library failed to load. Check browser console for errors.</div>';
|
| 586 |
+
return;
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
console.log('[NiiVue] Using window.Niivue from head= script');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
|
| 591 |
// Initialize NiiVue
|
| 592 |
const nv = new Niivue({
|