Merge PR #1606: isolate workspace DOM during rapid navigation

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
debpalash
2026-08-20 09:18:01 +05:30
5 changed files with 89 additions and 2 deletions
+1
View File
@@ -34,6 +34,7 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
- The dubbing editor's video and transcript columns can now be resized by pointer or keyboard, and the chosen split persists across launches (#1571) — thanks @invio-a11y!
- CPU-only synthesis now gets a bounded ten-minute execution budget, and a render that exhausts it is reported as a compute timeout instead of misleading "generation capacity is busy" queue pressure (#1588) — thanks @ChienNguyen1111!
- Rapid Launchpad ↔ Dub navigation now replaces the workspace DOM owner cleanly, so late media/waveform cleanup cannot trigger React's `insertBefore` crash (#1590) — thanks @nicolas-jacques!
- Watermark embedding failures now log the full traceback instead of just the exception message, so a silently-unmarked-audio incident (audio passes through unmarked by design) is diagnosable from the log alone (#1576) — thanks @paoloantinori!
- Dubbing now recovers rapid two-speaker exchanges when diarization collapses them, defaults new projects to lip sync without overwriting saved timing choices, and keeps the editor usable on narrow screens (#1584) — thanks @victordonat0!
- `OMNIVOICE_ASR_BACKEND=omnivoice` now selects the PyTorch-native Whisper path, so the documented ROCm escape hatch no longer fails as an unknown engine (#1582) — thanks @patmansk!
+9
View File
@@ -812,6 +812,15 @@ VoiceStudio now tries, in order: the default GitHub host → a gh-proxy mirror
**Linked issues:** [#130](https://github.com/debpalash/VoiceStudio/issues/130), [#60](https://github.com/debpalash/VoiceStudio/issues/60), [#57](https://github.com/debpalash/VoiceStudio/issues/57)
## Workspace navigation crashes with `insertBefore` / `NotFoundError`
This was a `v0.5.0` workspace-lifecycle bug exposed by rapid Launchpad ↔ Dub
navigation while media renderers were cleaning up. Current builds isolate each
workspace under its own DOM owner. Update VoiceStudio; no model or project data
repair is required.
**Linked issue:** [#1590](https://github.com/debpalash/VoiceStudio/issues/1590)
## Uninstalling / removing all of VoiceStudio's data
VoiceStudio is fully local — no accounts, no services, nothing to deactivate. To
+3 -2
View File
@@ -12,6 +12,7 @@ import { useAppStore, FONT_STACKS } from './store';
import { NAV_ITEMS } from './components/navItems';
import SearchableSelect from './components/SearchableSelect';
import DirectionDialog from './components/DirectionDialog';
import ModeLifecycleBoundary from './components/ModeLifecycleBoundary';
import { resolveDubDefaultTrack } from './utils/dubDefaultTrack';
// Lazy-load heavy/conditional components so they don't bloat the initial bundle.
@@ -1451,7 +1452,7 @@ function App() {
<NavRail mode={mode} setMode={setMode} side={navRailSide} onFlipSide={flipNavRailSide} />
)}
<div className="main-content">
<ModeLifecycleBoundary mode={mode}>
{/* ═══ LAUNCHPAD TAB ═══ */}
{mode === 'settings' ? (
<ErrorBoundary name="settings">
@@ -1779,7 +1780,7 @@ function App() {
</div>
</div>
)}
</div>
</ModeLifecycleBoundary>
{/* ── SIDEBAR ── */}
<Suspense fallback={<LazyFallback />}>
@@ -0,0 +1,17 @@
/**
* Owns the DOM subtree for one top-level workspace.
*
* Some workspaces host imperative renderers (WaveSurfer, media elements, and
* portals). Replacing the host when navigation changes prevents a late
* renderer cleanup from mutating the next workspace's React-owned DOM.
*/
export default function ModeLifecycleBoundary({ mode, children }) {
return (
<Fragment>
<div key={mode} className="main-content" data-mode={mode}>
{children}
</div>
</Fragment>
);
}
import { Fragment } from 'react';
@@ -0,0 +1,59 @@
import { useLayoutEffect, useRef } from 'react';
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import ModeLifecycleBoundary from './ModeLifecycleBoundary';
function ImperativeWorkspace({ name }) {
const hostRef = useRef(null);
useLayoutEffect(() => {
const host = hostRef.current;
const owned = document.createElement('div');
owned.dataset.owner = name;
host.appendChild(owned);
return () => {
// Model a renderer whose asynchronous destroy completes after React has
// committed the next route. If the DOM host were reused, this would
// erase that route's children and recreate the reported ownership race.
setTimeout(() => host.replaceChildren(), 0);
};
}, [name]);
return <section ref={hostRef}>{name}</section>;
}
describe('ModeLifecycleBoundary', () => {
it('replaces the DOM owner throughout the reported rapid navigation loop', () => {
vi.useFakeTimers();
const sequence = ['launchpad', 'dub', 'dub', 'launchpad', 'dub'];
const view = render(
<ModeLifecycleBoundary mode={sequence[0]}>
<ImperativeWorkspace name={sequence[0]} />
</ModeLifecycleBoundary>,
);
let previousHost = view.container.firstElementChild;
let previousMode = sequence[0];
for (const mode of sequence.slice(1)) {
view.rerender(
<ModeLifecycleBoundary mode={mode}>
<ImperativeWorkspace name={mode} />
</ModeLifecycleBoundary>,
);
const nextHost = view.container.firstElementChild;
if (mode !== previousMode) {
expect(nextHost).not.toBe(previousHost);
expect(previousHost.isConnected).toBe(false);
} else {
expect(nextHost).toBe(previousHost);
}
expect(nextHost.querySelector('[data-owner]')?.dataset.owner).toBe(mode);
vi.runAllTimers();
expect(nextHost.textContent).toContain(mode);
expect(nextHost.querySelector('[data-owner]')?.dataset.owner).toBe(mode);
previousHost = nextHost;
previousMode = mode;
}
vi.useRealTimers();
});
});