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

Add delete method to observable map #24

Closed

Conversation

George-Payne
Copy link

@George-Payne George-Payne commented May 19, 2020

This PR implements the delete method described in #23

Tests, documentation, and examples will be added on go-ahead for #23

fixes: #23

@Serabe
Copy link
Contributor

Serabe commented May 19, 2020

The Stencil subscription need to be updated as well.

Also, we would need some test cases on test-app for this kind of usage of the store. We would need to be very cautious before adding this, as it opens the door to not using the store for a limited amount of keys, which can lead to memleaks and other problems.

The issues I can think of with using @stencil/store as a map are:

1.- We need to make sure that we can ask for a key before it's been added and still have it updated when some code actually adds it.
2.- How can we prevent memleaking in the subscriptions arrays? The list will grow and grow without a reliable way of reducing it. (And this point spans to a lot of different cases in combination with the first one).

@Serabe Serabe mentioned this pull request May 19, 2020
- update stencil subscription to listen for deletes
- add example usage in test-app
@George-Payne
Copy link
Author

@Serabe I updated the PR with an update to the stencil subscription and an example usage in the test-app

1.- We need to make sure that we can ask for a key before it's been added and still have it updated when some code actually adds it.

This works fine, get adds it to the list of subscriptions, and is updated as expected on set. However this requirement means that a delete and a set have the same subscription logic, we can't remove a subscription on delete as it may be set again in the future.

2.- How can we prevent memleaking in the subscriptions arrays? The list will grow and grow without a reliable way of reducing it. (And this point spans to a lot of different cases in combination with the first one).

This is an issue. As subscriptions are bound to component lifetimes it doesn't clean up its subscriptions until its no longer connected, so a store with a lot of churn, and a long lived component will result in an ever growing subscription array. I have an idea of how to fix this, will try something out.

@Serabe
Copy link
Contributor

Serabe commented May 20, 2020

With the current example, the solution proposed in the issue will work without any of the concerns of adding delete to store.

@George-Payne
Copy link
Author

George-Payne commented May 20, 2020

With the current example, the solution proposed in the issue will work without any of the concerns of adding delete to store.

Yep, the workaround is fine, perfectly happy to leave as is and go that route.

I'm not opposed to adding this to ObservableMap for completeness

I was continuing working on this because it felt it would be nice to have it baked in, but I'm happy to drop it and close the PR and issue.

I've pushed a commit that makes the stencil subscription only subscribe to the keys used in an elements most recent render. This should always be enough of a subscription, as conditional rendering based on an item in the store will cause an access (thereby subscribing), and if that condition changes, it is already re-rendering, so will get the latest from the store.

Let me know if you want me to clean it up / add tests, otherwise I'm happy to close out with the workaround.

@Serabe
Copy link
Contributor

Serabe commented May 20, 2020

