Skip to content

Commit 147c781

Browse files
authored
Fix notebook kernel widget crash when toggling preview tabs. (#12915)
Addresses #12914 and #12464 When a notebook replaces a preview tab, the kernel status badge widget can render before `attachView()` completes. At that point `scopedContextKeyService` is still undefined, causing `useMenu` to crash when it tries to create a menu. This PR: - Makes `KernelStatusBadge` defer menu creation until `container` is set (the readiness signal for `attachView()`) - Makes `useMenu` handle `undefined` `contextKeyService` gracefully and synchronously mask stale menus when the service identity changes between renders - Fixes `editorActionBarFactory` to use the correct private `_contextKeyService` field ### Before _Opening up a new notebook in the same preview tab as a previous one caused a crash of the widget._ https://github.com/user-attachments/assets/9e662fbd-dd71-4726-a4a8-b1acb2fae57f ### After _Widget no longer crashes when swapping editors in a preview tab_ https://github.com/user-attachments/assets/60793fb0-7175-47d9-b619-fab198f1bb83 ### Release Notes #### New Features - N/A #### Bug Fixes - Fixed a crash in the notebook kernel selector when a notebook replaces another notebook in a preview tab (#12914) ### QA Notes @:editor-action-bar @:positron-notebooks @:quarto 1. Single-click a notebook in the explorer to open in a preview tab 2. Double-click or edit the notebook so it replaces the preview tab 3. Verify no crash occurs and the kernel selector badge appears correctly
1 parent 764bbf4 commit 147c781

5 files changed

Lines changed: 290 additions & 20 deletions

File tree

src/vs/workbench/contrib/positronNotebook/browser/KernelStatusBadge.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,15 @@ export function KernelStatusBadge() {
8181
// This shouldn't happen...
8282
return '';
8383
}));
84-
const menu = useMenu(MenuId.PositronNotebookKernelSubmenu, notebookInstance.scopedContextKeyService);
84+
// scopedContextKeyService is only available after attachView() is called.
85+
// When a notebook replaces a preview tab, the widget may render before
86+
// attachView() completes. Use container (set last in attachView) as the
87+
// readiness signal.
88+
const container = useObservedValue(notebookInstance.container);
89+
const menu = useMenu(
90+
MenuId.PositronNotebookKernelSubmenu,
91+
container ? notebookInstance.scopedContextKeyService : undefined
92+
);
8593

