Skip to content

Commit c87fe20

Browse files
committed
fix(menu): the menu keeps working after a rebuild
Tauri keys click channels by menu id, so closing the superseded tree dropped the new tree's channels. Fixes #532
1 parent ee36ed8 commit c87fe20

2 files changed

Lines changed: 204 additions & 6 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect, beforeEach, vi } from "vitest";
3+
import { act, renderHook, waitFor } from "@testing-library/react";
4+
import { buildMenuModel, type MenuFlags, type HistorySubmenu, type SubmenuLabels } from "../lib/menuModel";
5+
import { fallbackTranslations as en } from "../locales";
6+
import type { MenuHandlers, useNativeMenu as nativeMenuHook } from "./useNativeMenu";
7+
8+
interface MockItem {
9+
id: string;
10+
text?: string;
11+
items?: MockItem[];
12+
close: () => Promise<void>;
13+
setAsAppMenu: () => Promise<void>;
14+
}
15+
16+
/** Mirrors tauri 2.12.0: `menu/plugin.rs` keys click channels by menu id, the `Drop` in
17+
* `menu/mod.rs` removes one by that id, and `muda` hands out a counter value when no id is given. */
18+
const tauri = vi.hoisted(() => ({
19+
channels: new Map<string, (id: string) => void>(),
20+
installed: [] as Record<string, unknown>[],
21+
closed: [] as string[],
22+
seq: 0,
23+
}));
24+
25+
vi.mock("../lib/platform", () => ({ isDesktopShell: true, isMacDesktop: false }));
26+
27+
vi.mock("@tauri-apps/api/menu", () => {
28+
const create = async (opts: Record<string, unknown> = {}) => {
29+
const id = typeof opts.id === "string" ? opts.id : `auto-${tauri.seq++}`;
30+
const action = opts.action as ((id: string) => void) | undefined;
31+
if (action) tauri.channels.set(id, action);
32+
const item = {
33+
...opts,
34+
id,
35+
close: async () => {
36+
tauri.closed.push(id);
37+
tauri.channels.delete(id);
38+
},
39+
setText: async () => undefined,
40+
setEnabled: async () => undefined,
41+
setChecked: async () => undefined,
42+
insert: async () => undefined,
43+
removeAt: async () => undefined,
44+
setAsAppMenu: async () => {
45+
tauri.installed.push(item);
46+
},
47+
};
48+
return item;
49+
};
50+
const kind = { new: create };
51+
return { Menu: kind, Submenu: kind, MenuItem: kind, IconMenuItem: kind, PredefinedMenuItem: kind, CheckMenuItem: kind };
52+
});
53+
54+
const BASE_FLAGS: MenuFlags = {
55+
hasObjects: true,
56+
documentEmits: true,
57+
sourceEditing: false,
58+
canBatchExport: false,
59+
batchRowCount: 0,
60+
batchPrintCount: 0,
61+
connectDataWizard: true,
62+
canBatchPdf: false,
63+
pdfCurrentPageOnly: false,
64+
canUndo: false,
65+
canRedo: false,
66+
includeQuit: true,
67+
};
68+
const BATCH_FLAGS: MenuFlags = { ...BASE_FLAGS, canBatchExport: true, batchRowCount: 3, batchPrintCount: 3 };
69+
70+
const LABELS: SubmenuLabels = { file: "File", edit: "Edit", help: "Help", quit: "Quit" };
71+
const HISTORY: HistorySubmenu = {
72+
label: "History",
73+
clearLabel: "Clear history",
74+
canClear: true,
75+
items: [
76+
{ index: 0, label: "Add text", current: false, enabled: true },
77+
{ index: 1, label: "Move text", current: true, enabled: true },
78+
],
79+
};
80+
81+
const model = (flags: MenuFlags) => buildMenuModel(en, flags);
82+
83+
const spyHandlers = (): MenuHandlers => {
84+
const out: Partial<MenuHandlers> = {};
85+
const full = model(BATCH_FLAGS);
86+
for (const item of [...full.file.flat(), ...full.edit.flat(), ...full.help.flat()]) out[item.id] = vi.fn();
87+
return out as MenuHandlers;
88+
};
89+
90+
const liveItems = (): MockItem[] => {
91+
const walk = (items: MockItem[]): MockItem[] => items.flatMap((i) => [i, ...walk(i.items ?? [])]);
92+
return walk(((tauri.installed.at(-1) as MockItem | undefined)?.items ?? []));
93+
};
94+
95+
/** Fires the click the way Rust does, with the item's own id as the payload. */
96+
const click = (text: string) => {
97+
const item = liveItems().find((i) => i.text === text);
98+
expect(item, `item "${text}" on the menubar`).toBeTruthy();
99+
const channel = tauri.channels.get((item as MockItem).id);
100+
expect(channel, `click channel of "${text}"`).toBeTruthy();
101+
(channel as (id: string) => void)((item as MockItem).id);
102+
};
103+
104+
let useNativeMenu: typeof nativeMenuHook;
105+
106+
beforeEach(async () => {
107+
tauri.channels.clear();
108+
tauri.installed.length = 0;
109+
tauri.closed.length = 0;
110+
tauri.seq = 0;
111+
// The hook keeps the installed tree in module state, so every case starts with no menu.
112+
vi.resetModules();
113+
({ useNativeMenu } = await import("./useNativeMenu"));
114+
});
115+
116+
/** The swap closes the superseded tree without awaiting it, so a click before that is no proof. */
117+
const swapped = (trees: number) =>
118+
waitFor(() => {
119+
expect(tauri.installed).toHaveLength(trees);
120+
expect(tauri.closed.length).toBeGreaterThan(0);
121+
});
122+
123+
interface Rebuilt {
124+
handlers: MenuHandlers;
125+
onHistoryJump: (index: number) => void;
126+
onHistoryClear: () => void;
127+
onInitError: () => void;
128+
}
129+
130+
async function rebuild(next: { flags?: MenuFlags; dark?: boolean }): Promise<Rebuilt> {
131+
const handlers = spyHandlers();
132+
const onHistoryJump = vi.fn();
133+
const onHistoryClear = vi.fn();
134+
const onInitError = vi.fn();
135+
const { rerender } = renderHook(
136+
({ flags }: { flags: MenuFlags }) =>
137+
useNativeMenu(model(flags), LABELS, handlers, {}, HISTORY, onHistoryJump, onHistoryClear, onInitError),
138+
{ initialProps: { flags: BASE_FLAGS } },
139+
);
140+
await waitFor(() => expect(tauri.installed).toHaveLength(1));
141+
if (next.dark !== undefined) act(() => setDark(next.dark as boolean));
142+
rerender({ flags: next.flags ?? BASE_FLAGS });
143+
await swapped(2);
144+
expect(onInitError).not.toHaveBeenCalled();
145+
return { handlers, onHistoryJump, onHistoryClear, onInitError };
146+
}
147+
148+
/** The jsdom stub in the test setup never changes, so the theme needs its own. */
149+
let listeners: (() => void)[] = [];
150+
let darkScheme = false;
151+
function setDark(dark: boolean) {
152+
darkScheme = dark;
153+
for (const fire of listeners) fire();
154+
}
155+
beforeEach(() => {
156+
listeners = [];
157+
darkScheme = false;
158+
// The hook reads `matches` off the object it subscribed with, so the flag has to be live on it.
159+
window.matchMedia = ((query: string) => ({
160+
get matches() {
161+
return query.includes("dark") && darkScheme;
162+
},
163+
addEventListener: (_: string, fire: () => void) => listeners.push(fire),
164+
removeEventListener: () => undefined,
165+
})) as unknown as typeof window.matchMedia;
166+
});
167+
168+
describe("useNativeMenu after a rebuild", () => {
169+
it("keeps every command of the new tree clickable when batch entries change the structure", async () => {
170+
const { handlers } = await rebuild({ flags: BATCH_FLAGS });
171+
172+
click(en.app.newDesign);
173+
click(en.app.sendToZebraBatchFmt.replace("{n}", "3"));
174+
click(en.app.exportBatchZplFmt.replace("{n}", "3"));
175+
176+
expect(handlers.new).toHaveBeenCalledOnce();
177+
expect(handlers.sendToZebra).toHaveBeenCalledOnce();
178+
expect(handlers.exportBatch).toHaveBeenCalledOnce();
179+
});
180+
181+
it("keeps the history submenu clickable too, the steps and the clear entry", async () => {
182+
const { onHistoryJump, onHistoryClear } = await rebuild({ flags: BATCH_FLAGS });
183+
184+
// The steps have no id of their own either since the fix, so they belong in this guard.
185+
click("Move text");
186+
click("Clear history");
187+
188+
expect(onHistoryJump).toHaveBeenCalledWith(1);
189+
expect(onHistoryClear).toHaveBeenCalledOnce();
190+
});
191+
192+
it("keeps the commands clickable when the OS theme flips", async () => {
193+
const { handlers } = await rebuild({ dark: true });
194+
195+
click(en.app.newDesign);
196+
click(en.app.sendToZebra);
197+
198+
expect(handlers.new).toHaveBeenCalledOnce();
199+
expect(handlers.sendToZebra).toHaveBeenCalledOnce();
200+
});
201+
});

