Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,78 @@ describe('DaemonWorkspaceProvider', () => {
);
});

describe('useDaemonWorkspace guard diagnostics', () => {
const REGISTRY_KEY = '__qwenWebShellDaemonWorkspaceProviderCopies';

function renderBareConsumer() {
Comment thread
wenshao marked this conversation as resolved.
Outdated
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;
Comment thread
wenshao marked this conversation as resolved.
Outdated
}

it('reports the consumer as outside the subtree once this module copy rendered a provider', async () => {
Comment thread
wenshao marked this conversation as resolved.
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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
wenshao marked this conversation as resolved.
Outdated

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;
Expand Down Expand Up @@ -217,6 +235,10 @@ export function DaemonWorkspaceProvider({

const contextValue = useMemo<DaemonWorkspaceContextValue | undefined>(() => {
if (!client) return undefined;
const copies = renderedProviderCopies();
if (!copies.includes(moduleInstanceId)) {
Comment thread
wenshao marked this conversation as resolved.
Outdated
copies.push(moduleInstanceId);
}
Comment thread
wenshao marked this conversation as resolved.
Outdated
return {
client,
token,
Expand Down Expand Up @@ -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;
Expand Down
28 changes: 28 additions & 0 deletions packages/web-shell/client/main.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface CapturedWorkspaceSessionProps {

const testState = vi.hoisted(() => ({
props: undefined as CapturedWorkspaceSessionProps | undefined,
throwOnRender: false,
}));

vi.mock('react-dom/client', async (importOriginal) => ({
Expand All @@ -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;
},
Expand All @@ -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);
Expand All @@ -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', () => {
Expand Down
12 changes: 10 additions & 2 deletions packages/web-shell/client/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
Comment thread
wenshao marked this conversation as resolved.
Outdated
Comment thread
wenshao marked this conversation as resolved.
Outdated
language={language}
/>
)}
>
<DaemonWorkspaceProvider baseUrl={baseUrl} token={daemonToken}>
Expand Down
Loading