Last active
January 3, 2025 20:08
-
-
Save gibson042/d8ab425d133400959bc5bb875898a97b to your computer and use it in GitHub Desktop.
GitHub Fixer user script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name GitHub Fixer | |
// @namespace https://github.com/gibson042 | |
// @description Sticky pull request tabs, filter CI checks by status, expand all toggles on <{Alt,Option}+Shift+E>. | |
// @source https://gist.github.com/gibson042/d8ab425d133400959bc5bb875898a97b | |
// @updateURL https://gist.github.com/gibson042/d8ab425d133400959bc5bb875898a97b/raw/github-fixer.user.js | |
// @downloadURL https://gist.github.com/gibson042/d8ab425d133400959bc5bb875898a97b/raw/github-fixer.user.js | |
// @version 0.6.1 | |
// @date 2025-01-03 | |
// @author Richard Gibson <@gmail.com> | |
// @include https://github.com/* | |
// ==/UserScript== | |
// | |
// **COPYRIGHT NOTICE** | |
// | |
// To the extent possible under law, the author(s) have dedicated all copyright | |
// and related and neighboring rights to this software to the public domain | |
// worldwide. This software is distributed without any warranty. | |
// For the CC0 Public Domain Dedication, see | |
// <https://creativecommons.org/publicdomain/zero/1.0/>. | |
// | |
// **END COPYRIGHT NOTICE** | |
// | |
// | |
// Changelog: | |
// 0.6.0 (2025-01-02) | |
// * New: <{Alt,Option}+Shift+E> expands all comments/files/etc. | |
// 0.5.0 (2024-12-30) | |
// * Fixed: Support "the new merge experience". | |
// 0.4.6 (2024-12-12) | |
// * Improved: Use better styles for the repo navigation menu when it is stuck. | |
// 0.4.0 (2024-12-04) | |
// * New: Add the small-screen repo navigation menu to the sticky header for mid-page access. | |
// 0.3.2 (2024-12-03) | |
// * Improved: Better align colors with the GitHub theme and user color scheme preferences. | |
// 0.3.0 (2024-10-16) | |
// * New: Reconstitute pull request titles that ellipsis-split a single commit's subject. | |
// 0.2.1 (2024-09-10) | |
// * Improved: Make CI check status filter controls more accessible. | |
// 0.2.0 (2024-09-10) | |
// * Fixed: Manually differentiate "cancelled" vs. "action required" CI checks. | |
// 0.1.3 (2024-04-18) | |
// * Improved: Support "action required" CI checks (and note inability to support "cancelled" vs. "failing"). | |
// 0.1.2 (2024-03-12) | |
// * Fixed: Support GitHub's dynamic content replacement. | |
// 0.1.1 (2024-03-11) | |
// * Fixed: Differentiate "queued" vs. "expected" CI checks. | |
// 0.1.0 (2024-03-08) | |
// * New: Support CI check filtering by status. | |
// 0.0.1 (2023-09-08) | |
// * original release | |
(function fix() { | |
"use strict"; | |
const ID = "gibson042-github-fixer"; | |
const log = console.log.bind(console, `[${ID}]`); | |
const warn = console.warn.bind(console, `[${ID}]`); | |
const error = console.error.bind(console, `[${ID}]`); | |
const BUTTON_SELECTOR = "button, input[type=button], [role=button]"; | |
const CLOSED_TOGGLE_SELECTOR = `:is(${[ | |
".Details-content--closed", | |
".discussion-item-toggle-closed", | |
".outdated-comment:not(.open) .show-outdated-button", | |
".js-details-container.js-file:not(.open) button.js-details-target", | |
].join(", ")}):not(details[open] > summary, details[open] > summary *)`; | |
const FILTER_CLASS = `${ID}-status-filter`; | |
const STATUSES = ["successful", "failing", "cancelled", "skipped", "action required", "queued", "expected", "in progress"]; | |
const STATUS_SEP = "::"; | |
const STABLE_CONTAINER_SELECTOR = `:is(${[ | |
".merge-pr.is-merging", | |
/* classic <=2024-12 */ ":has(> .pull-merging)", | |
].join(", ")})`; // "stable" as in "not dynamically replaced" | |
const CHECK_CONTAINER_SELECTOR = `:is(${[ | |
"section[aria-label=Checks]", | |
/* classic <=2024-12 */ ".mergeability-details", | |
].join(", ")})`; | |
const CHECK_SUMMARY_SELECTOR = `:is(${[ | |
`${CHECK_CONTAINER_SELECTOR} > [class*='MergeBoxSectionHeader-']`, | |
/* classic <=2024-12 */ `${CHECK_CONTAINER_SELECTOR} .status-meta`, | |
].join(", ")})`; | |
// As of 2024-09-10, "cancelled" and "action required" cannot be distinguished by CSS alone. | |
const CANCELLED_OR_ACTION_REQUIRED_SELECTOR = ":has(.merge-status-icon > .octicon-stop.neutral-check)"; | |
const HIDDEN_STATUSES_ATTR = `data-${ID}-hidden-statuses`; | |
const HIDDEN_STATUSES_PROP = HIDDEN_STATUSES_ATTR.replace(/^data-|-([a-z])/g, (_, letter) => letter?.toUpperCase() || ""); | |
const dataProperty = Object.freeze({ configurable: true, enumerable: false, writable: true }); | |
const defineName = (name, fn) => Object.defineProperty(fn, "name", { value: name }); | |
const withResolvers = () => { | |
let resolve, reject; | |
const promise = new Promise((...args) => ({ 0: resolve, 1: reject } = args)); | |
return { promise, resolve, reject }; | |
}; | |
const sink = () => {}; | |
const ciCheckSelector = ({ ifHiddenStatus } = {}) => { | |
const stableContainerFilter = ifHiddenStatus | |
? `[${HIDDEN_STATUSES_ATTR}*='::${ifHiddenStatus}::']` | |
: ""; | |
const stableContainerSelector = `${STABLE_CONTAINER_SELECTOR}${stableContainerFilter}`; | |
return `:is(${[ | |
`${stableContainerSelector} ${CHECK_CONTAINER_SELECTOR} [class*='ExpandedChecks-module__checksContainer'] > *`, | |
/* classic <=2024-12 */ `${stableContainerSelector} ${CHECK_CONTAINER_SELECTOR} .merge-status-list .merge-status-item`, | |
].join(", ")})`; | |
}; | |
// Reconstitute pull request titles that ellipsis-split a single commit's subject. | |
const elPRTitle = document.querySelector("#new_pull_request input#pull_request_title"); | |
if (elPRTitle?.value.endsWith("…")) { | |
const elPRBody = document.querySelector("#new_pull_request #pull_request_body"); | |
const mTail = /^…(.*)\n*/.exec(elPRBody?.value); | |
if (mTail) { | |
elPRTitle.value = elPRTitle.value.slice(0, -1) + mTail[1]; | |
elPRBody.value = elPRBody.value.slice(mTail[0].length); | |
} | |
} | |
// Add toggle affordances to CI check statuses in the summary, | |
// tracking hidden statuses as delimiter-separated values in a data property on a containing element. | |
const containers = new WeakSet(); | |
forEachMutation(document.body, { subtree: true, childList: true, characterData: true }, mutation => { | |
const nodes = mutation.type === "childList" ? mutation.addedNodes : [mutation.target.parentNode]; | |
for (const item of findMatches(nodes, `${ciCheckSelector()}${CANCELLED_OR_ACTION_REQUIRED_SELECTOR}`)) { | |
item.classList.toggle(`${ID}-action-required`, !/\bCancelled\b/i.test(item.textContent)); | |
} | |
for (const elSummary of findMatches(nodes, CHECK_SUMMARY_SELECTOR)) { | |
const elContainer = elSummary.closest(CHECK_CONTAINER_SELECTOR); | |
const elStableContainer = elContainer?.closest(STABLE_CONTAINER_SELECTOR); | |
if (!elContainer) { | |
warn(`no ${JSON.stringify(CHECK_CONTAINER_SELECTOR)} container`, elSummary); | |
continue; | |
} else if (!elStableContainer) { | |
warn(`no ${JSON.stringify(STABLE_CONTAINER_SELECTOR)} container`, elSummary); | |
continue; | |
} | |
if (!containers.has(elContainer)) { | |
// Register this new container. | |
containers.add(elContainer); | |
elContainer.addEventListener("click", evt => { | |
const { target } = evt; | |
if (!target?.matches?.(`${CHECK_SUMMARY_SELECTOR} .${FILTER_CLASS}`)) return; | |
const elContainer = target.closest(CHECK_CONTAINER_SELECTOR); | |
const elStableContainer = elContainer.closest(STABLE_CONTAINER_SELECTOR); | |
const hidden = new Set((elStableContainer.dataset[HIDDEN_STATUSES_PROP] || "").split(STATUS_SEP).filter(val => !!val)); | |
// If checked, the corresponding status is *not* hidden. | |
const checked = target.ariaChecked === "true" ? false : true; | |
target.ariaChecked = `${checked}`; | |
if (checked) { | |
hidden.delete(target.dataset.status); | |
} else { | |
hidden.add(target.dataset.status); | |
} | |
elStableContainer.dataset[HIDDEN_STATUSES_PROP] = ["", ...hidden, ""].join(STATUS_SEP); | |
}); | |
} | |
// Skip elements that we've already addressed. | |
if (elSummary.querySelector(`.${FILTER_CLASS}`)) continue; | |
const hidden = new Set((elStableContainer.dataset[HIDDEN_STATUSES_PROP] || "").split(STATUS_SEP)); | |
// To preserve GitHub's own element reference, don't overwrite <button> HTML. | |
const selReplaceable = ":scope:not(:has(button)), :scope:has(button) > *:not(button, :has(button))"; | |
for (const el of findMatches([elSummary], selReplaceable)) { | |
el.innerHTML = el.innerHTML.replace( | |
RegExp(`[0-9]+\\s+(${STATUSES.join("|")})`, "g"), | |
(text, status) => { | |
return [ | |
`<button class="${FILTER_CLASS}" tabindex="0" title="toggle visibility"`, | |
`data-status="${status}" role="checkbox" aria-checked=${!hidden.has(status)}>${text}</button>`, | |
].join(" "); | |
}, | |
); | |
} | |
} | |
}); | |
document.head.insertAdjacentHTML("beforeend", `<style type="text/css"> | |
/* Expose repository links over a sticky header. */ | |
button[data-action^='click:deferred-side-panel'].is-stuck { | |
top: 12px !important; | |
left: 12px !important; | |
background-color: var(--${ID}-bgColor); | |
z-index: 1; | |
} | |
:root:has(button[data-action^='click:deferred-side-panel'].is-stuck) [role=region][aria-labelledby] > h2:first-child { | |
margin-left: min(var(--pane-width, 0px), 40px); | |
} | |
.AppHeader .AppHeader-localBar.is-stuck { | |
left: auto !important; | |
right: 12px !important; | |
max-width: min-content; | |
padding-left: 0; | |
z-index: 9999; | |
} | |
.AppHeader .AppHeader-localBar.is-stuck .UnderlineNav { | |
box-shadow: none; | |
} | |
.AppHeader .AppHeader-localBar.is-stuck .UnderlineNav-body { | |
display: none; | |
} | |
.AppHeader .AppHeader-localBar.is-stuck .UnderlineNav-actions { | |
visibility: visible !important; | |
padding-right: 0 !important; | |
} | |
.AppHeader .AppHeader-localBar.is-stuck .UnderlineNav-actions [popovertarget]:not([popover] *) { | |
zoom: 0.8; | |
} | |
.gh-header-sticky.is-stuck, [data-testid=issue-metadata-sticky] /*, [I WISH!] :stuck */ { | |
padding-right: 16px; | |
} | |
/* Place a sticky tabnav over the sticky header top left, invisible except on hover. */ | |
:root, body { | |
--${ID}-bgColor: var(--overlay-bgColor, #ffffff); | |
--${ID}-borderColor: var(--overlay-borderColor, #d1d9e080); | |
--${ID}-highlight-bgColor: var(--highlight-neutral-bgColor, #fff8c5); | |
} | |
@media (prefers-color-scheme: dark) { | |
:root, body { | |
--${ID}-bgColor: var(--overlay-bgColor, #151b23); | |
--${ID}-borderColor: var(--overlay-borderColor, #3d444db3); | |
--${ID}-highlight-bgColor: var(--highlight-neutral-bgColor, #d2992266); | |
} | |
} | |
.tabnav.js-sticky { | |
width: unset !important; | |
} | |
.tabnav.is-stuck { | |
max-width: 80px; | |
max-height: 60px; | |
padding-top: 60px; | |
overflow: auto; | |
z-index: 999; | |
opacity: 0; | |
} | |
.tabnav.is-stuck:hover { | |
max-width: fit-content; | |
max-height: unset; | |
overflow: unset; | |
opacity: 1; | |
} | |
/* Hide children except the navigation tabs, and arrange those vertically. */ | |
.tabnav.is-stuck > * { | |
display: none !important; | |
} | |
.tabnav.is-stuck > nav.tabnav-tabs { | |
display: flex !important; | |
flex-direction: column; | |
background-color: var(--${ID}-bgColor); | |
border: 1px solid var(--${ID}-borderColor); | |
} | |
/* Update tab styling for better vertical functionality. */ | |
.tabnav.is-stuck *:where(.tabnav-tab.selected, .tabnav-tab[aria-selected=true], .tabnav-tab[aria-current]:not([aria-current=false])) { | |
border-bottom-width: 1px; | |
border-bottom-style: solid; | |
} | |
.tabnav.is-stuck .tabnav-tab:hover { | |
background-color: var(--${ID}-highlight-bgColor); | |
} | |
/* Style the filter toggles. */ | |
${CHECK_CONTAINER_SELECTOR} .${FILTER_CLASS} { | |
cursor: pointer; | |
display: inline; | |
position: relative; | |
z-index: 99; | |
border: none; | |
padding: 1px 3px; | |
background: transparent; | |
font-weight: inherit; | |
color: var(--fgColor-accent, var(--color-accent-fg, blue)); | |
} | |
${CHECK_CONTAINER_SELECTOR} .${FILTER_CLASS}[aria-checked=true] { | |
font-weight: bold; | |
} | |
/* Hide any item whose status is marked as hidden. */ | |
${ciCheckSelector({ ifHiddenStatus: "successful" })}:has( > [aria-label*=' successful ']) { display: none !important; } | |
${ciCheckSelector({ ifHiddenStatus: "failing" })}:has( > [aria-label*=' failing ']) { display: none !important; } | |
${ciCheckSelector({ ifHiddenStatus: "skipped" })}:has( > [aria-label*=' skipped ']) { display: none !important; } | |
${ciCheckSelector({ ifHiddenStatus: "cancelled" })}:has( > [aria-label*=' cancelled ']) { display: none !important; } | |
${ciCheckSelector({ ifHiddenStatus: "action required" })}:has(> [aria-label*=' action required ']) { display: none !important; } | |
${ciCheckSelector({ ifHiddenStatus: "queued" })}:has( > [aria-label*=' queued ']) { display: none !important; } | |
/* As of 2024-12-30, "expected" checks appear under label "pending". */ | |
${ciCheckSelector({ ifHiddenStatus: "expected" })}:has(> [aria-label*=' expected '], > [aria-label*=' pending ']) { display: none !important; } | |
${ciCheckSelector({ ifHiddenStatus: "in progress" })}:has( > [aria-label*=' in progress ']) { display: none !important; } | |
/* classic <=2024-12 */ | |
${ciCheckSelector({ ifHiddenStatus: "successful" })}:has( .merge-status-icon > .octicon-check.color-fg-success) { display: none !important; } | |
${ciCheckSelector({ ifHiddenStatus: "failing" })}:has( .merge-status-icon > .octicon-x.color-fg-danger) { display: none !important; } | |
${ciCheckSelector({ ifHiddenStatus: "skipped" })}:has( .merge-status-icon > .octicon-skip.neutral-check) { display: none !important; } | |
${ciCheckSelector({ ifHiddenStatus: "cancelled" })}${CANCELLED_OR_ACTION_REQUIRED_SELECTOR}:not(.${ID}-action-required) { display: none !important; } | |
${ciCheckSelector({ ifHiddenStatus: "action required" })}${CANCELLED_OR_ACTION_REQUIRED_SELECTOR}.${ID}-action-required { display: none !important; } | |
/* As of 2024-03-11, "queued" and "expected" differ only by "queued" having a link after the status icon [to /apps/github-actions for GH actions]. */ | |
${ciCheckSelector({ ifHiddenStatus: "queued" })}:has( .merge-status-icon > [class*=pending]):has(.merge-status-icon + :not(div)) { display: none !important; } | |
${ciCheckSelector({ ifHiddenStatus: "expected" })}:has(.merge-status-icon > [class*=pending]):has(.merge-status-icon + div) { display: none !important; } | |
/* As of 2024-03-08, "in progress" icons manifest as \`<div class="mx-auto">…</div>\` with no dedicated class. */ | |
${ciCheckSelector({ ifHiddenStatus: "in progress" })}:has(.merge-status-icon > :not(.color-fg-success, .color-fg-danger, .neutral-check, [class*=pending])) { display: none !important; } | |
</style>`); | |
// Ensure stickiness on resize/scroll. | |
{ | |
let tDebounce = -Infinity; | |
let waiting = false; | |
const ensureStickyNav = evt => { | |
const t = evt.timeStamp; | |
if (t < tDebounce) { | |
waiting ||= setTimeout(ensureStickyNav, 100, { timeStamp: t + 100 }); | |
return; | |
} | |
tDebounce = t + 100; | |
waiting = false; | |
const selGlobalNav = ":root:not(:has(.AppHeader .AppHeader-localBar)) button[data-action^='click:deferred-side-panel']"; | |
const selTabNav = ".js-pull-header-details ~ .tabnav"; | |
const selRepoNav = ".AppHeader .AppHeader-localBar"; | |
const selNeedsStickiness = ":not(.is-placeholder):not(.js-sticky)"; | |
const stickyNavs = document.querySelectorAll( | |
[selGlobalNav, selTabNav, selRepoNav].map(s => `${s}${selNeedsStickiness}`).join(", "), | |
); | |
for (const el of stickyNavs) { | |
el.classList.add(..."js-sticky js-sticky-offset-scroll top-0".split(" ")); | |
} | |
const navMenuItems = document.querySelectorAll( | |
`${selRepoNav}.is-stuck .UnderlineNav-actions [role=menu] > [hidden]`, | |
); | |
for (const el of navMenuItems) el.hidden = false; | |
// If anything was made sticky, dispatch a synthetic scroll in case it should already be stuck. | |
if (stickyNavs.length && !waiting) document.dispatchEvent(new Event("scroll")); | |
}; | |
for (const capture of [true, false]) { | |
window.addEventListener("resize", ensureStickyNav, { capture, passive: true }); | |
document.addEventListener("scroll", ensureStickyNav, { capture, passive: true }); | |
} | |
const loadedKit = Promise.withResolvers(); | |
loadedKit.promise.then(ensureStickyNav); | |
window.addEventListener("load", loadedKit.resolve, { once: true }); | |
if (document.readyState === "complete") loadedKit.resolve({ timeStamp: 0 }); | |
} | |
// Expand all comments/files/etc. on <{Alt,Option}+Shift+E>. | |
{ | |
const LABEL = `[${ID}] expand all`; | |
const { scrollingElement = document.documentElement } = document; | |
const isInDoc = el => | |
(el.compareDocumentPosition(document) & Node.DOCUMENT_POSITION_DISCONNECTED) === 0; | |
const isDifferent = (el, text) => !isInDoc(el) || el.textContent !== text; | |
const makeClassAttrSelector = (classAttrs, extra) => | |
[...classAttrs].map(c => `[class='${CSS.escape(c)}']`).concat(extra || []).join(", "); | |
let restoreScroll; | |
const clickInViewport = async el => { | |
const top = el.getBoundingClientRect()?.top ?? null; | |
const viewportHeight = | |
window.visualViewport?.height || | |
scrollingElement.clientHeight || | |
window.innerHeight; | |
let forceRepeat = top == null; | |
if (top < 0 || top > viewportHeight) { | |
forceRepeat = true; | |
if (!restoreScroll) { | |
history.pushState({}, ""); | |
restoreScroll = () => history.back(); | |
} | |
scrollingElement.scrollTop += top - 50; | |
} | |
el.click(); | |
await null; // to wait for effect propagation (e.g., DOM updates) | |
return forceRepeat; | |
} | |
const findAndClickToggles = async function* (T) { | |
const waiterClasses = new Set(); | |
const waiters = new Map(); | |
const shouldClick = el => | |
/\b(?:more|load)\b/i.test(el.textContent) && | |
getComputedStyle(el).visibility !== "hidden"; | |
while (true) { | |
let forceRepeat = false; | |
const selMaybes = makeClassAttrSelector(waiterClasses, BUTTON_SELECTOR); | |
for (let el of [...document.querySelectorAll(selMaybes)].filter(shouldClick)) { | |
waiters.set(el, el.textContent); | |
yield clickInViewport(el); | |
} | |
for (let el of document.querySelectorAll(CLOSED_TOGGLE_SELECTOR)) { | |
const repeat = await clickInViewport(el); | |
yield repeat; | |
forceRepeat ||= repeat; | |
} | |
for (let [el, text] of waiters.entries()) { | |
if (isDifferent(el, text) && el.className) waiterClasses.add(el.className); | |
} | |
const selWaiters = makeClassAttrSelector(waiterClasses) || ":not(*)"; | |
forceRepeat ||= !!document.querySelectorAll(selWaiters).length; | |
if (!forceRepeat) break; | |
await new Promise(resolve => setTimeout(resolve, T)); | |
} | |
}; | |
let active = false; | |
document.addEventListener("keydown", async ({key, altKey, ctrlKey, metaKey, shiftKey}) => { | |
// Normalize to overcome native composition such as "é". | |
const simpleKey = key.normalize("NFD").charAt(0).toUpperCase(); | |
if (!(altKey && shiftKey && !ctrlKey && !metaKey && simpleKey === "E")) return; | |
if (active) return; | |
active = true; | |
restoreScroll = null; | |
try { | |
console.time(LABEL); | |
const workPoolConfig = { mode: "allSettled", capacity: 8 }; | |
for await (const _ of makeWorkPool(findAndClickToggles(1000), workPoolConfig)); | |
restoreScroll?.(); | |
console.timeEnd(LABEL); | |
} finally { | |
active = false; | |
} | |
}); | |
} | |
function findMatches (nodes, selector) { | |
return new Set([...nodes].flatMap(el => [ | |
...(el.matches?.(selector) ? [el] : []), | |
...(el.querySelectorAll?.(selector) || []), | |
])); | |
} | |
function forEachMutation(target, options, callback) { | |
// As a convenience, invoke a childList callback with initial state. | |
if (options.childList) { | |
callback({ type: "childList", target, addedNodes: target.childNodes }, undefined, undefined); | |
} | |
const observer = new MutationObserver(mutations => { | |
mutations = [...mutations]; | |
let i = 0; | |
for (const mutation of mutations) { | |
// Invoke the callback, ignoring the rest of mutations if it returns truthy. | |
if (callback(mutation, i++, mutations)) return; | |
} | |
}); | |
observer.observe(target, options); | |
} | |
function makeWorkPool(source, config, processInput = x => x) { | |
// Validate arguments. | |
if (typeof config !== "object" || !config) config = { capacity: config, mode: undefined }; | |
const { capacity = 10, mode = "all" } = config; | |
if (!(capacity === Infinity || (Number.isInteger(capacity) && capacity > 0))) { | |
throw RangeError("capacity must be a positive integer"); | |
} | |
if (!(mode === "all" || mode === "allSettled")) throw RangeError("mode must be 'all' or 'allSettled'"); | |
// Translate source into an async iterator of [index, input] pairs. | |
const inputs = (async function* (i = 0) { | |
for await (const input of source) yield [i++, input]; | |
})(); | |
// Concurrently consume up to `capacity` inputs, pushing the result of | |
// processing each into a linked chain of promises before consuming | |
// more. | |
let { promise, resolve, reject } = withResolvers(); | |
let pending = 0; | |
const REJECTION = Symbol("rejection"); | |
const take = async () => { | |
while (pending < capacity) { | |
pending++; | |
// Read an input, propagating failure into a fulfillment distinguishable from both | |
// success ({ done: false, value }) and successful completion | |
// ({ done: true, value: undefined }). | |
const inputP = inputs.next().catch(err => ({ done: true, value: [err] })); | |
const { done, value: inputData } = await inputP; | |
if (done) { | |
if (inputData) { | |
// Fail the chain upon inability to read an input. | |
reject(inputData[0]); | |
pending = NaN; | |
} else if (--pending <= 0) { | |
// Finish the chain only when pending drops to zero (if it's still positive, | |
// we'll get another chance in each future `take`). | |
// This dummy signaling record conveys no result. | |
resolve({ nextP: undefined, i: -1, result: undefined }); | |
pending = NaN; | |
} | |
return; | |
} | |
// Asynchronously process the input, and when done, settle the then-current | |
// tail promise, rejecting it (and failing the chain) when processing fails in | |
// mode "all" but otherwise fulfilling it with a record that includes the source | |
// index to which it corresponds and a reference to a new [unsettled] successor | |
// (thereby extending the chain) and then trying to consume more input via `take()`. | |
const [i, input] = inputData; | |
const pushResult = (status, value, reason) => { | |
pending--; | |
const result = | |
// Analogous to `Promise.allSettled`, mode "allSettled" produces | |
// { status, value, reason } records. | |
mode === "allSettled" ? { status, value, reason } : | |
// Analogous to `Promise.all`, mode "all" produces unwrapped fulfillment | |
// values (or fails with the first rejection). | |
status === "fulfilled" ? value : REJECTION; | |
if (result === REJECTION) return void reject(reason); | |
const resolveCurrent = resolve; | |
({ promise, resolve, reject } = withResolvers()); | |
resolveCurrent({ nextP: promise, i, result }); | |
take(); | |
}; | |
(async x => processInput(x))(input).then( | |
result => pushResult("fulfilled", result), | |
err => pushResult("rejected", undefined, err), | |
); | |
} | |
}; | |
take(); | |
// Provide results as an async iterator of [index, result] pairs in result fulfillment | |
// order. | |
// Source order can be recovered with consumer code like | |
// const results = []; | |
// for (const [i, result] of workPool(...)) results[i] = result; | |
// or something more sophisticated to eagerly detect complete subsequences immediately above | |
// a previous high-water mark. | |
const results = (async function* (nextP) { | |
while (true) { | |
const { nextP: successor, i, result } = await nextP; | |
nextP = successor; | |
if (!successor) break; | |
yield [i, result]; | |
} | |
})(promise); | |
for (const name of ["return", "throw"]) { | |
const builtin = results[name]; | |
const forwarder = defineName(name, (...args) => { | |
(async () => inputs[name](...args))().catch(sink); | |
return Reflect.apply(builtin, results, args); | |
}); | |
Object.defineProperty(results, name, { ...dataProperty, value: forwarder }); | |
} | |
return results; | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment