detached 5c01908

refactor: use Map registry for bridge callbacks with unique IDs and timeout

commit
5c01908df7b1c28b0c5c79d177f2300896c52c93
author
brice · · 2026-01-24 16:38 UTC
parent
200ec03
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +46 -11
M webui/src/lib/bridge.ts +46 -11
@@ -76,13 +76,27 @@ export function postMessage(msg: BridgeMessage): void {
7676 bridge.postMessage(plainMsg);
7777 }
7878
79+/**
80+ * Internal callback registry using Map for tracking pending requests.
81+ * Callbacks must also be exposed on window for Go backend to call,
82+ * but we track them here for proper cleanup and to prevent leaks.
83+ */
84+const pendingCallbacks = new Map<string, {
85+ cleanup: () => void;
86+ timeoutId: ReturnType<typeof setTimeout>;
87+}>();
88+
89+let callbackCounter = 0;
90+const CALLBACK_TIMEOUT_MS = 30000;
91+
7992 /**
8093 * Post a message and set up success/error callbacks with automatic cleanup.
94+ * Uses unique IDs to prevent naming conflicts between concurrent requests.
8195 *
8296 * @param type - Message type (e.g., "get_keybindings", "set_keybinding")
8397 * @param payload - Message payload (will be serialized to strip Proxies)
84- * @param successCallback - Window function name to call on success
85- * @param errorCallback - Window function name to call on error
98+ * @param successCallback - Base name for success callback (will be made unique)
99+ * @param errorCallback - Base name for error callback (will be made unique)
86100 * @param onSuccess - Handler for success response
87101 * @param onError - Handler for error response
88102 */
@@ -94,22 +108,40 @@ export function postMessageWithCallbacks<TResponse, TPayload extends Record<stri
94108 onSuccess: (response: TResponse) => void,
95109 onError: (error: string) => void,
96110 ): void {
97- // Cleanup function to remove callbacks from window
111+ const id = ++callbackCounter;
112+ const successKey = `${successCallback}_${id}`;
113+ const errorKey = `${errorCallback}_${id}`;
114+
115+ // Cleanup removes from both window and our tracking Map
98116 const cleanup = () => {
117+ const entry = pendingCallbacks.get(successKey);
118+ if (entry) {
119+ clearTimeout(entry.timeoutId);
120+ pendingCallbacks.delete(successKey);
121+ }
99122 // eslint-disable-next-line @typescript-eslint/no-explicit-any
100- delete (window as any)[successCallback];
123+ delete (window as any)[successKey];
101124 // eslint-disable-next-line @typescript-eslint/no-explicit-any
102- delete (window as any)[errorCallback];
125+ delete (window as any)[errorKey];
103126 };
104127
105- // Set up callbacks on window with automatic cleanup
128+ // Timeout prevents memory leaks if backend never responds
129+ const timeoutId = setTimeout(() => {
130+ cleanup();
131+ onError("Request timed out");
132+ }, CALLBACK_TIMEOUT_MS);
133+
134+ // Track in our Map for proper lifecycle management
135+ pendingCallbacks.set(successKey, { cleanup, timeoutId });
136+
137+ // Expose on window for Go backend (it calls window[callbackName](response))
106138 // eslint-disable-next-line @typescript-eslint/no-explicit-any
107- (window as any)[successCallback] = (response: TResponse) => {
139+ (window as any)[successKey] = (response: TResponse) => {
108140 cleanup();
109141 onSuccess(response);
110142 };
111143 // eslint-disable-next-line @typescript-eslint/no-explicit-any
112- (window as any)[errorCallback] = (msg: string) => {
144+ (window as any)[errorKey] = (msg: unknown) => {
113145 cleanup();
114146 onError(typeof msg === "string" ? msg : "Unknown error");
115147 };
@@ -118,11 +150,14 @@ export function postMessageWithCallbacks<TResponse, TPayload extends Record<stri
118150 postMessage({
119151 type,
120152 webviewId: getWebViewId(),
121- payload,
153+ payload: {
154+ ...payload,
155+ successCallback: successKey,
156+ errorCallback: errorKey,
157+ },
122158 });
123159 } catch (err) {
124160 cleanup();
125- const message = err instanceof Error ? err.message : String(err);
126- onError(message);
161+ onError(err instanceof Error ? err.message : String(err));
127162 }
128163 }
@@ -76,13 +76,27 @@ export function postMessage(msg: BridgeMessage): void {
76 bridge.postMessage(plainMsg);76 bridge.postMessage(plainMsg);
77 }77 }
78 78
79+/**
80+ * Internal callback registry using Map for tracking pending requests.
81+ * Callbacks must also be exposed on window for Go backend to call,
82+ * but we track them here for proper cleanup and to prevent leaks.
83+ */
84+const pendingCallbacks = new Map<string, {
85+ cleanup: () => void;
86+ timeoutId: ReturnType<typeof setTimeout>;
87+}>();
88+
89+let callbackCounter = 0;
90+const CALLBACK_TIMEOUT_MS = 30000;
91+
79 /**92 /**
80 * Post a message and set up success/error callbacks with automatic cleanup.93 * Post a message and set up success/error callbacks with automatic cleanup.
94+ * Uses unique IDs to prevent naming conflicts between concurrent requests.
81 *95 *
82 * @param type - Message type (e.g., "get_keybindings", "set_keybinding")96 * @param type - Message type (e.g., "get_keybindings", "set_keybinding")
83 * @param payload - Message payload (will be serialized to strip Proxies)97 * @param payload - Message payload (will be serialized to strip Proxies)
84- * @param successCallback - Window function name to call on success98+ * @param successCallback - Base name for success callback (will be made unique)
85- * @param errorCallback - Window function name to call on error99+ * @param errorCallback - Base name for error callback (will be made unique)
86 * @param onSuccess - Handler for success response100 * @param onSuccess - Handler for success response
87 * @param onError - Handler for error response101 * @param onError - Handler for error response
88 */102 */
@@ -94,22 +108,40 @@ export function postMessageWithCallbacks<TResponse, TPayload extends Record<stri
94 onSuccess: (response: TResponse) => void,108 onSuccess: (response: TResponse) => void,
95 onError: (error: string) => void,109 onError: (error: string) => void,
96 ): void {110 ): void {
97- // Cleanup function to remove callbacks from window111+ const id = ++callbackCounter;
112+ const successKey = `${successCallback}_${id}`;
113+ const errorKey = `${errorCallback}_${id}`;
114+
115+ // Cleanup removes from both window and our tracking Map
98 const cleanup = () => {116 const cleanup = () => {
117+ const entry = pendingCallbacks.get(successKey);
118+ if (entry) {
119+ clearTimeout(entry.timeoutId);
120+ pendingCallbacks.delete(successKey);
121+ }
99 // eslint-disable-next-line @typescript-eslint/no-explicit-any122 // eslint-disable-next-line @typescript-eslint/no-explicit-any
100- delete (window as any)[successCallback];123+ delete (window as any)[successKey];
101 // eslint-disable-next-line @typescript-eslint/no-explicit-any124 // eslint-disable-next-line @typescript-eslint/no-explicit-any
102- delete (window as any)[errorCallback];125+ delete (window as any)[errorKey];
103 };126 };
104 127
105- // Set up callbacks on window with automatic cleanup128+ // Timeout prevents memory leaks if backend never responds
129+ const timeoutId = setTimeout(() => {
130+ cleanup();
131+ onError("Request timed out");
132+ }, CALLBACK_TIMEOUT_MS);
133+
134+ // Track in our Map for proper lifecycle management
135+ pendingCallbacks.set(successKey, { cleanup, timeoutId });
136+
137+ // Expose on window for Go backend (it calls window[callbackName](response))
106 // eslint-disable-next-line @typescript-eslint/no-explicit-any138 // eslint-disable-next-line @typescript-eslint/no-explicit-any
107- (window as any)[successCallback] = (response: TResponse) => {139+ (window as any)[successKey] = (response: TResponse) => {
108 cleanup();140 cleanup();
109 onSuccess(response);141 onSuccess(response);
110 };142 };
111 // eslint-disable-next-line @typescript-eslint/no-explicit-any143 // eslint-disable-next-line @typescript-eslint/no-explicit-any
112- (window as any)[errorCallback] = (msg: string) => {144+ (window as any)[errorKey] = (msg: unknown) => {
113 cleanup();145 cleanup();
114 onError(typeof msg === "string" ? msg : "Unknown error");146 onError(typeof msg === "string" ? msg : "Unknown error");
115 };147 };
@@ -118,11 +150,14 @@ export function postMessageWithCallbacks<TResponse, TPayload extends Record<stri
118 postMessage({150 postMessage({
119 type,151 type,
120 webviewId: getWebViewId(),152 webviewId: getWebViewId(),
121- payload,153+ payload: {
154+ ...payload,
155+ successCallback: successKey,
156+ errorCallback: errorKey,
157+ },
122 });158 });
123 } catch (err) {159 } catch (err) {
124 cleanup();160 cleanup();
125- const message = err instanceof Error ? err.message : String(err);161+ onError(err instanceof Error ? err.message : String(err));
126- onError(message);
127 }162 }
128 }163 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close