VibecoderMcSwaggins commited on
Commit
0b424f6
Β·
unverified Β·
1 Parent(s): bc1d8e8

fix(ui): NiiVue viewer re-initializes after segmentation completes (#21)

Browse files

* fix(ui): NiiVue viewer re-initializes after segmentation completes

ROOT CAUSE: Gradio's js_on_load only runs ONCE on component mount.
When run_segmentation() updated the gr.HTML value, the JavaScript
never re-executed, leaving the viewer stuck at "Loading viewer...".

FIX: Created two JavaScript constants with different DOM access patterns:

1. NIIVUE_ON_LOAD_JS - Uses element.querySelector() (for js_on_load)
2. NIIVUE_UPDATE_JS - Uses document.querySelector() (for .then(js=...))

The key insight: The `js` parameter on event handlers does NOT have
access to `element` - it runs in global context and receives component
VALUES as arguments, not DOM elements.

Changes:
- viewer.py: Added NIIVUE_UPDATE_JS with document.querySelector()
- app.py: Wired .then(fn=None, js=NIIVUE_UPDATE_JS) after click handler
- components.py: Updated import to NIIVUE_ON_LOAD_JS

Also:
- Archived 9 completed specs to docs/specs/archive/
- Created Bug #11 spec with deep research findings
- Updated Bug #10 to point to Bug #11

All 130 tests pass.

Fixes: Bug #10, Bug #11 (NiiVue viewer black screen / not re-running)
See: docs/specs/11-bug-niivue-js-on-load-not-rerunning.md

* docs: align constant names with implementation

Update Bug #11 spec to use actual constant names:
- NIIVUE_INIT_JS β†’ NIIVUE_UPDATE_JS
- NIIVUE_JS_ON_LOAD β†’ NIIVUE_ON_LOAD_JS

Addresses CodeRabbit feedback on naming consistency.

docs/specs/10-bug-niivue-viewer-black-screen.md CHANGED
@@ -1,12 +1,12 @@
1
  # Bug #10: NiiVue 3D Viewer Renders Black Screen on HF Spaces
2
 
3
- ## Status: FIXED (pending HF Spaces verification)
4
 
5
  **Date:** 2025-12-09
6
- **Branch:** `fix/niivue-js-on-load`
7
  **Discovered:** After fixing Bug #9 (DeepISLES subprocess bridge)
8
 
9
- ### Fix Applied (2025-12-09)
10
 
11
  Implemented `js_on_load` approach (Solution 1 from this spec):
12
 
@@ -17,6 +17,16 @@ Implemented `js_on_load` approach (Solution 1 from this spec):
17
  The HTML now uses `data-*` attributes to pass volume URLs, and JavaScript
18
  executes via `js_on_load` instead of inline `<script>` tags.
19
 
 
 
 
 
 
 
 
 
 
 
20
  ---
21
 
22
  ## TL;DR - ROOT CAUSE
 
1
  # Bug #10: NiiVue 3D Viewer Renders Black Screen on HF Spaces
2
 
3
+ ## Status: PARTIALLY FIXED β†’ See Bug #11
4
 
5
  **Date:** 2025-12-09
6
+ **Branch:** `fix/niivue-js-on-load` (merged), now `fix/niivue-js-rerun`
7
  **Discovered:** After fixing Bug #9 (DeepISLES subprocess bridge)
8
 
9
+ ### Fix Applied (2025-12-09) - PARTIAL
10
 
11
  Implemented `js_on_load` approach (Solution 1 from this spec):
12
 
 
17
  The HTML now uses `data-*` attributes to pass volume URLs, and JavaScript
18
  executes via `js_on_load` instead of inline `<script>` tags.
19
 
20
+ ### Continued in Bug #11
21
+
22
+ After HF Spaces deployment, we discovered that `js_on_load` **only runs once
23
+ on component mount**, not on value updates. This means the NiiVue viewer
24
+ initializes correctly on page load, but when `run_segmentation()` updates
25
+ the gr.HTML value with new data-* attributes, the JS doesn't re-execute.
26
+
27
+ **See [Bug #11](./11-bug-niivue-js-on-load-not-rerunning.md) for the complete
28
+ analysis and the verified fix using `.then(fn=None, js=...)`.**
29
+
30
  ---
31
 
32
  ## TL;DR - ROOT CAUSE
docs/specs/11-bug-niivue-js-on-load-not-rerunning.md ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bug #11: NiiVue js_on_load Doesn't Re-run on Value Update
2
+
3
+ ## Status: FIXED
4
+
5
+ **Date:** 2025-12-09
6
+ **Branch:** `fix/niivue-js-rerun`
7
+ **Fixed By:** Implementing `.then(fn=None, js=NIIVUE_UPDATE_JS)` pattern with correct `document.querySelector` context.
8
+ **Related:** Bug #10 (Fixed)
9
+
10
+ ---
11
+
12
+ ## TL;DR - ROOT CAUSE
13
+
14
+ **Gradio's `js_on_load` only runs ONCE when the component first mounts.**
15
+
16
+ When we update the `gr.HTML` value with new content (after segmentation), the `js_on_load` code does NOT re-execute. The HTML updates, but the JavaScript initialization never runs.
17
+
18
+ ---
19
+
20
+ ## Symptom
21
+
22
+ After successful DeepISLES inference on HF Spaces:
23
+ - Viewer shows "Loading viewer..." (initial HTML state)
24
+ - Status never changes to "Checking WebGL2..." or "Loading NiiVue..."
25
+ - No error message displayed
26
+ - No brain scan visible
27
+
28
+ **What IS working:**
29
+ - DeepISLES inference completes (~36 seconds)
30
+ - Slice Comparison (matplotlib 2D view) renders correctly
31
+ - Metrics JSON displays correctly
32
+ - Download button provides the prediction mask
33
+ - Initial HTML renders with data-* attributes
34
+
35
+ **What is NOT working:**
36
+ - js_on_load JavaScript doesn't re-run when value updates
37
+ - NiiVue never initializes after segmentation
38
+
39
+ ---
40
+
41
+ ## Evidence
42
+
43
+ ### Gradio Documentation
44
+
45
+ From [Custom HTML Components Guide](https://www.gradio.app/guides/custom_HTML_components):
46
+
47
+ > "Event listeners attached in `js_on_load` are only attached **once** when the component is first rendered. If your component creates new elements dynamically that need event listeners, attach the event listener to a parent element..."
48
+
49
+ ### Observed Behavior
50
+
51
+ 1. Page loads β†’ js_on_load runs β†’ No volumeUrl β†’ Shows "Waiting for segmentation..."
52
+ 2. User clicks "Run Segmentation"
53
+ 3. DeepISLES runs successfully
54
+ 4. `run_segmentation()` returns new HTML with data-volume-url attribute
55
+ 5. gr.HTML value updates with new HTML
56
+ 6. **js_on_load does NOT re-run** ← THE BUG
57
+ 7. Viewer shows "Loading viewer..." (static HTML, no JS executed)
58
+
59
+ ### Server Logs (Working)
60
+
61
+ ```text
62
+ INFO: Running segmentation for sub-stroke0001
63
+ INFO: DeepISLES subprocess completed in 35.73s
64
+ ```
65
+
66
+ Inference works. The problem is client-side JavaScript execution.
67
+
68
+ ---
69
+
70
+ ## Code Flow Analysis
71
+
72
+ ### Current Implementation (BROKEN)
73
+
74
+ ```python
75
+ # components.py - js_on_load set once at component creation
76
+ niivue_viewer = gr.HTML(
77
+ label="Interactive 3D Viewer",
78
+ js_on_load=NIIVUE_ON_LOAD_JS, # Runs ONCE on mount
79
+ )
80
+
81
+ # app.py - returns new HTML value after segmentation
82
+ def run_segmentation(...):
83
+ # ... inference ...
84
+ niivue_html = create_niivue_html(dwi_url, mask_url)
85
+ return niivue_html, ... # Value updates, but js_on_load doesn't re-run
86
+ ```
87
+
88
+ ### Why It Fails
89
+
90
+ 1. Component mounts β†’ js_on_load runs (no data yet)
91
+ 2. Value updates β†’ HTML re-renders, js_on_load SKIPPED
92
+ 3. New HTML has data-* attributes but no JS execution
93
+
94
+ ---
95
+
96
+ ## Proposed Solutions (Ranked)
97
+
98
+ ### Solution 1: Use `js` Parameter on Event Handler (Recommended)
99
+
100
+ Gradio allows running JavaScript after an event completes:
101
+
102
+ ```python
103
+ run_btn.click(
104
+ fn=run_segmentation,
105
+ inputs=[...],
106
+ outputs=[results["niivue_viewer"], ...],
107
+ ).then(
108
+ fn=None, # MUST be explicit!
109
+ js=NIIVUE_UPDATE_JS, # ⚠️ CANNOT reuse NIIVUE_ON_LOAD_JS - different context!
110
+ )
111
+ ```
112
+
113
+ **Pros:** Native Gradio pattern, runs after each update
114
+ **Cons:** Requires separate JS constant (see "Different JS Context" section below)
115
+
116
+ **⚠️ CRITICAL:** The `js` param does NOT have access to `element`. You must use
117
+ `document.querySelector()` instead. See the corrected JavaScript in the
118
+ "Recommended Implementation" section.
119
+
120
+ ### Solution 2: MutationObserver in js_on_load
121
+
122
+ Watch for DOM changes and re-initialize. This approach IS valid because
123
+ `js_on_load` has access to `element`:
124
+
125
+ ```javascript
126
+ // In js_on_load - 'element' IS available here
127
+ const initNiiVue = async () => {
128
+ const container = element.querySelector('.niivue-viewer') || element;
129
+ const volumeUrl = container.dataset.volumeUrl;
130
+ if (!volumeUrl) return;
131
+ // ... NiiVue initialization code ...
132
+ };
133
+
134
+ // Watch for attribute changes (when Python updates data-volume-url)
135
+ const observer = new MutationObserver((mutations) => {
136
+ for (const mutation of mutations) {
137
+ if (mutation.type === 'attributes' &&
138
+ mutation.attributeName.startsWith('data-')) {
139
+ initNiiVue();
140
+ break;
141
+ }
142
+ }
143
+ });
144
+
145
+ // Observe the element for attribute changes
146
+ observer.observe(element, {
147
+ attributes: true,
148
+ subtree: true,
149
+ attributeFilter: ['data-volume-url', 'data-mask-url']
150
+ });
151
+
152
+ // Initial check
153
+ initNiiVue();
154
+ ```
155
+
156
+ **Pros:** Self-contained in js_on_load, no separate event wiring needed
157
+ **Cons:** More complex, relies on Gradio updating DOM attributes (may not work
158
+ if Gradio replaces the entire element instead of updating attributes)
159
+
160
+ ### Solution 3: Use gradio-iframe Component
161
+
162
+ The `gradio-iframe` package allows JavaScript to execute normally:
163
+
164
+ ```python
165
+ from gradio_iframe import iFrame
166
+
167
+ niivue_viewer = iFrame(
168
+ value=create_niivue_html_with_script(...), # Scripts execute in iframe
169
+ )
170
+ ```
171
+
172
+ **Pros:** Scripts execute normally inside iframe
173
+ **Cons:** Additional dependency, iframe quirks
174
+
175
+ ### Solution 4: Embed JS in HTML via data: URL iframe
176
+
177
+ Self-contained iframe with script:
178
+
179
+ ```python
180
+ def create_niivue_html(...):
181
+ html_with_script = f'''<script>...</script><canvas>...</canvas>'''
182
+ encoded = base64.b64encode(html_with_script.encode()).decode()
183
+ return f'<iframe src="data:text/html;base64,{encoded}"></iframe>'
184
+ ```
185
+
186
+ **Pros:** No external dependency, scripts execute
187
+ **Cons:** Complex, potential CSP issues
188
+
189
+ ### Solution 5: Custom Gradio Component
190
+
191
+ Build a proper `gradio_niivue` Svelte component:
192
+
193
+ ```bash
194
+ gradio cc create NiiVue --template HTML
195
+ ```
196
+
197
+ **Pros:** Most robust, proper lifecycle hooks
198
+ **Cons:** Significant development effort
199
+
200
+ ---
201
+
202
+ ## Investigation Steps
203
+
204
+ ### Step 1: Test Solution 1 (js param on .then())
205
+
206
+ ```python
207
+ run_btn.click(
208
+ fn=run_segmentation,
209
+ inputs=[...],
210
+ outputs=[...],
211
+ ).then(
212
+ fn=None,
213
+ js="console.log('then JS ran'); console.log(document.querySelector('.niivue-viewer'));"
214
+ )
215
+ ```
216
+
217
+ Verify:
218
+ - Does `js` run after value update?
219
+ - Does it have access to the updated DOM?
220
+
221
+ ### Step 2: Test Solution 2 (MutationObserver)
222
+
223
+ Add observer to js_on_load and check if it triggers on value change.
224
+
225
+ ### Step 3: Check Browser Console
226
+
227
+ Open DevTools and look for:
228
+ - JavaScript errors
229
+ - Console logs from js_on_load
230
+ - Network requests to NiiVue CDN
231
+
232
+ ---
233
+
234
+ ## Temporary Workaround
235
+
236
+ The 2D Slice Comparison view works correctly and provides adequate visualization for evaluation purposes while we fix the 3D viewer.
237
+
238
+ ---
239
+
240
+ ## Priority Assessment
241
+
242
+ **Severity:** P1 (High)
243
+ - 3D viewer is a key feature for the demo
244
+ - The fix we deployed doesn't fully work
245
+ - Blocks demo usability for 3D visualization
246
+
247
+ **Impact:**
248
+ - Users see "Loading viewer..." indefinitely
249
+ - 2D fallback still works
250
+ - Demo is partially functional
251
+
252
+ ---
253
+
254
+ ## Deep Web Research (2025-12-09)
255
+
256
+ ### Relationship Between Bug #10 and Bug #11
257
+
258
+ **They are the SAME underlying issue with two symptoms:**
259
+
260
+ 1. **Bug #10**: Gradio strips `<script>` tags from gr.HTML for XSS security
261
+ 2. **Bug #11**: Gradio's `js_on_load` only runs once on component mount
262
+
263
+ Both stem from Gradio's design decision to limit JavaScript execution in HTML components for security reasons.
264
+
265
+ ### Verified Gradio Behavior (from official docs)
266
+
267
+ #### js_on_load Limitation (CONFIRMED)
268
+
269
+ From [Gradio Custom HTML Components](https://www.gradio.app/guides/custom_HTML_components):
270
+
271
+ > "Event listeners attached in `js_on_load` are **only attached once** when the component is first rendered."
272
+
273
+ #### Solution 1 VALIDATED: `.then(fn=None, js=...)`
274
+
275
+ From [Gradio Custom CSS and JS](https://www.gradio.app/guides/custom-CSS-and-JS):
276
+
277
+ > "You can pass both a JavaScript function and a Python function (in which case the JavaScript function is run first) or **only Javascript (and set the Python `fn` to `None`)**."
278
+
279
+ **Critical Implementation Detail** from [GitHub Issue #6729](https://github.com/gradio-app/gradio/issues/6729):
280
+
281
+ > "`js` without `fn` is executed only if `fn` is **explicitly** set to `None`"
282
+
283
+ ```python
284
+ # WORKS
285
+ b1.click(js=js, fn=None)
286
+
287
+ # DOES NOT WORK
288
+ b2.click(js=js) # fn defaults to something, not None
289
+ ```
290
+
291
+ #### js Parameter Signature
292
+
293
+ From [Gradio HTML Docs](https://www.gradio.app/docs/gradio/html):
294
+
295
+ > "The `js` parameter is an optional frontend js method to run before running 'fn'. Input arguments for js method are values of 'inputs' and 'outputs', return should be a list of values for output components."
296
+
297
+ ### Alternative Solutions Research
298
+
299
+ #### gradio-iframe Package
300
+
301
+ From [PyPI gradio-iframe](https://pypi.org/project/gradio-iframe/):
302
+
303
+ - Version: 0.0.10 (Jan 2024)
304
+ - **JavaScript executes normally inside iframe**
305
+ - Known issues: Height doesn't always adjust, not fully responsive
306
+ - Status: Alpha, possibly abandoned (no updates in 12 months)
307
+ - **Risk:** May not be compatible with Gradio 6.x
308
+
309
+ #### MutationObserver Pattern
310
+
311
+ From [MDN MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver):
312
+
313
+ MutationObserver can watch for DOM changes and trigger re-initialization:
314
+
315
+ ```javascript
316
+ const observer = new MutationObserver((mutations) => {
317
+ mutations.forEach((mutation) => {
318
+ if (mutation.type === 'attributes' &&
319
+ mutation.attributeName === 'data-volume-url') {
320
+ initNiiVue();
321
+ }
322
+ });
323
+ });
324
+ observer.observe(element, { attributes: true, attributeFilter: ['data-volume-url'] });
325
+ ```
326
+
327
+ **Caveat from Gradio docs:**
328
+
329
+ > "Warning: The use of query selectors in custom JS and CSS is not guaranteed to work across Gradio versions that bind to Gradio's own HTML elements as the Gradio HTML DOM may change."
330
+
331
+ #### ipyniivue (Jupyter Widget)
332
+
333
+ From [GitHub ipyniivue](https://github.com/niivue/ipyniivue):
334
+
335
+ - Built on anywidget framework
336
+ - Designed for Jupyter, not Gradio
337
+ - No direct Gradio integration exists
338
+
339
+ ### Recommended Implementation
340
+
341
+ Based on research, **Solution 1 (`.then(fn=None, js=...)`) is the correct fix**.
342
+
343
+ #### Step 1: Create a NEW JavaScript constant for event handlers
344
+
345
+ We **CANNOT** reuse `NIIVUE_ON_LOAD_JS` because it uses `element` which is not
346
+ available in the event handler context. We need a new constant:
347
+
348
+ ```python
349
+ # viewer.py - NEW constant for event handler context
350
+ NIIVUE_UPDATE_JS = f"""
351
+ (async () => {{
352
+ // ⚠️ NO 'element' available - must use document.querySelector()
353
+ const container = document.querySelector('.niivue-viewer');
354
+ if (!container) {{
355
+ console.error('NiiVue container not found');
356
+ return;
357
+ }}
358
+
359
+ const canvas = container.querySelector('canvas');
360
+ const status = container.querySelector('.niivue-status');
361
+
362
+ // Get URLs from data attributes
363
+ const volumeUrl = container.dataset.volumeUrl;
364
+ const maskUrl = container.dataset.maskUrl;
365
+
366
+ // Skip if no volume URL
367
+ if (!volumeUrl) {{
368
+ console.log('No volume URL yet');
369
+ return;
370
+ }}
371
+
372
+ try {{
373
+ if (status) status.innerText = 'Loading NiiVue...';
374
+
375
+ const {{ Niivue }} = await import('{NIIVUE_CDN_URL}');
376
+ const nv = new Niivue({{
377
+ logging: false,
378
+ show3Dcrosshair: true,
379
+ backColor: [0, 0, 0, 1]
380
+ }});
381
+
382
+ await nv.attachToCanvas(canvas);
383
+ if (status) status.style.display = 'none';
384
+
385
+ const volumes = [{{ url: volumeUrl, name: 'input.nii.gz' }}];
386
+ if (maskUrl) {{
387
+ volumes.push({{ url: maskUrl, colorMap: 'red', opacity: 0.5 }});
388
+ }}
389
+
390
+ await nv.loadVolumes(volumes);
391
+ nv.setSliceType(nv.sliceTypeMultiplanar);
392
+ nv.drawScene();
393
+
394
+ console.log('NiiVue initialized via .then()');
395
+ }} catch (error) {{
396
+ console.error('NiiVue init error:', error);
397
+ if (container) {{
398
+ const errorDiv = document.createElement('div');
399
+ errorDiv.style.cssText = 'color:#f66;padding:20px;text-align:center;';
400
+ errorDiv.textContent = 'Error: ' + error.message;
401
+ container.innerHTML = '';
402
+ container.appendChild(errorDiv);
403
+ }}
404
+ }}
405
+ }})();
406
+ """
407
+ ```
408
+
409
+ #### Step 2: Wire up the event handler in app.py
410
+
411
+ ```python
412
+ # app.py
413
+ from stroke_deepisles_demo.ui.viewer import NIIVUE_UPDATE_JS
414
+
415
+ run_btn.click(
416
+ fn=run_segmentation,
417
+ inputs=[case_selector, settings["fast_mode"], settings["show_ground_truth"]],
418
+ outputs=[results["niivue_viewer"], results["slice_plot"], results["metrics"],
419
+ results["download"], status],
420
+ ).then(
421
+ fn=None, # MUST be explicit per GitHub Issue #6729!
422
+ js=NIIVUE_UPDATE_JS,
423
+ )
424
+ ```
425
+
426
+ **Why this works:**
427
+ 1. Python `run_segmentation()` updates gr.HTML value with new data-* attributes
428
+ 2. `.then()` chains after the click handler completes
429
+ 3. `fn=None` tells Gradio to skip Python, run JS only
430
+ 4. `js=NIIVUE_UPDATE_JS` runs our initialization code
431
+ 5. JS uses `document.querySelector()` to find the updated DOM
432
+
433
+ ### ⚠️ CRITICAL: Different JS Context (VERIFIED)
434
+
435
+ The `js` parameter on event handlers has a **completely different context** than `js_on_load`:
436
+
437
+ | Context | `js_on_load` | `js` on event handler |
438
+ |---------|--------------|----------------------|
439
+ | `element` | βœ… Available | ❌ **NOT available** |
440
+ | `props` | βœ… Available | ❌ **NOT available** |
441
+ | `trigger()` | βœ… Available | ❌ **NOT available** |
442
+ | Arguments | None | Receives input/output **values** |
443
+
444
+ From [Gradio Custom CSS and JS](https://www.gradio.app/guides/custom-CSS-and-JS):
445
+
446
+ > "Input arguments for js method are **values of 'inputs' and 'outputs'**"
447
+
448
+ Example from Gradio docs:
449
+ ```python
450
+ reverse_btn.click(
451
+ None, [subject, verb, object], output2,
452
+ js="(s, v, o) => o + ' ' + v + ' ' + s" # Receives VALUES, not DOM elements
453
+ )
454
+ ```
455
+
456
+ **This is why we need TWO separate JavaScript constants:**
457
+ - `NIIVUE_ON_LOAD_JS` - Uses `element.querySelector()` (for initial mount)
458
+ - `NIIVUE_UPDATE_JS` - Uses `document.querySelector()` (for .then() handler)
459
+
460
+ ### Risk Assessment: Is This Fixable?
461
+
462
+ | Approach | Feasibility | Risk Level | Notes |
463
+ |----------|-------------|------------|-------|
464
+ | `.then(fn=None, js=...)` | βœ… High | Low | Native Gradio, documented |
465
+ | MutationObserver | βœ… High | Medium | Complex, DOM stability warning |
466
+ | gradio-iframe | ⚠️ Medium | High | Abandoned, Gradio 6 compat unknown |
467
+ | data: URL iframe | ⚠️ Medium | Medium | CSP issues possible |
468
+ | Custom component | βœ… High | Low | Most work, most robust |
469
+
470
+ **Verdict: YES, this is fixable.** Solution 1 should work based on verified documentation.
471
+
472
+ ---
473
+
474
+ ## References
475
+
476
+ - [Gradio Custom HTML Components](https://www.gradio.app/guides/custom_HTML_components) - js_on_load limitation
477
+ - [Gradio Custom CSS and JS](https://www.gradio.app/guides/custom-CSS-and-JS) - js parameter docs
478
+ - [Gradio Event Listeners](https://www.gradio.app/docs/gradio/blocks#events) - .then() method
479
+ - [GitHub Issue #6729](https://github.com/gradio-app/gradio/issues/6729) - fn=None requirement
480
+ - [gradio-iframe PyPI](https://pypi.org/project/gradio-iframe/) - Alternative approach
481
+ - [ipyniivue GitHub](https://github.com/niivue/ipyniivue) - Jupyter widget (not Gradio)
482
+ - [MDN MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) - DOM watching
483
+ - [Bug #10 Spec](./10-bug-niivue-viewer-black-screen.md) - Previous fix attempt
484
+ - [Issue #19](https://github.com/The-Obstacle-Is-The-Way/stroke-deepisles-demo/issues/19) - Base64 optimization (related)
docs/specs/{01-phase-0-repo-bootstrap.md β†’ archive/01-phase-0-repo-bootstrap.md} RENAMED
File without changes
docs/specs/{02-phase-1-data-access.md β†’ archive/02-phase-1-data-access.md} RENAMED
File without changes
docs/specs/{03-phase-2-deepisles-docker.md β†’ archive/03-phase-2-deepisles-docker.md} RENAMED
File without changes
docs/specs/{04-phase-3-pipeline.md β†’ archive/04-phase-3-pipeline.md} RENAMED
File without changes
docs/specs/{05-phase-4-gradio-ui.md β†’ archive/05-phase-4-gradio-ui.md} RENAMED
File without changes
docs/specs/{06-phase-5-polish.md β†’ archive/06-phase-5-polish.md} RENAMED
File without changes
docs/specs/{08-bug-hf-spaces-dataset-loop.md β†’ archive/08-bug-hf-spaces-dataset-loop.md} RENAMED
File without changes
docs/specs/{09-bug-deepisles-not-installed-hf-spaces.md β†’ archive/09-bug-deepisles-not-installed-hf-spaces.md} RENAMED
File without changes
docs/specs/{data-discovery.md β†’ archive/data-discovery.md} RENAMED
File without changes
src/stroke_deepisles_demo/ui/app.py CHANGED
@@ -17,6 +17,7 @@ from stroke_deepisles_demo.ui.components import (
17
  create_settings_accordion,
18
  )
19
  from stroke_deepisles_demo.ui.viewer import (
 
20
  create_niivue_html,
21
  nifti_to_data_url,
22
  render_slice_comparison,
@@ -199,6 +200,9 @@ def create_app() -> gr.Blocks:
199
  results["download"],
200
  status,
201
  ],
 
 
 
202
  )
203
 
204
  # Trigger data loading after UI renders (prevents startup timeout)
 
17
  create_settings_accordion,
18
  )
19
  from stroke_deepisles_demo.ui.viewer import (
20
+ NIIVUE_UPDATE_JS,
21
  create_niivue_html,
22
  nifti_to_data_url,
23
  render_slice_comparison,
 
200
  results["download"],
201
  status,
202
  ],
203
+ ).then(
204
+ fn=None, # Explicitly None to run JS only
205
+ js=NIIVUE_UPDATE_JS,
206
  )
207
 
208
  # Trigger data loading after UI renders (prevents startup timeout)
src/stroke_deepisles_demo/ui/components.py CHANGED
@@ -6,7 +6,7 @@ import gradio as gr
6
 
7
  from stroke_deepisles_demo.core.config import get_settings
8
  from stroke_deepisles_demo.core.logging import get_logger
9
- from stroke_deepisles_demo.ui.viewer import NIIVUE_JS_ON_LOAD
10
 
11
  logger = get_logger(__name__)
12
 
@@ -45,7 +45,7 @@ def create_results_display() -> dict[str, gr.components.Component]:
45
  # The HTML value contains data-* attributes with volume URLs.
46
  niivue_viewer = gr.HTML(
47
  label="Interactive 3D Viewer",
48
- js_on_load=NIIVUE_JS_ON_LOAD,
49
  )
50
 
51
  # Slice comparisons (Matplotlib)
 
6
 
7
  from stroke_deepisles_demo.core.config import get_settings
8
  from stroke_deepisles_demo.core.logging import get_logger
9
+ from stroke_deepisles_demo.ui.viewer import NIIVUE_ON_LOAD_JS
10
 
11
  logger = get_logger(__name__)
12
 
 
45
  # The HTML value contains data-* attributes with volume URLs.
46
  niivue_viewer = gr.HTML(
47
  label="Interactive 3D Viewer",
48
+ js_on_load=NIIVUE_ON_LOAD_JS,
49
  )
50
 
51
  # Slice comparisons (Matplotlib)
src/stroke_deepisles_demo/ui/viewer.py CHANGED
@@ -326,9 +326,9 @@ def create_niivue_html(
326
 
327
 
328
  # JavaScript code for js_on_load parameter
329
- # This runs when the gr.HTML component loads/updates
330
  # Variables available: element, props, trigger
331
- NIIVUE_JS_ON_LOAD = f"""
332
  (async () => {{
333
  const container = element.querySelector('.niivue-viewer') || element;
334
  const canvas = element.querySelector('canvas');
@@ -410,3 +410,94 @@ NIIVUE_JS_ON_LOAD = f"""
410
  }}
411
  }})();
412
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
 
327
 
328
  # JavaScript code for js_on_load parameter
329
+ # This runs when the gr.HTML component FIRST loads (mounts)
330
  # Variables available: element, props, trigger
331
+ NIIVUE_ON_LOAD_JS = f"""
332
  (async () => {{
333
  const container = element.querySelector('.niivue-viewer') || element;
334
  const canvas = element.querySelector('canvas');
 
410
  }}
411
  }})();
412
  """
413
+
414
+ # JavaScript code for event handlers (e.g. .then(js=...))
415
+ # This runs after Python updates the HTML value.
416
+ # ⚠️ CRITICAL: 'element' is NOT available here! Must use document.querySelector
417
+ NIIVUE_UPDATE_JS = f"""
418
+ (async () => {{
419
+ // We must find the container globally since 'element' is not available in event handlers
420
+ const container = document.querySelector('.niivue-viewer');
421
+
422
+ if (!container) {{
423
+ console.error('NiiVue container not found');
424
+ return;
425
+ }}
426
+
427
+ const canvas = container.querySelector('canvas');
428
+ const status = container.querySelector('.niivue-status');
429
+
430
+ // Get URLs from data attributes
431
+ const volumeUrl = container.dataset.volumeUrl;
432
+ const maskUrl = container.dataset.maskUrl;
433
+
434
+ // Skip if no volume URL
435
+ if (!volumeUrl) {{
436
+ return;
437
+ }}
438
+
439
+ try {{
440
+ if (status) status.innerText = 'Reloading NiiVue...';
441
+
442
+ // Check WebGL2 support
443
+ const gl = canvas.getContext('webgl2');
444
+ if (!gl) {{
445
+ container.innerHTML = '<div style="color:#fff;padding:20px;text-align:center;">WebGL2 not supported. Please use a modern browser.</div>';
446
+ return;
447
+ }}
448
+
449
+ // Dynamically import NiiVue from CDN
450
+ const {{ Niivue }} = await import('{NIIVUE_CDN_URL}');
451
+
452
+ // Initialize NiiVue
453
+ const nv = new Niivue({{
454
+ logging: false,
455
+ show3Dcrosshair: true,
456
+ textHeight: 0.04,
457
+ backColor: [0, 0, 0, 1],
458
+ crosshairColor: [0.2, 0.8, 0.2, 1]
459
+ }});
460
+
461
+ // Attach to canvas
462
+ await nv.attachToCanvas(canvas);
463
+
464
+ // Hide status message
465
+ if (status) status.style.display = 'none';
466
+
467
+ // Prepare volumes
468
+ const volumes = [{{ url: volumeUrl, name: 'input.nii.gz' }}];
469
+
470
+ if (maskUrl) {{
471
+ volumes.push({{
472
+ url: maskUrl,
473
+ colorMap: 'red',
474
+ opacity: 0.5
475
+ }});
476
+ }}
477
+
478
+ // Load volumes
479
+ await nv.loadVolumes(volumes);
480
+
481
+ // Configure view: multiplanar + 3D
482
+ nv.setSliceType(nv.sliceTypeMultiplanar);
483
+ if (typeof nv.setMultiplanarLayout === 'function') {{
484
+ nv.setMultiplanarLayout(2);
485
+ }}
486
+ nv.opts.show3Dcrosshair = true;
487
+ nv.setRenderAzimuthElevation(120, 10);
488
+ nv.drawScene();
489
+
490
+ console.log('NiiVue viewer re-initialized successfully via event handler');
491
+
492
+ }} catch (error) {{
493
+ console.error('NiiVue re-initialization error:', error);
494
+ const errorDiv = document.createElement('div');
495
+ errorDiv.style.cssText = 'color:#f66;padding:20px;text-align:center;';
496
+ errorDiv.textContent = 'Error reloading viewer: ' + error.message;
497
+ if (container) {{
498
+ container.innerHTML = '';
499
+ container.appendChild(errorDiv);
500
+ }}
501
+ }}
502
+ }})();
503
+ """