mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-12 01:52:17 -10:00
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:
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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in new issue
Block a user