Skip to content

Data: Await resolver implementing isFulfilled#70806

Merged
jsnajdr merged 8 commits intoWordPress:trunkfrom
johnhooks:fix/issue-70805
Dec 18, 2025
Merged

Data: Await resolver implementing isFulfilled#70806
jsnajdr merged 8 commits intoWordPress:trunkfrom
johnhooks:fix/issue-70805

Conversation

@johnhooks
Copy link
Copy Markdown
Contributor

@johnhooks johnhooks commented Jul 20, 2025

What?

Add dispatch of finishResolution when a resolver's isFulfilled callback is true in the @wordpress/data package.

Fixes #70805.

Why?

A deadlock issue was discovered in the @wordpress/data package when using resolveSelect on a selector/resolver pair implementing isFulfilled.

This failed test run from before adding the fix demonstrates the issue.

How?

The concept is to dispatch finishResolution to the metadata store when resolver.isFulfilled is true. This adds the correct state to the store, allowing resolution of the promises in mapResolveSelector and mapSuspendSelector.

@johnhooks johnhooks requested a review from nerrad as a code owner July 20, 2025 17:10
@github-actions
Copy link
Copy Markdown

github-actions bot commented Jul 20, 2025

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: johnhooks <bitmachina@git.wordpress.org>
Co-authored-by: jsnajdr <jsnajdr@git.wordpress.org>
Co-authored-by: Mamaduka <mamaduka@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@t-hamano t-hamano added [Type] Code Quality Issues or PRs that relate to code quality [Package] Data /packages/data labels Jul 21, 2025
@Mamaduka
Copy link
Copy Markdown
Member

@johnhooks, do you mind rebasing the branch on top of the latest trunk?

@jsnajdr, IIRC you worked recently on improving this area of the code.

Side note: isFulfilled already has a test at the registry level, maybe we should colocate the new test there:

it( 'should use isFulfilled definition before calling the side effect', () => {

@Mamaduka Mamaduka requested a review from jsnajdr August 25, 2025 05:23
@Mamaduka Mamaduka changed the title Await resolver implementing isFulfilled Core Data: Await resolver implementing isFulfilled Aug 25, 2025
@Mamaduka Mamaduka changed the title Core Data: Await resolver implementing isFulfilled Data: Await resolver implementing isFulfilled Aug 25, 2025
DHANUSHRAJA22 added a commit to DHANUSHRAJA22/gutenberg that referenced this pull request Aug 27, 2025
…ced tests for isFulfilled and async promise hygieneAdd advanced tests for isFulfilled and async promise hygieneUpdate registry.js

Add comprehensive test suite for isFulfilled edge cases and async promise hygiene to registry.js.

Includes tests for:
- Non-existent selector handling
- Async promise cleanup and resolution
- Concurrent async selectors with parameters
- Promise rejection handling
- Memory leak prevention for promise references

References: PR WordPress#70806, addresses bug WordPress#70805

These tests ensure proper cleanup of async promise chains and prevent memory leaks in selector resolution.
@johnhooks
Copy link
Copy Markdown
Contributor Author

@Mamaduka I've updated the branch, colocated the tests, and added a few more after reviewing the other tests in packages/data/src/test/registry.js.

Comment thread packages/data/src/redux-store/index.js Outdated
store.dispatch(
metadataActions.finishResolution( selectorName, args )
);
}, 0 );
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be neatly integrated into the main setTimeout callback several lines below. Just don't run the fulfill function when isFulfilled returns true:

const isFulfilled =
  typeof resolver.isFulfilled === 'function' &&
  resolver.isFulfilled( state, ...args );
if ( ! isFulfilled ) {
  const action = resolver.fulfill( ...args );
  if ( action ) {
    await store.dispatch( action );
  }
}

It's the same thing, without code duplication, with just one difference: both startResolution and finishResolution are called, right after each other without any waiting. But that's fine, it's even welcome, because outside observers will see the same sequence of updates, no matter how the selector/resolver is internally implemented.

Another way to fix this is in the mapResolveSelector and mapSuspendSelector functions. These functions could perform the isFulfilled check and return a result right away. In that case, the startResolution/finishResolution actions would never be called when isFulfilled is true. But it's probably a worse solution than the first one, again for consistency reasons: sometimes, for the same selector, start/finishResolution would be called, sometimes not.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with calling startResolution/finishResolution and being consistent. I'll wrap the call to isFulfilled into the main setTimeout. Thank you for the review.

Comment thread packages/data/src/test/registry.js Outdated

const promise = registry.resolveSelect( 'demo' ).getData();
jest.runAllTimers();
await expect( promise ).rejects.toThrow( 'isFulfilled error' );
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jsnajdr this is odd. The error is caught and the state of the resolver transitions to failed, but Jest fails the test without this line. I'm still researching the issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've walked through with the debugger, wow there is a lot going on for a single resolution. Though after running jest.runAllTimers, promise has a rejected status and Error: isFulfilled error, even though it was caught in the try/catch and included in the FAIL_RESOLUTION action.

Copy link
Copy Markdown
Contributor Author

@johnhooks johnhooks Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OH I got it, mapResolveSelector rejects it. So the test is correct, if this is the desired behavior.

Should the resolver resolution fail if isFulfilled throws an error?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, resolveSelect detects the error and returns it.

If isFulfilled throws, that is certainly suprising and unexpected and the error should be surfaced. So yes, the resolution should fail.

Comment thread packages/data/src/redux-store/index.js Outdated
return;
}

const state = store.getState();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be inlined into the isFulfilled call, we don't use the state anywhere else do we?

resolver.isFulfilled( store.getState(), ...args )

Comment thread packages/data/src/test/registry.js Outdated
} );

const promise1 = registry.resolveSelect( 'demo' ).getPage( 1 );
jest.runAllTimers();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the resolveSelect tests, we would be better off if we used jest.useRealTimers(). Then you don't need to run timers manually.

It will happen automatically if you move these tests to redux-store/test/index.js, where all other resolveSelect tests are. This registry.js suite has jest.useFakeTimers() at the start.

Comment thread packages/data/src/test/registry.js Outdated

const promise = registry.resolveSelect( 'demo' ).getData();
jest.runAllTimers();
await expect( promise ).rejects.toThrow( 'isFulfilled error' );
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, resolveSelect detects the error and returns it.

If isFulfilled throws, that is certainly suprising and unexpected and the error should be surfaced. So yes, the resolution should fail.

Add a test to the `@wordpress/data` package to demostrate the deadlock of
awaiting a resolver implementing `isFulfilled`.

See WordPress#70805.
Reduces duplication by handling isFulfilled in the same code path as fulfill.
Both paths now mark the resolver as running first, and isFulfilled is now
wrapped in try/catch for error handling.
Copy link
Copy Markdown
Member

@jsnajdr jsnajdr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice, thanks for working on this and addressing all feedback 👍

@jsnajdr jsnajdr merged commit a04037f into WordPress:trunk Dec 18, 2025
36 checks passed
@github-actions github-actions bot added this to the Gutenberg 22.4 milestone Dec 18, 2025
@johnhooks
Copy link
Copy Markdown
Contributor Author

@jsnajdr absolutely, I appreciate you taking the time to help me through it.

Also shout out to @TimothyBJacobs for suggesting I create the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Data /packages/data [Type] Code Quality Issues or PRs that relate to code quality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@wordpress/data isFulfilled Deadlock Issue

4 participants