‎src/hooks/useNativeMenu.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,6 @@ let latest: MenuData | null = null;
9393
let installed: InstalledMenu | null = null;
9494
let updateGen = 0;
9595
let queue: Promise<void> = Promise.resolve();
96-
/** Unique step ids: positional ids could collide with an item still closing. */
97-
let stepIdSeq = 0;
9896
/** The OS toggles a clicked CheckMenuItem's checkmark itself; after a jump we
9997
* re-assert every step's checkmark so that stray toggle can't linger. */
10098
let checkmarkDirty = false;
@@ -143,7 +141,6 @@ async function makeStepItem(
143141
): Promise<HistoryStep> {
144142
const { CheckMenuItem } = await import('@tauri-apps/api/menu');
145143
const item = await CheckMenuItem.new({
146-
id: `history-${stepIdSeq++}`,
147144
text: step.label,
148145
checked: step.current,
149146
enabled: step.enabled,
@@ -171,11 +168,12 @@ async function rebuildMenu(structureKey: string, d: MenuData, gen: number): Prom
171168

172169
const items = new Map<MenuItemId, ItemHandle>();
173170
const buildItem = async (item: { id: MenuItemId; label: string; enabled: boolean }) => {
171+
// Nothing here names a menu item: Tauri keys click channels by menu id and drops one when that
172+
// id closes, so a rebuild's old tree would take the new tree's channels. Its own ids are unique.
174173
const base = {
175-
id: item.id,
176174
text: item.label,
177175
enabled: item.enabled,
178-
action: (id: string) => latest?.handlers[id as MenuItemId]?.(),
176+
action: () => latest?.handlers[item.id]?.(),
179177
};
180178
const icon = await itemIcon(item.id, d.icons, d.dark);
181179
const handle = track(icon ? await IconMenuItem.new({ ...base, icon }) : await MenuItem.new(base));
@@ -199,7 +197,6 @@ async function rebuildMenu(structureKey: string, d: MenuData, gen: number): Prom
199197
steps.push(hstep);
200198
}
201199
const clear = track(await MenuItem.new({
202-
id: 'historyClear',
203200
text: d.history.clearLabel,
204201
enabled: d.history.canClear,
205202
action: () => latest?.onHistoryClear(),

0 commit comments

Comments
 (0)