Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Data: Implement atomic stores #26866

Merged
merged 58 commits into from
Nov 20, 2020
Merged

Data: Implement atomic stores #26866

merged 58 commits into from
Nov 20, 2020

Conversation

youknowriad
Copy link
Contributor

@youknowriad youknowriad commented Nov 10, 2020

This PR is an attempt to solve some of the current flaws of the data module:

  • Creating async stores with resolvers + controls + actions + selectors + reducer is very hard. Luckily we don't have much stores like that (just one), but it's definitely something that I hope we can improve.
  • Improve the performance at scale: Redux (whether you subscribe to just one store or multiple) doesn't scale very well. If you keep rendering a growing list of components on a page (say a block list), these components will add computation time regardless of whether they rerender or not. In a block list for instance, no matter if the components of that blocklist subscribe to just a single store say 'core/block-editor', for each change of any block, they'll still have to call the selectors that power these components and that's way too much. We did introduce the async mode which "solves" this issue or more exactly "hides" this issue but I believe we can do better there.

  • the performance factor is an important one, I'd like for our applications to scale properly when we render more and more components. This can be solved by making sure components only "subscribe" to the data they're interested in. Tools like mobx, recoil and the like solve this by relying on observables basically (atoms are just hidden observables).

  • Recoil and jotai also have very interesting ways to define async behavior and data which can be used as a replacement for selectors + resolvers + controls + async actions. Reducing the number of concepts would make it easier to create these kinds of stores. It's a very different way of defining stores though and a mental model that requires some getting used to in the beginning but probably simpler than what we have anyway.

This is what this PR is about. It introduces a new @wordpress/stan package that serves as a low-level package to build atomic stores. To learn more, read the README of the @wordpress/stan package in this PR.

I've refactored the keyboard-shortcuts package store as an example of how to leverage atomic stores with the data package.

Notice that this PR also makes useSelect only subscribes to the stores that are used in mapSelectToProps. This and other improvements related to atomic stores are showing a 10ms gain in terms of typing performance in this PR. This is equivalent to approximatively 25 to 30% of the time it takes to type a character in long posts.

TODO

  • Make @wordpress/stan a bundled package like icons or interface

An alternative to: #26724 and #26692.
Related: #26733
closes #26849

@github-actions
Copy link

github-actions bot commented Nov 10, 2020

Size Change: +985 B (0%)

Total Size: 1.19 MB

