Skip to content

feat(execution): expose asyncWorkFinished execution hook#4658

Merged
yaacovCR merged 1 commit intographql:17.x.xfrom
yaacovCR:expose-hook
Apr 12, 2026
Merged

feat(execution): expose asyncWorkFinished execution hook#4658
yaacovCR merged 1 commit intographql:17.x.xfrom
yaacovCR:expose-hook

Conversation

@yaacovCR
Copy link
Copy Markdown
Contributor

@yaacovCR yaacovCR commented Apr 5, 2026

Cancelled async work may still be running even after the result returns. This hook allows interested execution harnesses to track when this async work completes.

Depends on:

The hook is available through hooks.asyncWorkFinished on execution args.

An execute wrapper that waits for all tracked async work can be written as:

function executeAndWaitForAsyncWorkFinished(
  args: ExecutionArgs,
): PromiseOrValue<ExecutionResult> {
  let hookHasFired = false;
  const { promise: hookFinished, resolve: resolveHookFinished } =
    Promise.withResolvers<void>();

  const userAsyncWorkFinishedHook = args.hooks?.asyncWorkFinished;
  const result = execute({
    ...args,
    hooks: {
      ...args.hooks,
      asyncWorkFinished(info) {
        try {
          userAsyncWorkFinishedHook?.(info);
        } finally {
          hookHasFired = true;
          resolveHookFinished();
        }
      },
    },
  });

  return hookHasFired ? result : hookFinished.then(() => result);
}

To ensure resolver-side async work is also tracked and awaited, use info.getAsyncHelpers().track(...) or info.getAsyncHelpers().promiseAll(...).

promiseAll(...) is optimized for the common case where the returned promise is awaited (or returned) from the resolver. It only starts tracking on rejection, and does so as a side-effect. Un-awaited async side effects are an anti-pattern:

resolve(_source, _args, _context, info) {
  const { promiseAll } = info.getAsyncHelpers();
  promiseAll([Promise.reject(new Error('bad')), pendingCleanup]).catch(
    () => undefined,
  );
  return 'ok';
}

In that anti-pattern, tracking starts only after rejection (on a later microtask), so this work is not guaranteed to delay hooks.asyncWorkFinished.

Use track(...) for un-awaited async side effects:

resolve(_source, _args, _context, info) {
  const { track } = info.getAsyncHelpers();
  track([doCleanupAsync().catch(() => undefined)]);
  return 'ok';
}

@yaacovCR yaacovCR added the PR: feature 🚀 requires increase of "minor" version number label Apr 5, 2026
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 5, 2026

@yaacovCR is attempting to deploy a commit to the The GraphQL Foundation Team on Vercel.

A member of the Team first needs to authorize it.

@yaacovCR yaacovCR force-pushed the expose-hook branch 3 times, most recently from fcf5a8a to 419725a Compare April 10, 2026 08:21
@yaacovCR yaacovCR changed the title feat(execution): expose hook to track when completion of all async work feat(execution): expose hook to track completion of all async work Apr 10, 2026
@yaacovCR yaacovCR force-pushed the expose-hook branch 2 times, most recently from 3f32497 to 4dc9171 Compare April 10, 2026 14:37
yaacovCR added a commit that referenced this pull request Apr 12, 2026
canonize async work tracking so promises started during execution are still observed after early errors or abort paths

route defaultTypeResolver promise handling through tracked async helpers

motivation:

- #4658
@yaacovCR yaacovCR force-pushed the expose-hook branch 4 times, most recently from 634c9ea to 0f40f15 Compare April 12, 2026 07:58
@yaacovCR yaacovCR changed the title feat(execution): expose hook to track completion of all async work feat(execution): expose asyncWorkFinished execution hook Apr 12, 2026
Expose an execution hook that runs once tracked async work has finished,
including work that can outlive a returned execution result.

The hook is available through `hooks.asyncWorkFinished` on execution args.

An `execute` wrapper that waits for all tracked async work can be written as:

```ts
function executeAndWaitForAsyncWorkFinished(
  args: ExecutionArgs,
): PromiseOrValue<ExecutionResult> {
  let hookHasFired = false;
  const { promise: hookFinished, resolve: resolveHookFinished } =
    Promise.withResolvers<void>();

  const userAsyncWorkFinishedHook = args.hooks?.asyncWorkFinished;
  const result = execute({
    ...args,
    hooks: {
      ...args.hooks,
      asyncWorkFinished(info) {
        try {
          userAsyncWorkFinishedHook?.(info);
        } finally {
          hookHasFired = true;
          resolveHookFinished();
        }
      },
    },
  });

  return hookHasFired ? result : hookFinished.then(() => result);
}
```

To ensure resolver-side async work is also tracked and awaited, use
`info.getAsyncHelpers().track(...)` or `info.getAsyncHelpers().promiseAll(...)`.

`promiseAll(...)` is optimized for the common case where the returned promise is
awaited (or returned) from the resolver. It only starts tracking on rejection,
and does so as a side-effect. Un-awaited async side effects are an anti-pattern:

```ts
resolve(_source, _args, _context, info) {
  const { promiseAll } = info.getAsyncHelpers();
  promiseAll([Promise.reject(new Error('bad')), pendingCleanup]).catch(
    () => undefined,
  );
  return 'ok';
}
```

In that anti-pattern, tracking starts only after rejection (on a later
microtask), so this work is not guaranteed to delay
`hooks.asyncWorkFinished`.

Use `track(...)` for un-awaited async side effects:

```ts
resolve(_source, _args, _context, info) {
  const { track } = info.getAsyncHelpers();
  track([doCleanupAsync().catch(() => undefined)]);
  return 'ok';
}
```
@yaacovCR yaacovCR merged commit 6e87945 into graphql:17.x.x Apr 12, 2026
21 of 22 checks passed
@yaacovCR yaacovCR deleted the expose-hook branch April 12, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

PR: feature 🚀 requires increase of "minor" version number

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant