Electron PR Review Workbench
A practical architecture for a GitHub pull-request review desktop app using tuples in the right places and ordinary UI state in the renderer.
If you are building a GitHub PR review app in Electron, Yieldless fits best in the main process, preload bridge, and service layer. It is less useful as the shape of your React component state.
That distinction matters:
- Use tuples while you are still doing failure-heavy work: GitHub API calls, local Git reads, diff parsing, cache reads, IPC boundaries.
- Once the renderer receives the result, fold it into ordinary screen state and move on.
Main-process contract
import type { IpcProcedure } from "yieldless/ipc";
type ReviewContract = {
loadPullRequest: IpcProcedure<
[owner: string, repo: string, number: number],
{
summary: PullRequestSummary;
files: PullRequestFile[];
threads: ReviewThread[];
diffSummary: string;
},
Error
>;
};Main-process implementation
import { all } from "yieldless/all";
import { createAbortableIpcMain } from "yieldless/ipc";
import { err, ok, safeTry } from "yieldless/error";
import { runCommandSafe } from "yieldless/node";
import { runTaskGroup } from "yieldless/task";
const ipc = createAbortableIpcMain<ReviewContract>(ipcMain);
const appLifecycle = new AbortController();
ipc.handle("loadPullRequest", async (_event, requestSignal, owner, repo, number) => {
return await runTaskGroup(async (_group, signal) => {
const [fetchError, payload] = await all([
() => safeTry(github.loadPullRequest(owner, repo, number)),
() => safeTry(github.loadPullRequestFiles(owner, repo, number)),
() => safeTry(github.loadReviewThreads(owner, repo, number)),
() =>
runCommandSafe(
"git",
["diff", "--stat", "origin/main...HEAD"],
{ cwd: repositoryRoot, signal },
),
]);
if (fetchError) {
return err(fetchError);
}
const [summary, files, threads, diff] = payload;
return ok({
summary,
files,
threads,
diffSummary: diff.stdout,
});
}, {
signal: AbortSignal.any([appLifecycle.signal, requestSignal]),
});
});The important thing here is not the exact helper list. It is the layering:
yieldless/errorkeeps the boundary readable withok()anderr().yieldless/allloads the PR payload in parallel.yieldless/taskgives the whole request one shared cancellation signal.yieldless/nodekeeps Git subprocess failures in the same tuple contract.
Preload bridge
import { createAbortableIpcBridge, createAbortableIpcRenderer } from "yieldless/ipc";
const client = createAbortableIpcRenderer<ReviewContract>(ipcRenderer);
export const reviewBridge = createAbortableIpcBridge(client, [
"loadPullRequest",
] as const);Renderer state
This is the point where Yieldless should usually stop being the dominant shape.
import { match } from "yieldless/error";
type PullRequestPayload = {
summary: PullRequestSummary;
files: PullRequestFile[];
threads: ReviewThread[];
diffSummary: string;
};
type PullRequestScreenState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; data: PullRequestPayload }
| { kind: "error"; message: string };
async function loadPullRequest(
signal: AbortSignal,
owner: string,
repo: string,
number: number,
): Promise<PullRequestScreenState> {
const result = await window.reviewBridge.withSignal.loadPullRequest(
signal,
owner,
repo,
number,
);
return match(result, {
ok: (data) => ({ kind: "ready", data }),
err: (error) => ({ kind: "error", message: error.message }),
});
}That keeps the renderer normal. Components render idle, loading, ready, or error. They do not need to carry [error, value] through every prop.
Why this fits PR review tools well
- Pull-request work crosses many noisy boundaries: GitHub HTTP, local Git, caches, and Electron IPC.
- Cancellation matters because users jump between PRs quickly and the old load should stop being important.
- The renderer wants stable view state, not a low-level transport shape.
Good additions
Deduplicate duplicate PR loads while a user is switching tabs or refreshing panels.
import { singleFlight } from "yieldless/singleflight";
const loadPullRequestPayload = singleFlight(
(signal, owner: string, repo: string, number: number) =>
loadPullRequestFromGitHub(owner, repo, number, signal),
{
getKey: (owner, repo, number) => `${owner}/${repo}#${String(number)}`,
},
);Use bounded iterable work when processing many files in a diff.
import { mapAsyncLimit } from "yieldless/iterable";
const [diffError, files] = await mapAsyncLimit(
github.streamPullRequestFiles(owner, repo, number),
(file, _index, signal) => enrichFile(file, signal),
{
concurrency: 6,
signal,
},
);Avoid
Do not keep tuples as your component model.
function PullRequestView(props: {
result: SafeResult<PullRequestPayload, SerializedIpcError>;
}) {
// Every child now has to know tuple mechanics.
}Fold the tuple once into idle, loading, ready, and error state. UI code should speak in screen states.
Rule of thumb
- Use Yieldless in the main process and preload boundary.
- Convert tuples into domain state at the renderer edge.
- Do not force tuple results deep into component trees.