Filename Size Change
build/annotations/index.js 3.8 kB +1 B
build/autop/index.js 2.83 kB -1 B
build/blob/index.js 665 B +1 B
build/block-directory/index.js 8.72 kB +2 B (0%)
build/block-editor/index.js 133 kB -3 B (0%)
build/block-editor/style-rtl.css 11.3 kB -2 B (0%)
build/block-editor/style.css 11.3 kB -3 B (0%)
build/block-library/editor-rtl.css 8.95 kB -77 B (0%)
build/block-library/editor.css 8.95 kB -77 B (0%)
build/block-library/index.js 147 kB +4 B (0%)
build/blocks/index.js 48 kB -3 B (0%)
build/components/index.js 172 kB +152 B (0%)
build/compose/index.js 9.93 kB -18 B (0%)
build/data/index.js 9.58 kB +777 B (8%) 🔍
build/date/index.js 11.2 kB -2 B (0%)
build/deprecated/index.js 769 B +1 B
build/dom/index.js 4.92 kB +1 B
build/edit-navigation/index.js 11.2 kB +7 B (0%)
build/edit-post/index.js 306 kB +1 B
build/edit-site/index.js 23.2 kB -85 B (0%)
build/edit-site/style-rtl.css 3.85 kB -1 B
build/edit-site/style.css 3.85 kB -2 B (0%)
build/edit-widgets/index.js 26.4 kB +8 B (0%)
build/editor/index.js 42.7 kB +5 B (0%)
build/element/index.js 4.62 kB +2 B (0%)
build/format-library/index.js 6.86 kB -2 B (0%)
build/i18n/index.js 3.57 kB +1 B
build/keyboard-shortcuts/index.js 2.83 kB +287 B (10%) ⚠️
build/keycodes/index.js 1.94 kB +1 B
build/list-reusable-blocks/index.js 3.1 kB +1 B
build/media-utils/index.js 5.32 kB +1 B
build/notices/index.js 1.82 kB +1 B
build/plugins/index.js 2.56 kB +4 B (0%)
build/primitives/index.js 1.43 kB -1 B
build/reusable-blocks/index.js 3.07 kB +1 B
build/rich-text/index.js 13.4 kB +4 B (0%)
build/server-side-render/index.js 2.77 kB +2 B (0%)
build/shortcode/index.js 1.69 kB -1 B
build/warning/index.js 1.14 kB -2 B (0%)
ℹ️ View Unchanged
Filename Size Change
build/a11y/index.js 1.14 kB 0 B
build/api-fetch/index.js 3.42 kB 0 B
build/block-directory/style-rtl.css 943 B 0 B
build/block-directory/style.css 942 B 0 B
build/block-library/style-rtl.css 8.1 kB 0 B
build/block-library/style.css 8.1 kB 0 B
build/block-library/theme-rtl.css 792 B 0 B
build/block-library/theme.css 793 B 0 B
build/block-serialization-default-parser/index.js 1.87 kB 0 B
build/block-serialization-spec-parser/index.js 3.06 kB 0 B
build/components/style-rtl.css 15.3 kB 0 B
build/components/style.css 15.3 kB 0 B
build/core-data/index.js 14.8 kB 0 B
build/data-controls/index.js 827 B 0 B
build/dom-ready/index.js 571 B 0 B
build/edit-navigation/style-rtl.css 881 B 0 B
build/edit-navigation/style.css 885 B 0 B
build/edit-post/style-rtl.css 6.45 kB 0 B
build/edit-post/style.css 6.44 kB 0 B
build/edit-widgets/style-rtl.css 3.13 kB 0 B
build/edit-widgets/style.css 3.13 kB 0 B
build/editor/editor-styles-rtl.css 476 B 0 B
build/editor/editor-styles.css 478 B 0 B
build/editor/style-rtl.css 3.85 kB 0 B
build/editor/style.css 3.85 kB 0 B
build/escape-html/index.js 735 B 0 B
build/format-library/style-rtl.css 547 B 0 B
build/format-library/style.css 548 B 0 B
build/hooks/index.js 2.16 kB 0 B
build/html-entities/index.js 623 B 0 B
build/is-shallow-equal/index.js 698 B 0 B
build/list-reusable-blocks/style-rtl.css 476 B 0 B
build/list-reusable-blocks/style.css 476 B 0 B
build/nux/index.js 3.42 kB 0 B
build/nux/style-rtl.css 671 B 0 B
build/nux/style.css 668 B 0 B
build/priority-queue/index.js 790 B 0 B
build/redux-routine/index.js 2.84 kB 0 B
build/token-list/index.js 1.27 kB 0 B
build/url/index.js 4.05 kB 0 B
build/viewport/index.js 1.86 kB 0 B
build/wordcount/index.js 1.22 kB 0 B

compressed-size-action

@gziolo gziolo added [Package] Data /packages/data [Status] In Progress Tracking issues with work in progress [Type] Technical Prototype Offers a technical exploration into an idea as an example of what's possible [Type] Feature New feature to highlight in changelogs. labels Nov 13, 2020
@gziolo gziolo changed the title Implement atomic stores Data: Implement atomic stores Nov 13, 2020
@jsnajdr
Copy link
Member

jsnajdr commented Nov 13, 2020

@gziolo requested your review on: #26866 Implement atomic stores.

Some description of what's going on would be helpful 🙂

@gziolo
Copy link
Member

gziolo commented Nov 13, 2020

@gziolo requested your review on: #26866 Implement atomic stores.

Some description of what's going on would be helpful 🙂

It's a work in progress, you can ignore my ping for now. Documentation for the new package can be found here: https://github.com/WordPress/gutenberg/blob/98f1ba60a36d666c9c16d4771c28ca3ea7247730/packages/stan/README.md


