fix(web-shell): make the workspace provider guard self-diagnosing and reload on root retry

The strict useDaemonWorkspace guard fails closed with a message that
cannot distinguish an absent provider from a duplicated module copy
(two DaemonWorkspaceContext instances in one page, seen in dev when the
module graph is refreshed under a live page), so the root boundary
showed a dead-end fallback that only a manual reload could clear.

The provider now registers a per-module-copy marker on first render,
and the guard's error states which of the three cases it hit: no
provider rendered, a provider rendered from a different module copy
(with both copy ids), or a provider from this copy rendered and the
consumer is outside its subtree. The standalone root boundary's retry
now reloads the page, since re-mounting the same broken module graph
would throw again.

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
Shaojin WenandQwen-Coder committed 2026-09-09 09:44:02 +08:00
1 parent c069801657
commit ca4cdfb7de
4 files changed
+144 -3

No files matched your search

@@ -486,6 +486,78 @@ describe('DaemonWorkspaceProvider', () => {
);
});
describe('useDaemonWorkspace guard diagnostics', () => {
const REGISTRY_KEY = '__qwenWebShellDaemonWorkspaceProviderCopies';
function renderBareConsumer() {
let error: Error | undefined;
function Harness() {
try {
useDaemonWorkspace();
} catch (e) {
error = e as Error;
}
return null;
}
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(<Harness />);
});
return error;
}
it('reports the consumer as outside the subtree once this module copy rendered a provider', async () => {
await renderWithProvider(null);
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
const error = renderBareConsumer();
expect(error?.message).toContain(
'useDaemonWorkspace must be used within DaemonWorkspaceProvider',
);
expect(error?.message).toContain('outside its subtree');
});
it('reports when no provider has rendered in this page', () => {
const scope = globalThis as typeof globalThis & {
[REGISTRY_KEY]?: string[];
};
const saved = scope[REGISTRY_KEY];
scope[REGISTRY_KEY] = [];
try {
const error = renderBareConsumer();
expect(error?.message).toContain(
'no DaemonWorkspaceProvider has rendered in this page',
);
} finally {
scope[REGISTRY_KEY] = saved;
}
});
it('reports duplicate module copies with the foreign copy id', () => {
const scope = globalThis as typeof globalThis & {
[REGISTRY_KEY]?: string[];
};
const saved = scope[REGISTRY_KEY];
scope[REGISTRY_KEY] = ['other-copy'];
try {
const error = renderBareConsumer();
expect(error?.message).toContain('other-copy');
expect(error?.message).toContain('duplicate copies');
} finally {
scope[REGISTRY_KEY] = saved;
}
});
});
it('exposes workspace actions', async () => {
let actions: DaemonWorkspaceActions | undefined;
@@ -27,6 +27,24 @@ const DaemonWorkspaceContext = createContext<
DaemonWorkspaceContextValue | undefined
>(undefined);
// Module-copy marker for diagnosing "must be used within" failures. When the
// page ends up with two copies of this module (a Vite dev module-graph hiccup,
// a stale chunk next to a rebuilt one), each copy has its own
// DaemonWorkspaceContext, so a consumer resolved to one copy reads no provider
// even though a provider from the other copy is mounted. The registry records
// which module copies have rendered a provider so the strict hook's error can
// say which case it hit. Diagnostic only — the context itself is never shared.
const moduleInstanceId = Math.random().toString(36).slice(2, 8);
const PROVIDER_REGISTRY_KEY = '__qwenWebShellDaemonWorkspaceProviderCopies';
function renderedProviderCopies(): string[] {
const scope = globalThis as typeof globalThis & {
[PROVIDER_REGISTRY_KEY]?: string[];
};
return (scope[PROVIDER_REGISTRY_KEY] ??= []);
}
// Module-level sentinel for deferred-disposal StrictMode guard.
// See the useEffect cleanup in DaemonWorkspaceProvider for details.
let pendingDisposeClient: DaemonClient | undefined;
@@ -217,6 +235,10 @@ export function DaemonWorkspaceProvider({
const contextValue = useMemo<DaemonWorkspaceContextValue | undefined>(() => {
if (!client) return undefined;
const copies = renderedProviderCopies();
if (!copies.includes(moduleInstanceId)) {
copies.push(moduleInstanceId);
}
return {
client,
token,
@@ -252,8 +274,19 @@ export function DaemonWorkspaceProvider({
export function useDaemonWorkspace(): DaemonWorkspaceContextValue {
const context = useContext(DaemonWorkspaceContext);
if (!context) {
const copies = renderedProviderCopies();
const detail = copies.includes(moduleInstanceId)
? 'a DaemonWorkspaceProvider from this module copy rendered, so ' +
'this consumer is outside its subtree'
: copies.length > 0
? `a DaemonWorkspaceProvider rendered from module copy ` +
`${copies.join(', ')}, but this hook resolved module copy ` +
`${moduleInstanceId} — the page holds duplicate copies of the ` +
`DaemonWorkspaceProvider module`
: 'no DaemonWorkspaceProvider has rendered in this page';
throw new Error(
'useDaemonWorkspace must be used within DaemonWorkspaceProvider',
`useDaemonWorkspace must be used within DaemonWorkspaceProvider ` +
`(${detail})`,
);
}
return context;
+28
View File
@@ -15,6 +15,7 @@ interface CapturedWorkspaceSessionProps {
const testState = vi.hoisted(() => ({
props: undefined as CapturedWorkspaceSessionProps | undefined,
throwOnRender: false,
}));
vi.mock('react-dom/client', async (importOriginal) => ({
@@ -26,6 +27,9 @@ vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
}));
vi.mock('./components/WorkspaceSessionProvider', () => ({
WorkspaceSessionProvider: (props: CapturedWorkspaceSessionProps) => {
if (testState.throwOnRender) {
throw new Error('render boom');
}
testState.props = props;
return null;
},
@@ -45,6 +49,7 @@ describe('StandaloneApp', () => {
beforeEach(() => {
testState.props = undefined;
testState.throwOnRender = false;
window.history.replaceState(null, '', '/');
container = document.createElement('div');
document.body.appendChild(container);
@@ -54,6 +59,29 @@ describe('StandaloneApp', () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
it('reloads the page when the root error fallback retry is clicked', () => {
testState.throwOnRender = true;
const reload = vi.fn();
vi.stubGlobal('location', { ...window.location, reload });
// The boundary logs the caught error; keep the test output clean.
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
act(() => root.render(<StandaloneApp daemonToken="token" />));
const retry = container.querySelector('button');
expect(retry?.textContent).toBe('Try again');
act(() => {
retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(reload).toHaveBeenCalledTimes(1);
consoleError.mockRestore();
});
it('keeps the controlled session target in sync with URL changes', () => {
+10 -2
View File
@@ -195,8 +195,16 @@ export function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
return (
<ErrorBoundary
label="web-shell-root"
fallback={(error, reset) => (
<RootErrorFallback error={error} onRetry={reset} language={language} />
fallback={(error) => (
<RootErrorFallback
error={error}
// An in-place boundary retry re-mounts the same module graph, so a
// crash rooted in page-level module state (e.g. a duplicated
// context module in dev) would throw again. Only a full reload
// rebuilds the graph.
onRetry={() => window.location.reload()}
language={language}
/>
)}
>
<DaemonWorkspaceProvider baseUrl={baseUrl} token={daemonToken}>