The problem I'm seeing with that approach is that it does not fix all the issues. Let's look into the following component (I'm writing it directly here, without checking any syntax, so some it might contain some errors):

@Component({ tag: 'paginated-items' })
export class PaginatedItems {
  @Prop() page = 0;
  @Prop() itemsPerPage = 10;

  get itemsToRender() {
    const items = [];
    const { page, itemsPerPage } = this;
    const limit = (page + 1) * itemsPerPage;
    for (let i = page * itemsPerPage; i < limit; i++) {
      items.push(dataStore.get(`id-${i}`));
    }

    return items;
  }

  render() {
    return <Host>{ this.itemsToRender.map(item => <div>{item.name}</div>) }</Host>
  }
}

If we use it like:

<paginated-items page={page} />
<button onClick={() => page--}>Previous</button>
<button onClick={() => page++}>Next</button>

All the re-renders to PaginatedItems come from out of the store, so we are not cleaning any listener.

So far, we are increasing the number of listeners without a reliable way of limiting them. If we could find a way to add the delete method without any drawback, it'd be great, but so far I'm seeing added problems (extra subscriptions hard to clean, extra code) without a significant amount of benefits as a counterpart.

@George-Payne
Copy link
Author

All the re-renders to PaginatedItems come from out of the store, so we are not cleaning any listener.

They are cleaned up with the approach in the PR.

<paginated-items page={page} itemsPerPage={3} />
<button onClick={() => page--}>Previous</button>
<button onClick={() => page++}>Next</button>

(Excuse my pseudo code, hopefully it makes sense)

First render:

paginated-pages render

  • get id-0 ->
    • elmsToSubscriptions is empty.
    • current set to []
    • insert paginated-pages -> id-0 to elmsToSubscriptions
    • insert id-0 -> paginated-pages to subscriptions
  • get id-1 ->
    • insert paginated-pages -> id-1 to elmsToSubscriptions
    • insert id-1 -> paginated-pages to subscriptions
  • get id-2 ->
    • insert paginated-pages -> id-2 to elmsToSubscriptions
    • insert id-2 -> paginated-pages to subscriptions

end paginated-pages render

  • current is empty so nothing to clean.
  • subscriptions contains:
    • id-0 -> [paginated-pages]
    • id-1 -> [paginated-pages]
    • id-2 -> [paginated-pages]
  • elmsToSubscriptions contains:
    • paginated-pages -> [id-0, id-1, id-2]

User clicks "Previous".

page prop is updated so paginated-pages rerenders:

paginated-pages render

  • get id-3 ->
    • elmsToSubscriptions contains [id-0, id-1, id-2]
    • current set to [id-0, id-1, id-2]
    • elmsToSubscriptions is cleared
    • insert paginated-pages -> id-3 to elmsToSubscriptions
    • insert id-3 -> paginated-pages to subscriptions
  • get id-4 ->
    • insert paginated-pages -> id-4 to elmsToSubscriptions
    • insert id-4 -> paginated-pages to subscriptions
  • get id-5 ->
    • insert paginated-pages -> id-5 to elmsToSubscriptions
    • insert id-5 -> paginated-pages to subscriptions

end paginated-pages render

  • subscriptions contains:
    • id-0 -> [paginated-pages]
    • id-1 -> [paginated-pages]
    • id-2 -> [paginated-pages]
    • id-3 -> [paginated-pages]
    • id-4 -> [paginated-pages]
    • id-5 -> [paginated-pages]
  • current contains [id-0, id-1, id-2] so check if they are still needed:
    • elmsToSubscriptions does not contain id-0
      • remove id-0 -> paginated-pages from subscriptions
    • elmsToSubscriptions does not contain id-1
      • remove id-1 -> paginated-pages from subscriptions
    • elmsToSubscriptions does not contain id-2
      • remove id-2 -> paginated-pages from subscriptions

Finishing state

  • subscriptions contains:
    • id-3 -> [paginated-pages]
    • id-4 -> [paginated-pages]
    • id-5 -> [paginated-pages]
  • elmsToSubscriptions contains:
    • paginated-pages -> [id-3, id-4, id-5]

@Serabe
Copy link
Contributor

Serabe commented May 22, 2020

Sorry for taking so long, but I need to look deeper into the code and run some tests and those are going to take time. Need to slot some time for it.

Sorry!

@Serabe
Copy link
Contributor

Serabe commented Jun 12, 2020

Hi!

In first place, let me apologise for taking so long, but I wanted to make sure I gave a good look at this issue before going on with the talk and I've been having some busy weeks.

The @stencil/store was conceived as a way to keep some shared values in our application easily accessible without having to rely on cumbersome event dispatching to rerender the components when needed. Another goal was to to keep the library as simple and small as possible. I think deleting keys open the door to much bigger changes, as deleting them means that we don't have a known set of properties. If we need to delete is only natural that we'll need to add them. Not knowing the keys means that we'll need to somehow iterate over them

I've taken a good look at the code and try to write something with it. The task I tried to accomplish is simple: Have a table with two columns (key and value) and show those same data are displayed in another table (just displayed, not editable). The code is crappy, but was just a proof of concept. It can be found at the end of this comment.

The goal could not be achieved as we would need to rerender any component using the store when we add a key. At that point, the perf of doing it at the store level or doing it in a library built on top of it is indistinguishable.

import { Component, ComponentInterface, Host, h, State } from '@stencil/core';
import { createStore } from '@stencil/store';

function* generateRandomString() {
  while (true) {
    const result = [];

    for (let i = 0; i < 10; i++) {
      result.push(String.fromCharCode(Math.floor(Math.random() * (91 - 65)) + 65));
    }

    yield result.join('');
  }
}

function* filter(gen, predicate) {
  let done, value;
  do {
    let obj = gen.next();
    done = obj.done;
    value = obj.value;
    if (predicate(value)) {
      yield value;
    }
  } while (!done);
}

function* random() {
  while (true) {
    yield Math.random();
  }
}

const values = createStore<Record<string, number>>({});

@Component({
  tag: 'spread-sheet',
  shadow: true,
})
export class SpreadSheet implements ComponentInterface {
  @State() keys = new Set<string>();

  componentWillLoad() {
    this.fillValues();
  }

  getKeyGenerator() {
    return filter(generateRandomString(), (str) => !(str in values));
  }

  fillValues() {
    const newKeys = new Set<string>();
    values.reset();
    const keyGen = this.getKeyGenerator();
    const randomGen = random();

    for (let i = 0; i < 500; i++) {
      const { value } = keyGen.next();
      newKeys.add(value);
      values.set(value, randomGen.next().value || 0);
    }

    this.keys = newKeys;
  }

  render() {
    const { keys } = this;
    console.log('SERABE', keys, values);
    return (
      <Host>
        <button onClick={() => this.fillValues()}>Redo</button>
        <table>
          <thead>
            <tr>
              <td>Key</td>
              <td>Value</td>
            </tr>
          </thead>
          <tbody>
            {Array.from(keys.entries()).map(([key]) => (
              <tr>
                <td>
                  <input
                    value={key}
                    onChange={(evt) => {
                      const value = values.get(key);
                      values.delete(key);
                      values.set((evt.target as any).value, value);
                    }}
                  />
                </td>
                <td>
                  <input
                    value={values.get(key)}
                    onChange={(evt) => {
                      values.set(key, (evt.target as any).value);
                    }}
                  />
                </td>
              </tr>
            ))}
          </tbody>
        </table>

        <table>
          <thead>
            <tr>
              <td>Key</td>
              <td>Value</td>
            </tr>
          </thead>
          <tbody>
            {Array.from(keys.entries()).map(([key]) => (
              <tr>
                <td>{key}</td>
                <td>{values.get(key)}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </Host>
    );
  }
}

@George-Payne
Copy link
Author

In first place, let me apologise for taking so long

No worries, sorry for being slow to reply also. Busy couple of weeks.

The goal could not be achieved as we would need to rerender any component using the store when we add a key. At that point, the perf of doing it at the store level or doing it in a library built on top of it is indistinguishable.

Kind of, but this is not the goal of the PR.

Lets say we adjust your code to look like this:

<tbody>
    {Array.from(keys), (key) => (
        <spread-sheet-row key={key} storeKey={key} />
    ))}
</tbody>

and the spread-sheet-row reads the data from the store itself.

we would need to rerender any component using the store when we add a key.

True, but that is a change to spread-sheet so it should be rerendered. This is unavoidable in any situation.

However, if we were to modify the data on a single, existing key:

values.set(existingKey, randomGen.next().value || 0);

In the workaround, we would have to rerender everything, but in the updated version, we only need to update the spread-sheet-row that is reading data from that key. This is goal of the PR, and what is missing in the workaround.

I'll leave it up to you if that is a worthwhile goal, feel free to close the PR and issue if not.

In the end I went for a similar workaround as you initially proposed. It works fine.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add delete method
2 participants