```js
// No one has subscribed to the sum instance yet, its value is "null"
console.log( sumInstance.get() ); // prints null.
Copy link
Member

Choose a reason for hiding this comment

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

This looks wrong. If I have a React component that wants to render the sumInstance.get() value, then even the initial render should print 3, not null.

The subscription will be created only after the initial render, in a useEffect or componentDidMount callback. It can't be created anywhere else, because it's a side effect.

This is true no matter how exactly the React bindings are implemented (@wordpress/data adapter or anything else).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This looks wrong. If I have a React component that wants to render the sumInstance.get() value, then even the initial render should print 3, not null

This is already handled properly in useSelect, we can compute the initial value before the subscription if we want. I may add a more direct way to do that with atoms: make the "resolve" a public API of the atom instance but it's very important that the atoms stay lazy. If I don't use a selector (which becomes an atom), I don't want it to keep updating its value and consuming CPU.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a good remark though, and I need to think more about how to solve this in a better way: having the laziness while allowing sync resolution for react hooks.

Copy link
Member

Choose a reason for hiding this comment

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

This is already handled properly in useSelect, we can compute the initial value before the subscription if we want.

I tested this with the following example:

const count = createAtom( 1 );
const double = createDerivedAtom( ( get ) => get( count ) * 2 );

const getDouble = ( get ) => () => get( double );

registerAtomicStore( 'standa', {
	atoms: { double },
	selectors: { getDouble },
} );

function UI() {
	const dbl = useSelect( ( select ) => select( 'standa' ).getDouble() );
	return React.createElement( 'div', null, dbl );
}

const rendered = ReactDOMServer.renderToString(
	React.createElement( UI, null )
);

console.log( rendered );

In the end, the double atom is really resolved even at the time of the initial render and the example correctly renders <div>2</div>.

But the reason why it is resolved is not great: the atoms in the atomic store are instantiated in the data package's own atom registry, and that registry has an onAdd callback that immediately subscribes to the new atom's changes and relays them to the globalListener. I.e., every atom has at least one subscription during its entire lifetime. That ensures that the atom is resolved in the initial render, but at the same time completely defeats the lazy optimizations.

After removing the onAdd and onDelete callbacks from the createAtomRegistry call (with SSR, we don't need any subscriptions anyway), the resolution of double is not done. The selector returns null on initial render.

Copy link
Member

Choose a reason for hiding this comment

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

This is a good remark though, and I need to think more about how to solve this in a better way: having the laziness while allowing sync resolution for react hooks.

The selector value should be computed when calling .get(). Calling the getter is a strong-enough signal that someone is interested in the value and that it's worth computing.

Copy link
Contributor Author

@youknowriad youknowriad Nov 14, 2020

Choose a reason for hiding this comment

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

Tests are back to normal and we're gaining 10ms when typing in this PR (this is huge)

Copy link
Member

Choose a reason for hiding this comment

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

resolving might trigger effects (async atoms) in which case, we shouldn't resolve in render phase

It's interesting that both Recoil and Jotai run the resolvers, both sync and async ones, during get. I.e., during the render phase, firing the side effects right away.

That suggests that running the resolver or any side effect during render is not bad per-se. We only need to avoid doing certain things that would make it bad:

  • the resolver shouldn't do any sync state updates, like setting the isResolving flag and triggering a setState by that
  • the resolver shouldn't allocate any resources (e.g., make subscriptions) because they won't be cleaned up in case of aborted render
  • the resolver should be resilient against running twice

Looking at Apollo Client, I see they execute the query in two phases: queryData.execute() during render, and then queryData.afterExecute() in a useEffect hook. Only the latter can allocate something and be sure that it will be cleaned up.

To implement a good React binding, maybe exposing only the simple get primitive and nothing else is not enough? The current get does two tasks at once: "synchronously reading the value" and "triggering the resolution". There can easily be two lower-level primitives that do that separately.

Copy link
Contributor Author

@youknowriad youknowriad Nov 16, 2020

Choose a reason for hiding this comment

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

To implement a good React binding, maybe exposing only the simple get primitive and nothing else is not enough? The current get does two tasks at once: "synchronously reading the value" and "triggering the resolution". There can easily be two lower-level primitives that do that separately.

Yes, I agree, I think there's value in "execute" and "afterExecute" to handle things like resolution state... but I believe we can start with the current API and see later. The important thing is to ensure @wordpress/stan remains a bundled package (like icons and interface) where we're able to make breaking changes. (I'll update the building to do that)

Copy link
Member

Choose a reason for hiding this comment

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

but I believe we can start with the current API and see later.

My perspective is that I'd like to implement something like React Query on top of the stan store. That means maintaining mainly collections rather than individual atomic values, performing CRUD operations on them, acknowledging that these operations can fail and having first-class support for error handling, having control over the cache ("invalidating resolution" in the language of @wordpress/data)...

Recoil or Jotai provide some basic support for that, but I feel I'd quickly hit a wall when trying to implement some of the more complex requirements.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes, that's definitely where we'll see the most complex use-cases :) (basically, how to write core-data with stan).
I believe as is right now, it will provide a lot of good for performance for things like block-editor store but handling the complexities of something like core-data is still something that need to be tested.

@youknowriad
Copy link
Contributor Author

youknowriad commented Nov 15, 2020

The API right now is a bit inconsistent.

  • The registry has the following API: registry.read( atom ) and registry.write( atom, value )
  • the derived atoms have the following one: createDerivedAtom( ( { get } ) => get( atom ), { get, set } => set( get(atom), value ) )

We need to consolidate on a single naming: "read/write" or "get/set". not sure what's better?

@jsnajdr
Copy link
Member

jsnajdr commented Nov 16, 2020

The registry has the following API: registry.read( atom ) and registry.write( atom, value )

Why did you choose to hide the atom instances completely, allowing access only through the registry as a proxy?

An alternative is splitting the registry interface into two:

registry.get( atom ).read();
registry.get( atom ).write( value );

@youknowriad
Copy link
Contributor Author

Why did you choose to hide the atom instances completely, allowing access only through the registry as a proxy?

Because the concept of atom instances is a bit confusing IMO and users shouldn't have access to it. (implementation detail). I actually already have the "get" function you propose but I'm marking it unstable.

Also, now I'm considering renaming "registry" to just "store" because it's what holds the actual state. I think it's a bit better.

@jsnajdr
Copy link
Member

jsnajdr commented Nov 16, 2020

Because the concept of atom instances is a bit confusing IMO and users shouldn't have access to it.

The concept still needs to be a part of the programmer's mental model, doesn't it? They need to know that createAtom only "declares" an atom, something like a class, and it's only really "created" in a store. If I don't have access to the concept of atom instance, I can't even describe in English what the library does. That suggests it's not an implementation detail, but a part of the public description.

@youknowriad
Copy link
Contributor Author

Here's how I see the mental model evolving.

  • The store holds the state of all atoms.
  • I want to retrieve the value of an atom, I call store.read( atom )
  • I want to update the value of an atom, I call store.write( atom, value )
  • I want to be notified about the change of the value of an atom, I call store.subscribe( atom, listener )

Note that I didn't use "atom state" here at all, I just used "value of an atom" or "state of an atom", I believe it's simpler to understand that way.

Basically, for someone familiar with redux stores, the difference would be that the store holds states per atoms and not just a global state. In this mental modal, the atom is an "identifier".

@youknowriad
Copy link
Contributor Author

Regardless though, we still need to figure out if use read/write or get/set for the APIs :P

let mapOutput;
const [ , dispatch ] = useState( {} );
const rerender = () => dispatch( {} );
const isMountedAndNotUnsubscribing = useRef( true );
Copy link
Member

Choose a reason for hiding this comment

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

There is a package called use-subcription, directly from the React team, that provides a useSubscription hook for subscribing to things from React components in async mode.

It solves all the gotchas and race conditions that need to be taken care of. Using it in useSelect (both in this new and the legacy version) would simplify the hook tremendously, outsourcing most of the complexity. All that remains is calling mapSelect and memoizing it properly, and handling the "async mode" where the state updates are fired through the priority queue rather than done directly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh good reminder, I actually did try that package once of useSelect but at that time, it was causing a big loss in performance, maybe with the atoms implementation, it will be better.

Co-authored-by: Greg Ziółkowski <[email protected]>
@gziolo gziolo removed the [Status] In Progress Tracking issues with work in progress label Nov 20, 2020
youknowriad and others added 3 commits November 20, 2020 08:07
Co-authored-by: Greg Ziółkowski <[email protected]>
Co-authored-by: Greg Ziółkowski <[email protected]>
Copy link
Member

@gziolo gziolo left a comment

Choose a reason for hiding this comment

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

In my opinion, this proposal is already in a mergeable state. @youknowriad, you did amazing job bringing the latest development in state management to WordPress. The performance implications are the best justification that new concepts introduced are worth exploring further:

capture_d___e__cran_2020-11-20_a___8 37 54_am

I hope to see more improvements as we try to apply the new type of store to the most popular stores.

As noted in my comments, it's a bit unfortunate that we don't have the store (keyboard shortcuts) covered with unit tests which would give 100% confidence that the transition from Redux to @wordpress/stan is bulletproof. At the same time, this code is well tested indirectly through a huge number of e2e tests so it makes me feel more comfortable.

I'd love to hear from @jsnajdr and @adamziel, how they feel about the internal implementation of @wordpress/stan (that part is more approachable to me) and in particular, about optimizations applied to @wordpress/data. I'm not as familiar with Redux, recoil, and others, but seeing the number of unit tests that existed before and were added makes me confident that is good enough to proceed.

packages/stan/tsconfig.json Outdated Show resolved Hide resolved
packages/data/tsconfig.json Show resolved Hide resolved
Co-authored-by: Jon Surrell <[email protected]>
@youknowriad
Copy link
Contributor Author

@sirreal I actually already renamed the file and fixed the types.

@sirreal
Copy link
Member

sirreal commented Nov 20, 2020

@sirreal I actually already renamed the file and fixed the types.

data and stan should be added here in order to be compiled as part of the main typechecking/building flows:

gutenberg/tsconfig.json

Lines 2 to 29 in 8df048c

"references": [
{ "path": "bin" },
{ "path": "packages/a11y" },
{ "path": "packages/autop" },
{ "path": "packages/blob" },
{ "path": "packages/block-editor" },
{ "path": "packages/components" },
{ "path": "packages/deprecated" },
{ "path": "packages/element" },
{ "path": "packages/dependency-extraction-webpack-plugin" },
{ "path": "packages/dom-ready" },
{ "path": "packages/escape-html" },
{ "path": "packages/eslint-plugin" },
{ "path": "packages/html-entities" },
{ "path": "packages/hooks" },
{ "path": "packages/i18n" },
{ "path": "packages/icons" },
{ "path": "packages/is-shallow-equal" },
{ "path": "packages/lazy-import" },
{ "path": "packages/prettier-config" },
{ "path": "packages/primitives" },
{ "path": "packages/priority-queue" },
{ "path": "packages/project-management-automation" },
{ "path": "packages/token-list" },
{ "path": "packages/url" },
{ "path": "packages/warning" },
{ "path": "packages/wordcount" }
],

Otherwise it needs to be built on its own, i.e. node_modules/.bin/tsc --build packages/data. I see some errors when building data, would you like me to push fixes for them or provide suggestions?

@youknowriad
Copy link
Contributor Author

@sirreal go push fixes :)

} );
} catch ( error ) {
if ( unresolved.length === 0 ) {
throw error;
Copy link
Contributor

@adamziel adamziel Nov 20, 2020

Choose a reason for hiding this comment

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

If I read it correctly, it will silence any resolution exceptions.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think the entire try/catch could be avoided if get: ( atom ) => { was async and defered the responsibility of waiting for dependencies to resolver function. I'll propose a change later today.

Copy link
Contributor

Choose a reason for hiding this comment

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

(or there's always this functional API idea where get: (atom) would return an observable and reduce this file to ~20 LOC)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

how so? It throws the error once again?

Copy link
Contributor

@adamziel adamziel Nov 20, 2020

Choose a reason for hiding this comment

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

Only if unresolved.length === 0, I imagine there could be a case where dependent atom's resolve() throws an error in this line: return atomState.get(); and it gets supressed since unresolved.length is no longer 0. Although the solution could be maybe as simple as moving .get() above push. I didn't actually test it yet so I apologize if that's inaccurate - I'll come up with a test case later today to verify that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This case is very weird :P While I agree we should have better error handling, I'm not sure we should be throwing errors because in the example above, you have a failing promise and not an exception that is thrown

Copy link
Contributor

@adamziel adamziel Nov 20, 2020

Choose a reason for hiding this comment

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

@youknowriad ah that's an unfortunate syntax, just mentally replace throwing error with Promise.reject(). That's my point exactly, registry.get and registry.subscribe() both rely on a promise but both return a value - there's no way to catch the rejection (also it doesn't bubble up).

Copy link
Member

Choose a reason for hiding this comment

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

The sum resolver with the + operation is special because + doesn't mind if one argument is null:

42 + null === 42;
null + null === 0;

The sum resolver will never throw. It will return questionable sums instead 🙂

It's better to test this with a resolver that doesn't tolerate null values. Like when the upstream values are arrays.

Copy link
Contributor

@adamziel adamziel Nov 20, 2020

Choose a reason for hiding this comment

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

@jsnajdr IMHO that should be two separate tests - sum resolver is also a valid use case and we must account for tolerance of null values.

Copy link
Contributor

Choose a reason for hiding this comment

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

I couldn't come up with anything that wouldn't async/await all the things, here's my attempt: #27155

@sirreal
Copy link
Member

sirreal commented Nov 20, 2020

Noting that I'm finished with the types for now, they seem to be building correctly. I'll save any further changes for follow-up PRs.

@youknowriad
Copy link
Contributor Author

Let's try that 🎉

@youknowriad youknowriad merged commit 4cfe6ba into master Nov 20, 2020
@youknowriad youknowriad deleted the add/atomic-store branch November 20, 2020 12:25
@github-actions github-actions bot added this to the Gutenberg 9.5 milestone Nov 20, 2020
@gziolo
Copy link
Member

gziolo commented Nov 20, 2020

Important note: @wordpress/stan won't be published to npm until WordPress 5.6 is released which is planned for December 8th (https://make.wordpress.org/core/2020/08/13/wordpress-5-6-release-planning/). It will most likely happen a few days later after that date when the forthcoming Gutenberg plugin is released.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Package] Data /packages/data [Type] Feature New feature to highlight in changelogs. [Type] Technical Prototype Offers a technical exploration into an idea as an example of what's possible
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Replace hand-picked redux concepts with react hooks...?
9 participants