8694
// Callback to load actions from the menu
8795
const getActions = React.useCallback(() => {

src/vs/workbench/contrib/positronNotebook/browser/useMenu.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,22 @@ export function useMenu(
4343
const [menu, setMenu] = React.useState<IMenu | undefined>();
4444
const [version, setVersion] = React.useState(0);
4545

46+
// Track which contextKeyService created the current menu so we can
47+
// synchronously mask stale menus when the service identity changes
48+
// (not just when it becomes undefined).
49+
const menuOwnerRef = React.useRef<IContextKeyService | undefined>(undefined);
50+
4651
// Main effect
4752
React.useEffect(() => {
4853
if (!contextKeyService) {
54+
setMenu(undefined);
55+
menuOwnerRef.current = undefined;
4956
return;
5057
}
5158

5259
const menu = menuService.createMenu(menuId, contextKeyService, options);
5360
setMenu(menu);
61+
menuOwnerRef.current = contextKeyService;
5462
setVersion(0);
5563

5664
const disposable = combinedDisposable(
@@ -60,5 +68,10 @@ export function useMenu(
6068
return () => disposable.dispose();
6169
}, [menuService, contextKeyService, menuId, options]);
6270

63-
return React.useMemo(() => ({ current: menu, version }), [menu, version]);
71+
// Synchronously mask the menu whenever the contextKeyService doesn't
72+
// match the service that created it. This covers both the undefined
73+
// case and the case where the service changes identity (e.g. during
74+
// notebook tab reuse: service A -> undefined -> service B).
75+
const current = (contextKeyService && contextKeyService === menuOwnerRef.current) ? menu : undefined;
76+
return React.useMemo(() => ({ current, version }), [current, version]);
6477
}

src/vs/workbench/contrib/positronNotebook/browser/useMenuActions.tsx

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
44
*--------------------------------------------------------------------------------------------*/
55
import React from 'react';
6-
import { MenuItemAction, SubmenuItemAction, IMenuActionOptions } from '../../../../platform/actions/common/actions.js';
6+
import { IMenuActionOptions } from '../../../../platform/actions/common/actions.js';
77
import { IVersionedMenu } from './useMenu.js';
88

99
/**
@@ -17,16 +17,8 @@ export function useMenuActions(
1717
menu: IVersionedMenu,
1818
options?: IMenuActionOptions,
1919
) {
20-
// State
21-
const [actions, setActions] = React.useState<[string, (MenuItemAction | SubmenuItemAction)[]][]>([]);
22-
23-
// Main effect
24-
React.useEffect(() => {
25-
if (!menu.current) {
26-
return setActions([]);
27-
}
28-
setActions(menu.current.getActions(options));
29-
}, [menu, options]);
30-
31-
return actions;
20+
return React.useMemo(
21+
() => menu.current ? menu.current.getActions(options) : [],
22+
[menu, options],
23+
);
3224
}

src/vs/workbench/contrib/positronNotebook/test/browser/notebookCells/CellOutputActionBar.test.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,11 @@ suite('CellOutputActionBar', () => {
115115
</PositronReactServicesContext.Provider>
116116
);
117117

118-
// TODO: Render three times to settle chained useEffect state updates
119-
// in useMenu and useMenuActions. Each hook sets state in an effect,
120-
// requiring a separate render cycle to propagate.
121-
// https://github.com/posit-dev/positron/issues/12464
118+
// Render twice: once for the initial render, once for the useMenu
119+
// effect to create the menu and trigger a re-render. useMenuActions
120+
// now uses useMemo so it resolves in the same cycle as useMenu.
122121
const container = render(element);
123122
render(element);
124-
render(element);
125123
return new CellOutputActionBarFixture(container);
126124
}
127125

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
3+
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
/* eslint-disable local/code-no-dangerous-type-assertions */
7+
8+
import assert from 'assert';
9+
import sinon from 'sinon';
10+
import { Emitter } from '../../../../../base/common/event.js';
11+
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
12+
import { setupReactRenderer } from '../../../../../base/test/browser/react.js';
13+
import { IMenu, IMenuChangeEvent, IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../../../../../platform/actions/common/actions.js';
14+
import { IVersionedMenu, useMenu } from '../../browser/useMenu.js';
15+
import { useMenuActions } from '../../browser/useMenuActions.js';
16+
import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js';
17+
import { PositronReactServicesContext } from '../../../../../base/browser/positronReactRendererContext.js';
18+
import { PositronReactServices } from '../../../../../base/browser/positronReactServices.js';
19+
20+
type Actions = [string, (MenuItemAction | SubmenuItemAction)[]][];
21+
22+
/** Harness that renders useMenuActions with a directly controlled IVersionedMenu. */
23+
function UseMenuActionsHarness({ menu, onActions }: {
24+
menu: IVersionedMenu;
25+
onActions: (a: Actions) => void;
26+
}) {
27+
const actions = useMenuActions(menu);
28+
onActions(actions);
29+
return null;
30+
}
31+
32+
/** Harness that composes useMenu + useMenuActions the way real components do. */
33+
function ComposedHarness({ contextKeyService, onActions }: {
34+
contextKeyService: MockContextKeyService | undefined;
35+
onActions: (a: Actions) => void;
36+
}) {
37+
const menu = useMenu(MenuId.CommandPalette, contextKeyService);
38+
const actions = useMenuActions(menu);
39+
onActions(actions);
40+
return null;
41+
}
42+
43+
suite('useMenuActions', () => {
44+
const { render } = setupReactRenderer();
45+
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
46+
47+
const action = (id: string) => ({ id }) as MenuItemAction;
48+
49+
suite('standalone', () => {
50+
test('returns empty array when menu.current is undefined', () => {
51+
let captured: Actions = [];
52+
const menu: IVersionedMenu = { current: undefined, version: 0 };
53+
54+
render(<UseMenuActionsHarness menu={menu} onActions={a => { captured = a; }} />);
55+
assert.deepStrictEqual(captured, []);
56+
});
57+
58+
test('returns actions when menu.current is present', () => {
59+
let captured: Actions = [];
60+
const expected: Actions = [['group', [action('a1'), action('a2')]]];
61+
const menu: IVersionedMenu = {
62+
current: { getActions: () => expected } as unknown as IMenu,
63+
version: 0,
64+
};
65+
66+
render(<UseMenuActionsHarness menu={menu} onActions={a => { captured = a; }} />);
67+
assert.deepStrictEqual(captured, expected);
68+
});
69+
70+
test('updates when version changes', () => {
71+
let captured: Actions = [];
72+
const actionsV0: Actions = [['g', [action('old')]]];
73+
const actionsV1: Actions = [['g', [action('new')]]];
74+
let currentActions = actionsV0;
75+
76+
const mockMenu = { getActions: () => currentActions } as unknown as IMenu;
77+
78+
render(<UseMenuActionsHarness
79+
menu={{ current: mockMenu, version: 0 }}
80+
onActions={a => { captured = a; }}
81+
/>);
82+
assert.deepStrictEqual(captured, actionsV0);
83+
84+
currentActions = actionsV1;
85+
render(<UseMenuActionsHarness
86+
menu={{ current: mockMenu, version: 1 }}
87+
onActions={a => { captured = a; }}
88+
/>);
89+
assert.deepStrictEqual(captured, actionsV1);
90+
});
91+
});
92+
93+
suite('composed with useMenu', () => {
94+
let contextKeyService: MockContextKeyService;
95+
let menuActions: Actions;
96+
let onDidChange: Emitter<IMenuChangeEvent>;
97+
98+
setup(() => {
99+
contextKeyService = disposables.add(new MockContextKeyService());
100+
menuActions = [];
101+
onDidChange = disposables.add(new Emitter<IMenuChangeEvent>());
102+
});
103+
104+
/** Tracks all menus created by the mock service. */
105+
let createdMenus: { menu: IMenu; dispose: sinon.SinonSpy }[];
106+
107+
function createServices(): PositronReactServices {
108+
createdMenus = [];
109+
const menuService: IMenuService = {
110+
_serviceBrand: undefined,
111+
createMenu: () => {
112+
const dispose = sinon.spy();
113+
const menu: IMenu = {
114+
onDidChange: onDidChange.event,
115+
dispose,
116+
getActions: () => menuActions,
117+
};
118+
createdMenus.push({ menu, dispose });
119+
return menu;
120+
},
121+
getMenuActions: () => [],
122+
getMenuContexts: () => new Set(),
123+
resetHiddenStates: () => { },
124+
};
125+
return {
126+
get: (id: any) => {
127+
if (id === IMenuService) { return menuService; }
128+
throw new Error(`Unexpected service: ${id}`);
129+
},
130+
} as unknown as PositronReactServices;
131+
}
132+
133+
test('resolves actions in the same render as useMenu', () => {
134+
menuActions = [['g', [action('a1')]]];
135+
let captured: Actions = [];
136+
const services = createServices();
137+
138+
const element = (
139+
<PositronReactServicesContext.Provider value={services}>
140+
<ComposedHarness
141+
contextKeyService={contextKeyService}
142+
onActions={a => { captured = a; }}
143+
/>
144+
</PositronReactServicesContext.Provider>
145+
);
146+
147+
// Render 1: initial (effect queued, menu undefined)
148+
render(element);
149+
assert.deepStrictEqual(captured, [], 'no actions before effect settles');
150+
151+
// Render 2: effect creates menu; useMemo resolves actions in same cycle
152+
render(element);
153+
assert.deepStrictEqual(captured, [['g', [action('a1')]]]);
154+
});
155+
156+
test('updates actions when menu fires onDidChange', () => {
157+
menuActions = [['g', [action('v0')]]];
158+
let captured: Actions = [];
159+
const services = createServices();
160+
161+
const element = (
162+
<PositronReactServicesContext.Provider value={services}>
163+
<ComposedHarness
164+
contextKeyService={contextKeyService}
165+
onActions={a => { captured = a; }}
166+
/>
167+
</PositronReactServicesContext.Provider>
168+
);
169+
170+
render(element);
171+
render(element);
172+
assert.deepStrictEqual(captured, [['g', [action('v0')]]]);
173+
174+
// Simulate menu content change
175+
menuActions = [['g', [action('v1')]]];
176+
onDidChange.fire({} as IMenuChangeEvent);
177+
178+
// One render to pick up the version bump
179+
render(element);
180+
assert.deepStrictEqual(captured, [['g', [action('v1')]]]);
181+
});
182+
183+
test('clears actions when contextKeyService becomes undefined', () => {
184+
menuActions = [['g', [action('a1')]]];
185+
let captured: Actions = [];
186+
const services = createServices();
187+
188+
const withService = (
189+
<PositronReactServicesContext.Provider value={services}>
190+
<ComposedHarness
191+
contextKeyService={contextKeyService}
192+
onActions={a => { captured = a; }}
193+
/>
194+
</PositronReactServicesContext.Provider>
195+
);
196+
render(withService);
197+
render(withService);
198+
assert.deepStrictEqual(captured, [['g', [action('a1')]]]);
199+
200+
// Remove contextKeyService -- actions must clear synchronously
201+
render(
202+
<PositronReactServicesContext.Provider value={services}>
203+
<ComposedHarness
204+
contextKeyService={undefined}
205+
onActions={a => { captured = a; }}
206+
/>
207+
</PositronReactServicesContext.Provider>
208+
);
209+
assert.deepStrictEqual(captured, [], 'actions must clear when service disappears');
210+
});
211+
212+
test('clears stale actions and disposes old menu on contextKeyService identity swap', () => {
213+
menuActions = [['g', [action('a1')]]];
214+
let captured: Actions = [];
215+
const services = createServices();
216+
217+
const serviceA = contextKeyService;
218+
const serviceB = disposables.add(new MockContextKeyService());
219+
220+
// Mount with service A and settle
221+
const withA = (
222+
<PositronReactServicesContext.Provider value={services}>
223+
<ComposedHarness
224+
contextKeyService={serviceA}
225+
onActions={a => { captured = a; }}
226+
/>
227+
</PositronReactServicesContext.Provider>
228+
);
229+
render(withA);
230+
render(withA);
231+
assert.deepStrictEqual(captured, [['g', [action('a1')]]]);
232+
assert.strictEqual(createdMenus.length, 1, 'one menu created for service A');
233+
234+
// Swap to service B -- stale actions must not leak on first render
235+
render(
236+
<PositronReactServicesContext.Provider value={services}>
237+
<ComposedHarness
238+
contextKeyService={serviceB}
239+
onActions={a => { captured = a; }}
240+
/>
241+
</PositronReactServicesContext.Provider>
242+
);
243+
assert.deepStrictEqual(captured, [], 'stale actions from service A must not appear');
244+
245+
// Settle the effect for service B
246+
render(
247+
<PositronReactServicesContext.Provider value={services}>
248+
<ComposedHarness
249+
contextKeyService={serviceB}
250+
onActions={a => { captured = a; }}
251+
/>
252+
</PositronReactServicesContext.Provider>
253+
);
254+
assert.deepStrictEqual(captured, [['g', [action('a1')]]], 'actions from service B menu');
255+
assert.strictEqual(createdMenus.length, 2, 'a new menu was created for service B');
256+
sinon.assert.calledOnce(createdMenus[0].dispose);
257+
});
258+
});
259+
});

0 commit comments

Comments
 (0)